전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -166,7 +166,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3))
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 99))
async def test_text_turn_speech_returns_openai_audio_for_owned_persisted_turn(self) -> None:
async def test_text_turn_speech_returns_openai_audio_for_owned_persisted_turn(
self,
) -> None:
session = SimpleNamespace(
persona=SimpleNamespace(code="P1"),
turns=[
@ -186,30 +188,37 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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,
"synthesize_stream",
new=synthesize,
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,
"synthesize_stream",
new=synthesize,
),
):
response = await voice_routes.voice_speech(
voice_routes.VoiceSpeechRequest(session_id=SESSION_ID, turn_seq=4),
@ -223,7 +232,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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:
async def test_audio_start_binary_chunks_audio_end_ping_close_contract(
self,
) -> None:
websocket = FakeWebSocket(
[
_control({"type": "audio_start", "format": "webm"}),
@ -252,55 +263,66 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
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],
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],
[
(message.get("type"), message.get("state"))
for message in websocket.sent_json
],
[("ready", "idle"), ("state", "listening"), ("pong", None)],
)
handle_utterance.assert_awaited_once()
kwargs = handle_utterance.await_args.kwargs
self.assertEqual(kwargs["session_id"], SESSION_ID)
self.assertEqual(kwargs["principal"].user_id, _principal().user_id)
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)
self.assertIs(kwargs["barge_in"], True)
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(
kwargs["provider_events"],
utterance.prosody.provider_events,
[
{
"type": "sigh",
@ -339,7 +361,15 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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}),
_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"}),
@ -347,33 +377,40 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
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,
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)
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:
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. "}),
@ -384,41 +421,51 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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,
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],
[
(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()
kwargs = run_turn.await_args.kwargs
self.assertEqual(kwargs["session_id"], SESSION_ID)
self.assertEqual(kwargs["principal"].user_id, _principal().user_id)
self.assertEqual(kwargs["voice_preset"], VOICE_PRESET)
self.assertEqual(kwargs["learner_text"], "I need help practicing.")
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(
@ -437,22 +484,27 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
)
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,
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]
@ -469,7 +521,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(websocket.sent_json[-1], {"type": "state", "state": "idle"})
async def test_stt_result_waits_for_final_transcript_before_running_turn(self) -> None:
async def test_stt_result_waits_for_final_transcript_before_running_turn(
self,
) -> None:
websocket = FakeWebSocket(
[
_control(
@ -486,26 +540,32 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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,
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]
@ -521,7 +581,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
},
websocket.sent_json,
)
self.assertEqual(websocket.sent_json[-1], {"type": "state", "state": "listening"})
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(
@ -549,30 +611,37 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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],
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]
@ -598,13 +667,13 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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)
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(
kwargs["provider_events"],
turn.prosody.provider_events,
[
{
"type": "speech_final",
@ -615,7 +684,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
],
)
async def test_oversize_binary_audio_reports_error_and_drops_utterance(self) -> None:
async def test_oversize_binary_audio_reports_error_and_drops_utterance(
self,
) -> None:
websocket = FakeWebSocket(
[
_control({"type": "audio_start", "format": "webm"}),
@ -626,30 +697,37 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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,
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]
@ -664,48 +742,62 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
handle_utterance.assert_not_awaited()
run_turn.assert_not_awaited()
async def test_unauthenticated_client_closes_before_session_or_voice_checks(self) -> None:
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:
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"}])
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:
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:
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)
@ -733,14 +825,17 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
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,
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()
@ -749,31 +844,40 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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.",
))
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:
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,
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,
@ -791,14 +895,17 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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,
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,
@ -817,14 +924,17 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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,
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()
@ -836,7 +946,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
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:
async def test_catalog_voice_map_is_used_for_dev_persona_binding_helper(
self,
) -> None:
voice_map = PersonaVoiceMap(
provider="openai",
voice_id="verse",