2624 lines
102 KiB
Python
2624 lines
102 KiB
Python
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":
|
||
pass
|
||
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 = 8,
|
||
) -> 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_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_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 = (
|
||
"график",
|
||
"время работы",
|
||
"режим работы",
|
||
"статус",
|
||
"заявк",
|
||
"заказ",
|
||
"обращен",
|
||
"запрос",
|
||
"тариф",
|
||
"стоимост",
|
||
"цен",
|
||
"оплат",
|
||
"услуг",
|
||
"адрес",
|
||
"филиал",
|
||
"город",
|
||
"офис",
|
||
"отделени",
|
||
"не работает",
|
||
"ошибк",
|
||
"проблем",
|
||
"сбой",
|
||
"интернет",
|
||
"связь",
|
||
"оператор",
|
||
"менеджер",
|
||
"сотрудник",
|
||
"компан",
|
||
"подключ",
|
||
"доставк",
|
||
)
|
||
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 (
|
||
"Men kompaniyamyzdyn qyzmetteri men otinishteri boiynsha komek bere alamyn. "
|
||
"Eger suraq bizdin qyzmetke qatysty bolsa, qysqasha naqtylaңыз. Qalasaңыз, operatorga qosamyn.",
|
||
"AI qongyraudyn taqyrybyn kompaniya qyzmetteri sheginde naqtylaudy usyndy.",
|
||
)
|
||
return (
|
||
"Я помогу по вопросам наших услуг и обращений. Если вопрос связан с нашей компанией, скажите коротко, что именно нужно. Если хотите, сразу соединю с оператором.",
|
||
"AI мягко вернул разговор к вопросам компании и предложил перевод на оператора.",
|
||
)
|
||
|
||
|
||
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_early_intent_bucket(text: str) -> str:
|
||
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],
|
||
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_recent_caller_texts(transcript_window)
|
||
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_early_intent_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,
|
||
request_metadata: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
v2_metadata = _voice_v2_metadata(transcript_text, request_metadata)
|
||
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",
|
||
},
|
||
}
|
||
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],
|
||
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. 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],
|
||
kb_results: list[Any],
|
||
name_value: str | None,
|
||
name_status: str | 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,
|
||
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,
|
||
request_metadata: dict[str, Any] | 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()
|
||
v2_metadata = _voice_v2_metadata(transcript_text, request_metadata)
|
||
reply_phase = _voice_reply_phase(request_metadata)
|
||
|
||
if reply_phase == "early_plan":
|
||
return _voice_early_plan(
|
||
language=language,
|
||
transcript_text=transcript_text,
|
||
request_metadata=request_metadata,
|
||
)
|
||
|
||
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 not kb_results and _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
|
||
|
||
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:
|
||
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:
|
||
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_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)
|
||
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)
|
||
|
||
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,
|
||
)
|
||
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,
|
||
kb_results=kb_results,
|
||
disclosure_required=disclosure_required,
|
||
customer_name_value=effective_name_value,
|
||
customer_name_status=effective_name_status,
|
||
request_metadata=request_metadata,
|
||
)
|
||
decision["reply_text"] = _voice_postprocess_reply_text(
|
||
language=decision["language"],
|
||
transcript_text=payload.transcript_text,
|
||
transcript_window=transcript_window,
|
||
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 {})
|
||
if effective_name_status == "name_obtained" and effective_name_value:
|
||
decision["reply_text"] = _voice_reply_with_name(
|
||
decision["language"],
|
||
decision["reply_text"],
|
||
effective_name_value,
|
||
)
|
||
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"]
|
||
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()
|
||
|
||
|