한신대 피드백 개선팩 반영

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

@ -131,6 +131,7 @@ class TurnResponse(BaseModel):
crisis_kind: str = "none"
crisis_resource: Optional[CrisisResourceResponse] = None
conversation_stopped: bool = False
output_error: Optional[str] = None
class SessionEndResponse(BaseModel):
@ -678,11 +679,12 @@ def _stream_result_from_done(
evaluation: Optional[dict],
) -> orchestrator.TurnResult:
assert ctx.state_after is not None
output_error = str(data.get("output_error") or "") or None
return orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=_stage_label(ctx.state_after.stage),
effective_openness=ctx.state_after.effective_openness,
client_reply=final_reply or None,
client_reply=None if output_error else final_reply or None,
safety_flagged=bool(data.get("safety_flagged")),
state_after=ctx.state_after,
evaluation=evaluation,
@ -694,6 +696,7 @@ def _stream_result_from_done(
tokens_in=int(data.get("tokens_in") or 0),
tokens_out=int(data.get("tokens_out") or 0),
cost_usd=float(data.get("cost_usd") or 0.0),
output_error=output_error,
)
@ -1330,6 +1333,7 @@ async def submit_turn(
crisis_kind=result.crisis_kind,
crisis_resource=result.crisis_resource,
conversation_stopped=result.conversation_stopped,
output_error=result.output_error,
)

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

View file

@ -807,10 +807,9 @@ def _review_summary_from_evaluation(
return fallback
status = str(evaluation_record.get("status") or "")
if status != "ready":
error = _compact_text(str(evaluation_record.get("error") or payload.get("error") or ""))
return (
"저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. "
+ (f"사유: {error}" if error else "평가가 완료되면 코칭 항목이 갱신됩니다.")
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. "
"AI 평가 재시도가 필요합니다."
)
rationale = _compact_text(str(payload.get("supervisor_rationale") or ""))
critique = _compact_text(str(payload.get("supervisor_critique") or ""))
@ -1159,9 +1158,10 @@ def _review_note_from_turn_eval(
return ReviewNote(
author="평가 AI",
tone="warn",
title=" 평가 실패",
title=" 직후 평가 실패",
body=_review_note_body_markdown(
f"이 발화의 fast-loop 평가를 완료하지 못했습니다.\n\n사유: {error_text}"
"이 발화의 fast-loop(턴 직후) 평가를 완료하지 못했습니다.\n\n"
"AI 평가 재시도가 필요합니다."
),
quote=quote,
)
@ -1178,7 +1178,7 @@ def _review_note_from_turn_eval(
return ReviewNote(
author="평가 AI",
tone="warn",
title=f"의도와 다른 부분 · {dimension}".rstrip(" ·") or "의도와 다른 부분",
title=f"의도와 다른 부분 · {dimension} · 턴 직후".replace(" · · ", " · ").rstrip(" ·"),
body=_review_note_body_markdown(body or "권장 반응과 실제 반응에 차이가 있었어요."),
quote=quote,
)
@ -1188,7 +1188,7 @@ def _review_note_from_turn_eval(
return ReviewNote(
author="평가 AI",
tone="good",
title="적절한 개입",
title="적절한 개입 · 턴 직후",
body=_review_note_body_markdown(
note_text
or (
@ -1203,7 +1203,7 @@ def _review_note_from_turn_eval(
return ReviewNote(
author="평가 AI",
tone="warn",
title="점검해볼 지점",
title="점검해볼 지점 · 턴 직후",
body=_review_note_body_markdown(note_text),
quote=quote,
)

View file

@ -0,0 +1,73 @@
"""Strict client reply quality gate regressions."""
from __future__ import annotations
import unittest
from .services import guardrail
class ClientReplyQualityGateTest(unittest.TestCase):
def test_blocks_role_meta_speech_variants(self) -> None:
samples = [
"내담자 역할로 응답하겠습니다. 엄마가 가보라고 해서요.",
"AI로서 답변드리면 저는 우울한 학생입니다.",
"상담자 입장에서 보면 제 핵심신념은 무가치감입니다.",
"제 핵심신념은 저는 쓸모없다는 것입니다.",
]
for sample in samples:
with self.subTest(sample=sample):
result = guardrail.sanitize_client_reply(sample, ideation_stage=1, turn_seq=2)
self.assertTrue(result.needs_regeneration)
self.assertIn("role_meta", result.reasons)
def test_blocks_repeated_greeting_after_opening_turn_only_when_greeting_starts_reply(self) -> None:
blocked = guardrail.sanitize_client_reply(
"안녕하세요. 처음 뵙겠습니다. 저는 서연이에요.",
ideation_stage=1,
turn_seq=3,
)
first_turn = guardrail.sanitize_client_reply(
"안녕하세요. 엄마가 가보라고 해서 왔어요.",
ideation_stage=1,
turn_seq=1,
)
quoted = guardrail.sanitize_client_reply(
"방금 선생님이 안녕하세요라고 말해서 더 어색했어요.",
ideation_stage=1,
turn_seq=3,
)
self.assertTrue(blocked.needs_regeneration)
self.assertIn("repeat_greeting_after_opening", blocked.reasons)
self.assertFalse(first_turn.needs_regeneration)
self.assertFalse(quoted.needs_regeneration)
def test_blocks_near_duplicate_previous_client_reply_but_allows_short_overlap(self) -> None:
previous = "몰라요. 엄마가 그냥 가보라고 해서 왔어요."
duplicate = guardrail.sanitize_client_reply(
"몰라요. 엄마가 그냥 가보라고 해서 왔어요.",
ideation_stage=1,
previous_client_reply=previous,
)
near_duplicate = guardrail.sanitize_client_reply(
"엄마가 그냥 가보라고 해서 왔어요. 몰라요.",
ideation_stage=1,
previous_client_reply=previous,
)
allowed = guardrail.sanitize_client_reply(
"엄마가 가보라고 한 건 맞는데, 지금은 좀 짜증나요.",
ideation_stage=1,
previous_client_reply=previous,
)
self.assertTrue(duplicate.needs_regeneration)
self.assertIn("duplicate_client_reply", duplicate.reasons)
self.assertTrue(near_duplicate.needs_regeneration)
self.assertIn("duplicate_client_reply", near_duplicate.reasons)
self.assertFalse(allowed.needs_regeneration)
if __name__ == "__main__":
unittest.main()

View file

@ -92,9 +92,11 @@ class CaptureGenerateEngine:
self.payload: dict[str, Any] | None = None
self._payload_builder = EngineClient(base_url="http://engine.test")
self.text = text
self.requests: list[Any] = []
async def generate(self, req):
self.request = req
self.requests.append(req)
self.payload = self._payload_builder._payload(req)
return GenerateResponse(
text=self.text,
@ -283,6 +285,26 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("자동적 사고", blob)
self.assertIn("행동활성화", blob)
def test_prepare_turn_maps_recent_turns_from_client_ai_perspective(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="history-session",
case_id="history-case",
card=persona.P1,
state=_initial_state(),
learner_text="그 말을 듣고 어떤 생각이 들었나요?",
memory=orchestrator.TurnMemory(
recent_turns=[
{"speaker": "counselor", "text": "왜 상담에 오게 됐나요?"},
{"speaker": "client", "text": "엄마가 가보라고 해서요."},
],
),
)
recent = ctx.messages[-3:-1]
self.assertEqual([message.role for message in recent], ["user", "assistant"])
self.assertEqual(recent[0].content, "왜 상담에 오게 됐나요?")
self.assertEqual(recent[1].content, "엄마가 가보라고 해서요.")
async def test_run_turn_generate_sends_only_masked_engine_payload(self) -> None:
ctx = _prepare_context()
engine = CaptureGenerateEngine()
@ -395,6 +417,78 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("[NAME]", result.client_reply or "")
self.assertIn("그 이름은 그대로고", result.client_reply or "")
async def test_run_turn_generate_retries_once_after_role_meta_reply(self) -> None:
class SequenceEngine(CaptureGenerateEngine):
def __init__(self) -> None:
super().__init__("")
self.responses = [
"내담자 역할로 응답하겠습니다. 엄마가 가보라고 해서요.",
"엄마가 그냥 가보라고 해서 왔어요.",
]
async def generate(self, req):
self.request = req
self.requests.append(req)
self.payload = self._payload_builder._payload(req)
text = self.responses.pop(0)
return GenerateResponse(
text=text,
model="fake-model",
provider="fake-provider",
tokens_in=3,
tokens_out=4,
cost_usd=0.0,
)
ctx = orchestrator.prepare_turn(
session_id="quality-session",
case_id="quality-case",
card=persona.P1,
state=_initial_state(),
learner_text="어머니가 오라고 하셨군요. 지금은 어떤 마음인가요?",
)
engine = SequenceEngine()
result = await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type]
self.assertEqual(len(engine.requests), 2)
self.assertEqual(result.client_reply, "엄마가 그냥 가보라고 해서 왔어요.")
self.assertFalse(result.safety_flagged)
async def test_run_turn_generate_returns_retryable_error_without_saving_bad_fallback(self) -> None:
class AlwaysBadEngine(CaptureGenerateEngine):
def __init__(self) -> None:
super().__init__("")
async def generate(self, req):
self.request = req
self.requests.append(req)
self.payload = self._payload_builder._payload(req)
return GenerateResponse(
text="AI로서 내담자 역할로 응답하겠습니다.",
model="fake-model",
provider="fake-provider",
tokens_in=3,
tokens_out=4,
cost_usd=0.0,
)
ctx = orchestrator.prepare_turn(
session_id="quality-session",
case_id="quality-case",
card=persona.P1,
state=_initial_state(),
learner_text="지금 이 자리에서 가장 말하기 어려운 게 뭔가요?",
)
engine = AlwaysBadEngine()
result = await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type]
self.assertEqual(len(engine.requests), 2)
self.assertIsNone(result.client_reply)
self.assertTrue(result.safety_flagged)
self.assertEqual(getattr(result, "output_error", None), "client_reply_quality_retryable")
async def test_run_turn_stream_humanizes_split_masked_placeholder_reply(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
@ -422,6 +516,38 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("그 이름은 그대로고요.", streamed)
self.assertEqual(events[-1].event, "done")
async def test_run_turn_stream_buffers_role_meta_reply_without_token_leak(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="quality-stream-session",
case_id="quality-stream-case",
card=persona.P1,
state=_initial_state(),
learner_text="어머니가 오라고 하셨군요. 지금은 어떤 마음인가요?",
)
engine = CaptureStreamEngine(
chunks=[
"내담자 ",
"역할로 응답하겠습니다. 엄마가 가보라고 해서요.",
]
)
events = [
event
async for event in orchestrator.run_turn_stream(
ctx,
engine, # type: ignore[arg-type]
)
]
self.assertEqual([event.event for event in events], ["safety", "done"])
self.assertEqual(
"".join(str(event.data.get("text", "")) for event in events if event.event == "token"),
"",
)
self.assertEqual(events[0].data["reason"], "client_reply_quality_retryable")
self.assertTrue(events[-1].data["safety_flagged"])
self.assertEqual(events[-1].data["output_error"], "client_reply_quality_retryable")
if __name__ == "__main__":
unittest.main()

View file

@ -621,9 +621,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
review_turn = review.turns[0]
self.assertIsNotNone(review_turn.note)
assert review_turn.note is not None
self.assertEqual(review_turn.note.title, "턴 평가 실패")
self.assertIn("fast-loop 평가를 완료하지 못했습니다", review_turn.note.body)
self.assertIn("RuntimeError", review_turn.note.body)
self.assertEqual(review_turn.note.title, "턴 직후 평가 실패")
self.assertIn("fast-loop(턴 직후) 평가를 완료하지 못했습니다", review_turn.note.body)
self.assertIn("AI 평가 재시도가 필요합니다", review_turn.note.body)
self.assertNotIn("RuntimeError", review_turn.note.body)
self.assertNotIn("김서연", review_turn.note.body)
self.assertNotIn("010-1234-5678", review_turn.note.body)
@ -1117,8 +1118,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async for event in orchestrator.run_turn_stream(ctx, FakeStreamEngine()) # type: ignore[arg-type]
]
self.assertEqual([event.event for event in events], ["token", "error"])
self.assertIn("engine unavailable", events[1].data["detail"])
self.assertEqual([event.event for event in events], ["error"])
self.assertIn("engine unavailable", events[0].data["detail"])
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
principal = _principal()
@ -1667,7 +1668,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertFalse(response.reviewReady)
self.assertTrue(response.degraded)
self.assertEqual(response.supervisorState, "평가 실패")
self.assertIn("session evaluation timeout after 45s", response.summary)
self.assertIn("deep-loop 평가 AI 산출물을 표시하지 못했습니다", response.summary)
self.assertIn("AI 평가 재시도가 필요합니다", response.summary)
self.assertNotIn("session evaluation timeout after 45s", response.summary)
self.assertEqual(response.rubric, [])
self.assertEqual(response.goodMoments, [])
self.assertEqual(response.growthPoints, [])

View file

@ -338,22 +338,39 @@ async def close_session(sid: str):
# session_id 가 오면 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
# ════════════════════════════════════════════════════════════════════════════
def _split_messages(messages: list[GwMessage]) -> GatewayPromptParts:
"""EngineMessage[] → named prompt parts for the current gateway turn.
- system 들은 합쳐서 --system-prompt 주입할 텍스트로.
- 마지막 user 발화를 이번 stdin content .
- 상주 세션 재사용 시에는 풀이 이미 컨텍스트를 들고 있으므로 마지막 user 보냄.
"""
def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None) -> GatewayPromptParts:
"""EngineMessage[] → named prompt parts for the current gateway turn."""
system_parts: list[str] = []
last_user = ""
non_system: list[GwMessage] = []
for m in messages:
if m.role == "system":
system_parts.append(m.content)
elif m.role == "user":
last_user = m.content
else:
non_system.append(m)
last_user_index: int | None = None
for index, m in enumerate(non_system):
if m.role == "user":
last_user_index = index
last_user = ""
if last_user_index is not None:
last_user = non_system[last_user_index].content
user_payload = last_user
if ai_role == "client" and last_user_index is not None:
history_parts: list[str] = []
for m in non_system[:last_user_index]:
content = m.content.strip()
if not content:
continue
speaker = "상담자" if m.role == "user" else "내담자"
history_parts.append(f"{speaker}: {content}")
if history_parts:
user_payload = "[직전 대화]\n" + "\n".join(history_parts) + "\n\n[이번 상담자 발화]\n" + last_user
system_prompt = "\n\n".join(p for p in system_parts if p.strip())
return GatewayPromptParts(system_prompt=system_prompt, user_payload=last_user)
return GatewayPromptParts(system_prompt=system_prompt, user_payload=user_payload)
def _inject_schema(system_prompt: str, schema: Optional[dict[str, Any]]) -> str:
@ -400,7 +417,7 @@ async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[Engi
@app.post("/v1/generate")
async def v1_generate(req: GwGenerateReq):
"""단발 생성 (평가 deep-loop, 회기종료 압축 등). GenerateResponse 호환 dict 반환."""
prompt_parts = _split_messages(req.messages)
prompt_parts = _split_messages(req.messages, ai_role=req.ai_role)
system_prompt = _inject_schema(prompt_parts.system_prompt, req.structured_schema)
if not prompt_parts.user_payload:
raise HTTPException(400, "no user message in payload")
@ -436,7 +453,7 @@ async def v1_generate(req: GwGenerateReq):
@app.post("/v1/stream")
async def v1_stream(req: GwGenerateReq):
"""SSE 토큰 스트림. data: 라인으로 텍스트 델타를 흘리고 done/error 프레이밍."""
prompt_parts = _split_messages(req.messages)
prompt_parts = _split_messages(req.messages, ai_role=req.ai_role)
system_prompt = _inject_schema(prompt_parts.system_prompt, req.structured_schema)
if not prompt_parts.user_payload:
raise HTTPException(400, "no user message in payload")

View file

@ -210,6 +210,39 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(parts.system_prompt, "system one\n\nsystem two")
self.assertEqual(parts.user_payload, "current client")
def test_split_messages_injects_client_history_before_current_counselor_turn(self):
parts = gateway._split_messages(
[
contract.EngineMessage(role="system", content="client persona system"),
contract.EngineMessage(role="user", content="상담자 이전 질문"),
contract.EngineMessage(role="assistant", content="내담자 이전 답변"),
contract.EngineMessage(role="user", content="이번 상담자 발화"),
],
ai_role="client",
)
self.assertEqual(parts.system_prompt, "client persona system")
self.assertIn("[직전 대화]", parts.user_payload)
self.assertIn("상담자: 상담자 이전 질문", parts.user_payload)
self.assertIn("내담자: 내담자 이전 답변", parts.user_payload)
self.assertIn("[이번 상담자 발화]", parts.user_payload)
self.assertTrue(parts.user_payload.rstrip().endswith("이번 상담자 발화"))
def test_split_messages_does_not_inject_history_for_evaluator_requests(self):
parts = gateway._split_messages(
[
contract.EngineMessage(role="system", content="eval system"),
contract.EngineMessage(role="user", content="이전 평가 입력"),
contract.EngineMessage(role="assistant", content="이전 평가 출력"),
contract.EngineMessage(role="user", content="이번 평가 입력"),
],
ai_role="evaluator",
)
self.assertEqual(parts.system_prompt, "eval system")
self.assertEqual(parts.user_payload, "이번 평가 입력")
self.assertNotIn("[직전 대화]", parts.user_payload)
def test_split_messages_preserves_no_user_payload_boundary(self):
parts = gateway._split_messages(
[contract.EngineMessage(role="system", content="system only")]