평가 캐시와 개인정보 마스킹 보강
This commit is contained in:
parent
f0771db919
commit
6a81ec596c
11 changed files with 737 additions and 14 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
90
apps/api/app/services/pii_masking_eval.py
Normal file
90
apps/api/app/services/pii_masking_eval.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
70
apps/api/app/test_pii_masking_eval.py
Normal file
70
apps/api/app/test_pii_masking_eval.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue