한신대 피드백 개선팩 반영

This commit is contained in:
Yun Chan 2026-07-03 19:53:14 +09:00
parent 5a9c110c11
commit 6b6241f468
25 changed files with 1247 additions and 94 deletions

View file

@ -19,6 +19,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from enum import Enum
from typing import Iterable, Protocol
@ -448,7 +449,50 @@ class OutputGuardResult:
reasons: list[str] = field(default_factory=list)
def sanitize_client_reply(text: str, *, ideation_stage: int) -> OutputGuardResult:
_ROLE_META_PATTERNS = [
re.compile(r"(?:내담자|상담자)\s*역할\s*로?\s*응답"),
re.compile(r"AI\s*로서"),
re.compile(r"상담자\s*입장\s*에서"),
re.compile(r"\s*(?:핵심신념|자동적\s*사고|인지왜곡)\s*은"),
]
_OPENING_GREETING_RE = re.compile(r"^\s*(?:안녕하세요|처음\s*뵙겠습니다|반갑습니다)(?:[\s,.!?。!?]|$)")
_DUPLICATE_PUNCT_RE = re.compile(r"[\s\.,!?。!?…\"'“”‘’()\[\]{}:;·~\-]+")
def _normalize_duplicate_text(value: str) -> str:
return _DUPLICATE_PUNCT_RE.sub("", value.casefold())
def _duplicate_tokens(value: str) -> list[str]:
cleaned = _DUPLICATE_PUNCT_RE.sub(" ", value.casefold())
return [token for token in cleaned.split() if len(token) >= 2]
def _is_near_duplicate_client_reply(text: str, previous: str) -> bool:
current_norm = _normalize_duplicate_text(text)
previous_norm = _normalize_duplicate_text(previous)
if len(current_norm) < 12 or len(previous_norm) < 12:
return False
if current_norm == previous_norm or current_norm in previous_norm or previous_norm in current_norm:
return True
if SequenceMatcher(None, current_norm, previous_norm).ratio() >= 0.88:
return True
current_tokens = set(_duplicate_tokens(text))
previous_tokens = set(_duplicate_tokens(previous))
if min(len(current_tokens), len(previous_tokens)) < 4:
return False
overlap = len(current_tokens & previous_tokens) / min(len(current_tokens), len(previous_tokens))
return overlap >= 0.82
def sanitize_client_reply(
text: str,
*,
ideation_stage: int,
turn_seq: int | None = None,
previous_client_reply: str | None = None,
) -> OutputGuardResult:
"""내담자 AI 응답 출력 가드레일.
1. 자살/자해 수단·방법 정보가 있으면 needs_regeneration=True(차단, 재생성 신호).
@ -466,6 +510,18 @@ def sanitize_client_reply(text: str, *, ideation_stage: int) -> OutputGuardResul
blocked = True
needs_regen = True
break
if any(pattern.search(text) for pattern in _ROLE_META_PATTERNS):
reasons.append("role_meta")
blocked = True
needs_regen = True
if turn_seq is not None and turn_seq > 1 and _OPENING_GREETING_RE.search(text):
reasons.append("repeat_greeting_after_opening")
blocked = True
needs_regen = True
if previous_client_reply and _is_near_duplicate_client_reply(text, previous_client_reply):
reasons.append("duplicate_client_reply")
blocked = True
needs_regen = True
if ideation_stage > IDEATION_STAGE_CAP:
reasons.append(f"ideation_over_cap:{ideation_stage}>{IDEATION_STAGE_CAP}")

View file

@ -118,6 +118,7 @@ class TurnResult:
tokens_in: int = 0
tokens_out: int = 0
cost_usd: float = 0.0
output_error: Optional[str] = None
# ════════════════════════════════════════════════════════════════════════════
@ -211,6 +212,15 @@ def _mask_recent_turns(turns: Optional[list[dict[str, str]]]) -> list[dict[str,
return masked
def _latest_client_reply(turns: list[dict[str, str]]) -> Optional[str]:
for turn in reversed(turns):
if turn.get("speaker") == "client":
text = str(turn.get("text", "")).strip()
if text:
return text
return None
# ════════════════════════════════════════════════════════════════════════════
# 4~8단계 — 동기 생성 경로 (폴백/테스트)
# ════════════════════════════════════════════════════════════════════════════
@ -238,29 +248,57 @@ async def run_turn_generate(
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
)
started = time.perf_counter()
resp: GenerateResponse = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
audit_hook,
session_id=ctx.session_id,
provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
inference_geo=resp.inference_geo,
latency_ms=latency_ms,
)
reply = resp.text
previous_client_reply = _latest_client_reply(ctx.memory.recent_turns)
resp: GenerateResponse | None = None
reply = ""
safety_flagged = ctx.crisis is not None and ctx.crisis.escalate
for attempt in range(2):
started = time.perf_counter()
resp = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
audit_hook,
session_id=ctx.session_id,
provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
inference_geo=resp.inference_geo,
latency_ms=latency_ms,
)
# 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차). 재생성 루프는 후속.
reply = "…(말을 잇지 못하고 잠시 침묵한다)"
# 5) 출력 가드레일 — 수단 차단 + persona 품질 재생성
guard = guardrail.sanitize_client_reply(
resp.text,
ideation_stage=st.ideation_stage,
turn_seq=st.turn_seq,
previous_client_reply=previous_client_reply,
)
if guard.needs_regeneration:
if attempt == 0:
continue
return TurnResult(
turn_seq=st.turn_seq,
stage=st.stage.value,
effective_openness=st.effective_openness,
client_reply=None,
safety_flagged=True,
state_after=st,
evaluation=None,
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
llm_provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
output_error="client_reply_quality_retryable",
)
safety_flagged = safety_flagged or guard.blocked
reply = guard.text
break
assert resp is not None
# 6) 평가 훅(주입형) — 평가 AI 4차원 태깅 (Features 소유)
evaluation: Optional[dict] = None
@ -340,8 +378,9 @@ async def run_turn_stream(
accumulated = ""
flagged = False
output_error: str | None = None
stream_meta: dict[str, Any] = {}
display_sanitizer = guardrail.PiiPlaceholderStreamSanitizer()
previous_client_reply = _latest_client_reply(ctx.memory.recent_turns)
if ctx.crisis is not None and ctx.crisis.escalate:
flagged = True
resource = guardrail.crisis_resource()
@ -375,9 +414,6 @@ 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:
@ -392,23 +428,27 @@ async def run_turn_stream(
text_piece = payload.text
accumulated += text_piece
# 출력 가드레일(누적 스캔) — 수단정보 발견 시 차단·재생성 신호
guard = guardrail.sanitize_client_reply(accumulated, ideation_stage=st.ideation_stage)
if guard.needs_regeneration and not flagged:
flagged = True
yield StreamEvent("safety", {"reason": "means_info_blocked"})
# 토큰은 더 내보내지 않고 안전 대체로 종결
accumulated = "…(말을 잇지 못하고 잠시 침묵한다)"
break
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})
guard = guardrail.sanitize_client_reply(
accumulated,
ideation_stage=st.ideation_stage,
turn_seq=st.turn_seq,
previous_client_reply=previous_client_reply,
)
if guard.needs_regeneration:
flagged = True
output_error = "client_reply_quality_retryable"
yield StreamEvent(
"safety",
{
"reason": output_error,
"reasons": guard.reasons,
},
)
accumulated = ""
else:
flagged = flagged or guard.blocked
if guard.text:
yield StreamEvent("token", {"text": guard.text})
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
@ -444,17 +484,12 @@ async def run_turn_stream(
"tokens_in": _safe_int(stream_meta.get("tokens_in")),
"tokens_out": _safe_int(stream_meta.get("tokens_out")),
"cost_usd": _safe_float(stream_meta.get("cost_usd")),
"output_error": output_error,
},
)
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

@ -300,7 +300,7 @@ def build_turn_messages(
# non-system history records 는 요청 계약상 보존하고, 별도 prompt 동작 변경에서 소비한다.
if memory.recent_turns:
for t in memory.recent_turns:
role = "assistant" if t.get("speaker") == "counselor" else "user"
role = "user" if t.get("speaker") == "counselor" else "assistant"
# 내담자(자기) 과거 발화는 assistant, 상담자 발화는 user 로 매핑한다.
messages.append(EngineMessage(role=role, content=t.get("text", ""), cache=False))