1698 lines
57 KiB
Python
1698 lines
57 KiB
Python
"""Deterministic WebSocket contract tests for the voice gateway route."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from .deps import Principal, Role
|
|
from .persona_repository import PersonaVoiceMap
|
|
from .routes import voice as voice_routes
|
|
from .services import multimodal_alliance_store
|
|
from .services.voice import (
|
|
MELOTTS_TTS_MODEL,
|
|
StreamingTranscriptEvent,
|
|
TranscriptResult,
|
|
TranscriptWord,
|
|
TTSChunk,
|
|
VoicePreset,
|
|
)
|
|
from .store import TurnRecord
|
|
|
|
|
|
SESSION_ID = "voice-ws-contract-session"
|
|
VOICE_PRESET = VoicePreset(preset="neutral", openai_voice="sage")
|
|
|
|
|
|
def _principal(
|
|
role: Role = Role.LEARNER,
|
|
*,
|
|
consent_at: float | None = 1.0,
|
|
profile_completed_at: float | None = 1.0,
|
|
) -> Principal:
|
|
return Principal(
|
|
user_id="00000000-0000-0000-0000-000000000201",
|
|
role=role,
|
|
cohort_ids=[],
|
|
email=f"voice-ws-{role.value}@hs.ac.kr",
|
|
display_name="Voice WS Contract",
|
|
consent_at=consent_at if role == Role.LEARNER else None,
|
|
profile_completed_at=profile_completed_at if role == Role.LEARNER else None,
|
|
)
|
|
|
|
|
|
def _control(payload: dict[str, object]) -> dict[str, object]:
|
|
return {"text": json.dumps(payload)}
|
|
|
|
|
|
def _binary(data: bytes) -> dict[str, object]:
|
|
return {"bytes": data}
|
|
|
|
|
|
class FakeWebSocket:
|
|
def __init__(self, incoming: list[dict[str, object]] | None = None) -> None:
|
|
self._incoming = list(incoming or [])
|
|
self.accepted = False
|
|
self.client_state = voice_routes.WebSocketState.CONNECTING
|
|
self.cookies: dict[str, str] = {}
|
|
self.query_params: dict[str, str] = {}
|
|
self.sent_json: list[dict[str, object]] = []
|
|
self.sent_text: list[str] = []
|
|
self.sent_bytes: list[bytes] = []
|
|
self.close_codes: list[int] = []
|
|
|
|
async def accept(self) -> None:
|
|
self.accepted = True
|
|
self.client_state = voice_routes.WebSocketState.CONNECTED
|
|
|
|
async def receive(self) -> dict[str, object]:
|
|
if self._incoming:
|
|
return self._incoming.pop(0)
|
|
return {"type": "websocket.disconnect"}
|
|
|
|
async def send_text(self, data: str) -> None:
|
|
self.sent_text.append(data)
|
|
self.sent_json.append(json.loads(data))
|
|
|
|
async def send_bytes(self, data: bytes) -> None:
|
|
self.sent_bytes.append(data)
|
|
|
|
async def close(self, code: int = 1000) -> None:
|
|
self.close_codes.append(code)
|
|
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 VoiceHealthContractTest(unittest.IsolatedAsyncioTestCase):
|
|
async def test_melotts_health_reports_the_operated_model(self) -> None:
|
|
service = SimpleNamespace(
|
|
is_available=lambda: True,
|
|
stt_available=lambda: True,
|
|
tts_available=lambda: True,
|
|
tts_provider=lambda: "melotts",
|
|
stt_provider=lambda: "local_whisper",
|
|
stt_model=lambda: "small",
|
|
batch_stt_available=lambda: False,
|
|
)
|
|
|
|
with patch.object(voice_routes, "voice_service", service):
|
|
response = await voice_routes.voice_health()
|
|
|
|
body = json.loads(bytes(response.body))
|
|
self.assertEqual(body["status"], "ok")
|
|
self.assertEqual(body["tts_provider"], "melotts")
|
|
self.assertEqual(body["tts_model"], MELOTTS_TTS_MODEL)
|
|
|
|
|
|
class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
|
|
def _bind_result(self) -> tuple[str, VoicePreset, None, dict[str, object]]:
|
|
return (
|
|
SESSION_ID,
|
|
VOICE_PRESET,
|
|
None,
|
|
{"degraded": False, "persona_catalog_source": "session"},
|
|
)
|
|
|
|
def test_provider_events_get_internal_taxonomy_without_raw_payload(self) -> None:
|
|
events = voice_routes._safe_provider_events(
|
|
[
|
|
{
|
|
"type": "SIGH",
|
|
"confidence": 0.81,
|
|
"text": "raw transcript must drop",
|
|
},
|
|
{
|
|
"kind": "voice_activity",
|
|
"start_ms": 10,
|
|
"raw_text": "drop",
|
|
},
|
|
{
|
|
"label": "vendor custom marker",
|
|
"score": 0.44,
|
|
},
|
|
]
|
|
)
|
|
|
|
self.assertEqual(
|
|
events,
|
|
[
|
|
{
|
|
"type": "SIGH",
|
|
"confidence": 0.81,
|
|
"event_type": "sigh",
|
|
"category": "paralinguistic",
|
|
},
|
|
{
|
|
"kind": "voice_activity",
|
|
"start_ms": 10,
|
|
"event_type": "voice_activity",
|
|
"category": "speech_activity",
|
|
},
|
|
{
|
|
"label": "vendor custom marker",
|
|
"score": 0.44,
|
|
"event_type": "vendor_custom_marker",
|
|
"category": "unknown",
|
|
},
|
|
],
|
|
)
|
|
|
|
def test_text_tts_uses_only_the_persisted_client_visible_reply(self) -> None:
|
|
session = SimpleNamespace(
|
|
turns=[
|
|
TurnRecord(
|
|
turn_seq=2,
|
|
speaker="counselor",
|
|
stage="초기",
|
|
text="raw learner text",
|
|
text_masked="masked learner text",
|
|
),
|
|
TurnRecord(
|
|
turn_seq=2,
|
|
speaker="client",
|
|
stage="초기",
|
|
text="raw client reply",
|
|
text_masked="마스킹된 내담자 응답",
|
|
),
|
|
TurnRecord(
|
|
turn_seq=3,
|
|
speaker="client",
|
|
stage="초기",
|
|
text="hidden evaluator reply",
|
|
text_masked="hidden evaluator reply",
|
|
visible_to=("evaluator",),
|
|
),
|
|
]
|
|
)
|
|
|
|
self.assertEqual(
|
|
voice_routes._client_turn_text_for_speech(session, 2),
|
|
"마스킹된 내담자 응답",
|
|
)
|
|
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3))
|
|
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 99))
|
|
|
|
def test_text_tts_maps_logical_turn_to_db_transcript_sequence(self) -> None:
|
|
session = SimpleNamespace(
|
|
turns=[
|
|
TurnRecord(
|
|
turn_seq=1,
|
|
speaker="counselor",
|
|
stage="초기",
|
|
text="첫 질문",
|
|
text_masked="첫 질문",
|
|
),
|
|
TurnRecord(
|
|
turn_seq=2,
|
|
speaker="client",
|
|
stage="초기",
|
|
text="첫 응답",
|
|
text_masked="첫 응답",
|
|
),
|
|
TurnRecord(
|
|
turn_seq=3,
|
|
speaker="counselor",
|
|
stage="초기",
|
|
text="둘째 질문",
|
|
text_masked="둘째 질문",
|
|
),
|
|
TurnRecord(
|
|
turn_seq=4,
|
|
speaker="client",
|
|
stage="초기",
|
|
text="둘째 응답",
|
|
text_masked="둘째 응답",
|
|
),
|
|
]
|
|
)
|
|
|
|
self.assertEqual(
|
|
voice_routes._client_turn_text_for_speech(session, 1), "첫 응답"
|
|
)
|
|
self.assertEqual(
|
|
voice_routes._client_turn_text_for_speech(session, 2), "둘째 응답"
|
|
)
|
|
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3))
|
|
|
|
async def test_text_turn_speech_returns_openai_audio_for_owned_persisted_turn(
|
|
self,
|
|
) -> None:
|
|
session = SimpleNamespace(
|
|
persona=SimpleNamespace(code="P1"),
|
|
turns=[
|
|
TurnRecord(
|
|
turn_seq=4,
|
|
speaker="client",
|
|
stage="초기",
|
|
text="내담자 응답",
|
|
text_masked="내담자 응답",
|
|
)
|
|
],
|
|
)
|
|
synthesized: list[tuple[str, VoicePreset]] = []
|
|
|
|
async def synthesize(text: str, voice: VoicePreset):
|
|
synthesized.append((text, voice))
|
|
yield TTSChunk(audio=b"mp3-a")
|
|
yield TTSChunk(audio=b"mp3-b")
|
|
|
|
with (
|
|
patch.object(
|
|
voice_routes,
|
|
"_practice_access_error",
|
|
AsyncMock(return_value=None),
|
|
),
|
|
patch.object(
|
|
voice_routes.turn_runtime,
|
|
"load_owned_session",
|
|
AsyncMock(return_value=(session, None)),
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"_resolve_session_voice",
|
|
AsyncMock(return_value=VOICE_PRESET),
|
|
),
|
|
patch.object(
|
|
voice_routes.voice_service,
|
|
"is_available",
|
|
return_value=True,
|
|
),
|
|
patch.object(
|
|
voice_routes.voice_service,
|
|
"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",
|
|
new=synthesize,
|
|
),
|
|
):
|
|
response = await voice_routes.voice_speech(
|
|
voice_routes.VoiceSpeechRequest(session_id=SESSION_ID, turn_seq=4),
|
|
_principal(),
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.media_type, "audio/mpeg")
|
|
self.assertEqual(response.body, b"mp3-amp3-b")
|
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
|
self.assertEqual(response.headers["x-vignette-tts-provider"], "openai")
|
|
self.assertEqual(synthesized, [("내담자 응답", VOICE_PRESET)])
|
|
|
|
async def test_audio_start_binary_chunks_audio_end_ping_close_contract(
|
|
self,
|
|
) -> None:
|
|
websocket = FakeWebSocket(
|
|
[
|
|
_control({"type": "audio_start", "format": "webm"}),
|
|
_binary(b"chunk-one"),
|
|
_binary(b"chunk-two"),
|
|
_control({"type": "ping"}),
|
|
_control(
|
|
{
|
|
"type": "audio_end",
|
|
"format": "webm",
|
|
"silence_ms": "450",
|
|
"barge_in": "true",
|
|
"provider_events": [
|
|
{
|
|
"type": "sigh",
|
|
"confidence": 0.82,
|
|
"text": "raw transcript must not persist",
|
|
},
|
|
{"kind": "noise", "label": "x" * 120},
|
|
"invalid",
|
|
],
|
|
}
|
|
),
|
|
_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,
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"_run_turn_and_speak",
|
|
AsyncMock(),
|
|
),
|
|
patch.object(
|
|
voice_routes.time,
|
|
"monotonic",
|
|
side_effect=[10.0, 12.0],
|
|
),
|
|
):
|
|
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
|
|
|
|
self.assertTrue(websocket.accepted)
|
|
self.assertEqual(websocket.close_codes, [1000])
|
|
self.assertEqual(
|
|
[
|
|
(message.get("type"), message.get("state"))
|
|
for message in websocket.sent_json
|
|
],
|
|
[("ready", "idle"), ("state", "listening"), ("pong", None)],
|
|
)
|
|
handle_utterance.assert_awaited_once()
|
|
context = handle_utterance.await_args.args[1]
|
|
utterance = handle_utterance.await_args.args[2]
|
|
self.assertEqual(context.session_id, SESSION_ID)
|
|
self.assertEqual(context.principal.user_id, _principal().user_id)
|
|
self.assertEqual(context.voice_preset, VOICE_PRESET)
|
|
self.assertEqual(utterance.audio, b"chunk-onechunk-two")
|
|
self.assertEqual(utterance.fmt, "webm")
|
|
self.assertIsNone(utterance.sample_rate)
|
|
self.assertIsNone(utterance.channels)
|
|
self.assertIsNone(utterance.sample_width)
|
|
self.assertEqual(utterance.audio_started_at, 10.0)
|
|
self.assertEqual(utterance.audio_ended_at, 12.0)
|
|
self.assertEqual(utterance.prosody.silence_ms, 450)
|
|
self.assertIs(utterance.prosody.barge_in, True)
|
|
self.assertEqual(
|
|
utterance.prosody.provider_events,
|
|
[
|
|
{
|
|
"type": "sigh",
|
|
"confidence": 0.82,
|
|
"event_type": "sigh",
|
|
"category": "paralinguistic",
|
|
},
|
|
{
|
|
"kind": "noise",
|
|
"label": "x" * 80,
|
|
"event_type": "background_noise",
|
|
"category": "audio_quality",
|
|
},
|
|
],
|
|
)
|
|
|
|
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_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(
|
|
[
|
|
_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()
|
|
utterance = handle_utterance.await_args.args[2]
|
|
self.assertEqual(utterance.fmt, "pcm")
|
|
self.assertEqual(utterance.sample_rate, 16000)
|
|
self.assertEqual(utterance.channels, 1)
|
|
self.assertEqual(utterance.sample_width, 2)
|
|
|
|
async def test_text_turn_strips_text_runs_turn_and_ping_close_still_work(
|
|
self,
|
|
) -> None:
|
|
websocket = FakeWebSocket(
|
|
[
|
|
_control({"type": "text_turn", "text": " I need help practicing. "}),
|
|
_control({"type": "ping"}),
|
|
_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]
|
|
|
|
self.assertEqual(websocket.close_codes, [1000])
|
|
self.assertEqual(
|
|
[
|
|
(message.get("type"), message.get("state"))
|
|
for message in websocket.sent_json
|
|
],
|
|
[("ready", "idle"), ("pong", None)],
|
|
)
|
|
handle_utterance.assert_not_awaited()
|
|
run_turn.assert_awaited_once()
|
|
context = run_turn.await_args.args[1]
|
|
turn = run_turn.await_args.args[2]
|
|
self.assertEqual(context.session_id, SESSION_ID)
|
|
self.assertEqual(context.principal.user_id, _principal().user_id)
|
|
self.assertEqual(context.voice_preset, VOICE_PRESET)
|
|
self.assertEqual(turn.learner_text, "I need help practicing.")
|
|
|
|
async def test_turn_persistence_failure_uses_structured_voice_error(self) -> None:
|
|
websocket = FakeWebSocket(
|
|
[
|
|
_control({"type": "text_turn", "text": "I need help practicing."}),
|
|
_control({"type": "close"}),
|
|
]
|
|
)
|
|
run_turn = AsyncMock(
|
|
side_effect=HTTPException(
|
|
status_code=503,
|
|
detail=(
|
|
"voice session turn append persistence unavailable; "
|
|
"runtime fallback is disabled in prod"
|
|
),
|
|
)
|
|
)
|
|
|
|
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,
|
|
"_run_turn_and_speak",
|
|
run_turn,
|
|
),
|
|
):
|
|
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
|
|
|
|
self.assertEqual(websocket.close_codes, [1000])
|
|
self.assertEqual(websocket.sent_json[0].get("type"), "ready")
|
|
self.assertEqual(websocket.sent_json[0].get("state"), "idle")
|
|
self.assertEqual(
|
|
websocket.sent_json[-2],
|
|
{
|
|
"type": "error",
|
|
"code": "turn_persistence_unavailable",
|
|
"detail": "voice turn persistence unavailable; retry the utterance",
|
|
},
|
|
)
|
|
self.assertEqual(websocket.sent_json[-1], {"type": "state", "state": "idle"})
|
|
|
|
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)
|
|
turn = run_turn.await_args.args[2]
|
|
self.assertEqual(turn.learner_text, "I am done now.")
|
|
self.assertEqual(turn.prosody.duration_s, 2.0)
|
|
self.assertEqual(turn.prosody.silence_ms, 1300)
|
|
self.assertIs(turn.prosody.barge_in, False)
|
|
self.assertEqual(
|
|
turn.prosody.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(
|
|
[
|
|
_control({"type": "audio_start", "format": "webm"}),
|
|
_binary(b"12345"),
|
|
_control({"type": "close"}),
|
|
]
|
|
)
|
|
handle_utterance = AsyncMock()
|
|
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,
|
|
"_handle_utterance",
|
|
handle_utterance,
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"_run_turn_and_speak",
|
|
run_turn,
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"_MAX_AUDIO_BYTES",
|
|
4,
|
|
),
|
|
):
|
|
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
|
|
|
|
self.assertEqual(websocket.close_codes, [1000])
|
|
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:
|
|
websocket = FakeWebSocket()
|
|
bind_session = AsyncMock()
|
|
|
|
with (
|
|
patch.object(
|
|
voice_routes,
|
|
"_principal_from_websocket",
|
|
AsyncMock(return_value=None),
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"_bind_session",
|
|
bind_session,
|
|
),
|
|
patch.object(
|
|
voice_routes.voice_service,
|
|
"is_available",
|
|
return_value=True,
|
|
) as is_available,
|
|
):
|
|
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
|
|
|
|
self.assertTrue(websocket.accepted)
|
|
self.assertEqual(websocket.close_codes, [voice_routes.WS_CLOSE_UNAUTHORIZED])
|
|
self.assertEqual(
|
|
websocket.sent_json, [{"type": "error", "detail": "not authenticated"}]
|
|
)
|
|
bind_session.assert_not_awaited()
|
|
is_available.assert_not_called()
|
|
|
|
async def test_non_learner_client_closes_before_session_or_voice_checks(
|
|
self,
|
|
) -> None:
|
|
websocket = FakeWebSocket()
|
|
bind_session = AsyncMock()
|
|
|
|
with (
|
|
patch.object(
|
|
voice_routes,
|
|
"_principal_from_websocket",
|
|
AsyncMock(return_value=_principal(Role.TEACHER)),
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"_bind_session",
|
|
bind_session,
|
|
),
|
|
patch.object(
|
|
voice_routes.voice_service,
|
|
"is_available",
|
|
return_value=True,
|
|
) as is_available,
|
|
):
|
|
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
|
|
|
|
self.assertTrue(websocket.accepted)
|
|
self.assertEqual(websocket.close_codes, [voice_routes.WS_CLOSE_UNAUTHORIZED])
|
|
self.assertEqual(
|
|
websocket.sent_json,
|
|
[{"type": "error", "detail": "only learners can use voice"}],
|
|
)
|
|
bind_session.assert_not_awaited()
|
|
is_available.assert_not_called()
|
|
|
|
async def test_bind_session_uses_db_voice_map_for_existing_session(self) -> None:
|
|
websocket = FakeWebSocket()
|
|
websocket.query_params = {"session_id": SESSION_ID}
|
|
sess = SimpleNamespace(persona=SimpleNamespace(code="P2"))
|
|
voice_map = PersonaVoiceMap(
|
|
provider="openai",
|
|
voice_id="voice-p2-custom",
|
|
base_params={
|
|
"preset": "calm-adult-male",
|
|
"openai_voice": "onyx",
|
|
"rate": 1.08,
|
|
"instructions": "Low, guarded delivery.",
|
|
},
|
|
)
|
|
get_voice_map = AsyncMock(return_value=voice_map)
|
|
|
|
with (
|
|
patch.object(
|
|
voice_routes,
|
|
"_load_voice_session",
|
|
AsyncMock(return_value=(sess, None)),
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"get_session_voice_map",
|
|
get_voice_map,
|
|
),
|
|
):
|
|
session_id, voice, err, meta = await voice_routes._bind_session(
|
|
websocket, _principal()
|
|
)
|
|
|
|
self.assertEqual(session_id, SESSION_ID)
|
|
self.assertIsNone(err)
|
|
self.assertEqual(meta["persona_catalog_source"], "session")
|
|
self.assertEqual(
|
|
voice,
|
|
VoicePreset(
|
|
preset="calm-adult-male",
|
|
openai_voice="onyx",
|
|
rate=1.08,
|
|
instructions="Low, guarded delivery.",
|
|
),
|
|
)
|
|
get_voice_map.assert_awaited_once_with(SESSION_ID)
|
|
|
|
async def test_bind_session_rejects_existing_session_without_onboarding(
|
|
self,
|
|
) -> None:
|
|
websocket = FakeWebSocket()
|
|
websocket.query_params = {"session_id": SESSION_ID}
|
|
load_voice_session = AsyncMock()
|
|
|
|
with (
|
|
patch.object(
|
|
voice_routes,
|
|
"user_onboarding_complete",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"user_has_consent",
|
|
AsyncMock(return_value=True),
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"_load_voice_session",
|
|
load_voice_session,
|
|
),
|
|
):
|
|
session_id, voice, err, meta = await voice_routes._bind_session(
|
|
websocket,
|
|
_principal(profile_completed_at=None),
|
|
)
|
|
|
|
self.assertIsNone(session_id)
|
|
self.assertIsNone(voice)
|
|
self.assertEqual(err, "onboarding_required")
|
|
self.assertEqual(meta, {})
|
|
load_voice_session.assert_not_awaited()
|
|
|
|
async def test_bind_session_rejects_existing_session_without_consent(self) -> None:
|
|
websocket = FakeWebSocket()
|
|
websocket.query_params = {"session_id": SESSION_ID}
|
|
load_voice_session = AsyncMock()
|
|
|
|
with (
|
|
patch.object(
|
|
voice_routes,
|
|
"user_has_consent",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"_load_voice_session",
|
|
load_voice_session,
|
|
),
|
|
):
|
|
session_id, voice, err, meta = await voice_routes._bind_session(
|
|
websocket,
|
|
_principal(consent_at=None),
|
|
)
|
|
|
|
self.assertIsNone(session_id)
|
|
self.assertIsNone(voice)
|
|
self.assertEqual(err, "consent_required")
|
|
self.assertEqual(meta, {})
|
|
load_voice_session.assert_not_awaited()
|
|
|
|
async def test_bind_session_explicit_preset_overrides_db_voice_map(self) -> None:
|
|
websocket = FakeWebSocket()
|
|
websocket.query_params = {"session_id": SESSION_ID, "preset": "soft-young-fem"}
|
|
sess = SimpleNamespace(persona=SimpleNamespace(code="P2"))
|
|
get_voice_map = AsyncMock()
|
|
|
|
with (
|
|
patch.object(
|
|
voice_routes,
|
|
"_load_voice_session",
|
|
AsyncMock(return_value=(sess, None)),
|
|
),
|
|
patch.object(
|
|
voice_routes,
|
|
"get_session_voice_map",
|
|
get_voice_map,
|
|
),
|
|
):
|
|
session_id, voice, err, _ = await voice_routes._bind_session(
|
|
websocket, _principal()
|
|
)
|
|
|
|
self.assertEqual(session_id, SESSION_ID)
|
|
self.assertIsNone(err)
|
|
self.assertEqual(voice.preset, "soft-young-fem")
|
|
self.assertEqual(voice.openai_voice, "coral")
|
|
get_voice_map.assert_not_awaited()
|
|
|
|
async def test_catalog_voice_map_is_used_for_dev_persona_binding_helper(
|
|
self,
|
|
) -> None:
|
|
voice_map = PersonaVoiceMap(
|
|
provider="openai",
|
|
voice_id="verse",
|
|
base_params={"preset": "soft-young-fem", "rate": 0.9},
|
|
)
|
|
get_voice_map = AsyncMock(return_value=voice_map)
|
|
|
|
with patch.object(voice_routes, "get_persona_voice_map", get_voice_map):
|
|
voice = await voice_routes._resolve_catalog_voice(
|
|
persona_id="00000000-0000-0000-0000-000000000301",
|
|
version=7,
|
|
persona_code="P1",
|
|
explicit_preset=None,
|
|
)
|
|
|
|
self.assertEqual(voice.preset, "soft-young-fem")
|
|
self.assertEqual(voice.openai_voice, "verse")
|
|
self.assertAlmostEqual(voice.rate, 0.9)
|
|
get_voice_map.assert_awaited_once_with(
|
|
persona_id="00000000-0000-0000-0000-000000000301",
|
|
version=7,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|