diff --git a/apps/api/app/config.py b/apps/api/app/config.py index afae5ab..b931c55 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -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") diff --git a/apps/api/app/services/evaluator.py b/apps/api/app/services/evaluator.py index 9385b67..fb11e4f 100644 --- a/apps/api/app/services/evaluator.py +++ b/apps/api/app/services/evaluator.py @@ -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", ] diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py index d24a4a2..bc0560a 100644 --- a/apps/api/app/services/guardrail.py +++ b/apps/api/app/services/guardrail.py @@ -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·&().-]{2,30}?" + r"(?:대학교|대학원|고등학교|중학교|초등학교|병원|의원|클리닉|상담센터|센터|복지관|교육청|보건소|연구소|재단|협회|학과|학부))" + r"(?P\s*(?:입니다|이에요|예요|이고|이고요|에서|에|의|은|는|이|가|을|를)?)" + r"(?=$|[\s,.;!?。])" + ), + ), + # 한국어 이름: 이름/성명/실명 라벨 뒤 값. + ( + "NAME", + re.compile( + r"(?P(?:이름|성명|실명|본명)\s*[::]\s*)" + r"(?P[가-힣]{2,4})" + r"(?=$|[\s,.;!?。])" + ), + ), + # 한국어 이름: 역할/관계 명사 뒤에 붙은 인명 + 조사/호칭. + ( + "NAME", + re.compile( + r"(?P(?:내담자|상담자|학생|보호자|담임|교수|선생님|친구|엄마|아빠|어머니|아버지|동생|언니|오빠|형|누나)\s+)" + rf"(?P{_KOREAN_FULL_NAME})" + r"(?P\s*(?:님|씨|학생|상담자|내담자)?" + r"(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는|입니다|이에요|예요|이고|이고요))" + ), + ), + # 한국어 이름: 성씨 기반 full-name + 조사. 문맥 없는 순수 2~4글자 마스킹은 오탐이 커서 피한다. + ( + "NAME", + re.compile( + rf"(?{_KOREAN_FULL_NAME})" + r"(?P(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))" + ), + ), + # 한국어 이름: "김서연 씨", "박민수님" 같은 명시 호칭. + ( + "NAME", + re.compile( + rf"(?{_KOREAN_FULL_NAME})" + r"(?P\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) # ════════════════════════════════════════════════════════════════════════════ diff --git a/apps/api/app/services/pii_masking_eval.py b/apps/api/app/services/pii_masking_eval.py new file mode 100644 index 0000000..93726c4 --- /dev/null +++ b/apps/api/app/services/pii_masking_eval.py @@ -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) diff --git a/apps/api/app/test_evaluation_persistence.py b/apps/api/app/test_evaluation_persistence.py index 758342d..c8e3f79 100644 --- a/apps/api/app/test_evaluation_persistence.py +++ b/apps/api/app/test_evaluation_persistence.py @@ -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", diff --git a/apps/api/app/test_evaluator_model_routing.py b/apps/api/app/test_evaluator_model_routing.py index e59dee5..1854de7 100644 --- a/apps/api/app/test_evaluator_model_routing.py +++ b/apps/api/app/test_evaluator_model_routing.py @@ -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) diff --git a/apps/api/app/test_orchestrator_masking.py b/apps/api/app/test_orchestrator_masking.py index 3fddcbd..c8ac4b4 100644 --- a/apps/api/app/test_orchestrator_masking.py +++ b/apps/api/app/test_orchestrator_masking.py @@ -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() diff --git a/apps/api/app/test_pii_masking_eval.py b/apps/api/app/test_pii_masking_eval.py new file mode 100644 index 0000000..d1e6de2 --- /dev/null +++ b/apps/api/app/test_pii_masking_eval.py @@ -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() diff --git a/apps/api/app/turn_runtime.py b/apps/api/app/turn_runtime.py index 1b62ba1..1e80a61 100644 --- a/apps/api/app/turn_runtime.py +++ b/apps/api/app/turn_runtime.py @@ -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, diff --git a/data/privacy/pii-masking-ko-fixtures.json b/data/privacy/pii-masking-ko-fixtures.json new file mode 100644 index 0000000..37cf633 --- /dev/null +++ b/data/privacy/pii-masking-ko-fixtures.json @@ -0,0 +1,38 @@ +[ + { + "id": "ko_name_label", + "text": "이름: 김서연, 연락은 하지 말아 주세요.", + "expected_entities": ["NAME"], + "forbidden_substrings": ["김서연"], + "required_substrings": ["[NAME]"] + }, + { + "id": "ko_name_relationship_context", + "text": "친구 박민수에게 오늘 상담 내용을 말하지 않았어요.", + "expected_entities": ["NAME"], + "forbidden_substrings": ["박민수"], + "required_substrings": ["친구 [NAME]에게"] + }, + { + "id": "ko_org_school_department", + "text": "소속은 한신대학교 상담심리학과입니다.", + "expected_entities": ["ORG"], + "unexpected_entities": ["NAME"], + "forbidden_substrings": ["한신대학교", "상담심리학과"], + "required_substrings": ["[ORG]"] + }, + { + "id": "ko_org_health_center", + "text": "지난주 마음봄상담센터에서 안내를 받았습니다.", + "expected_entities": ["ORG"], + "unexpected_entities": ["NAME"], + "forbidden_substrings": ["마음봄상담센터"], + "required_substrings": ["[ORG]"] + }, + { + "id": "ko_common_words_false_positive", + "text": "학교 가는 게 힘들고 엄마랑 친구 이야기를 하면 불안해요.", + "unexpected_entities": ["NAME", "ORG"], + "required_substrings": ["학교", "엄마", "친구"] + } +] diff --git a/scripts/evaluate-pii-masking.py b/scripts/evaluate-pii-masking.py new file mode 100644 index 0000000..9a951f7 --- /dev/null +++ b/scripts/evaluate-pii-masking.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +API_ROOT = REPO_ROOT / "apps" / "api" +sys.path.insert(0, str(API_ROOT)) + +from app.services.pii_masking_eval import evaluate_fixture # noqa: E402 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Evaluate local PII masking fixtures.") + parser.add_argument( + "--fixtures", + default=str(REPO_ROOT / "data" / "privacy" / "pii-masking-ko-fixtures.json"), + ) + parser.add_argument("--json", action="store_true") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + report = evaluate_fixture(Path(args.fixtures)) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + else: + status = "PASS" if report["passed"] else "FAIL" + print( + f"{status}: {report['cases_passed']}/{report['cases_total']} cases, " + f"entity_recall={report['expected_entity_recall']}, " + f"forbidden_removed={report['forbidden_substring_removal']}, " + f"unexpected_entity_violations={report['unexpected_entity_violations']}" + ) + for result in report["results"]: + if not result["passed"]: + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main())