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.
1307 lines
50 KiB
Python
1307 lines
50 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 _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()
|