Files
call-center/services/ai_orchestrator_service/voice.py
T
didar d2438b6954
deploy / deploy (push) Successful in 30s
feat: canonical intent taxonomy for AI operator (kb_answer -> intent_code)
Centralizes fixed control intents and adds a data-driven intent_code
field on kb_articles so many phrasings of the same FAQ question
resolve to one stable code (e.g. VOUCHER_ACTIVATION) instead of a
free-form, unvalidated string the LLM invented on the fly.

- services/shared/intents.py: CONTROL_INTENTS + normalize_intent()
- kb_articles.intent_code column (ORM + dev/sqlite runtime compat +
  migrations/sql/0034_* for postgres/sqlite)
- kb_service CRUD exposes intent_code
- orchestrator surfaces intent_code to the LLM and validates its
  intent output against control intents + the KB codes shown that turn
- voice.py: _voice_early_intent_bucket renamed to _voice_ack_topic_bucket
  to stop it being conflated with the canonical FAQ intent
2026-08-31 00:17:51 +05:00

2762 lines
109 KiB
Python
Raw Blame History

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