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], ) -> dict[str, Any]: bridge = _bridge_app() 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": { "queue_code": queue_code, "direction": "inbound", }, }, 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 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), ) 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, }, ) channel = _resolve_handoff_channel(session, link) 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 = body.voice_session_id link.ai_session_id = body.ai_session_id or link.ai_session_id or voice_session.ai_session_id link.ai_state = "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 link.updated_at = now voice_session.ai_session_id = body.ai_session_id or voice_session.ai_session_id 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 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, }, ) 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) 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 "" ) 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) ) 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_request_text=customer_request_text, ai_outcome_text=ai_outcome_text or "AI обработал обращение без дополнительной сводки.", handoff_reason=handoff_reason, recommended_next_step=( "Проверьте контекст звонка и продолжайте разговор вручную." if is_handoff else "Продолжайте разговор, учитывая уже собранный AI контекст." ), 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()