음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -12,7 +12,7 @@ from .deps import Principal, Role
from .engine_client import EngineError
from .routes import sessions
from .routes import voice as voice_routes
from .services import memory, orchestrator, persona as persona_service, state_machine
from .services import live_coach, memory, orchestrator, persona as persona_service, state_machine
from .services.voice import TTSChunk, TranscriptResult, VoicePreset
from .store import InProcSession, TurnRecord, store
@ -25,6 +25,7 @@ def _principal() -> Principal:
email="turn-test@hs.ac.kr",
display_name="Turn Test",
consent_at=1.0,
profile_completed_at=1.0,
)
@ -310,6 +311,79 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("조금 말해볼게요", learner_turn.evaluation["appropriateness_note"])
self.assertIsNone(client_turn.evaluation)
async def test_live_coach_degrades_to_rule_based_suggestion_when_engine_fails(self) -> None:
principal = _principal()
sess = _session(principal)
sess.turns.append(
TurnRecord(
turn_seq=1,
speaker="counselor",
stage=sess.state.stage.value,
text="그냥 학교는 가야 하는 거 아닐까요?",
text_masked="그냥 학교는 가야 하는 거 아닐까요?",
evaluation={"appropriateness": "warn", "appropriateness_note": "조언이 빠름"},
)
)
with patch.object(
sessions,
"_retrieve_live_coach_grounding",
AsyncMock(return_value=[]),
), patch.object(
sessions.engine_client,
"generate",
AsyncMock(side_effect=EngineError("offline")),
):
response = await sessions.live_coach_turn(
sess.session_id,
sessions.LiveCoachRequest(
learner_text="그냥 학교는 가야 하는 거 아닐까요?",
client_reply="몰라요. 그런 말 들으려고 온 건 아닌데요.",
turn_seq=1,
),
principal,
)
self.assertEqual(response.status, "degraded")
self.assertEqual(response.tone, "warn")
self.assertEqual(response.focus, "rapport")
self.assertIn("조언", response.title + response.message)
self.assertTrue(response.next_utterance)
self.assertTrue(response.sources)
self.assertEqual(response.sources[0].source_id, "workbook_0615_case_conceptualization")
history = await sessions.list_live_coach_history(sess.session_id, principal)
self.assertEqual(history.source, "runtime")
self.assertEqual(len(history.events), 1)
self.assertEqual(history.events[0].turn_seq, 1)
self.assertEqual(history.events[0].suggestion.title, response.title)
self.assertIn("학교", history.events[0].learner_text_excerpt or "")
async def test_live_coach_uses_official_risk_reference_pack_for_crisis_signal(self) -> None:
item = live_coach.LiveCoachInput(
session_id="risk-coach-session",
turn_seq=3,
stage="exploration",
effective_openness=0.45,
theory_mode="humanistic",
persona_code="P1",
persona_name="서연",
learner_text="죽고 싶다는 생각이 들 때도 있나요?",
client_reply="가끔 그런 생각이 들어요.",
recent_turns=[],
evaluation={"appropriateness": "warn", "appropriateness_note": "위험사정 필요"},
)
engine = SimpleNamespace(generate=AsyncMock(side_effect=EngineError("offline")))
suggestion = await live_coach.generate_live_coaching(item, engine=engine)
self.assertEqual(suggestion.focus, "risk")
source_ids = [source.source_id for source in suggestion.sources]
self.assertIn("official_suicide_risk_guidelines", source_ids)
official = next(source for source in suggestion.sources if source.source_id == "official_suicide_risk_guidelines")
self.assertEqual(official.source_type, "official_guideline")
self.assertTrue(official.citation)
engine.generate.assert_called_once()
async def test_start_session_uses_stable_case_context_and_seed_recall(self) -> None:
principal = _principal()
card = persona_service.P1
@ -394,6 +468,25 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(caught.exception.detail, "consent_required")
get_persona.assert_not_awaited()
async def test_start_session_requires_onboarding_before_consent_and_catalog_lookup(self) -> None:
principal = _principal()
principal.profile_completed_at = None
with patch.object(
sessions,
"get_catalog_persona",
AsyncMock(side_effect=AssertionError("onboarding gate must run before catalog lookup")),
) as get_persona:
with self.assertRaises(sessions.HTTPException) as caught:
await sessions.start_session(
sessions.SessionStartRequest(persona_code=persona_service.P1.code),
principal,
)
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(caught.exception.detail, "onboarding_required")
get_persona.assert_not_awaited()
async def test_run_turn_stream_parses_gateway_done_telemetry(self) -> None:
class FakeStreamEngine:
engine_mode = "claude_cli"