한신대 피드백 개선팩 반영

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

@ -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)})