한신대 피드백 개선팩 반영
This commit is contained in:
parent
5a9c110c11
commit
6b6241f468
25 changed files with 1247 additions and 94 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
73
apps/api/app/test_client_reply_quality.py
Normal file
73
apps/api/app/test_client_reply_quality.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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, [])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue