세션 평가와 교수자 분석 보강

This commit is contained in:
Yun Chan 2026-07-01 12:10:52 +09:00
parent 5c4ac04e06
commit fe2796f05a
51 changed files with 4928 additions and 240 deletions

View file

@ -44,6 +44,7 @@ _KOREAN_SURNAME_CHARS = (
)
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
_KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?"
_KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}"
_KOREAN_NAME_STOPWORDS = {
"연락",
"연락처",
@ -126,7 +127,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
rf"(?<![가-힣])(?P<value>{_KOREAN_CONTEXTLESS_NAME})"
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
),
),
@ -372,6 +373,71 @@ _MEANS_TERMS = [
"약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약",
"손목을", "칼로", "뛰어내리", "수면제 ", "", "방법은", "어떻게 죽",
]
_DISPLAY_PLACEHOLDER_LABELS = {
"NAME": "그 이름",
"ORG": "그 기관",
"PHONE": "연락처",
"EMAIL": "이메일",
"RRN": "식별번호",
"NUMID": "식별번호",
"DATE": "그 날짜",
"MONEY": "그 금액",
"ADDR": "그 주소",
}
_DISPLAY_PLACEHOLDER_RE = re.compile(
r"\[(?P<label>NAME|ORG|PHONE|EMAIL|RRN|NUMID|DATE|MONEY|ADDR)\]"
r"(?P<particle>[은는이가을를와과])?"
)
_DISPLAY_PLACEHOLDER_STREAM_TAIL = 16
def _adjust_particle(value: str, particle: str | None) -> str:
if particle is None:
return value
if particle in ("", ""):
return value + ""
if particle in ("", ""):
return value + ""
if particle in ("", ""):
return value + ""
if particle in ("", ""):
return value + ""
return value + particle
def humanize_pii_placeholders(text: str) -> str:
"""사용자에게 보이는 내담자 응답에서 PII placeholder 토큰을 자연어로 낮춘다."""
if not text:
return text
def _replace(match: re.Match[str]) -> str:
label = match.group("label")
replacement = _DISPLAY_PLACEHOLDER_LABELS.get(label, "그 정보")
return _adjust_particle(replacement, match.group("particle"))
return _DISPLAY_PLACEHOLDER_RE.sub(_replace, text)
class PiiPlaceholderStreamSanitizer:
"""SSE 토큰 경계를 가로질러 나온 PII placeholder를 사용자 표시 전에 치환한다."""
def __init__(self) -> None:
self._tail = ""
def feed(self, chunk: str) -> str:
if not chunk:
return ""
self._tail += chunk
if len(self._tail) <= _DISPLAY_PLACEHOLDER_STREAM_TAIL:
return ""
ready = self._tail[:-_DISPLAY_PLACEHOLDER_STREAM_TAIL]
self._tail = self._tail[-_DISPLAY_PLACEHOLDER_STREAM_TAIL:]
return humanize_pii_placeholders(ready)
def flush(self) -> str:
tail = self._tail
self._tail = ""
return humanize_pii_placeholders(tail)
@dataclass(slots=True)
@ -406,7 +472,7 @@ def sanitize_client_reply(text: str, *, ideation_stage: int) -> OutputGuardResul
blocked = True
return OutputGuardResult(
text=text,
text=humanize_pii_placeholders(text),
blocked=blocked,
needs_regeneration=needs_regen,
reasons=reasons,
@ -440,6 +506,8 @@ __all__ = [
"CrisisResult",
"classify_crisis",
"OutputGuardResult",
"PiiPlaceholderStreamSanitizer",
"humanize_pii_placeholders",
"sanitize_client_reply",
"clamp_ideation",
"crisis_resource",

View file

@ -237,6 +237,7 @@ async def run_turn_generate(
# 5) 출력 가드레일 — 수단 차단 + ideation 상한
guard = guardrail.sanitize_client_reply(reply, ideation_stage=st.ideation_stage)
reply = guard.text
safety_flagged = guard.blocked or (ctx.crisis is not None and ctx.crisis.escalate)
if guard.needs_regeneration:
# 수단정보 누출 → 안전 대체 응답으로 치환(1차). 재생성 루프는 후속.
@ -320,6 +321,7 @@ async def run_turn_stream(
accumulated = ""
flagged = False
stream_meta: dict[str, Any] = {}
display_sanitizer = guardrail.PiiPlaceholderStreamSanitizer()
if ctx.crisis is not None and ctx.crisis.escalate:
flagged = True
resource = guardrail.crisis_resource()
@ -353,6 +355,9 @@ async def run_turn_stream(
if packet.event == ENGINE_GATEWAY_SSE_ERROR:
payload = packet.payload
detail = payload.detail if isinstance(payload, StreamErrorEvent) else "engine stream error"
display_tail = display_sanitizer.flush()
if display_tail:
yield StreamEvent("token", {"text": display_tail})
yield StreamEvent("error", {"detail": detail})
return
if packet.event == ENGINE_GATEWAY_SSE_DONE:
@ -376,7 +381,14 @@ async def run_turn_stream(
accumulated = "…(말을 잇지 못하고 잠시 침묵한다)"
break
yield StreamEvent("token", {"text": text_piece})
display_piece = display_sanitizer.feed(text_piece)
if display_piece:
yield StreamEvent("token", {"text": display_piece})
if not flagged:
display_tail = display_sanitizer.flush()
if display_tail:
yield StreamEvent("token", {"text": display_tail})
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
@ -415,8 +427,14 @@ async def run_turn_stream(
},
)
except EngineGatewaySseDecodeError as e:
display_tail = display_sanitizer.flush()
if display_tail:
yield StreamEvent("token", {"text": display_tail})
yield StreamEvent("error", {"detail": str(e)})
except EngineError as e:
display_tail = display_sanitizer.flush()
if display_tail:
yield StreamEvent("token", {"text": display_tail})
yield StreamEvent("error", {"detail": str(e)})

View file

@ -164,6 +164,7 @@ def build_learner_growth(
*,
learner_label: Callable[[str], str],
limit: int | None = None,
point_limit: int | None = 6,
) -> list[LearnerGrowthMetrics]:
grouped: dict[str, list[InProcSession]] = {}
for sess in sessions:
@ -222,7 +223,7 @@ def build_learner_growth(
avg_rapport=avg([value for value in rapport_values if value is not None]),
trend=trend,
top_techniques=top_techniques,
points=points[-6:],
points=points if point_limit is None else points[-point_limit:],
)
)
sorted_result = sorted(result, key=lambda item: item.latest_at, reverse=True)