평가 캐시와 개인정보 마스킹 보강

This commit is contained in:
Yun Chan 2026-06-28 20:12:20 +09:00
parent f0771db919
commit 6a81ec596c
11 changed files with 737 additions and 14 deletions

View file

@ -92,6 +92,18 @@ class Settings(BaseSettings):
default="",
validation_alias="EVALUATOR_DEEP_MODEL",
)
evaluator_semantic_cache_enabled: bool = Field(
default=True,
validation_alias="EVALUATOR_SEMANTIC_CACHE_ENABLED",
)
evaluator_semantic_cache_ttl_seconds: int = Field(
default=900,
validation_alias="EVALUATOR_SEMANTIC_CACHE_TTL_SECONDS",
)
evaluator_semantic_cache_max_entries: int = Field(
default=256,
validation_alias="EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES",
)
# ── 외부 LLM 키 (게이트웨이가 못 받을 때 직접 폴백, PII 마스킹 후만) ──
anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY")

View file

@ -24,9 +24,11 @@ MASTERPLAN §2.3 (평가 AI 2-tier 루프):
from __future__ import annotations
import hashlib
import json
import os
import time
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Optional
from pydantic import BaseModel, Field
@ -48,6 +50,7 @@ from ..taxonomy import (
Technique,
TechniqueCategory,
)
from . import guardrail
if TYPE_CHECKING: # 런타임 import 회피(순환·소유권 경계). 타입 힌트 전용.
from .orchestrator import LlmAuditHook, TurnContext
@ -67,12 +70,100 @@ _APPROPRIATENESS = ("pos", "warn", "neutral")
# 의도이탈 심각도 (taxonomy.SupervisorComment.severity 와 동일 어휘).
_SEVERITY = ("minor", "moderate", "major")
_EVALUATOR_CACHE_VERSION = "evaluator-semantic-cache-v1"
_EVALUATOR_CACHE: "OrderedDict[str, tuple[float, dict[str, Any]]]" = OrderedDict()
_EVALUATOR_CACHE_STATS = {
"hits": 0,
"misses": 0,
"stores": 0,
"evictions": 0,
}
def _configured_model(value: str | None) -> str | None:
model = (value or "").strip()
return model or None
def clear_evaluator_semantic_cache() -> None:
"""Clear in-process evaluator cache and counters. Test/support hook only."""
_EVALUATOR_CACHE.clear()
for key in _EVALUATOR_CACHE_STATS:
_EVALUATOR_CACHE_STATS[key] = 0
def evaluator_semantic_cache_stats() -> dict[str, int]:
"""Return in-process evaluator cache counters without exposing keys."""
stats = dict(_EVALUATOR_CACHE_STATS)
stats["entries"] = len(_EVALUATOR_CACHE)
return stats
def _semantic_cache_enabled() -> bool:
return (
bool(settings.evaluator_semantic_cache_enabled)
and settings.evaluator_semantic_cache_ttl_seconds > 0
and settings.evaluator_semantic_cache_max_entries > 0
)
def _canonical_json(value: Any) -> str:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
def _evaluator_cache_key(req: GenerateRequest) -> str:
payload = {
"version": _EVALUATOR_CACHE_VERSION,
"ai_role": req.ai_role,
"messages": [m.model_dump() for m in req.messages],
"model": req.model or "gateway-default",
"max_tokens": req.max_tokens,
"temperature": req.temperature,
"structured_schema": req.structured_schema,
"session_id": req.session_id,
"metadata": req.metadata,
}
return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest()
def _evaluator_cache_get(cache_key: str) -> Optional[dict[str, Any]]:
if not _semantic_cache_enabled():
return None
now = time.monotonic()
entry = _EVALUATOR_CACHE.get(cache_key)
if entry is None:
_EVALUATOR_CACHE_STATS["misses"] += 1
return None
expires_at, value = entry
if expires_at <= now:
_EVALUATOR_CACHE.pop(cache_key, None)
_EVALUATOR_CACHE_STATS["evictions"] += 1
_EVALUATOR_CACHE_STATS["misses"] += 1
return None
_EVALUATOR_CACHE.move_to_end(cache_key)
_EVALUATOR_CACHE_STATS["hits"] += 1
return json.loads(_canonical_json(value))
def _evaluator_cache_put(cache_key: str, value: dict[str, Any]) -> None:
if not _semantic_cache_enabled():
return
now = time.monotonic()
ttl = float(settings.evaluator_semantic_cache_ttl_seconds)
_EVALUATOR_CACHE[cache_key] = (now + ttl, json.loads(_canonical_json(value)))
_EVALUATOR_CACHE.move_to_end(cache_key)
_EVALUATOR_CACHE_STATS["stores"] += 1
max_entries = int(settings.evaluator_semantic_cache_max_entries)
while len(_EVALUATOR_CACHE) > max_entries:
_EVALUATOR_CACHE.popitem(last=False)
_EVALUATOR_CACHE_STATS["evictions"] += 1
def _parse_technique(raw: str) -> Optional[Technique]:
s = (raw or "").strip()
return _TECHNIQUE_BY_KO.get(s) or _TECHNIQUE_BY_CODE.get(s)
@ -386,6 +477,7 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
"""fast-loop 평가 프롬프트(L0 역할 + 후보 라벨 + 이번 턴 맥락)."""
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
client_reply_masked = guardrail.mask_pii(client_reply).text_masked
recent = "\n".join(
f"{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}"
for t in (ctx.recent_turns or [])[-4:]
@ -423,7 +515,7 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
+ crisis_note
+ f"\n\n[직전 맥락]\n{recent}\n\n"
f"[평가 대상 — 상담자(학습자) 발화]\n{ctx.learner_text_masked}\n\n"
f"[이어진 내담자 응답]\n{client_reply}\n\n"
f"[이어진 내담자 응답]\n{client_reply_masked}\n\n"
"위 4차원으로 구조화 평가하라. 후보 code 외 라벨 금지, 각 판단에 rationale 첨부."
)
return [
@ -653,6 +745,10 @@ async def evaluate_turn(
session_id=ctx.session_id,
metadata={"loop": "fast", "stage": st.stage.value, "turn_seq": st.turn_seq},
)
cache_key = _evaluator_cache_key(req)
cached = _evaluator_cache_get(cache_key)
if cached is not None:
return TurnEvaluation.model_validate(cached)
started = time.perf_counter()
resp = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
@ -679,7 +775,9 @@ async def evaluate_turn(
base.error = "no_structured_output"
return base
try:
return _parse_fast(payload, turn_seq=st.turn_seq, stage=st.stage.value, theory=theory)
result = _parse_fast(payload, turn_seq=st.turn_seq, stage=st.stage.value, theory=theory)
_evaluator_cache_put(cache_key, result.model_dump())
return result
except Exception as e: # 파싱 방어
base.error = f"parse_error: {e}"
return base
@ -730,6 +828,10 @@ async def evaluate_session(
session_id=session_id,
metadata={"loop": "deep", "scope": scope, "stage": stage},
)
cache_key = _evaluator_cache_key(req)
cached = _evaluator_cache_get(cache_key)
if cached is not None:
return SessionEvaluation.model_validate(cached)
started = time.perf_counter()
resp = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
@ -767,6 +869,7 @@ async def evaluate_session(
dev = _parse_intent_deviation(d)
if dev is not None:
base.intent_deviations.append(dev)
_evaluator_cache_put(cache_key, base.model_dump())
return base
@ -817,4 +920,6 @@ __all__ = [
"make_eval_hook",
"build_fast_messages",
"build_deep_messages",
"clear_evaluator_semantic_cache",
"evaluator_semantic_cache_stats",
]

View file

@ -36,7 +36,79 @@ CRISIS_RESOURCE_MESSAGE = (
# ════════════════════════════════════════════════════════════════════════════
# 정규식 폴백 패턴 (Presidio 미설치 시). 한국 맥락 우선.
# TODO: Presidio + MedicalNERRecognizer 로 정밀화(이름/주소/기관 NER).
_KOREAN_SURNAME_CHARS = (
"김이박최정강조윤장임한오서신권황안송전홍유고문양손배백허남심노하"
"곽성차주우구민류나진지엄채원천방공현함변염여추도소석선설마길연위표"
"명기반왕금옥육인맹제모탁국어은편용예봉경"
)
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
_KOREAN_NAME_STOPWORDS = {
"연락",
"이야기",
"생각",
"마음",
"기분",
"상담",
"학교",
"엄마",
"아빠",
"어머니",
"아버지",
"친구",
"내담자",
"상담자",
"선생님",
"소속",
"안내",
}
_PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
# 한국어 기관/소속명: 학교·병원·센터·학과 등 명시 suffix가 있는 경우만 보수적으로 마스킹.
(
"ORG",
re.compile(
r"(?<![가-힣A-Za-z0-9])"
r"(?P<value>[가-힣A-Za-z0-9·&().-]{2,30}?"
r"(?:대학교|대학원|고등학교|중학교|초등학교|병원|의원|클리닉|상담센터|센터|복지관|교육청|보건소|연구소|재단|협회|학과|학부))"
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요|에서|에|의|은|는|이|가|을|를)?)"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: 이름/성명/실명 라벨 뒤 값.
(
"NAME",
re.compile(
r"(?P<prefix>(?:이름|성명|실명|본명)\s*[:]\s*)"
r"(?P<value>[가-힣]{2,4})"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: 역할/관계 명사 뒤에 붙은 인명 + 조사/호칭.
(
"NAME",
re.compile(
r"(?P<prefix>(?:내담자|상담자|학생|보호자|담임|교수|선생님|친구|엄마|아빠|어머니|아버지|동생|언니|오빠|형|누나)\s+)"
rf"(?P<value>{_KOREAN_FULL_NAME})"
r"(?P<suffix>\s*(?:님|씨|학생|상담자|내담자)?"
r"(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는|입니다|이에요|예요|이고|이고요))"
),
),
# 한국어 이름: 성씨 기반 full-name + 조사. 문맥 없는 순수 2~4글자 마스킹은 오탐이 커서 피한다.
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME})"
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
),
),
# 한국어 이름: "김서연 씨", "박민수님" 같은 명시 호칭.
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME})"
r"(?P<suffix>\s?(?:씨|님)(?:은|는|이|가|을|를|와|과|에게|한테|고|이고|인데)?)"
r"(?=$|[\s,.;!?。])"
),
),
# 주민등록번호 (6자리-7자리)
("RRN", re.compile(r"\b\d{6}[-\s]?\d{7}\b")),
# 휴대폰 (010-1234-5678 등)
@ -53,7 +125,6 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("MONEY", re.compile(r"\d{1,3}(?:,\d{3})+\s?원|\d{3,}\s?원")),
# 한국 주소 단편: ○○시/도 ○○시/군/구 ○○동/읍/면/로/길 (행정구역 연쇄)
("ADDR", re.compile(r"[가-힣]{2,}(?:시|도)\s?[가-힣]{1,4}(?:시|군|구)\s?[가-힣0-9]{1,}(?:동|읍|면|로|길)")),
# TODO(NER): 한국어 이름/기관명은 Presidio ko 모델/NER 필요(정규식 false-positive 위험).
]
# Presidio 지연 로드 캐시 (-1=미시도, None=미설치, 객체=설치됨)
@ -85,6 +156,30 @@ class MaskResult:
used_presidio: bool = False
def _mask_regex_pii(text: str) -> tuple[str, list[str]]:
masked = text
found: list[str] = []
def replace_match(label: str):
def _replace(match: re.Match[str]) -> str:
group = match.groupdict().get("value")
if group is None:
found.append(label)
return f"[{label}]"
if label == "NAME" and group in _KOREAN_NAME_STOPWORDS:
return match.group(0)
value_start = match.start("value") - match.start(0)
value_end = match.end("value") - match.start(0)
found.append(label)
return f"{match.group(0)[:value_start]}[{label}]{match.group(0)[value_end:]}"
return _replace
for label, pat in _PII_PATTERNS:
masked = pat.sub(replace_match(label), masked)
return masked, sorted(set(found))
def mask_pii(text: str) -> MaskResult:
"""PII 마스킹. Presidio 가용 시 우선, 아니면 정규식 폴백.
@ -99,18 +194,18 @@ def mask_pii(text: str) -> MaskResult:
results = analyzer.analyze(text=text, language="en") # TODO: ko 모델 등록 시 language="ko"
ents = sorted({r.entity_type for r in results})
anonymized = anonymizer.anonymize(text=text, analyzer_results=results)
return MaskResult(text_masked=anonymized.text, entities=ents, used_presidio=True)
masked, regex_ents = _mask_regex_pii(anonymized.text)
return MaskResult(
text_masked=masked,
entities=sorted(set(ents + regex_ents)),
used_presidio=True,
)
except Exception:
pass # 폴백으로
# 정규식 폴백
masked = text
found: list[str] = []
for label, pat in _PII_PATTERNS:
if pat.search(masked):
found.append(label)
masked = pat.sub(f"[{label}]", masked)
return MaskResult(text_masked=masked, entities=sorted(set(found)), used_presidio=False)
masked, found = _mask_regex_pii(text)
return MaskResult(text_masked=masked, entities=found, used_presidio=False)
# ════════════════════════════════════════════════════════════════════════════

View file

@ -0,0 +1,90 @@
"""PII masking evaluation helpers for local regression fixtures."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping
from . import guardrail
MaskFunc = Callable[[str], guardrail.MaskResult]
def load_cases(path: Path) -> list[dict[str, Any]]:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, list):
raise ValueError("PII masking fixture must be a list")
return [dict(item) for item in data]
def evaluate_case(case: Mapping[str, Any], *, mask_func: MaskFunc = guardrail.mask_pii) -> dict[str, Any]:
case_id = str(case.get("id") or "")
text = str(case.get("text") or "")
result = mask_func(text)
entities = set(result.entities)
expected_entities = {str(item) for item in case.get("expected_entities") or []}
unexpected_entities = {str(item) for item in case.get("unexpected_entities") or []}
forbidden_substrings = [str(item) for item in case.get("forbidden_substrings") or []]
required_substrings = [str(item) for item in case.get("required_substrings") or []]
missing_entities = sorted(expected_entities - entities)
unexpected_detected = sorted(unexpected_entities & entities)
forbidden_remaining = [item for item in forbidden_substrings if item and item in result.text_masked]
required_missing = [item for item in required_substrings if item and item not in result.text_masked]
passed = not (missing_entities or unexpected_detected or forbidden_remaining or required_missing)
return {
"id": case_id,
"passed": passed,
"entities": sorted(entities),
"masked_text": result.text_masked,
"missing_entities": missing_entities,
"unexpected_entities": unexpected_detected,
"forbidden_remaining": forbidden_remaining,
"required_missing": required_missing,
}
def evaluate_cases(
cases: Iterable[Mapping[str, Any]],
*,
mask_func: MaskFunc = guardrail.mask_pii,
) -> dict[str, Any]:
case_list = list(cases)
results = [evaluate_case(case, mask_func=mask_func) for case in case_list]
total_expected_entities = 0
matched_expected_entities = 0
total_forbidden = 0
removed_forbidden = 0
unexpected_violations = 0
for case, result in zip(case_list, results):
expected_entities = {str(item) for item in case.get("expected_entities") or []}
forbidden = [str(item) for item in case.get("forbidden_substrings") or []]
total_expected_entities += len(expected_entities)
matched_expected_entities += len(expected_entities) - len(result["missing_entities"])
total_forbidden += len(forbidden)
removed_forbidden += len(forbidden) - len(result["forbidden_remaining"])
unexpected_violations += len(result["unexpected_entities"])
passed_cases = sum(1 for result in results if result["passed"])
return {
"passed": passed_cases == len(results),
"cases_total": len(results),
"cases_passed": passed_cases,
"cases_failed": len(results) - passed_cases,
"expected_entity_recall": _ratio(matched_expected_entities, total_expected_entities),
"forbidden_substring_removal": _ratio(removed_forbidden, total_forbidden),
"unexpected_entity_violations": unexpected_violations,
"results": results,
}
def evaluate_fixture(path: Path, *, mask_func: MaskFunc = guardrail.mask_pii) -> dict[str, Any]:
return evaluate_cases(load_cases(path), mask_func=mask_func)
def _ratio(numerator: int, denominator: int) -> float:
if denominator <= 0:
return 1.0
return round(numerator / denominator, 4)

View file

@ -9,6 +9,7 @@ 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
@ -44,6 +45,29 @@ class FakeAcquire:
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",

View file

@ -6,7 +6,7 @@ import unittest
from typing import Any
from .config import settings
from .engine_client import GenerateResponse
from .engine_client import EngineError, GenerateResponse
from .services import evaluator, orchestrator, persona, state_machine
@ -57,16 +57,36 @@ class CaptureEvaluatorEngine:
)
class FailingEvaluatorEngine:
def __init__(self) -> None:
self.requests: list[Any] = []
async def generate(self, req: Any) -> GenerateResponse:
self.requests.append(req)
raise EngineError("synthetic evaluator failure")
class EvaluatorModelRoutingTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self._fast_model = settings.evaluator_fast_model
self._deep_model = settings.evaluator_deep_model
self._cache_enabled = settings.evaluator_semantic_cache_enabled
self._cache_ttl = settings.evaluator_semantic_cache_ttl_seconds
self._cache_max_entries = settings.evaluator_semantic_cache_max_entries
settings.evaluator_fast_model = ""
settings.evaluator_deep_model = ""
settings.evaluator_semantic_cache_enabled = True
settings.evaluator_semantic_cache_ttl_seconds = 900
settings.evaluator_semantic_cache_max_entries = 256
evaluator.clear_evaluator_semantic_cache()
async def asyncTearDown(self) -> None:
settings.evaluator_fast_model = self._fast_model
settings.evaluator_deep_model = self._deep_model
settings.evaluator_semantic_cache_enabled = self._cache_enabled
settings.evaluator_semantic_cache_ttl_seconds = self._cache_ttl
settings.evaluator_semantic_cache_max_entries = self._cache_max_entries
evaluator.clear_evaluator_semantic_cache()
async def test_fast_evaluator_uses_configured_model_override(self) -> None:
settings.evaluator_fast_model = "cheap-fast"
@ -115,3 +135,126 @@ class EvaluatorModelRoutingTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(engine.requests), 1)
self.assertIsNone(engine.requests[0].model)
async def test_fast_evaluator_reuses_semantic_cache_for_identical_prompt(self) -> None:
engine = CaptureEvaluatorEngine()
ctx = _turn_context()
first = await evaluator.evaluate_turn(
ctx,
"괜찮아요.",
engine=engine, # type: ignore[arg-type]
)
second = await evaluator.evaluate_turn(
ctx,
"괜찮아요.",
engine=engine, # type: ignore[arg-type]
)
self.assertIsNone(first.error)
self.assertIsNone(second.error)
self.assertEqual(len(engine.requests), 1)
stats = evaluator.evaluator_semantic_cache_stats()
self.assertEqual(stats["misses"], 1)
self.assertEqual(stats["hits"], 1)
self.assertEqual(stats["stores"], 1)
self.assertEqual(stats["entries"], 1)
async def test_deep_evaluator_reuses_semantic_cache_for_identical_prompt(self) -> None:
engine = CaptureEvaluatorEngine()
masked_turns = [
{"speaker": "counselor", "text": "천천히 이야기해줘도 괜찮아요."},
{"speaker": "client", "text": "잘 모르겠어요."},
]
first = await evaluator.evaluate_session(
session_id="evaluator-model-session",
stage="라포",
masked_turns=masked_turns,
engine=engine, # type: ignore[arg-type]
)
second = await evaluator.evaluate_session(
session_id="evaluator-model-session",
stage="라포",
masked_turns=masked_turns,
engine=engine, # type: ignore[arg-type]
)
self.assertIsNone(first.error)
self.assertIsNone(second.error)
self.assertEqual(len(engine.requests), 1)
stats = evaluator.evaluator_semantic_cache_stats()
self.assertEqual(stats["misses"], 1)
self.assertEqual(stats["hits"], 1)
self.assertEqual(stats["stores"], 1)
async def test_semantic_cache_key_separates_model_override(self) -> None:
engine = CaptureEvaluatorEngine()
settings.evaluator_fast_model = "cheap-fast-a"
await evaluator.evaluate_turn(
_turn_context(),
"괜찮아요.",
engine=engine, # type: ignore[arg-type]
)
settings.evaluator_fast_model = "cheap-fast-b"
await evaluator.evaluate_turn(
_turn_context(),
"괜찮아요.",
engine=engine, # type: ignore[arg-type]
)
self.assertEqual(len(engine.requests), 2)
self.assertEqual(engine.requests[0].model, "cheap-fast-a")
self.assertEqual(engine.requests[1].model, "cheap-fast-b")
stats = evaluator.evaluator_semantic_cache_stats()
self.assertEqual(stats["misses"], 2)
self.assertEqual(stats["hits"], 0)
async def test_semantic_cache_hit_does_not_record_second_audit_event(self) -> None:
engine = CaptureEvaluatorEngine()
audit_payloads: list[dict[str, Any]] = []
async def audit_hook(payload: dict[str, Any]) -> None:
audit_payloads.append(payload)
await evaluator.evaluate_turn(
_turn_context(),
"괜찮아요.",
engine=engine, # type: ignore[arg-type]
audit_hook=audit_hook,
)
await evaluator.evaluate_turn(
_turn_context(),
"괜찮아요.",
engine=engine, # type: ignore[arg-type]
audit_hook=audit_hook,
)
self.assertEqual(len(engine.requests), 1)
self.assertEqual(len(audit_payloads), 1)
self.assertEqual(audit_payloads[0]["provider"], "fake-provider")
stats = evaluator.evaluator_semantic_cache_stats()
self.assertEqual(stats["hits"], 1)
async def test_engine_error_is_not_cached(self) -> None:
engine = FailingEvaluatorEngine()
first = await evaluator.evaluate_turn(
_turn_context(),
"괜찮아요.",
engine=engine, # type: ignore[arg-type]
)
second = await evaluator.evaluate_turn(
_turn_context(),
"괜찮아요.",
engine=engine, # type: ignore[arg-type]
)
self.assertIn("engine_error", first.error or "")
self.assertIn("engine_error", second.error or "")
self.assertEqual(len(engine.requests), 2)
stats = evaluator.evaluator_semantic_cache_stats()
self.assertEqual(stats["hits"], 0)
self.assertEqual(stats["stores"], 0)
self.assertEqual(stats["misses"], 2)

View file

@ -7,6 +7,7 @@ import unittest
from typing import Any
from unittest.mock import patch
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .engine_client import EngineClient, GenerateResponse
from .services import guardrail, orchestrator, persona, state_machine
@ -17,6 +18,15 @@ RAW_RRN = "990101-1234567"
RAW_TEXT = f"My phone is {RAW_PHONE}, email {RAW_EMAIL}, and RRN {RAW_RRN}."
RAW_VALUES = (RAW_PHONE, RAW_EMAIL, RAW_RRN)
MASK_VALUES = ("[PHONE]", "[EMAIL]", "[RRN]")
RAW_KO_NAME = "김서연"
RAW_KO_ORG = "한신대학교"
RAW_KO_DEPT = "상담심리학과"
RAW_KO_TEXT = (
f"내담자 {RAW_KO_NAME}{RAW_KO_ORG} {RAW_KO_DEPT} 학생이고 "
"연락은 하지 말아 주세요."
)
RAW_KO_VALUES = (RAW_KO_NAME, RAW_KO_ORG, RAW_KO_DEPT)
MASK_KO_VALUES = ("[NAME]", "[ORG]")
def _json_blob(value: Any) -> str:
@ -62,6 +72,18 @@ def _assert_masked_pii_present(test: unittest.TestCase, value: object) -> None:
test.assertIn(masked, blob)
def _assert_no_raw_ko_pii(test: unittest.TestCase, value: object) -> None:
blob = _json_blob(value)
for raw in RAW_KO_VALUES:
test.assertNotIn(raw, blob)
def _assert_masked_ko_pii_present(test: unittest.TestCase, value: object) -> None:
blob = _json_blob(value)
for masked in MASK_KO_VALUES:
test.assertIn(masked, blob)
class CaptureGenerateEngine:
def __init__(self) -> None:
self.request = None
@ -101,6 +123,13 @@ class CaptureStreamEngine:
'"tokens_in":5,"tokens_out":6,"cost_usd":0.0}'
)
async def stream_packets(self, req):
decoder = EngineGatewaySseLineDecoder()
async for raw in self.stream(req):
packet = decoder.feed_line(raw)
if packet is not None:
yield packet
class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
@ -145,6 +174,47 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
for masked in MASK_VALUES:
self.assertIn(masked, blob)
def test_mask_pii_masks_korean_name_and_institution_context(self) -> None:
masked = guardrail.mask_pii(
f"이름: {RAW_KO_NAME}, 소속은 {RAW_KO_ORG} {RAW_KO_DEPT}입니다."
)
self.assertFalse(masked.used_presidio)
self.assertIn("NAME", masked.entities)
self.assertIn("ORG", masked.entities)
for raw in RAW_KO_VALUES:
self.assertNotIn(raw, masked.text_masked)
self.assertIn("[NAME]", masked.text_masked)
self.assertGreaterEqual(masked.text_masked.count("[ORG]"), 2)
def test_mask_pii_does_not_mask_common_korean_context_words_as_names(self) -> None:
masked = guardrail.mask_pii("학교 가는 게 힘들고 엄마랑 친구 이야기를 하면 불안해요.")
self.assertEqual(masked.text_masked, "학교 가는 게 힘들고 엄마랑 친구 이야기를 하면 불안해요.")
self.assertNotIn("NAME", masked.entities)
self.assertNotIn("ORG", masked.entities)
def test_prepare_turn_masks_korean_pii_from_engine_messages(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text=RAW_KO_TEXT,
recall_summary=f"지난 회기 요약에 {RAW_KO_NAME}{RAW_KO_ORG}가 남아 있었다.",
pinned_facts=[f"소속 {RAW_KO_DEPT}"],
recent_turns=[
{"speaker": "counselor", "text": f"{RAW_KO_NAME} 씨가 상담실에 왔다."},
],
)
blob = _message_blob(ctx.messages)
_assert_no_raw_ko_pii(self, blob)
_assert_masked_ko_pii_present(self, blob)
for raw in RAW_KO_VALUES:
self.assertIn(raw, ctx.learner_text_raw)
self.assertNotIn(raw, ctx.learner_text_masked)
def test_prepare_turn_threads_theory_mode_into_engine_messages(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="theory-session",
@ -220,6 +290,34 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
for key in ("messages", "prompt", "text"):
self.assertNotIn(key, audit_payloads[0])
async def test_run_turn_generate_sends_only_masked_korean_pii(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text=RAW_KO_TEXT,
)
engine = CaptureGenerateEngine()
audit_payloads: list[dict[str, Any]] = []
async def audit_hook(payload: dict[str, Any]) -> None:
audit_payloads.append(payload)
await orchestrator.run_turn_generate(
ctx,
engine, # type: ignore[arg-type]
audit_hook=audit_hook,
)
self.assertIsNotNone(engine.request)
self.assertIsNotNone(engine.payload)
_assert_no_raw_ko_pii(self, engine.request.messages)
_assert_no_raw_ko_pii(self, engine.payload)
_assert_masked_ko_pii_present(self, engine.request.messages)
_assert_masked_ko_pii_present(self, engine.payload)
_assert_no_raw_ko_pii(self, audit_payloads)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,70 @@
import json
import subprocess
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
from app.services import guardrail
from app.services.pii_masking_eval import evaluate_fixture, load_cases
REPO_ROOT = Path(__file__).resolve().parents[3]
FIXTURE_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-ko-fixtures.json"
SCRIPT_PATH = REPO_ROOT / "scripts" / "evaluate-pii-masking.py"
class PiiMaskingEvalTests(unittest.TestCase):
def setUp(self) -> None:
self.presidio_patch = patch.object(
guardrail,
"_try_load_presidio",
return_value=(None, None),
)
self.presidio_patch.start()
self.addCleanup(self.presidio_patch.stop)
def test_fixture_cases_are_valid_json_list(self) -> None:
cases = load_cases(FIXTURE_PATH)
self.assertGreaterEqual(len(cases), 5)
self.assertTrue(all(case.get("id") for case in cases))
self.assertTrue(all(case.get("text") for case in cases))
def test_ko_name_org_fixture_passes_without_raw_identifier_leak(self) -> None:
report = evaluate_fixture(FIXTURE_PATH)
self.assertTrue(report["passed"], report)
self.assertEqual(report["cases_failed"], 0)
self.assertEqual(report["expected_entity_recall"], 1.0)
self.assertEqual(report["forbidden_substring_removal"], 1.0)
self.assertEqual(report["unexpected_entity_violations"], 0)
blob = json.dumps(report, ensure_ascii=False)
for raw in ("김서연", "박민수", "한신대학교", "상담심리학과", "마음봄상담센터"):
self.assertNotIn(raw, blob)
def test_cli_reports_json_and_nonzero_gate_shape(self) -> None:
completed = subprocess.run(
[
sys.executable,
"-X",
"utf8",
str(SCRIPT_PATH),
"--fixtures",
str(FIXTURE_PATH),
"--json",
],
cwd=str(REPO_ROOT),
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
report = json.loads(completed.stdout)
self.assertTrue(report["passed"])
self.assertEqual(report["cases_total"], 5)
if __name__ == "__main__":
unittest.main()

View file

@ -13,7 +13,7 @@ from typing import Optional
from . import db, session_persistence
from .deps import Principal
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .services import orchestrator, state_machine
from .services import guardrail, orchestrator, state_machine
from .store import InProcSession, TurnRecord, store
_STAGE_LABELS = {
@ -130,6 +130,7 @@ async def record_completed_turn(
context=f"{context_prefix} turn append",
)
if result.client_reply:
client_mask = guardrail.mask_pii(result.client_reply)
await append_completed_turn(
sess,
TurnRecord(
@ -137,7 +138,7 @@ async def record_completed_turn(
speaker="client",
stage=stage_label(result.state_after.stage),
text=result.client_reply,
text_masked=result.client_reply,
text_masked=client_mask.text_masked,
llm_provider=result.llm_provider,
model=result.model,
tokens_in=result.tokens_in,