deploy / deploy (push) Successful in 32s
The speculative "early plan" turn (computed on partial ASR, before the caller finishes talking) can win the race and get spoken as the actual reply, but it unconditionally skipped KB search and answered common questions (schedule/address/price/status/problem) with a hardcoded clarifying question even when the FAQ already had the answer. KB search is a cheap in-memory lexical scan over a DB-cached row set, so it fits the early-plan latency budget unlike a real LLM call. Now early-plan runs it and, on a match, answers from the KB snippet (intent resolved via normalize_intent) instead of guessing a generic clarifying question; with no match it falls back to the prior behavior unchanged. operator_request is unaffected.
2811 lines
112 KiB
Python
2811 lines
112 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.intents import normalize_intent
|
||
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,
|
||
kb_results: list[Any] = (),
|
||
) -> 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 == "operator_request":
|
||
return {
|
||
"language": language,
|
||
"intent": "handoff_request",
|
||
"reply_text": _voice_compact_reply_text(_voice_handoff_reply(language), language=language),
|
||
"confidence": 0.62,
|
||
"needs_handoff": True,
|
||
"handoff_reason": "Запрос требует участия живого оператора.",
|
||
"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,
|
||
},
|
||
}
|
||
if early_intent in {"schedule", "address", "price", "status", "problem"} and kb_results:
|
||
# A cheap lexical KB lookup fits the early-plan latency budget (no LLM
|
||
# round-trip), unlike the properly grounded/paraphrased "final" decision.
|
||
# Answering from the KB here beats guessing a generic clarifying question
|
||
# when the FAQ already has the answer — see the plan doc for why this
|
||
# branch exists at all (the early reply can win the race and get spoken
|
||
# before the final, LLM-grounded decision is ready).
|
||
article = kb_results[0]
|
||
snippet = _app()._article_snippet(article, limit=220)
|
||
reply_text = f"Қысқаша айтайын: {snippet}" if language == "kz" else f"Коротко подскажу: {snippet}"
|
||
topic_code = getattr(article, "intent_code", None)
|
||
return {
|
||
"language": language,
|
||
"intent": normalize_intent(topic_code or "kb_answer", known_topic_codes=[topic_code] if topic_code else []),
|
||
"reply_text": _voice_compact_reply_text(reply_text, language=language),
|
||
"confidence": 0.7,
|
||
"needs_handoff": False,
|
||
"handoff_reason": None,
|
||
"case_action": "keep_open",
|
||
"kb_refs": [article.article_id],
|
||
"summary_text": "AI ответил по базе ЧЗВ на предварительной стадии.",
|
||
"model": "voice_early_plan_kb",
|
||
"latency_ms": 1,
|
||
"metadata": {
|
||
**v2_metadata,
|
||
"reply_phase": "early_plan",
|
||
"early_intent": early_intent,
|
||
},
|
||
}
|
||
if early_intent in {"schedule", "address", "price", "status", "problem"}:
|
||
reply_text = _voice_summary_slot_prompt(language, context_summary) or _voice_topic_prompt(language, [transcript_text])
|
||
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": "clarification",
|
||
"reply_text": _voice_compact_reply_text(reply_text, language=language),
|
||
"confidence": 0.62,
|
||
"needs_handoff": False,
|
||
"handoff_reason": 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,
|
||
kb_results=kb_results,
|
||
)
|
||
|
||
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
|
||
# Unlike name extraction/context-summary writes above (skipped for
|
||
# early_plan_only since they're stateful and heavier), KB search is a
|
||
# cheap in-memory lexical scan over a DB-cached row set (see
|
||
# _load_kb_rows_cached) and comfortably fits the early-plan latency
|
||
# budget, so it always runs — this lets _voice_early_plan answer from
|
||
# the FAQ instead of guessing a generic clarifying question.
|
||
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()
|