"""Regression tests for turn-evaluation persistence mapping.""" from __future__ import annotations from pathlib import Path import json import unittest from unittest.mock import patch from .deps import Principal, Role from . import session_persistence from .routes import sessions from .services import evaluator, guardrail, session_metrics from .services import persona as persona_service from .services import state_machine from .store import InProcSession class FakeEvaluationConn: def __init__(self) -> None: self.executed: list[tuple[str, tuple[object, ...]]] = [] self.fetchvals: list[tuple[str, tuple[object, ...]]] = [] async def execute(self, query: str, *args: object) -> str: self.executed.append((query, args)) return "INSERT 0 1" async def fetchval(self, query: str, *args: object) -> int: self.fetchvals.append((query, args)) if "app.technique_label_def" in query: return 101 if "app.client_state_def" in query: return 202 raise AssertionError(f"unexpected fetchval query: {query}") class FakeMissingEvaluationConn: def __init__(self) -> None: self.fetches: list[tuple[str, tuple[object, ...]]] = [] self.fetchrows: list[tuple[str, tuple[object, ...]]] = [] async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: self.fetches.append((query, args)) if "FROM app.sessions s" in query: return [{"id": "11111111-1111-1111-1111-111111111111"}] if "FROM app.session_state" in query: return [{"session_id": "11111111-1111-1111-1111-111111111111"}] if "FROM app.turns" in query: return [] raise AssertionError(f"unexpected fetch query: {query}") async def fetchrow(self, query: str, *args: object) -> dict[str, object]: self.fetchrows.append((query, args)) return {} class FakeAcquire: def __init__(self, conn) -> None: self.conn = conn async def __aenter__(self) -> FakeEvaluationConn: return self.conn async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: return None class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase): def test_session_metrics_prefers_rehydrated_technique_label_ko(self) -> None: ev = { "techniques": [ {"code": "empathy", "label_ko": "공감", "label": "legacy empathy"}, {"code": "open_question"}, ] } self.assertEqual(session_metrics.turn_techniques(ev), ["공감", "open_question"]) def test_fast_evaluator_masks_client_reply_before_prompting(self) -> None: card = persona_service.P1 state = state_machine.init_state(params=card.openness_params()) ctx = sessions.orchestrator.prepare_turn( session_id="eval-mask-session", case_id="eval-mask-case", card=card, state=state, learner_text="윤찬 상담자가 서연 씨의 이야기에 집중해 보겠습니다.", learner_identity="윤찬", ) messages = evaluator.build_fast_messages( ctx, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.", ) blob = "\n".join(message.content for message in messages) self.assertNotIn("김서연", blob) self.assertNotIn("한신대학교", blob) self.assertNotIn("상담심리학과", blob) self.assertNotIn("윤찬", blob) self.assertNotIn("서연 씨", blob) self.assertIn("[COUNSELOR]", blob) self.assertIn("[CLIENT]", blob) self.assertIn("[NAME]", blob) self.assertIn("[ORG]", blob) def test_feedback_rows_preserve_review_scalar_contract(self) -> None: evaluation = { "loop": "fast", "turn_seq": 2, "stage": "탐색", "appropriateness": "pos", "appropriateness_note": "정서를 먼저 반영했다.", "rapport_signal": 0.75, "theory_mode": "humanistic", "techniques": [ { "code": "empathy", "label_ko": "공감", "category": "relational", "rationale": "감정을 명시적으로 반영했다.", } ], "client_state_read": [ { "code": "affect_contact", "label_ko": "정서 접촉/표현", "rationale": "내담자가 감정을 언급했다.", } ], } rows = { row["dimension"]: row for row in session_persistence._evaluation_feedback_rows(evaluation) } self.assertEqual(rows["appropriateness"]["score"], 5.0) self.assertEqual(rows["appropriateness"]["rationale"], "정서를 먼저 반영했다.") self.assertEqual(rows["rapport_signal"]["score"], 0.75) self.assertEqual(rows["theory_mode"]["rationale"], "humanistic") self.assertEqual(rows["technique:empathy"]["rationale"], "감정을 명시적으로 반영했다.") self.assertEqual(rows["client_state:affect_contact"]["rationale"], "내담자가 감정을 언급했다.") def test_alternative_rows_accept_string_and_dict_shapes(self) -> None: rows = session_persistence._evaluation_alternative_rows( { "alternative_utterances": [ "감정을 먼저 반영해 보세요.", {"text": "조언 전에 의미를 확인해 보세요.", "rationale": "성급한 해결 방지"}, {"suggestion": "침묵을 허용해 보세요."}, {"rationale": "빈 제안은 저장하지 않음"}, ] } ) self.assertEqual( rows, [ {"suggestion": "감정을 먼저 반영해 보세요.", "rationale": None}, {"suggestion": "조언 전에 의미를 확인해 보세요.", "rationale": "성급한 해결 방지"}, {"suggestion": "침묵을 허용해 보세요.", "rationale": None}, ], ) def test_turn_evaluation_rows_mask_pii_before_db_persistence(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) evaluation = { "appropriateness": "warn", "appropriateness_note": "보라별에게 010-1234-5678을 되물었다.", "techniques": [ { "code": "reflection", "rationale": "미래학교상담연구랩 이야기를 바로 조언했다.", } ], "client_state_read": [ { "code": "avoidance", "rationale": "보라별이 기관 미래학교상담연구랩을 피했다.", } ], "intent_deviation": { "expected": "보라별의 감정을 확인", "actual": "010-1234-5678 연락처를 재질문", }, "alternative_utterances": [ { "text": "보라별님, 미래학교상담연구랩 이야기는 잠시 미뤄도 괜찮아요.", "rationale": "010-1234-5678 같은 연락처 재확인을 피함", } ], } blob = json.dumps( { "feedback": session_persistence._evaluation_feedback_rows(evaluation), "comments": session_persistence._evaluation_comment_rows(evaluation), "alternatives": session_persistence._evaluation_alternative_rows(evaluation), }, ensure_ascii=False, ) for raw in ("보라별", "미래학교상담연구랩", "010-1234-5678"): self.assertNotIn(raw, blob) for masked in ("[NAME]", "[ORG]", "[PHONE]"): self.assertIn(masked, blob) def test_turn_from_legacy_row_remasks_raw_text_fallback(self) -> None: class FakeKoRecognizer: def analyze(self, text: str): start = text.find("보라별") if start < 0: return [] return [guardrail.PiiEntitySpan("NAME", start, start + len("보라별"))] guardrail.set_ko_pii_recognizer(FakeKoRecognizer()) self.addCleanup(guardrail.set_ko_pii_recognizer, None) turn = session_persistence._turn_from_row( { "id": "11111111-1111-1111-1111-111111111111", "seq": 2, "speaker": "client", "stage": "explore", "text": "보라별의 전화는 010-1234-5678입니다.", "text_masked": "", "created_at": None, "visible_to": ("client", "evaluator"), } ) self.assertEqual(turn.text, turn.text_masked) self.assertNotIn("보라별", turn.text) self.assertNotIn("010-1234-5678", turn.text) self.assertIn("[NAME]", turn.text) self.assertIn("[PHONE]", turn.text) def test_session_evaluation_write_from_result_preserves_payload_shape(self) -> None: result = evaluator.SessionEvaluation( session_id="session-1", stage="explore", scope="session_end", turns_evaluated=2, ) write = session_persistence.SessionEvaluationWrite.from_result( session_id="session-1", learner_id="learner-1", result=result, ) self.assertEqual(write.status, "ready") self.assertEqual(write.source, "engine") self.assertEqual(write.scope, "session_end") self.assertEqual(write.stage, "explore") self.assertEqual(write.payload, result.to_dict()) self.assertNotIn("error", write.payload) self.assertIsNone(write.error) def test_session_evaluation_write_masks_deep_payload_before_storage(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) result = evaluator.SessionEvaluation( session_id="session-privacy", stage="정리", scope="session_end", turns_evaluated=3, strengths=["보라별의 감정을 반영했다."], improvements=["010-1234-5678 같은 연락처 재확인은 피한다."], intent_deviations=[ evaluator.IntentDeviation( dimension="pacing", expected="미래학교상담연구랩 이야기를 기다린다", actual="보라별에게 바로 조언했다", severity="moderate", ) ], supervisor_rationale="보라별의 호소를 요약했다.", supervisor_critique="미래학교상담연구랩과 010-1234-5678을 반복했다.", alternative_utterances=["보라별님, 지금 감정부터 천천히 볼까요?"], error="보라별 평가 경고", ) write = session_persistence.SessionEvaluationWrite.from_result( session_id="session-privacy", learner_id="learner-privacy", result=result, ) blob = json.dumps({"payload": write.payload, "error": write.error}, ensure_ascii=False) self.assertEqual(write.status, "error") for raw in ("보라별", "미래학교상담연구랩", "010-1234-5678"): self.assertNotIn(raw, blob) for masked in ("[NAME]", "[ORG]", "[PHONE]"): self.assertIn(masked, blob) def test_session_evaluation_write_role_tokenizes_known_identities(self) -> None: result = evaluator.SessionEvaluation( session_id="session-role-privacy", stage="정리", scope="session_end", turns_evaluated=2, strengths=["윤찬 상담자가 서연의 마음을 반영했다."], improvements=["서연 씨의 반응을 윤찬이 한 번 더 기다린다."], supervisor_rationale="윤찬과 서연의 상호작용을 확인했다.", ) write = session_persistence.SessionEvaluationWrite.from_result( session_id="session-role-privacy", learner_id="learner-role-privacy", result=result, counselor_identity="윤찬", client_identity="서연(가명) · 고2 · 우울/자살사고", ) blob = json.dumps(write.payload, ensure_ascii=False) self.assertNotIn("윤찬", blob) self.assertNotIn("서연", blob) self.assertIn("[COUNSELOR]", blob) self.assertIn("[CLIENT]", blob) def test_session_evaluation_write_from_error_preserves_fallback_shape(self) -> None: write = session_persistence.SessionEvaluationWrite.from_error( session_id="session-1", learner_id="learner-1", scope="session_end", stage="explore", error=RuntimeError("engine timeout"), ) self.assertEqual(write.status, "error") self.assertEqual(write.source, "engine") self.assertEqual(write.scope, "session_end") self.assertEqual(write.stage, "explore") self.assertEqual(write.payload, {}) self.assertEqual(write.error, "engine timeout") def test_session_evaluation_write_from_error_names_empty_exception(self) -> None: write = session_persistence.SessionEvaluationWrite.from_error( session_id="session-1", learner_id="learner-1", scope="session_end", stage="explore", error=TimeoutError(), ) self.assertEqual(write.error, "TimeoutError") def test_session_evaluation_write_from_error_masks_error_detail(self) -> None: class FakeKoRecognizer: def analyze(self, text: str): start = text.find("보라별") if start < 0: return [] return [guardrail.PiiEntitySpan("NAME", start, start + len("보라별"))] guardrail.set_ko_pii_recognizer(FakeKoRecognizer()) self.addCleanup(guardrail.set_ko_pii_recognizer, None) write = session_persistence.SessionEvaluationWrite.from_error( session_id="session-1", learner_id="learner-1", scope="session_end", stage="explore", error="보라별 처리 중 010-1234-5678 오류", ) self.assertNotIn("보라별", write.error or "") self.assertNotIn("010-1234-5678", write.error or "") self.assertIn("[NAME]", write.error or "") self.assertIn("[PHONE]", write.error or "") async def test_save_session_evaluation_remasks_direct_write_payload(self) -> None: class FakeKoRecognizer: def analyze(self, text: str): start = text.find("보라별") if start < 0: return [] return [guardrail.PiiEntitySpan("NAME", start, start + len("보라별"))] guardrail.set_ko_pii_recognizer(FakeKoRecognizer()) self.addCleanup(guardrail.set_ko_pii_recognizer, None) conn = FakeEvaluationConn() write = session_persistence.SessionEvaluationWrite( session_id="11111111-1111-1111-1111-111111111111", learner_id="22222222-2222-2222-2222-222222222222", status="ready", source="engine", scope="session_end", stage="정리", payload={"strengths": ["보라별의 전화 010-1234-5678을 반복했다."]}, error="보라별 direct write error", ) with ( patch.object(session_persistence, "get_pool", return_value=object()), patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)), ): saved = await session_persistence.save_session_evaluation(write) self.assertTrue(saved) _, args = conn.executed[0] blob = json.dumps({"payload": args[5], "error": args[6]}, ensure_ascii=False) self.assertNotIn("보라별", blob) self.assertNotIn("010-1234-5678", blob) self.assertIn("[NAME]", blob) self.assertIn("[PHONE]", blob) self.assertIn( "WHERE app.session_evaluation.status <> 'ready'", conn.executed[0][0], ) def test_late_error_never_replaces_ready_evaluation_record(self) -> None: ready = {"status": "ready", "payload": {"strengths": ["완료"]}} error = {"status": "error", "error": "late timeout"} self.assertFalse( session_persistence._should_replace_evaluation_record(ready, error) ) self.assertTrue( session_persistence._should_replace_evaluation_record(error, ready) ) self.assertTrue( session_persistence._should_replace_evaluation_record(ready, ready) ) async def test_fallback_cache_keeps_ready_result_when_late_error_arrives(self) -> None: session_id = "11111111-1111-1111-1111-111111111111" session_persistence._EVALUATION_CACHE[session_id] = {"status": "ready"} self.addCleanup(session_persistence._EVALUATION_CACHE.pop, session_id, None) write = session_persistence.SessionEvaluationWrite.from_error( session_id=session_id, learner_id="22222222-2222-2222-2222-222222222222", scope="session_end", stage="정리", error="late timeout", ) with ( patch.object(session_persistence, "runtime_fallback_allowed", return_value=True), patch.object(session_persistence, "get_pool", side_effect=RuntimeError("offline")), patch.object(session_persistence, "require_runtime_fallback_allowed"), ): saved = await session_persistence.save_session_evaluation(write) self.assertFalse(saved) self.assertEqual(session_persistence._EVALUATION_CACHE[session_id]["status"], "ready") def test_rebuild_turn_evaluation_restores_review_shape(self) -> None: rebuilt = session_persistence._rebuild_turn_evaluations( [("11111111-1111-1111-1111-111111111111", 2, "탐색")], feedback_rows=[ { "turn_id": "11111111-1111-1111-1111-111111111111", "dimension": "appropriateness", "score": 1.0, "rationale": "조언이 너무 빨랐다.", "top1_score": None, "loop": "fast", }, { "turn_id": "11111111-1111-1111-1111-111111111111", "dimension": "technique:empathy", "score": None, "rationale": "정서 반영이 포함됐다.", "top1_score": None, "loop": "fast", }, { "turn_id": "11111111-1111-1111-1111-111111111111", "dimension": "rapport_signal", "score": -0.4, "rationale": None, "top1_score": None, "loop": "fast", }, ], technique_rows=[ { "turn_id": "11111111-1111-1111-1111-111111111111", "code": "empathy", "label_ko": "공감", "category": "relational", } ], client_state_rows=[ { "turn_id": "11111111-1111-1111-1111-111111111111", "code": "defensive", "label_ko": "방어", } ], comment_rows=[ { "turn_id": "11111111-1111-1111-1111-111111111111", "intent_deviation": { "dimension": "pacing", "expected": "감정 탐색", "actual": "해결 조언", "severity": "moderate", }, } ], alternative_rows=[ { "turn_id": "11111111-1111-1111-1111-111111111111", "suggestion": "감정을 먼저 반영해 보세요.", "rationale": None, } ], ) ev = rebuilt["11111111-1111-1111-1111-111111111111"] self.assertEqual(ev["turn_seq"], 2) self.assertEqual(ev["stage"], "탐색") self.assertEqual(ev["appropriateness"], "warn") self.assertEqual(ev["appropriateness_note"], "조언이 너무 빨랐다.") self.assertEqual(ev["rapport_signal"], -0.4) self.assertEqual(ev["techniques"][0]["rationale"], "정서 반영이 포함됐다.") self.assertEqual(ev["client_state_read"][0]["label_ko"], "방어") self.assertEqual(ev["intent_deviation"]["dimension"], "pacing") self.assertEqual(ev["alternative_utterances"], ["감정을 먼저 반영해 보세요."]) def test_evaluation_rls_blocks_raw_learner_writes(self) -> None: root = Path(__file__).resolve().parents[3] sql = (root / "infra/db/init/04_audit_eval_rls.sql").read_text(encoding="utf-8") self.assertIn("ALTER TABLE app.feedback_scores ENABLE ROW LEVEL SECURITY", sql) self.assertIn("ALTER TABLE app.turn_technique ENABLE ROW LEVEL SECURITY", sql) self.assertIn("ALTER TABLE app.turn_client_state ENABLE ROW LEVEL SECURITY", sql) self.assertIn("ALTER TABLE app.supervisor_comment ENABLE ROW LEVEL SECURITY", sql) self.assertIn("ALTER TABLE app.alternative_utterance ENABLE ROW LEVEL SECURITY", sql) self.assertIn("CREATE POLICY p_feedback_delete", sql) self.assertIn("CREATE POLICY p_turn_technique_delete", sql) self.assertIn("CREATE POLICY p_turn_client_state_delete", sql) self.assertIn("CREATE POLICY p_supervisor_comment_delete", sql) self.assertIn("CREATE POLICY p_alternative_utterance_delete", sql) feedback_insert = sql.split("CREATE POLICY p_feedback_insert", 1)[1].split(");", 1)[0] self.assertNotIn("learner_id = app.current_uid()", feedback_insert) def test_append_turn_requires_inserted_turn_id(self) -> None: source = Path(session_persistence.__file__).read_text(encoding="utf-8") self.assertIn("RETURNING id", source) self.assertIn("if inserted_turn_id is None:", source) class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase): async def test_missing_session_evaluation_finder_uses_stale_ai_context_query(self) -> None: conn = FakeMissingEvaluationConn() sentinel = object() with ( patch.object(session_persistence, "get_pool", return_value=object()), patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)) as acquire, patch.object(session_persistence, "_session_from_rows", return_value=sentinel), ): found, durable = await session_persistence.list_sessions_missing_session_evaluation( older_than_seconds=150.0, limit=3, ) self.assertTrue(durable) self.assertEqual(found, [sentinel]) acquire.assert_called_once_with(ai_context=True, ai_view="evaluator") self.assertEqual(conn.fetches[0][1], (150.0, 3)) session_query = conn.fetches[0][0] self.assertIn("s.ended_at IS NOT NULL", session_query) self.assertIn("se.session_id IS NULL", session_query) self.assertIn("'client' = ANY(t.visible_to)", session_query) self.assertIn("ORDER BY s.ended_at ASC", session_query) self.assertEqual( conn.fetches[1][1], (["11111111-1111-1111-1111-111111111111"],), ) self.assertIn("FROM app.session_state", conn.fetches[1][0]) self.assertIn("FROM app.turns", conn.fetches[2][0]) async def test_record_llm_call_audit_inserts_metadata_only(self) -> None: conn = FakeEvaluationConn() payload = { "session_id": "11111111-1111-1111-1111-111111111111", "provider": "claude_cli", "model": "sonnet", "tokens_in": 120, "tokens_out": 45, "cost_usd": 0.0123, "inference_geo": "us", "latency_ms": 345, "messages": [{"content": "raw prompt must not be persisted"}], } with ( patch.object(session_persistence, "get_pool", return_value=object()), patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)) as acquire, ): ok = await session_persistence.record_llm_call_audit(payload) self.assertTrue(ok) acquire.assert_called_once_with(ai_context=True, ai_view="evaluator") self.assertEqual(len(conn.executed), 1) query, args = conn.executed[0] self.assertIn("INSERT INTO audit.llm_call_log", query) self.assertNotIn("raw prompt", query) self.assertNotIn("messages", query) self.assertEqual(args[0], "11111111-1111-1111-1111-111111111111") self.assertIsNone(args[1]) self.assertEqual(args[2], "claude_cli") self.assertEqual(args[3], "sonnet") self.assertEqual(args[4], 120) self.assertEqual(args[5], 45) self.assertEqual(args[6], 0.0123) self.assertEqual(args[7], "us") self.assertEqual(args[8], 345) async def test_persist_turn_evaluation_uses_evaluator_context_and_real_fast_tables(self) -> None: conn = FakeEvaluationConn() evaluation = { "loop": "fast", "turn_seq": 3, "stage": "탐색", "appropriateness": "warn", "appropriateness_note": "해결 제안이 빨랐다.", "techniques": [ { "code": "empathy", "label_ko": "공감", "category": "relational", "rationale": "정서 반영.", } ], "client_state_read": [ { "code": "defensive", "label_ko": "방어", "rationale": "짧은 회피 반응.", } ], "intent_deviation": { "dimension": "pacing", "expected": "탐색", "actual": "조언", "severity": "minor", }, "alternative_utterances": ["감정을 먼저 반영해 보세요."], } await session_persistence._persist_turn_evaluation( conn, "11111111-1111-1111-1111-111111111111", evaluation, ) executed_sql = "\n".join(query for query, _ in conn.executed) self.assertIn("set_config('app.ai_context', '1', true)", executed_sql) self.assertIn("set_config('app.current_ai_view', 'evaluator', true)", executed_sql) self.assertIn("INSERT INTO app.feedback_scores", executed_sql) self.assertIn("INSERT INTO app.turn_technique", executed_sql) self.assertIn("INSERT INTO app.turn_client_state", executed_sql) self.assertIn("INSERT INTO app.supervisor_comment", executed_sql) self.assertIn("DELETE FROM app.alternative_utterance", executed_sql) self.assertIn("INSERT INTO app.alternative_utterance", executed_sql) async def test_replace_turn_evaluation_clears_stale_normalized_rows_before_reinsert(self) -> None: conn = FakeEvaluationConn() evaluation = { "loop": "fast", "turn_seq": 3, "stage": "탐색", "appropriateness": "pos", "techniques": [ {"code": "reflection", "label_ko": "반영", "category": "relational"} ], "client_state_read": [{"code": "open", "label_ko": "개방"}], "alternative_utterances": ["조금 더 머물러도 괜찮습니다."], } with ( patch.object(session_persistence, "get_pool", return_value=object()), patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)) as acquire, ): ok = await session_persistence.replace_turn_evaluation( turn_id="11111111-1111-1111-1111-111111111111", evaluation=evaluation, ) self.assertTrue(ok) acquire.assert_called_once_with(ai_context=True, ai_view="evaluator") executed_sql = "\n".join(query for query, _ in conn.executed) self.assertLess( executed_sql.index("DELETE FROM app.feedback_scores"), executed_sql.index("INSERT INTO app.feedback_scores"), ) self.assertIn("DELETE FROM app.turn_technique", executed_sql) self.assertIn("DELETE FROM app.turn_client_state", executed_sql) self.assertIn("DELETE FROM app.supervisor_comment", executed_sql) self.assertIn("DELETE FROM app.alternative_utterance", executed_sql) self.assertIn("INSERT INTO app.turn_technique", executed_sql) self.assertIn("INSERT INTO app.turn_client_state", executed_sql) self.assertIn("INSERT INTO app.alternative_utterance", executed_sql) async def test_route_loader_only_hydrates_when_requested(self) -> None: principal = Principal( user_id="00000000-0000-0000-0000-000000000101", role=Role.LEARNER, cohort_ids=[], email="eval-map@hs.ac.kr", display_name="Eval Map", ) card = persona_service.P1 sess = InProcSession( session_id="eval-map-session", case_id="eval-map-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(), ), ) calls: list[bool] = [] async def fake_load_session(*args, **kwargs): calls.append(bool(kwargs.get("include_turn_evaluation"))) return sess with patch.object(sessions.session_persistence, "load_session", fake_load_session): await sessions._load_session_or_404(sess.session_id, principal) await sessions._load_session_or_404( sess.session_id, principal, allow_ended=True, include_turn_evaluation=True, ) self.assertEqual(calls, [False, True]) if __name__ == "__main__": unittest.main()