Files
call-center/services/asterisk_bridge_service/voice_ai.py
T
arys 1dcfaf46cf feat: no-answer retry, agent status machine, escalation events/timeline (ТЗ §13-15,22,25-27,37, AC-08,16-19)
Phase 2 of the L1->L2 routing engine (Phase 1: MR!4).

- ami_loop() now also captures native AMI DialEnd/Hangup frames (not
  only UserEvent), needed to detect that an escalated agent did not
  answer. No dialplan change required - Redirect already routes the
  client channel into an existing Dial()-based transfer context, so
  Asterisk emits these events on its own; the listener just wasn't
  reading them before.
- retry_escalation_no_answer(): on NOANSWER/BUSY/CANCEL/CHANUNAVAIL/
  CONGESTION, releases the non-answering agent, excludes it, and
  reserves+redirects to the next available agent via the routing
  engine's existing exclude_agent_ids support. Exhausted pool marks
  the escalation failed and leaves the call with the AI instead of
  dropping the client (ТЗ §32).
- Agent status now actually moves through
  RESERVED -> RINGING -> TALKING -> AFTER_CALL_WORK -> AVAILABLE
  instead of staying stuck on RESERVED for the whole call; a new
  acw_sweep_loop background thread (same pattern as the existing
  failed_retry_loop) times out AFTER_CALL_WORK back to AVAILABLE.
- escalations gains attempt_count/real_agent_id/attempted_agent_ids_json
  (migration 0033); fixes a latent bug where assigned_agent_id stored
  the SIP extension instead of the real agent_id despite routing-service
  already returning it in RoutingAgentReserveOut.
- Every transition now records an interaction timeline entry and
  publishes the ТЗ §25 event catalog (AgentReserved/AgentRinging/
  AgentNoAnswer/AgentConnected/TransferCompleted/TransferFailed)
  through the existing emit_voice_event/EventOutboxRow idempotent path.

Not in this MR (see plan): SLA config, Callback, L3 (needs real
technical agents from the business), metrics.
2026-08-30 14:19:23 +05:00

1307 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import 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 _append_escalation_timeline(
escalation: EscalationRow,
link: AsteriskCallLinkRow,
*,
action: str,
extra: dict[str, Any] | None = None,
) -> None:
try:
append_interaction_timeline(
interaction_id=link.interaction_id,
action=action,
metadata={
"call_id": escalation.call_id,
"escalation_id": escalation.escalation_id,
"from_level": escalation.from_level,
"to_level": escalation.to_level,
"attempt_count": escalation.attempt_count,
**(extra or {}),
},
)
except Exception:
pass
def _emit_escalation_event(
*,
event_type: str,
escalation: EscalationRow,
link: AsteriskCallLinkRow,
extra: dict[str, Any] | None = None,
) -> None:
bridge = _bridge_app()
try:
bridge._emit_voice_event(
event_type=event_type,
call_id=escalation.call_id,
interaction_id=link.interaction_id,
payload={
"escalation_id": escalation.escalation_id,
"tenant_id": escalation.tenant_id,
"from_level": escalation.from_level,
"to_level": escalation.to_level,
"reason_code": escalation.reason_code,
"attempt_count": escalation.attempt_count,
**(extra or {}),
},
)
except Exception:
LOGGER.warning("bridge.escalation_event_failed event_type=%s call_id=%s", event_type, escalation.call_id)
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,
exclude_agent_ids: list[str] | None = None,
) -> dict[str, Any] | 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": exclude_agent_ids or [],
},
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
agent_id = str((response or {}).get("agent_id") or "").strip()
extension = str((response or {}).get("extension") or "").strip()
if not agent_id or not extension:
return None
return {
"agent_id": agent_id,
"extension": extension,
"endpoint": (response or {}).get("endpoint"),
"display_name": (response or {}).get("display_name"),
}
def set_routing_agent_status(agent_id: str, status: str) -> None:
bridge = _bridge_app()
try:
bridge._patch_json(
f"{bridge._routing_service_url()}/internal/routing/agents/{agent_id}/status",
{"status": status},
timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(),
max_attempts=1,
retry_backoff_seconds=0.0,
)
except Exception:
LOGGER.warning("bridge.routing_set_status_failed agent_id=%s status=%s", agent_id, status)
def _redirect_channel_to_agent(*, channel: str, extension: str) -> None:
bridge = _bridge_app()
bridge._ami_action(
"Redirect",
{
"Channel": channel,
"Context": bridge._transfer_context(),
"Exten": extension,
"Priority": 1,
},
)
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_agent = _reserve_routing_agent(
call_id=call_id,
level=level,
tenant_id=resolved_tenant_id,
required_skills=required_skills,
)
if reserved_agent:
return queue_code, reserved_agent["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,
attempt_count=row.attempt_count or 0,
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)
agent = _reserve_routing_agent(
call_id=call_id,
level=body.target_level,
tenant_id=link.tenant_id,
required_skills=body.required_skills,
)
if not agent:
escalation.status = "failed"
escalation.completed_at = now
session.commit()
_emit_escalation_event(
event_type="TransferFailed",
escalation=escalation,
link=link,
extra={"reason": "no_available_agent"},
)
raise HTTPException(status_code=409, detail=f"No available {body.target_level} agent right now")
escalation.real_agent_id = agent["agent_id"]
escalation.attempted_agent_ids_json = json.dumps([agent["agent_id"]], ensure_ascii=False)
session.flush()
_append_escalation_timeline(escalation, link, action="escalation.agent_reserved", extra={"agent_id": agent["agent_id"]})
_emit_escalation_event(event_type="AgentReserved", escalation=escalation, link=link, extra={"agent_id": agent["agent_id"]})
try:
_redirect_channel_to_agent(channel=channel, extension=agent["extension"])
except Exception as exc:
try:
bridge._release_routing_agent(call_id)
except Exception:
LOGGER.warning("bridge.escalation_release_agent_failed call_id=%s", call_id)
escalation.status = "failed"
escalation.completed_at = utc_now_iso()
session.commit()
_append_escalation_timeline(escalation, link, action="escalation.transfer_failed", extra={"agent_id": agent["agent_id"], "error": str(exc)})
_emit_escalation_event(event_type="TransferFailed", escalation=escalation, link=link, extra={"agent_id": agent["agent_id"], "error": str(exc)})
raise HTTPException(status_code=502, detail=f"Failed to redirect call to agent: {exc}") from exc
set_routing_agent_status(agent["agent_id"], "RINGING")
escalation.status = "ringing"
escalation.assigned_agent_id = agent["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()
_append_escalation_timeline(escalation, link, action="escalation.agent_ringing", extra={"agent_id": agent["agent_id"]})
_emit_escalation_event(event_type="AgentRinging", escalation=escalation, link=link, extra={"agent_id": agent["agent_id"]})
return _escalation_to_out(escalation)
finally:
session.close()
def retry_escalation_no_answer(session, *, call_id: str, dial_outcome: str) -> None:
"""AC-08 / ТЗ §14: агент не ответил — освободить его и попробовать следующего.
Вызывается из bridge_processing при нативном AMI DialEnd/Hangup с исходом
NOANSWER/BUSY/CANCEL/CHANUNAVAIL/CONGESTION на канале агента, зарезервированного
под активную (status='ringing') эскалацию этого call_id.
"""
bridge = _bridge_app()
escalation = session.execute(
select(EscalationRow).where(
EscalationRow.call_id == call_id,
EscalationRow.status == "ringing",
)
).scalar_one_or_none()
if escalation is None:
return
link = bridge._find_call_link(session, call_id)
if link is None:
return
now = utc_now_iso()
no_answer_agent_id = escalation.real_agent_id
if no_answer_agent_id:
try:
routing_release_by_agent_id(no_answer_agent_id)
except Exception:
LOGGER.warning("bridge.escalation_retry_release_failed call_id=%s agent_id=%s", call_id, no_answer_agent_id)
escalation.attempt_count = (escalation.attempt_count or 0) + 1
session.flush()
_append_escalation_timeline(
escalation,
link,
action="escalation.agent_no_answer",
extra={"agent_id": no_answer_agent_id, "dial_outcome": dial_outcome},
)
_emit_escalation_event(
event_type="AgentNoAnswer",
escalation=escalation,
link=link,
extra={"agent_id": no_answer_agent_id, "dial_outcome": dial_outcome},
)
attempted_ids = json.loads(escalation.attempted_agent_ids_json or "[]")
channel = _resolve_handoff_channel(session, link)
required_skills = json.loads(escalation.required_skills_json or "[]")
next_agent = _reserve_routing_agent(
call_id=call_id,
level=escalation.to_level,
tenant_id=escalation.tenant_id,
required_skills=required_skills,
exclude_agent_ids=attempted_ids,
)
if not next_agent:
escalation.status = "failed"
escalation.completed_at = now
session.commit()
_append_escalation_timeline(escalation, link, action="escalation.failed_no_agents")
_emit_escalation_event(event_type="TransferFailed", escalation=escalation, link=link, extra={"reason": "no_more_agents"})
LOGGER.warning("bridge.escalation_retry_exhausted call_id=%s attempts=%s", call_id, escalation.attempt_count)
return
escalation.real_agent_id = next_agent["agent_id"]
escalation.attempted_agent_ids_json = json.dumps(attempted_ids + [next_agent["agent_id"]], ensure_ascii=False)
session.flush()
_append_escalation_timeline(escalation, link, action="escalation.agent_reserved", extra={"agent_id": next_agent["agent_id"]})
_emit_escalation_event(event_type="AgentReserved", escalation=escalation, link=link, extra={"agent_id": next_agent["agent_id"]})
try:
_redirect_channel_to_agent(channel=channel, extension=next_agent["extension"])
except Exception as exc:
try:
routing_release_by_agent_id(next_agent["agent_id"])
except Exception:
pass
escalation.status = "failed"
escalation.completed_at = utc_now_iso()
session.commit()
_append_escalation_timeline(escalation, link, action="escalation.transfer_failed", extra={"agent_id": next_agent["agent_id"], "error": str(exc)})
_emit_escalation_event(event_type="TransferFailed", escalation=escalation, link=link, extra={"agent_id": next_agent["agent_id"], "error": str(exc)})
return
set_routing_agent_status(next_agent["agent_id"], "RINGING")
escalation.assigned_agent_id = next_agent["extension"]
session.commit()
_append_escalation_timeline(escalation, link, action="escalation.agent_ringing", extra={"agent_id": next_agent["agent_id"]})
_emit_escalation_event(event_type="AgentRinging", escalation=escalation, link=link, extra={"agent_id": next_agent["agent_id"]})
def routing_release_by_agent_id(agent_id: str, *, next_status: str = "AVAILABLE") -> None:
bridge = _bridge_app()
bridge._post_json(
f"{bridge._routing_service_url()}/internal/routing/release-agent",
{"agent_id": agent_id, "next_status": next_status},
timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(),
max_attempts=1,
retry_backoff_seconds=0.0,
)
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()