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

@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import unittest
from types import SimpleNamespace
@ -12,7 +14,14 @@ from fastapi import HTTPException
from .deps import Principal, Role
from .persona_repository import PersonaVoiceMap
from .routes import voice as voice_routes
from .services.voice import TTSChunk, VoicePreset
from .services import multimodal_alliance_store
from .services.voice import (
StreamingTranscriptEvent,
TranscriptResult,
TranscriptWord,
TTSChunk,
VoicePreset,
)
from .store import TurnRecord
@ -78,6 +87,69 @@ class FakeWebSocket:
self.client_state = voice_routes.WebSocketState.DISCONNECTED
class _FakeRouteStreamingSession:
def __init__(self) -> None:
self.on_event = None
self.audio: list[bytes] = []
self.aborted = False
async def send_audio(self, audio: bytes) -> None:
self.audio.append(audio)
assert self.on_event is not None
await self.on_event(
StreamingTranscriptEvent(
text="안녕",
final=False,
speech_final=False,
confidence=0.7,
)
)
async def finish(self) -> TranscriptResult:
assert self.on_event is not None
await self.on_event(
StreamingTranscriptEvent(
text="안녕하세요 반가워요",
final=True,
speech_final=True,
confidence=0.93,
)
)
return TranscriptResult(
text="안녕하세요 반가워요",
language="ko",
model="nova-3",
duration=0.9,
words=[
TranscriptWord("안녕하세요", 0.0, 0.45, 0.94),
TranscriptWord("반가워요", 0.5, 0.9, 0.91),
],
provider_events=[
{
"type": "speech_final",
"provider": "deepgram",
"source": "streaming_stt",
"start_ms": 0,
"duration_ms": 900,
"confidence": 0.93,
"is_final": True,
},
{
"type": "stt_word",
"provider": "deepgram",
"source": "stt_word_timestamps",
"start_ms": 0,
"end_ms": 450,
"confidence": 0.94,
"is_final": True,
},
],
)
async def abort(self) -> None:
self.aborted = True
class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
def _bind_result(self) -> tuple[str, VoicePreset, None, dict[str, object]]:
return (
@ -256,6 +328,16 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
"tts_provider",
return_value="openai",
),
patch.object(
voice_routes.voice_service,
"tts_available",
return_value=True,
),
patch.object(
voice_routes.voice_service,
"tts_provider_for_voice",
return_value="openai",
),
patch.object(
voice_routes.voice_service,
"synthesize_stream",
@ -400,6 +482,417 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(int.from_bytes(audio[40:44], "little"), 4)
self.assertEqual(audio[44:], b"\x00\x00\xff\x7f")
async def test_deepgram_stream_relays_interim_final_and_appends_g7_timeline(
self,
) -> None:
websocket = FakeWebSocket(
[
_control(
{
"type": "audio_start",
"format": "pcm",
"sample_rate": 16000,
"channels": 1,
"sample_width": 2,
}
),
_binary(b"\x00\x00\xff\x7f"),
_control({"type": "audio_end", "format": "pcm"}),
_control({"type": "close"}),
]
)
session = _FakeRouteStreamingSession()
consent_gate = AsyncMock(return_value={"consent_snapshot_id": "test"})
append_timeline = AsyncMock(return_value={"timeline_id": "timeline"})
run_turn = AsyncMock()
transcribe = AsyncMock()
async def open_stream(**kwargs):
session.on_event = kwargs["on_event"]
return session
with (
patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal()),
),
patch.object(
voice_routes,
"_bind_session",
AsyncMock(return_value=self._bind_result()),
),
patch.object(voice_routes.voice_service, "is_available", return_value=True),
patch.object(
voice_routes.voice_service, "can_stream_audio", return_value=True
),
patch.object(
voice_routes.voice_service,
"batch_stt_available",
return_value=False,
),
patch.object(
voice_routes.voice_service, "stt_provider", return_value="deepgram"
),
patch.object(
voice_routes.voice_service, "stt_model", return_value="nova-3"
),
patch.object(
voice_routes.voice_service,
"tts_provider_for_voice",
return_value="openai",
),
patch.object(
voice_routes.voice_service,
"tts_model_for_voice",
return_value="gpt-4o-mini-tts",
),
patch.object(
voice_routes.voice_service,
"open_streaming_transcription",
new=open_stream,
),
patch.object(
voice_routes.multimodal_alliance_store,
"assert_voice_processing_allowed",
consent_gate,
),
patch.object(
voice_routes.multimodal_alliance_store,
"append_runtime_timeline",
append_timeline,
),
patch.object(voice_routes.voice_service, "transcribe", transcribe),
patch.object(voice_routes, "_run_turn_and_speak", run_turn),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertEqual(session.audio, [b"\x00\x00\xff\x7f"])
self.assertFalse(session.aborted)
self.assertEqual(consent_gate.await_count, 3)
ready = next(
payload for payload in websocket.sent_json if payload.get("type") == "ready"
)
self.assertEqual(ready["stt_provider"], "deepgram")
self.assertEqual(ready["stt_model"], "nova-3")
self.assertEqual(ready["tts_provider"], "openai")
self.assertEqual(ready["tts_model"], "gpt-4o-mini-tts")
self.assertIs(ready["stt_batch_fallback_available"], False)
transcribe.assert_not_awaited()
append_timeline.assert_awaited_once()
run_turn.assert_awaited_once()
transcripts = [
payload for payload in websocket.sent_json if payload.get("type") == "transcript"
]
self.assertEqual(
transcripts,
[
{
"type": "transcript",
"text": "안녕",
"final": False,
"speech_final": False,
"speaker": "counselor",
},
{
"type": "transcript",
"text": "안녕하세요 반가워요",
"final": True,
"speech_final": True,
"speaker": "counselor",
},
],
)
timeline = append_timeline.await_args.kwargs["timeline"]
self.assertEqual(timeline.audio_duration_ms, 900)
self.assertEqual(len(timeline.words), 2)
self.assertEqual([word.word_index for word in timeline.words], [0, 1])
self.assertNotIn("안녕하세요", timeline.model_dump_json())
self.assertNotEqual(
timeline.words[0].token_hash,
hashlib.sha256("안녕하세요".encode("utf-8")).hexdigest(),
)
self.assertEqual(timeline.events[0].event_type, "pace")
turn = run_turn.await_args.args[2]
self.assertEqual(turn.learner_text, "안녕하세요 반가워요")
self.assertEqual(turn.prosody.provider_events[0]["provider"], "deepgram")
self.assertEqual(turn.prosody.provider_events[0]["model"], "nova-3")
async def test_streaming_consent_required_blocks_provider_before_audio(self) -> None:
websocket = FakeWebSocket(
[
_control(
{
"type": "audio_start",
"format": "pcm",
"sample_rate": 16000,
"channels": 1,
"sample_width": 2,
}
),
_binary(b"must-not-leave-process"),
_control({"type": "audio_end", "format": "pcm"}),
_control({"type": "close"}),
]
)
open_stream = AsyncMock()
handle_utterance = AsyncMock()
consent_gate = AsyncMock(
side_effect=multimodal_alliance_store.MultimodalConsentRequiredError(
"voice consent required"
)
)
with (
patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal()),
),
patch.object(
voice_routes,
"_bind_session",
AsyncMock(return_value=self._bind_result()),
),
patch.object(voice_routes.voice_service, "is_available", return_value=True),
patch.object(
voice_routes.voice_service, "can_stream_audio", return_value=True
),
patch.object(
voice_routes.voice_service,
"open_streaming_transcription",
open_stream,
),
patch.object(
voice_routes.multimodal_alliance_store,
"assert_voice_processing_allowed",
consent_gate,
),
patch.object(voice_routes, "_handle_utterance", handle_utterance),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
open_stream.assert_not_awaited()
handle_utterance.assert_not_awaited()
self.assertEqual(consent_gate.await_count, 1)
self.assertIn(
{
"type": "error",
"code": "multimodal_consent_required",
"detail": "voice consent required",
},
websocket.sent_json,
)
async def test_streaming_consent_withdrawal_aborts_before_provider_finalize(self) -> None:
websocket = FakeWebSocket(
[
_control(
{
"type": "audio_start",
"format": "pcm",
"sample_rate": 16000,
"channels": 1,
"sample_width": 2,
}
),
_binary(b"\x00\x00\xff\x7f"),
_control({"type": "audio_end", "format": "pcm"}),
_control({"type": "close"}),
]
)
session = _FakeRouteStreamingSession()
run_turn = AsyncMock()
async def open_stream(**kwargs):
session.on_event = kwargs["on_event"]
return session
consent_gate = AsyncMock(
side_effect=[
{"consent_snapshot_id": "granted"},
multimodal_alliance_store.MultimodalConsentWithdrawnError(
"voice consent withdrawn"
),
]
)
with (
patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal()),
),
patch.object(
voice_routes,
"_bind_session",
AsyncMock(return_value=self._bind_result()),
),
patch.object(voice_routes.voice_service, "is_available", return_value=True),
patch.object(
voice_routes.voice_service, "can_stream_audio", return_value=True
),
patch.object(
voice_routes.voice_service,
"open_streaming_transcription",
new=open_stream,
),
patch.object(
voice_routes.multimodal_alliance_store,
"assert_voice_processing_allowed",
consent_gate,
),
patch.object(voice_routes, "_run_turn_and_speak", run_turn),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertTrue(session.aborted)
self.assertEqual(consent_gate.await_count, 2)
run_turn.assert_not_awaited()
self.assertIn(
{
"type": "error",
"code": "multimodal_consent_withdrawn",
"detail": "voice consent withdrawn",
},
websocket.sent_json,
)
async def test_streaming_consent_is_rechecked_before_more_audio_leaves_process(
self,
) -> None:
websocket = FakeWebSocket(
[
_control(
{
"type": "audio_start",
"format": "pcm",
"sample_rate": 16000,
"channels": 1,
"sample_width": 2,
}
),
_binary(b"must-not-reach-provider-after-withdrawal"),
_control({"type": "audio_end", "format": "pcm"}),
_control({"type": "close"}),
]
)
session = _FakeRouteStreamingSession()
async def open_stream(**kwargs):
session.on_event = kwargs["on_event"]
return session
consent_gate = AsyncMock(
side_effect=[
{"consent_snapshot_id": "granted"},
multimodal_alliance_store.MultimodalConsentWithdrawnError(
"voice consent withdrawn"
),
]
)
with (
patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal()),
),
patch.object(
voice_routes,
"_bind_session",
AsyncMock(return_value=self._bind_result()),
),
patch.object(voice_routes.voice_service, "is_available", return_value=True),
patch.object(
voice_routes.voice_service, "can_stream_audio", return_value=True
),
patch.object(
voice_routes.voice_service,
"open_streaming_transcription",
new=open_stream,
),
patch.object(
voice_routes.multimodal_alliance_store,
"assert_voice_processing_allowed",
consent_gate,
),
patch.object(voice_routes, "_STREAMING_CONSENT_RECHECK_SECONDS", 0.0),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertTrue(session.aborted)
self.assertEqual(session.audio, [])
self.assertEqual(consent_gate.await_count, 2)
self.assertIn(
{
"type": "error",
"code": "multimodal_consent_withdrawn",
"detail": "voice consent withdrawn",
},
websocket.sent_json,
)
async def test_streaming_connect_failure_uses_existing_batch_fallback(self) -> None:
websocket = FakeWebSocket(
[
_control(
{
"type": "audio_start",
"format": "pcm",
"sample_rate": 16000,
"channels": 1,
"sample_width": 2,
}
),
_binary(b"batch-fallback-audio"),
_control({"type": "audio_end", "format": "pcm"}),
_control({"type": "close"}),
]
)
handle_utterance = AsyncMock()
with (
patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal()),
),
patch.object(
voice_routes,
"_bind_session",
AsyncMock(return_value=self._bind_result()),
),
patch.object(voice_routes.voice_service, "is_available", return_value=True),
patch.object(
voice_routes.voice_service, "can_stream_audio", return_value=True
),
patch.object(
voice_routes.voice_service,
"batch_stt_available",
return_value=True,
),
patch.object(
voice_routes.voice_service,
"open_streaming_transcription",
AsyncMock(side_effect=RuntimeError("provider unavailable")),
),
patch.object(
voice_routes.multimodal_alliance_store,
"assert_voice_processing_allowed",
AsyncMock(return_value={"consent_snapshot_id": "test"}),
),
patch.object(voice_routes, "_handle_utterance", handle_utterance),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
handle_utterance.assert_awaited_once()
utterance = handle_utterance.await_args.args[2]
self.assertEqual(utterance.audio, b"batch-fallback-audio")
self.assertIn(
{
"type": "degraded",
"reason": "streaming STT unavailable; using batch fallback",
},
websocket.sent_json,
)
async def test_pcm_control_metadata_flows_to_handle_utterance(self) -> None:
websocket = FakeWebSocket(
[
@ -774,16 +1267,180 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertEqual(websocket.close_codes, [1000])
self.assertEqual(
websocket.sent_json[-1],
self.assertIn(
{
"type": "error",
"detail": "audio too large; please send a shorter utterance",
},
websocket.sent_json,
)
self.assertEqual(
websocket.sent_json[-1], {"type": "state", "state": "idle"}
)
handle_utterance.assert_not_awaited()
run_turn.assert_not_awaited()
def test_route_audio_buffer_never_exceeds_hard_cap(self) -> None:
buffer = bytearray(b"123")
with patch.object(voice_routes, "_MAX_AUDIO_BYTES", 4):
self.assertFalse(
voice_routes._append_audio_chunk_with_cap(buffer, b"45")
)
self.assertEqual(buffer, b"123")
async def test_streaming_event_queue_applies_backpressure_at_capacity(self) -> None:
queue: asyncio.Queue[StreamingTranscriptEvent] = asyncio.Queue(maxsize=1)
event = StreamingTranscriptEvent(
text="중간", final=False, speech_final=False
)
await queue.put(event)
blocked_put = asyncio.create_task(queue.put(event))
await asyncio.sleep(0)
self.assertFalse(blocked_put.done())
self.assertIs(queue.get_nowait(), event)
await asyncio.wait_for(blocked_put, timeout=0.1)
self.assertEqual(queue.qsize(), 1)
async def test_streaming_finish_drains_full_queue_without_deadlock(self) -> None:
websocket = FakeWebSocket()
await websocket.accept()
queue: asyncio.Queue[StreamingTranscriptEvent] = asyncio.Queue(maxsize=1)
class BurstFinishSession:
async def finish(self) -> TranscriptResult:
for index in range(3):
await queue.put(
StreamingTranscriptEvent(
text=f"중간-{index}",
final=index == 2,
speech_final=index == 2,
)
)
return TranscriptResult(text="중간-2", model="nova-3")
result, last = await asyncio.wait_for(
voice_routes._finish_streaming_transcription(
websocket,
BurstFinishSession(), # type: ignore[arg-type]
queue,
),
timeout=0.5,
)
self.assertEqual(result.text, "중간-2")
self.assertEqual(last, ("중간-2", True))
self.assertEqual(
[item["text"] for item in websocket.sent_json],
["중간-0", "중간-1", "중간-2"],
)
async def test_existing_socket_rechecks_g7_consent_before_each_stt(self) -> None:
websocket = FakeWebSocket(
[
_control(
{
"type": "audio_start",
"format": "pcm",
"sample_rate": 16000,
"channels": 1,
"sample_width": 2,
}
),
_binary(b"\x00\x00\xff\x7f"),
_control({"type": "audio_end", "format": "pcm"}),
_control(
{
"type": "audio_start",
"format": "pcm",
"sample_rate": 16000,
"channels": 1,
"sample_width": 2,
}
),
_binary(b"\x00\x00\xff\x7f"),
_control({"type": "audio_end", "format": "pcm"}),
_control({"type": "close"}),
]
)
gate_calls = 0
async def consent_gate(*, principal: Principal, session_id: str) -> None:
nonlocal gate_calls
self.assertEqual(principal.user_id, _principal().user_id)
self.assertEqual(session_id, SESSION_ID)
gate_calls += 1
if gate_calls == 2:
raise multimodal_alliance_store.MultimodalConsentWithdrawnError(
"multimodal voice consent was withdrawn"
)
transcribe = AsyncMock(
return_value=SimpleNamespace(
text="first synthetic utterance",
duration=0.01,
provider_events=[],
)
)
run_turn = AsyncMock()
with (
patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal()),
),
patch.object(
voice_routes,
"_bind_session",
AsyncMock(return_value=self._bind_result()),
),
patch.object(
voice_routes.voice_service,
"is_available",
return_value=True,
),
patch.object(
voice_routes.multimodal_alliance_store,
"assert_voice_processing_allowed",
new=consent_gate,
),
patch.object(
voice_routes.voice_service,
"transcribe",
transcribe,
),
patch.object(
voice_routes,
"_run_turn_and_speak",
run_turn,
),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertEqual(gate_calls, 2)
transcribe.assert_awaited_once()
run_turn.assert_awaited_once()
self.assertEqual(websocket.close_codes, [1000])
self.assertIn(
{
"type": "error",
"code": "multimodal_consent_withdrawn",
"detail": "multimodal voice consent was withdrawn",
},
websocket.sent_json,
)
withdrawn_index = next(
index
for index, payload in enumerate(websocket.sent_json)
if payload.get("code") == "multimodal_consent_withdrawn"
)
self.assertNotIn(
"transcript",
[
payload.get("type")
for payload in websocket.sent_json[withdrawn_index + 1 :]
],
)
async def test_unauthenticated_client_closes_before_session_or_voice_checks(
self,
) -> None: