vignette/apps/api/app/test_evaluation_persistence.py
2026-06-27 11:20:24 +09:00

247 lines
9.6 KiB
Python

"""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()