런타임 계약과 학습자 흐름 보강
This commit is contained in:
parent
f456b8997a
commit
206018b088
56 changed files with 4306 additions and 1008 deletions
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue