feat(ai): add rolling conversation summary memory
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user