G0~G8 성과·동맹 측정 OS 작업 일괄 고정

8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -13,11 +13,14 @@ 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
@ -36,9 +39,29 @@ from ..persona_repository import (
get_session_voice_map,
)
from ..runtime_policy import require_runtime_fallback_allowed
from ..services import evaluator, orchestrator, state_machine
from ..services import (
evaluator,
multimodal_alliance,
multimodal_alliance_store,
orchestrator,
rupture_scenario_director,
state_machine,
)
from ..services import voice as voice_svc
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
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"])
@ -92,7 +115,9 @@ WS_CLOSE_BAD_REQUEST = 1008
WS_CLOSE_UNAUTHORIZED = 1008
# Per-utterance audio cap to avoid unbounded memory growth.
_MAX_AUDIO_BYTES = 10 * 1024 * 1024
_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 = {
@ -103,6 +128,7 @@ _PROVIDER_EVENT_ALLOWED_KEYS = {
"label",
"source",
"provider",
"model",
"start_ms",
"end_ms",
"duration_ms",
@ -130,6 +156,7 @@ _PROVIDER_EVENT_TAXONOMY = {
"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"),
@ -154,6 +181,15 @@ def _is_turn_persistence_unavailable(exc: Exception) -> bool:
_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."""
@ -166,13 +202,20 @@ async def voice_health() -> JSONResponse:
"available": available,
"stt_available": stt_available,
"tts_available": tts_available,
"stt_model": voice_svc.STT_MODEL,
"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.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
@ -313,7 +356,7 @@ async def voice_ws(websocket: WebSocket) -> None:
if not voice_service.is_available():
await _safe_send_json(
websocket,
{"type": "degraded", "reason": "OPENAI_API_KEY is not configured"},
{"type": "degraded", "reason": "voice STT/TTS is not configured"},
)
await _safe_close(websocket, WS_CLOSE_DEGRADED)
return
@ -325,12 +368,23 @@ async def voice_ws(websocket: WebSocket) -> None:
"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
@ -339,6 +393,13 @@ async def voice_ws(websocket: WebSocket) -> 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:
@ -349,16 +410,28 @@ async def voice_ws(websocket: WebSocket) -> None:
# 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"}
)
audio_buf.extend(msg["bytes"])
if len(audio_buf) > _MAX_AUDIO_BYTES:
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,
{
@ -366,8 +439,79 @@ async def voice_ws(websocket: WebSocket) -> None:
"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.
@ -384,16 +528,94 @@ async def voice_ws(websocket: WebSocket) -> None:
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()
await _safe_send_json(
websocket, {"type": "state", "state": "listening"}
)
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
@ -408,28 +630,101 @@ async def voice_ws(websocket: WebSocket) -> None:
0, int((audio_started_at - last_audio_end_at) * 1000)
)
end_format = _safe_str(ctrl.get("format")) or audio_format
await _handle_utterance(
websocket,
VoiceSessionContext(session_id, principal, voice_preset),
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")
),
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
@ -437,11 +732,19 @@ async def voice_ws(websocket: WebSocket) -> 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(
@ -451,8 +754,13 @@ async def voice_ws(websocket: WebSocket) -> None:
)
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,
@ -475,11 +783,16 @@ async def voice_ws(websocket: WebSocket) -> None:
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,
@ -495,9 +808,61 @@ async def voice_ws(websocket: WebSocket) -> None:
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,
*,
@ -509,6 +874,12 @@ async def _handle_stt_result_control(
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"))
@ -576,6 +947,13 @@ async def _handle_utterance(
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(
@ -641,6 +1019,286 @@ async def _handle_utterance(
)
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,
@ -670,21 +1328,10 @@ async def _run_turn_and_speak(
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
recall = await session_routes.ensure_recall_context(sess)
kb_cues = session_routes.cached_kb_cues(context.session_id)
ctx = orchestrator.prepare_turn(
ctx = await _prepare_voice_turn_context(
session_id=context.session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
sess=sess,
learner_text=learner_text,
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,
)
assert ctx.state_after is not None
@ -782,6 +1429,41 @@ async def _run_turn_and_speak(
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,
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,
@ -1113,6 +1795,15 @@ def _safe_int(value: object) -> int | None:
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()