deploy / deploy (push) Successful in 30s
Centralizes fixed control intents and adds a data-driven intent_code field on kb_articles so many phrasings of the same FAQ question resolve to one stable code (e.g. VOUCHER_ACTIVATION) instead of a free-form, unvalidated string the LLM invented on the fly. - services/shared/intents.py: CONTROL_INTENTS + normalize_intent() - kb_articles.intent_code column (ORM + dev/sqlite runtime compat + migrations/sql/0034_* for postgres/sqlite) - kb_service CRUD exposes intent_code - orchestrator surfaces intent_code to the LLM and validates its intent output against control intents + the KB codes shown that turn - voice.py: _voice_early_intent_bucket renamed to _voice_ack_topic_bucket to stop it being conflated with the canonical FAQ intent
2762 lines
109 KiB
Python
2762 lines
109 KiB
Python
from __future__ import annotations
|
||
|
||
import difflib
|
||
import importlib
|
||
import json
|
||
import re
|
||
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
|
||
from services.shared.sql_models import (
|
||
AISessionRow,
|
||
AITurnRow,
|
||
AsteriskCallLinkRow,
|
||
Customer,
|
||
CustomerExternalIdentity,
|
||
Interaction,
|
||
InteractionTimeline,
|
||
VoiceAISessionRow,
|
||
VoiceTranscriptSegmentRow,
|
||
)
|
||
from services.ai_orchestrator_service import operator_persona as persona
|
||
from services.ai_orchestrator_service.voice_name_config import (
|
||
load_effective_voice_name_collection_config,
|
||
voice_name_collection_confirmation_greeting,
|
||
voice_name_collection_followup_markers,
|
||
voice_name_collection_inline_followup,
|
||
voice_name_collection_personalized_greeting,
|
||
voice_name_collection_start_prompt,
|
||
)
|
||
|
||
|
||
def _app():
|
||
return importlib.import_module("services.ai_orchestrator_service.app")
|
||
|
||
|
||
def _voice_max_context_segments() -> int:
|
||
app = _app()
|
||
return max(6, app._int_env("AI_VOICE_MAX_CONTEXT_SEGMENTS", 12))
|
||
|
||
|
||
def _voice_disclosure_prefix(language: str) -> str:
|
||
return persona.voice_disclosure_prefix(language)
|
||
|
||
|
||
def _voice_greeting(language: str, operator_config: Any | None = None) -> str:
|
||
return persona.voice_greeting(language, operator_config)
|
||
|
||
|
||
def _voice_handoff_reply(language: str) -> str:
|
||
return persona.voice_handoff_reply(language)
|
||
|
||
|
||
def _normalize_phone(value: str | None) -> str | None:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return None
|
||
digits = "".join(ch for ch in raw if ch.isdigit())
|
||
if not digits:
|
||
return raw
|
||
if raw.startswith("+"):
|
||
return f"+{digits}"
|
||
return digits
|
||
|
||
|
||
def _voice_identity_subjects(caller_number: str | None) -> list[str]:
|
||
normalized = _normalize_phone(caller_number)
|
||
values = [normalized, str(caller_number or "").strip() or None]
|
||
seen: set[str] = set()
|
||
result: list[str] = []
|
||
for value in values:
|
||
if not value or value in seen:
|
||
continue
|
||
seen.add(value)
|
||
result.append(value)
|
||
return result
|
||
|
||
|
||
def _resolve_caller_from_call(session, call_id: str) -> tuple[str | None, str | None]:
|
||
row = session.execute(
|
||
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
return None, None
|
||
return row.caller_number, row.caller_name
|
||
|
||
|
||
def _ensure_voice_identity(
|
||
session,
|
||
*,
|
||
customer_id: str,
|
||
caller_number: str,
|
||
caller_name: str | None,
|
||
now: str,
|
||
) -> None:
|
||
for subject in _voice_identity_subjects(caller_number):
|
||
identity = session.execute(
|
||
select(CustomerExternalIdentity).where(
|
||
CustomerExternalIdentity.channel == "voice",
|
||
CustomerExternalIdentity.external_subject == subject,
|
||
)
|
||
).scalar_one_or_none()
|
||
if identity:
|
||
identity.customer_id = customer_id
|
||
identity.display_name_snapshot = caller_name or identity.display_name_snapshot
|
||
identity.updated_at = now
|
||
continue
|
||
session.add(
|
||
CustomerExternalIdentity(
|
||
identity_id=new_id("cei"),
|
||
customer_id=customer_id,
|
||
channel="voice",
|
||
external_subject=subject,
|
||
display_name_snapshot=caller_name,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
)
|
||
|
||
|
||
def _resolve_or_create_voice_customer_id(
|
||
session,
|
||
*,
|
||
interaction: Interaction,
|
||
call_id: str,
|
||
) -> str | None:
|
||
app = _app()
|
||
if app._customer_id_is_real(interaction.customer_id):
|
||
return str(interaction.customer_id)
|
||
|
||
caller_number, caller_name = _resolve_caller_from_call(session, call_id)
|
||
subjects = _voice_identity_subjects(caller_number)
|
||
now = utc_now_iso()
|
||
for subject in subjects:
|
||
identity = session.execute(
|
||
select(CustomerExternalIdentity).where(
|
||
CustomerExternalIdentity.channel == "voice",
|
||
CustomerExternalIdentity.external_subject == subject,
|
||
)
|
||
).scalar_one_or_none()
|
||
if identity:
|
||
interaction.customer_id = identity.customer_id
|
||
interaction.updated_at = now
|
||
return identity.customer_id
|
||
|
||
if not caller_number:
|
||
return str(interaction.customer_id or "").strip() or None
|
||
|
||
customer = Customer(
|
||
customer_id=new_id("cus"),
|
||
display_name=caller_name or caller_number,
|
||
phones_json=json.dumps([caller_number], ensure_ascii=False),
|
||
preferred_phone=caller_number,
|
||
tags_json=json.dumps(["voice"], ensure_ascii=False),
|
||
created_at=now,
|
||
)
|
||
session.add(customer)
|
||
interaction.customer_id = customer.customer_id
|
||
interaction.updated_at = now
|
||
_ensure_voice_identity(
|
||
session,
|
||
customer_id=customer.customer_id,
|
||
caller_number=caller_number,
|
||
caller_name=caller_name,
|
||
now=now,
|
||
)
|
||
return customer.customer_id
|
||
|
||
|
||
def _voice_start_stage(agent_profile: str | None, metadata: dict[str, Any] | None) -> bool:
|
||
payload = metadata if isinstance(metadata, dict) else {}
|
||
if str(agent_profile or "").strip() == "voice_start":
|
||
return True
|
||
return str(payload.get("stage") or "").strip() == "voice_start"
|
||
|
||
|
||
def _voice_start_language(language_hint: str | None, metadata: dict[str, Any] | None, fallback: str | None = None) -> str:
|
||
payload = metadata if isinstance(metadata, dict) else {}
|
||
return (
|
||
str(payload.get("voice_start_language") or language_hint or fallback or "ru").strip()
|
||
or "ru"
|
||
)
|
||
|
||
|
||
def _voice_start_name_prompt(language: str, config) -> str:
|
||
return voice_name_collection_start_prompt(config, language)
|
||
|
||
|
||
def _voice_start_downstream_queue(metadata: dict[str, Any] | None, voice_session: VoiceAISessionRow) -> tuple[str | None, str | None]:
|
||
payload = metadata if isinstance(metadata, dict) else {}
|
||
queue_code = str(payload.get("downstream_queue_code") or payload.get("next_queue_code") or "").strip() or None
|
||
queue_id = str(payload.get("downstream_queue_id") or payload.get("next_queue_id") or voice_session.handoff_target_queue_id or "").strip() or None
|
||
return queue_code, queue_id
|
||
|
||
|
||
def _display_name_looks_trusted(
|
||
customer: Customer | None,
|
||
caller_number: str | None,
|
||
caller_name: str | None,
|
||
) -> bool:
|
||
display_name = str(getattr(customer, "display_name", "") or "").strip()
|
||
if not display_name:
|
||
return False
|
||
normalized_display = _voice_text_key(display_name)
|
||
normalized_number = _voice_text_key(_normalize_phone(caller_number) or caller_number)
|
||
normalized_caller_name = _voice_text_key(caller_name)
|
||
if not normalized_display:
|
||
return False
|
||
if normalized_display == normalized_number or normalized_display == normalized_caller_name:
|
||
return False
|
||
if re.fullmatch(r"\+?\d[\d\s\-()]{4,}", display_name):
|
||
return False
|
||
placeholder_markers = {
|
||
"caller",
|
||
"client",
|
||
"customer",
|
||
"unknown",
|
||
"anonymous",
|
||
"\u043a\u043b\u0438\u0435\u043d\u0442",
|
||
"\u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0439",
|
||
"\u0430\u0431\u043e\u043d\u0435\u043d\u0442",
|
||
}
|
||
tokens = [token for token in re.findall(r"[^\W\d_]+", normalized_display, flags=re.UNICODE) if token]
|
||
if not tokens:
|
||
return False
|
||
return not all(token in placeholder_markers for token in tokens)
|
||
|
||
|
||
def _canonical_name(text: str) -> str:
|
||
words = [part for part in re.split(r"\s+", text.strip()) if part]
|
||
return " ".join(word[:1].upper() + word[1:].lower() if len(word) > 1 else word.upper() for word in words)
|
||
|
||
|
||
def _normalize_name_candidate(text: str | None) -> str | None:
|
||
raw = str(text or "").strip(" \t\r\n,.;:!?\"'()[]{}")
|
||
if not raw or any(ch.isdigit() for ch in raw):
|
||
return None
|
||
words = re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", raw, flags=re.UNICODE)
|
||
if not words or len(words) > 4:
|
||
return None
|
||
lowered = [_voice_text_key(word) for word in words]
|
||
stop_words = {
|
||
"\u043c\u0435\u043d\u044f",
|
||
"\u0437\u043e\u0432\u0443\u0442",
|
||
"\u044d\u0442\u043e",
|
||
"\u044f",
|
||
"\u043c\u043e\u0435",
|
||
"\u0438\u043c\u044f",
|
||
"\u043c\u0435\u043d\u0456\u04a3",
|
||
"\u0430\u0442\u044b\u043c",
|
||
"\u0430\u0442\u044b\u043c\u044b",
|
||
"\u0430\u0442\u044b\u043c",
|
||
"\u0431\u043e\u043b\u0430\u0434\u044b",
|
||
}
|
||
filtered = [word for word, lowered_word in zip(words, lowered) if lowered_word not in stop_words]
|
||
if not filtered:
|
||
return None
|
||
if len(filtered) == 2 and len(filtered[1]) == 1:
|
||
filtered = [filtered[0]]
|
||
elif len(filtered) == 2 and len(filtered[0]) == 1:
|
||
filtered = [filtered[1]]
|
||
invalid_tokens = {
|
||
"\u0434\u0430",
|
||
"\u0445\u0430\u0447\u0443",
|
||
"\u043d\u0435\u0442",
|
||
"\u0430\u043b\u043b\u043e",
|
||
"\u043f\u0440\u0438\u0432\u0435\u0442",
|
||
"\u0445\u043e\u0447\u0443",
|
||
"\u0443\u0437\u043d\u0430\u0442\u044c",
|
||
"\u043f\u043e\u043c\u043e\u0449\u044c",
|
||
"\u0432\u043e\u043f\u0440\u043e\u0441",
|
||
"\u0442\u0430\u0440\u0438\u0444",
|
||
"\u0441\u0442\u0430\u0442\u0443\u0441",
|
||
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
|
||
"\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430",
|
||
"\u043a\u0435\u0440\u0435\u043a",
|
||
"\u0441\u04b1\u0440\u0430\u049b",
|
||
"\u043a\u04e9\u043c\u0435\u043a",
|
||
"\u0442\u0430\u0440\u0438\u0444",
|
||
}
|
||
if any(_voice_text_key(word) in invalid_tokens for word in filtered):
|
||
return None
|
||
if len(filtered) > 2:
|
||
return None
|
||
if len(filtered) == 2:
|
||
similarity = difflib.SequenceMatcher(
|
||
None,
|
||
_voice_text_key(filtered[0]),
|
||
_voice_text_key(filtered[1]),
|
||
).ratio()
|
||
if similarity >= 0.72:
|
||
return None
|
||
return _canonical_name(" ".join(filtered))
|
||
|
||
|
||
def _name_followup_needed(text: str) -> bool:
|
||
normalized = _voice_text_key(text)
|
||
request_markers = (
|
||
"\u0445\u043e\u0442\u0435\u043b",
|
||
"\u043d\u0443\u0436\u043d",
|
||
"\u043f\u043e\u043c\u043e\u0433",
|
||
"\u0432\u043e\u043f\u0440\u043e\u0441",
|
||
"\u043f\u0440\u043e\u0431\u043b\u0435\u043c",
|
||
"\u0442\u0430\u0440\u0438\u0444",
|
||
"\u0441\u0442\u0430\u0442\u0443\u0441",
|
||
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
|
||
"\u043a\u0430\u0436\u0435\u0442\u0441\u044f",
|
||
"\u0431\u043e\u043b\u0430\u0434\u044b",
|
||
"\u043a\u0435\u0440\u0435\u043a",
|
||
"\u0441\u04b1\u0440\u0430\u0493",
|
||
"\u043a\u04e9\u043c\u0435\u043a",
|
||
"\u0442\u0430\u0440\u0438\u0444",
|
||
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
|
||
)
|
||
return any(marker in normalized for marker in request_markers)
|
||
|
||
|
||
def _extract_name_candidate(text: str | None, language: str) -> tuple[str | None, bool]:
|
||
raw = str(text or "").strip()
|
||
if not raw:
|
||
return None, False
|
||
normalized = _voice_text_key(raw)
|
||
service_topic = _voice_has_service_topic(raw)
|
||
explicit_patterns = [
|
||
r"(?:\u043c\u0435\u043d\u044f\s+\u0437\u043e\u0432\u0443\u0442|my name is|i am|this is)\s+(.+)",
|
||
r"(?:\u044f|it's me)\s+(.+)",
|
||
r"(?:\u043c\u0435\u043d\u0456\u04a3\s+\u0430\u0442\u044b\u043c|mening atym|aty\u043c)\s+(.+)",
|
||
r"(?:\u043c\u0435\u043d)\s+(.+)",
|
||
]
|
||
for pattern in explicit_patterns:
|
||
match = re.search(pattern, normalized, flags=re.IGNORECASE)
|
||
if not match:
|
||
continue
|
||
tail = match.group(1).strip()
|
||
tail_words = re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", tail, flags=re.UNICODE)
|
||
cutoff_tokens = {
|
||
"\u0445\u043e\u0442\u0435\u043b",
|
||
"\u0445\u043e\u0447\u0443",
|
||
"\u043c\u043d\u0435",
|
||
"\u043d\u0430\u0434\u043e",
|
||
"\u043d\u0443\u0436\u043d\u043e",
|
||
"\u0443\u0437\u043d\u0430\u0442\u044c",
|
||
"\u0433\u0440\u0430\u0444\u0438\u043a",
|
||
"\u0440\u0430\u0431\u043e\u0442\u044b",
|
||
"\u0430\u0434\u0440\u0435\u0441",
|
||
"\u0444\u0438\u043b\u0438\u0430\u043b",
|
||
"\u0433\u043e\u0440\u043e\u0434",
|
||
"\u0442\u0430\u0440\u0438\u0444",
|
||
"\u0441\u0442\u0430\u0442\u0443\u0441",
|
||
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
|
||
"\u0432\u043e\u043f\u0440\u043e\u0441",
|
||
"\u043a\u0435\u0440\u0435\u043a",
|
||
"\u0441\u04b1\u0440\u0430\u0493",
|
||
"\u043a\u04e9\u043c\u0435\u043a",
|
||
}
|
||
candidate_words: list[str] = []
|
||
for word in tail_words:
|
||
if _voice_text_key(word) in cutoff_tokens:
|
||
break
|
||
candidate_words.append(word)
|
||
if len(candidate_words) >= 3:
|
||
break
|
||
candidate = _normalize_name_candidate(" ".join(candidate_words)) or _normalize_name_candidate(tail)
|
||
if candidate:
|
||
return candidate, False
|
||
|
||
candidate = _normalize_name_candidate(raw)
|
||
if candidate:
|
||
if service_topic:
|
||
return None, False
|
||
lower_cand = candidate.lower()
|
||
stopwords = {
|
||
"здравствуйте", "привет", "алло", "да", "нет", "добрый", "день",
|
||
"саламатсыз", "сәлеметсіз", "ба", "бе", "ау", "слышно", "интернет", "не", "работает", "вопрос", "у", "меня"
|
||
}
|
||
cand_words = set(lower_cand.split())
|
||
if cand_words.issubset(stopwords) or len(lower_cand) < 2:
|
||
return None, False
|
||
|
||
word_count = len(re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", raw, flags=re.UNICODE))
|
||
if word_count <= 2 and not _name_followup_needed(raw):
|
||
return candidate, False
|
||
return candidate, True
|
||
return None, False
|
||
|
||
|
||
def _voice_start_name_outcome(text: str | None, language: str) -> tuple[str, str | None, str]:
|
||
raw = str(text or "").strip()
|
||
if not raw or _voice_is_low_signal_caller_text(raw):
|
||
return "name_not_obtained", None, "none"
|
||
candidate, needs_followup = _extract_name_candidate(raw, language)
|
||
if candidate and not needs_followup:
|
||
return "name_obtained", candidate, "voice_start"
|
||
if candidate:
|
||
return "name_followup_required", candidate, "voice_start"
|
||
if _name_followup_needed(raw):
|
||
return "name_followup_required", None, "none"
|
||
return "name_not_obtained", None, "none"
|
||
|
||
|
||
def _voice_start_result_metadata(result: VoiceStartResult) -> dict[str, Any]:
|
||
return {
|
||
"voice_start_language": result.language,
|
||
"customer_name_status": result.customer_name_status,
|
||
"customer_name_value": result.customer_name_value,
|
||
"customer_name_source": result.customer_name_source,
|
||
"customer_name_resolved_at": result.resolved_at,
|
||
"downstream_queue_code": result.downstream_queue_code,
|
||
"downstream_queue_id": result.downstream_queue_id,
|
||
"customer_id": result.customer_id,
|
||
}
|
||
|
||
|
||
def _persist_voice_start_result(
|
||
session,
|
||
*,
|
||
voice_session: VoiceAISessionRow,
|
||
interaction: Interaction,
|
||
result: VoiceStartResult,
|
||
) -> None:
|
||
now = result.resolved_at or utc_now_iso()
|
||
voice_session.voice_start_language = result.language
|
||
voice_session.customer_name_status = result.customer_name_status
|
||
voice_session.customer_name_value = result.customer_name_value
|
||
voice_session.customer_name_source = result.customer_name_source
|
||
voice_session.customer_name_resolved_at = now
|
||
voice_session.updated_at = now
|
||
link = session.execute(
|
||
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == voice_session.call_id)
|
||
).scalar_one_or_none()
|
||
if link is not None:
|
||
link.voice_start_language = result.language
|
||
link.customer_name_status = result.customer_name_status
|
||
link.customer_name_value = result.customer_name_value
|
||
link.customer_name_source = result.customer_name_source
|
||
link.customer_name_resolved_at = now
|
||
link.updated_at = now
|
||
_append_timeline(
|
||
interaction.interaction_id,
|
||
"voice.start.completed",
|
||
{
|
||
"call_id": voice_session.call_id,
|
||
"voice_session_id": voice_session.session_id,
|
||
"ai_session_id": voice_session.ai_session_id,
|
||
"language": result.language,
|
||
"customer_id": result.customer_id,
|
||
"customer_name_status": result.customer_name_status,
|
||
"customer_name_value": result.customer_name_value,
|
||
"customer_name_source": result.customer_name_source,
|
||
"downstream_queue_code": result.downstream_queue_code,
|
||
"downstream_queue_id": result.downstream_queue_id,
|
||
},
|
||
)
|
||
|
||
|
||
def _voice_short_name(name: str | None) -> str | None:
|
||
canonical = _normalize_name_candidate(name)
|
||
if not canonical:
|
||
raw = str(name or "").strip()
|
||
if not raw:
|
||
return None
|
||
canonical = _canonical_name(raw)
|
||
return canonical.split(" ", 1)[0].strip() or None
|
||
|
||
|
||
def _voice_personalized_greeting(language: str, name: str | None, config) -> str:
|
||
short_name = _voice_short_name(name)
|
||
if not short_name:
|
||
return _voice_greeting(language)
|
||
return voice_name_collection_personalized_greeting(config, language, short_name)
|
||
|
||
|
||
def _voice_name_confirmation_greeting(language: str, name: str | None, config) -> str:
|
||
short_name = _voice_short_name(name)
|
||
if not short_name:
|
||
return _voice_greeting(language)
|
||
return voice_name_collection_confirmation_greeting(config, language, short_name)
|
||
|
||
|
||
def _voice_inline_name_followup(language: str, config) -> str:
|
||
return voice_name_collection_inline_followup(config, language)
|
||
|
||
|
||
def _voice_name_followup_asked(transcript_window: list[VoiceTranscriptSegmentRow], language: str, config) -> bool:
|
||
markers = tuple(_voice_text_key(marker) for marker in voice_name_collection_followup_markers(config, language))
|
||
for segment in transcript_window:
|
||
if segment.speaker != "assistant":
|
||
continue
|
||
normalized = _voice_text_key(segment.text)
|
||
if any(marker in normalized for marker in markers):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _voice_explicit_name_candidate(text: str | None) -> str | None:
|
||
raw = str(text or "").strip()
|
||
if not raw:
|
||
return None
|
||
normalized = _voice_text_key(raw)
|
||
cutoff_tokens = {
|
||
"\u043c\u043d\u0435",
|
||
"\u043d\u0430\u0434\u043e",
|
||
"\u043d\u0443\u0436\u043d\u043e",
|
||
"\u0445\u043e\u0447\u0443",
|
||
"\u0445\u043e\u0442\u0435\u043b",
|
||
"\u0443\u0437\u043d\u0430\u0442\u044c",
|
||
"\u0433\u0440\u0430\u0444\u0438\u043a",
|
||
"\u0440\u0430\u0431\u043e\u0442\u044b",
|
||
"\u0430\u0434\u0440\u0435\u0441",
|
||
"\u0444\u0438\u043b\u0438\u0430\u043b",
|
||
"\u0433\u043e\u0440\u043e\u0434",
|
||
"\u0442\u0430\u0440\u0438\u0444",
|
||
"\u0441\u0442\u0430\u0442\u0443\u0441",
|
||
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
|
||
"\u0432\u043e\u043f\u0440\u043e\u0441",
|
||
"\u043a\u0435\u0440\u0435\u043a",
|
||
"\u0441\u04b1\u0440\u0430\u0493",
|
||
"\u043a\u04e9\u043c\u0435\u043a",
|
||
}
|
||
patterns = (
|
||
r"(?:меня зовут|мое имя|это|my name is|i am|this is)\s+(.+)",
|
||
r"(?:менің атым|аты[мң]?|mening atym)\s+(.+)",
|
||
)
|
||
for pattern in patterns:
|
||
match = re.search(pattern, normalized, flags=re.IGNORECASE)
|
||
if not match:
|
||
continue
|
||
tail = match.group(1).strip()
|
||
tail_words = re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", tail, flags=re.UNICODE)
|
||
candidate_words: list[str] = []
|
||
for word in tail_words:
|
||
if _voice_text_key(word) in cutoff_tokens:
|
||
break
|
||
candidate_words.append(word)
|
||
if len(candidate_words) >= 3:
|
||
break
|
||
candidate = _normalize_name_candidate(" ".join(candidate_words)) or _normalize_name_candidate(tail)
|
||
if candidate:
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def _voice_is_name_confirmation(text: str | None, current_name: str | None) -> bool:
|
||
normalized = _voice_text_key(text)
|
||
if not normalized or not current_name:
|
||
return False
|
||
yes_markers = {
|
||
"да",
|
||
"ага",
|
||
"верно",
|
||
"правильно",
|
||
"именно",
|
||
"точно",
|
||
"иә",
|
||
"ия",
|
||
"дурыс",
|
||
"durys",
|
||
"yes",
|
||
"correct",
|
||
"right",
|
||
}
|
||
if normalized in yes_markers:
|
||
return True
|
||
current_keys = {
|
||
_voice_text_key(current_name),
|
||
_voice_text_key(_voice_short_name(current_name) or ""),
|
||
}
|
||
if normalized in current_keys:
|
||
return True
|
||
normalized_tokens = {token for token in normalized.split(" ") if token}
|
||
if normalized_tokens & yes_markers and normalized_tokens & {key for key in current_keys if key}:
|
||
return True
|
||
return any(marker in normalized for marker in ("да это", "верно это", "ия бұл", "дұрыс"))
|
||
|
||
|
||
def _voice_has_name_correction(text: str | None) -> bool:
|
||
normalized = _voice_text_key(text)
|
||
correction_markers = (
|
||
"нет",
|
||
"неа",
|
||
"не так",
|
||
"ошибка",
|
||
"не правильно",
|
||
"жок",
|
||
"жоқ",
|
||
"меня зовут",
|
||
"мое имя",
|
||
"менің атым",
|
||
"аты",
|
||
)
|
||
return any(marker in normalized for marker in correction_markers)
|
||
|
||
|
||
def _voice_reply_already_names_customer(reply_text: str, normalized_short: str) -> bool:
|
||
tokens = _voice_text_key(reply_text).split(" ")
|
||
if normalized_short in tokens:
|
||
return True
|
||
stem_len = max(len(normalized_short) - 2, 3)
|
||
stem = normalized_short[:stem_len]
|
||
return any(len(token) >= stem_len and token.startswith(stem) for token in tokens)
|
||
|
||
|
||
def _voice_greeting_word(language: str) -> str:
|
||
if str(language or "").strip().lower() == "kz":
|
||
return "Сәлеметсіз бе"
|
||
return "Здравствуйте"
|
||
|
||
|
||
def _voice_reply_with_name(
|
||
language: str,
|
||
reply_text: str,
|
||
name: str | None,
|
||
*,
|
||
greet: bool = False,
|
||
) -> str:
|
||
short_name = _voice_short_name(name)
|
||
if not short_name:
|
||
return reply_text
|
||
prefix = _voice_disclosure_prefix(language)
|
||
normalized_short = _voice_text_key(short_name)
|
||
lead = f"{_voice_greeting_word(language)}, {short_name}" if greet else short_name
|
||
if reply_text.startswith(prefix):
|
||
rest = reply_text[len(prefix) :].lstrip()
|
||
if _voice_reply_already_names_customer(rest, normalized_short):
|
||
return reply_text
|
||
return f"{prefix}{lead}, {rest}"
|
||
if _voice_reply_already_names_customer(reply_text, normalized_short):
|
||
return reply_text
|
||
return f"{lead}, {reply_text}"
|
||
|
||
|
||
def _voice_name_metadata(
|
||
*,
|
||
language: str,
|
||
customer_id: str | None,
|
||
status: str,
|
||
value: str | None,
|
||
source: str,
|
||
resolved_at: str | None,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"voice_start_language": language,
|
||
"customer_id": customer_id,
|
||
"customer_name_status": status,
|
||
"customer_name_value": value,
|
||
"customer_name_source": source,
|
||
"customer_name_resolved_at": resolved_at,
|
||
}
|
||
|
||
|
||
def _persist_voice_name_state(
|
||
session,
|
||
*,
|
||
voice_session: VoiceAISessionRow,
|
||
status: str,
|
||
value: str | None,
|
||
source: str,
|
||
resolved_at: str | None,
|
||
) -> None:
|
||
voice_session.customer_name_status = status
|
||
voice_session.customer_name_value = value
|
||
voice_session.customer_name_source = source
|
||
voice_session.customer_name_resolved_at = resolved_at
|
||
link = session.execute(
|
||
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == voice_session.call_id)
|
||
).scalar_one_or_none()
|
||
if link is not None:
|
||
link.customer_name_status = status
|
||
link.customer_name_value = value
|
||
link.customer_name_source = source
|
||
link.customer_name_resolved_at = resolved_at
|
||
link.updated_at = utc_now_iso()
|
||
|
||
|
||
def _finalize_customer_name(
|
||
session,
|
||
*,
|
||
customer: Customer | None,
|
||
customer_id: str | None,
|
||
call_id: str,
|
||
final_name: str | None,
|
||
resolved_at: str,
|
||
) -> str | None:
|
||
canonical_name = _normalize_name_candidate(final_name)
|
||
if not canonical_name:
|
||
return None
|
||
if customer is None and customer_id:
|
||
customer = session.execute(
|
||
select(Customer).where(Customer.customer_id == customer_id)
|
||
).scalar_one_or_none()
|
||
if customer is not None:
|
||
customer.display_name = canonical_name
|
||
identities = session.execute(
|
||
select(CustomerExternalIdentity).where(
|
||
CustomerExternalIdentity.customer_id == (customer.customer_id if customer else customer_id),
|
||
CustomerExternalIdentity.channel == "voice",
|
||
)
|
||
).scalars().all()
|
||
for identity in identities:
|
||
identity.display_name_snapshot = canonical_name
|
||
identity.updated_at = resolved_at
|
||
caller_number, _ = _resolve_caller_from_call(session, call_id)
|
||
if customer is not None and caller_number:
|
||
_ensure_voice_identity(
|
||
session,
|
||
customer_id=customer.customer_id,
|
||
caller_number=caller_number,
|
||
caller_name=canonical_name,
|
||
now=resolved_at,
|
||
)
|
||
return canonical_name
|
||
|
||
|
||
def _voice_downstream_name_update(
|
||
*,
|
||
language: str,
|
||
transcript_text: str,
|
||
transcript_window: list[VoiceTranscriptSegmentRow],
|
||
current_status: str,
|
||
current_name: str | None,
|
||
current_source: str,
|
||
current_resolved_at: str | None,
|
||
now: str,
|
||
config,
|
||
) -> dict[str, Any]:
|
||
status = str(current_status or "name_not_obtained").strip() or "name_not_obtained"
|
||
name_value = _normalize_name_candidate(current_name) or (str(current_name or "").strip() or None)
|
||
source = str(current_source or "none").strip() or "none"
|
||
resolved_at = str(current_resolved_at or "").strip() or None
|
||
action = "ignored"
|
||
service_topic = _voice_has_service_topic(transcript_text)
|
||
|
||
explicit_candidate = _voice_explicit_name_candidate(transcript_text)
|
||
candidate, needs_followup = _extract_name_candidate(transcript_text, language)
|
||
candidate = explicit_candidate or candidate
|
||
candidate_key = _voice_text_key(candidate)
|
||
current_key = _voice_text_key(name_value)
|
||
uncertain_behavior = config.downstream.uncertain_name_behavior
|
||
|
||
if status == "name_followup_required" and uncertain_behavior == "discard_and_collect":
|
||
status = "name_not_obtained"
|
||
name_value = None
|
||
source = "none"
|
||
resolved_at = None
|
||
|
||
if status == "name_followup_required":
|
||
if uncertain_behavior == "finalize_immediately" and name_value:
|
||
action = "finalize_existing"
|
||
candidate = name_value
|
||
elif name_value and _voice_is_name_confirmation(transcript_text, name_value):
|
||
action = "confirm"
|
||
candidate = name_value
|
||
elif candidate and name_value and candidate_key and candidate_key != current_key and (explicit_candidate or not service_topic):
|
||
action = "correct"
|
||
elif candidate and (explicit_candidate or not service_topic):
|
||
action = "provide" if not name_value else "confirm"
|
||
candidate = candidate or name_value
|
||
elif status == "name_obtained":
|
||
pass
|
||
else:
|
||
if explicit_candidate:
|
||
action = "provide"
|
||
elif candidate and not needs_followup and not service_topic:
|
||
action = "provide"
|
||
|
||
finalizable = False
|
||
if action == "confirm":
|
||
finalizable = config.downstream.finalize_on_confirmation and bool(candidate or name_value)
|
||
elif action in {"correct", "provide"}:
|
||
finalizable = config.downstream.finalize_on_explicit_name and bool(candidate or name_value)
|
||
elif action == "finalize_existing":
|
||
finalizable = bool(name_value)
|
||
|
||
if finalizable:
|
||
status = "name_obtained"
|
||
name_value = _normalize_name_candidate(candidate or name_value)
|
||
if action == "finalize_existing":
|
||
source = str(current_source or "none").strip() or "none"
|
||
else:
|
||
source = current_source if action == "confirm" and current_status == "name_obtained" else "voice_followup"
|
||
resolved_at = now
|
||
elif action in {"confirm", "correct", "provide"} and bool(candidate or name_value):
|
||
status = "name_followup_required"
|
||
name_value = _normalize_name_candidate(candidate or name_value)
|
||
if action == "confirm":
|
||
source = str(current_source or "none").strip() or "none"
|
||
resolved_at = resolved_at or now
|
||
else:
|
||
source = "voice_followup"
|
||
resolved_at = now
|
||
|
||
inline_followup = (
|
||
config.downstream.missing_name_behavior == "ask_inline_once"
|
||
and status == "name_not_obtained"
|
||
and action == "ignored"
|
||
and not _voice_name_followup_asked(transcript_window, language, config)
|
||
and not _voice_is_low_signal_caller_text(transcript_text)
|
||
and not service_topic
|
||
)
|
||
return {
|
||
"status": status,
|
||
"value": name_value,
|
||
"source": source,
|
||
"resolved_at": resolved_at,
|
||
"action": action,
|
||
"finalizable": finalizable,
|
||
"inline_followup": inline_followup,
|
||
}
|
||
|
||
|
||
def _voice_recent_segments(
|
||
session,
|
||
*,
|
||
voice_session_id: str,
|
||
limit: int,
|
||
) -> list[VoiceTranscriptSegmentRow]:
|
||
rows = session.execute(
|
||
select(VoiceTranscriptSegmentRow)
|
||
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
|
||
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
|
||
.order_by(VoiceTranscriptSegmentRow.id.desc())
|
||
.limit(max(limit, 1))
|
||
).scalars().all()
|
||
return list(reversed(rows))
|
||
|
||
|
||
def _voice_text_key(text: str | None) -> str:
|
||
compact = re.sub(r"[^\w\s]+", " ", str(text or "").lower(), flags=re.UNICODE)
|
||
return re.sub(r"\s+", " ", compact).strip()
|
||
|
||
|
||
def _voice_recent_caller_texts(
|
||
transcript_window: list[VoiceTranscriptSegmentRow],
|
||
*,
|
||
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:
|
||
return True
|
||
low_signal_phrases = {
|
||
"алло",
|
||
"ага",
|
||
"да",
|
||
"добрый день",
|
||
"здравствуйте",
|
||
"ладно",
|
||
"неа",
|
||
"нет",
|
||
"ой",
|
||
"ок",
|
||
"понял",
|
||
"поняла",
|
||
"привет",
|
||
"слышу",
|
||
"слышно",
|
||
"угу",
|
||
"хорошо",
|
||
"ясно",
|
||
}
|
||
return normalized in low_signal_phrases
|
||
|
||
|
||
def _voice_is_hearing_check_caller_text(text: str | None) -> bool:
|
||
normalized = _voice_text_key(text)
|
||
if not normalized:
|
||
return False
|
||
markers = (
|
||
"алло ты меня слышишь",
|
||
"вы меня слышите",
|
||
"меня слышно",
|
||
"меня слышишь",
|
||
"ты меня слышишь",
|
||
"слышишь меня",
|
||
)
|
||
return any(marker in normalized for marker in markers)
|
||
|
||
|
||
def _voice_is_confused_caller_text(text: str | None) -> bool:
|
||
normalized = _voice_text_key(text)
|
||
confusion_markers = (
|
||
"не понял",
|
||
"не поняла",
|
||
"не понимаю",
|
||
"неясно",
|
||
"о каком",
|
||
"каком запросе",
|
||
"повторите",
|
||
"что именно",
|
||
"что вы имеете",
|
||
)
|
||
return any(marker in normalized for marker in confusion_markers)
|
||
|
||
|
||
def _voice_recent_clarification_count(transcript_window: list[VoiceTranscriptSegmentRow]) -> int:
|
||
markers = (
|
||
"в двух словах",
|
||
"график работы какого",
|
||
"какая услуга",
|
||
"какой филиал",
|
||
"назовите пожалуйста",
|
||
"опишите пожалуйста",
|
||
"подскажите",
|
||
"скажите коротко",
|
||
"уточните",
|
||
"цель звонка",
|
||
"что вам нужно",
|
||
"что именно не работает",
|
||
"чтобы помочь",
|
||
)
|
||
count = 0
|
||
for segment in transcript_window:
|
||
if segment.speaker != "assistant":
|
||
continue
|
||
normalized = _voice_text_key(segment.text)
|
||
if any(marker in normalized for marker in markers):
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def _voice_repeated_assistant_reply_count(transcript_window: list[VoiceTranscriptSegmentRow]) -> int:
|
||
assistant_texts = [
|
||
_voice_text_key(segment.text)
|
||
for segment in transcript_window
|
||
if segment.speaker == "assistant" and str(segment.text or "").strip()
|
||
]
|
||
if not assistant_texts:
|
||
return 0
|
||
last_text = assistant_texts[-1]
|
||
return sum(1 for text in assistant_texts[-3:] if text == last_text)
|
||
|
||
|
||
def _voice_topic_prompt(language: str, caller_texts: list[str]) -> str | None:
|
||
context = _voice_text_key(" ".join(caller_texts))
|
||
if not context:
|
||
return None
|
||
if any(marker in context for marker in ("график", "время работы", "режим работы", "часы работы", "работаете")):
|
||
city_scope = any(
|
||
marker in context
|
||
for marker in (
|
||
"алмат",
|
||
"астан",
|
||
"в городе",
|
||
"город",
|
||
"караганд",
|
||
"кызылорд",
|
||
"павлодар",
|
||
"семей",
|
||
"тараз",
|
||
"шымкент",
|
||
)
|
||
)
|
||
if city_scope:
|
||
if language == "kz":
|
||
return "Osy qaladagy qaysy filial nemese mekenjai qyzyqtyratynyn aitnyz."
|
||
return "Подскажите, какой филиал или адрес в этом городе вас интересует?"
|
||
if language == "kz":
|
||
return "Qai filialdyn, mekendyng nemese qalanyng jumys uaqyty qyzyqtyratynyn aitnyz."
|
||
return "Подскажите, график работы какого филиала, адреса или города вас интересует?"
|
||
if any(marker in context for marker in ("статус", "заявк", "заказ", "обращени", "запрос")):
|
||
if language == "kz":
|
||
return "Otinish no'mirin nemese resimdegen telefon nomerin aitnyz."
|
||
return "Назовите, пожалуйста, номер заявки или телефон, по которому она оформлялась."
|
||
if any(marker in context for marker in ("тариф", "стоимост", "цен", "оплат", "оплата", "услуг")):
|
||
if language == "kz":
|
||
return "Qai qyzmet, tarif nemese bagasy qyzyqtyratynyn naqtylańyz."
|
||
return "Уточните, какая услуга, тариф или стоимость вас интересует."
|
||
if any(marker in context for marker in ("адрес", "филиал", "город", "офис", "отделени", "где находит")):
|
||
if language == "kz":
|
||
return "Qai filial, mekenjai nemese qala qyzyqtyratynyn aitnyz."
|
||
return "Подскажите, какой филиал, адрес или город вас интересует."
|
||
if any(marker in context for marker in ("не работает", "ошибка", "проблем", "сбой", "интернет", "связь")):
|
||
if language == "kz":
|
||
return "Bir soilemmen naqty aitynyz: ne istep turmagan?"
|
||
return "Опишите, пожалуйста, проблему одним предложением: что именно не работает?"
|
||
return None
|
||
|
||
|
||
def _voice_summary_slot_prompt(language: str, context_summary: str | dict[str, Any] | None) -> str | None:
|
||
summary = load_context_summary(context_summary)
|
||
intent = str(summary.get("active_intent") or "").strip()
|
||
facts = summary.get("confirmed_facts") if isinstance(summary.get("confirmed_facts"), dict) else {}
|
||
city = str(facts.get("city") or "").strip()
|
||
branch_hint = str(facts.get("branch_hint") or "").strip()
|
||
if intent == "schedule" and city and not branch_hint:
|
||
if language == "kz":
|
||
return f"{city} qalasy boiynsha qaysy filial nemese mekenjai qyzyqtyratynyn aitnyz."
|
||
return f"По городу {city} уточните, пожалуйста, филиал или адрес."
|
||
return None
|
||
|
||
|
||
def _voice_generic_prompt(language: str) -> str:
|
||
if language == "kz":
|
||
return "Jyldam komektesu ushin eki ush sozben ne kerek ekenin aitnyz: jumys uaqyty, otinish statusy, tarif nemese operator."
|
||
return "Чтобы помочь быстрее, скажите в двух словах, что вам нужно: график работы, статус заявки, тариф или оператор."
|
||
|
||
|
||
def _voice_has_service_topic(text: str | None) -> bool:
|
||
normalized = _voice_text_key(text)
|
||
if not normalized:
|
||
return False
|
||
service_markers = (
|
||
"график",
|
||
"время работы",
|
||
"режим работы",
|
||
"статус",
|
||
"заявк",
|
||
"заказ",
|
||
"обращен",
|
||
"запрос",
|
||
"тариф",
|
||
"стоимост",
|
||
"цен",
|
||
"оплат",
|
||
"услуг",
|
||
"адрес",
|
||
"филиал",
|
||
"город",
|
||
"офис",
|
||
"отделени",
|
||
"не работает",
|
||
"ошибк",
|
||
"проблем",
|
||
"сбой",
|
||
"связь",
|
||
"оператор",
|
||
"менеджер",
|
||
"сотрудник",
|
||
"компан",
|
||
"подключ",
|
||
"доставк",
|
||
"газ",
|
||
"счётчик",
|
||
"счетчик",
|
||
"ваучер",
|
||
"отключ",
|
||
"квитанц",
|
||
"показан",
|
||
"приложен",
|
||
"безопасн",
|
||
"техническ",
|
||
"плит",
|
||
"котл",
|
||
"труб",
|
||
"утечк",
|
||
"запах",
|
||
"абонент",
|
||
"договор",
|
||
"поверк",
|
||
"монтаж",
|
||
"счёт",
|
||
"счет",
|
||
"долг",
|
||
"задолжен",
|
||
"перерасчёт",
|
||
"перерасчет",
|
||
"регион",
|
||
"аимак",
|
||
"aimaq",
|
||
"qazaqgaz",
|
||
"казахгаз",
|
||
"есептегіш",
|
||
"төлеу",
|
||
"өтінім",
|
||
"шарт",
|
||
)
|
||
return any(marker in normalized for marker in service_markers)
|
||
|
||
|
||
def _voice_is_off_domain_request(text: str | None) -> bool:
|
||
normalized = _voice_text_key(text)
|
||
if not normalized or _voice_has_service_topic(normalized):
|
||
return False
|
||
broad_markers = (
|
||
"ядерн",
|
||
"реактор",
|
||
"кондиционер",
|
||
"компрессор",
|
||
"испарител",
|
||
"конденсатор",
|
||
"космос",
|
||
"планет",
|
||
"математ",
|
||
"теорем",
|
||
"физик",
|
||
"хими",
|
||
"биолог",
|
||
"истори",
|
||
"рецепт",
|
||
"борщ",
|
||
"салат",
|
||
"анекдот",
|
||
"стих",
|
||
"програм",
|
||
"python",
|
||
"java",
|
||
"javascript",
|
||
"погод",
|
||
"плов",
|
||
"приготов",
|
||
"кулинар",
|
||
"блюдо",
|
||
"фильм",
|
||
"кино",
|
||
"спорт",
|
||
"футбол",
|
||
"хоккей",
|
||
"теннис",
|
||
"баскетбол",
|
||
"президент",
|
||
"политик",
|
||
"выборы",
|
||
"правительств",
|
||
"курс валют",
|
||
"криптовалют",
|
||
"биткоин",
|
||
"песн",
|
||
"музык",
|
||
"танц",
|
||
"шутк",
|
||
"загадк",
|
||
"сериал",
|
||
"книга",
|
||
"стихотвор",
|
||
"чемпионат",
|
||
"ауа райы",
|
||
"аспаздық",
|
||
"кітап",
|
||
"ән айт",
|
||
)
|
||
if any(marker in normalized for marker in broad_markers):
|
||
return True
|
||
broad_openers = (
|
||
"как работает",
|
||
"как устроен",
|
||
"что такое",
|
||
"объясни",
|
||
"расскажи про",
|
||
"посоветуй",
|
||
"как приготовить",
|
||
"какая погода",
|
||
"кто президент",
|
||
"кто выиграл",
|
||
"какая команда",
|
||
"ауа райы қалай",
|
||
"әнді айт",
|
||
)
|
||
return any(normalized.startswith(prefix) for prefix in broad_openers)
|
||
|
||
|
||
def _voice_off_domain_reply(language: str) -> tuple[str, str]:
|
||
if language == "kz":
|
||
return (
|
||
"Кешіріңіз, мен тек газ қызметтері бойынша көмектесемін: төлеу, есептегіш, ваучер. Қалай көмектесе аламын?",
|
||
"AI off-topic сұрауды газ қызметтері тақырыбына шектеді.",
|
||
)
|
||
return (
|
||
"Извините, я помогаю только по вопросам газа: оплата, счётчики, ваучеры. Чем могу помочь?",
|
||
"AI отклонил off-topic вопрос и ограничил тему газоснабжением.",
|
||
)
|
||
|
||
|
||
def _voice_confusion_prompt(language: str, caller_texts: list[str]) -> str:
|
||
topic_prompt = _voice_topic_prompt(language, caller_texts)
|
||
if topic_prompt:
|
||
if language == "kz":
|
||
return f"Naqtylap alaiyn. {topic_prompt}"
|
||
return f"Подскажите точнее. {topic_prompt}"
|
||
if language == "kz":
|
||
return "Naqtylap alaiyn: jumys uaqyty, otinish statusy, tarif nemese operator degenniń birin aitnyz."
|
||
return "Подскажите точнее: график работы, статус заявки, тариф или оператор."
|
||
|
||
|
||
def _voice_loop_handoff(language: str) -> tuple[str, str, str]:
|
||
if language == "kz":
|
||
return (
|
||
"Men suraqty birneshe ret naqtylap korgendim. Sizdi kuttirmey, tiri operatorga qosamyn.",
|
||
"Voice AI birneshe naqtylau areketinen keyin suraqty anyqtay almady.",
|
||
"AI birneshe naqtylau areketinen keyin qongyraudyn maqratyn operatorga berdi.",
|
||
)
|
||
return (
|
||
"Похоже, я не смог точно уточнить вопрос в голосовом режиме. Чтобы не задерживать вас, перевожу на оператора.",
|
||
"Voice AI не смог уточнить запрос после нескольких попыток без прогресса.",
|
||
"AI не смог уточнить цель звонка после нескольких попыток и перевел клиента на оператора.",
|
||
)
|
||
|
||
|
||
def _voice_recent_timeline(
|
||
session,
|
||
*,
|
||
interaction_id: str,
|
||
limit: int = 6,
|
||
) -> list[dict[str, Any]]:
|
||
rows = session.execute(
|
||
select(InteractionTimeline)
|
||
.where(InteractionTimeline.interaction_id == interaction_id)
|
||
.order_by(InteractionTimeline.id.desc())
|
||
.limit(max(limit, 1))
|
||
).scalars().all()
|
||
result: list[dict[str, Any]] = []
|
||
for row in reversed(rows):
|
||
try:
|
||
metadata = json.loads(row.metadata_json or "{}")
|
||
except Exception:
|
||
metadata = {}
|
||
result.append({"timestamp": row.timestamp, "action": row.action, "metadata": metadata})
|
||
return result
|
||
|
||
|
||
def _append_timeline(interaction_id: str, action: str, metadata: dict[str, Any]) -> None:
|
||
app = _app()
|
||
try:
|
||
app._interaction_request(
|
||
"POST",
|
||
f"/interactions/{interaction_id}/timeline",
|
||
payload={"action": action, "metadata": metadata},
|
||
)
|
||
except Exception:
|
||
session = get_session()
|
||
try:
|
||
app._push_timeline(session, interaction_id, action, metadata)
|
||
session.commit()
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _record_voice_ai_turn(
|
||
session,
|
||
*,
|
||
ai_session_id: str,
|
||
interaction_id: str,
|
||
role: str,
|
||
source_type: str,
|
||
text: str,
|
||
payload: dict[str, Any],
|
||
model: str | None = None,
|
||
latency_ms: int | None = None,
|
||
) -> None:
|
||
session.add(
|
||
AITurnRow(
|
||
turn_id=new_id("ait"),
|
||
session_id=ai_session_id,
|
||
thread_id=None,
|
||
interaction_id=interaction_id,
|
||
role=role,
|
||
source_type=source_type,
|
||
text=text,
|
||
payload_json=json.dumps(payload, ensure_ascii=False),
|
||
model=model,
|
||
finish_reason="stop",
|
||
latency_ms=latency_ms,
|
||
created_at=utc_now_iso(),
|
||
)
|
||
)
|
||
|
||
|
||
def _ensure_voice_ai_session(
|
||
session,
|
||
*,
|
||
voice_session: VoiceAISessionRow,
|
||
interaction: Interaction,
|
||
customer_id: str | None,
|
||
language: str,
|
||
agent_profile: str,
|
||
) -> tuple[AISessionRow, bool]:
|
||
existing = None
|
||
if str(voice_session.ai_session_id or "").strip():
|
||
existing = session.execute(
|
||
select(AISessionRow).where(AISessionRow.session_id == voice_session.ai_session_id)
|
||
).scalar_one_or_none()
|
||
if (
|
||
existing
|
||
and existing.status not in {"closed", "error"}
|
||
and str(existing.agent_profile or "").strip() == str(agent_profile or "").strip()
|
||
):
|
||
existing.call_id = voice_session.call_id
|
||
existing.interaction_id = interaction.interaction_id
|
||
existing.customer_id = customer_id
|
||
existing.language = language
|
||
existing.agent_profile = agent_profile
|
||
existing.updated_at = utc_now_iso()
|
||
return existing, False
|
||
if existing and existing.status not in {"closed", "error"}:
|
||
now = utc_now_iso()
|
||
existing.status = "closed"
|
||
existing.closed_at = now
|
||
existing.updated_at = now
|
||
|
||
now = utc_now_iso()
|
||
ai_session = AISessionRow(
|
||
session_id=new_id("ais"),
|
||
channel="voice",
|
||
call_id=voice_session.call_id,
|
||
thread_id=None,
|
||
interaction_id=interaction.interaction_id,
|
||
customer_id=customer_id,
|
||
agent_profile=agent_profile,
|
||
language=language,
|
||
status="active",
|
||
summary_text="",
|
||
last_user_message_id=None,
|
||
last_ai_message_id=None,
|
||
handoff_reason=None,
|
||
created_at=now,
|
||
updated_at=now,
|
||
closed_at=None,
|
||
)
|
||
session.add(ai_session)
|
||
voice_session.ai_session_id = ai_session.session_id
|
||
return ai_session, True
|
||
|
||
|
||
def _voice_policy_mode() -> str:
|
||
return persona.voice_policy_mode()
|
||
|
||
|
||
def _voice_reply_phase(metadata: dict[str, Any] | None = None) -> str:
|
||
payload = metadata if isinstance(metadata, dict) else {}
|
||
return str(payload.get("reply_phase") or "final").strip().lower() or "final"
|
||
|
||
|
||
def _voice_v2_enabled(metadata: dict[str, Any] | None = None) -> bool:
|
||
payload = metadata if isinstance(metadata, dict) else {}
|
||
if bool(payload.get("voice_v2_enabled")):
|
||
return True
|
||
return _voice_policy_mode() in {"v2_fast_conversational", "v2_streaming_duplex"}
|
||
|
||
|
||
def _voice_ack_topic_bucket(text: str) -> str:
|
||
"""Coarse keyword heuristic used only to pick an ack phrase / early clarifying
|
||
question (see _voice_ack_kind_for_intent and _voice_early_plan) while the real,
|
||
KB-grounded decision is still in flight. This is NOT the canonical FAQ intent
|
||
(see services.shared.intents) and must never be echoed back as the final
|
||
decision's `intent` value.
|
||
"""
|
||
normalized = " ".join(str(text or "").strip().lower().split())
|
||
if not normalized:
|
||
return "unknown"
|
||
if any(token in normalized for token in ("оператор", "оператором", "человек", "менеджер", "сотрудник")):
|
||
return "operator_request"
|
||
if any(token in normalized for token in ("график", "распис", "жұмыс")):
|
||
return "schedule"
|
||
if any(token in normalized for token in ("адрес", "филиал", "офис", "мекен", "қайда")):
|
||
return "address"
|
||
if any(token in normalized for token in ("тариф", "цена", "стоимость", "баға", "сколько стоит")):
|
||
return "price"
|
||
if any(token in normalized for token in ("статус", "заявк", "заказ", "өтінім")):
|
||
return "status"
|
||
if any(token in normalized for token in ("не работает", "ошибка", "проблем", "істемей")):
|
||
return "problem"
|
||
return "unknown"
|
||
|
||
|
||
def _voice_ack_kind_for_intent(intent: str) -> str:
|
||
if intent == "operator_request":
|
||
return "handoff"
|
||
if intent in {"schedule", "address", "price", "status", "problem"}:
|
||
return "understanding"
|
||
return "generic"
|
||
|
||
|
||
def _voice_compact_reply_text(text: str, *, language: str) -> str:
|
||
normalized = " ".join(str(text or "").strip().split())
|
||
if not normalized:
|
||
return normalized
|
||
if len(normalized) <= 180:
|
||
return normalized
|
||
parts = [segment.strip() for segment in re.split(r"(?<=[.!?])\s+", normalized) if segment.strip()]
|
||
if parts:
|
||
compact = " ".join(parts[:2]).strip()
|
||
if len(compact) <= 180:
|
||
return compact
|
||
shortened = normalized[:177].rsplit(" ", 1)[0].strip()
|
||
if not shortened:
|
||
shortened = normalized[:177].strip()
|
||
ending = "…" if language == "kz" else "..."
|
||
return f"{shortened}{ending}"
|
||
|
||
|
||
def _voice_caller_context_before_current(caller_texts: list[str], transcript_text: str) -> list[str]:
|
||
if not caller_texts:
|
||
return []
|
||
current_key = _voice_text_key(transcript_text)
|
||
if current_key and _voice_text_key(caller_texts[-1]) == current_key:
|
||
return caller_texts[:-1]
|
||
return caller_texts
|
||
|
||
|
||
def _voice_service_context_texts(caller_texts: list[str]) -> list[str]:
|
||
return [text for text in caller_texts if _voice_has_service_topic(text)]
|
||
|
||
|
||
def _voice_is_midcall_greeting_reply(text: str | None) -> bool:
|
||
normalized = _voice_text_key(text)
|
||
if not normalized:
|
||
return False
|
||
greeting_markers = ("здравствуйте", "сәлеметсіз", "сәлем")
|
||
followup_markers = (
|
||
"чем помочь",
|
||
"как я могу помочь",
|
||
"коротко расскажите",
|
||
"о чем именно",
|
||
"что именно",
|
||
"что вас интересует",
|
||
"қалай көмектесе",
|
||
)
|
||
return any(marker in normalized for marker in greeting_markers) and any(
|
||
marker in normalized for marker in followup_markers
|
||
)
|
||
|
||
|
||
def _voice_contains_false_lookup_promise(text: str | None) -> bool:
|
||
normalized = _voice_text_key(text)
|
||
if not normalized:
|
||
return False
|
||
markers = (
|
||
"сейчас уточню",
|
||
"я уточню",
|
||
"уточню",
|
||
"минуточ",
|
||
"подождите",
|
||
"подождите пожалуйста",
|
||
"проверю",
|
||
"сейчас проверю",
|
||
"я проверю",
|
||
"посмотрю",
|
||
"сейчас посмотрю",
|
||
)
|
||
return any(marker in normalized for marker in markers)
|
||
|
||
|
||
def _voice_hearing_check_reply(language: str, caller_texts: list[str]) -> str:
|
||
followup = _voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language)
|
||
if language == "kz":
|
||
return f"Ia, estip turmyn. {followup}"
|
||
return f"Да, вас слышу. {followup}"
|
||
|
||
|
||
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,
|
||
) -> str:
|
||
normalized_reply = str(reply_text or "").strip()
|
||
if not normalized_reply or needs_handoff:
|
||
return normalized_reply
|
||
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
|
||
if _voice_is_hearing_check_caller_text(transcript_text):
|
||
return _voice_hearing_check_reply(language, prior_caller_texts or caller_texts)
|
||
if _voice_contains_false_lookup_promise(normalized_reply) and not kb_results:
|
||
return active_topic_prompt or _voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language)
|
||
if _voice_is_midcall_greeting_reply(normalized_reply) and any(
|
||
segment.speaker == "assistant" for segment in transcript_window
|
||
):
|
||
context_texts = prior_caller_texts if _voice_is_low_signal_caller_text(transcript_text) else caller_texts
|
||
return _voice_topic_prompt(language, context_texts) or _voice_generic_prompt(language)
|
||
if (
|
||
active_topic_prompt
|
||
and not _voice_has_service_topic(transcript_text)
|
||
and (
|
||
"тариф" in _voice_text_key(normalized_reply)
|
||
or "услуг" in _voice_text_key(normalized_reply)
|
||
or "как я могу помочь" in _voice_text_key(normalized_reply)
|
||
)
|
||
):
|
||
return active_topic_prompt
|
||
return normalized_reply
|
||
|
||
|
||
def _voice_v2_metadata(
|
||
transcript_text: str,
|
||
request_metadata: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
if not _voice_v2_enabled(request_metadata):
|
||
return {}
|
||
payload = request_metadata if isinstance(request_metadata, dict) else {}
|
||
early_intent = _voice_ack_topic_bucket(transcript_text)
|
||
metadata: dict[str, Any] = {
|
||
"voice_v2_enabled": True,
|
||
"early_intent": early_intent,
|
||
"ack_kind": _voice_ack_kind_for_intent(early_intent),
|
||
}
|
||
for key in ("response_plan_id", "playback_generation", "partial_transcript"):
|
||
value = payload.get(key)
|
||
if value not in {None, ""}:
|
||
metadata[key] = value
|
||
return metadata
|
||
|
||
|
||
def _voice_early_plan(
|
||
*,
|
||
language: str,
|
||
transcript_text: str,
|
||
context_summary: str | dict[str, Any] | None = None,
|
||
request_metadata: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
v2_metadata = _voice_v2_metadata(transcript_text, request_metadata)
|
||
payload = request_metadata if isinstance(request_metadata, dict) else {}
|
||
early_intent = str(payload.get("early_intent") or v2_metadata.get("early_intent") or "").strip()
|
||
lower_text = str(transcript_text or "").strip().lower()
|
||
if _app()._looks_like_human_request(lower_text) or _app()._is_sensitive_request(lower_text):
|
||
return {
|
||
"language": language,
|
||
"intent": "handoff_request",
|
||
"reply_text": _voice_handoff_reply(language),
|
||
"confidence": 0.25,
|
||
"needs_handoff": True,
|
||
"handoff_reason": "Запрос требует участия живого оператора.",
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": "AI заранее распознал необходимость подключить оператора.",
|
||
"model": "voice_early_plan",
|
||
"latency_ms": 1,
|
||
"metadata": {
|
||
**v2_metadata,
|
||
"reply_phase": "early_plan",
|
||
},
|
||
}
|
||
if _voice_is_off_domain_request(transcript_text):
|
||
reply_text, summary_text = _voice_off_domain_reply(language)
|
||
return {
|
||
"language": language,
|
||
"intent": "clarification",
|
||
"reply_text": _voice_compact_reply_text(reply_text, language=language),
|
||
"confidence": 0.6,
|
||
"needs_handoff": False,
|
||
"handoff_reason": None,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": summary_text,
|
||
"model": "voice_early_plan_off_domain",
|
||
"latency_ms": 1,
|
||
"metadata": {
|
||
**v2_metadata,
|
||
"reply_phase": "early_plan",
|
||
},
|
||
}
|
||
if early_intent in {"schedule", "address", "price", "status", "problem", "operator_request"}:
|
||
reply_text = _voice_summary_slot_prompt(language, context_summary) or _voice_topic_prompt(language, [transcript_text])
|
||
if early_intent == "operator_request":
|
||
reply_text = _voice_handoff_reply(language)
|
||
if not reply_text and language == "kz":
|
||
if early_intent == "schedule":
|
||
reply_text = "Qai filialdyn, mekenjaidyn nemese qalanyng jumys uaqyty qyzyqtyratynyn aitnyz."
|
||
elif early_intent == "address":
|
||
reply_text = "Qai filial, mekenjai nemese qala qyzyqtyratynyn aitnyz."
|
||
elif early_intent == "price":
|
||
reply_text = "Qai qyzmet, tarif nemese bagasy qyzyqtyratynyn naqtylanyz."
|
||
elif early_intent == "status":
|
||
reply_text = "Otinish nomirin nemese resimdegen telefon nomirin aitnyz."
|
||
elif early_intent == "problem":
|
||
reply_text = "Bir soilemmen naqty aitynyz: ne istep turmagan?"
|
||
if not reply_text:
|
||
if early_intent == "schedule":
|
||
reply_text = "\u041f\u043e\u0434\u0441\u043a\u0430\u0436\u0438\u0442\u0435, \u0433\u0440\u0430\u0444\u0438\u043a \u0440\u0430\u0431\u043e\u0442\u044b \u043a\u0430\u043a\u043e\u0433\u043e \u0444\u0438\u043b\u0438\u0430\u043b\u0430, \u0430\u0434\u0440\u0435\u0441\u0430 \u0438\u043b\u0438 \u0433\u043e\u0440\u043e\u0434\u0430 \u0432\u0430\u0441 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u0435\u0442?"
|
||
elif early_intent == "address":
|
||
reply_text = "\u041f\u043e\u0434\u0441\u043a\u0430\u0436\u0438\u0442\u0435, \u043a\u0430\u043a\u043e\u0439 \u0444\u0438\u043b\u0438\u0430\u043b, \u0430\u0434\u0440\u0435\u0441 \u0438\u043b\u0438 \u0433\u043e\u0440\u043e\u0434 \u0432\u0430\u0441 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u0435\u0442."
|
||
elif early_intent == "price":
|
||
reply_text = "\u0423\u0442\u043e\u0447\u043d\u0438\u0442\u0435, \u043a\u0430\u043a\u0430\u044f \u0443\u0441\u043b\u0443\u0433\u0430, \u0442\u0430\u0440\u0438\u0444 \u0438\u043b\u0438 \u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0432\u0430\u0441 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u0435\u0442."
|
||
elif early_intent == "status":
|
||
reply_text = "\u041d\u0430\u0437\u043e\u0432\u0438\u0442\u0435, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043d\u043e\u043c\u0435\u0440 \u0437\u0430\u044f\u0432\u043a\u0438 \u0438\u043b\u0438 \u0442\u0435\u043b\u0435\u0444\u043e\u043d, \u043f\u043e \u043a\u043e\u0442\u043e\u0440\u043e\u043c\u0443 \u043e\u043d\u0430 \u043e\u0444\u043e\u0440\u043c\u043b\u044f\u043b\u0430\u0441\u044c."
|
||
elif early_intent == "problem":
|
||
reply_text = "\u041e\u043f\u0438\u0448\u0438\u0442\u0435, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443 \u043e\u0434\u043d\u0438\u043c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043c: \u0447\u0442\u043e \u0438\u043c\u0435\u043d\u043d\u043e \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442?"
|
||
if reply_text:
|
||
return {
|
||
"language": language,
|
||
"intent": "handoff_request" if early_intent == "operator_request" else "clarification",
|
||
"reply_text": _voice_compact_reply_text(reply_text, language=language),
|
||
"confidence": 0.62,
|
||
"needs_handoff": early_intent == "operator_request",
|
||
"handoff_reason": (
|
||
"Запрос требует участия живого оператора."
|
||
if early_intent == "operator_request"
|
||
else None
|
||
),
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": "Early domain plan is prepared.",
|
||
"model": "voice_early_plan_domain",
|
||
"latency_ms": 1,
|
||
"metadata": {
|
||
**v2_metadata,
|
||
"reply_phase": "early_plan",
|
||
"early_intent": early_intent,
|
||
},
|
||
}
|
||
return {
|
||
"language": language,
|
||
"intent": "clarification",
|
||
"reply_text": "",
|
||
"confidence": 0.45,
|
||
"needs_handoff": False,
|
||
"handoff_reason": None,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": "Early plan is prepared.",
|
||
"model": "voice_early_plan",
|
||
"latency_ms": 1,
|
||
"metadata": {
|
||
**v2_metadata,
|
||
"reply_phase": "early_plan",
|
||
},
|
||
}
|
||
|
||
|
||
def _voice_llm_prompt_messages(
|
||
*,
|
||
language: str,
|
||
customer: Customer | None,
|
||
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,
|
||
operator_config: Any | None = None,
|
||
) -> list[dict[str, str]]:
|
||
app = _app()
|
||
history = [
|
||
{
|
||
"speaker": segment.speaker,
|
||
"text": segment.text,
|
||
"sequence_no": segment.sequence_no,
|
||
"source_type": segment.source_type,
|
||
"interrupted": bool(segment.barge_in_interrupted),
|
||
"created_at": segment.created_at,
|
||
}
|
||
for segment in transcript_window[-12:]
|
||
]
|
||
kb_context = [
|
||
{
|
||
"article_id": article.article_id,
|
||
"title": article.title,
|
||
"snippet": app._article_snippet(article, limit=240),
|
||
"intent_code": getattr(article, "intent_code", None),
|
||
}
|
||
for article in kb_results[:3]
|
||
]
|
||
payload = {
|
||
"customer": {
|
||
"customer_id": customer.customer_id if customer else interaction.customer_id,
|
||
"display_name": customer.display_name if customer else None,
|
||
"name_status": name_status,
|
||
"name_value": name_value,
|
||
"channel": "voice",
|
||
},
|
||
"interaction": {
|
||
"interaction_id": interaction.interaction_id,
|
||
"status": interaction.status,
|
||
"queue_id": interaction.queue_id,
|
||
"subject": interaction.subject,
|
||
},
|
||
"voice_turn": {
|
||
"last_user_text": transcript_text,
|
||
"language": language,
|
||
},
|
||
"conversation_summary": conversation_summary_text,
|
||
"kb_results": kb_context,
|
||
"history": history,
|
||
}
|
||
system_prompt = persona.operator_system_prompt(
|
||
language=language,
|
||
channel_label="Voice",
|
||
is_voice=True,
|
||
config=operator_config,
|
||
)
|
||
system_prompt += (
|
||
" Do not open `reply_text` with a greeting or by addressing the customer by name "
|
||
"(e.g. do not write 'Здравствуйте, <имя>' or start with '<имя>,'). The system inserts "
|
||
"the customer's name into the spoken reply separately, so naming them yourself would "
|
||
"make it get said twice."
|
||
)
|
||
if name_status in ("name_not_obtained", "name_followup_required"):
|
||
system_prompt += (
|
||
" The user's name is not yet obtained. If the user explicitly provided their name in this turn, "
|
||
"extract it into the `extracted_name` JSON field. Extract ONLY the actual name, not surrounding words "
|
||
"like commands or verbs (e.g. from 'Меня зовут Ернор. Перезаписать.' extract only 'Ернор'). "
|
||
"If they did not provide a name or just stated a problem "
|
||
"(e.g., 'У меня не работает интернет'), set `extracted_name` to null and politely ask for their name "
|
||
"in your `reply_text` before assisting."
|
||
)
|
||
elif name_status == "name_obtained" and name_value:
|
||
system_prompt += (
|
||
f" The customer's name is currently recorded as '{name_value}'. "
|
||
"If the user explicitly corrects their name in this turn (e.g. 'Нет, меня зовут X' or 'Моё имя Y'), "
|
||
"extract the corrected name into `extracted_name`. Extract ONLY the actual name. "
|
||
"Otherwise set `extracted_name` to null."
|
||
)
|
||
|
||
return [
|
||
{
|
||
"role": "system",
|
||
"content": system_prompt,
|
||
},
|
||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||
]
|
||
|
||
|
||
def _voice_llm_decision(
|
||
*,
|
||
language: str,
|
||
customer: Customer | None,
|
||
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,
|
||
operator_config: Any | None = None,
|
||
) -> dict[str, Any] | None:
|
||
app = _app()
|
||
if _voice_policy_mode() not in {"llm_guarded", "v2_fast_conversational", "v2_streaming_duplex"}:
|
||
return None
|
||
if app._ai_provider() != "openai_compatible":
|
||
return None
|
||
try:
|
||
raw = app._request_structured_model_decision(
|
||
_voice_llm_prompt_messages(
|
||
language=language,
|
||
customer=customer,
|
||
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,
|
||
operator_config=operator_config,
|
||
),
|
||
timeout_seconds=app._ai_voice_timeout_seconds(),
|
||
)
|
||
except Exception:
|
||
return None
|
||
decision = app._sanitize_decision(
|
||
raw,
|
||
fallback_language=language,
|
||
known_topic_codes=[
|
||
code for article in kb_results if (code := getattr(article, "intent_code", None))
|
||
],
|
||
)
|
||
if not str(decision.get("reply_text") or "").strip():
|
||
decision["reply_text"] = _voice_generic_prompt(language)
|
||
if decision.get("needs_handoff") and not decision.get("handoff_reason"):
|
||
decision["handoff_reason"] = "Требуется участие живого оператора."
|
||
if decision.get("needs_handoff") and not str(decision.get("reply_text") or "").strip():
|
||
decision["reply_text"] = _voice_handoff_reply(language)
|
||
return {
|
||
"language": decision["language"],
|
||
"intent": decision["intent"],
|
||
"reply_text": str(decision["reply_text"] or "").strip(),
|
||
"confidence": float(decision["confidence"] or 0.0),
|
||
"needs_handoff": bool(decision["needs_handoff"]),
|
||
"handoff_reason": decision["handoff_reason"],
|
||
"case_action": decision["case_action"],
|
||
"kb_refs": decision["kb_refs"],
|
||
"summary_text": (
|
||
str(decision.get("reply_text") or "").strip()
|
||
or str(decision.get("handoff_reason") or "").strip()
|
||
or "AI подготовил ответ операторским стилем."
|
||
),
|
||
"model": decision["_model"],
|
||
"latency_ms": decision["_latency_ms"],
|
||
}
|
||
|
||
|
||
def _voice_decision(
|
||
*,
|
||
language: str,
|
||
customer: Customer | None,
|
||
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,
|
||
customer_name_status: str | None = None,
|
||
request_metadata: dict[str, Any] | None = None,
|
||
operator_config: Any | None = None,
|
||
) -> dict[str, Any]:
|
||
app = _app()
|
||
normalized = str(transcript_text or "").strip()
|
||
lower_text = normalized.lower()
|
||
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(
|
||
language=language,
|
||
transcript_text=transcript_text,
|
||
context_summary=context_summary,
|
||
request_metadata=request_metadata,
|
||
)
|
||
|
||
if persona.is_identity_request(normalized):
|
||
decision = {
|
||
"language": language,
|
||
"intent": "identity_question",
|
||
"reply_text": persona.identity_reply(language, operator_config),
|
||
"confidence": 0.98,
|
||
"needs_handoff": False,
|
||
"handoff_reason": None,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": "AI ответил на вопрос о своей личности.",
|
||
"model": "operator_identity_policy",
|
||
"latency_ms": 1,
|
||
}
|
||
if v2_metadata:
|
||
decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
|
||
decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
|
||
return decision
|
||
|
||
if app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text):
|
||
return {
|
||
"language": language,
|
||
"intent": "handoff_request",
|
||
"reply_text": _voice_handoff_reply(language),
|
||
"confidence": 0.25,
|
||
"needs_handoff": True,
|
||
"handoff_reason": "Запрос требует участия живого оператора.",
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": "AI собрал первичный контекст и запросил живого оператора.",
|
||
"model": model,
|
||
"latency_ms": 1,
|
||
}
|
||
|
||
if _voice_is_hearing_check_caller_text(normalized):
|
||
prior_caller_texts = _voice_caller_context_before_current(caller_texts, transcript_text)
|
||
decision = {
|
||
"language": language,
|
||
"intent": "clarification",
|
||
"reply_text": _voice_hearing_check_reply(language, prior_caller_texts or caller_texts),
|
||
"confidence": 0.66,
|
||
"needs_handoff": False,
|
||
"handoff_reason": None,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": "AI подтвердил, что слышит клиента, и продолжил активный сценарий без сброса диалога.",
|
||
"model": "voice_policy_hearing_check",
|
||
"latency_ms": 1,
|
||
}
|
||
if v2_metadata:
|
||
decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
|
||
decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
|
||
return decision
|
||
|
||
clarification_count = _voice_recent_clarification_count(transcript_window)
|
||
repeated_reply_count = _voice_repeated_assistant_reply_count(transcript_window)
|
||
recent_caller_window = caller_texts[-3:]
|
||
low_signal_count = sum(1 for text in recent_caller_window if _voice_is_low_signal_caller_text(text))
|
||
caller_confused = _voice_is_confused_caller_text(normalized)
|
||
if clarification_count >= 4 or (
|
||
clarification_count >= 3 and (caller_confused or low_signal_count >= 2 or repeated_reply_count >= 2)
|
||
):
|
||
reply_text, handoff_reason, summary_text = _voice_loop_handoff(language)
|
||
return {
|
||
"language": language,
|
||
"intent": "handoff_request",
|
||
"reply_text": reply_text,
|
||
"confidence": 0.34,
|
||
"needs_handoff": True,
|
||
"handoff_reason": handoff_reason,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": summary_text,
|
||
"model": model,
|
||
"latency_ms": 1,
|
||
}
|
||
|
||
if _voice_is_off_domain_request(normalized):
|
||
reply_text, summary_text = _voice_off_domain_reply(language)
|
||
decision = {
|
||
"language": language,
|
||
"intent": "clarification",
|
||
"reply_text": reply_text,
|
||
"confidence": 0.58,
|
||
"needs_handoff": False,
|
||
"handoff_reason": None,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": summary_text,
|
||
"model": "voice_policy_off_domain",
|
||
"latency_ms": 1,
|
||
}
|
||
if v2_metadata:
|
||
decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
|
||
decision["metadata"] = v2_metadata
|
||
return decision
|
||
|
||
summary_slot_prompt = _voice_summary_slot_prompt(language, context_summary)
|
||
if not kb_results and summary_slot_prompt:
|
||
decision = {
|
||
"language": language,
|
||
"intent": "clarification",
|
||
"reply_text": summary_slot_prompt,
|
||
"confidence": 0.72,
|
||
"needs_handoff": False,
|
||
"handoff_reason": None,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": "AI продолжил активный сценарий из conversation summary и уточнил недостающий слот.",
|
||
"model": "voice_policy_context_summary",
|
||
"latency_ms": 1,
|
||
}
|
||
if v2_metadata:
|
||
decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
|
||
decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
|
||
return decision
|
||
|
||
llm_decision = _voice_llm_decision(
|
||
language=language,
|
||
customer=customer,
|
||
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,
|
||
operator_config=operator_config,
|
||
)
|
||
if llm_decision is not None:
|
||
if v2_metadata:
|
||
llm_decision["reply_text"] = _voice_compact_reply_text(
|
||
str(llm_decision.get("reply_text") or ""),
|
||
language=language,
|
||
)
|
||
llm_decision["metadata"] = {
|
||
**(llm_decision.get("metadata") or {}),
|
||
**v2_metadata,
|
||
"reply_phase": "final",
|
||
}
|
||
return llm_decision
|
||
|
||
if kb_results:
|
||
article = kb_results[0]
|
||
snippet = app._article_snippet(article, limit=220)
|
||
decision = {
|
||
"language": language,
|
||
"intent": "kb_answer",
|
||
"reply_text": (
|
||
f"Қысқаша айтайын: {snippet}"
|
||
if language == "kz"
|
||
else f"Коротко подскажу: {snippet}"
|
||
),
|
||
"confidence": 0.78,
|
||
"needs_handoff": False,
|
||
"handoff_reason": None,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [article.article_id],
|
||
"summary_text": "AI дал ответ по доступному контексту.",
|
||
"model": "voice_policy_fallback",
|
||
"latency_ms": 1,
|
||
}
|
||
if v2_metadata:
|
||
decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
|
||
decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
|
||
return decision
|
||
|
||
reply_text = _voice_confusion_prompt(language, caller_texts) if caller_confused else (
|
||
_voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language)
|
||
)
|
||
decision = {
|
||
"language": language,
|
||
"intent": "clarification",
|
||
"reply_text": reply_text,
|
||
"confidence": 0.62,
|
||
"needs_handoff": False,
|
||
"handoff_reason": None,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [],
|
||
"summary_text": "AI уточняет цель звонка и собирает контекст.",
|
||
"model": "voice_policy_fallback",
|
||
"latency_ms": 1,
|
||
}
|
||
if v2_metadata:
|
||
decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
|
||
decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
|
||
return decision
|
||
|
||
|
||
def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStartOut:
|
||
app = _app()
|
||
session = get_session()
|
||
try:
|
||
voice_session = session.execute(
|
||
select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id)
|
||
).scalar_one_or_none()
|
||
if voice_session is None:
|
||
raise HTTPException(status_code=404, detail="Voice AI session not found")
|
||
interaction = session.execute(
|
||
select(Interaction).where(Interaction.interaction_id == payload.interaction_id)
|
||
).scalar_one_or_none()
|
||
if interaction is None:
|
||
raise HTTPException(status_code=404, detail="Interaction not found")
|
||
|
||
metadata = payload.metadata if isinstance(payload.metadata, dict) else {}
|
||
language = _voice_start_language(
|
||
payload.language_hint,
|
||
metadata,
|
||
voice_session.voice_start_language or voice_session.language,
|
||
)
|
||
config = load_effective_voice_name_collection_config(session)
|
||
operator_config = app._load_ai_operator_config(session)
|
||
customer_id = payload.customer_id or _resolve_or_create_voice_customer_id(
|
||
session,
|
||
interaction=interaction,
|
||
call_id=payload.call_id,
|
||
)
|
||
customer = None
|
||
if customer_id:
|
||
customer = session.execute(
|
||
select(Customer).where(Customer.customer_id == customer_id)
|
||
).scalar_one_or_none()
|
||
ai_session, created = _ensure_voice_ai_session(
|
||
session,
|
||
voice_session=voice_session,
|
||
interaction=interaction,
|
||
customer_id=customer_id,
|
||
language=language,
|
||
agent_profile=payload.agent_profile or voice_session.agent_profile,
|
||
)
|
||
now = utc_now_iso()
|
||
voice_session.customer_id = customer_id
|
||
voice_session.language = language
|
||
voice_session.status = "greeting"
|
||
voice_session.updated_at = now
|
||
if customer and not interaction.customer_id:
|
||
interaction.customer_id = customer.customer_id
|
||
interaction.updated_at = now
|
||
if _voice_start_stage(payload.agent_profile or voice_session.agent_profile, metadata):
|
||
caller_number, caller_name = _resolve_caller_from_call(session, payload.call_id)
|
||
downstream_queue_code, downstream_queue_id = _voice_start_downstream_queue(metadata, voice_session)
|
||
trusted_name = customer.display_name if _display_name_looks_trusted(customer, caller_number, caller_name) else None
|
||
|
||
def _complete_voice_start_handoff(
|
||
*,
|
||
status: str,
|
||
value: str | None,
|
||
source: str,
|
||
summary_text: str,
|
||
) -> VoiceAIStartOut:
|
||
result = VoiceStartResult(
|
||
language=language,
|
||
customer_id=customer_id,
|
||
customer_name_status=status,
|
||
customer_name_value=value,
|
||
customer_name_source=source,
|
||
downstream_queue_id=downstream_queue_id,
|
||
downstream_queue_code=downstream_queue_code,
|
||
resolved_at=now,
|
||
)
|
||
_persist_voice_start_result(
|
||
session,
|
||
voice_session=voice_session,
|
||
interaction=interaction,
|
||
result=result,
|
||
)
|
||
voice_session.status = "handoff_requested"
|
||
ai_session.status = "handoff_required"
|
||
ai_session.summary_text = summary_text
|
||
ai_session.handoff_reason = "voice_start_completed"
|
||
ai_session.updated_at = now
|
||
session.commit()
|
||
if created:
|
||
_append_timeline(
|
||
interaction.interaction_id,
|
||
"ai.session_started",
|
||
{
|
||
"call_id": payload.call_id,
|
||
"voice_session_id": voice_session.session_id,
|
||
"ai_session_id": ai_session.session_id,
|
||
"language": language,
|
||
},
|
||
)
|
||
response_metadata = _voice_start_result_metadata(result)
|
||
return VoiceAIStartOut(
|
||
session_id=ai_session.session_id,
|
||
language=language,
|
||
greeting_text="",
|
||
disclosure_required=False,
|
||
needs_handoff=True,
|
||
handoff_reason="voice_start_completed",
|
||
summary_text=summary_text,
|
||
start_result=result,
|
||
metadata=response_metadata,
|
||
)
|
||
|
||
if not config.enabled:
|
||
return _complete_voice_start_handoff(
|
||
status="name_not_obtained",
|
||
value=None,
|
||
source="none",
|
||
summary_text="Voice start name collection is disabled and handed off without a name.",
|
||
)
|
||
|
||
if trusted_name and config.start.known_customer_behavior == "trust_and_handoff":
|
||
return _complete_voice_start_handoff(
|
||
status="name_obtained",
|
||
value=trusted_name,
|
||
source="known_customer",
|
||
summary_text="Voice start completed using a trusted known customer name.",
|
||
)
|
||
|
||
if trusted_name and config.start.known_customer_behavior == "confirm_in_downstream":
|
||
return _complete_voice_start_handoff(
|
||
status="name_followup_required",
|
||
value=trusted_name,
|
||
source="known_customer",
|
||
summary_text="Voice start handed off a known customer name for downstream confirmation.",
|
||
)
|
||
|
||
should_ask_on_start = False
|
||
if config.start.ask_name_on_start:
|
||
if trusted_name:
|
||
should_ask_on_start = config.start.known_customer_behavior == "ask_on_start"
|
||
else:
|
||
should_ask_on_start = config.start.unknown_customer_behavior == "ask_on_start"
|
||
|
||
if not should_ask_on_start:
|
||
return _complete_voice_start_handoff(
|
||
status="name_not_obtained",
|
||
value=None,
|
||
source="none",
|
||
summary_text="Voice start skipped name collection and handed off without a name.",
|
||
)
|
||
|
||
voice_session.status = "greeting"
|
||
ai_session.status = "active"
|
||
ai_session.summary_text = ""
|
||
ai_session.handoff_reason = None
|
||
ai_session.updated_at = now
|
||
session.commit()
|
||
if created:
|
||
_append_timeline(
|
||
interaction.interaction_id,
|
||
"ai.session_started",
|
||
{
|
||
"call_id": payload.call_id,
|
||
"voice_session_id": voice_session.session_id,
|
||
"ai_session_id": ai_session.session_id,
|
||
"language": language,
|
||
},
|
||
)
|
||
return VoiceAIStartOut(
|
||
session_id=ai_session.session_id,
|
||
language=language,
|
||
greeting_text=_voice_start_name_prompt(language, config),
|
||
disclosure_required=voice_session.disclosure_played_at is None,
|
||
metadata={
|
||
"voice_start_language": language,
|
||
"downstream_queue_code": downstream_queue_code,
|
||
"downstream_queue_id": downstream_queue_id,
|
||
},
|
||
)
|
||
session.commit()
|
||
if created:
|
||
_append_timeline(
|
||
interaction.interaction_id,
|
||
"ai.session_started",
|
||
{
|
||
"call_id": payload.call_id,
|
||
"voice_session_id": voice_session.session_id,
|
||
"ai_session_id": ai_session.session_id,
|
||
"language": language,
|
||
},
|
||
)
|
||
name_status = str(voice_session.customer_name_status or "name_not_obtained").strip() or "name_not_obtained"
|
||
name_value = _normalize_name_candidate(voice_session.customer_name_value) or (
|
||
str(voice_session.customer_name_value or "").strip() or None
|
||
)
|
||
name_source = str(voice_session.customer_name_source or "none").strip() or "none"
|
||
name_resolved_at = str(voice_session.customer_name_resolved_at or "").strip() or None
|
||
if name_status == "name_not_obtained" and not name_value:
|
||
caller_number, caller_name = _resolve_caller_from_call(session, payload.call_id)
|
||
if _display_name_looks_trusted(customer, caller_number, caller_name):
|
||
name_status = "name_obtained"
|
||
name_value = customer.display_name
|
||
name_source = "known_customer"
|
||
name_resolved_at = now
|
||
_persist_voice_name_state(
|
||
session,
|
||
voice_session=voice_session,
|
||
status=name_status,
|
||
value=name_value,
|
||
source=name_source,
|
||
resolved_at=name_resolved_at,
|
||
)
|
||
if name_status == "name_obtained" and name_value:
|
||
finalized_name = _finalize_customer_name(
|
||
session,
|
||
customer=customer,
|
||
customer_id=customer_id,
|
||
call_id=payload.call_id,
|
||
final_name=name_value,
|
||
resolved_at=now,
|
||
)
|
||
if finalized_name:
|
||
name_value = finalized_name
|
||
name_resolved_at = now
|
||
_persist_voice_name_state(
|
||
session,
|
||
voice_session=voice_session,
|
||
status=name_status,
|
||
value=name_value,
|
||
source=name_source,
|
||
resolved_at=name_resolved_at,
|
||
)
|
||
greeting_text = _voice_greeting(language, operator_config)
|
||
if name_status == "name_followup_required":
|
||
if config.downstream.uncertain_name_behavior == "finalize_immediately" and name_value:
|
||
finalized_name = _finalize_customer_name(
|
||
session,
|
||
customer=customer,
|
||
customer_id=customer_id,
|
||
call_id=payload.call_id,
|
||
final_name=name_value,
|
||
resolved_at=now,
|
||
)
|
||
if finalized_name:
|
||
name_status = "name_obtained"
|
||
name_value = finalized_name
|
||
name_resolved_at = now
|
||
_persist_voice_name_state(
|
||
session,
|
||
voice_session=voice_session,
|
||
status=name_status,
|
||
value=name_value,
|
||
source=name_source,
|
||
resolved_at=name_resolved_at,
|
||
)
|
||
elif config.downstream.uncertain_name_behavior == "discard_and_collect":
|
||
name_status = "name_not_obtained"
|
||
name_value = None
|
||
name_source = "none"
|
||
name_resolved_at = None
|
||
_persist_voice_name_state(
|
||
session,
|
||
voice_session=voice_session,
|
||
status=name_status,
|
||
value=name_value,
|
||
source=name_source,
|
||
resolved_at=name_resolved_at,
|
||
)
|
||
if name_status == "name_obtained" and name_value:
|
||
greeting_text = _voice_personalized_greeting(language, name_value, config)
|
||
elif name_status == "name_followup_required":
|
||
greeting_text = _voice_name_confirmation_greeting(language, name_value, config)
|
||
else:
|
||
if config.enabled and config.downstream.missing_name_behavior == "ask_inline_once":
|
||
inline_prompt = _voice_inline_name_followup(language, config)
|
||
if inline_prompt and inline_prompt not in greeting_text:
|
||
greeting_text = f"{greeting_text} {inline_prompt}"
|
||
session.commit()
|
||
return VoiceAIStartOut(
|
||
session_id=ai_session.session_id,
|
||
language=language,
|
||
greeting_text=greeting_text,
|
||
disclosure_required=voice_session.disclosure_played_at is None,
|
||
metadata=_voice_name_metadata(
|
||
language=voice_session.voice_start_language or language,
|
||
customer_id=customer_id,
|
||
status=name_status,
|
||
value=name_value,
|
||
source=name_source,
|
||
resolved_at=name_resolved_at,
|
||
),
|
||
)
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDecisionOut:
|
||
app = _app()
|
||
session = get_session()
|
||
try:
|
||
voice_session = session.execute(
|
||
select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id)
|
||
).scalar_one_or_none()
|
||
if voice_session is None:
|
||
raise HTTPException(status_code=404, detail="Voice AI session not found")
|
||
interaction = session.execute(
|
||
select(Interaction).where(Interaction.interaction_id == payload.interaction_id)
|
||
).scalar_one_or_none()
|
||
if interaction is None:
|
||
raise HTTPException(status_code=404, detail="Interaction not found")
|
||
|
||
customer_id = _resolve_or_create_voice_customer_id(
|
||
session,
|
||
interaction=interaction,
|
||
call_id=payload.call_id,
|
||
)
|
||
customer = None
|
||
if customer_id:
|
||
customer = session.execute(
|
||
select(Customer).where(Customer.customer_id == customer_id)
|
||
).scalar_one_or_none()
|
||
|
||
ai_session, _ = _ensure_voice_ai_session(
|
||
session,
|
||
voice_session=voice_session,
|
||
interaction=interaction,
|
||
customer_id=customer_id,
|
||
language=str(payload.language or voice_session.language or "ru").strip() or "ru",
|
||
agent_profile=voice_session.agent_profile,
|
||
)
|
||
now = utc_now_iso()
|
||
request_metadata = payload.metadata if isinstance(payload.metadata, dict) else {}
|
||
early_plan_only = _voice_reply_phase(request_metadata) == "early_plan"
|
||
voice_session.customer_id = customer_id
|
||
if not early_plan_only:
|
||
voice_session.last_user_utterance_at = now
|
||
voice_session.status = "thinking"
|
||
voice_session.updated_at = now
|
||
if not early_plan_only:
|
||
ai_session.updated_at = now
|
||
ai_session.call_id = payload.call_id
|
||
ai_session.interaction_id = interaction.interaction_id
|
||
ai_session.customer_id = customer_id
|
||
ai_session.language = str(payload.language or voice_session.language or "ru").strip() or "ru"
|
||
config = load_effective_voice_name_collection_config(session)
|
||
operator_config = app._load_ai_operator_config(session)
|
||
|
||
if not early_plan_only:
|
||
_record_voice_ai_turn(
|
||
session,
|
||
ai_session_id=ai_session.session_id,
|
||
interaction_id=interaction.interaction_id,
|
||
role="user",
|
||
source_type="voice_asr",
|
||
text=payload.transcript_text,
|
||
payload={
|
||
"voice_session_id": payload.voice_session_id,
|
||
"call_id": payload.call_id,
|
||
"sequence_no": payload.sequence_no,
|
||
"barge_in": payload.barge_in,
|
||
"metadata": payload.metadata,
|
||
},
|
||
)
|
||
if payload.barge_in:
|
||
last_assistant_segment = session.execute(
|
||
select(VoiceTranscriptSegmentRow)
|
||
.where(VoiceTranscriptSegmentRow.session_id == voice_session.session_id)
|
||
.where(VoiceTranscriptSegmentRow.speaker == "assistant")
|
||
.order_by(VoiceTranscriptSegmentRow.id.desc())
|
||
.limit(1)
|
||
).scalar_one_or_none()
|
||
if last_assistant_segment:
|
||
last_assistant_segment.barge_in_interrupted = True
|
||
transcript_window = _voice_recent_segments(
|
||
session,
|
||
voice_session_id=voice_session.session_id,
|
||
limit=_voice_max_context_segments(),
|
||
)
|
||
if _voice_start_stage(voice_session.agent_profile, payload.metadata):
|
||
language = ai_session.language or "ru"
|
||
downstream_queue_code, downstream_queue_id = _voice_start_downstream_queue(payload.metadata, voice_session)
|
||
if config.enabled and config.start.ask_name_on_start:
|
||
status, name_value, name_source = _voice_start_name_outcome(payload.transcript_text, language)
|
||
else:
|
||
status, name_value, name_source = "name_not_obtained", None, "none"
|
||
result = VoiceStartResult(
|
||
language=language,
|
||
customer_id=customer_id,
|
||
customer_name_status=status,
|
||
customer_name_value=name_value,
|
||
customer_name_source=name_source,
|
||
downstream_queue_id=downstream_queue_id,
|
||
downstream_queue_code=downstream_queue_code,
|
||
resolved_at=now,
|
||
)
|
||
_persist_voice_start_result(
|
||
session,
|
||
voice_session=voice_session,
|
||
interaction=interaction,
|
||
result=result,
|
||
)
|
||
decision_metadata = _voice_start_result_metadata(result)
|
||
summary_text = (
|
||
f"Voice start completed with status {status}."
|
||
if not name_value
|
||
else f"Voice start completed with status {status} and candidate name {name_value}."
|
||
)
|
||
_record_voice_ai_turn(
|
||
session,
|
||
ai_session_id=ai_session.session_id,
|
||
interaction_id=interaction.interaction_id,
|
||
role="assistant",
|
||
source_type="voice_policy",
|
||
text=summary_text,
|
||
payload={
|
||
"voice_session_id": payload.voice_session_id,
|
||
"call_id": payload.call_id,
|
||
"decision": {
|
||
"language": language,
|
||
"customer_name_status": status,
|
||
"customer_name_value": name_value,
|
||
"customer_name_source": name_source,
|
||
"downstream_queue_code": downstream_queue_code,
|
||
"downstream_queue_id": downstream_queue_id,
|
||
},
|
||
},
|
||
model="voice_start_policy",
|
||
latency_ms=1,
|
||
)
|
||
voice_session.language = language
|
||
voice_session.status = "handoff_requested"
|
||
voice_session.handoff_reason = "voice_start_completed"
|
||
voice_session.updated_at = now
|
||
ai_session.status = "handoff_required"
|
||
ai_session.handoff_reason = "voice_start_completed"
|
||
ai_session.summary_text = summary_text
|
||
ai_session.updated_at = now
|
||
session.commit()
|
||
return VoiceAITurnDecisionOut(
|
||
language=language,
|
||
intent="voice_start_identity",
|
||
reply_text="",
|
||
confidence=1.0 if status == "name_obtained" else 0.55,
|
||
needs_handoff=True,
|
||
handoff_reason="voice_start_completed",
|
||
case_action="keep_open",
|
||
kb_refs=[],
|
||
summary_text=summary_text,
|
||
model="voice_start_policy",
|
||
latency_ms=1,
|
||
status="handoff_requested",
|
||
metadata=decision_metadata,
|
||
)
|
||
timeline_window = _voice_recent_timeline(
|
||
session,
|
||
interaction_id=interaction.interaction_id,
|
||
)
|
||
current_name_status = str(voice_session.customer_name_status or "name_not_obtained").strip() or "name_not_obtained"
|
||
current_name_value = _normalize_name_candidate(voice_session.customer_name_value) or (
|
||
str(voice_session.customer_name_value or "").strip() or None
|
||
)
|
||
current_name_source = str(voice_session.customer_name_source or "none").strip() or "none"
|
||
current_name_resolved_at = str(voice_session.customer_name_resolved_at or "").strip() or None
|
||
effective_name_status = current_name_status
|
||
effective_name_value = current_name_value
|
||
effective_name_source = current_name_source
|
||
effective_name_resolved_at = current_name_resolved_at
|
||
inline_name_followup = False
|
||
if not early_plan_only:
|
||
name_update = _voice_downstream_name_update(
|
||
language=ai_session.language or "ru",
|
||
transcript_text=payload.transcript_text,
|
||
transcript_window=transcript_window,
|
||
current_status=current_name_status,
|
||
current_name=current_name_value,
|
||
current_source=current_name_source,
|
||
current_resolved_at=current_name_resolved_at,
|
||
now=now,
|
||
config=config,
|
||
)
|
||
if name_update["finalizable"]:
|
||
finalized_name = _finalize_customer_name(
|
||
session,
|
||
customer=customer,
|
||
customer_id=customer_id,
|
||
call_id=payload.call_id,
|
||
final_name=name_update["value"],
|
||
resolved_at=now,
|
||
)
|
||
if finalized_name:
|
||
name_update["value"] = finalized_name
|
||
name_update["resolved_at"] = now
|
||
effective_name_status = name_update["status"]
|
||
effective_name_value = name_update["value"]
|
||
effective_name_source = name_update["source"]
|
||
effective_name_resolved_at = name_update["resolved_at"]
|
||
inline_name_followup = bool(name_update["inline_followup"])
|
||
_persist_voice_name_state(
|
||
session,
|
||
voice_session=voice_session,
|
||
status=effective_name_status,
|
||
value=effective_name_value,
|
||
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(
|
||
session,
|
||
payload.transcript_text,
|
||
language=ai_session.language,
|
||
)
|
||
disclosure_required = voice_session.disclosure_played_at is None
|
||
decision = _voice_decision(
|
||
language=ai_session.language or "ru",
|
||
customer=customer,
|
||
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,
|
||
customer_name_status=effective_name_status,
|
||
request_metadata=request_metadata,
|
||
operator_config=operator_config,
|
||
)
|
||
decision["reply_text"] = _voice_postprocess_reply_text(
|
||
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")),
|
||
)
|
||
|
||
if decision.get("extracted_name") and not early_plan_only:
|
||
effective_name_status = "name_obtained"
|
||
effective_name_value = str(decision["extracted_name"]).strip()
|
||
effective_name_source = "llm_extraction"
|
||
effective_name_resolved_at = now
|
||
inline_name_followup = False
|
||
finalized_name = _finalize_customer_name(
|
||
session,
|
||
customer=customer,
|
||
customer_id=customer_id,
|
||
call_id=payload.call_id,
|
||
final_name=effective_name_value,
|
||
resolved_at=now,
|
||
)
|
||
if finalized_name:
|
||
effective_name_value = finalized_name
|
||
_persist_voice_name_state(
|
||
session,
|
||
voice_session=voice_session,
|
||
status=effective_name_status,
|
||
value=effective_name_value,
|
||
source=effective_name_source,
|
||
resolved_at=effective_name_resolved_at,
|
||
)
|
||
|
||
decision_metadata = _voice_name_metadata(
|
||
language=voice_session.voice_start_language or ai_session.language or "ru",
|
||
customer_id=customer_id,
|
||
status=effective_name_status,
|
||
value=effective_name_value,
|
||
source=effective_name_source,
|
||
resolved_at=effective_name_resolved_at,
|
||
)
|
||
decision_metadata.update(decision.get("metadata") or {})
|
||
suppress_name_prefix = bool(request_metadata.get("suppress_name_prefix")) if isinstance(request_metadata, dict) else False
|
||
if effective_name_status == "name_obtained" and effective_name_value and not early_plan_only and not suppress_name_prefix:
|
||
just_learned_name = current_name_status != "name_obtained"
|
||
decision["reply_text"] = _voice_reply_with_name(
|
||
decision["language"],
|
||
decision["reply_text"],
|
||
effective_name_value,
|
||
greet=just_learned_name,
|
||
)
|
||
elif inline_name_followup and not decision["needs_handoff"] and not early_plan_only:
|
||
inline_followup = _voice_inline_name_followup(decision["language"], config)
|
||
decision["reply_text"] = (
|
||
f"{decision['reply_text']} {inline_followup}".strip()
|
||
if str(decision["reply_text"] or "").strip()
|
||
else inline_followup
|
||
)
|
||
if decision_metadata.get("voice_v2_enabled"):
|
||
decision["reply_text"] = _voice_compact_reply_text(
|
||
decision["reply_text"],
|
||
language=decision["language"],
|
||
)
|
||
decision["metadata"] = decision_metadata
|
||
if early_plan_only:
|
||
decision_status = str(voice_session.status or "active").strip() or "active"
|
||
session.rollback()
|
||
return VoiceAITurnDecisionOut(
|
||
language=decision["language"],
|
||
intent=decision["intent"],
|
||
reply_text=decision["reply_text"],
|
||
confidence=decision["confidence"],
|
||
needs_handoff=decision["needs_handoff"],
|
||
handoff_reason=decision["handoff_reason"],
|
||
case_action=decision["case_action"],
|
||
kb_refs=decision["kb_refs"],
|
||
summary_text=decision["summary_text"],
|
||
model=decision["model"],
|
||
latency_ms=decision["latency_ms"],
|
||
status=decision_status,
|
||
metadata=decision_metadata,
|
||
)
|
||
_record_voice_ai_turn(
|
||
session,
|
||
ai_session_id=ai_session.session_id,
|
||
interaction_id=interaction.interaction_id,
|
||
role="assistant",
|
||
source_type="voice_policy",
|
||
text=decision["reply_text"] or decision["summary_text"] or decision["handoff_reason"],
|
||
payload={
|
||
"voice_session_id": payload.voice_session_id,
|
||
"call_id": payload.call_id,
|
||
"kb_refs": decision["kb_refs"],
|
||
"timeline_window": timeline_window,
|
||
"context_segments": [
|
||
{
|
||
"speaker": segment.speaker,
|
||
"text": segment.text,
|
||
"sequence_no": segment.sequence_no,
|
||
}
|
||
for segment in transcript_window
|
||
],
|
||
"decision": decision,
|
||
},
|
||
model=decision["model"],
|
||
latency_ms=decision["latency_ms"],
|
||
)
|
||
voice_session.language = decision["language"]
|
||
voice_session.updated_at = now
|
||
voice_session.status = "handoff_requested" if decision["needs_handoff"] else "active"
|
||
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()
|
||
_append_timeline(
|
||
interaction.interaction_id,
|
||
"ai.handoff_requested" if decision["needs_handoff"] else "ai.reply_generated",
|
||
{
|
||
"call_id": payload.call_id,
|
||
"voice_session_id": payload.voice_session_id,
|
||
"ai_session_id": ai_session.session_id,
|
||
"sequence_no": payload.sequence_no,
|
||
"confidence": decision["confidence"],
|
||
"kb_refs": decision["kb_refs"],
|
||
"handoff_reason": decision["handoff_reason"],
|
||
**decision_metadata,
|
||
},
|
||
)
|
||
return VoiceAITurnDecisionOut(
|
||
language=decision["language"],
|
||
intent=decision["intent"],
|
||
reply_text=decision["reply_text"],
|
||
confidence=decision["confidence"],
|
||
needs_handoff=decision["needs_handoff"],
|
||
handoff_reason=decision["handoff_reason"],
|
||
case_action=decision["case_action"],
|
||
kb_refs=decision["kb_refs"],
|
||
summary_text=decision["summary_text"],
|
||
model=decision["model"],
|
||
latency_ms=decision["latency_ms"],
|
||
status="handoff_requested" if decision["needs_handoff"] else "active",
|
||
metadata=decision_metadata,
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
try:
|
||
session.rollback()
|
||
finally:
|
||
pass
|
||
raise HTTPException(status_code=502, detail=f"Voice AI turn failed: {exc}") from exc
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def close_voice_session(session_id: str) -> dict[str, Any]:
|
||
session = get_session()
|
||
try:
|
||
voice_session = session.execute(
|
||
select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id)
|
||
).scalar_one_or_none()
|
||
if voice_session is None:
|
||
raise HTTPException(status_code=404, detail="Voice AI session not found")
|
||
now = utc_now_iso()
|
||
voice_session.status = "completed" if voice_session.status != "error" else "error"
|
||
voice_session.ended_at = voice_session.ended_at or now
|
||
voice_session.updated_at = now
|
||
if str(voice_session.ai_session_id or "").strip():
|
||
ai_session = session.execute(
|
||
select(AISessionRow).where(AISessionRow.session_id == voice_session.ai_session_id)
|
||
).scalar_one_or_none()
|
||
if ai_session:
|
||
ai_session.status = "closed" if ai_session.status != "error" else "error"
|
||
ai_session.closed_at = now
|
||
ai_session.updated_at = now
|
||
session.commit()
|
||
return {
|
||
"ok": True,
|
||
"voice_session_id": voice_session.session_id,
|
||
"ai_session_id": voice_session.ai_session_id,
|
||
"status": voice_session.status,
|
||
}
|
||
finally:
|
||
session.close()
|