766 lines
32 KiB
Python
766 lines
32 KiB
Python
"""Regression tests for turn-evaluation persistence mapping."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import json
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from .deps import Principal, Role
|
|
from . import session_evaluation_repository, session_persistence
|
|
from .routes import sessions
|
|
from .services import evaluator, guardrail, 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 FakeMissingEvaluationConn:
|
|
def __init__(self) -> None:
|
|
self.fetches: list[tuple[str, tuple[object, ...]]] = []
|
|
self.fetchrows: list[tuple[str, tuple[object, ...]]] = []
|
|
|
|
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
|
|
self.fetches.append((query, args))
|
|
if "FROM app.sessions s" in query:
|
|
return [{"id": "11111111-1111-1111-1111-111111111111"}]
|
|
if "FROM app.session_state" in query:
|
|
return [{"session_id": "11111111-1111-1111-1111-111111111111"}]
|
|
if "FROM app.turns" in query:
|
|
return []
|
|
raise AssertionError(f"unexpected fetch query: {query}")
|
|
|
|
async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
|
|
self.fetchrows.append((query, args))
|
|
return {}
|
|
|
|
|
|
class FakeAcquire:
|
|
def __init__(self, conn) -> 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.IsolatedAsyncioTestCase):
|
|
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="윤찬 상담자가 서연 씨의 이야기에 집중해 보겠습니다.",
|
|
learner_identity="윤찬",
|
|
)
|
|
|
|
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.assertNotIn("윤찬", blob)
|
|
self.assertNotIn("서연 씨", blob)
|
|
self.assertIn("[COUNSELOR]", blob)
|
|
self.assertIn("[CLIENT]", 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_turn_evaluation_rows_mask_pii_before_db_persistence(self) -> None:
|
|
class FakeKoRecognizer:
|
|
def analyze(self, text: str):
|
|
spans = []
|
|
for entity_type, value in (
|
|
("NAME", "보라별"),
|
|
("ORG", "미래학교상담연구랩"),
|
|
):
|
|
start = text.find(value)
|
|
if start >= 0:
|
|
spans.append(guardrail.PiiEntitySpan(entity_type, start, start + len(value)))
|
|
return spans
|
|
|
|
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
|
|
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
|
|
evaluation = {
|
|
"appropriateness": "warn",
|
|
"appropriateness_note": "보라별에게 010-1234-5678을 되물었다.",
|
|
"techniques": [
|
|
{
|
|
"code": "reflection",
|
|
"rationale": "미래학교상담연구랩 이야기를 바로 조언했다.",
|
|
}
|
|
],
|
|
"client_state_read": [
|
|
{
|
|
"code": "avoidance",
|
|
"rationale": "보라별이 기관 미래학교상담연구랩을 피했다.",
|
|
}
|
|
],
|
|
"intent_deviation": {
|
|
"expected": "보라별의 감정을 확인",
|
|
"actual": "010-1234-5678 연락처를 재질문",
|
|
},
|
|
"alternative_utterances": [
|
|
{
|
|
"text": "보라별님, 미래학교상담연구랩 이야기는 잠시 미뤄도 괜찮아요.",
|
|
"rationale": "010-1234-5678 같은 연락처 재확인을 피함",
|
|
}
|
|
],
|
|
}
|
|
|
|
blob = json.dumps(
|
|
{
|
|
"feedback": session_persistence._evaluation_feedback_rows(evaluation),
|
|
"comments": session_persistence._evaluation_comment_rows(evaluation),
|
|
"alternatives": session_persistence._evaluation_alternative_rows(evaluation),
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
for raw in ("보라별", "미래학교상담연구랩", "010-1234-5678"):
|
|
self.assertNotIn(raw, blob)
|
|
for masked in ("[NAME]", "[ORG]", "[PHONE]"):
|
|
self.assertIn(masked, blob)
|
|
|
|
def test_turn_from_legacy_row_remasks_raw_text_fallback(self) -> None:
|
|
class FakeKoRecognizer:
|
|
def analyze(self, text: str):
|
|
start = text.find("보라별")
|
|
if start < 0:
|
|
return []
|
|
return [guardrail.PiiEntitySpan("NAME", start, start + len("보라별"))]
|
|
|
|
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
|
|
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
|
|
turn = session_persistence._turn_from_row(
|
|
{
|
|
"id": "11111111-1111-1111-1111-111111111111",
|
|
"seq": 2,
|
|
"speaker": "client",
|
|
"stage": "explore",
|
|
"text": "보라별의 전화는 010-1234-5678입니다.",
|
|
"text_masked": "",
|
|
"created_at": None,
|
|
"visible_to": ("client", "evaluator"),
|
|
}
|
|
)
|
|
|
|
self.assertEqual(turn.text, turn.text_masked)
|
|
self.assertNotIn("보라별", turn.text)
|
|
self.assertNotIn("010-1234-5678", turn.text)
|
|
self.assertIn("[NAME]", turn.text)
|
|
self.assertIn("[PHONE]", turn.text)
|
|
|
|
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_evaluation_repository.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_masks_deep_payload_before_storage(self) -> None:
|
|
class FakeKoRecognizer:
|
|
def analyze(self, text: str):
|
|
spans = []
|
|
for entity_type, value in (
|
|
("NAME", "보라별"),
|
|
("ORG", "미래학교상담연구랩"),
|
|
):
|
|
start = text.find(value)
|
|
if start >= 0:
|
|
spans.append(guardrail.PiiEntitySpan(entity_type, start, start + len(value)))
|
|
return spans
|
|
|
|
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
|
|
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
|
|
result = evaluator.SessionEvaluation(
|
|
session_id="session-privacy",
|
|
stage="정리",
|
|
scope="session_end",
|
|
turns_evaluated=3,
|
|
strengths=["보라별의 감정을 반영했다."],
|
|
improvements=["010-1234-5678 같은 연락처 재확인은 피한다."],
|
|
intent_deviations=[
|
|
evaluator.IntentDeviation(
|
|
dimension="pacing",
|
|
expected="미래학교상담연구랩 이야기를 기다린다",
|
|
actual="보라별에게 바로 조언했다",
|
|
severity="moderate",
|
|
)
|
|
],
|
|
supervisor_rationale="보라별의 호소를 요약했다.",
|
|
supervisor_critique="미래학교상담연구랩과 010-1234-5678을 반복했다.",
|
|
alternative_utterances=["보라별님, 지금 감정부터 천천히 볼까요?"],
|
|
error="보라별 평가 경고",
|
|
)
|
|
|
|
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
|
|
session_id="session-privacy",
|
|
learner_id="learner-privacy",
|
|
result=result,
|
|
)
|
|
blob = json.dumps({"payload": write.payload, "error": write.error}, ensure_ascii=False)
|
|
|
|
self.assertEqual(write.status, "error")
|
|
for raw in ("보라별", "미래학교상담연구랩", "010-1234-5678"):
|
|
self.assertNotIn(raw, blob)
|
|
for masked in ("[NAME]", "[ORG]", "[PHONE]"):
|
|
self.assertIn(masked, blob)
|
|
|
|
def test_session_evaluation_write_role_tokenizes_known_identities(self) -> None:
|
|
result = evaluator.SessionEvaluation(
|
|
session_id="session-role-privacy",
|
|
stage="정리",
|
|
scope="session_end",
|
|
turns_evaluated=2,
|
|
strengths=["윤찬 상담자가 서연의 마음을 반영했다."],
|
|
improvements=["서연 씨의 반응을 윤찬이 한 번 더 기다린다."],
|
|
supervisor_rationale="윤찬과 서연의 상호작용을 확인했다.",
|
|
)
|
|
|
|
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
|
|
session_id="session-role-privacy",
|
|
learner_id="learner-role-privacy",
|
|
result=result,
|
|
counselor_identity="윤찬",
|
|
client_identity="서연(가명) · 고2 · 우울/자살사고",
|
|
)
|
|
blob = json.dumps(write.payload, ensure_ascii=False)
|
|
|
|
self.assertNotIn("윤찬", blob)
|
|
self.assertNotIn("서연", blob)
|
|
self.assertIn("[COUNSELOR]", blob)
|
|
self.assertIn("[CLIENT]", blob)
|
|
|
|
def test_session_evaluation_write_from_error_preserves_fallback_shape(self) -> None:
|
|
write = session_evaluation_repository.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_evaluation_repository.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_session_evaluation_write_from_error_masks_error_detail(self) -> None:
|
|
class FakeKoRecognizer:
|
|
def analyze(self, text: str):
|
|
start = text.find("보라별")
|
|
if start < 0:
|
|
return []
|
|
return [guardrail.PiiEntitySpan("NAME", start, start + len("보라별"))]
|
|
|
|
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
|
|
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
|
|
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
|
|
session_id="session-1",
|
|
learner_id="learner-1",
|
|
scope="session_end",
|
|
stage="explore",
|
|
error="보라별 처리 중 010-1234-5678 오류",
|
|
)
|
|
|
|
self.assertNotIn("보라별", write.error or "")
|
|
self.assertNotIn("010-1234-5678", write.error or "")
|
|
self.assertIn("[NAME]", write.error or "")
|
|
self.assertIn("[PHONE]", write.error or "")
|
|
|
|
async def test_save_session_evaluation_remasks_direct_write_payload(self) -> None:
|
|
class FakeKoRecognizer:
|
|
def analyze(self, text: str):
|
|
start = text.find("보라별")
|
|
if start < 0:
|
|
return []
|
|
return [guardrail.PiiEntitySpan("NAME", start, start + len("보라별"))]
|
|
|
|
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
|
|
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
|
|
conn = FakeEvaluationConn()
|
|
write = session_evaluation_repository.SessionEvaluationWrite(
|
|
session_id="11111111-1111-1111-1111-111111111111",
|
|
learner_id="22222222-2222-2222-2222-222222222222",
|
|
status="ready",
|
|
source="engine",
|
|
scope="session_end",
|
|
stage="정리",
|
|
payload={"strengths": ["보라별의 전화 010-1234-5678을 반복했다."]},
|
|
error="보라별 direct write error",
|
|
)
|
|
|
|
with (
|
|
patch.object(session_evaluation_repository, "get_pool", return_value=object()),
|
|
patch.object(session_evaluation_repository, "acquire", return_value=FakeAcquire(conn)),
|
|
):
|
|
saved = await session_evaluation_repository.save_session_evaluation(write)
|
|
|
|
self.assertTrue(saved)
|
|
_, args = conn.executed[0]
|
|
blob = json.dumps({"payload": args[5], "error": args[6]}, ensure_ascii=False)
|
|
self.assertNotIn("보라별", blob)
|
|
self.assertNotIn("010-1234-5678", blob)
|
|
self.assertIn("[NAME]", blob)
|
|
self.assertIn("[PHONE]", blob)
|
|
self.assertIn(
|
|
"WHERE app.session_evaluation.status <> 'ready'",
|
|
conn.executed[0][0],
|
|
)
|
|
|
|
def test_late_error_never_replaces_ready_evaluation_record(self) -> None:
|
|
ready = {"status": "ready", "payload": {"strengths": ["완료"]}}
|
|
error = {"status": "error", "error": "late timeout"}
|
|
|
|
self.assertFalse(
|
|
session_evaluation_repository._should_replace_evaluation_record(ready, error)
|
|
)
|
|
self.assertTrue(
|
|
session_evaluation_repository._should_replace_evaluation_record(error, ready)
|
|
)
|
|
self.assertTrue(
|
|
session_evaluation_repository._should_replace_evaluation_record(ready, ready)
|
|
)
|
|
|
|
async def test_fallback_cache_keeps_ready_result_when_late_error_arrives(self) -> None:
|
|
session_id = "11111111-1111-1111-1111-111111111111"
|
|
session_evaluation_repository._EVALUATION_CACHE[session_id] = {"status": "ready"}
|
|
self.addCleanup(session_evaluation_repository._EVALUATION_CACHE.pop, session_id, None)
|
|
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
|
|
session_id=session_id,
|
|
learner_id="22222222-2222-2222-2222-222222222222",
|
|
scope="session_end",
|
|
stage="정리",
|
|
error="late timeout",
|
|
)
|
|
|
|
with (
|
|
patch.object(session_evaluation_repository, "runtime_fallback_allowed", return_value=True),
|
|
patch.object(session_evaluation_repository, "get_pool", side_effect=RuntimeError("offline")),
|
|
patch.object(session_evaluation_repository, "require_runtime_fallback_allowed"),
|
|
):
|
|
saved = await session_evaluation_repository.save_session_evaluation(write)
|
|
|
|
self.assertFalse(saved)
|
|
self.assertEqual(session_evaluation_repository._EVALUATION_CACHE[session_id]["status"], "ready")
|
|
|
|
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)
|
|
self.assertIn("CREATE POLICY p_feedback_delete", sql)
|
|
self.assertIn("CREATE POLICY p_turn_technique_delete", sql)
|
|
self.assertIn("CREATE POLICY p_turn_client_state_delete", sql)
|
|
self.assertIn("CREATE POLICY p_supervisor_comment_delete", sql)
|
|
self.assertIn("CREATE POLICY p_alternative_utterance_delete", 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_missing_session_evaluation_finder_uses_stale_ai_context_query(self) -> None:
|
|
conn = FakeMissingEvaluationConn()
|
|
sentinel = object()
|
|
|
|
with (
|
|
patch.object(session_persistence, "get_pool", return_value=object()),
|
|
patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)) as acquire,
|
|
patch.object(session_persistence, "_session_from_rows", return_value=sentinel),
|
|
):
|
|
found, durable = await session_persistence.list_sessions_missing_session_evaluation(
|
|
older_than_seconds=150.0,
|
|
limit=3,
|
|
)
|
|
|
|
self.assertTrue(durable)
|
|
self.assertEqual(found, [sentinel])
|
|
acquire.assert_called_once_with(ai_context=True, ai_view="evaluator")
|
|
self.assertEqual(conn.fetches[0][1], (150.0, 3))
|
|
session_query = conn.fetches[0][0]
|
|
self.assertIn("s.ended_at IS NOT NULL", session_query)
|
|
self.assertIn("se.session_id IS NULL", session_query)
|
|
self.assertIn("'client' = ANY(t.visible_to)", session_query)
|
|
self.assertIn("ORDER BY s.ended_at ASC", session_query)
|
|
self.assertEqual(
|
|
conn.fetches[1][1],
|
|
(["11111111-1111-1111-1111-111111111111"],),
|
|
)
|
|
self.assertIn("FROM app.session_state", conn.fetches[1][0])
|
|
self.assertIn("FROM app.turns", conn.fetches[2][0])
|
|
|
|
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_replace_turn_evaluation_clears_stale_normalized_rows_before_reinsert(self) -> None:
|
|
conn = FakeEvaluationConn()
|
|
evaluation = {
|
|
"loop": "fast",
|
|
"turn_seq": 3,
|
|
"stage": "탐색",
|
|
"appropriateness": "pos",
|
|
"techniques": [
|
|
{"code": "reflection", "label_ko": "반영", "category": "relational"}
|
|
],
|
|
"client_state_read": [{"code": "open", "label_ko": "개방"}],
|
|
"alternative_utterances": ["조금 더 머물러도 괜찮습니다."],
|
|
}
|
|
|
|
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.replace_turn_evaluation(
|
|
turn_id="11111111-1111-1111-1111-111111111111",
|
|
evaluation=evaluation,
|
|
)
|
|
|
|
self.assertTrue(ok)
|
|
acquire.assert_called_once_with(ai_context=True, ai_view="evaluator")
|
|
executed_sql = "\n".join(query for query, _ in conn.executed)
|
|
self.assertLess(
|
|
executed_sql.index("DELETE FROM app.feedback_scores"),
|
|
executed_sql.index("INSERT INTO app.feedback_scores"),
|
|
)
|
|
self.assertIn("DELETE FROM app.turn_technique", executed_sql)
|
|
self.assertIn("DELETE FROM app.turn_client_state", executed_sql)
|
|
self.assertIn("DELETE FROM app.supervisor_comment", executed_sql)
|
|
self.assertIn("DELETE FROM app.alternative_utterance", 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.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()
|