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

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",
]