세션 평가와 교수자 분석 보강
This commit is contained in:
parent
5c4ac04e06
commit
fe2796f05a
51 changed files with 4928 additions and 240 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue