feat(ai): add rolling conversation summary memory

This commit is contained in:
Yera All
2026-04-13 11:59:43 +05:00
parent 989bf9a0c1
commit 6b8f5d069b
14 changed files with 900 additions and 26 deletions
+3 -3
View File
@@ -40,12 +40,12 @@ AI_CUSTOMER_PERSONA_MODE=operator_humanlike
AI_DISCLOSURE_MODE=hidden
AI_TELEGRAM_ENABLED=0
AI_TELEGRAM_ALWAYS_REPLY=0
AI_TELEGRAM_MAX_CONTEXT_MESSAGES=20
AI_TELEGRAM_MAX_CONTEXT_MESSAGES=12
AI_TELEGRAM_MAX_KB_RESULTS=3
AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD=0.65
AI_WHATSAPP_ENABLED=0
AI_WHATSAPP_ALWAYS_REPLY=0
AI_WHATSAPP_MAX_CONTEXT_MESSAGES=20
AI_WHATSAPP_MAX_CONTEXT_MESSAGES=12
AI_WHATSAPP_MAX_KB_RESULTS=3
AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65
AI_VOICE_RUNTIME_SERVICE_URL=http://localhost:8018
@@ -102,7 +102,7 @@ AI_VOICE_VAD_MIN_SPEECH_MS=300
AI_VOICE_VAD_TRAILING_SILENCE_MS=400
AI_VOICE_TURN_MAX_MS=10000
AI_VOICE_MEDIA_IDLE_TIMEOUT_SECONDS=15
AI_VOICE_MAX_CONTEXT_SEGMENTS=8
AI_VOICE_MAX_CONTEXT_SEGMENTS=12
AI_VOICE_HANDOFF_TIMEOUT_SECONDS=8
AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:ai-voice-runtime
+3 -3
View File
@@ -36,12 +36,12 @@ AI_CUSTOMER_PERSONA_MODE=operator_humanlike
AI_DISCLOSURE_MODE=hidden
AI_TELEGRAM_ENABLED=0
AI_TELEGRAM_ALWAYS_REPLY=0
AI_TELEGRAM_MAX_CONTEXT_MESSAGES=20
AI_TELEGRAM_MAX_CONTEXT_MESSAGES=12
AI_TELEGRAM_MAX_KB_RESULTS=3
AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD=0.65
AI_WHATSAPP_ENABLED=0
AI_WHATSAPP_ALWAYS_REPLY=0
AI_WHATSAPP_MAX_CONTEXT_MESSAGES=20
AI_WHATSAPP_MAX_CONTEXT_MESSAGES=12
AI_WHATSAPP_MAX_KB_RESULTS=3
AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65
AI_VOICE_ENABLED=0
@@ -98,7 +98,7 @@ AI_VOICE_VAD_MIN_SPEECH_MS=300
AI_VOICE_VAD_TRAILING_SILENCE_MS=400
AI_VOICE_TURN_MAX_MS=10000
AI_VOICE_MEDIA_IDLE_TIMEOUT_SECONDS=15
AI_VOICE_MAX_CONTEXT_SEGMENTS=8
AI_VOICE_MAX_CONTEXT_SEGMENTS=12
AI_VOICE_HANDOFF_TIMEOUT_SECONDS=8
AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:ai-voice-runtime
+1 -1
View File
@@ -46,7 +46,7 @@ x-app-env: &app_env
AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY: "1"
AI_VOICE_TURN_MAX_MS: "10000"
AI_VOICE_MEDIA_IDLE_TIMEOUT_SECONDS: "15"
AI_VOICE_MAX_CONTEXT_SEGMENTS: "8"
AI_VOICE_MAX_CONTEXT_SEGMENTS: "12"
AI_VOICE_HANDOFF_TIMEOUT_SECONDS: "8"
AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS: svc:ai-voice-runtime
+3 -3
View File
@@ -292,7 +292,7 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_
env["AI_TIMEOUT_SECONDS"] = env.get("AI_TIMEOUT_SECONDS", "20")
env["AI_TELEGRAM_ENABLED"] = env.get("AI_TELEGRAM_ENABLED", "0")
env["AI_TELEGRAM_ALWAYS_REPLY"] = env.get("AI_TELEGRAM_ALWAYS_REPLY", "0")
env["AI_TELEGRAM_MAX_CONTEXT_MESSAGES"] = env.get("AI_TELEGRAM_MAX_CONTEXT_MESSAGES", "20")
env["AI_TELEGRAM_MAX_CONTEXT_MESSAGES"] = env.get("AI_TELEGRAM_MAX_CONTEXT_MESSAGES", "12")
env["AI_TELEGRAM_MAX_KB_RESULTS"] = env.get("AI_TELEGRAM_MAX_KB_RESULTS", "3")
env["AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get(
"AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD",
@@ -300,7 +300,7 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_
)
env["AI_WHATSAPP_ENABLED"] = env.get("AI_WHATSAPP_ENABLED", "0")
env["AI_WHATSAPP_ALWAYS_REPLY"] = env.get("AI_WHATSAPP_ALWAYS_REPLY", "0")
env["AI_WHATSAPP_MAX_CONTEXT_MESSAGES"] = env.get("AI_WHATSAPP_MAX_CONTEXT_MESSAGES", "20")
env["AI_WHATSAPP_MAX_CONTEXT_MESSAGES"] = env.get("AI_WHATSAPP_MAX_CONTEXT_MESSAGES", "12")
env["AI_WHATSAPP_MAX_KB_RESULTS"] = env.get("AI_WHATSAPP_MAX_KB_RESULTS", "3")
env["AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get(
"AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD",
@@ -330,7 +330,7 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_
env["AI_VOICE_V2_PARTIAL_ASR"] = env.get("AI_VOICE_V2_PARTIAL_ASR", "1")
env["AI_VOICE_V2_EMOTIVE_ACK_ENABLED"] = env.get("AI_VOICE_V2_EMOTIVE_ACK_ENABLED", "1")
env["AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY"] = env.get("AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY", "1")
env["AI_VOICE_MAX_CONTEXT_SEGMENTS"] = env.get("AI_VOICE_MAX_CONTEXT_SEGMENTS", "8")
env["AI_VOICE_MAX_CONTEXT_SEGMENTS"] = env.get("AI_VOICE_MAX_CONTEXT_SEGMENTS", "12")
env["AI_VOICE_HANDOFF_TIMEOUT_SECONDS"] = env.get("AI_VOICE_HANDOFF_TIMEOUT_SECONDS", "8")
env["AI_VOICE_VAD_TRAILING_SILENCE_MS"] = env.get("AI_VOICE_VAD_TRAILING_SILENCE_MS", "400")
env["AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS"] = env.get(
+2 -2
View File
@@ -23,7 +23,7 @@ from services.ai_voice_runtime_service.providers.tts import build_tts_provider
def _default_provider_name() -> str:
return os.getenv("AI_VOICE_TTS_PROVIDER", "openai").strip() or "openai"
return os.getenv("AI_VOICE_TTS_PROVIDER", "yandex").strip() or "yandex"
def _parse_args() -> argparse.Namespace:
@@ -39,7 +39,7 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument(
"--provider",
default=_default_provider_name(),
help="TTS provider name. Defaults to AI_VOICE_TTS_PROVIDER or openai.",
help="TTS provider name. Defaults to AI_VOICE_TTS_PROVIDER or yandex.",
)
parser.add_argument("--sample-rate", type=int, default=8000, help="Target WAV sample rate. Defaults to 8000.")
parser.add_argument("--default-language", default="ru", help="Fallback language for ambiguous prompts. Defaults to ru.")
+2 -2
View File
@@ -16,7 +16,7 @@ from services.ai_voice_runtime_service.providers.tts import build_tts_provider
def _parse_args() -> argparse.Namespace:
default_provider = os.getenv("AI_VOICE_TTS_PROVIDER", "openai").strip() or "openai"
default_provider = os.getenv("AI_VOICE_TTS_PROVIDER", "yandex").strip() or "yandex"
parser = argparse.ArgumentParser(
description="Generate TTS once, cache it on the server, and materialize a WAV prompt file.",
)
@@ -26,7 +26,7 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument(
"--provider",
default=default_provider,
help="TTS provider name. Defaults to AI_VOICE_TTS_PROVIDER or openai.",
help="TTS provider name. Defaults to AI_VOICE_TTS_PROVIDER or yandex.",
)
parser.add_argument("--sample-rate", type=int, default=8000, help="Target WAV sample rate. Defaults to 8000.")
return parser.parse_args()
+121 -7
View File
@@ -16,6 +16,12 @@ from sqlalchemy import select
from services.shared.core import Role, new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.ai_context_summary import (
dump_context_summary,
render_context_summary_text,
update_context_summary_from_assistant_turn,
update_context_summary_from_user_turn,
)
from services.shared.kb_localization import normalize_kb_language
from services.shared.kb_search import search_kb_rows
from services.shared.models import (
@@ -1467,11 +1473,11 @@ def _ai_whatsapp_always_reply() -> bool:
def _ai_max_context_messages() -> int:
return max(4, _int_env("AI_TELEGRAM_MAX_CONTEXT_MESSAGES", 20))
return max(4, _int_env("AI_TELEGRAM_MAX_CONTEXT_MESSAGES", 12))
def _ai_whatsapp_max_context_messages() -> int:
return max(4, _int_env("AI_WHATSAPP_MAX_CONTEXT_MESSAGES", 20))
return max(4, _int_env("AI_WHATSAPP_MAX_CONTEXT_MESSAGES", 12))
def _ai_max_kb_results() -> int:
@@ -2293,6 +2299,7 @@ def _openai_prompt(
messages: list[Any],
kb_results: list[KBArticleRow],
language: str,
conversation_summary_text: str = "",
channel_label: str = "Telegram",
channel_key: str = "telegram",
) -> list[dict[str, str]]:
@@ -2338,6 +2345,7 @@ def _openai_prompt(
"chat_id": thread.chat_id,
"display_name": thread.display_name,
},
"conversation_summary": conversation_summary_text,
"kb_results": kb_context,
"history": history,
}
@@ -2383,6 +2391,7 @@ def _openai_compatible_decision(
messages: list[Any],
kb_results: list[KBArticleRow],
language: str,
conversation_summary_text: str = "",
channel_label: str = "Telegram",
channel_key: str = "telegram",
) -> dict[str, Any]:
@@ -2394,6 +2403,7 @@ def _openai_compatible_decision(
messages=messages,
kb_results=kb_results,
language=language,
conversation_summary_text=conversation_summary_text,
channel_label=channel_label,
channel_key=channel_key,
)
@@ -2563,6 +2573,7 @@ def _decide_reply(
messages: list[Any],
kb_results: list[KBArticleRow],
language: str,
conversation_summary_text: str = "",
channel_label: str = "Telegram",
channel_key: str = "telegram",
) -> dict[str, Any]:
@@ -2577,6 +2588,7 @@ def _decide_reply(
messages=messages,
kb_results=kb_results,
language=language,
conversation_summary_text=conversation_summary_text,
channel_label=channel_label,
channel_key=channel_key,
)
@@ -2594,6 +2606,51 @@ def _decide_reply(
return _sanitize_decision(raw, fallback_language=language)
def _update_ai_session_context_summary_from_user_turn(
ai_session: AISessionRow,
*,
channel: str,
language: str,
customer_name: str | None,
text: str,
now: str,
) -> str:
summary = update_context_summary_from_user_turn(
ai_session.context_summary_json,
channel=channel,
language=language,
customer_name=customer_name,
text=text,
now=now,
)
ai_session.context_summary_json = dump_context_summary(summary)
ai_session.context_summary_updated_at = now
return render_context_summary_text(summary)
def _update_ai_session_context_summary_from_assistant_turn(
ai_session: AISessionRow,
*,
language: str,
customer_name: str | None,
reply_text: str,
decision: dict[str, Any],
now: str,
) -> None:
summary = update_context_summary_from_assistant_turn(
ai_session.context_summary_json,
language=language,
customer_name=customer_name,
reply_text=reply_text,
decision_intent=str(decision.get("intent") or "").strip() or None,
needs_handoff=bool(decision.get("needs_handoff")),
handoff_reason=str(decision.get("handoff_reason") or "").strip() or None,
now=now,
)
ai_session.context_summary_json = dump_context_summary(summary)
ai_session.context_summary_updated_at = now
def _update_thread_after_close(session, thread: TelegramThreadRow, when: str) -> None:
thread.status = "closed"
thread.ai_state = "closed"
@@ -2692,6 +2749,7 @@ def _process_job(job_id: str) -> dict[str, Any]:
ai_session.customer_id = customer_id
ai_session.last_user_message_id = trigger_message.message_id
ai_session.updated_at = utc_now_iso()
user_turn_now = ai_session.updated_at
_record_turn(
session,
session_id=ai_session.session_id,
@@ -2702,6 +2760,14 @@ def _process_job(job_id: str) -> dict[str, Any]:
text=trigger_message.text,
payload={"message_id": trigger_message.message_id, "author_type": trigger_message.author_type},
)
conversation_summary_text = _update_ai_session_context_summary_from_user_turn(
ai_session,
channel="telegram",
language=ai_session.language or "ru",
customer_name=customer.display_name if customer else thread.display_name,
text=trigger_message.text,
now=user_turn_now,
)
session.commit()
messages = _last_messages(session, thread.thread_id, _ai_max_context_messages())
@@ -2713,6 +2779,7 @@ def _process_job(job_id: str) -> dict[str, Any]:
messages=messages,
kb_results=kb_results,
language=ai_session.language or "ru",
conversation_summary_text=conversation_summary_text,
)
decision = _apply_always_reply_mode(decision, last_user_text=trigger_message.text)
_record_turn(
@@ -2728,9 +2795,8 @@ def _process_job(job_id: str) -> dict[str, Any]:
finish_reason=decision["_finish_reason"],
latency_ms=decision["_latency_ms"],
)
ai_session.updated_at = utc_now_iso()
ai_session.summary_text = decision["reply_text"] or (decision["handoff_reason"] or ai_session.summary_text)
session.commit()
decision_now = utc_now_iso()
ai_session.updated_at = decision_now
needs_handoff = False
if not _ai_telegram_always_reply():
@@ -2768,6 +2834,15 @@ def _process_job(job_id: str) -> dict[str, Any]:
ai_session.status = "handoff_required"
ai_session.handoff_reason = reason
ai_session.updated_at = utc_now_iso()
ai_session.summary_text = decision["reply_text"] or (reason or ai_session.summary_text)
_update_ai_session_context_summary_from_assistant_turn(
ai_session,
language=decision["language"],
customer_name=customer.display_name if customer else thread.display_name,
reply_text=decision["reply_text"] or reason,
decision=decision,
now=ai_session.updated_at,
)
_mark_job_done(session, job)
session.commit()
return {"ok": True, "status": "handoff_required", "job_id": job.job_id}
@@ -2799,6 +2874,17 @@ def _process_job(job_id: str) -> dict[str, Any]:
return resolved
raise
ai_session.updated_at = utc_now_iso()
ai_session.summary_text = decision["reply_text"] or (decision["handoff_reason"] or ai_session.summary_text)
_update_ai_session_context_summary_from_assistant_turn(
ai_session,
language=decision["language"],
customer_name=customer.display_name if customer else thread.display_name,
reply_text=decision["reply_text"],
decision=decision,
now=ai_session.updated_at,
)
if decision["case_action"] == "escalate":
_interaction_request(
"POST",
@@ -2926,6 +3012,7 @@ def _process_whatsapp_job(job_id: str) -> dict[str, Any]:
ai_session.customer_id = customer_id
ai_session.last_user_message_id = trigger_message.message_id
ai_session.updated_at = utc_now_iso()
user_turn_now = ai_session.updated_at
_record_turn(
session,
session_id=ai_session.session_id,
@@ -2936,6 +3023,14 @@ def _process_whatsapp_job(job_id: str) -> dict[str, Any]:
text=trigger_message.text,
payload={"message_id": trigger_message.message_id, "author_type": trigger_message.author_type},
)
conversation_summary_text = _update_ai_session_context_summary_from_user_turn(
ai_session,
channel="whatsapp",
language=ai_session.language or "ru",
customer_name=customer.display_name if customer else thread.display_name,
text=trigger_message.text,
now=user_turn_now,
)
session.commit()
messages = _last_whatsapp_messages(session, thread.thread_id, _ai_whatsapp_max_context_messages())
@@ -2951,6 +3046,7 @@ def _process_whatsapp_job(job_id: str) -> dict[str, Any]:
messages=messages,
kb_results=kb_results,
language=ai_session.language or "ru",
conversation_summary_text=conversation_summary_text,
channel_label="WhatsApp",
channel_key="whatsapp",
)
@@ -2974,8 +3070,6 @@ def _process_whatsapp_job(job_id: str) -> dict[str, Any]:
latency_ms=decision["_latency_ms"],
)
ai_session.updated_at = utc_now_iso()
ai_session.summary_text = decision["reply_text"] or (decision["handoff_reason"] or ai_session.summary_text)
session.commit()
needs_handoff = False
if not _ai_whatsapp_always_reply():
@@ -3013,6 +3107,15 @@ def _process_whatsapp_job(job_id: str) -> dict[str, Any]:
ai_session.status = "handoff_required"
ai_session.handoff_reason = reason
ai_session.updated_at = utc_now_iso()
ai_session.summary_text = decision["reply_text"] or (reason or ai_session.summary_text)
_update_ai_session_context_summary_from_assistant_turn(
ai_session,
language=decision["language"],
customer_name=customer.display_name if customer else thread.display_name,
reply_text=decision["reply_text"] or reason,
decision=decision,
now=ai_session.updated_at,
)
_mark_job_done(session, job)
session.commit()
return {"ok": True, "status": "handoff_required", "job_id": job.job_id}
@@ -3044,6 +3147,17 @@ def _process_whatsapp_job(job_id: str) -> dict[str, Any]:
return resolved
raise
ai_session.updated_at = utc_now_iso()
ai_session.summary_text = decision["reply_text"] or (decision["handoff_reason"] or ai_session.summary_text)
_update_ai_session_context_summary_from_assistant_turn(
ai_session,
language=decision["language"],
customer_name=customer.display_name if customer else thread.display_name,
reply_text=decision["reply_text"],
decision=decision,
now=ai_session.updated_at,
)
if decision["case_action"] == "escalate":
_interaction_request(
"POST",
+62 -5
View File
@@ -8,6 +8,14 @@ from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from services.shared.ai_context_summary import (
context_summary_context_texts,
dump_context_summary,
load_context_summary,
render_context_summary_text,
update_context_summary_from_assistant_turn,
update_context_summary_from_user_turn,
)
from services.shared.core import new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.models import VoiceAIStartIn, VoiceAIStartOut, VoiceAITurnDecisionOut, VoiceAITurnIn, VoiceStartResult
@@ -39,7 +47,7 @@ def _app():
def _voice_max_context_segments() -> int:
app = _app()
return max(6, app._int_env("AI_VOICE_MAX_CONTEXT_SEGMENTS", 8))
return max(6, app._int_env("AI_VOICE_MAX_CONTEXT_SEGMENTS", 12))
def _voice_disclosure_prefix(language: str) -> str:
@@ -787,12 +795,29 @@ def _voice_text_key(text: str | None) -> str:
def _voice_recent_caller_texts(
transcript_window: list[VoiceTranscriptSegmentRow],
*,
limit: int = 8,
limit: int = 12,
) -> list[str]:
caller_texts = [str(segment.text or "").strip() for segment in transcript_window if segment.speaker == "caller"]
return caller_texts[-max(limit, 1) :]
def _voice_context_texts_with_summary(
transcript_window: list[VoiceTranscriptSegmentRow],
context_summary: str | dict[str, Any] | None,
) -> list[str]:
result = context_summary_context_texts(context_summary)
result.extend(_voice_recent_caller_texts(transcript_window, limit=12))
deduped: list[str] = []
seen: set[str] = set()
for text in result:
normalized = _voice_text_key(text)
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped.append(str(text).strip())
return deduped[-12:]
def _voice_is_low_signal_caller_text(text: str | None) -> bool:
normalized = _voice_text_key(text)
if not normalized:
@@ -1311,6 +1336,7 @@ def _voice_postprocess_reply_text(
language: str,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
context_summary: str | dict[str, Any] | None = None,
reply_text: str,
kb_results: list[Any],
needs_handoff: bool,
@@ -1318,7 +1344,7 @@ def _voice_postprocess_reply_text(
normalized_reply = str(reply_text or "").strip()
if not normalized_reply or needs_handoff:
return normalized_reply
caller_texts = _voice_recent_caller_texts(transcript_window)
caller_texts = _voice_context_texts_with_summary(transcript_window, context_summary)
prior_caller_texts = _voice_caller_context_before_current(caller_texts, transcript_text)
active_topic_texts = _voice_service_context_texts(prior_caller_texts or caller_texts)
active_topic_prompt = _voice_topic_prompt(language, active_topic_texts) if active_topic_texts else None
@@ -1435,6 +1461,7 @@ def _voice_llm_prompt_messages(
interaction: Interaction,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
conversation_summary_text: str = "",
kb_results: list[Any],
name_value: str | None,
name_status: str | None,
@@ -1449,7 +1476,7 @@ def _voice_llm_prompt_messages(
"interrupted": bool(segment.barge_in_interrupted),
"created_at": segment.created_at,
}
for segment in transcript_window[-6:]
for segment in transcript_window[-12:]
]
kb_context = [
{
@@ -1477,6 +1504,7 @@ def _voice_llm_prompt_messages(
"last_user_text": transcript_text,
"language": language,
},
"conversation_summary": conversation_summary_text,
"kb_results": kb_context,
"history": history,
}
@@ -1518,6 +1546,7 @@ def _voice_llm_decision(
interaction: Interaction,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
conversation_summary_text: str = "",
kb_results: list[Any],
name_value: str | None,
name_status: str | None,
@@ -1535,6 +1564,7 @@ def _voice_llm_decision(
interaction=interaction,
transcript_text=transcript_text,
transcript_window=transcript_window,
conversation_summary_text=conversation_summary_text,
kb_results=kb_results,
name_value=name_value,
name_status=name_status,
@@ -1749,6 +1779,7 @@ def _voice_decision(
interaction: Interaction,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
context_summary: str | dict[str, Any] | None = None,
kb_results: list[Any],
disclosure_required: bool,
customer_name_value: str | None = None,
@@ -1758,10 +1789,11 @@ def _voice_decision(
app = _app()
normalized = str(transcript_text or "").strip()
lower_text = normalized.lower()
caller_texts = _voice_recent_caller_texts(transcript_window)
caller_texts = _voice_context_texts_with_summary(transcript_window, context_summary)
model = app._ai_model()
v2_metadata = _voice_v2_metadata(transcript_text, request_metadata)
reply_phase = _voice_reply_phase(request_metadata)
conversation_summary_text = render_context_summary_text(context_summary)
if reply_phase == "early_plan":
return _voice_early_plan(
@@ -1854,6 +1886,7 @@ def _voice_decision(
interaction=interaction,
transcript_text=transcript_text,
transcript_window=transcript_window,
conversation_summary_text=conversation_summary_text,
kb_results=kb_results,
name_value=customer_name_value or (customer.display_name if customer else None),
name_status=customer_name_status,
@@ -2415,6 +2448,16 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe
source=effective_name_source,
resolved_at=effective_name_resolved_at,
)
user_context_summary = update_context_summary_from_user_turn(
ai_session.context_summary_json,
channel="voice",
language=ai_session.language or "ru",
customer_name=effective_name_value or (customer.display_name if customer else None),
text=payload.transcript_text,
now=now,
)
ai_session.context_summary_json = dump_context_summary(user_context_summary)
ai_session.context_summary_updated_at = now
kb_results = []
if not early_plan_only:
kb_results = app._kb_search(
@@ -2429,6 +2472,7 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe
interaction=interaction,
transcript_text=payload.transcript_text,
transcript_window=transcript_window,
context_summary=ai_session.context_summary_json,
kb_results=kb_results,
disclosure_required=disclosure_required,
customer_name_value=effective_name_value,
@@ -2439,6 +2483,7 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe
language=decision["language"],
transcript_text=payload.transcript_text,
transcript_window=transcript_window,
context_summary=ai_session.context_summary_json,
reply_text=str(decision.get("reply_text") or ""),
kb_results=kb_results,
needs_handoff=bool(decision.get("needs_handoff")),
@@ -2546,6 +2591,18 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe
voice_session.handoff_reason = decision["handoff_reason"]
ai_session.status = "handoff_required" if decision["needs_handoff"] else "active"
ai_session.handoff_reason = decision["handoff_reason"]
updated_context_summary = update_context_summary_from_assistant_turn(
ai_session.context_summary_json,
language=decision["language"],
customer_name=effective_name_value or (customer.display_name if customer else None),
reply_text=decision["reply_text"] or decision["handoff_reason"] or "",
decision_intent=decision["intent"],
needs_handoff=bool(decision["needs_handoff"]),
handoff_reason=decision["handoff_reason"],
now=now,
)
ai_session.context_summary_json = dump_context_summary(updated_context_summary)
ai_session.context_summary_updated_at = now
ai_session.summary_text = decision["summary_text"] or decision["reply_text"] or ai_session.summary_text
ai_session.updated_at = now
session.commit()
+434
View File
@@ -0,0 +1,434 @@
import json
import re
from typing import Any
_LOW_SIGNAL_TEXTS = {
"",
"ага",
"алло",
"да",
"добрый",
"добрый день",
"здравствуйте",
"ладно",
"неа",
"нет",
"ой",
"ок",
"okay",
"понял",
"поняла",
"привет",
"слышу",
"слышно",
"угу",
"хм",
"хорошо",
"hi",
"hello",
"ясно",
}
_CITY_ALIASES = {
"алма ата": "Алма-Ата",
"алмат": "Алмата",
"астан": "Астана",
"актау": "Актау",
"актоб": "Актобе",
"атырау": "Атырау",
"караганд": "Караганда",
"кокшетау": "Кокшетау",
"костан": "Костанай",
"кызылорд": "Кызылорда",
"орал": "Орал",
"павлодар": "Павлодар",
"петропавл": "Петропавловск",
"семей": "Семей",
"талдыкорган": "Талдыкорган",
"тар": "Тараз",
"туркестан": "Туркестан",
"уральск": "Уральск",
"шымкент": "Шымкент",
}
_SERVICE_HINTS = (
(("график", "работает", "часы работы", "режим работы", "jumys uaqyty"), "график работы", "schedule"),
(("адрес", "филиал", "офис", "отделени", "мкр", "микрорайон", "район", "мекен"), "филиал или адрес", "address"),
(("тариф", "цена", "стоимость", "оплат", "баға", "сколько стоит"), "тариф или стоимость", "price"),
(("статус", "заявк", "заказ", "өтінім", "обращени"), "статус заявки", "status"),
(("не работает", "ошибка", "проблем", "сбой", "интернет", "связь"), "техническая проблема", "problem"),
)
_INTENT_SLOT_DEFAULTS = {
"address": ["city_or_branch"],
"price": ["service_or_tariff"],
"problem": ["problem_details"],
"schedule": ["city_or_branch"],
"status": ["request_number_or_phone"],
}
def _default_summary() -> dict[str, Any]:
return {
"version": 1,
"channel": None,
"language": None,
"customer_name": None,
"active_intent": None,
"active_request_text": None,
"scenario_state": None,
"confirmed_facts": {
"branch_hint": None,
"city": None,
"request_number": None,
"service_hint": None,
},
"requested_clarifications": [],
"open_slots": [],
"updated_at": None,
}
def _text_key(value: str | None) -> str:
compact = re.sub(r"[^\w\s-]+", " ", str(value or "").lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", compact).strip()
def load_context_summary(raw: str | dict[str, Any] | None) -> dict[str, Any]:
summary = _default_summary()
payload: dict[str, Any] = {}
if isinstance(raw, dict):
payload = raw
elif isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = {}
if isinstance(parsed, dict):
payload = parsed
for key in ("channel", "language", "customer_name", "active_intent", "active_request_text", "scenario_state", "updated_at"):
value = payload.get(key)
if value not in {None, ""}:
summary[key] = str(value).strip()
facts = payload.get("confirmed_facts")
if isinstance(facts, dict):
for key in summary["confirmed_facts"]:
value = facts.get(key)
if value not in {None, ""}:
summary["confirmed_facts"][key] = str(value).strip()
clarifications = payload.get("requested_clarifications")
if isinstance(clarifications, list):
summary["requested_clarifications"] = [str(item).strip() for item in clarifications if str(item).strip()]
open_slots = payload.get("open_slots")
if isinstance(open_slots, list):
summary["open_slots"] = [str(item).strip() for item in open_slots if str(item).strip()]
return summary
def dump_context_summary(summary: dict[str, Any]) -> str:
normalized = load_context_summary(summary)
return json.dumps(normalized, ensure_ascii=False, sort_keys=True)
def _is_low_signal_text(text: str | None) -> bool:
normalized = _text_key(text)
return normalized in _LOW_SIGNAL_TEXTS
def _canonical_city(candidate: str) -> str | None:
normalized = _text_key(candidate)
if not normalized:
return None
for alias, canonical in _CITY_ALIASES.items():
if alias in normalized:
return canonical
tokens = [token for token in re.split(r"[\s-]+", normalized) if token]
if not tokens:
return None
trimmed = " ".join(tokens[:3])
if len(trimmed) < 3:
return None
return " ".join(token.capitalize() for token in trimmed.split())
def _extract_city(text: str | None) -> str | None:
source = str(text or "").strip()
if not source:
return None
lowered = _text_key(source)
for alias, canonical in _CITY_ALIASES.items():
if alias in lowered:
return canonical
match = re.search(
r"(?:в городе|город(?:е)?|қала(?:да|сы)?|qala(?:da)?)\s+([A-Za-zА-Яа-яЁёІіҢңҒғҮүҰұҚқӨөҺһ\- ]{2,40})",
source,
flags=re.IGNORECASE,
)
if not match:
return None
candidate = re.split(r"[,.!?;:]", match.group(1), maxsplit=1)[0].strip()
return _canonical_city(candidate)
def _extract_request_number(text: str | None) -> str | None:
normalized = str(text or "").strip()
if not normalized:
return None
match = re.search(r"\b[А-ЯA-Z]{0,3}\d{5,}\b", normalized, flags=re.IGNORECASE)
if match:
return match.group(0).strip()
match = re.search(r"\b\d{6,}\b", normalized)
if match:
return match.group(0).strip()
return None
def _extract_branch_hint(text: str | None) -> str | None:
normalized = _text_key(text)
if not normalized:
return None
if not any(marker in normalized for marker in ("адрес", "филиал", "офис", "отделени", "район", "мкр", "микрорайон", "мекен")):
return None
compact = re.sub(r"\s+", " ", str(text or "").strip())
return compact[:160] if compact else None
def _extract_service_hint(text: str | None) -> tuple[str | None, str | None]:
normalized = _text_key(text)
if not normalized:
return None, None
for markers, label, intent in _SERVICE_HINTS:
if any(marker in normalized for marker in markers):
return label, intent
return None, None
def _intent_open_slots(intent: str | None, facts: dict[str, Any]) -> list[str]:
normalized_intent = str(intent or "").strip().lower()
city = str(facts.get("city") or "").strip()
branch_hint = str(facts.get("branch_hint") or "").strip()
service_hint = str(facts.get("service_hint") or "").strip()
request_number = str(facts.get("request_number") or "").strip()
if normalized_intent in {"schedule", "address"}:
if not city and not branch_hint:
return ["city_or_branch"]
if city and not branch_hint:
return ["branch_or_address"]
return []
if normalized_intent == "price":
return [] if service_hint else ["service_or_tariff"]
if normalized_intent == "status":
return [] if request_number else ["request_number_or_phone"]
if normalized_intent == "problem":
return [] if service_hint else ["problem_details"]
return list(_INTENT_SLOT_DEFAULTS.get(normalized_intent, []))
def _requested_clarifications_from_reply(text: str | None) -> list[str]:
normalized = _text_key(text)
if not normalized:
return []
if any(marker in normalized for marker in ("город", "в городе", "қала")):
if any(marker in normalized for marker in ("филиал", "адрес", "офис", "отделени")):
return ["branch_or_address"]
return ["city_or_branch"]
if any(marker in normalized for marker in ("филиал", "адрес", "офис", "отделени", "район")):
return ["branch_or_address"]
if any(marker in normalized for marker in ("тариф", "стоимость", "цена", "услуга", "баға")):
return ["service_or_tariff"]
if any(marker in normalized for marker in ("номер заявки", "номер заказа", "телефон", "өтінім")):
return ["request_number_or_phone"]
if any(marker in normalized for marker in ("что именно", "уточните", "подскажите точнее")):
return ["goal"]
return []
def _merge_open_slots(
current_slots: list[str],
*,
clarified_slots: list[str],
fallback_slots: list[str],
) -> tuple[list[str], list[str]]:
specific_current_slots = [slot for slot in current_slots if slot and slot != "goal"]
specific_fallback_slots = [slot for slot in fallback_slots if slot and slot != "goal"]
if clarified_slots:
if clarified_slots == ["goal"]:
preserved_slots = specific_current_slots or specific_fallback_slots
if preserved_slots:
return preserved_slots, preserved_slots
return clarified_slots, clarified_slots
if specific_current_slots and specific_fallback_slots and set(specific_current_slots) == set(specific_fallback_slots):
return specific_current_slots, specific_current_slots
return fallback_slots, current_slots
def _looks_like_request_statement(text: str | None) -> bool:
normalized = _text_key(text)
if not normalized:
return False
markers = (
"интересует",
"как работает",
"какой график",
"мне надо",
"мне нужен",
"нужно",
"подключ",
"подскажите",
"узнать",
"хочу",
)
return any(marker in normalized for marker in markers)
def render_context_summary_text(raw: str | dict[str, Any] | None) -> str:
summary = load_context_summary(raw)
parts: list[str] = []
if summary["customer_name"]:
parts.append(f"Customer name: {summary['customer_name']}")
if summary["language"]:
parts.append(f"Language: {summary['language']}")
if summary["active_intent"]:
parts.append(f"Active intent: {summary['active_intent']}")
if summary["active_request_text"]:
parts.append(f"Active request: {summary['active_request_text']}")
facts = summary["confirmed_facts"]
fact_parts = []
if facts.get("city"):
fact_parts.append(f"city={facts['city']}")
if facts.get("branch_hint"):
fact_parts.append(f"branch={facts['branch_hint']}")
if facts.get("service_hint"):
fact_parts.append(f"service={facts['service_hint']}")
if facts.get("request_number"):
fact_parts.append(f"request_number={facts['request_number']}")
if fact_parts:
parts.append("Confirmed facts: " + ", ".join(fact_parts))
if summary["open_slots"]:
parts.append("Open slots: " + ", ".join(summary["open_slots"]))
if summary["scenario_state"]:
parts.append(f"Scenario state: {summary['scenario_state']}")
return " | ".join(parts)
def context_summary_context_texts(raw: str | dict[str, Any] | None) -> list[str]:
summary = load_context_summary(raw)
result: list[str] = []
if summary["active_request_text"]:
result.append(summary["active_request_text"])
facts = summary["confirmed_facts"]
if facts.get("city"):
result.append(f"город {facts['city']}")
if facts.get("branch_hint"):
result.append(facts["branch_hint"])
if facts.get("service_hint"):
result.append(facts["service_hint"])
if summary["active_intent"] == "schedule":
result.append("график работы")
elif summary["active_intent"] == "address":
result.append("филиал или адрес")
elif summary["active_intent"] == "price":
result.append("тариф или стоимость")
elif summary["active_intent"] == "status":
result.append("статус заявки")
elif summary["active_intent"] == "problem":
result.append("техническая проблема")
seen: set[str] = set()
deduped: list[str] = []
for item in result:
normalized = _text_key(item)
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped.append(str(item).strip())
return deduped
def update_context_summary_from_user_turn(
raw: str | dict[str, Any] | None,
*,
channel: str,
language: str | None,
customer_name: str | None,
text: str | None,
now: str | None = None,
) -> dict[str, Any]:
summary = load_context_summary(raw)
summary["channel"] = str(channel or summary["channel"] or "").strip() or summary["channel"]
if language:
summary["language"] = str(language).strip()
if customer_name:
summary["customer_name"] = str(customer_name).strip()
compact_text = re.sub(r"\s+", " ", str(text or "").strip())
if not compact_text or _is_low_signal_text(compact_text):
if now:
summary["updated_at"] = now
return summary
city = _extract_city(compact_text)
branch_hint = _extract_branch_hint(compact_text)
request_number = _extract_request_number(compact_text)
service_hint, inferred_intent = _extract_service_hint(compact_text)
should_refresh_request_text = (
bool(city or branch_hint or request_number or service_hint)
or _looks_like_request_statement(compact_text)
or not str(summary.get("active_request_text") or "").strip()
)
if should_refresh_request_text:
summary["active_request_text"] = compact_text[:500]
if city:
summary["confirmed_facts"]["city"] = city
if branch_hint:
summary["confirmed_facts"]["branch_hint"] = branch_hint
if request_number:
summary["confirmed_facts"]["request_number"] = request_number
if service_hint:
summary["confirmed_facts"]["service_hint"] = service_hint
if inferred_intent:
summary["active_intent"] = inferred_intent
summary["scenario_state"] = f"{inferred_intent}_in_progress"
summary["open_slots"] = _intent_open_slots(summary["active_intent"], summary["confirmed_facts"])
if now:
summary["updated_at"] = now
return summary
def update_context_summary_from_assistant_turn(
raw: str | dict[str, Any] | None,
*,
language: str | None,
customer_name: str | None,
reply_text: str | None,
decision_intent: str | None,
needs_handoff: bool,
handoff_reason: str | None,
now: str | None = None,
) -> dict[str, Any]:
summary = load_context_summary(raw)
if language:
summary["language"] = str(language).strip()
if customer_name:
summary["customer_name"] = str(customer_name).strip()
if decision_intent and decision_intent not in {"clarification", "kb_answer", "unknown"}:
summary["active_intent"] = str(decision_intent).strip()
if needs_handoff:
summary["scenario_state"] = "handoff_requested"
summary["open_slots"] = []
summary["requested_clarifications"] = []
else:
clarifications = _requested_clarifications_from_reply(reply_text)
fallback_slots = _intent_open_slots(summary["active_intent"], summary["confirmed_facts"])
merged_open_slots, merged_requested_clarifications = _merge_open_slots(
list(summary["open_slots"]),
clarified_slots=clarifications,
fallback_slots=fallback_slots,
)
summary["open_slots"] = merged_open_slots
summary["requested_clarifications"] = merged_requested_clarifications
if summary["active_intent"]:
summary["scenario_state"] = f"{summary['active_intent']}_in_progress"
if handoff_reason and needs_handoff:
summary["active_request_text"] = summary["active_request_text"] or str(handoff_reason).strip()
if now:
summary["updated_at"] = now
return summary
+9
View File
@@ -375,11 +375,20 @@ def _apply_runtime_schema_compatibility() -> None:
if "ai_sessions" in table_names:
columns = _table_columns(inspector, "ai_sessions")
_add_column_if_missing(conn, columns, "ai_sessions", "call_id", "VARCHAR(128)")
_add_column_if_missing(conn, columns, "ai_sessions", "context_summary_json", "TEXT DEFAULT '{}'")
_add_column_if_missing(conn, columns, "ai_sessions", "context_summary_updated_at", "VARCHAR(64)")
indexes = _table_indexes(inspector, "ai_sessions")
if "idx_ai_sessions_call_id" not in indexes:
conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_ai_sessions_call_id ON ai_sessions(call_id)")
)
if "idx_ai_sessions_context_summary_updated_at" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_ai_sessions_context_summary_updated_at "
"ON ai_sessions(context_summary_updated_at)"
)
)
if "voice_events" in table_names:
columns = _table_columns(inspector, "voice_events")
+2
View File
@@ -372,6 +372,8 @@ class AISessionRow(Base):
language: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="active")
summary_text: Mapped[str] = mapped_column(Text, default="")
context_summary_json: Mapped[str] = mapped_column(Text, default="{}")
context_summary_updated_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
last_user_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
last_ai_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+99
View File
@@ -0,0 +1,99 @@
import json
from services.shared.ai_context_summary import (
render_context_summary_text,
update_context_summary_from_assistant_turn,
update_context_summary_from_user_turn,
)
def test_context_summary_tracks_city_and_branch_slot_for_schedule():
summary = update_context_summary_from_user_turn(
None,
channel="voice",
language="ru",
customer_name="Ернур",
text="Мне нужен график работы в городе Алмата.",
now="2026-04-12T00:00:00+00:00",
)
assert summary["channel"] == "voice"
assert summary["customer_name"] == "Ернур"
assert summary["active_intent"] == "schedule"
assert summary["confirmed_facts"]["city"] == "Алмата"
assert summary["open_slots"] == ["branch_or_address"]
def test_context_summary_does_not_overwrite_meaningful_request_with_low_signal():
summary = update_context_summary_from_user_turn(
None,
channel="voice",
language="ru",
customer_name="Ернур",
text="Мне нужен график работы в городе Алмата.",
now="2026-04-12T00:00:00+00:00",
)
updated = update_context_summary_from_user_turn(
json.dumps(summary, ensure_ascii=False),
channel="voice",
language="ru",
customer_name="Ернур",
text="Алло",
now="2026-04-12T00:00:02+00:00",
)
assert updated["active_request_text"] == "Мне нужен график работы в городе Алмата."
assert updated["confirmed_facts"]["city"] == "Алмата"
def test_context_summary_assistant_turn_keeps_slots_and_renders_text():
summary = update_context_summary_from_user_turn(
None,
channel="telegram",
language="ru",
customer_name="Клиент",
text="Хочу узнать график работы в городе Алмата",
now="2026-04-12T00:00:00+00:00",
)
updated = update_context_summary_from_assistant_turn(
json.dumps(summary, ensure_ascii=False),
language="ru",
customer_name="Клиент",
reply_text="Подскажите, какой филиал или адрес в этом городе вас интересует?",
decision_intent="clarification",
needs_handoff=False,
handoff_reason=None,
now="2026-04-12T00:00:03+00:00",
)
rendered = render_context_summary_text(updated)
assert updated["open_slots"] == ["branch_or_address"]
assert "city=Алмата" in rendered
assert "график работы" in rendered
def test_context_summary_generic_clarification_does_not_downgrade_specific_slot():
summary = update_context_summary_from_user_turn(
None,
channel="telegram",
language="ru",
customer_name="Клиент",
text="Хочу узнать график работы в городе Алмата",
now="2026-04-12T00:00:00+00:00",
)
updated = update_context_summary_from_assistant_turn(
json.dumps(summary, ensure_ascii=False),
language="ru",
customer_name="Клиент",
reply_text="Уточните, пожалуйста, что именно нужно проверить или подсказать.",
decision_intent="clarification",
needs_handoff=False,
handoff_reason=None,
now="2026-04-12T00:00:03+00:00",
)
assert updated["open_slots"] == ["branch_or_address"]
assert updated["requested_clarifications"] == ["branch_or_address"]
+120
View File
@@ -1590,6 +1590,71 @@ def test_turn_voice_session_early_plan_does_not_persist_partial_turns(monkeypatc
session.close()
def test_voice_llm_prompt_includes_context_summary_and_uses_12_segments():
transcript_window = [
SimpleNamespace(
speaker="caller" if index % 2 == 0 else "assistant",
text=f"segment {index}",
sequence_no=index,
source_type="voice_policy",
barge_in_interrupted=False,
created_at=utc_now_iso(),
)
for index in range(1, 16)
]
messages = voice_module._voice_llm_prompt_messages(
language="ru",
customer=None,
interaction=SimpleNamespace(
interaction_id="int_voice_prompt",
status="open",
queue_id="que_voice",
subject="schedule",
customer_id="cus_voice_prompt",
),
transcript_text="Мне нужен график работы",
transcript_window=transcript_window,
conversation_summary_text="Customer name: Ернур | Active intent: schedule | Confirmed facts: city=Алмата",
kb_results=[],
name_value="Ернур",
name_status="name_obtained",
)
payload = json.loads(messages[1]["content"])
assert payload["conversation_summary"].startswith("Customer name: Ернур")
assert len(payload["history"]) == 12
assert payload["history"][0]["sequence_no"] == 4
def test_voice_postprocess_reply_uses_summary_context_when_raw_window_lost_topic():
reply_text = voice_module._voice_postprocess_reply_text(
language="ru",
transcript_text="Сколько раз повторять тебе?",
transcript_window=[
SimpleNamespace(speaker="caller", text="Сколько раз повторять тебе?", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
],
context_summary=json.dumps(
{
"customer_name": "Ернур",
"active_intent": "schedule",
"active_request_text": "Мне нужен график работы в городе Алмата",
"confirmed_facts": {"city": "Алмата", "branch_hint": None, "service_hint": "график работы", "request_number": None},
"open_slots": ["branch_or_address"],
},
ensure_ascii=False,
),
reply_text="Пожалуйста, уточните, какая услуга вас интересует, чтобы я мог подсказать тариф.",
kb_results=[],
needs_handoff=False,
)
normalized = reply_text.lower()
assert "тариф" not in normalized
assert "услуг" not in normalized
assert "филиал" in normalized or "адрес" in normalized
def test_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
@@ -1639,6 +1704,61 @@ def test_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch):
assert delivered.json()[-1]["telegram_message_id_external"] == "5551"
def test_ai_enqueue_persists_internal_context_summary(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
created = create_inbound_thread(
telegram_client,
f"chat_ai_context_summary_{new_id('chat')}",
"Хочу узнать график работы в городе Алмата",
)
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] in {"done", "handoff_required", "running"}
deadline = time.time() + 2.0
last_summary = None
while time.time() < deadline:
session = get_session()
try:
thread = session.execute(
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == created["thread_id"])
).scalar_one()
if not thread.ai_session_id:
time.sleep(0.05)
continue
ai_session = session.execute(
select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id)
).scalar_one()
last_summary = json.loads(ai_session.context_summary_json or "{}")
if (
last_summary.get("active_intent") == "schedule"
and last_summary.get("confirmed_facts", {}).get("city") == "Алмата"
):
break
finally:
session.close()
time.sleep(0.05)
assert last_summary is not None
assert last_summary["active_intent"] == "schedule"
assert last_summary["confirmed_facts"]["city"] == "Алмата"
assert last_summary["open_slots"] == ["branch_or_address"]
def test_ai_enqueue_relaxed_kb_search_answers_phrase_query(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
+39
View File
@@ -64,3 +64,42 @@ def test_init_sql_schema_backfills_whatsapp_message_runtime_columns():
assert "author_type" in columns
assert "ix_whatsapp_messages_chat_external_unique" in indexes
assert "idx_whatsapp_messages_next_delivery_attempt_at" in indexes
def test_init_sql_schema_backfills_ai_session_context_summary_columns():
with engine.begin() as conn:
conn.execute(text("DROP TABLE IF EXISTS ai_sessions"))
conn.execute(
text(
"""
CREATE TABLE ai_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id VARCHAR(64) NOT NULL UNIQUE,
channel VARCHAR(32) NOT NULL,
thread_id VARCHAR(64) NULL,
interaction_id VARCHAR(64) NULL,
customer_id VARCHAR(64) NULL,
agent_profile VARCHAR(64) NOT NULL,
language VARCHAR(16) NULL,
status VARCHAR(32) NOT NULL,
summary_text TEXT NOT NULL,
last_user_message_id VARCHAR(64) NULL,
last_ai_message_id VARCHAR(64) NULL,
handoff_reason TEXT NULL,
created_at VARCHAR(64) NOT NULL,
updated_at VARCHAR(64) NOT NULL,
closed_at VARCHAR(64) NULL
)
"""
)
)
init_sql_schema()
inspector = inspect(engine)
columns = {item["name"] for item in inspector.get_columns("ai_sessions")}
indexes = {item["name"] for item in inspector.get_indexes("ai_sessions")}
assert "context_summary_json" in columns
assert "context_summary_updated_at" in columns
assert "idx_ai_sessions_context_summary_updated_at" in indexes