"""Voice routes for the OpenAI STT/TTS cascade over WebSocket. Client sends JSON controls plus binary audio chunks: audio_start -> binary audio chunks -> audio_end Server emits: ready -> state(listening) -> state(thinking) -> transcript -> reply -> state(speaking) -> binary audio chunks -> tts_end -> state(idle) When voice is not configured, the route reports degraded state and closes cleanly instead of crashing. """ from __future__ import annotations import asyncio import json import hashlib import hmac import time from dataclasses import dataclass, field as dataclass_field from typing import Optional from uuid import NAMESPACE_URL, uuid5 from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, status from fastapi.responses import JSONResponse, Response from pydantic import BaseModel, Field from starlette.websockets import WebSocketState from .. import session_persistence, turn_runtime from ..auth_sessions import get_session, user_has_consent, user_onboarding_complete from ..config import settings from ..deps import CurrentPrincipal, Principal, Role from ..engine_client import EngineError, engine_client from ..persona_repository import ( PersonaVoiceMap, get_catalog_persona, get_persona_voice_map, get_session_voice_map, ) from ..runtime_policy import require_runtime_fallback_allowed from ..services import ( evaluator, guardrail, multimodal_alliance, multimodal_alliance_store, orchestrator, rupture_scenario_director, state_machine, ) from ..services import voice as voice_svc from ..services.voice import ( StreamingTranscriptEvent, TranscriptResult, VoicePreset, VoiceUnavailable, resolve_voice, voice_service, ) from ..services.voice_runtime import ( VOICE_AUDIO_BUFFER_MAX_BYTES, VOICE_STREAMING_EVENT_QUEUE_MAX_ITEMS, VOICE_UVICORN_WS_MAX_QUEUE, voice_runtime_metrics, ) from ..store import InProcSession, TurnRecord, store router = APIRouter(prefix="/voice", tags=["voice"]) class VoiceSpeechRequest(BaseModel): """Request OpenAI TTS for an already-persisted client reply.""" session_id: str = Field(min_length=1, max_length=80) turn_seq: int = Field(ge=1) @dataclass(frozen=True, slots=True) class VoiceSessionContext: session_id: str principal: Principal voice_preset: VoicePreset @dataclass(frozen=True, slots=True) class VoiceProsody: audio_ref: str | None = None duration_s: float | None = None silence_ms: int | None = None speech_rate: float | None = None barge_in: bool | None = None provider_events: list[dict[str, object]] = dataclass_field(default_factory=list) @dataclass(frozen=True, slots=True) class VoiceTurnInput: learner_text: str prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody) @dataclass(frozen=True, slots=True) class VoiceAudioInput: audio: bytes fmt: str | None sample_rate: int | None = None channels: int | None = None sample_width: int | None = None audio_started_at: float | None = None audio_ended_at: float | None = None prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody) # WebSocket close codes. WS_CLOSE_DEGRADED = 1011 WS_CLOSE_BAD_REQUEST = 1008 WS_CLOSE_UNAUTHORIZED = 1008 # Per-utterance audio cap to avoid unbounded memory growth. _MAX_AUDIO_BYTES = VOICE_AUDIO_BUFFER_MAX_BYTES _STREAMING_EVENT_QUEUE_MAX_ITEMS = VOICE_STREAMING_EVENT_QUEUE_MAX_ITEMS _STREAMING_CONSENT_RECHECK_SECONDS = 1.0 _PROVIDER_EVENT_MAX_ITEMS = 12 _PROVIDER_EVENT_MAX_STRING = 80 _PROVIDER_EVENT_ALLOWED_KEYS = { "category", "event_type", "type", "kind", "label", "source", "provider", "model", "start_ms", "end_ms", "duration_ms", "confidence", "score", "is_final", } _PROVIDER_EVENT_TAXONOMY = { "barge_in": ("barge_in", "turn_taking"), "interrupt": ("barge_in", "turn_taking"), "interruption": ("barge_in", "turn_taking"), "overlap": ("barge_in", "turn_taking"), "sigh": ("sigh", "paralinguistic"), "sighing": ("sigh", "paralinguistic"), "sob": ("cry", "paralinguistic"), "cry": ("cry", "paralinguistic"), "crying": ("cry", "paralinguistic"), "weep": ("cry", "paralinguistic"), "laugh": ("laugh", "paralinguistic"), "laughter": ("laugh", "paralinguistic"), "breath": ("breath", "paralinguistic"), "breathing": ("breath", "paralinguistic"), "voice_activity": ("voice_activity", "speech_activity"), "vad": ("voice_activity", "speech_activity"), "speech_start": ("speech_start", "speech_activity"), "speech_end": ("speech_end", "speech_activity"), "speech_final": ("speech_final", "speech_activity"), "stt_word": ("stt_word", "timing"), "silence": ("silence", "timing"), "pause": ("silence", "timing"), "long_pause": ("silence", "timing"), "speech_rate": ("speech_rate", "prosody"), "fast_speech": ("speech_rate", "prosody"), "slow_speech": ("speech_rate", "prosody"), "pitch": ("pitch", "prosody"), "intonation": ("intonation", "prosody"), "prosody": ("prosody", "prosody"), "noise": ("background_noise", "audio_quality"), "background_noise": ("background_noise", "audio_quality"), } def _is_turn_persistence_unavailable(exc: Exception) -> bool: if not isinstance(exc, HTTPException) or exc.status_code != 503: return False detail = str(exc.detail or "") return "turn append" in detail and "persistence unavailable" in detail _PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label") def _append_audio_chunk_with_cap(buffer: bytearray, chunk: bytes) -> bool: """Append only when the route-owned buffer remains within its hard cap.""" if len(chunk) > _MAX_AUDIO_BYTES - len(buffer): return False buffer.extend(chunk) return True @router.get("/health") async def voice_health() -> JSONResponse: """Return voice service readiness.""" available = voice_service.is_available() stt_available = voice_service.stt_available() tts_available = voice_service.tts_available() tts_provider = voice_service.tts_provider() body = { "status": "ok" if available else "degraded", "available": available, "stt_available": stt_available, "tts_available": tts_available, "stt_provider": voice_service.stt_provider(), "stt_model": voice_service.stt_model(), "stt_batch_fallback_available": voice_service.batch_stt_available(), "tts_model": ( voice_svc.MELOTTS_TTS_MODEL if tts_provider == "melotts" else ( voice_svc.HIGGS_TTS_MODEL if tts_provider == "higgs" else voice_svc.TTS_MODEL ) ), "tts_provider": tts_provider, "limits": { "max_utterance_audio_bytes": _MAX_AUDIO_BYTES, "streaming_event_queue_max_items": _STREAMING_EVENT_QUEUE_MAX_ITEMS, "uvicorn_ws_max_queue": VOICE_UVICORN_WS_MAX_QUEUE, }, "reason": ( None if available else "STT 또는 TTS provider가 준비되지 않았습니다" ), } return JSONResponse(body, status_code=200 if available else 503) @router.post("/speech") async def voice_speech( body: VoiceSpeechRequest, principal: CurrentPrincipal ) -> Response: """Synthesize the persisted client reply for a completed text turn. The browser sends only session/turn identifiers. The server reloads the owned session and speaks the stored client-visible reply, so this endpoint cannot be used as an arbitrary paid text-to-speech proxy. """ learner = principal if learner.role != Role.LEARNER and learner.can_access_role(Role.LEARNER): learner = learner.with_role(Role.LEARNER) if learner.role != Role.LEARNER: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="only learners can use voice", ) access_error = await _practice_access_error(learner) if access_error is not None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=access_error) sess, err = await turn_runtime.load_owned_session( body.session_id, learner, allow_ended=False, ) if sess is None: status_code = { turn_runtime.SessionAccessError.FORBIDDEN: status.HTTP_403_FORBIDDEN, turn_runtime.SessionAccessError.ENDED: status.HTTP_409_CONFLICT, }.get(err, status.HTTP_404_NOT_FOUND) raise HTTPException( status_code=status_code, detail=f"voice session {err or 'not_found'}" ) text = _client_turn_text_for_speech(sess, body.turn_seq) if text is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="client reply not found for turn", ) voice_preset = await _resolve_session_voice( session_id=body.session_id, persona_code=sess.persona.code, explicit_preset=None, ) if not voice_service.tts_available(voice_preset): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="TTS provider is not configured", ) try: chunks = [ chunk.audio async for chunk in voice_service.synthesize_stream(text, voice_preset) ] except VoiceUnavailable as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc), ) from exc except Exception as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"TTS failed: {exc}", ) from exc audio = b"".join(chunks) if not audio: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="client reply has no speakable text", ) return Response( content=audio, media_type=voice_service.tts_media_type_for_voice(voice_preset), headers={ "Cache-Control": "no-store", "X-Vignette-TTS-Model": voice_service.tts_model_for_voice(voice_preset), "X-Vignette-TTS-Provider": voice_service.tts_provider_for_voice(voice_preset), }, ) @router.websocket("/ws") async def voice_ws(websocket: WebSocket) -> None: """Run one authenticated learner voice cascade.""" await websocket.accept() # Authenticate the same server-side browser session used by REST routes. principal = await _principal_from_websocket(websocket) if principal is None: await _safe_send_json( websocket, {"type": "error", "detail": "not authenticated"} ) await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED) return if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER): principal = principal.with_role(Role.LEARNER) if principal.role != Role.LEARNER: await _safe_send_json( websocket, {"type": "error", "detail": "only learners can use voice"} ) await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED) return # Bind to an existing session first. persona_code creation is dev-only. session_id, voice_preset, err, bind_meta = await _bind_session(websocket, principal) if err is not None: await _safe_send_json(websocket, {"type": "error", "detail": err}) await _safe_close(websocket, WS_CLOSE_BAD_REQUEST) return assert session_id is not None and voice_preset is not None if bind_meta.get("degraded"): await _safe_send_json( websocket, { "type": "degraded", "reason": bind_meta.get( "degraded_reason", "voice session binding degraded" ), **bind_meta, }, ) # Voice misconfiguration is reported explicitly and then closed cleanly. if not voice_service.is_available(): await _safe_send_json( websocket, {"type": "degraded", "reason": "voice STT/TTS is not configured"}, ) await _safe_close(websocket, WS_CLOSE_DEGRADED) return await _safe_send_json( websocket, { "type": "ready", "session_id": session_id, "voice": voice_preset.openai_voice, "preset": voice_preset.preset, "stt_provider": voice_service.stt_provider(), "stt_model": voice_service.stt_model(), "stt_batch_fallback_available": voice_service.batch_stt_available(), "tts_provider": voice_service.tts_provider_for_voice(voice_preset), "tts_model": voice_service.tts_model_for_voice(voice_preset), "limits": { "max_utterance_audio_bytes": _MAX_AUDIO_BYTES, "streaming_event_queue_max_items": _STREAMING_EVENT_QUEUE_MAX_ITEMS, "uvicorn_ws_max_queue": VOICE_UVICORN_WS_MAX_QUEUE, }, "state": "idle", **bind_meta, }, ) runtime_connection_id = voice_runtime_metrics.websocket_opened() audio_buf = bytearray() receiving = False audio_started_at: float | None = None last_audio_end_at: float | None = None audio_format: str | None = None audio_sample_rate: int | None = None audio_channels: int | None = None audio_sample_width: int | None = None streaming_session: voice_svc.DeepgramStreamingSession | None = None streaming_consent_checked_at: float | None = None streaming_events: asyncio.Queue[StreamingTranscriptEvent] = asyncio.Queue( maxsize=_STREAMING_EVENT_QUEUE_MAX_ITEMS ) discard_audio_until_end = False last_stream_transcript: tuple[str, bool] | None = None try: while True: msg = await websocket.receive() mtype = msg.get("type") if mtype == "websocket.disconnect": break # Binary frames are audio chunks. if msg.get("bytes") is not None: if discard_audio_until_end: continue if not receiving: # Be tolerant when audio arrives before audio_start. receiving = True audio_started_at = time.monotonic() audio_buf.clear() voice_runtime_metrics.audio_buffer_cleared( runtime_connection_id ) await _safe_send_json( websocket, {"type": "state", "state": "listening"} ) chunk = msg["bytes"] if not _append_audio_chunk_with_cap(audio_buf, chunk): voice_runtime_metrics.audio_overflow_rejected( runtime_connection_id ) if streaming_session is not None: await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None await _safe_send_json( websocket, { "type": "error", "detail": "audio too large; please send a shorter utterance", }, ) await _safe_send_json( websocket, {"type": "state", "state": "idle"} ) audio_buf.clear() voice_runtime_metrics.audio_buffer_cleared( runtime_connection_id ) receiving = False discard_audio_until_end = True continue voice_runtime_metrics.audio_chunk_received( runtime_connection_id, current_buffer_bytes=len(audio_buf), chunk_bytes=len(chunk), ) if streaming_session is not None: now = time.monotonic() if ( streaming_consent_checked_at is None or now - streaming_consent_checked_at >= _STREAMING_CONSENT_RECHECK_SECONDS ): if not await _multimodal_voice_processing_allowed( websocket, session_id=session_id, principal=principal, ): await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None discard_audio_until_end = True receiving = False continue streaming_consent_checked_at = now try: await streaming_session.send_audio(chunk) await asyncio.sleep(0) drained = await _drain_streaming_transcripts( websocket, streaming_events ) voice_runtime_metrics.streaming_queue_observed( runtime_connection_id, queue_items=streaming_events.qsize(), ) if drained is not None: last_stream_transcript = drained except Exception: await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None if voice_service.batch_stt_available(): voice_runtime_metrics.provider_fallback() await _safe_send_json( websocket, { "type": "degraded", "reason": "streaming STT unavailable; using batch fallback", }, ) else: discard_audio_until_end = True receiving = False await _safe_send_json( websocket, { "type": "error", "code": "streaming_stt_unavailable", "detail": "streaming STT failed and no batch fallback is configured", }, ) await _safe_send_json( websocket, {"type": "state", "state": "idle"} ) continue # Text frames are JSON controls. text = msg.get("text") if text is None: continue try: ctrl = json.loads(text) except (json.JSONDecodeError, TypeError): await _safe_send_json( websocket, {"type": "error", "detail": "invalid control json"} ) continue ctype = ctrl.get("type") if ctype == "audio_start": if streaming_session is not None: await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None receiving = True discard_audio_until_end = False last_stream_transcript = None while not streaming_events.empty(): streaming_events.get_nowait() voice_runtime_metrics.streaming_queue_observed( runtime_connection_id, queue_items=0, ) audio_started_at = time.monotonic() audio_format = _safe_str(ctrl.get("format")) audio_sample_rate = _safe_int(ctrl.get("sample_rate")) audio_channels = _safe_int(ctrl.get("channels")) audio_sample_width = _safe_int(ctrl.get("sample_width")) audio_buf.clear() voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id) if voice_service.can_stream_audio( fmt=audio_format, sample_rate=audio_sample_rate, channels=audio_channels, sample_width=audio_sample_width, ): if not await _multimodal_voice_processing_allowed( websocket, session_id=session_id, principal=principal, ): receiving = False discard_audio_until_end = True else: async def queue_streaming_event( event: StreamingTranscriptEvent, ) -> None: queue_was_full = streaming_events.full() queue_wait_started = time.perf_counter() await streaming_events.put(event) voice_runtime_metrics.streaming_queue_observed( runtime_connection_id, queue_items=streaming_events.qsize(), saturated=queue_was_full, wait_seconds=( time.perf_counter() - queue_wait_started ), ) try: streaming_session = ( await voice_service.open_streaming_transcription( fmt=audio_format, sample_rate=audio_sample_rate, channels=audio_channels, sample_width=audio_sample_width, on_event=queue_streaming_event, ) ) streaming_consent_checked_at = time.monotonic() except Exception: if voice_service.batch_stt_available(): voice_runtime_metrics.provider_fallback() await _safe_send_json( websocket, { "type": "degraded", "reason": "streaming STT unavailable; using batch fallback", }, ) else: receiving = False discard_audio_until_end = True await _safe_send_json( websocket, { "type": "error", "code": "streaming_stt_unavailable", "detail": "streaming STT is unavailable and no batch fallback is configured", }, ) await _safe_send_json( websocket, {"type": "state", "state": "idle"} ) if not discard_audio_until_end: await _safe_send_json( websocket, {"type": "state", "state": "listening"} ) elif ctype == "audio_end": receiving = False audio_ended_at = time.monotonic() silence_ms = _safe_int(ctrl.get("silence_ms")) if ( silence_ms is None and last_audio_end_at is not None and audio_started_at is not None ): silence_ms = max( 0, int((audio_started_at - last_audio_end_at) * 1000) ) end_format = _safe_str(ctrl.get("format")) or audio_format context = VoiceSessionContext(session_id, principal, voice_preset) utterance = VoiceAudioInput( audio=bytes(audio_buf), fmt=end_format, sample_rate=_safe_int(ctrl.get("sample_rate")) or audio_sample_rate, channels=_safe_int(ctrl.get("channels")) or audio_channels, sample_width=_safe_int(ctrl.get("sample_width")) or audio_sample_width, audio_started_at=audio_started_at, audio_ended_at=audio_ended_at, prosody=VoiceProsody( silence_ms=silence_ms, barge_in=_safe_bool(ctrl.get("barge_in")), provider_events=_safe_provider_events( ctrl.get("provider_events") ), ), ) if discard_audio_until_end: if streaming_session is not None: await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None elif streaming_session is not None: if not await _multimodal_voice_processing_allowed( websocket, session_id=session_id, principal=principal, ): await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None else: try: streaming_result, drained = ( await _finish_streaming_transcription( websocket, streaming_session, streaming_events, ) ) voice_runtime_metrics.streaming_queue_observed( runtime_connection_id, queue_items=streaming_events.qsize(), ) streaming_session = None streaming_consent_checked_at = None except Exception: if streaming_session is not None: await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None if voice_service.batch_stt_available(): voice_runtime_metrics.provider_fallback() await _safe_send_json( websocket, { "type": "degraded", "reason": "streaming STT unavailable; using batch fallback", }, ) await _handle_utterance(websocket, context, utterance) else: await _safe_send_json( websocket, { "type": "error", "code": "streaming_stt_unavailable", "detail": "streaming STT finalization failed and no batch fallback is configured", }, ) await _safe_send_json( websocket, {"type": "state", "state": "idle"} ) else: if drained is not None: last_stream_transcript = drained if await _multimodal_voice_processing_allowed( websocket, session_id=session_id, principal=principal, ): await _handle_streaming_utterance( websocket, context, utterance, streaming_result, transcript_already_sent=( last_stream_transcript == (streaming_result.text, True) ), ) else: await _handle_utterance(websocket, context, utterance) last_audio_end_at = audio_ended_at audio_started_at = None audio_format = None audio_sample_rate = None audio_channels = None audio_sample_width = None audio_buf.clear() voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id) discard_audio_until_end = False last_stream_transcript = None elif ctype == "text_turn": # Text-only path for accessibility and deterministic tests. if streaming_session is not None: await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None receiving = False audio_buf.clear() voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id) learner_text = (ctrl.get("text") or "").strip() if learner_text: await _run_turn_and_speak( websocket, VoiceSessionContext(session_id, principal, voice_preset), VoiceTurnInput(learner_text=learner_text), ) elif ctype == "stt_result": if streaming_session is not None: await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None receiving = False audio_buf.clear() voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id) stt_received_at = time.monotonic() await _handle_stt_result_control( websocket, session_id=session_id, principal=principal, voice_preset=voice_preset, ctrl=ctrl, audio_started_at=audio_started_at, audio_ended_at=stt_received_at, last_audio_end_at=last_audio_end_at, ) last_audio_end_at = stt_received_at audio_started_at = None audio_format = None audio_sample_rate = None audio_channels = None audio_sample_width = None elif ctype == "ping": await _safe_send_json(websocket, {"type": "pong"}) elif ctype == "close": if streaming_session is not None: await streaming_session.abort() streaming_session = None streaming_consent_checked_at = None break except WebSocketDisconnect: pass except Exception as e: voice_runtime_metrics.websocket_error() if _is_turn_persistence_unavailable(e): await _safe_send_json( websocket, { "type": "error", "code": "turn_persistence_unavailable", "detail": "voice turn persistence unavailable; retry the utterance", }, ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) else: await _safe_send_json( websocket, {"type": "error", "detail": f"voice ws error: {e}"} ) finally: if streaming_session is not None: await streaming_session.abort() voice_runtime_metrics.websocket_closed(runtime_connection_id) await _safe_close(websocket) async def _multimodal_voice_processing_allowed( websocket: WebSocket, *, session_id: str, principal: Principal, ) -> bool: """Stop before STT/derived processing when G7 consent is not active.""" try: await multimodal_alliance_store.assert_voice_processing_allowed( principal=principal, session_id=session_id, ) except multimodal_alliance_store.MultimodalConsentWithdrawnError as exc: await _safe_send_json( websocket, { "type": "error", "code": "multimodal_consent_withdrawn", "detail": str(exc), }, ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return False except multimodal_alliance_store.MultimodalConsentRequiredError as exc: await _safe_send_json( websocket, { "type": "error", "code": "multimodal_consent_required", "detail": str(exc), }, ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return False except (multimodal_alliance_store.MultimodalAllianceError, RuntimeError): await _safe_send_json( websocket, { "type": "error", "code": "multimodal_consent_unavailable", "detail": "multimodal consent state is unavailable; voice processing is blocked", }, ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return False return True async def _handle_stt_result_control( websocket: WebSocket, *, session_id: str, principal: Principal, voice_preset: VoicePreset, ctrl: dict[str, object], audio_started_at: float | None = None, audio_ended_at: float | None = None, last_audio_end_at: float | None = None, ) -> None: if not await _multimodal_voice_processing_allowed( websocket, session_id=session_id, principal=principal, ): return learner_text = str(ctrl.get("text") or "").strip() transcript_final = _safe_bool(ctrl.get("final")) silence_ms = _safe_int(ctrl.get("silence_ms")) if ( silence_ms is None and last_audio_end_at is not None and audio_started_at is not None ): silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000)) provider_events = _safe_provider_events(ctrl.get("provider_events")) decision = voice_svc.assess_end_of_turn( transcript_text=learner_text, transcript_final=bool(transcript_final), silence_ms=silence_ms, ) await _safe_send_json( websocket, { "type": "eot", "ready": decision.ready, "reason": decision.reason, "silence_ms": decision.silence_ms, "threshold_ms": decision.threshold_ms, }, ) if not decision.ready: await _safe_send_json(websocket, {"type": "state", "state": "listening"}) return await _safe_send_json(websocket, {"type": "state", "state": "thinking"}) await _safe_send_json( websocket, { "type": "transcript", "text": learner_text, "final": True, "speaker": "counselor", }, ) await _run_turn_and_speak( websocket, VoiceSessionContext(session_id, principal, voice_preset), VoiceTurnInput( learner_text=learner_text, prosody=VoiceProsody( duration_s=_elapsed_seconds(audio_started_at, audio_ended_at), silence_ms=decision.silence_ms, barge_in=_safe_bool(ctrl.get("barge_in")), provider_events=provider_events, ), ), ) async def _handle_utterance( websocket: WebSocket, context: VoiceSessionContext, utterance: VoiceAudioInput, ) -> None: """Transcribe one utterance, generate the client reply, then synthesize TTS.""" if not utterance.audio: await _safe_send_json( websocket, {"type": "transcript", "text": "", "final": True} ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return if not await _multimodal_voice_processing_allowed( websocket, session_id=context.session_id, principal=context.principal, ): return # STT begins after the learner stops speaking. await _safe_send_json(websocket, {"type": "state", "state": "thinking"}) upload_audio, upload_fmt = _normalize_audio_upload( utterance.audio, fmt=utterance.fmt, sample_rate=utterance.sample_rate, channels=utterance.channels, sample_width=utterance.sample_width, ) filename, content_type = _audio_meta(upload_fmt) try: stt = await voice_service.transcribe( upload_audio, filename=filename, content_type=content_type ) except VoiceUnavailable as e: await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)}) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return except Exception as e: await _safe_send_json( websocket, {"type": "error", "detail": f"STT failed: {e}"} ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return learner_text = stt.text audio_ref = _voice_audio_ref(upload_audio, upload_fmt) duration_s = stt.duration or _elapsed_seconds( utterance.audio_started_at, utterance.audio_ended_at ) speech_rate = _estimate_speech_rate(learner_text, duration_s) provider_events = _merge_provider_events( utterance.prosody.provider_events, getattr(stt, "provider_events", []), ) await _safe_send_json( websocket, { "type": "transcript", "text": learner_text, "final": True, "speaker": "counselor", }, ) if not learner_text: await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return await _run_turn_and_speak( websocket, context, VoiceTurnInput( learner_text=learner_text, prosody=VoiceProsody( audio_ref=audio_ref, duration_s=duration_s, silence_ms=utterance.prosody.silence_ms, speech_rate=speech_rate, barge_in=utterance.prosody.barge_in, provider_events=provider_events, ), ), ) async def _drain_streaming_transcripts( websocket: WebSocket, events: asyncio.Queue[StreamingTranscriptEvent], ) -> tuple[str, bool] | None: """Relay provider-neutral streaming updates without concurrent ASGI sends.""" last: tuple[str, bool] | None = None while True: try: event = events.get_nowait() except asyncio.QueueEmpty: return last await _relay_streaming_transcript(websocket, event) last = (event.text, event.speech_final) async def _finish_streaming_transcription( websocket: WebSocket, session: voice_svc.DeepgramStreamingSession, events: asyncio.Queue[StreamingTranscriptEvent], ) -> tuple[TranscriptResult, tuple[str, bool] | None]: """Finalize while draining the bounded event queue to avoid producer deadlock.""" finish_task = asyncio.create_task(session.finish()) last: tuple[str, bool] | None = None try: while not finish_task.done(): event_task = asyncio.create_task(events.get()) done, _ = await asyncio.wait( {finish_task, event_task}, return_when=asyncio.FIRST_COMPLETED, ) if event_task in done: event = event_task.result() await _relay_streaming_transcript(websocket, event) last = (event.text, event.speech_final) else: event_task.cancel() try: await event_task except asyncio.CancelledError: pass result = await finish_task drained = await _drain_streaming_transcripts(websocket, events) return result, drained or last except Exception: if not finish_task.done(): finish_task.cancel() raise async def _relay_streaming_transcript( websocket: WebSocket, event: StreamingTranscriptEvent ) -> None: await _safe_send_json( websocket, { "type": "transcript", "text": event.text, # Deepgram is_final seals one segment; speech_final seals the # learner utterance. The browser's `final` contract means the # latter so it never stops capture at an intermediate segment. "final": event.speech_final, "speech_final": event.speech_final, "speaker": "counselor", }, ) async def _handle_streaming_utterance( websocket: WebSocket, context: VoiceSessionContext, utterance: VoiceAudioInput, stt: TranscriptResult, *, transcript_already_sent: bool, ) -> None: """Persist and run one finalized provider-streamed learner utterance.""" learner_text = stt.text.strip() await _safe_send_json(websocket, {"type": "state", "state": "thinking"}) if not transcript_already_sent: await _safe_send_json( websocket, { "type": "transcript", "text": learner_text, "final": True, "speech_final": True, "speaker": "counselor", }, ) if not learner_text: await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return audio_ref = _voice_audio_ref(utterance.audio, utterance.fmt) duration_s = stt.duration or _elapsed_seconds( utterance.audio_started_at, utterance.audio_ended_at ) if stt.words: duration_s = max(duration_s or 0.0, max(word.end for word in stt.words)) speech_rate = _estimate_speech_rate(learner_text, duration_s) provider_events = _merge_provider_events( [ { "type": "stt_metadata", "provider": "deepgram", "model": stt.model, "source": "streaming_stt", "is_final": True, } ], utterance.prosody.provider_events, stt.provider_events, ) try: await _persist_streaming_timeline( context=context, utterance=utterance, stt=stt, audio_ref=audio_ref, duration_s=duration_s, ) except multimodal_alliance_store.MultimodalConsentWithdrawnError as exc: await _safe_send_json( websocket, { "type": "error", "code": "multimodal_consent_withdrawn", "detail": str(exc), }, ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return except multimodal_alliance_store.MultimodalConsentRequiredError as exc: await _safe_send_json( websocket, { "type": "error", "code": "multimodal_consent_required", "detail": str(exc), }, ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return except multimodal_alliance_store.MultimodalAllianceError: await _safe_send_json( websocket, { "type": "error", "code": "multimodal_timeline_unavailable", "detail": "multimodal timeline persistence is unavailable; voice turn is blocked", }, ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return await _run_turn_and_speak( websocket, context, VoiceTurnInput( learner_text=learner_text, prosody=VoiceProsody( audio_ref=audio_ref, duration_s=duration_s, silence_ms=utterance.prosody.silence_ms, speech_rate=speech_rate, barge_in=utterance.prosody.barge_in, provider_events=provider_events, ), ), ) async def _persist_streaming_timeline( *, context: VoiceSessionContext, utterance: VoiceAudioInput, stt: TranscriptResult, audio_ref: str, duration_s: float | None, ) -> dict[str, object] | None: audio_sha256 = hashlib.sha256(utterance.audio).hexdigest() submission_id = uuid5( NAMESPACE_URL, f"vignette:g7:streaming-stt:{context.session_id}:{audio_sha256}:{stt.model}", ) duration_ms = max( 1, round((duration_s or 0.0) * 1000), *(round(word.end * 1000) for word in stt.words), ) words = [] for index, word in enumerate(sorted(stt.words, key=lambda item: item.start)): start_ms = max(0, min(duration_ms - 1, round(word.start * 1000))) end_ms = max(start_ms + 1, min(duration_ms, round(word.end * 1000))) words.append( { "word_index": index, "start_ms": start_ms, "end_ms": end_ms, "speaker": "learner", # Common counselling words are dictionary-attackable when stored # as plain SHA-256. Bind the pseudonym to this deployment and # submission so the timeline remains useful without creating a # reusable transcript fingerprint. "token_hash": hmac.new( settings.session_secret.encode("utf-8"), ( f"{context.session_id}:{submission_id}:" f"{word.word.casefold()}" ).encode("utf-8"), hashlib.sha256, ).hexdigest(), } ) events = [] for index, event in enumerate(_safe_provider_events(stt.provider_events)): provider_type = str(event.get("event_type") or "") event_type = _g7_event_type(provider_type) if event_type is None: continue start_ms = max(0, _safe_int(event.get("start_ms")) or 0) end_ms = _safe_int(event.get("end_ms")) if end_ms is None: end_ms = start_ms + max(0, _safe_int(event.get("duration_ms")) or 0) start_ms = min(start_ms, duration_ms - 1) end_ms = min(duration_ms, max(start_ms + 1, end_ms)) confidence = _safe_float(event.get("confidence")) uncertainty = 0.5 if confidence is None else max(0.0, min(1.0, 1.0 - confidence)) events.append( { "event_id": f"oas-g7-event-{submission_id.hex}-{index}", "event_type": event_type, "start_ms": start_ms, "end_ms": end_ms, "actor": "learner", "observed_feature": f"provider observed {provider_type}", "uncertainty": uncertainty, "source": "stt_word_timestamps", } ) timeline = multimodal_alliance.align_voice_timeline( audio_duration_ms=duration_ms, words=words, events=events, ) _, media_type = _audio_meta(utterance.fmt) return await multimodal_alliance_store.append_runtime_timeline( session_id=context.session_id, submission_id=submission_id, timeline=timeline, audio_asset={ "audio_ref": audio_ref, "audio_sha256": audio_sha256, "media_type": media_type, "byte_size": len(utterance.audio), }, ) def _g7_event_type(provider_type: str) -> str | None: if provider_type in {"speech_final", "speech_end", "voice_activity", "speech_rate"}: return "pace" if provider_type in {"barge_in", "interrupt", "interruption"}: return "interruption" if provider_type == "overlap": return "overlap" if provider_type in {"silence", "pause", "long_pause"}: return "silence" if provider_type in {"background_noise", "noise"}: return "audio_quality" if provider_type in {"sigh", "cry", "laugh", "breath", "pitch", "intonation", "prosody"}: return "prosody" return None async def _run_turn_and_speak( websocket: WebSocket, context: VoiceSessionContext, turn: VoiceTurnInput, ) -> None: """Run one counseling turn and stream synthesized client speech.""" learner_text = turn.learner_text prosody = turn.prosody speech_rate = prosody.speech_rate if speech_rate is None: speech_rate = _estimate_speech_rate(learner_text, prosody.duration_s) sess, err = await _load_voice_session(context.session_id, context.principal) if sess is None: await _safe_send_json( websocket, {"type": "error", "detail": err or "session not found or ended"} ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return from . import sessions as session_routes if session_routes.session_time_over(sess): await _safe_send_json( websocket, {"type": "error", "detail": "session_time_over"}, ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return ctx = await _prepare_voice_turn_context( session_id=context.session_id, sess=sess, learner_text=learner_text, ) assert ctx.state_after is not None # Voice needs the full client reply before TTS starts. try: result = await orchestrator.run_turn_generate( ctx, engine_client, eval_hook=evaluator.make_eval_hook( engine_client, audit_hook=session_persistence.record_llm_call_audit, ), audit_hook=session_persistence.record_llm_call_audit, ) except EngineError as e: await _safe_send_json( websocket, {"type": "error", "detail": f"engine unavailable: {e}"} ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return reply = result.client_reply or "" # Persist only after the client reply has been generated. A failed AI turn # must not leave a learner-only transcript in review or history. await turn_runtime.finalize_completed_turn( sess, ctx, result, context_prefix="voice session", counselor_turn=TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="counselor", stage=turn_runtime.stage_label(ctx.state_after.stage), text=learner_text, text_masked=ctx.learner_text_masked, audio_ref=prosody.audio_ref, silence_ms=prosody.silence_ms, speech_rate=speech_rate, barge_in=prosody.barge_in, provider_events=prosody.provider_events, evaluation=result.evaluation, ), ) # Send the final client text before audio playback. await _safe_send_json( websocket, { "type": "reply", "text": reply, "speaker": "client", "stage": result.stage, "effective_openness": round(result.effective_openness, 4), "turn_seq": result.turn_seq, "safety_flagged": result.safety_flagged, "crisis_kind": result.crisis_kind, "crisis_resource": result.crisis_resource, "conversation_stopped": result.conversation_stopped, "progress": session_routes.build_session_progress( result.state_after, prev_rapport_credit=sess.prev_rapport_credit, goal_stages=list(sess.goal_stages or []), ).model_dump(), }, ) if not reply: await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return # TTS speaking state comes before chunk metadata and binary audio. await _safe_send_json( websocket, { "type": "state", "state": "speaking", "voice": context.voice_preset.openai_voice, "tts_provider": voice_service.tts_provider_for_voice(context.voice_preset), }, ) try: n = 0 async for ck in voice_service.synthesize_stream(reply, context.voice_preset): # 바이너리 오디오 청크만 송신(프론트가 Web Audio AnalyserNode로 립싱크 자체 산출). await _safe_send_bytes(websocket, ck.audio) n += 1 await _safe_send_json(websocket, {"type": "tts_end", "chunks": n}) except VoiceUnavailable as e: await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)}) except Exception as e: await _safe_send_json( websocket, {"type": "error", "detail": f"TTS failed: {e}"} ) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) async def _prepare_voice_turn_context( *, session_id: str, sess: InProcSession, learner_text: str, ) -> orchestrator.TurnContext: """Build voice turn context with the same fail-closed G3 ledger projection.""" from . import sessions as session_routes recall = await session_routes.ensure_recall_context(sess) kb_cues = session_routes.cached_kb_cues(session_id) scenario_context = ( await rupture_scenario_director.load_stored_scenario_context( session_id=session_id, case_id=sess.case_id, ) ) return orchestrator.prepare_turn( session_id=session_id, case_id=sess.case_id, card=sess.persona, state=sess.state, learner_text=learner_text, learner_identity=sess.learner_label, memory=orchestrator.TurnMemory( recall_summary=recall.recall_summary, pinned_facts=recall.pinned_facts, recent_turns=sess.recent_turns(visible_to="client"), kb_behavior_cues=kb_cues, ), theory_mode=sess.theory_mode, scenario_context=scenario_context, ) async def _load_voice_session( session_id: str, principal: Principal, ) -> tuple[InProcSession | None, str | None]: sess, err = await turn_runtime.load_owned_session(session_id, principal) if err == turn_runtime.SessionAccessError.NOT_FOUND: return None, f"unknown session {session_id}" if err == turn_runtime.SessionAccessError.FORBIDDEN: return None, "session does not belong to user" if err == turn_runtime.SessionAccessError.ENDED: return None, "session already ended" assert sess is not None return sess, None def _client_turn_text_for_speech(sess: InProcSession, turn_seq: int) -> str | None: """완료된 상담 턴 번호에 대응하는 client-visible 응답을 반환한다. degraded 인메모리 미러는 상담자/내담자 한 쌍이 같은 논리 turn_seq를 쓰지만, DB의 app.turns.seq는 발화마다 1씩 증가한다. DB 스냅샷에서 논리 1턴을 그대로 seq=1로 찾으면 상담자 발화만 잡혀 TTS가 404가 되므로 두 저장 형태를 명시적으로 구분한다. """ visible_clients = [ turn for turn in sess.turns if turn.speaker == "client" and turn.is_visible_to("client") ] counselor_sequences = { turn.turn_seq for turn in sess.turns if turn.speaker == "counselor" } paired_sequences = counselor_sequences.intersection( turn.turn_seq for turn in visible_clients ) if paired_sequences or not counselor_sequences: match = next( (turn for turn in reversed(visible_clients) if turn.turn_seq == turn_seq), None, ) else: index = turn_seq - 1 match = visible_clients[index] if 0 <= index < len(visible_clients) else None if match is not None: text = (match.text_masked or match.text).strip() return guardrail.humanize_pii_placeholders(text) or None return None async def _principal_from_websocket(websocket: WebSocket) -> Principal | None: """Restore the same server-side browser session used by REST routes.""" raw_cookie = websocket.cookies.get(settings.cookie_name) if raw_cookie is None and settings.environment == "dev": raw_cookie = websocket.cookies.get("vignette_sid") session = await get_session(raw_cookie) if session is None: return None if session.account_status != "approved": return None try: role = Role(session.role) except ValueError: return None return Principal( user_id=session.user_id, role=role, admin_access=session.admin_access, super_admin=session.super_admin, account_status=session.account_status, cohort_ids=session.cohort_ids, email=session.email, display_name=session.display_name, consent_at=session.consent_at, profile_completed_at=session.profile_completed_at, ) async def _practice_access_error(principal: Principal) -> str | None: if principal.profile_completed_at is None and not await user_onboarding_complete( principal.user_id ): return "onboarding_required" if principal.consent_at is None and not await user_has_consent(principal.user_id): return "consent_required" return None async def _bind_session( websocket: WebSocket, principal: Principal, ) -> tuple[Optional[str], Optional[VoicePreset], Optional[str], dict[str, object]]: """Bind an existing session or create a dev-only voice session.""" qp = websocket.query_params explicit_preset = qp.get("preset") access_error = await _practice_access_error(principal) if access_error is not None: return None, None, access_error, {} session_id = qp.get("session_id") if session_id: sess, err = await _load_voice_session(session_id, principal) if sess is None: return None, None, err or f"unknown session {session_id}", {} vp = await _resolve_session_voice( session_id=session_id, persona_code=sess.persona.code, explicit_preset=explicit_preset, ) return ( session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"}, ) # persona_code session creation is local-dev only. Production uses REST start. if settings.environment != "dev": return None, None, "session_id required", {} persona_code = qp.get("persona_code") if not persona_code: return None, None, "session_id or persona_code query required", {} try: catalog_persona = await get_catalog_persona(persona_code) except Exception: return None, None, "persona catalog database unavailable", {} if catalog_persona is None: return None, None, f"unknown persona {persona_code}", {} card = catalog_persona.card st = state_machine.init_state( params=card.openness_params(), ) sess = await session_persistence.create_session( learner_id=principal.user_id, card=card, theory_mode="humanistic", state=st, session_no=1, carry_rapport=st.rapport_credit, persona_id=catalog_persona.persona_id, persona_version=catalog_persona.version, ) session_source = "database" if sess is None: require_runtime_fallback_allowed("voice session creation") sess = store.create( learner_id=principal.user_id, persona=card, theory_mode="humanistic", state=st, session_no=1, carry_rapport=st.rapport_credit, ) session_source = "runtime" else: store.put(sess) vp = await _resolve_catalog_voice( persona_id=catalog_persona.persona_id, version=catalog_persona.version, persona_code=card.code, explicit_preset=explicit_preset, ) degraded_reasons: list[str] = [] if catalog_persona.degraded: degraded_reasons.append( "카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다" ) if session_source == "runtime": degraded_reasons.append( "세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다" ) bind_meta = { "degraded": bool(degraded_reasons), "degraded_reason": "; ".join(degraded_reasons) if degraded_reasons else None, "persona_catalog_source": catalog_persona.source, "session_source": session_source, } return sess.session_id, vp, None, bind_meta async def _resolve_session_voice( *, session_id: str, persona_code: str, explicit_preset: str | None, ) -> VoicePreset: fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset) if explicit_preset: return fallback try: voice_map = await get_session_voice_map(session_id) except Exception: return fallback return _voice_from_map(voice_map, persona_code=persona_code) or fallback async def _resolve_catalog_voice( *, persona_id: str | None, version: int | None, persona_code: str, explicit_preset: str | None, ) -> VoicePreset: fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset) if explicit_preset: return fallback try: voice_map = await get_persona_voice_map(persona_id=persona_id, version=version) except Exception: return fallback return _voice_from_map(voice_map, persona_code=persona_code) or fallback def _voice_from_map( voice_map: PersonaVoiceMap | None, *, persona_code: str, ) -> VoicePreset | None: if voice_map is None: return None return voice_svc.resolve_voice_from_map( provider=voice_map.provider, voice_id=voice_map.voice_id, base_params=voice_map.base_params, persona_code=persona_code, ) def _audio_meta(fmt: Optional[str]) -> tuple[str, str]: """Map the browser audio format to upload metadata.""" f = (fmt or "webm").lower().lstrip(".") table = { "webm": ("audio.webm", "audio/webm"), "ogg": ("audio.ogg", "audio/ogg"), "opus": ("audio.ogg", "audio/ogg"), "wav": ("audio.wav", "audio/wav"), "mp3": ("audio.mp3", "audio/mpeg"), "mp4": ("audio.mp4", "audio/mp4"), "m4a": ("audio.m4a", "audio/mp4"), "pcm": ("audio.wav", "audio/wav"), } return table.get(f, ("audio.webm", "audio/webm")) def _normalize_audio_upload( audio: bytes, *, fmt: Optional[str], sample_rate: int | None = None, channels: int | None = None, sample_width: int | None = None, ) -> tuple[bytes, str]: f = (fmt or "webm").lower().lstrip(".") or "webm" if f != "pcm": return audio, f if sample_width not in (None, 2): raise ValueError("pcm sample_width must be 2 bytes") return _wav_from_pcm16( audio, sample_rate=_bounded_int( sample_rate, default=48000, minimum=8000, maximum=96000 ), channels=_bounded_int(channels, default=1, minimum=1, maximum=2), ), "wav" def _bounded_int(value: int | None, *, default: int, minimum: int, maximum: int) -> int: if value is None: return default return min(maximum, max(minimum, value)) def _wav_from_pcm16(pcm: bytes, *, sample_rate: int, channels: int) -> bytes: byte_rate = sample_rate * channels * 2 block_align = channels * 2 data_size = len(pcm) header = b"".join( [ b"RIFF", (36 + data_size).to_bytes(4, "little"), b"WAVE", b"fmt ", (16).to_bytes(4, "little"), (1).to_bytes(2, "little"), channels.to_bytes(2, "little"), sample_rate.to_bytes(4, "little"), byte_rate.to_bytes(4, "little"), block_align.to_bytes(2, "little"), (16).to_bytes(2, "little"), b"data", data_size.to_bytes(4, "little"), ] ) return header + pcm def _voice_audio_ref(audio: bytes, fmt: Optional[str]) -> str | None: if not audio: return None f = (fmt or "webm").lower().lstrip(".") or "webm" digest = hashlib.sha256(audio).hexdigest()[:24] return f"voice:{f}:sha256:{digest}" def _elapsed_seconds(started_at: float | None, ended_at: float | None) -> float | None: if started_at is None or ended_at is None: return None return max(0.001, ended_at - started_at) def _estimate_speech_rate(text: str, duration_s: float | None) -> float | None: if not text or not duration_s or duration_s <= 0: return None units = sum(1 for ch in text if not ch.isspace()) if units <= 0: return None return round((units / duration_s) * 60.0, 2) def _safe_int(value: object) -> int | None: if value is None: return None try: return int(value) except (TypeError, ValueError): return None def _safe_float(value: object) -> float | None: if value is None: return None try: return float(value) except (TypeError, ValueError): return None def _safe_str(value: object) -> str | None: if isinstance(value, str): text = value.strip() return text or None return None def _safe_bool(value: object) -> bool | None: if value is None: return None if isinstance(value, bool): return value if isinstance(value, str): normalized = value.strip().lower() if normalized in {"1", "true", "yes", "y"}: return True if normalized in {"0", "false", "no", "n"}: return False return bool(value) def _provider_event_slug(value: object) -> str: text = str(value or "").strip().lower() if not text: return "" chars = [] previous_underscore = False for char in text: if char.isalnum(): chars.append(char) previous_underscore = False elif not previous_underscore: chars.append("_") previous_underscore = True return "".join(chars).strip("_")[:_PROVIDER_EVENT_MAX_STRING] def _provider_event_taxonomy(event: dict[str, object]) -> tuple[str, str]: for field in _PROVIDER_EVENT_TYPE_FIELDS: slug = _provider_event_slug(event.get(field)) if not slug: continue canonical = _PROVIDER_EVENT_TAXONOMY.get(slug) if canonical: return canonical return slug, "unknown" return "", "" def _safe_provider_events(value: object) -> list[dict[str, object]]: if not isinstance(value, list): return [] events: list[dict[str, object]] = [] for item in value[:_PROVIDER_EVENT_MAX_ITEMS]: if not isinstance(item, dict): continue safe: dict[str, object] = {} for key in _PROVIDER_EVENT_ALLOWED_KEYS: raw = item.get(key) if isinstance(raw, bool): safe[key] = raw elif isinstance(raw, (int, float)): safe[key] = raw elif isinstance(raw, str): text = raw.strip() if text: safe[key] = text[:_PROVIDER_EVENT_MAX_STRING] if safe: event_type, category = _provider_event_taxonomy(safe) if event_type: safe["event_type"] = event_type if category: safe["category"] = category events.append(safe) return events def _merge_provider_events(*values: object) -> list[dict[str, object]]: merged: list[dict[str, object]] = [] for value in values: merged.extend(_safe_provider_events(value)) if len(merged) >= _PROVIDER_EVENT_MAX_ITEMS: return merged[:_PROVIDER_EVENT_MAX_ITEMS] return merged async def _safe_send_json(websocket: WebSocket, payload: dict) -> None: if websocket.client_state != WebSocketState.CONNECTED: return try: await websocket.send_text(json.dumps(payload, ensure_ascii=False)) except Exception: pass async def _safe_send_bytes(websocket: WebSocket, data: bytes) -> None: if websocket.client_state != WebSocketState.CONNECTED: return try: await websocket.send_bytes(data) except Exception: pass async def _safe_close(websocket: WebSocket, code: int = 1000) -> None: if websocket.client_state == WebSocketState.DISCONNECTED: return try: await websocket.close(code=code) except Exception: pass __all__ = ["router"]