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:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue