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()