329 lines
13 KiB
Python
329 lines
13 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 evaluator
|
|
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_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_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()
|