런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -190,6 +190,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(kwargs["voice_preset"], VOICE_PRESET)
self.assertEqual(kwargs["audio"], b"chunk-onechunk-two")
self.assertEqual(kwargs["fmt"], "webm")
self.assertIsNone(kwargs["sample_rate"])
self.assertIsNone(kwargs["channels"])
self.assertIsNone(kwargs["sample_width"])
self.assertEqual(kwargs["audio_started_at"], 10.0)
self.assertEqual(kwargs["audio_ended_at"], 12.0)
self.assertEqual(kwargs["silence_ms"], 450)
@ -212,6 +215,62 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
],
)
def test_pcm_upload_is_wrapped_as_wav_before_stt(self) -> None:
audio, fmt = voice_routes._normalize_audio_upload(
b"\x00\x00\xff\x7f",
fmt="pcm",
sample_rate=16000,
channels=1,
sample_width=2,
)
self.assertEqual(fmt, "wav")
self.assertTrue(audio.startswith(b"RIFF"))
self.assertEqual(audio[8:12], b"WAVE")
self.assertEqual(audio[12:16], b"fmt ")
self.assertEqual(int.from_bytes(audio[24:28], "little"), 16000)
self.assertEqual(int.from_bytes(audio[22:24], "little"), 1)
self.assertEqual(audio[36:40], b"data")
self.assertEqual(int.from_bytes(audio[40:44], "little"), 4)
self.assertEqual(audio[44:], b"\x00\x00\xff\x7f")
async def test_pcm_control_metadata_flows_to_handle_utterance(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"}),
]
)
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,
"_handle_utterance",
handle_utterance,
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
handle_utterance.assert_awaited_once()
kwargs = handle_utterance.await_args.kwargs
self.assertEqual(kwargs["fmt"], "pcm")
self.assertEqual(kwargs["sample_rate"], 16000)
self.assertEqual(kwargs["channels"], 1)
self.assertEqual(kwargs["sample_width"], 2)
async def test_text_turn_strips_text_runs_turn_and_ping_close_still_work(self) -> None:
websocket = FakeWebSocket(
[
@ -259,6 +318,152 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(kwargs["voice_preset"], VOICE_PRESET)
self.assertEqual(kwargs["learner_text"], "I need help practicing.")
async def test_stt_result_waits_for_final_transcript_before_running_turn(self) -> None:
websocket = FakeWebSocket(
[
_control(
{
"type": "stt_result",
"text": "I am still talking",
"final": False,
"silence_ms": 2500,
}
),
_control({"type": "close"}),
]
)
run_turn = AsyncMock()
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,
"_handle_utterance",
handle_utterance,
), patch.object(
voice_routes,
"_run_turn_and_speak",
run_turn,
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
handle_utterance.assert_not_awaited()
run_turn.assert_not_awaited()
self.assertIn(
{
"type": "eot",
"ready": False,
"reason": "final_transcript_pending",
"silence_ms": 2500,
"threshold_ms": 1200,
},
websocket.sent_json,
)
self.assertEqual(websocket.sent_json[-1], {"type": "state", "state": "listening"})
async def test_stt_result_runs_turn_only_after_eot_ready(self) -> None:
websocket = FakeWebSocket(
[
_control({"type": "audio_start", "format": "pcm"}),
_control(
{
"type": "stt_result",
"text": " I am done now. ",
"final": True,
"silence_ms": 1300,
"barge_in": False,
"provider_events": [
{
"type": "speech_final",
"confidence": 0.91,
"text": "raw transcript must not persist",
}
],
}
),
_control({"type": "close"}),
]
)
run_turn = AsyncMock()
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,
"_handle_utterance",
handle_utterance,
), patch.object(
voice_routes,
"_run_turn_and_speak",
run_turn,
), patch.object(
voice_routes.time,
"monotonic",
side_effect=[10.0, 12.0],
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
handle_utterance.assert_not_awaited()
run_turn.assert_awaited_once()
self.assertIn(
{
"type": "eot",
"ready": True,
"reason": "ready",
"silence_ms": 1300,
"threshold_ms": 1200,
},
websocket.sent_json,
)
self.assertIn(
{
"type": "transcript",
"text": "I am done now.",
"final": True,
"speaker": "counselor",
},
websocket.sent_json,
)
self.assertIn({"type": "state", "state": "thinking"}, websocket.sent_json)
kwargs = run_turn.await_args.kwargs
self.assertEqual(kwargs["learner_text"], "I am done now.")
self.assertEqual(kwargs["duration_s"], 2.0)
self.assertEqual(kwargs["silence_ms"], 1300)
self.assertIs(kwargs["barge_in"], False)
self.assertEqual(
kwargs["provider_events"],
[
{
"type": "speech_final",
"confidence": 0.91,
"event_type": "speech_final",
"category": "speech_activity",
}
],
)
async def test_oversize_binary_audio_reports_error_and_drops_utterance(self) -> None:
websocket = FakeWebSocket(
[