Files
call-center/services/ai_orchestrator_service/voice.py
T
Yera All a47c09d465 fix(voice): prevent regex heuristic from overwriting LLM-extracted customer name
- Block old _voice_downstream_name_update from overwriting name_obtained status
  unless user explicitly says 'меня зовут X' (explicit_candidate only)
- Move _persist_voice_name_state to run AFTER LLM extraction, not before,
  so LLM always gets a chance to override the regex result
- Fixes bug where phrases like 'можешь назвать их' were incorrectly
  captured as customer name, overwriting the real name
2026-04-08 01:06:08 +05:00

2108 lines
82 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 importlib
import json
import re
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
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", 8))
def _voice_disclosure_prefix(language: str) -> str:
if language == "kz":
return "Men kompaniyanyn AI operatoriymyn. "
return "Я AI-оператор компании. "
def _voice_greeting(language: str) -> str:
if language == "kz":
return (
"Men kompaniyanyn AI operatoriymyn. Salemetsiz be. "
"Suragynyzdy aitanyz, men birden komektesuge tyrisamyn."
)
return (
"Я AI-оператор компании. Здравствуйте. "
"Коротко расскажите, с чем помочь, и я сразу начну разбираться."
)
def _voice_handoff_reply(language: str) -> str:
if language == "kz":
return (
"Men AI operator retinde bastapky konteksti jiyap aldyм. "
"Kazir sizdi tiry operatorga kosamyn."
)
return (
"Я как AI-оператор собрал первичный контекст. "
"Сейчас переведу вас на живого оператора."
)
def _voice_disclosure_prefix(language: str) -> str:
return persona.voice_disclosure_prefix(language)
def _voice_greeting(language: str) -> str:
return persona.voice_greeting(language)
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
invalid_tokens = {
"\u0434\u0430",
"\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
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)
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",
"\u043d\u0443\u0436\u043d\u043e",
"\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, _name_followup_needed(raw)
candidate = _normalize_name_candidate(raw)
if candidate:
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)
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()
candidate = _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_with_name(language: str, reply_text: str, name: str | None) -> 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)
if reply_text.startswith(prefix):
rest = reply_text[len(prefix) :].lstrip()
if _voice_text_key(rest).startswith(normalized_short):
return reply_text
return f"{prefix}{short_name}, {rest}"
if _voice_text_key(reply_text).startswith(normalized_short):
return reply_text
return f"{short_name}, {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"
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:
action = "correct"
elif candidate:
action = "provide" if not name_value else "confirm"
candidate = candidate or name_value
elif status == "name_obtained":
if explicit_candidate and candidate_key and candidate_key != current_key:
action = "correct"
else:
if explicit_candidate:
action = "provide"
elif candidate and not needs_followup:
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)
)
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 = 4,
) -> 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_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_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 ("график", "время работы", "режим работы", "часы работы", "работаете")):
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_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_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"Qongyrau taqyrybyn naqtylap jatyrmyn. {topic_prompt}"
return f"Сейчас уточняю цель звонка. {topic_prompt}"
if language == "kz":
return "Qazir qongyraudyn maqratyn anyqtap jatyrmyn. Qysqasha aitnyz: jumys uaqyty, otinish statusy, tarif nemese operator."
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_llm_prompt_messages(
*,
language: str,
customer: Customer | None,
interaction: Interaction,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
kb_results: list[Any],
name_value: str | None,
name_status: str | 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[-6:]
]
kb_context = [
{
"article_id": article.article_id,
"title": article.title,
"snippet": app._article_snippet(article, limit=240),
}
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,
},
"kb_results": kb_context,
"history": history,
}
system_prompt = persona.operator_system_prompt(
language=language,
channel_label="Voice",
is_voice=True,
)
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. 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."
)
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],
kb_results: list[Any],
name_value: str | None,
name_status: str | None,
) -> dict[str, Any] | None:
app = _app()
if _voice_policy_mode() != "llm_guarded":
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,
kb_results=kb_results,
name_value=name_value,
name_status=name_status,
)
)
except Exception:
return None
decision = app._sanitize_decision(raw, fallback_language=language)
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_legacy(
*,
language: str,
customer: Customer | None,
interaction: Interaction,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
kb_results: list[Any],
disclosure_required: bool,
) -> dict[str, Any]:
app = _app()
normalized = str(transcript_text or "").strip()
lower_text = normalized.lower()
needs_handoff = app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text)
model = app._ai_model()
if needs_handoff:
reply_text = _voice_handoff_reply(language)
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
return {
"language": language,
"intent": "handoff_request",
"reply_text": reply_text,
"confidence": 0.25,
"needs_handoff": True,
"handoff_reason": "Запрос требует участия живого оператора.",
"case_action": "keep_open",
"kb_refs": [],
"summary_text": "AI собрал первичный контекст и запросил живого оператора.",
"model": model,
"latency_ms": 1,
}
customer_name = customer.display_name if customer else "клиент"
if kb_results:
article = kb_results[0]
snippet = app._article_snippet(article, limit=220)
reply_text = f"По базе знаний вижу следующее: {snippet}"
summary_text = f"AI дал первичный ответ по базе знаний для {customer_name}."
kb_refs = [article.article_id]
confidence = 0.82
intent = "kb_answer"
else:
transcript_context = " ".join(segment.text for segment in transcript_window[-3:] if segment.speaker == "caller")
reply_text = (
"Я услышал запрос и уже собрал основной контекст. "
"Пожалуйста, уточните самый важный результат, который вы хотите получить."
)
if transcript_context and transcript_context != normalized:
reply_text += " Если правильно понял, речь идет об этом вопросе из разговора."
summary_text = f"AI уточняет цель звонка и собирает контекст для {customer_name}."
kb_refs = []
confidence = 0.68
intent = "clarification"
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
return {
"language": language,
"intent": intent,
"reply_text": reply_text,
"confidence": confidence,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": kb_refs,
"summary_text": summary_text,
"model": model,
"latency_ms": 1,
}
def _voice_decision(
*,
language: str,
customer: Customer | None,
interaction: Interaction,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
kb_results: list[Any],
disclosure_required: bool,
customer_name_value: str | None = None,
customer_name_status: str | None = None,
) -> dict[str, Any]:
app = _app()
normalized = str(transcript_text or "").strip()
lower_text = normalized.lower()
caller_texts = _voice_recent_caller_texts(transcript_window)
needs_handoff = app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text)
model = app._ai_model()
if needs_handoff:
reply_text = _voice_handoff_reply(language)
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
return {
"language": language,
"intent": "handoff_request",
"reply_text": reply_text,
"confidence": 0.25,
"needs_handoff": True,
"handoff_reason": "Запрос требует участия живого оператора.",
"case_action": "keep_open",
"kb_refs": [],
"summary_text": "AI собрал первичный контекст и запросил живого оператора.",
"model": model,
"latency_ms": 1,
}
customer_name = customer.display_name if customer else "клиент"
if kb_results:
article = kb_results[0]
snippet = app._article_snippet(article, limit=220)
reply_text = f"По базе знаний вижу следующее: {snippet}"
summary_text = f"AI дал первичный ответ по базе знаний для {customer_name}."
kb_refs = [article.article_id]
confidence = 0.82
intent = "kb_answer"
else:
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)
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
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 caller_confused:
reply_text = _voice_confusion_prompt(language, caller_texts)
else:
topic_prompt = _voice_topic_prompt(language, caller_texts)
if clarification_count >= 2 and not topic_prompt:
reply_text = _voice_confusion_prompt(language, caller_texts)
else:
reply_text = topic_prompt or _voice_generic_prompt(language)
summary_text = f"AI уточняет цель звонка и собирает контекст для {customer_name}."
kb_refs = []
confidence = 0.68
intent = "clarification"
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
return {
"language": language,
"intent": intent,
"reply_text": reply_text,
"confidence": confidence,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": kb_refs,
"summary_text": summary_text,
"model": model,
"latency_ms": 1,
}
def _voice_decision(
*,
language: str,
customer: Customer | None,
interaction: Interaction,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
kb_results: list[Any],
disclosure_required: bool,
customer_name_value: str | None = None,
customer_name_status: str | None = None,
) -> dict[str, Any]:
app = _app()
normalized = str(transcript_text or "").strip()
lower_text = normalized.lower()
caller_texts = _voice_recent_caller_texts(transcript_window)
model = app._ai_model()
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,
}
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,
}
llm_decision = _voice_llm_decision(
language=language,
customer=customer,
interaction=interaction,
transcript_text=transcript_text,
transcript_window=transcript_window,
kb_results=kb_results,
name_value=customer_name_value or (customer.display_name if customer else None),
name_status=customer_name_status,
)
if llm_decision is not None:
return llm_decision
if kb_results:
article = kb_results[0]
snippet = app._article_snippet(article, limit=220)
return {
"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,
}
reply_text = _voice_confusion_prompt(language, caller_texts) if caller_confused else (
_voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language)
)
return {
"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,
}
def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStartOut:
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)
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_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)
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()
voice_session.customer_id = customer_id
voice_session.last_user_utterance_at = now
voice_session.status = "thinking"
voice_session.updated_at = now
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)
_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
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
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,
kb_results=kb_results,
disclosure_required=disclosure_required,
customer_name_value=name_update["value"],
customer_name_status=name_update["status"],
)
if decision.get("extracted_name") and name_update["status"] not in ("name_obtained", "name_obtained_previously"):
name_update["status"] = "name_obtained"
name_update["value"] = str(decision["extracted_name"]).strip()
name_update["source"] = "llm_extraction"
name_update["resolved_at"] = now
name_update["inline_followup"] = False
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
_persist_voice_name_state(
session,
voice_session=voice_session,
status=name_update["status"],
value=name_update["value"],
source=name_update["source"],
resolved_at=name_update["resolved_at"],
)
decision_metadata = _voice_name_metadata(
language=voice_session.voice_start_language or ai_session.language or "ru",
customer_id=customer_id,
status=name_update["status"],
value=name_update["value"],
source=name_update["source"],
resolved_at=name_update["resolved_at"],
)
if name_update["status"] == "name_obtained" and name_update["value"]:
decision["reply_text"] = _voice_reply_with_name(
decision["language"],
decision["reply_text"],
name_update["value"],
)
elif name_update["inline_followup"] and not decision["needs_handoff"]:
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
)
decision["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"]
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()