"""Regression tests for turn-evaluation persistence mapping.""" from __future__ import annotations from pathlib import Path import unittest from unittest.mock import patch from .deps import Principal, Role from . import session_persistence from .routes import sessions from .services import evaluator, 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 FakeAcquire: def __init__(self, conn: FakeEvaluationConn) -> 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.TestCase): 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="오늘 상담에서 집중해 보겠습니다.", ) 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.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_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_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_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) 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_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_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()