"""Regression tests for session turn persistence ordering.""" from __future__ import annotations import asyncio import json import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch from fastapi import HTTPException 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, GenerateResponse from .routes import eval as eval_routes 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.voice import TTSChunk, TranscriptResult, VoicePreset from .store import InProcSession, TurnRecord, store async def _decoded_stream_packets(stream_engine, req): decoder = EngineGatewaySseLineDecoder() async for raw in stream_engine.stream(req): packet = decoder.feed_line(raw) if packet is not None: yield packet class _AsyncConnContext: def __init__(self, conn): self.conn = conn async def __aenter__(self): return self.conn async def __aexit__(self, exc_type, exc, tb): return False def _principal() -> Principal: return Principal( user_id="00000000-0000-0000-0000-000000000101", role=Role.LEARNER, cohort_ids=[], email="turn-test@hs.ac.kr", display_name="Turn Test", consent_at=1.0, profile_completed_at=1.0, ) def _session(principal: Principal) -> InProcSession: card = persona_service.P1 sess = InProcSession( session_id="turn-persistence-session", case_id="turn-persistence-case", learner_id=principal.user_id, persona_code=card.code, theory_mode="humanistic", persona=card, state=state_machine.SessionState( resistance=card.base_resistance(), ideation_stage=card.ideation_baseline(), ), ) store.put(sess) return sess async def _consume_event_source(response: object) -> bytes: body = bytearray() iterator = getattr(response, "body_iterator") async for chunk in iterator: if isinstance(chunk, str): body.extend(chunk.encode("utf-8")) elif isinstance(chunk, (bytes, bytearray)): body.extend(chunk) else: body.extend(str(chunk).encode("utf-8")) return bytes(body) class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: 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: 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, ): response = await sessions.end_session(sess.session_id, principal) self.assertEqual(response.session_id, sess.session_id) schedule_session_evaluation.assert_not_called() async def test_failed_end_evaluation_keeps_old_review_and_next_session_writable( self, ) -> None: principal = _principal() card = persona_service.P1 case_id = "00000000-0000-0000-0000-00000000ca5e" old_session = _session(principal) old_session.case_id = case_id old_session.session_no = 1 old_session.turns.extend( [ TurnRecord( turn_seq=1, speaker="learner", stage=old_session.state.stage.value, text="지금 가장 버거운 마음이 어떤 건가요?", text_masked="지금 가장 버거운 마음이 어떤 건가요?", ), TurnRecord( turn_seq=2, speaker="client", stage=old_session.state.stage.value, text="아무것도 하고 싶지 않아요.", text_masked="아무것도 하고 싶지 않아요.", ), ] ) durable_sessions = {old_session.session_id: old_session} end_order: list[str] = [] async def persist_end(sess: InProcSession, _carry: memory.CarryOver) -> None: end_order.append("persisted") sess.ended = True sess.ended_at = sess.created_at + 120 durable_sessions[sess.session_id] = sess def schedule_evaluation(sess: InProcSession) -> None: self.assertTrue(durable_sessions[sess.session_id].ended) end_order.append("evaluation_scheduled") with ( patch.object( sessions, "_load_session_or_404", AsyncMock(return_value=old_session), ), patch.object(sessions, "_end_persisted_session", persist_end), patch.object( sessions.rupture_runtime, "schedule_session_scan", ), patch.object( sessions, "_schedule_session_evaluation", schedule_evaluation, ), ): ended = await sessions.end_session(old_session.session_id, principal) self.assertEqual(ended.session_id, old_session.session_id) self.assertEqual(ended.session_no, 1) self.assertEqual(end_order, ["persisted", "evaluation_scheduled"]) self.assertTrue(durable_sessions[old_session.session_id].ended) self.assertIsNotNone(durable_sessions[old_session.session_id].ended_at) durable_evaluations: dict[str, dict[str, object]] = {} async def save_evaluation( write: session_persistence.SessionEvaluationWrite, ) -> bool: durable_evaluations[write.session_id] = write.cache_record() return True with ( patch.object( sessions.evaluator, "evaluate_session", AsyncMock(side_effect=asyncio.TimeoutError), ), patch.object( sessions.session_persistence, "save_session_evaluation", save_evaluation, ), patch.object( sessions, "_enqueue_session_review_ready_notification", AsyncMock(return_value=None), ), ): await sessions._generate_and_save_session_evaluation(old_session) old_evaluation = durable_evaluations[old_session.session_id] self.assertEqual(old_evaluation["status"], "error") self.assertIn("session evaluation timeout", str(old_evaluation["error"])) self.assertTrue(durable_sessions[old_session.session_id].ended) catalog_persona = SimpleNamespace( card=card, persona_id="00000000-0000-0000-0000-0000000000a1", version=3, degraded=False, ) case_context = sessions.session_persistence.CaseContext( case_id=case_id, last_session_no=1, ) next_session_id = "00000000-0000-0000-0000-000000000702" async def create_next_session(**kwargs: object) -> InProcSession: self.assertEqual(kwargs["case_id"], case_id) self.assertEqual(kwargs["session_no"], 2) created = InProcSession( session_id=next_session_id, case_id=case_id, learner_id=principal.user_id, persona_code=card.code, theory_mode=str(kwargs["theory_mode"]), persona=card, state=kwargs["state"], session_no=int(kwargs["session_no"]), prev_rapport_credit=float(kwargs["carry_rapport"]), ) durable_sessions[created.session_id] = created return created def close_background(coro: object) -> None: close = getattr(coro, "close", None) if callable(close): close() 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=memory.RecallContext()), ), patch.object( sessions.session_persistence, "create_session", create_next_session, ), patch.object(sessions.asyncio, "create_task", close_background), ): started = await sessions.start_session( sessions.SessionStartRequest(persona_code=card.code), principal, ) self.assertNotEqual(started.session_id, old_session.session_id) self.assertEqual(started.session_id, next_session_id) self.assertEqual(started.case_id, case_id) self.assertEqual(started.session_no, 2) next_session = durable_sessions[next_session_id] persisted_turns: list[TurnRecord] = [] async def persist_turn(**kwargs: object) -> bool: turn = kwargs["turn"] assert isinstance(turn, TurnRecord) persisted_turns.append(turn) return True async def successful_turn( ctx: orchestrator.TurnContext, _engine: object, **_kwargs: object, ) -> orchestrator.TurnResult: assert ctx.state_after is not None return orchestrator.TurnResult( turn_seq=ctx.state_after.turn_seq, stage=ctx.state_after.stage.value, effective_openness=ctx.state_after.effective_openness, client_reply="조금 더 이야기해볼게요.", safety_flagged=False, state_after=ctx.state_after, ) with ( patch.object( sessions, "_load_session_or_404", AsyncMock(return_value=next_session), ), patch.object( sessions.orchestrator, "run_turn_generate", successful_turn, ), patch.object( sessions.session_persistence, "append_turn", persist_turn, ), patch.object( sessions.session_persistence, "update_state", AsyncMock(return_value=True), ), patch.object( sessions.rupture_runtime, "schedule_session_scan", ), ): first_turn = await sessions.submit_turn( next_session_id, sessions.TurnRequest(text="지난 이야기부터 이어가도 괜찮을까요?"), principal, ) self.assertEqual(first_turn.client_reply, "조금 더 이야기해볼게요.") self.assertEqual([turn.speaker for turn in persisted_turns], ["counselor", "client"]) self.assertEqual([turn.speaker for turn in next_session.turns], ["counselor", "client"]) with ( patch.object( sessions, "_load_session_or_404", AsyncMock(return_value=old_session), ), patch.object( sessions.session_persistence, "load_session_evaluation", AsyncMock(return_value=(old_evaluation, True)), ), patch.object( sessions.session_persistence, "load_case_worksheet", AsyncMock(return_value=(None, True)), ), ): old_review = await sessions.get_session_review( old_session.session_id, principal, ) self.assertFalse(old_review.reviewReady) self.assertTrue(old_review.degraded) self.assertEqual(old_review.supervisorState, "평가 실패") self.assertIn("AI 평가 재시도가 필요합니다", old_review.summary) teacher = Principal( user_id="00000000-0000-0000-0000-000000000202", role=Role.TEACHER, cohort_ids=[], email="teacher@hs.ac.kr", display_name="Teacher", ) retry_save = AsyncMock(return_value=True) with ( patch.object( eval_routes, "_load_session_or_404", AsyncMock(return_value=old_session), ), patch.object( eval_routes.evaluator, "evaluate_session", AsyncMock(side_effect=EngineError("controlled evaluator 500")), ), patch.object( eval_routes.session_persistence, "save_session_evaluation", retry_save, ), ): with self.assertRaises(HTTPException) as retry_error: await eval_routes.reevaluate_session( old_session.session_id, eval_routes.ReevaluateRequest(scope="session_end"), teacher, ) self.assertEqual(retry_error.exception.status_code, 503) retry_save.assert_awaited_once() retry_write = retry_save.await_args.args[0] self.assertEqual(retry_write.status, "error") async def test_append_turn_writes_provider_events_to_db(self) -> None: class FakeConn: def __init__(self) -> None: self.insert_query = "" self.insert_args: tuple[object, ...] = () async def fetchval(self, query: str, *args: object) -> object: if "SELECT id FROM app.sessions" in query: return "turn-persistence-session" if "COALESCE(MAX(seq)" in query: return 1 if "INSERT INTO app.turns" in query: self.insert_query = query self.insert_args = args return "00000000-0000-0000-0000-000000009999" return None class FakeAcquire: def __init__(self, conn: FakeConn) -> None: self.conn = conn async def __aenter__(self) -> FakeConn: return self.conn async def __aexit__(self, exc_type, exc, tb) -> None: return None conn = FakeConn() turn = TurnRecord( turn_seq=1, speaker="counselor", stage="rapport", text="voice text", text_masked="voice text", audio_ref="voice:webm:sha256:test", silence_ms=1234, speech_rate=210.0, barge_in=True, 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), ), ): ok = await session_persistence.append_turn( session_id="turn-persistence-session", learner_id=_principal().user_id, turn=turn, ) self.assertTrue(ok) self.assertIn("provider_events", conn.insert_query) self.assertIn("$17::jsonb", conn.insert_query) self.assertEqual(conn.insert_args[16], [{"type": "sigh", "confidence": 0.82}]) self.assertEqual(conn.insert_args[17], list(turn.visible_to)) self.assertEqual(turn.turn_id, "00000000-0000-0000-0000-000000009999") async def test_create_session_binds_goal_stages_as_jsonb_array(self) -> None: class FakeTransaction: async def __aenter__(self) -> None: return None async def __aexit__(self, exc_type, exc, tb) -> None: return None class FakeConn: def __init__(self) -> None: self.session_insert_args: tuple[object, ...] = () def transaction(self) -> FakeTransaction: return FakeTransaction() async def fetchrow(self, query: str, *args: object) -> dict[str, object]: if "UPDATE app.case_profile" in query: return {"last_session_no": 2} if "INSERT INTO app.sessions" in query: self.session_insert_args = args return { "id": "00000000-0000-0000-0000-000000000301", "runtime_case_id": args[0], "case_id": args[1], "learner_id": args[2], "persona_code": args[5], "session_no": args[8], "theory_mode": args[9], "started_at": session_persistence.datetime.fromtimestamp( 1_000.0, tz=session_persistence.timezone.utc ), "ended_at": None, "prev_rapport_credit": args[10], } raise AssertionError(f"unexpected query: {query}") async def execute(self, query: str, *args: object) -> str: self.assert_jsonb_object(query, args) return "INSERT 0 1" @staticmethod def assert_jsonb_object(query: str, args: tuple[object, ...]) -> None: if "INSERT INTO app.session_state" in query: assert isinstance(args[8], dict) conn = FakeConn() goals = ["라포", "탐색"] with ( patch.object(session_persistence, "get_pool", return_value=object()), patch.object( session_persistence, "acquire", return_value=_AsyncConnContext(conn), ), ): created = await session_persistence.create_session( learner_id=_principal().user_id, card=persona_service.P1, theory_mode="humanistic", state=state_machine.SessionState(), session_no=2, carry_rapport=0.25, persona_id="00000000-0000-0000-0000-000000000201", persona_version=1, case_id="00000000-0000-0000-0000-000000000202", goal_stages=goals, learner_feedback_enabled=False, ) self.assertIsNotNone(created) self.assertIsInstance(conn.session_insert_args[11], list) self.assertEqual(conn.session_insert_args[11], goals) self.assertNotIsInstance(conn.session_insert_args[11], str) self.assertFalse(conn.session_insert_args[12]) assert created is not None self.assertFalse(created.learner_feedback_enabled) async def test_generate_turn_engine_failure_does_not_append_learner_turn( self, ) -> None: principal = _principal() sess = _session(principal) with patch.object( sessions.orchestrator, "run_turn_generate", AsyncMock(side_effect=EngineError("engine unavailable: test")), ): with self.assertRaises(sessions.HTTPException) as caught: await sessions.submit_turn( sess.session_id, sessions.TurnRequest(text="실패한 발화"), principal, ) self.assertEqual(caught.exception.status_code, 503) self.assertEqual(sess.turns, []) async def test_live_coach_turn_marks_runtime_persistence_source(self) -> None: principal = _principal() sess = _session(principal) sess.turns.extend( [ TurnRecord( turn_seq=1, speaker="counselor", stage="explore", text="요즘 가장 크게 남는 마음은 무엇인가요?", text_masked="요즘 가장 크게 남는 마음은 무엇인가요?", ), TurnRecord( turn_seq=2, speaker="client", stage="explore", text="잘 모르겠지만 계속 답답해요.", text_masked="잘 모르겠지만 계속 답답해요.", ), ] ) suggestion = live_coach.LiveCoachSuggestion( status="ready", tone="pos", focus="emotion", title="감정 반영이 선명합니다", message="방금 응답의 정서를 먼저 붙잡았습니다.", ) 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( sessions.session_persistence, "get_live_coach_quota", AsyncMock( side_effect=[ ({"remaining": 3, "max": 3}, True), ({"remaining": 2, "max": 3}, True), ] ), ), patch.object( sessions.session_persistence, "save_live_coach_event", AsyncMock(return_value=({"event_id": "cached-live-coach"}, False)), ), patch.object( sessions.session_persistence, "list_live_coach_credit_events", AsyncMock(return_value=([], True)), ), ): response = await sessions.live_coach_turn( sess.session_id, sessions.LiveCoachRequest( learner_text="요즘 가장 크게 남는 마음은 무엇인가요?", client_reply="잘 모르겠지만 계속 답답해요.", turn_seq=1, ), principal, ) self.assertEqual(response.persistence_source, "runtime") self.assertEqual(response.quota.remaining, 2) async def test_generate_turn_persists_client_engine_telemetry(self) -> None: principal = _principal() sess = _session(principal) sessions._RECALL_CACHE[sess.session_id] = memory.RecallContext( recall_summary="직전 회기에서 김서연은 가족 이야기를 열어두었다.", pinned_facts=["박민수와 주 1회 상담 약속"], ) async def successful_turn(ctx, engine, **kwargs): assert ctx.state_after is not None self.assertIn("[NAME]", ctx.memory.recall_summary or "") self.assertNotIn("김서연", ctx.memory.recall_summary or "") self.assertEqual(ctx.memory.pinned_facts, ["[NAME]와 주 1회 상담 약속"]) return orchestrator.TurnResult( turn_seq=ctx.state_after.turn_seq, stage=ctx.state_after.stage.value, effective_openness=ctx.state_after.effective_openness, client_reply=( "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요. " "여기서 뭘 해야 하는지 잘 모르겠는데요." ), safety_flagged=False, state_after=ctx.state_after, llm_provider="claude_cli", model="gateway-default", tokens_in=17, tokens_out=23, cost_usd=0.012345, ) with patch.object(sessions.orchestrator, "run_turn_generate", successful_turn): response = await sessions.submit_turn( sess.session_id, sessions.TurnRequest(text="요즘 많이 힘들었겠어요."), principal, ) 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.assertNotIn("김서연", client_turn.text_masked) self.assertNotIn("한신대학교", client_turn.text_masked) self.assertNotIn("상담심리학과", client_turn.text_masked) self.assertIn("[NAME]", client_turn.text_masked) self.assertIn("[ORG]", client_turn.text_masked) self.assertIn("잘 모르겠는데요", client_turn.text_masked) self.assertNotIn("[NAME]는데요", client_turn.text_masked) self.assertEqual(client_turn.llm_provider, "claude_cli") self.assertEqual(client_turn.model, "gateway-default") self.assertEqual(client_turn.tokens_in, 17) 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: principal = _principal() sess = _session(principal) async def should_not_generate(*args, **kwargs): raise AssertionError("engine must not be called for learner_real crisis") with patch.object(sessions.engine_client, "generate", should_not_generate): response = await sessions.submit_turn( sess.session_id, sessions.TurnRequest(text="저 지금 죽고싶어요. 도와주세요."), principal, ) self.assertTrue(response.safety_flagged) self.assertTrue(response.conversation_stopped) self.assertEqual(response.crisis_kind, "learner_real") self.assertIsNotNone(response.crisis_resource) self.assertEqual(response.crisis_resource.number, "109") self.assertIsNone(response.client_reply) self.assertEqual(len(sess.turns), 1) self.assertEqual(sess.turns[0].speaker, "counselor") async def test_record_safety_event_writes_teacher_alert_payload(self) -> None: principal = _principal() sess = _session(principal) ctx = orchestrator.prepare_turn( session_id=sess.session_id, case_id=sess.case_id, card=sess.persona, state=sess.state, learner_text="저 지금 자살하고 싶어요. 도와주세요.", theory_mode=sess.theory_mode, ) 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, client_reply=None, safety_flagged=True, state_after=ctx.state_after or sess.state, crisis_kind="learner_real", crisis_resource={"title": "자살예방상담전화 109", "number": "109"}, conversation_stopped=True, ) calls: list[tuple[str, tuple[object, ...]]] = [] class FakeConn: async def execute(self, query: str, *args: object) -> str: calls.append((query, args)) return "INSERT 0 1" class FakeAcquire: async def __aenter__(self) -> FakeConn: return FakeConn() 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: await turn_runtime.record_safety_event(sess, ctx, result) acquire.assert_called_once_with(ai_context=True) self.assertEqual(len(calls), 1) query, args = calls[0] self.assertIn("INSERT INTO app.safety_events", query) self.assertEqual(args[0], sess.session_id) self.assertEqual(args[1], "learner_real") self.assertGreaterEqual(args[2], 4) detail = args[3] self.assertTrue(detail["conversation_stopped"]) self.assertEqual(detail["crisis_resource"]["number"], "109") self.assertEqual(detail["alert_status"], "teacher_dashboard") def test_safety_alert_detail_parser_handles_legacy_json_string(self) -> None: detail = session_persistence._json_object_payload( '"{\\"crisis_resource\\":{\\"number\\":\\"109\\"},\\"alert_status\\":\\"teacher_dashboard\\"}"' ) 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: principal = _principal() sess = _session(principal) ctx = orchestrator.prepare_turn( session_id=sess.session_id, case_id=sess.case_id, card=sess.persona, state=sess.state, learner_text="저 지금 자살하고 싶어요. 도와주세요.", theory_mode=sess.theory_mode, ) 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, client_reply=None, safety_flagged=True, state_after=ctx.state_after or sess.state, crisis_kind="learner_real", crisis_resource={"title": "자살예방상담전화 109", "number": "109"}, conversation_stopped=True, ) class FakeConn: async def execute(self, query: str, *args: object) -> str: raise RuntimeError("safety_events unavailable") class FakeAcquire: async def __aenter__(self) -> FakeConn: return FakeConn() async def __aexit__( self, exc_type: object, exc: object, tb: object ) -> None: return None previous_environment = settings.environment settings.environment = "staging" try: with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()): with self.assertRaises(HTTPException) as raised: await turn_runtime.record_safety_event(sess, ctx, result) finally: settings.environment = previous_environment self.assertEqual(raised.exception.status_code, 503) self.assertIn("safety event persistence unavailable", raised.exception.detail) async def test_stream_turn_persists_client_engine_telemetry(self) -> None: principal = _principal() sess = _session(principal) async def successful_stream(ctx, engine, **kwargs): assert ctx.state_after is not None yield orchestrator.StreamEvent("token", {"text": "괜찮아요."}) yield orchestrator.StreamEvent( "done", { "session_id": ctx.session_id, "stage": ctx.state_after.stage.value, "effective_openness": ctx.state_after.effective_openness, "turn_seq": ctx.state_after.turn_seq, "safety_flagged": False, "llm_provider": "claude_cli", "model": "gateway-default", "tokens_in": 31, "tokens_out": 37, "cost_usd": 0.023456, }, ) with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream): response = await sessions.stream_turn( sess.session_id, sessions.TurnRequest(text="스트림 성공 발화"), principal, ) body = await _consume_event_source(response) self.assertIn(b"done", body) self.assertEqual(len(sess.turns), 2) client_turn = sess.turns[1] self.assertEqual(client_turn.llm_provider, "claude_cli") self.assertEqual(client_turn.model, "gateway-default") self.assertEqual(client_turn.tokens_in, 31) 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: principal = _principal() sess = _session(principal) def should_not_stream(*args, **kwargs): 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, ): response = await sessions.stream_turn( sess.session_id, sessions.TurnRequest(text="저 지금 자살하고 싶어요. 도와주세요."), principal, ) body = await _consume_event_source(response) rendered = body.decode("utf-8") self.assertIn("'event': 'safety'", rendered) self.assertIn("'event': 'done'", rendered) self.assertIn("109", rendered) self.assertIn("conversation_stopped", rendered) completed_turn.assert_awaited_once() safety_event.assert_awaited_once() saved_result = completed_turn.await_args.args[2] self.assertTrue(saved_result.safety_flagged) 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: principal = _principal() sess = _session(principal) async def successful_stream(ctx, engine, **kwargs): assert ctx.state_after is not None yield orchestrator.StreamEvent("token", {"text": "조금 말해볼게요."}) yield orchestrator.StreamEvent( "done", { "session_id": ctx.session_id, "stage": ctx.state_after.stage.value, "effective_openness": ctx.state_after.effective_openness, "turn_seq": ctx.state_after.turn_seq, "safety_flagged": False, "llm_provider": "claude_cli", "model": "gateway-default", "tokens_in": 9, "tokens_out": 10, "cost_usd": 0.001, }, ) async def fake_eval_hook(ctx, client_reply): return { "loop": "fast", "turn_seq": ctx.state_after.turn_seq, "stage": ctx.state_after.stage.value, "appropriateness": "pos", "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, ), ): response = await sessions.stream_turn( sess.session_id, sessions.TurnRequest(text="스트림 평가 발화"), principal, ) await _consume_event_source(response) if sessions._STREAM_TURN_EVALUATION_TASKS: await asyncio.gather(*tuple(sessions._STREAM_TURN_EVALUATION_TASKS)) self.assertEqual(len(sess.turns), 2) learner_turn, client_turn = sess.turns 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.assertIsNone(client_turn.evaluation) async def test_stream_turn_done_does_not_wait_for_fast_loop_evaluation( self, ) -> None: principal = _principal() sess = _session(principal) evaluation_started = asyncio.Event() release_evaluation = asyncio.Event() async def successful_stream(ctx, engine, **kwargs): assert ctx.state_after is not None yield orchestrator.StreamEvent("token", {"text": "지금 답할게요."}) yield orchestrator.StreamEvent( "done", { "session_id": ctx.session_id, "turn_seq": ctx.state_after.turn_seq, "safety_flagged": False, "llm_provider": "claude_cli", "model": "gateway-default", }, ) async def slow_eval_hook(ctx, client_reply): evaluation_started.set() await release_evaluation.wait() return { "loop": "fast", "turn_seq": ctx.state_after.turn_seq, "stage": ctx.state_after.stage.value, "appropriateness": "pos", } with ( patch.object(sessions.orchestrator, "run_turn_stream", successful_stream), patch.object( sessions.evaluator, "make_eval_hook", return_value=slow_eval_hook, ), ): response = await sessions.stream_turn( sess.session_id, sessions.TurnRequest(text="평가를 기다리지 않는 발화"), principal, ) consume_task = asyncio.create_task(_consume_event_source(response)) await asyncio.wait_for(evaluation_started.wait(), timeout=1) body = await asyncio.wait_for(consume_task, timeout=0.2) self.assertIn("'event': 'done'", body.decode("utf-8")) self.assertEqual(len(sess.turns), 2) self.assertIsNone(sess.turns[0].evaluation) release_evaluation.set() if sessions._STREAM_TURN_EVALUATION_TASKS: await asyncio.gather(*tuple(sessions._STREAM_TURN_EVALUATION_TASKS)) self.assertEqual(sess.turns[0].evaluation["appropriateness"], "pos") async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review( self, ) -> None: principal = _principal() sess = _session(principal) async def successful_stream(ctx, engine, **kwargs): assert ctx.state_after is not None yield orchestrator.StreamEvent("token", {"text": "조금 말해볼게요."}) yield orchestrator.StreamEvent( "done", { "session_id": ctx.session_id, "stage": ctx.state_after.stage.value, "effective_openness": ctx.state_after.effective_openness, "turn_seq": ctx.state_after.turn_seq, "safety_flagged": False, "llm_provider": "claude_cli", "model": "gateway-default", "tokens_in": 9, "tokens_out": 10, "cost_usd": 0.001, }, ) 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, ), ): response = await sessions.stream_turn( sess.session_id, sessions.TurnRequest(text="스트림 평가 실패 발화"), principal, ) await _consume_event_source(response) if sessions._STREAM_TURN_EVALUATION_TASKS: await asyncio.gather(*tuple(sessions._STREAM_TURN_EVALUATION_TASKS)) self.assertEqual(len(sess.turns), 2) learner_turn = sess.turns[0] self.assertIsNotNone(learner_turn.evaluation) assert learner_turn.evaluation is not None self.assertEqual(learner_turn.evaluation["loop"], "fast") self.assertEqual(learner_turn.evaluation["appropriateness"], "neutral") self.assertEqual(learner_turn.evaluation["error"], "RuntimeError") self.assertNotIn("김서연", learner_turn.evaluation["error"]) self.assertNotIn("010-1234-5678", learner_turn.evaluation["error"]) review = sessions.build_session_review( sessions.SessionReviewReadInput( session=sess, evaluation_record=None, evaluation_durable=False, ) ) review_turn = review.turns[0] self.assertEqual(review_turn.id, "t1") self.assertEqual(review_turn.turn_id, learner_turn.turn_id) 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("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: 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, "official_counseling_guideline_seed" ) self.assertNotIn( "workbook_0615_case_conceptualization", {source.source_id for source in response.sources}, ) 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].stage, "라포") 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 ) self.assertIsNone(legacy_history.events[0].stage) 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( chunk_id=17, score=0.91, kb_kind="supervisor_pattern", heading_path="risk/protective-factors", context_prefix="공식 자살위험 평가 지침 / 보호요인 / 2026-06-15", body="최근성, 보호요인, 안전계획을 확인한다.", meta={ "source_title": "공식 자살위험 평가 지침", "source_type": "official_guideline_summary", "source_version": "2026-06-15", "citation": "허가된 공식 지침 요약", }, source_id="official_suicide_risk_guidelines", license_class="B", external_llm_ok=True, ) ], policy_name="evaluator:k=supervisor_pattern:s<=2", top1_score=0.91, latency_ms=12, query_text="정리 humanistic 자살 위험 최근성 보호요인", ) retrieve_grounding = AsyncMock(return_value=retrieval) with ( patch.object( sessions.db, "acquire", return_value=_AsyncConnContext(object()) ), patch.object( sessions.rag, "retrieve_eval_grounding", retrieve_grounding, ), patch.object(sessions.rag, "log_retrieval", AsyncMock()), ): grounding = await sessions._retrieve_live_coach_grounding( learner_text="자살 위험 최근성과 보호요인을 확인했어요.", client_reply=( "박민수에게 연락할지 잘 모르겠는데요. 그래도 오늘은 " "친구에게 연락할 수 있어요." ), stage="정리", theory_mode="humanistic", ) self.assertEqual(len(grounding), 1) source = grounding[0] self.assertEqual(source.source_id, "official_suicide_risk_guidelines") self.assertEqual(source.title, "공식 자살위험 평가 지침") self.assertEqual(source.source_type, "official_guideline_summary") self.assertEqual(source.version, "2026-06-15") self.assertEqual(source.citation, "허가된 공식 지침 요약") self.assertEqual(source.license_class, "B") self.assertTrue(source.external_llm_ok) query = retrieve_grounding.await_args.kwargs["query"] self.assertNotIn("박민수", query) self.assertIn("[NAME]에게", query) self.assertIn("잘 모르겠는데요", query) self.assertNotIn("[NAME]는데요", query) async def test_live_coach_persistence_failure_is_not_swallowed(self) -> None: principal = _principal() sess = _session(principal) suggestion = live_coach.LiveCoachSuggestion( status="ready", tone="pos", focus="emotion", title="감정 반영이 선명합니다", message="학습자가 내담자의 감정을 먼저 되짚었습니다.", ) save_error = sessions.HTTPException( status_code=sessions.status.HTTP_503_SERVICE_UNAVAILABLE, 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 self.assertRaises(sessions.HTTPException) as caught: await sessions.live_coach_turn( sess.session_id, sessions.LiveCoachRequest( learner_text="그 마음이 컸겠네요.", client_reply="네, 아무도 몰라주는 것 같았어요.", turn_seq=1, ), principal, ) self.assertEqual(caught.exception.status_code, 503) self.assertIn("live coach event save", str(caught.exception.detail)) save_event.assert_awaited_once() async def test_live_coach_event_normalizes_legacy_stage_values(self) -> None: suggestion = live_coach.LiveCoachSuggestion( status="degraded", tone="neutral", focus="exploration", title="코칭", message="다음 발화를 준비하세요.", ) coded = live_coach.LiveCoachEvent( event_id="event-1", session_id="session-1", turn_seq=1, stage="rapport", created_at="2026-06-28T00:00:00Z", suggestion=suggestion, ) invalid = live_coach.LiveCoachEvent( event_id="event-2", session_id="session-1", turn_seq=2, stage="unknown-stage", created_at="2026-06-28T00:00:00Z", suggestion=suggestion, ) self.assertEqual(coded.stage, "라포") self.assertIsNone(invalid.stage) 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_live_coach_prompt_uses_client_role_token_not_persona_name( self, ) -> None: item = live_coach.LiveCoachInput( session_id="masked-coach-session", turn_seq=1, stage="rapport", effective_openness=0.25, theory_mode="humanistic", persona_code="P1", persona_name="서연", learner_text="오늘 어떤 마음으로 오셨어요?", client_reply="조금 긴장돼요.", prior_coach=[{"title": "서연에게 감정을 반영하세요", "focus": "emotion"}], ) prompt = "\n".join( message.content for message in live_coach._messages(item, grounding=[]) ) self.assertIn("[내담자] [CLIENT] (P1)", prompt) self.assertIn("[CLIENT]에게 감정을 반영하세요", prompt) self.assertNotIn("서연", prompt) async def test_live_coach_structured_output_masks_client_name_before_storage( self, ) -> None: principal = _principal() sess = _session(principal) engine_response = GenerateResponse( text="", model="fake-live-coach-model", provider="fake-provider", tokens_in=12, tokens_out=9, cost_usd=0.01, structured={ "tone": "pos", "focus": "emotion", "title": "서연의 감정을 반영하세요", "message": "서연이 말한 긴장을 한 번 더 따라가세요.", "next_utterance": "서연님, 그 긴장이 언제 가장 커지나요?", "rationale": "서연의 표현을 그대로 짚으면 초점이 선명해집니다.", "safety_note": "서연의 안전 신호도 확인하세요.", }, ) with ( patch.object( sessions, "_retrieve_live_coach_grounding", AsyncMock(return_value=[]), ), patch.object( sessions.engine_client, "generate", AsyncMock(return_value=engine_response), ), patch.object( session_persistence, "record_llm_call_audit", AsyncMock(return_value=True), ), ): response = await sessions.live_coach_turn( sess.session_id, sessions.LiveCoachRequest( learner_text="그 마음이 컸겠네요.", client_reply="네, 조금 긴장돼요.", turn_seq=1, ), principal, ) rendered = " ".join( value for value in ( response.title, response.message, response.next_utterance, response.rationale, response.safety_note, ) if value ) self.assertNotIn("서연", rendered) self.assertIn("[CLIENT]", rendered) history = await sessions.list_live_coach_history(sess.session_id, principal) persisted = history.events[0].suggestion.model_dump_json() self.assertNotIn("서연", persisted) self.assertIn("[CLIENT]", persisted) async def test_start_session_uses_stable_case_context_and_seed_recall(self) -> None: principal = _principal() card = persona_service.P1 case_context = sessions.session_persistence.CaseContext( case_id="00000000-0000-0000-0000-00000000ca5e", last_session_no=1, ) catalog_persona = SimpleNamespace( card=card, persona_id="00000000-0000-0000-0000-0000000000a1", version=3, degraded=False, ) recall = memory.RecallContext( recall_summary="지난 회기에서 가족 이야기를 열어두었다.", carry={ "rapport_credit": 0.6, "resistance": card.base_resistance(), "ideation_stage": card.ideation_baseline(), }, ) async def fake_create_session(**kwargs): self.assertEqual(kwargs["case_id"], case_context.case_id) self.assertEqual(kwargs["session_no"], 2) self.assertGreater(kwargs["state"].rapport_credit, 0) return InProcSession( session_id="stable-case-session", case_id=kwargs["case_id"], learner_id=principal.user_id, persona_code=card.code, theory_mode=kwargs["theory_mode"], persona=card, state=kwargs["state"], session_no=kwargs["session_no"], prev_rapport_credit=kwargs["carry_rapport"], ) def close_background(coro): 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), ): response = await sessions.start_session( sessions.SessionStartRequest(persona_code=card.code), principal, ) self.assertEqual(response.case_id, case_context.case_id) self.assertEqual(response.session_no, 2) 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: principal = _principal() card = persona_service.P1 case_context = sessions.session_persistence.CaseContext( case_id="00000000-0000-0000-0000-00000000ca5e", last_session_no=1, ) catalog_persona = SimpleNamespace( card=card, persona_id="00000000-0000-0000-0000-0000000000a1", version=3, degraded=False, ) test_case = self class FakeConn: async def fetchrow(self, query: str, *args: object): if "FROM app.case_profile" in query: return {"case_digest": "S1: 김서연은 가족 이야기를 열어두었다."} if "FROM app.session_summary" in query: return { "digest": "직전 회기에서 김서연은 침묵 이후 학교 이야기를 꺼냈다.", "open_threads": ["다음 회기에서 상담 지속 의사를 확인하기"], "end_state": { "rapport_credit": 0.55, "resistance": card.base_resistance(), "ideation_stage": card.ideation_baseline(), }, } return None async def fetch(self, query: str, *args: object): test_case.assertIn("FROM app.pinned_fact", query) test_case.assertIn("$2 = ANY(visible_to)", query) return [{"value": "박민수와 주 1회 상담 약속"}] class FakeAcquire: def __init__(self, conn: FakeConn) -> None: self.conn = conn async def __aenter__(self) -> FakeConn: return self.conn async def __aexit__( self, exc_type: object, exc: object, tb: object ) -> None: return None async def fake_create_session(**kwargs): return InProcSession( session_id="db-seed-recall-session", case_id=kwargs["case_id"], learner_id=principal.user_id, persona_code=card.code, theory_mode=kwargs["theory_mode"], persona=card, state=kwargs["state"], session_no=kwargs["session_no"], prev_rapport_credit=kwargs["carry_rapport"], ) def close_background(coro): 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), ): response = await sessions.start_session( sessions.SessionStartRequest(persona_code=card.code), principal, ) started = store.get(response.session_id) self.assertIsNotNone(started) captured_messages: list[str] = [] async def successful_turn(ctx, engine, **kwargs): assert ctx.state_after is not None captured_messages.extend(message.content for message in ctx.messages) return orchestrator.TurnResult( turn_seq=ctx.state_after.turn_seq, stage=ctx.state_after.stage.value, effective_openness=ctx.state_after.effective_openness, client_reply="조금 더 이야기해볼게요.", safety_flagged=False, 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, ), ): await sessions.submit_turn( response.session_id, sessions.TurnRequest(text="지난번 이야기를 이어가도 괜찮을까요?"), principal, ) message_blob = "\n".join(captured_messages) self.assertIn("[L2 회상", message_blob) self.assertIn("[케이스 큰그림]", message_blob) self.assertIn("[직전 회기 요약]", message_blob) self.assertIn("다음 회기에서 상담 지속 의사를 확인하기", message_blob) self.assertIn("[L4 고정 사실", message_blob) self.assertIn("[NAME]와 주 1회 상담 약속", message_blob) self.assertNotIn("김서연", message_blob) self.assertNotIn("박민수", message_blob) 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" ) ), ) 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, "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" default_model = None async def stream(self, req): yield "event: token" yield '{"ignored":"not data"}' yield 'data: {"text":"부분 응답"}' yield "event: done" yield ( 'data: {"provider":"claude_cli","model":"gateway-default",' '"tokens_in":5,"tokens_out":7,"cost_usd":0.034567}' ) async def stream_packets(self, req): async for packet in _decoded_stream_packets(self, req): yield packet principal = _principal() sess = _session(principal) ctx = orchestrator.prepare_turn( session_id=sess.session_id, case_id=sess.case_id, card=sess.persona, state=sess.state, learner_text="게이트웨이 스트림 테스트", memory=orchestrator.TurnMemory(recent_turns=[]), ) events = [ event async for event in orchestrator.run_turn_stream(ctx, FakeStreamEngine()) # type: ignore[arg-type] ] self.assertEqual([event.event for event in events], ["token", "done"]) self.assertEqual(events[0].data["text"], "부분 응답") self.assertEqual(events[1].data["llm_provider"], "claude_cli") self.assertEqual(events[1].data["model"], "gateway-default") self.assertEqual(events[1].data["tokens_in"], 5) self.assertEqual(events[1].data["tokens_out"], 7) self.assertEqual(events[1].data["cost_usd"], 0.034567) async def test_run_turn_stream_rejects_clean_eof_without_gateway_done(self) -> None: class FakeStreamEngine: engine_mode = "claude_cli" default_model = None async def stream(self, req): yield "event: token" yield 'data: {"text":"완료 전 부분 응답"}' async def stream_packets(self, req): async for packet in _decoded_stream_packets(self, req): yield packet principal = _principal() sess = _session(principal) ctx = orchestrator.prepare_turn( session_id=sess.session_id, case_id=sess.case_id, card=sess.persona, state=sess.state, learner_text="완료 이벤트 없는 스트림 테스트", memory=orchestrator.TurnMemory(recent_turns=[]), ) events = [ event async for event in orchestrator.run_turn_stream(ctx, FakeStreamEngine()) # type: ignore[arg-type] ] self.assertEqual([event.event for event in events], ["error"]) self.assertEqual(events[0].data["detail"], "client_stream_incomplete") async def test_run_turn_stream_treats_gateway_error_event_as_error(self) -> None: class FakeStreamEngine: engine_mode = "claude_cli" default_model = None async def stream(self, req): yield "event: token" yield 'data: {"text":"부분 응답"}' yield "event: error" yield 'data: {"detail":"engine unavailable: gateway"}' async def stream_packets(self, req): async for packet in _decoded_stream_packets(self, req): yield packet principal = _principal() sess = _session(principal) ctx = orchestrator.prepare_turn( session_id=sess.session_id, case_id=sess.case_id, card=sess.persona, state=sess.state, learner_text="게이트웨이 오류 테스트", memory=orchestrator.TurnMemory(recent_turns=[]), ) events = [ event async for event in orchestrator.run_turn_stream(ctx, FakeStreamEngine()) # type: ignore[arg-type] ] 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: principal = _principal() sess = _session(principal) async def failing_stream(*args, **kwargs): yield orchestrator.StreamEvent("token", {"text": "부분 응답"}) yield orchestrator.StreamEvent( "error", {"detail": "engine unavailable: stream"} ) with patch.object(sessions.orchestrator, "run_turn_stream", failing_stream): response = await sessions.stream_turn( sess.session_id, sessions.TurnRequest(text="스트림 실패 발화"), principal, ) body = await _consume_event_source(response) self.assertIn(b"engine unavailable: stream", body) self.assertEqual(sess.turns, []) async def test_voice_turn_engine_failure_does_not_append_learner_turn(self) -> None: class FakeWebSocket: def __init__(self) -> None: self.messages: list[dict[str, object]] = [] self.client_state = voice_routes.WebSocketState.CONNECTED async def send_text(self, data: str) -> None: self.messages.append(json.loads(data)) principal = _principal() sess = _session(principal) websocket = FakeWebSocket() with patch.object( voice_routes.orchestrator, "run_turn_generate", AsyncMock(side_effect=EngineError("voice engine unavailable")), ): await voice_routes._run_turn_and_speak( websocket, # type: ignore[arg-type] 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( any( message.get("type") == "error" and "engine unavailable" in str(message.get("detail")) for message in websocket.messages ), websocket.messages, ) self.assertEqual(sess.turns, []) async def test_voice_audio_turn_persists_paralinguistic_metadata(self) -> None: class FakeWebSocket: def __init__(self) -> None: self.messages: list[dict[str, object]] = [] self.binary: list[bytes] = [] self.client_state = voice_routes.WebSocketState.CONNECTED async def send_text(self, data: str) -> None: self.messages.append(json.loads(data)) async def send_bytes(self, data: bytes) -> None: self.binary.append(data) async def successful_turn(ctx, engine, **kwargs): assert ctx.state_after is not None return orchestrator.TurnResult( turn_seq=ctx.state_after.turn_seq, stage=ctx.state_after.stage.value, effective_openness=ctx.state_after.effective_openness, client_reply="천천히 말해줘서 고마워요.", safety_flagged=False, state_after=ctx.state_after, llm_provider="claude_cli", model="gateway-default", tokens_in=11, tokens_out=13, cost_usd=0.0012, ) async def fake_synthesize_stream(text, voice_preset): yield TTSChunk(audio=b"tts-audio") principal = _principal() sess = _session(principal) 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", }, ], ) ), ), 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] 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) learner_turn, client_turn = sess.turns self.assertTrue(str(learner_turn.audio_ref).startswith("voice:webm:sha256:")) self.assertEqual(learner_turn.silence_ms, 1234) self.assertGreater(learner_turn.speech_rate or 0, 0) self.assertTrue(learner_turn.barge_in) self.assertEqual( learner_turn.provider_events, [ { "type": "sigh", "confidence": 0.82, "event_type": "sigh", "category": "paralinguistic", }, { "kind": "voice_activity", "start_ms": 10, "event_type": "voice_activity", "category": "speech_activity", }, ], ) 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) ) async def test_review_exposes_voice_nonverbal_events_on_learner_turn(self) -> None: principal = _principal() sess = _session(principal) created_at = sess.created_at sess.turns.extend( [ TurnRecord( turn_seq=1, speaker="counselor", stage=sess.state.stage.value, text="learner voice turn", text_masked="learner voice turn", created_at=created_at + 1, audio_ref="voice:webm:sha256:test", silence_ms=1234, speech_rate=420.0, barge_in=True, provider_events=[ { "event_type": "sigh", "category": "paralinguistic", "confidence": 0.82, "provider": "stt-provider", "type": "raw_sigh", }, { "event_type": "speech_start", "category": "speech_activity", "start_ms": 100, }, { "event_type": "silence", "category": "timing", "duration_ms": 1234, }, { "event_type": "background_noise", "category": "audio_quality", "score": 77, "label": "busy cafe", }, ], ), TurnRecord( turn_seq=2, speaker="client", stage=sess.state.stage.value, text="client reply", text_masked="client reply", created_at=created_at + 2, audio_ref="voice:webm:sha256:client", silence_ms=2500, speech_rate=180.0, barge_in=True, provider_events=[ {"event_type": "cry", "category": "paralinguistic"} ], ), ] ) response = await sessions.get_session_review(sess.session_id, principal) self.assertEqual(len(response.turns), 2) learner_turn, client_turn = response.turns self.assertEqual( [event.kind for event in learner_turn.nonverbal], ["silence", "pace", "barge_in", "audio", "paralinguistic", "audio_quality"], ) 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(learner_turn.nonverbal[1].detail, "분당 420자") self.assertEqual(learner_turn.nonverbal[4].label, "음성 단서") self.assertEqual(learner_turn.nonverbal[4].detail, "한숨 감지 · 신뢰도 82%") exposed_details = " ".join(event.detail for event in learner_turn.nonverbal) self.assertNotIn("stt-provider", exposed_details) self.assertNotIn("raw_sigh", exposed_details) self.assertEqual(learner_turn.nonverbal[5].label, "오디오 품질") self.assertEqual(learner_turn.nonverbal[5].detail, "배경 소음 · 신뢰도 77%") self.assertEqual(client_turn.nonverbal, []) async def test_review_includes_case_formulation_worksheet_draft(self) -> None: principal = _principal() sess = _session(principal) created_at = sess.created_at sess.turns.extend( [ TurnRecord( turn_seq=1, speaker="counselor", stage=sess.state.stage.value, text="오늘은 어떤 목표로 이야기해보고 싶으세요?", text_masked="오늘은 어떤 목표로 이야기해보고 싶으세요?", created_at=created_at + 1, ), TurnRecord( turn_seq=2, speaker="client", stage=sess.state.stage.value, text="요즘 너무 불안하고 친구 관계 스트레스 때문에 잠을 잘 못 자요.", text_masked="요즘 너무 불안하고 친구 관계 스트레스 때문에 잠을 잘 못 자요.", created_at=created_at + 2, ), ] ) response = await sessions.get_session_review(sess.session_id, principal) worksheet = response.caseWorksheet self.assertEqual(worksheet.status, "draft_from_transcript") 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" ) self.assertEqual(complaint.confidence, "medium") self.assertEqual(complaint.evidence[0].turnId, "t2") self.assertIn("불안", complaint.value or "") risk = next(item for item in exploration.items if item.key == "risk") self.assertEqual(risk.confidence, "none") self.assertEqual(risk.evidence, []) self.assertIn("명시 근거", risk.emptyReason or "") async def test_review_prefers_saved_case_formulation_worksheet(self) -> None: principal = _principal() sess = _session(principal) sess.turns.append( TurnRecord( turn_seq=1, speaker="client", stage=sess.state.stage.value, text="자동 초안 대신 저장본을 확인합니다.", text_masked="자동 초안 대신 저장본을 확인합니다.", created_at=sess.created_at + 1, ) ) saved_payload = { "status": "saved_by_learner", "generatedBy": "learner-edited worksheet", "sections": [ { "key": "exploration_11", "title": "탐색 11항목", "items": [ { "key": "presenting_complaint", "label": "주호소", "value": "학습자가 저장한 주호소", "evidence": [], "confidence": "medium", "emptyReason": None, } ], } ], "limitations": ["학습자 저장본"], "savedAt": "2026-06-27T10:00:00+00:00", } with patch.object( sessions.session_persistence, "load_case_worksheet", AsyncMock(return_value=(saved_payload, True)), ): response = await sessions.get_session_review(sess.session_id, principal) worksheet = response.caseWorksheet self.assertEqual(worksheet.status, "saved_by_learner") self.assertEqual(worksheet.generatedBy, "learner-edited worksheet") self.assertEqual(worksheet.sections[0].items[0].value, "학습자가 저장한 주호소") self.assertEqual(worksheet.limitations, ["학습자 저장본"]) self.assertEqual(worksheet.savedAt, "2026-06-27T10:00:00+00:00") async def test_teacher_review_includes_manual_worksheet_decision(self) -> None: learner = _principal() teacher_principal = Principal( user_id="00000000-0000-0000-0000-000000000902", role=Role.TEACHER, ) sess = _session(learner) sess.ended = True sess.ended_at = sess.created_at + 600 sess.turns.append( TurnRecord( turn_seq=1, speaker="client", stage=sess.state.stage.value, text="저장본 검수를 확인합니다.", text_masked="저장본 검수를 확인합니다.", created_at=sess.created_at + 1, ) ) saved_payload = { "status": "saved_by_learner", "generatedBy": "learner-edited worksheet", "sections": [], "limitations": ["학습자 저장본"], "savedAt": "2026-06-27T10:00:00+00:00", } review_status = { "session_id": sess.session_id, "reviewer_id": teacher_principal.user_id, "status": "viewed", "note": "회기 전체 검토 메모", "worksheet_status": "changes_requested", "worksheet_note": "주호소 근거를 더 명확히 쓰도록 지도", "worksheet_reviewed_at": "2026-06-27T10:05:00Z", "reviewed_at": "", "updated_at": "2026-06-27T10:05:00Z", } with ( patch.object( sessions.session_persistence, "load_session", AsyncMock(return_value=sess), ), patch.object( sessions.session_persistence, "load_case_worksheet", AsyncMock(return_value=(saved_payload, True)), ), patch.object( sessions.session_persistence, "load_session_evaluation", AsyncMock(return_value=(None, False)), ), patch.object( sessions.session_persistence, "load_session_review_status", AsyncMock(return_value=(review_status, True)), ), ): 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" ) async def test_review_remasks_legacy_session_evaluation_payload(self) -> None: class FakeKoRecognizer: def analyze(self, text: str): spans = [] for entity_type, value in ( ("NAME", "보라별"), ("ORG", "미래학교상담연구랩"), ): start = text.find(value) if start >= 0: spans.append( guardrail.PiiEntitySpan( entity_type, start, start + len(value) ) ) return spans guardrail.set_ko_pii_recognizer(FakeKoRecognizer()) self.addCleanup(guardrail.set_ko_pii_recognizer, None) principal = _principal() sess = _session(principal) sess.ended = True sess.ended_at = sess.created_at + 180 sess.turns.extend( [ TurnRecord( turn_seq=1, speaker="counselor", stage=sess.state.stage.value, text="감정을 먼저 확인해보겠습니다.", text_masked="감정을 먼저 확인해보겠습니다.", created_at=sess.created_at + 1, ), TurnRecord( turn_seq=2, speaker="client", stage=sess.state.stage.value, text="연락처 이야기는 부담스러워요.", text_masked="연락처 이야기는 부담스러워요.", created_at=sess.created_at + 2, ), ] ) raw_record = { "status": "ready", "source": "engine", "scope": "session_end", "stage": "정리", "payload": { "loop": "deep", "session_id": sess.session_id, "stage": "정리", "scope": "session_end", "turns_evaluated": 2, "distribution": {}, "strengths": ["보라별의 감정을 반영했다."], "improvements": ["미래학교상담연구랩과 010-1234-5678 재확인을 줄인다."], "intent_deviations": [ { "dimension": "pacing", "expected": "보라별의 감정 확인", "actual": "010-1234-5678 연락처 재질문", "severity": "moderate", } ], "supervisor_rationale": "보라별의 호소를 요약했다.", "supervisor_critique": "미래학교상담연구랩 언급이 반복됐다.", "alternative_utterances": ["보라별님, 지금 감정부터 천천히 볼까요?"], }, "error": None, } with ( patch.object( sessions.session_persistence, "load_session", AsyncMock(return_value=sess), ), patch.object( sessions.session_persistence, "load_case_worksheet", AsyncMock(return_value=(None, False)), ), patch.object( sessions.session_persistence, "load_session_evaluation", AsyncMock(return_value=(raw_record, True)), ), ): response = await sessions.get_session_review(sess.session_id, principal) blob = response.model_dump_json() for raw in ("보라별", "미래학교상담연구랩", "010-1234-5678"): self.assertNotIn(raw, blob) for masked in ("[NAME]", "[ORG]", "[PHONE]"): self.assertIn(masked, blob) self.assertTrue(response.reviewReady) self.assertEqual(response.supervisorState, "평가 완료") async def test_review_surfaces_session_evaluation_error_record(self) -> None: principal = _principal() sess = _session(principal) sess.ended = True sess.ended_at = sess.created_at + 180 sess.turns.extend( [ TurnRecord( turn_seq=1, speaker="learner", stage=sess.state.stage.value, text="많이 지쳐 보였어요. 지금 제일 버거운 마음이 뭔가요?", text_masked="많이 지쳐 보였어요. 지금 제일 버거운 마음이 뭔가요?", created_at=sess.created_at + 1, ), TurnRecord( turn_seq=2, speaker="client", stage=sess.state.stage.value, text="그냥 아무것도 하고 싶지 않아요.", text_masked="그냥 아무것도 하고 싶지 않아요.", created_at=sess.created_at + 2, ), ] ) error_record = session_persistence.SessionEvaluationWrite.from_error( session_id=sess.session_id, learner_id=principal.user_id, scope="session_end", stage="정리", error="session evaluation timeout after 45s", ).cache_record() with ( patch.object( sessions.session_persistence, "load_session", AsyncMock(return_value=sess), ), patch.object( sessions.session_persistence, "load_case_worksheet", AsyncMock(return_value=(None, False)), ), patch.object( sessions.session_persistence, "load_session_evaluation", AsyncMock(return_value=(error_record, True)), ), ): response = await sessions.get_session_review(sess.session_id, principal) self.assertFalse(response.reviewReady) self.assertTrue(response.degraded) self.assertEqual(response.supervisorState, "평가 실패") 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, []) self.assertEqual(response.goodMoments, []) self.assertEqual(response.growthPoints, []) def test_review_marks_stale_missing_session_evaluation_as_failed(self) -> None: principal = _principal() sess = _session(principal) sess.ended = True sess.ended_at = sess.created_at + 180 sess.turns.extend( [ TurnRecord( turn_seq=1, speaker="counselor", stage=sess.state.stage.value, text="많이 지쳐 보였어요. 지금 제일 버거운 마음이 뭔가요?", text_masked="많이 지쳐 보였어요. 지금 제일 버거운 마음이 뭔가요?", created_at=sess.created_at + 1, ), TurnRecord( turn_seq=2, speaker="client", stage=sess.state.stage.value, text="그냥 아무것도 하고 싶지 않아요.", text_masked="그냥 아무것도 하고 싶지 않아요.", created_at=sess.created_at + 2, ), ] ) previous_timeout = settings.session_evaluation_timeout settings.session_evaluation_timeout = 10.0 try: response = sessions.build_session_review( sessions.SessionReviewReadInput( session=sess, evaluation_record=None, evaluation_durable=True, now_ts=(sess.ended_at or sess.created_at) + 41.0, ) ) finally: settings.session_evaluation_timeout = previous_timeout self.assertFalse(response.reviewReady) self.assertTrue(response.degraded) self.assertEqual(response.supervisorState, "평가 실패") self.assertIn("AI 평가 재시도가 필요합니다", response.summary) self.assertEqual(response.rubric, []) self.assertEqual(response.goodMoments, []) self.assertEqual(response.growthPoints, []) async def test_learner_can_save_case_formulation_worksheet(self) -> None: principal = _principal() sess = _session(principal) request = sessions.ReviewCaseWorksheetSaveRequest( sections=[ sessions.ReviewWorksheetSection( key="exploration_11", title="탐색 11항목", items=[ sessions.ReviewWorksheetItem( key="presenting_complaint", label="주호소", value="수정한 주호소", confidence="medium", ) ], ) ], 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, ): response = await sessions.save_session_review_worksheet( sess.session_id, request, principal, ) load_session.assert_awaited_once_with( sess.session_id, principal, allow_ended=True, include_turn_evaluation=False, ) save_worksheet.assert_awaited_once() save_kwargs = save_worksheet.await_args.kwargs 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(response.status, "saved_by_learner") self.assertEqual(response.sections[0].items[0].value, "수정한 주호소") if __name__ == "__main__": unittest.main()