현재 작업 상태 저장
This commit is contained in:
parent
07cc67761e
commit
6bd91b0d5e
674 changed files with 8726 additions and 298 deletions
247
apps/api/app/test_evaluation_persistence.py
Normal file
247
apps/api/app/test_evaluation_persistence.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue