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.
1099 lines
41 KiB
Python
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()
|