Files
call-center/services/shared/ai_context_summary.py
T

435 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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