평가 캐시와 개인정보 마스킹 보강
This commit is contained in:
parent
f0771db919
commit
6a81ec596c
11 changed files with 737 additions and 14 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue