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

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

@ -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)