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
+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)