Files
call-center/services/asterisk_bridge_service/voice_ai.py
T
Hermes Agent 2243f305b8 feat: L1->L2 agent pool and routing engine for voice escalation
Replaces the hardcoded single-extension redirect for AI->human call
escalation with a real Agent Pool + Routing Engine:

- agents/escalations/routing_rules tables (migration 0031), asterisk_call_links
  gains tenant_id/current_level/required_skills_json/priority.
- services/routing_service/engine.py: level/tenant/skill filtered agent
  selection with atomic (CAS) reservation, no double-booking.
- routing-service: /agents CRUD + /internal/routing/reserve-agent and
  /internal/routing/release-agent.
- asterisk-bridge-service: voice_ai.request_handoff now uses the Routing
  Engine automatically for any queue_code configured in
  ASTERISK_QUEUE_LEVEL_MAP_JSON (all other queue_codes keep the existing
  static ASTERISK_TRANSFER_TARGET_MAP_JSON behavior unchanged); new
  POST /asterisk/live-calls/{call_id}/escalations entrypoint; agent is
  released back to AVAILABLE and the escalation closed when the call ends.

Targets the Tele2 Kazgaz DID +77476456048 (from-tele2-kazgaz context) as the
first queue wired to real L2 routing instead of AI-only.

Known gap (documented in docs/architecture/l1-l2-routing-engine.md):
automatic no-answer retry-to-next-agent needs a small, separately reviewed
dialplan change and is left for a follow-up MR rather than guessed at blind.

Tests: services/routing_service/engine.py covered by
tests/test_routing_engine.py (selection filtering, atomic reservation,
release); existing test_asterisk_bridge_service.py and
test_routing_service_pg_counter.py suites still pass unmodified.
2026-08-28 16:22:32 +05:00

1099 lines
41 KiB
Python

from __future__ import annotations
import json
import logging
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 (
EscalationOut,
EscalationRequestIn,
VoiceAICallStateUpdateIn,
VoiceAIHandoffRequestIn,
VoiceAIMediaBridgeEventIn,
VoiceAISummaryOut,
VoiceAISummaryTranscriptSegmentOut,
VoiceLiveCallOut,
)
from services.shared.sql_models import (
AISessionRow,
AsteriskCallLinkRow,
EscalationRow,
VoiceAISessionRow,
VoiceTranscriptSegmentRow,
)
from services.shared.voice_transcripts import add_transcript_segment, next_transcript_sequence
LOGGER = logging.getLogger("uvicorn.error")
def _bridge_app():
import services.asterisk_bridge_service.app as bridge_app
return bridge_app
def _truncate(value: str | None, limit: int = 1000) -> str | None:
raw = str(value or "").strip()
if not raw:
return None
return raw[:limit]
def append_interaction_timeline(
*,
interaction_id: str | None,
action: str,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
bridge = _bridge_app()
if not str(interaction_id or "").strip():
return None
return bridge._post_json(
f"{bridge._interaction_service_url()}/interactions/{interaction_id}/timeline",
{"action": action, "metadata": metadata or {}},
timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(),
max_attempts=bridge._callcontrol_side_effect_max_attempts(),
retry_backoff_seconds=0.0,
)
def start_voice_ai_session(
*,
call_id: str,
linked_id: str | None,
interaction_id: str,
queue_id: str,
queue_code: str,
caller_number: str | None,
caller_name: str | None,
ai_config: dict[str, Any],
extra_metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
bridge = _bridge_app()
metadata = {
"queue_code": queue_code,
"direction": "inbound",
"stage": ai_config.get("stage"),
"next_queue_code": ai_config.get("next_queue_code"),
"next_queue_id": ai_config.get("next_queue_id"),
}
if isinstance(extra_metadata, dict):
metadata.update(extra_metadata)
return bridge._post_json(
f"{bridge._ai_voice_runtime_service_url()}/internal/voice-ai/sessions",
{
"call_id": call_id,
"linked_id": linked_id,
"interaction_id": interaction_id,
"queue_id": queue_id,
"caller_number": caller_number,
"caller_name": caller_name,
"agent_profile": ai_config.get("agent_profile") or "voice_support",
"language_hint": ai_config.get("language"),
"handoff_queue_id": ai_config.get("handoff_queue_id") or queue_id,
"metadata": metadata,
},
timeout_seconds=max(3.0, min(bridge._forward_timeout_seconds(), 12.0)),
max_attempts=1,
retry_backoff_seconds=0.0,
)
def notify_voice_ai_telephony_event(
*,
voice_session_id: str | None,
event_type: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
bridge = _bridge_app()
session_id = str(voice_session_id or "").strip()
if not session_id:
return None
return bridge._post_json(
f"{bridge._ai_voice_runtime_service_url()}/internal/voice-ai/sessions/{session_id}/telephony-events",
{"event_type": event_type, "payload": payload or {}},
timeout_seconds=3.0,
max_attempts=1,
retry_backoff_seconds=0.0,
)
def register_voice_ai_media_bridge(
*,
voice_session_id: str | None,
event_type: str,
media_uuid: str | None,
call_id: str,
linked_id: str | None,
channel: str | None,
service_address: str | None,
reason: str | None = None,
) -> dict[str, Any] | None:
bridge = _bridge_app()
session_id = str(voice_session_id or "").strip()
normalized_media_uuid = str(media_uuid or "").strip()
if not session_id or not normalized_media_uuid:
return None
payload = VoiceAIMediaBridgeEventIn(
event_type="ended" if str(event_type).strip() == "ended" else "requested",
media_uuid=normalized_media_uuid,
call_id=call_id,
linked_id=linked_id,
channel=channel,
service_address=service_address,
reason=reason,
)
return bridge._post_json(
f"{bridge._ai_voice_runtime_service_url()}/internal/voice-ai/sessions/{session_id}/media-bridge",
payload.model_dump(),
timeout_seconds=3.0,
max_attempts=1,
retry_backoff_seconds=0.0,
)
def trusted_voice_runtime_actor(actor: dict) -> bool:
bridge = _bridge_app()
return (
str(actor.get("auth_source") or "") == "service"
and str(actor.get("sub") or "") in bridge._ai_voice_runtime_trusted_subjects()
)
def assert_trusted_voice_runtime_actor(actor: dict) -> None:
if not trusted_voice_runtime_actor(actor):
raise HTTPException(status_code=403, detail="Trusted Voice AI runtime subject required")
def _queue_code_for_queue_id(queue_id: str | None) -> str | None:
bridge = _bridge_app()
target = str(queue_id or "").strip()
if not target:
return None
for queue_code, mapped_queue_id in bridge._queue_map().items():
if str(mapped_queue_id).strip() == target:
return str(queue_code).strip() or None
return None
def _reserve_routing_agent(
*,
call_id: str,
level: str,
tenant_id: str | None,
required_skills: list[str] | None = None,
) -> str | None:
bridge = _bridge_app()
try:
response = bridge._post_json(
f"{bridge._routing_service_url()}/internal/routing/reserve-agent",
{
"call_id": call_id,
"level": level,
"tenant_id": tenant_id,
"required_skills": required_skills or [],
"exclude_agent_ids": [],
},
timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(),
max_attempts=1,
retry_backoff_seconds=0.0,
)
except Exception:
LOGGER.warning(
"bridge.routing_reserve_failed call_id=%s level=%s tenant_id=%s",
call_id,
level,
tenant_id,
)
return None
extension = str((response or {}).get("extension") or "").strip()
return extension or None
def _resolve_handoff_extension(
target_queue_id: str | None,
fallback_queue_code: str | None,
*,
call_id: str | None = None,
target_level: str | None = None,
tenant_id: str | None = None,
required_skills: list[str] | None = None,
) -> tuple[str, str, str | None, str | None]:
bridge = _bridge_app()
queue_code = _queue_code_for_queue_id(target_queue_id) or str(fallback_queue_code or "").strip()
if not queue_code:
raise HTTPException(status_code=400, detail="Unable to resolve handoff queue_code")
level = target_level or bridge._routing_level_for_queue_code(queue_code)
if level and call_id:
resolved_tenant_id = tenant_id if tenant_id is not None else bridge._routing_tenant_for_queue_code(queue_code)
reserved_extension = _reserve_routing_agent(
call_id=call_id,
level=level,
tenant_id=resolved_tenant_id,
required_skills=required_skills,
)
if reserved_extension:
return queue_code, reserved_extension, level, resolved_tenant_id
raise HTTPException(status_code=409, detail=f"No available {level} agent right now")
extension = bridge._transfer_target_map().get(queue_code)
if not extension:
raise HTTPException(
status_code=400,
detail=f"Unknown transfer queue_code: {queue_code}",
)
return queue_code, extension, None, None
def _resolve_handoff_channel(session, link: AsteriskCallLinkRow) -> str:
bridge = _bridge_app()
snapshot = bridge._build_call_channel_snapshot(
session,
link,
operator_extension=link.operator_extension,
)
for channel in bridge._candidate_channels_for_call(
session,
link,
operator_extension=link.operator_extension,
prefer_operator=False,
snapshot=snapshot,
):
if (
link.operator_extension
and bridge._extract_extension_from_channel(channel) == str(link.operator_extension).strip()
):
continue
return channel
channel = bridge._resolve_channel_name(
session,
link,
prefer_operator=False,
refresh=True,
snapshot=snapshot,
)
if not channel:
raise HTTPException(status_code=409, detail="Unable to resolve active channel for AI handoff")
return channel
def _recover_channel_for_ended_call(session, link: AsteriskCallLinkRow) -> str | None:
bridge = _bridge_app()
snapshot = bridge._build_call_channel_snapshot(
session,
link,
operator_extension=link.operator_extension,
)
for channel in bridge._candidate_channels_for_call(
session,
link,
operator_extension=link.operator_extension,
prefer_operator=False,
snapshot=snapshot,
):
if channel:
now = utc_now_iso()
link.status = "active"
link.telephony_status = "connected"
link.ended_at = None
link.updated_at = now
LOGGER.warning(
"bridge.ai_handoff_revived call_id=%s interaction_id=%s channel=%s previous_ai_state=%s",
link.call_id,
link.interaction_id,
channel,
link.ai_state,
)
return channel
return None
def _summary_text_from_payload(summary: dict[str, Any] | None) -> str | None:
if not isinstance(summary, dict):
return None
for key in ("ai_outcome_text", "recommended_next_step", "customer_request_text"):
value = _truncate(summary.get(key), 1200)
if value:
return value
return None
def _voice_start_metadata(payload: dict[str, Any] | None) -> dict[str, str | None]:
metadata = payload if isinstance(payload, dict) else {}
return {
"voice_start_language": _truncate(
metadata.get("voice_start_language") or metadata.get("language"),
16,
),
"customer_name_status": _truncate(metadata.get("customer_name_status"), 32),
"customer_name_value": _truncate(metadata.get("customer_name_value"), 256),
"customer_name_source": _truncate(metadata.get("customer_name_source"), 32),
"customer_name_resolved_at": _truncate(metadata.get("customer_name_resolved_at"), 64),
"downstream_queue_code": _truncate(metadata.get("downstream_queue_code"), 64),
}
def _apply_voice_start_metadata(target: Any, metadata: dict[str, Any] | None) -> None:
payload = metadata if isinstance(metadata, dict) else {}
values = _voice_start_metadata(payload)
if hasattr(target, "voice_start_language") and (
"voice_start_language" in payload or "language" in payload
):
target.voice_start_language = values["voice_start_language"]
if hasattr(target, "customer_name_status") and "customer_name_status" in payload:
target.customer_name_status = values["customer_name_status"]
if hasattr(target, "customer_name_value") and "customer_name_value" in payload:
target.customer_name_value = values["customer_name_value"]
if hasattr(target, "customer_name_source") and "customer_name_source" in payload:
target.customer_name_source = values["customer_name_source"]
if hasattr(target, "customer_name_resolved_at") and "customer_name_resolved_at" in payload:
target.customer_name_resolved_at = values["customer_name_resolved_at"]
def _set_channel_variable(channel: str, name: str, value: str | None) -> None:
bridge = _bridge_app()
bridge._ami_action(
"Setvar",
{
"Channel": channel,
"Variable": name,
"Value": str(value or ""),
},
)
def _set_handoff_channel_vars(
channel: str,
*,
queue_code: str,
reuse_existing_call: bool,
metadata: dict[str, Any] | None,
) -> None:
values = _voice_start_metadata(metadata)
_set_channel_variable(channel, "MVPCC_START_LANGUAGE", values["voice_start_language"])
_set_channel_variable(channel, "MVPCC_CUSTOMER_NAME_STATUS", values["customer_name_status"])
_set_channel_variable(channel, "MVPCC_CUSTOMER_NAME_VALUE", values["customer_name_value"])
_set_channel_variable(channel, "MVPCC_CUSTOMER_NAME_SOURCE", values["customer_name_source"])
if reuse_existing_call:
_set_channel_variable(channel, "MVPCC_AI_QUEUE_OVERRIDE", queue_code)
_set_channel_variable(channel, "MVPCC_AI_REUSE_CALL", "1")
else:
_set_channel_variable(channel, "MVPCC_AI_QUEUE_OVERRIDE", "")
_set_channel_variable(channel, "MVPCC_AI_REUSE_CALL", "")
def _start_ai_session_for_reuse_handoff(
*,
link: AsteriskCallLinkRow,
queue_code: str,
metadata: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str] | None:
bridge = _bridge_app()
if bridge._transfer_target_map().get(queue_code) != "7100":
return None
ai_config = bridge._ai_voice_config_for_queue(queue_code)
if not ai_config:
return None
queue_id = str(bridge._queue_map().get(queue_code) or "").strip()
if not queue_id:
raise HTTPException(status_code=400, detail=f"Unknown AI queue mapping: {queue_code}")
started = start_voice_ai_session(
call_id=link.call_id,
linked_id=link.linked_id,
interaction_id=link.interaction_id,
queue_id=queue_id,
queue_code=queue_code,
caller_number=link.caller_number,
caller_name=link.caller_name,
ai_config=ai_config,
extra_metadata=metadata,
)
return started, queue_id
def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) -> VoiceLiveCallOut:
bridge = _bridge_app()
assert_trusted_voice_runtime_actor(actor)
session = get_session()
action = None
try:
link = bridge._load_live_call_or_404(session, call_id)
if bridge._call_is_ended(link):
revived_channel = _recover_channel_for_ended_call(session, link)
if not revived_channel:
LOGGER.warning(
"bridge.ai_handoff_rejected call_id=%s reason=call_ended interaction_id=%s ai_state=%s status=%s telephony_status=%s",
call_id,
link.interaction_id,
link.ai_state,
link.status,
link.telephony_status,
)
raise HTTPException(status_code=409, detail="Call is already ended")
if str(link.interaction_id or "").strip() != str(body.interaction_id or "").strip():
LOGGER.warning(
"bridge.ai_handoff_rejected call_id=%s reason=interaction_mismatch link_interaction_id=%s body_interaction_id=%s ai_state=%s channel_name=%s",
call_id,
str(link.interaction_id or "").strip() or None,
str(body.interaction_id or "").strip() or None,
link.ai_state,
link.channel_name,
)
raise HTTPException(status_code=409, detail="interaction_id mismatch")
if (
str(link.ai_state or "") in {"handoff_required", "human_owned"}
and str(link.voice_session_id or "") == body.voice_session_id
):
return bridge._live_call_to_out(session, link)
voice_session = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == body.voice_session_id)
).scalar_one_or_none()
if voice_session is None:
raise HTTPException(status_code=404, detail="Voice AI session not found")
queue_code, target_extension, resolved_level, resolved_tenant_id = _resolve_handoff_extension(
body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id,
fallback_queue_code=_queue_code_for_queue_id(link.queue_id),
call_id=call_id,
)
handoff_metadata = body.metadata or {}
actor_user = str(actor.get("user") or actor.get("sub") or "ai-voice-runtime").strip()
actor_role = str(actor.get("role") or "admin").strip() or "admin"
action = bridge._create_action_log(
session,
call_id=call_id,
interaction_id=link.interaction_id,
action_type="ai-handoff",
actor_user=actor_user,
actor_role=actor_role,
request_payload={
"voice_session_id": body.voice_session_id,
"ai_session_id": body.ai_session_id,
"target_queue_id": body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id,
"target_queue_code": queue_code,
"resolved_extension": target_extension,
"reason": body.reason,
"metadata": handoff_metadata,
},
)
channel = _resolve_handoff_channel(session, link)
reuse_started: dict[str, Any] | None = None
reuse_queue_id: str | None = None
if target_extension == "7100":
started = _start_ai_session_for_reuse_handoff(
link=link,
queue_code=queue_code,
metadata=handoff_metadata,
)
if started is None:
raise HTTPException(status_code=409, detail="AI reuse handoff is not configured for target queue")
reuse_started, reuse_queue_id = started
_set_handoff_channel_vars(
channel,
queue_code=queue_code,
reuse_existing_call=target_extension == "7100",
metadata=handoff_metadata,
)
LOGGER.warning(
"bridge.ai_handoff_redirect call_id=%s interaction_id=%s source_channel=%s target_extension=%s target_queue_id=%s",
call_id,
link.interaction_id,
channel,
target_extension,
body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id,
)
ami_result = bridge._ami_action(
"Redirect",
{
"Channel": channel,
"Context": bridge._transfer_context(),
"Exten": target_extension,
"Priority": 1,
},
)
now = utc_now_iso()
link.voice_session_id = (
str((reuse_started or {}).get("voice_session_id") or body.voice_session_id or link.voice_session_id or "")
or None
)
if target_extension == "7100":
link.ai_session_id = str((reuse_started or {}).get("ai_session_id") or "").strip() or None
else:
link.ai_session_id = (
str((reuse_started or {}).get("ai_session_id") or body.ai_session_id or link.ai_session_id or voice_session.ai_session_id or "")
or None
)
link.ai_state = (
_truncate((reuse_started or {}).get("status"), 32) or "greeting"
if target_extension == "7100"
else "handoff_required"
)
link.ai_handoff_reason = _truncate(body.reason, 4000)
link.ai_last_model_at = now
link.claimed_by_user = None
link.claimed_at = None
link.operator_extension = None
if resolved_level:
link.current_level = resolved_level
link.tenant_id = resolved_tenant_id
if reuse_queue_id:
link.queue_code = queue_code
link.queue_id = reuse_queue_id
_apply_voice_start_metadata(link, handoff_metadata)
link.updated_at = now
if target_extension == "7100":
voice_session.ai_session_id = str((reuse_started or {}).get("ai_session_id") or "").strip() or None
else:
voice_session.ai_session_id = (
str((reuse_started or {}).get("ai_session_id") or body.ai_session_id or voice_session.ai_session_id or "")
or None
)
voice_session.status = "handoff_requested"
voice_session.handoff_reason = _truncate(body.reason, 4000)
voice_session.handoff_target_queue_id = body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id
_apply_voice_start_metadata(voice_session, handoff_metadata)
voice_session.updated_at = now
if body.summary:
add_transcript_segment(
session,
session_id=voice_session.session_id,
call_id=call_id,
interaction_id=link.interaction_id,
speaker="system",
source_type="handoff_summary",
text=_summary_text_from_payload(body.summary) or body.reason or "",
payload=body.summary,
created_at=now,
)
if str(link.ai_session_id or "").strip():
ai_session = session.execute(
select(AISessionRow).where(AISessionRow.session_id == link.ai_session_id)
).scalar_one_or_none()
if ai_session:
ai_session.status = "handoff_required"
ai_session.handoff_reason = _truncate(body.reason, 4000)
ai_session.summary_text = (
_summary_text_from_payload(body.summary)
or _truncate(body.reason, 2000)
or ai_session.summary_text
)
ai_session.updated_at = now
bridge._set_action_result(
session,
action,
result_status="ok",
ami_action_id=str((ami_result or {}).get("action_id") or "") or None,
)
session.commit()
try:
append_interaction_timeline(
interaction_id=link.interaction_id,
action="ai.handoff_requested",
metadata={
"call_id": call_id,
"voice_session_id": body.voice_session_id,
"ai_session_id": body.ai_session_id,
"reason": body.reason,
"target_queue_id": body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id,
**{key: value for key, value in _voice_start_metadata(handoff_metadata).items() if value},
},
)
except Exception:
pass
return bridge._live_call_to_out(session, link)
except HTTPException as exc:
LOGGER.warning(
"bridge.ai_handoff_http_error call_id=%s detail=%s body_interaction_id=%s body_target_queue_id=%s",
call_id,
exc.detail if isinstance(exc.detail, str) else str(exc.detail),
str(body.interaction_id or "").strip() or None,
str(body.target_queue_id or "").strip() or None,
)
if action is not None:
bridge._set_action_result(
session,
action,
result_status="rejected",
error=exc.detail if isinstance(exc.detail, str) else str(exc.detail),
)
session.commit()
raise
except Exception as exc:
if action is not None:
bridge._set_action_result(session, action, result_status="failed", error=str(exc))
session.commit()
raise HTTPException(status_code=502, detail=f"AI handoff failed: {exc}") from exc
finally:
session.close()
def release_routing_agent(call_id: str) -> None:
bridge = _bridge_app()
bridge._post_json(
f"{bridge._routing_service_url()}/internal/routing/release-agent",
{"call_id": call_id},
timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(),
max_attempts=1,
retry_backoff_seconds=0.0,
)
def _escalation_to_out(row: EscalationRow) -> EscalationOut:
return EscalationOut(
escalation_id=row.escalation_id,
call_id=row.call_id,
tenant_id=row.tenant_id,
from_level=row.from_level,
to_level=row.to_level,
reason_code=row.reason_code,
required_skills=json.loads(row.required_skills_json or "[]"),
priority=row.priority,
topic=row.topic,
summary=row.summary,
status=row.status,
assigned_agent_id=row.assigned_agent_id,
requested_at=row.requested_at,
connected_at=row.connected_at,
completed_at=row.completed_at,
)
def create_escalation(call_id: str, body: EscalationRequestIn, actor: dict) -> EscalationOut:
bridge = _bridge_app()
session = get_session()
try:
link = bridge._load_live_call_or_404(session, call_id)
if bridge._call_is_ended(link):
raise HTTPException(status_code=409, detail="Call is already ended")
existing = session.execute(
select(EscalationRow).where(
EscalationRow.call_id == call_id,
EscalationRow.status.in_(["requested", "ringing"]),
)
).scalar_one_or_none()
if existing is not None:
raise HTTPException(status_code=409, detail="An active escalation already exists for this call")
from_level = str(link.current_level or "L1")
now = utc_now_iso()
escalation = EscalationRow(
escalation_id=new_id("esc"),
call_id=call_id,
tenant_id=link.tenant_id,
from_level=from_level,
to_level=body.target_level,
reason_code=body.reason_code,
required_skills_json=json.dumps(body.required_skills, ensure_ascii=False),
priority=body.priority,
topic=body.topic,
summary=body.summary,
status="requested",
requested_at=now,
)
session.add(escalation)
session.flush()
channel = _resolve_handoff_channel(session, link)
reserved_extension = _reserve_routing_agent(
call_id=call_id,
level=body.target_level,
tenant_id=link.tenant_id,
required_skills=body.required_skills,
)
if not reserved_extension:
escalation.status = "failed"
escalation.completed_at = now
session.commit()
raise HTTPException(status_code=409, detail=f"No available {body.target_level} agent right now")
bridge._ami_action(
"Redirect",
{
"Channel": channel,
"Context": bridge._transfer_context(),
"Exten": reserved_extension,
"Priority": 1,
},
)
escalation.status = "ringing"
escalation.assigned_agent_id = reserved_extension
link.current_level = body.target_level
link.required_skills_json = json.dumps(body.required_skills, ensure_ascii=False)
link.priority = body.priority
link.claimed_by_user = None
link.claimed_at = None
link.operator_extension = None
link.updated_at = now
session.commit()
return _escalation_to_out(escalation)
finally:
session.close()
def update_call_ai_state(call_id: str, body: VoiceAICallStateUpdateIn, actor: dict) -> VoiceLiveCallOut:
bridge = _bridge_app()
assert_trusted_voice_runtime_actor(actor)
session = get_session()
try:
link = bridge._load_live_call_or_404(session, call_id)
now = utc_now_iso()
if str(body.voice_session_id or "").strip():
link.voice_session_id = body.voice_session_id
if str(body.ai_session_id or "").strip():
link.ai_session_id = body.ai_session_id
link.ai_state = body.ai_state
if body.handoff_reason is not None:
link.ai_handoff_reason = _truncate(body.handoff_reason, 4000)
_apply_voice_start_metadata(link, body.metadata)
link.ai_last_model_at = now
link.updated_at = now
session.commit()
if body.ai_state == "error":
try:
append_interaction_timeline(
interaction_id=link.interaction_id,
action="ai.error",
metadata={
"call_id": call_id,
"voice_session_id": body.voice_session_id or link.voice_session_id,
"ai_session_id": body.ai_session_id or link.ai_session_id,
"error": _truncate(body.handoff_reason, 1000),
**(body.metadata or {}),
},
)
except Exception:
pass
return bridge._live_call_to_out(session, link)
finally:
session.close()
def _next_transcript_sequence(session, session_id: str) -> int:
return next_transcript_sequence(session, session_id)
def _segment_payload(row: VoiceTranscriptSegmentRow) -> dict[str, Any]:
try:
payload = json.loads(row.payload_json or "{}")
except Exception:
payload = {}
return payload if isinstance(payload, dict) else {}
def _segment_delivery_state(row: VoiceTranscriptSegmentRow) -> str | None:
payload = _segment_payload(row)
raw = payload.get("delivery_state") or payload.get("delivery_status")
if raw:
return str(raw).strip().lower() or None
if row.speaker == "assistant" and row.is_final and not row.barge_in_interrupted:
return "delivered"
return None
def _caller_low_signal_text(text: str | None) -> bool:
normalized = " ".join(str(text or "").strip().lower().replace(",", " ").replace(".", " ").split())
if not normalized:
return True
return normalized in {
"\u0430\u0433\u0430",
"\u0430\u043b\u043b\u043e",
"\u0434\u0430",
"\u0434\u043e\u0431\u0440\u044b\u0439 \u0434\u0435\u043d\u044c",
"\u0437\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435",
"\u043b\u0430\u0434\u043d\u043e",
"\u043d\u0435\u0442",
"\u043d\u0435\u0430",
"\u043e\u0439",
"\u043e\u043a",
"\u043f\u0440\u0438\u0432\u0435\u0442",
"\u0441\u043b\u044b\u0448\u043d\u043e",
"\u0441\u043b\u044b\u0448\u0443",
"\u0443\u0433\u0443",
"\u0445\u043e\u0440\u043e\u0448\u043e",
"\u044f\u0441\u043d\u043e",
}
def _caller_segment_intent_bearing(row: VoiceTranscriptSegmentRow) -> bool:
payload = _segment_payload(row)
marker = payload.get("intent_bearing")
if isinstance(marker, bool):
return marker
text = str(row.text or "").strip().lower()
if _caller_low_signal_text(text):
return False
markers = (
"\u0433\u0440\u0430\u0444\u0438\u043a",
"\u0432\u0440\u0435\u043c\u044f \u0440\u0430\u0431\u043e\u0442\u044b",
"\u0440\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b",
"\u0430\u0434\u0440\u0435\u0441",
"\u0444\u0438\u043b\u0438\u0430\u043b",
"\u0433\u043e\u0440\u043e\u0434",
"\u0437\u0430\u044f\u0432\u043a",
"\u0441\u0442\u0430\u0442\u0443\u0441",
"\u0442\u0430\u0440\u0438\u0444",
"\u0446\u0435\u043d\u0430",
"\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c",
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
"\u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440",
"\u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442",
"\u043e\u0448\u0438\u0431\u043a",
"\u043f\u0440\u043e\u0431\u043b\u0435\u043c",
"\u0445\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c",
"\u043c\u043d\u0435 \u043d\u0430\u0434\u043e",
"\u043c\u043d\u0435 \u043d\u0443\u0436\u043d\u043e",
)
return any(marker in text for marker in markers)
def _latest_customer_request_text(session, *, voice_session_id: str) -> str | None:
rows = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
.where(VoiceTranscriptSegmentRow.speaker == "caller")
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
.order_by(VoiceTranscriptSegmentRow.sequence_no.desc(), VoiceTranscriptSegmentRow.id.desc())
).scalars().all()
if not rows:
return None
for row in rows:
text = _truncate(row.text, 4000)
if text and _caller_segment_intent_bearing(row):
return text
for row in rows:
text = _truncate(row.text, 4000)
if text and not _caller_low_signal_text(text):
return text
return _truncate(rows[0].text, 4000)
def _latest_delivered_assistant_text(session, *, voice_session_id: str) -> str | None:
rows = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
.where(VoiceTranscriptSegmentRow.speaker == "assistant")
.order_by(VoiceTranscriptSegmentRow.sequence_no.desc(), VoiceTranscriptSegmentRow.id.desc())
).scalars().all()
for row in rows:
if _segment_delivery_state(row) != "delivered":
continue
text = _truncate(row.text, 4000)
if text:
return text
return None
def _latest_segment_text(
session,
*,
voice_session_id: str,
speaker: str,
final_only: bool = False,
) -> str | None:
normalized_speaker = str(speaker or "").strip().lower()
if normalized_speaker == "caller":
return _latest_customer_request_text(session, voice_session_id=voice_session_id)
if normalized_speaker == "assistant":
if final_only:
return _latest_delivered_assistant_text(session, voice_session_id=voice_session_id)
rows = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
.where(VoiceTranscriptSegmentRow.speaker == "assistant")
.order_by(VoiceTranscriptSegmentRow.id.desc())
).scalars().all()
for row in rows:
text = _truncate(row.text, 4000)
if text:
return text
return None
query = (
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
.where(VoiceTranscriptSegmentRow.speaker == speaker)
)
if final_only:
query = query.where(VoiceTranscriptSegmentRow.is_final.is_(True))
row = session.execute(query.order_by(VoiceTranscriptSegmentRow.id.desc()).limit(1)).scalar_one_or_none()
return _truncate(row.text if row else None, 4000)
def _summary_transcript_segments(
session,
*,
voice_session_id: str,
) -> list[VoiceAISummaryTranscriptSegmentOut]:
rows = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
.order_by(VoiceTranscriptSegmentRow.sequence_no.asc(), VoiceTranscriptSegmentRow.id.asc())
).scalars().all()
result: list[VoiceAISummaryTranscriptSegmentOut] = []
for row in rows:
text = _truncate(row.text, 2000)
if not text:
continue
speaker = str(row.speaker or "").strip().lower()
if speaker == "caller":
if not row.is_final:
continue
elif speaker == "assistant":
if _segment_delivery_state(row) != "delivered":
continue
else:
continue
result.append(
VoiceAISummaryTranscriptSegmentOut(
speaker=speaker,
text=text,
sequence_no=int(row.sequence_no or 0),
source_type=str(row.source_type or "").strip() or "unknown",
created_at=_truncate(row.created_at, 64) or utc_now_iso(),
interrupted=bool(row.barge_in_interrupted),
)
)
return result
def voice_ai_summary_for_call(call_id: str) -> VoiceAISummaryOut | None:
session = get_session()
try:
link = session.execute(
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id)
).scalar_one_or_none()
if link is None:
raise HTTPException(status_code=404, detail="Live call not found")
voice_session = None
if str(link.voice_session_id or "").strip():
voice_session = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == link.voice_session_id)
).scalar_one_or_none()
if voice_session is None:
voice_session = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == call_id)
).scalar_one_or_none()
if voice_session is None:
return None
ai_session = None
ai_session_id = (
str(link.ai_session_id or "").strip()
or str(voice_session.ai_session_id or "").strip()
)
if ai_session_id:
ai_session = session.execute(
select(AISessionRow).where(AISessionRow.session_id == ai_session_id)
).scalar_one_or_none()
transcript_segments = _summary_transcript_segments(
session,
voice_session_id=voice_session.session_id,
)
customer_request_text = _latest_segment_text(
session,
voice_session_id=voice_session.session_id,
speaker="caller",
) or "Последняя реплика клиента недоступна."
assistant_reply_text = _latest_segment_text(
session,
voice_session_id=voice_session.session_id,
speaker="assistant",
final_only=True,
)
handoff_reason = (
_truncate(voice_session.handoff_reason, 4000)
or _truncate(ai_session.handoff_reason if ai_session else None, 4000)
or _truncate(link.ai_handoff_reason, 4000)
or ""
)
customer_name_status = (
_truncate(voice_session.customer_name_status, 64)
or _truncate(link.customer_name_status, 64)
or None
)
customer_name_value = (
_truncate(voice_session.customer_name_value, 256)
or _truncate(link.customer_name_value, 256)
or None
)
customer_name_source = (
_truncate(voice_session.customer_name_source, 64)
or _truncate(link.customer_name_source, 64)
or None
)
voice_start_language = (
_truncate(voice_session.voice_start_language, 16)
or _truncate(link.voice_start_language, 16)
or _truncate(voice_session.language, 16)
or None
)
ai_outcome_text = (
assistant_reply_text
or _truncate(ai_session.summary_text if ai_session else None, 4000)
or handoff_reason
)
if not ai_outcome_text and not handoff_reason and not transcript_segments:
return None
handoff_states = {"handoff_requested", "handoff_required", "human_owned"}
is_handoff = (
str(link.ai_state or "") in handoff_states
or str(voice_session.status or "") in handoff_states
or bool(handoff_reason)
)
unresolved_name = customer_name_status in {"name_not_obtained", "name_followup_required"}
recommended_next_step = (
"Проверьте контекст звонка и продолжайте разговор вручную."
if is_handoff
else "Продолжайте разговор, учитывая уже собранный AI контекст."
)
if is_handoff and unresolved_name:
recommended_next_step = (
"Проверьте контекст звонка, продолжайте разговор вручную и уточните имя клиента."
)
return VoiceAISummaryOut(
call_id=call_id,
session_id=ai_session.session_id if ai_session else None,
voice_session_id=voice_session.session_id,
status_label=(
"AI передал звонок оператору"
if is_handoff
else "AI ответил клиенту"
),
status_tone="handoff" if is_handoff else "answered",
customer_name_status=customer_name_status,
customer_name_value=customer_name_value,
customer_name_source=customer_name_source,
voice_start_language=voice_start_language,
customer_request_text=customer_request_text,
ai_outcome_text=ai_outcome_text or "AI обработал обращение без дополнительной сводки.",
handoff_reason=handoff_reason,
recommended_next_step=recommended_next_step,
generated_at=(
_truncate(link.ai_last_model_at, 64)
or _truncate(voice_session.updated_at, 64)
or _truncate(ai_session.updated_at if ai_session else None, 64)
or utc_now_iso()
),
transcript_segments=transcript_segments,
)
finally:
session.close()