795 lines
30 KiB
Python
795 lines
30 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 (
|
|
VoiceAICallStateUpdateIn,
|
|
VoiceAIHandoffRequestIn,
|
|
VoiceAIMediaBridgeEventIn,
|
|
VoiceAISummaryOut,
|
|
VoiceAISummaryTranscriptSegmentOut,
|
|
VoiceLiveCallOut,
|
|
)
|
|
from services.shared.sql_models import AISessionRow, AsteriskCallLinkRow, 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 _resolve_handoff_extension(target_queue_id: str | None, fallback_queue_code: str | None) -> tuple[str, str]:
|
|
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")
|
|
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
|
|
|
|
|
|
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 = _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),
|
|
)
|
|
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 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 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 _latest_segment_text(
|
|
session,
|
|
*,
|
|
voice_session_id: str,
|
|
speaker: str,
|
|
final_only: bool = False,
|
|
) -> str | 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)
|
|
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
|
|
.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 not in {"caller", "assistant"}:
|
|
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()
|