"""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 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 EvaluationPersistenceMappingTest(unittest.TestCase): 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_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", }, } ], ) 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") 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_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", }, } 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.assertNotIn("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()