전 저장소 리팩터링과 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

@ -13,10 +13,18 @@ from .config import settings
from . import session_persistence, turn_runtime
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .deps import Principal, Role
from .engine_client import EngineError
from .engine_client import EngineError, GenerateResponse
from .routes import sessions
from .routes import voice as voice_routes
from .services import guardrail, live_coach, memory, orchestrator, persona as persona_service, rag, state_machine
from .services import (
guardrail,
live_coach,
memory,
orchestrator,
persona as persona_service,
rag,
state_machine,
)
from .services.voice import TTSChunk, TranscriptResult, VoicePreset
from .store import InProcSession, TurnRecord, store
@ -88,22 +96,32 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
store._sessions.clear()
sessions._RECALL_CACHE.clear()
sessions._KB_CUES_CACHE.clear()
session_persistence._LIVE_COACH_EVENT_CACHE.clear()
async def asyncTearDown(self) -> None:
store._sessions.clear()
sessions._RECALL_CACHE.clear()
sessions._KB_CUES_CACHE.clear()
session_persistence._LIVE_COACH_EVENT_CACHE.clear()
async def test_end_session_does_not_reschedule_evaluation_for_already_ended_session(self) -> None:
async def test_end_session_does_not_reschedule_evaluation_for_already_ended_session(
self,
) -> None:
principal = _principal()
sess = _session(principal)
sess.ended = True
sess.ended_at = 1_000.0
with (
patch.object(sessions, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(sessions, "_end_persisted_session", AsyncMock(return_value=None)),
patch.object(sessions, "_schedule_session_evaluation") as schedule_session_evaluation,
patch.object(
sessions, "_load_session_or_404", AsyncMock(return_value=sess)
),
patch.object(
sessions, "_end_persisted_session", AsyncMock(return_value=None)
),
patch.object(
sessions, "_schedule_session_evaluation"
) as schedule_session_evaluation,
):
response = await sessions.end_session(sess.session_id, principal)
@ -151,10 +169,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
provider_events=[{"type": "sigh", "confidence": 0.82}],
)
with patch.object(session_persistence, "get_pool", return_value=object()), patch.object(
session_persistence,
"acquire",
return_value=FakeAcquire(conn),
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(
session_persistence,
"acquire",
return_value=FakeAcquire(conn),
),
):
ok = await session_persistence.append_turn(
session_id="turn-persistence-session",
@ -169,7 +190,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(conn.insert_args[17], list(turn.visible_to))
self.assertEqual(turn.turn_id, "00000000-0000-0000-0000-000000009999")
async def test_generate_turn_engine_failure_does_not_append_learner_turn(self) -> None:
async def test_generate_turn_engine_failure_does_not_append_learner_turn(
self,
) -> None:
principal = _principal()
sess = _session(principal)
@ -296,11 +319,16 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
principal,
)
self.assertEqual(response.client_reply, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.")
self.assertEqual(
response.client_reply,
"저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.",
)
self.assertEqual(len(sess.turns), 2)
learner_turn, client_turn = sess.turns
self.assertIsNone(learner_turn.llm_provider)
self.assertEqual(client_turn.text, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.")
self.assertEqual(
client_turn.text, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요."
)
self.assertNotIn("김서연", client_turn.text_masked)
self.assertNotIn("한신대학교", client_turn.text_masked)
self.assertNotIn("상담심리학과", client_turn.text_masked)
@ -312,7 +340,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(client_turn.tokens_out, 23)
self.assertEqual(client_turn.cost_usd, 0.012345)
async def test_generate_real_crisis_stops_before_engine_and_returns_109_resource(self) -> None:
async def test_generate_real_crisis_stops_before_engine_and_returns_109_resource(
self,
) -> None:
principal = _principal()
sess = _session(principal)
@ -349,7 +379,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
result = orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1,
stage=ctx.state_after.stage.value if ctx.state_after else "라포",
effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0,
effective_openness=ctx.state_after.effective_openness
if ctx.state_after
else 0.0,
client_reply=None,
safety_flagged=True,
state_after=ctx.state_after or sess.state,
@ -368,10 +400,14 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def __aenter__(self) -> FakeConn:
return FakeConn()
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
async def __aexit__(
self, exc_type: object, exc: object, tb: object
) -> None:
return None
with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()) as acquire:
with patch.object(
turn_runtime.db, "acquire", return_value=FakeAcquire()
) as acquire:
await turn_runtime.record_safety_event(sess, ctx, result)
acquire.assert_called_once_with(ai_context=True)
@ -394,7 +430,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(detail["crisis_resource"]["number"], "109")
self.assertEqual(detail["alert_status"], "teacher_dashboard")
async def test_record_safety_event_fails_closed_when_insert_fails_outside_dev(self) -> None:
async def test_record_safety_event_fails_closed_when_insert_fails_outside_dev(
self,
) -> None:
principal = _principal()
sess = _session(principal)
ctx = orchestrator.prepare_turn(
@ -408,7 +446,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
result = orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1,
stage=ctx.state_after.stage.value if ctx.state_after else "라포",
effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0,
effective_openness=ctx.state_after.effective_openness
if ctx.state_after
else 0.0,
client_reply=None,
safety_flagged=True,
state_after=ctx.state_after or sess.state,
@ -425,7 +465,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def __aenter__(self) -> FakeConn:
return FakeConn()
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
async def __aexit__(
self, exc_type: object, exc: object, tb: object
) -> None:
return None
previous_environment = settings.environment
@ -480,17 +522,25 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(client_turn.tokens_out, 37)
self.assertEqual(client_turn.cost_usd, 0.023456)
async def test_stream_real_crisis_stops_before_engine_and_persists_learner_only(self) -> None:
async def test_stream_real_crisis_stops_before_engine_and_persists_learner_only(
self,
) -> None:
principal = _principal()
sess = _session(principal)
def should_not_stream(*args, **kwargs):
raise AssertionError("stream engine must not be called for learner_real crisis")
raise AssertionError(
"stream engine must not be called for learner_real crisis"
)
with (
patch.object(sessions.engine_client, "stream", should_not_stream),
patch.object(turn_runtime, "record_completed_turn", new_callable=AsyncMock) as completed_turn,
patch.object(turn_runtime, "record_safety_event", new_callable=AsyncMock) as safety_event,
patch.object(
turn_runtime, "record_completed_turn", new_callable=AsyncMock
) as completed_turn,
patch.object(
turn_runtime, "record_safety_event", new_callable=AsyncMock
) as safety_event,
):
response = await sessions.stream_turn(
sess.session_id,
@ -511,7 +561,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(saved_result.conversation_stopped)
self.assertEqual(saved_result.crisis_resource["number"], "109")
async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(self) -> None:
async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(
self,
) -> None:
principal = _principal()
sess = _session(principal)
@ -543,10 +595,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
"appropriateness_note": f"응답 반영: {client_reply}",
}
with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream), patch.object(
sessions.evaluator,
"make_eval_hook",
return_value=fake_eval_hook,
with (
patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
patch.object(
sessions.evaluator,
"make_eval_hook",
return_value=fake_eval_hook,
),
):
response = await sessions.stream_turn(
sess.session_id,
@ -560,10 +615,14 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(learner_turn.speaker, "counselor")
self.assertIsNotNone(learner_turn.evaluation)
self.assertEqual(learner_turn.evaluation["appropriateness"], "pos")
self.assertIn("조금 말해볼게요", learner_turn.evaluation["appropriateness_note"])
self.assertIn(
"조금 말해볼게요", learner_turn.evaluation["appropriateness_note"]
)
self.assertIsNone(client_turn.evaluation)
async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review(self) -> None:
async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review(
self,
) -> None:
principal = _principal()
sess = _session(principal)
@ -589,10 +648,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def failing_eval_hook(ctx, client_reply):
raise RuntimeError("김서연 평가 timeout 010-1234-5678")
with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream), patch.object(
sessions.evaluator,
"make_eval_hook",
return_value=failing_eval_hook,
with (
patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
patch.object(
sessions.evaluator,
"make_eval_hook",
return_value=failing_eval_hook,
),
):
response = await sessions.stream_turn(
sess.session_id,
@ -622,13 +684,17 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIsNotNone(review_turn.note)
assert review_turn.note is not None
self.assertEqual(review_turn.note.title, "턴 직후 평가 실패")
self.assertIn("fast-loop(턴 직후) 평가를 완료하지 못했습니다", review_turn.note.body)
self.assertIn(
"fast-loop(턴 직후) 평가를 완료하지 못했습니다", review_turn.note.body
)
self.assertIn("AI 평가 재시도가 필요합니다", review_turn.note.body)
self.assertNotIn("RuntimeError", review_turn.note.body)
self.assertNotIn("김서연", review_turn.note.body)
self.assertNotIn("010-1234-5678", review_turn.note.body)
async def test_live_coach_degrades_to_rule_based_suggestion_when_engine_fails(self) -> None:
async def test_live_coach_degrades_to_rule_based_suggestion_when_engine_fails(
self,
) -> None:
principal = _principal()
sess = _session(principal)
sess.turns.append(
@ -638,18 +704,24 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
stage=sess.state.stage.value,
text="그냥 학교는 가야 하는 거 아닐까요?",
text_masked="그냥 학교는 가야 하는 거 아닐까요?",
evaluation={"appropriateness": "warn", "appropriateness_note": "조언이 빠름"},
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")),
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,
@ -667,7 +739,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
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")
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)
@ -676,11 +750,85 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(history.events[0].suggestion.status, "degraded")
self.assertEqual(history.events[0].suggestion.title, response.title)
self.assertIn("학교", history.events[0].learner_text_excerpt or "")
session_persistence._LIVE_COACH_EVENT_CACHE[sess.session_id][0]["stage"] = "unknown-stage"
legacy_history = await sessions.list_live_coach_history(sess.session_id, principal)
session_persistence._LIVE_COACH_EVENT_CACHE[sess.session_id][0]["stage"] = (
"unknown-stage"
)
legacy_history = await sessions.list_live_coach_history(
sess.session_id, principal
)
self.assertIsNone(legacy_history.events[0].stage)
async def test_live_coach_rag_grounding_preserves_source_pack_metadata(self) -> None:
async def test_live_coach_degrades_when_llm_audit_is_unavailable(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": "pos",
"appropriateness_note": "감정 반영",
},
)
)
engine_response = GenerateResponse(
text="",
model="fake-live-coach-model",
provider="fake-provider",
tokens_in=11,
tokens_out=7,
cost_usd=0.01,
structured={
"tone": "pos",
"focus": "emotion",
"title": "엔진 생성 코칭",
"message": "감정 반영을 이어가세요.",
"next_utterance": "그 마음이 가장 컸던 순간이 언제였나요?",
},
)
with (
patch.object(
sessions,
"_retrieve_live_coach_grounding",
AsyncMock(return_value=[]),
),
patch.object(
sessions.engine_client,
"generate",
AsyncMock(return_value=engine_response),
) as generate,
patch.object(
session_persistence,
"record_llm_call_audit",
AsyncMock(return_value=False),
) as audit,
):
response = await sessions.live_coach_turn(
sess.session_id,
sessions.LiveCoachRequest(
learner_text="그 마음이 컸겠네요.",
client_reply="네, 아무도 몰라주는 것 같았어요.",
turn_seq=1,
),
principal,
)
generate.assert_awaited_once()
audit.assert_awaited_once()
self.assertEqual(response.status, "degraded")
self.assertNotEqual(response.title, "엔진 생성 코칭")
self.assertIn("응답 검증 기록", response.rationale or "")
history = await sessions.list_live_coach_history(sess.session_id, principal)
self.assertEqual(len(history.events), 1)
self.assertEqual(history.events[0].suggestion.status, "degraded")
async def test_live_coach_rag_grounding_preserves_source_pack_metadata(
self,
) -> None:
retrieval = rag.RetrievalResult(
chunks=[
rag.RetrievedChunk(
@ -706,8 +854,14 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
)
with (
patch.object(sessions.db, "acquire", return_value=_AsyncConnContext(object())),
patch.object(sessions.rag, "retrieve_eval_grounding", AsyncMock(return_value=retrieval)),
patch.object(
sessions.db, "acquire", return_value=_AsyncConnContext(object())
),
patch.object(
sessions.rag,
"retrieve_eval_grounding",
AsyncMock(return_value=retrieval),
),
patch.object(sessions.rag, "log_retrieval", AsyncMock()),
):
grounding = await sessions._retrieve_live_coach_grounding(
@ -740,19 +894,23 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
detail="live coach event save persistence unavailable; runtime fallback is disabled in prod",
)
with patch.object(
sessions,
"_retrieve_live_coach_grounding",
AsyncMock(return_value=[]),
), patch.object(
sessions.live_coach,
"generate_live_coaching",
AsyncMock(return_value=suggestion),
), patch.object(
session_persistence,
"save_live_coach_event",
AsyncMock(side_effect=save_error),
) as save_event:
with (
patch.object(
sessions,
"_retrieve_live_coach_grounding",
AsyncMock(return_value=[]),
),
patch.object(
sessions.live_coach,
"generate_live_coaching",
AsyncMock(return_value=suggestion),
),
patch.object(
session_persistence,
"save_live_coach_event",
AsyncMock(side_effect=save_error),
) as save_event,
):
with self.assertRaises(sessions.HTTPException) as caught:
await sessions.live_coach_turn(
sess.session_id,
@ -797,7 +955,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(coded.stage, "라포")
self.assertIsNone(invalid.stage)
async def test_live_coach_uses_official_risk_reference_pack_for_crisis_signal(self) -> None:
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,
@ -809,7 +969,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
learner_text="죽고 싶다는 생각이 들 때도 있나요?",
client_reply="가끔 그런 생각이 들어요.",
recent_turns=[],
evaluation={"appropriateness": "warn", "appropriateness_note": "위험사정 필요"},
evaluation={
"appropriateness": "warn",
"appropriateness_note": "위험사정 필요",
},
)
engine = SimpleNamespace(generate=AsyncMock(side_effect=EngineError("offline")))
@ -818,7 +981,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
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")
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()
@ -865,19 +1032,27 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
coro.close()
return None
with patch.object(sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)), patch.object(
sessions.session_persistence,
"get_case_context",
AsyncMock(return_value=case_context),
), patch.object(
sessions,
"_build_seed_recall",
AsyncMock(return_value=recall),
), patch.object(
sessions.session_persistence,
"create_session",
fake_create_session,
), patch.object(sessions.asyncio, "create_task", close_background):
with (
patch.object(
sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)
),
patch.object(
sessions.session_persistence,
"get_case_context",
AsyncMock(return_value=case_context),
),
patch.object(
sessions,
"_build_seed_recall",
AsyncMock(return_value=recall),
),
patch.object(
sessions.session_persistence,
"create_session",
fake_create_session,
),
patch.object(sessions.asyncio, "create_task", close_background),
):
response = await sessions.start_session(
sessions.SessionStartRequest(persona_code=card.code),
principal,
@ -888,7 +1063,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.recall_summary, recall.recall_summary)
self.assertIs(sessions._RECALL_CACHE[response.session_id], recall)
async def test_next_session_turn_injects_seed_recall_into_engine_messages(self) -> None:
async def test_next_session_turn_injects_seed_recall_into_engine_messages(
self,
) -> None:
principal = _principal()
card = persona_service.P1
case_context = sessions.session_persistence.CaseContext(
@ -931,7 +1108,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def __aenter__(self) -> FakeConn:
return self.conn
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
async def __aexit__(
self, exc_type: object, exc: object, tb: object
) -> None:
return None
async def fake_create_session(**kwargs):
@ -951,19 +1130,28 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
coro.close()
return None
with patch.object(sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)), patch.object(
sessions.session_persistence,
"get_case_context",
AsyncMock(return_value=case_context),
), patch.object(sessions.db, "get_pool", return_value=object()), patch.object(
sessions.db,
"acquire",
return_value=FakeAcquire(FakeConn()),
), patch.object(
sessions.session_persistence,
"create_session",
fake_create_session,
), patch.object(sessions.asyncio, "create_task", close_background):
with (
patch.object(
sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)
),
patch.object(
sessions.session_persistence,
"get_case_context",
AsyncMock(return_value=case_context),
),
patch.object(sessions.db, "get_pool", return_value=object()),
patch.object(
sessions.db,
"acquire",
return_value=FakeAcquire(FakeConn()),
),
patch.object(
sessions.session_persistence,
"create_session",
fake_create_session,
),
patch.object(sessions.asyncio, "create_task", close_background),
):
response = await sessions.start_session(
sessions.SessionStartRequest(persona_code=card.code),
principal,
@ -985,10 +1173,15 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
state_after=ctx.state_after,
)
with patch.object(sessions, "_load_session_or_404", AsyncMock(return_value=started)), patch.object(
sessions.orchestrator,
"run_turn_generate",
successful_turn,
with (
patch.object(
sessions, "_load_session_or_404", AsyncMock(return_value=started)
),
patch.object(
sessions.orchestrator,
"run_turn_generate",
successful_turn,
),
):
await sessions.submit_turn(
response.session_id,
@ -1006,14 +1199,20 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("김서연", message_blob)
self.assertNotIn("박민수", message_blob)
async def test_start_session_requires_learner_consent_before_catalog_lookup(self) -> None:
async def test_start_session_requires_learner_consent_before_catalog_lookup(
self,
) -> None:
principal = _principal()
principal.consent_at = None
with patch.object(
sessions,
"get_catalog_persona",
AsyncMock(side_effect=AssertionError("consent gate must run before catalog lookup")),
AsyncMock(
side_effect=AssertionError(
"consent gate must run before catalog lookup"
)
),
) as get_persona:
with self.assertRaises(sessions.HTTPException) as caught:
await sessions.start_session(
@ -1025,14 +1224,20 @@ 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:
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")),
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(
@ -1121,13 +1326,17 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual([event.event for event in events], ["error"])
self.assertIn("engine unavailable", events[0].data["detail"])
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(
self,
) -> None:
principal = _principal()
sess = _session(principal)
async def failing_stream(*args, **kwargs):
yield orchestrator.StreamEvent("token", {"text": "부분 응답"})
yield orchestrator.StreamEvent("error", {"detail": "engine unavailable: stream"})
yield orchestrator.StreamEvent(
"error", {"detail": "engine unavailable: stream"}
)
with patch.object(sessions.orchestrator, "run_turn_stream", failing_stream):
response = await sessions.stream_turn(
@ -1147,7 +1356,6 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.client_state = voice_routes.WebSocketState.CONNECTED
async def send_text(self, data: str) -> None:
import json
self.messages.append(json.loads(data))
@ -1162,10 +1370,12 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
):
await voice_routes._run_turn_and_speak(
websocket, # type: ignore[arg-type]
session_id=sess.session_id,
principal=principal,
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
learner_text="음성 실패 발화",
voice_routes.VoiceSessionContext(
session_id=sess.session_id,
principal=principal,
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
),
voice_routes.VoiceTurnInput(learner_text="음성 실패 발화"),
)
self.assertTrue(
@ -1186,7 +1396,6 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.client_state = voice_routes.WebSocketState.CONNECTED
async def send_text(self, data: str) -> None:
import json
self.messages.append(json.loads(data))
@ -1217,37 +1426,53 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
websocket = FakeWebSocket()
audio = b"\x00\x80" * 1600
with patch.object(
voice_routes.voice_service,
"transcribe",
AsyncMock(
return_value=TranscriptResult(
text="오늘은 좀 힘들었어요.",
duration=2.0,
provider_events=[
{"kind": "voice_activity", "start_ms": 10, "raw_text": "drop"},
],
)
with (
patch.object(
voice_routes.voice_service,
"transcribe",
AsyncMock(
return_value=TranscriptResult(
text="오늘은 좀 힘들었어요.",
duration=2.0,
provider_events=[
{
"kind": "voice_activity",
"start_ms": 10,
"raw_text": "drop",
},
],
)
),
),
patch.object(
voice_routes.orchestrator,
"run_turn_generate",
successful_turn,
),
patch.object(
voice_routes.voice_service,
"synthesize_stream",
fake_synthesize_stream,
),
), patch.object(
voice_routes.orchestrator,
"run_turn_generate",
successful_turn,
), patch.object(
voice_routes.voice_service,
"synthesize_stream",
fake_synthesize_stream,
):
await voice_routes._handle_utterance(
websocket, # type: ignore[arg-type]
session_id=sess.session_id,
principal=principal,
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
audio=audio,
fmt="webm",
silence_ms=1234,
barge_in=True,
provider_events=[{"type": "sigh", "confidence": 0.82, "text": "drop"}],
voice_routes.VoiceSessionContext(
session_id=sess.session_id,
principal=principal,
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
),
voice_routes.VoiceAudioInput(
audio=audio,
fmt="webm",
prosody=voice_routes.VoiceProsody(
silence_ms=1234,
barge_in=True,
provider_events=[
{"type": "sigh", "confidence": 0.82, "text": "drop"}
],
),
),
)
self.assertEqual(len(sess.turns), 2)
@ -1276,7 +1501,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(client_turn.audio_ref)
self.assertEqual(client_turn.provider_events, [])
self.assertEqual(client_turn.llm_provider, "claude_cli")
self.assertTrue(any(message.get("type") == "tts_end" for message in websocket.messages))
self.assertTrue(
any(message.get("type") == "tts_end" for message in websocket.messages)
)
async def test_review_exposes_voice_nonverbal_events_on_learner_turn(self) -> None:
principal = _principal()
@ -1332,7 +1559,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
silence_ms=2500,
speech_rate=180.0,
barge_in=True,
provider_events=[{"event_type": "cry", "category": "paralinguistic"}],
provider_events=[
{"event_type": "cry", "category": "paralinguistic"}
],
),
]
)
@ -1347,7 +1576,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(learner_turn.nonverbal[0].label, "침묵")
self.assertEqual(learner_turn.nonverbal[0].detail, "1.2초")
self.assertEqual([event.kind for event in learner_turn.nonverbal].count("silence"), 1)
self.assertEqual(
[event.kind for event in learner_turn.nonverbal].count("silence"), 1
)
self.assertEqual(learner_turn.nonverbal[1].detail, "분당 420자")
self.assertEqual(learner_turn.nonverbal[4].label, "음성 단서")
self.assertEqual(learner_turn.nonverbal[4].detail, "한숨 감지 · 신뢰도 82%")
@ -1390,7 +1621,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertGreaterEqual(len(worksheet.sections), 5)
exploration = worksheet.sections[0]
self.assertEqual(exploration.key, "exploration_11")
complaint = next(item for item in exploration.items if item.key == "presenting_complaint")
complaint = next(
item for item in exploration.items if item.key == "presenting_complaint"
)
self.assertEqual(complaint.confidence, "medium")
self.assertEqual(complaint.evidence[0].turnId, "t2")
self.assertIn("불안", complaint.value or "")
@ -1509,14 +1742,18 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(review_status, True)),
),
):
response = await sessions.get_session_review(sess.session_id, teacher_principal)
response = await sessions.get_session_review(
sess.session_id, teacher_principal
)
self.assertIsNotNone(response.teacherReview)
assert response.teacherReview is not None
self.assertEqual(response.caseWorksheet.status, "saved_by_learner")
self.assertEqual(response.teacherReview.worksheetStatus, "changes_requested")
self.assertIn("주호소", response.teacherReview.worksheetNote)
self.assertEqual(response.teacherReview.worksheetReviewedAt, "2026-06-27T10:05:00Z")
self.assertEqual(
response.teacherReview.worksheetReviewedAt, "2026-06-27T10:05:00Z"
)
async def test_review_remasks_legacy_session_evaluation_payload(self) -> None:
class FakeKoRecognizer:
@ -1528,7 +1765,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
):
start = text.find(value)
if start >= 0:
spans.append(guardrail.PiiEntitySpan(entity_type, start, start + len(value)))
spans.append(
guardrail.PiiEntitySpan(
entity_type, start, start + len(value)
)
)
return spans
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
@ -1668,7 +1909,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertFalse(response.reviewReady)
self.assertTrue(response.degraded)
self.assertEqual(response.supervisorState, "평가 실패")
self.assertIn("deep-loop 평가 AI 산출물을 표시하지 못했습니다", response.summary)
self.assertIn(
"deep-loop 평가 AI 산출물을 표시하지 못했습니다", response.summary
)
self.assertIn("AI 평가 재시도가 필요합니다", response.summary)
self.assertNotIn("session evaluation timeout after 45s", response.summary)
self.assertEqual(response.rubric, [])
@ -1743,15 +1986,18 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
limitations=["임상 루브릭 전"],
)
with patch.object(
sessions,
"_load_session_or_404",
AsyncMock(return_value=sess),
) as load_session, patch.object(
sessions.session_persistence,
"save_case_worksheet",
AsyncMock(return_value=True),
) as save_worksheet:
with (
patch.object(
sessions,
"_load_session_or_404",
AsyncMock(return_value=sess),
) as load_session,
patch.object(
sessions.session_persistence,
"save_case_worksheet",
AsyncMock(return_value=True),
) as save_worksheet,
):
response = await sessions.save_session_review_worksheet(
sess.session_id,
request,
@ -1769,7 +2015,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(save_kwargs["session_id"], sess.session_id)
self.assertEqual(save_kwargs["learner_id"], principal.user_id)
self.assertEqual(save_kwargs["payload"]["status"], "saved_by_learner")
self.assertEqual(save_kwargs["payload"]["sections"][0]["items"][0]["value"], "수정한 주호소")
self.assertEqual(
save_kwargs["payload"]["sections"][0]["items"][0]["value"], "수정한 주호소"
)
self.assertEqual(response.status, "saved_by_learner")
self.assertEqual(response.sections[0].items[0].value, "수정한 주호소")