세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등 - 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리 - 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치) - 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
This commit is contained in:
parent
7c41c3ce79
commit
778e8526d4
108 changed files with 6457 additions and 455 deletions
|
|
@ -3,13 +3,14 @@
|
|||
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_persistence
|
||||
from .routes import sessions
|
||||
from .services import evaluator, session_metrics
|
||||
from .services import evaluator, guardrail, session_metrics
|
||||
from .services import persona as persona_service
|
||||
from .services import state_machine
|
||||
from .store import InProcSession
|
||||
|
|
@ -33,8 +34,26 @@ class FakeEvaluationConn:
|
|||
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.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: FakeEvaluationConn) -> None:
|
||||
def __init__(self, conn) -> None:
|
||||
self.conn = conn
|
||||
|
||||
async def __aenter__(self) -> FakeEvaluationConn:
|
||||
|
|
@ -44,7 +63,7 @@ class FakeAcquire:
|
|||
return None
|
||||
|
||||
|
||||
class EvaluationPersistenceMappingTest(unittest.TestCase):
|
||||
class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
|
||||
def test_session_metrics_prefers_rehydrated_technique_label_ko(self) -> None:
|
||||
ev = {
|
||||
"techniques": [
|
||||
|
|
@ -137,6 +156,91 @@ class EvaluationPersistenceMappingTest(unittest.TestCase):
|
|||
],
|
||||
)
|
||||
|
||||
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",
|
||||
|
|
@ -159,6 +263,55 @@ class EvaluationPersistenceMappingTest(unittest.TestCase):
|
|||
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_persistence.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_from_error_preserves_fallback_shape(self) -> None:
|
||||
write = session_persistence.SessionEvaluationWrite.from_error(
|
||||
session_id="session-1",
|
||||
|
|
@ -186,6 +339,65 @@ class EvaluationPersistenceMappingTest(unittest.TestCase):
|
|||
|
||||
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_persistence.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_persistence.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_persistence, "get_pool", return_value=object()),
|
||||
patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)),
|
||||
):
|
||||
saved = await session_persistence.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)
|
||||
|
||||
def test_rebuild_turn_evaluation_restores_review_shape(self) -> None:
|
||||
rebuilt = session_persistence._rebuild_turn_evaluations(
|
||||
[("11111111-1111-1111-1111-111111111111", 2, "탐색")],
|
||||
|
|
@ -270,6 +482,11 @@ class EvaluationPersistenceMappingTest(unittest.TestCase):
|
|||
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)
|
||||
|
||||
|
|
@ -281,6 +498,32 @@ class EvaluationPersistenceMappingTest(unittest.TestCase):
|
|||
|
||||
|
||||
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.fetchrows[0][1], ("11111111-1111-1111-1111-111111111111",))
|
||||
self.assertIn("FROM app.turns", conn.fetches[1][0])
|
||||
|
||||
async def test_record_llm_call_audit_inserts_metadata_only(self) -> None:
|
||||
conn = FakeEvaluationConn()
|
||||
payload = {
|
||||
|
|
@ -366,6 +609,44 @@ class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase):
|
|||
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue