한신대 피드백 개선팩 반영
This commit is contained in:
parent
5a9c110c11
commit
6b6241f468
25 changed files with 1247 additions and 94 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue