한신대 피드백 개선팩 반영
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_kind: str = "none"
|
||||||
crisis_resource: Optional[CrisisResourceResponse] = None
|
crisis_resource: Optional[CrisisResourceResponse] = None
|
||||||
conversation_stopped: bool = False
|
conversation_stopped: bool = False
|
||||||
|
output_error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SessionEndResponse(BaseModel):
|
class SessionEndResponse(BaseModel):
|
||||||
|
|
@ -678,11 +679,12 @@ def _stream_result_from_done(
|
||||||
evaluation: Optional[dict],
|
evaluation: Optional[dict],
|
||||||
) -> orchestrator.TurnResult:
|
) -> orchestrator.TurnResult:
|
||||||
assert ctx.state_after is not None
|
assert ctx.state_after is not None
|
||||||
|
output_error = str(data.get("output_error") or "") or None
|
||||||
return orchestrator.TurnResult(
|
return orchestrator.TurnResult(
|
||||||
turn_seq=ctx.state_after.turn_seq,
|
turn_seq=ctx.state_after.turn_seq,
|
||||||
stage=_stage_label(ctx.state_after.stage),
|
stage=_stage_label(ctx.state_after.stage),
|
||||||
effective_openness=ctx.state_after.effective_openness,
|
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")),
|
safety_flagged=bool(data.get("safety_flagged")),
|
||||||
state_after=ctx.state_after,
|
state_after=ctx.state_after,
|
||||||
evaluation=evaluation,
|
evaluation=evaluation,
|
||||||
|
|
@ -694,6 +696,7 @@ def _stream_result_from_done(
|
||||||
tokens_in=int(data.get("tokens_in") or 0),
|
tokens_in=int(data.get("tokens_in") or 0),
|
||||||
tokens_out=int(data.get("tokens_out") or 0),
|
tokens_out=int(data.get("tokens_out") or 0),
|
||||||
cost_usd=float(data.get("cost_usd") or 0.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_kind=result.crisis_kind,
|
||||||
crisis_resource=result.crisis_resource,
|
crisis_resource=result.crisis_resource,
|
||||||
conversation_stopped=result.conversation_stopped,
|
conversation_stopped=result.conversation_stopped,
|
||||||
|
output_error=result.output_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from difflib import SequenceMatcher
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Iterable, Protocol
|
from typing import Iterable, Protocol
|
||||||
|
|
||||||
|
|
@ -448,7 +449,50 @@ class OutputGuardResult:
|
||||||
reasons: list[str] = field(default_factory=list)
|
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 응답 출력 가드레일.
|
"""내담자 AI 응답 출력 가드레일.
|
||||||
|
|
||||||
1. 자살/자해 수단·방법 정보가 있으면 → needs_regeneration=True(차단, 재생성 신호).
|
1. 자살/자해 수단·방법 정보가 있으면 → needs_regeneration=True(차단, 재생성 신호).
|
||||||
|
|
@ -466,6 +510,18 @@ def sanitize_client_reply(text: str, *, ideation_stage: int) -> OutputGuardResul
|
||||||
blocked = True
|
blocked = True
|
||||||
needs_regen = True
|
needs_regen = True
|
||||||
break
|
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:
|
if ideation_stage > IDEATION_STAGE_CAP:
|
||||||
reasons.append(f"ideation_over_cap:{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_in: int = 0
|
||||||
tokens_out: int = 0
|
tokens_out: int = 0
|
||||||
cost_usd: float = 0.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
|
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단계 — 동기 생성 경로 (폴백/테스트)
|
# 4~8단계 — 동기 생성 경로 (폴백/테스트)
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -238,29 +248,57 @@ async def run_turn_generate(
|
||||||
session_id=ctx.session_id,
|
session_id=ctx.session_id,
|
||||||
metadata={"stage": st.stage.value},
|
metadata={"stage": st.stage.value},
|
||||||
)
|
)
|
||||||
started = time.perf_counter()
|
previous_client_reply = _latest_client_reply(ctx.memory.recent_turns)
|
||||||
resp: GenerateResponse = await engine.generate(req)
|
resp: GenerateResponse | None = None
|
||||||
latency_ms = int((time.perf_counter() - started) * 1000)
|
reply = ""
|
||||||
await _record_llm_audit(
|
safety_flagged = ctx.crisis is not None and ctx.crisis.escalate
|
||||||
audit_hook,
|
for attempt in range(2):
|
||||||
session_id=ctx.session_id,
|
started = time.perf_counter()
|
||||||
provider=resp.provider,
|
resp = await engine.generate(req)
|
||||||
model=resp.model,
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||||
tokens_in=resp.tokens_in,
|
await _record_llm_audit(
|
||||||
tokens_out=resp.tokens_out,
|
audit_hook,
|
||||||
cost_usd=resp.cost_usd,
|
session_id=ctx.session_id,
|
||||||
inference_geo=resp.inference_geo,
|
provider=resp.provider,
|
||||||
latency_ms=latency_ms,
|
model=resp.model,
|
||||||
)
|
tokens_in=resp.tokens_in,
|
||||||
reply = resp.text
|
tokens_out=resp.tokens_out,
|
||||||
|
cost_usd=resp.cost_usd,
|
||||||
|
inference_geo=resp.inference_geo,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
|
||||||
# 5) 출력 가드레일 — 수단 차단 + ideation 상한
|
# 5) 출력 가드레일 — 수단 차단 + persona 품질 재생성
|
||||||
guard = guardrail.sanitize_client_reply(reply, ideation_stage=st.ideation_stage)
|
guard = guardrail.sanitize_client_reply(
|
||||||
reply = guard.text
|
resp.text,
|
||||||
safety_flagged = guard.blocked or (ctx.crisis is not None and ctx.crisis.escalate)
|
ideation_stage=st.ideation_stage,
|
||||||
if guard.needs_regeneration:
|
turn_seq=st.turn_seq,
|
||||||
# 수단정보 누출 → 안전 대체 응답으로 치환(1차). 재생성 루프는 후속.
|
previous_client_reply=previous_client_reply,
|
||||||
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 소유)
|
# 6) 평가 훅(주입형) — 평가 AI 4차원 태깅 (Features 소유)
|
||||||
evaluation: Optional[dict] = None
|
evaluation: Optional[dict] = None
|
||||||
|
|
@ -340,8 +378,9 @@ async def run_turn_stream(
|
||||||
|
|
||||||
accumulated = ""
|
accumulated = ""
|
||||||
flagged = False
|
flagged = False
|
||||||
|
output_error: str | None = None
|
||||||
stream_meta: dict[str, Any] = {}
|
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:
|
if ctx.crisis is not None and ctx.crisis.escalate:
|
||||||
flagged = True
|
flagged = True
|
||||||
resource = guardrail.crisis_resource()
|
resource = guardrail.crisis_resource()
|
||||||
|
|
@ -375,9 +414,6 @@ async def run_turn_stream(
|
||||||
if packet.event == ENGINE_GATEWAY_SSE_ERROR:
|
if packet.event == ENGINE_GATEWAY_SSE_ERROR:
|
||||||
payload = packet.payload
|
payload = packet.payload
|
||||||
detail = payload.detail if isinstance(payload, StreamErrorEvent) else "engine stream error"
|
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})
|
yield StreamEvent("error", {"detail": detail})
|
||||||
return
|
return
|
||||||
if packet.event == ENGINE_GATEWAY_SSE_DONE:
|
if packet.event == ENGINE_GATEWAY_SSE_DONE:
|
||||||
|
|
@ -392,23 +428,27 @@ async def run_turn_stream(
|
||||||
text_piece = payload.text
|
text_piece = payload.text
|
||||||
accumulated += text_piece
|
accumulated += text_piece
|
||||||
|
|
||||||
# 출력 가드레일(누적 스캔) — 수단정보 발견 시 차단·재생성 신호
|
guard = guardrail.sanitize_client_reply(
|
||||||
guard = guardrail.sanitize_client_reply(accumulated, ideation_stage=st.ideation_stage)
|
accumulated,
|
||||||
if guard.needs_regeneration and not flagged:
|
ideation_stage=st.ideation_stage,
|
||||||
flagged = True
|
turn_seq=st.turn_seq,
|
||||||
yield StreamEvent("safety", {"reason": "means_info_blocked"})
|
previous_client_reply=previous_client_reply,
|
||||||
# 토큰은 더 내보내지 않고 안전 대체로 종결
|
)
|
||||||
accumulated = "…(말을 잇지 못하고 잠시 침묵한다)"
|
if guard.needs_regeneration:
|
||||||
break
|
flagged = True
|
||||||
|
output_error = "client_reply_quality_retryable"
|
||||||
display_piece = display_sanitizer.feed(text_piece)
|
yield StreamEvent(
|
||||||
if display_piece:
|
"safety",
|
||||||
yield StreamEvent("token", {"text": display_piece})
|
{
|
||||||
|
"reason": output_error,
|
||||||
if not flagged:
|
"reasons": guard.reasons,
|
||||||
display_tail = display_sanitizer.flush()
|
},
|
||||||
if display_tail:
|
)
|
||||||
yield StreamEvent("token", {"text": display_tail})
|
accumulated = ""
|
||||||
|
else:
|
||||||
|
flagged = flagged or guard.blocked
|
||||||
|
if guard.text:
|
||||||
|
yield StreamEvent("token", {"text": guard.text})
|
||||||
|
|
||||||
latency_ms = int((time.perf_counter() - started) * 1000)
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||||
await _record_llm_audit(
|
await _record_llm_audit(
|
||||||
|
|
@ -444,17 +484,12 @@ async def run_turn_stream(
|
||||||
"tokens_in": _safe_int(stream_meta.get("tokens_in")),
|
"tokens_in": _safe_int(stream_meta.get("tokens_in")),
|
||||||
"tokens_out": _safe_int(stream_meta.get("tokens_out")),
|
"tokens_out": _safe_int(stream_meta.get("tokens_out")),
|
||||||
"cost_usd": _safe_float(stream_meta.get("cost_usd")),
|
"cost_usd": _safe_float(stream_meta.get("cost_usd")),
|
||||||
|
"output_error": output_error,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except EngineGatewaySseDecodeError as e:
|
except EngineGatewaySseDecodeError as e:
|
||||||
display_tail = display_sanitizer.flush()
|
|
||||||
if display_tail:
|
|
||||||
yield StreamEvent("token", {"text": display_tail})
|
|
||||||
yield StreamEvent("error", {"detail": str(e)})
|
yield StreamEvent("error", {"detail": str(e)})
|
||||||
except EngineError as 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)})
|
yield StreamEvent("error", {"detail": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -300,7 +300,7 @@ def build_turn_messages(
|
||||||
# non-system history records 는 요청 계약상 보존하고, 별도 prompt 동작 변경에서 소비한다.
|
# non-system history records 는 요청 계약상 보존하고, 별도 prompt 동작 변경에서 소비한다.
|
||||||
if memory.recent_turns:
|
if memory.recent_turns:
|
||||||
for t in 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 로 매핑한다.
|
# 내담자(자기) 과거 발화는 assistant, 상담자 발화는 user 로 매핑한다.
|
||||||
messages.append(EngineMessage(role=role, content=t.get("text", ""), cache=False))
|
messages.append(EngineMessage(role=role, content=t.get("text", ""), cache=False))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -807,10 +807,9 @@ def _review_summary_from_evaluation(
|
||||||
return fallback
|
return fallback
|
||||||
status = str(evaluation_record.get("status") or "")
|
status = str(evaluation_record.get("status") or "")
|
||||||
if status != "ready":
|
if status != "ready":
|
||||||
error = _compact_text(str(evaluation_record.get("error") or payload.get("error") or ""))
|
|
||||||
return (
|
return (
|
||||||
"저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. "
|
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. "
|
||||||
+ (f"사유: {error}" if error else "평가가 완료되면 코칭 항목이 갱신됩니다.")
|
"AI 평가 재시도가 필요합니다."
|
||||||
)
|
)
|
||||||
rationale = _compact_text(str(payload.get("supervisor_rationale") or ""))
|
rationale = _compact_text(str(payload.get("supervisor_rationale") or ""))
|
||||||
critique = _compact_text(str(payload.get("supervisor_critique") or ""))
|
critique = _compact_text(str(payload.get("supervisor_critique") or ""))
|
||||||
|
|
@ -1159,9 +1158,10 @@ def _review_note_from_turn_eval(
|
||||||
return ReviewNote(
|
return ReviewNote(
|
||||||
author="평가 AI",
|
author="평가 AI",
|
||||||
tone="warn",
|
tone="warn",
|
||||||
title="턴 평가 실패",
|
title="턴 직후 평가 실패",
|
||||||
body=_review_note_body_markdown(
|
body=_review_note_body_markdown(
|
||||||
f"이 발화의 fast-loop 평가를 완료하지 못했습니다.\n\n사유: {error_text}"
|
"이 발화의 fast-loop(턴 직후) 평가를 완료하지 못했습니다.\n\n"
|
||||||
|
"AI 평가 재시도가 필요합니다."
|
||||||
),
|
),
|
||||||
quote=quote,
|
quote=quote,
|
||||||
)
|
)
|
||||||
|
|
@ -1178,7 +1178,7 @@ def _review_note_from_turn_eval(
|
||||||
return ReviewNote(
|
return ReviewNote(
|
||||||
author="평가 AI",
|
author="평가 AI",
|
||||||
tone="warn",
|
tone="warn",
|
||||||
title=f"의도와 다른 부분 · {dimension}".rstrip(" ·") or "의도와 다른 부분",
|
title=f"의도와 다른 부분 · {dimension} · 턴 직후".replace(" · · ", " · ").rstrip(" ·"),
|
||||||
body=_review_note_body_markdown(body or "권장 반응과 실제 반응에 차이가 있었어요."),
|
body=_review_note_body_markdown(body or "권장 반응과 실제 반응에 차이가 있었어요."),
|
||||||
quote=quote,
|
quote=quote,
|
||||||
)
|
)
|
||||||
|
|
@ -1188,7 +1188,7 @@ def _review_note_from_turn_eval(
|
||||||
return ReviewNote(
|
return ReviewNote(
|
||||||
author="평가 AI",
|
author="평가 AI",
|
||||||
tone="good",
|
tone="good",
|
||||||
title="적절한 개입",
|
title="적절한 개입 · 턴 직후",
|
||||||
body=_review_note_body_markdown(
|
body=_review_note_body_markdown(
|
||||||
note_text
|
note_text
|
||||||
or (
|
or (
|
||||||
|
|
@ -1203,7 +1203,7 @@ def _review_note_from_turn_eval(
|
||||||
return ReviewNote(
|
return ReviewNote(
|
||||||
author="평가 AI",
|
author="평가 AI",
|
||||||
tone="warn",
|
tone="warn",
|
||||||
title="점검해볼 지점",
|
title="점검해볼 지점 · 턴 직후",
|
||||||
body=_review_note_body_markdown(note_text),
|
body=_review_note_body_markdown(note_text),
|
||||||
quote=quote,
|
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: dict[str, Any] | None = None
|
||||||
self._payload_builder = EngineClient(base_url="http://engine.test")
|
self._payload_builder = EngineClient(base_url="http://engine.test")
|
||||||
self.text = text
|
self.text = text
|
||||||
|
self.requests: list[Any] = []
|
||||||
|
|
||||||
async def generate(self, req):
|
async def generate(self, req):
|
||||||
self.request = req
|
self.request = req
|
||||||
|
self.requests.append(req)
|
||||||
self.payload = self._payload_builder._payload(req)
|
self.payload = self._payload_builder._payload(req)
|
||||||
return GenerateResponse(
|
return GenerateResponse(
|
||||||
text=self.text,
|
text=self.text,
|
||||||
|
|
@ -283,6 +285,26 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertIn("자동적 사고", blob)
|
self.assertIn("자동적 사고", blob)
|
||||||
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:
|
async def test_run_turn_generate_sends_only_masked_engine_payload(self) -> None:
|
||||||
ctx = _prepare_context()
|
ctx = _prepare_context()
|
||||||
engine = CaptureGenerateEngine()
|
engine = CaptureGenerateEngine()
|
||||||
|
|
@ -395,6 +417,78 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertNotIn("[NAME]", result.client_reply or "")
|
self.assertNotIn("[NAME]", result.client_reply or "")
|
||||||
self.assertIn("그 이름은 그대로고", 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:
|
async def test_run_turn_stream_humanizes_split_masked_placeholder_reply(self) -> None:
|
||||||
ctx = orchestrator.prepare_turn(
|
ctx = orchestrator.prepare_turn(
|
||||||
session_id="masking-session",
|
session_id="masking-session",
|
||||||
|
|
@ -422,6 +516,38 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertIn("그 이름은 그대로고요.", streamed)
|
self.assertIn("그 이름은 그대로고요.", streamed)
|
||||||
self.assertEqual(events[-1].event, "done")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -621,9 +621,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||||
review_turn = review.turns[0]
|
review_turn = review.turns[0]
|
||||||
self.assertIsNotNone(review_turn.note)
|
self.assertIsNotNone(review_turn.note)
|
||||||
assert review_turn.note is not None
|
assert review_turn.note is not None
|
||||||
self.assertEqual(review_turn.note.title, "턴 평가 실패")
|
self.assertEqual(review_turn.note.title, "턴 직후 평가 실패")
|
||||||
self.assertIn("fast-loop 평가를 완료하지 못했습니다", review_turn.note.body)
|
self.assertIn("fast-loop(턴 직후) 평가를 완료하지 못했습니다", review_turn.note.body)
|
||||||
self.assertIn("RuntimeError", 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("김서연", review_turn.note.body)
|
||||||
self.assertNotIn("010-1234-5678", 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]
|
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.assertEqual([event.event for event in events], ["error"])
|
||||||
self.assertIn("engine unavailable", events[1].data["detail"])
|
self.assertIn("engine unavailable", events[0].data["detail"])
|
||||||
|
|
||||||
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
|
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
|
||||||
principal = _principal()
|
principal = _principal()
|
||||||
|
|
@ -1667,7 +1668,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertFalse(response.reviewReady)
|
self.assertFalse(response.reviewReady)
|
||||||
self.assertTrue(response.degraded)
|
self.assertTrue(response.degraded)
|
||||||
self.assertEqual(response.supervisorState, "평가 실패")
|
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.rubric, [])
|
||||||
self.assertEqual(response.goodMoments, [])
|
self.assertEqual(response.goodMoments, [])
|
||||||
self.assertEqual(response.growthPoints, [])
|
self.assertEqual(response.growthPoints, [])
|
||||||
|
|
|
||||||
|
|
@ -338,22 +338,39 @@ async def close_session(sid: str):
|
||||||
# session_id 가 오면 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
|
# session_id 가 오면 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def _split_messages(messages: list[GwMessage]) -> GatewayPromptParts:
|
def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None) -> GatewayPromptParts:
|
||||||
"""EngineMessage[] → named prompt parts for the current gateway turn.
|
"""EngineMessage[] → named prompt parts for the current gateway turn."""
|
||||||
|
|
||||||
- system 들은 합쳐서 --system-prompt 로 주입할 텍스트로.
|
|
||||||
- 마지막 user 발화를 이번 턴 stdin content 로.
|
|
||||||
- 상주 세션 재사용 시에는 풀이 이미 컨텍스트를 들고 있으므로 마지막 user 만 보냄.
|
|
||||||
"""
|
|
||||||
system_parts: list[str] = []
|
system_parts: list[str] = []
|
||||||
last_user = ""
|
non_system: list[GwMessage] = []
|
||||||
for m in messages:
|
for m in messages:
|
||||||
if m.role == "system":
|
if m.role == "system":
|
||||||
system_parts.append(m.content)
|
system_parts.append(m.content)
|
||||||
elif m.role == "user":
|
else:
|
||||||
last_user = m.content
|
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())
|
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:
|
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")
|
@app.post("/v1/generate")
|
||||||
async def v1_generate(req: GwGenerateReq):
|
async def v1_generate(req: GwGenerateReq):
|
||||||
"""단발 생성 (평가 deep-loop, 회기종료 압축 등). GenerateResponse 호환 dict 반환."""
|
"""단발 생성 (평가 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)
|
system_prompt = _inject_schema(prompt_parts.system_prompt, req.structured_schema)
|
||||||
if not prompt_parts.user_payload:
|
if not prompt_parts.user_payload:
|
||||||
raise HTTPException(400, "no user message in payload")
|
raise HTTPException(400, "no user message in payload")
|
||||||
|
|
@ -436,7 +453,7 @@ async def v1_generate(req: GwGenerateReq):
|
||||||
@app.post("/v1/stream")
|
@app.post("/v1/stream")
|
||||||
async def v1_stream(req: GwGenerateReq):
|
async def v1_stream(req: GwGenerateReq):
|
||||||
"""SSE 토큰 스트림. data: 라인으로 텍스트 델타를 흘리고 done/error 프레이밍."""
|
"""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)
|
system_prompt = _inject_schema(prompt_parts.system_prompt, req.structured_schema)
|
||||||
if not prompt_parts.user_payload:
|
if not prompt_parts.user_payload:
|
||||||
raise HTTPException(400, "no user message in 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.system_prompt, "system one\n\nsystem two")
|
||||||
self.assertEqual(parts.user_payload, "current client")
|
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):
|
def test_split_messages_preserves_no_user_payload_boundary(self):
|
||||||
parts = gateway._split_messages(
|
parts = gateway._split_messages(
|
||||||
[contract.EngineMessage(role="system", content="system only")]
|
[contract.EngineMessage(role="system", content="system only")]
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,31 @@ async function routeSupervisorCapableLearner(page: Page) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function routeLearnerReviewUser(page: Page) {
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: "00000000-0000-0000-0000-000000000101",
|
||||||
|
email: "learner@hs.ac.kr",
|
||||||
|
role: "learner",
|
||||||
|
display_name: "E2E Learner",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: 1782820000,
|
||||||
|
onboarding_completed_at: 1782820001,
|
||||||
|
nickname: "E2E Learner",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isReviewResponse(sessionId: string) {
|
function isReviewResponse(sessionId: string) {
|
||||||
return (response: Response) => {
|
return (response: Response) => {
|
||||||
const url = new URL(response.url());
|
const url = new URL(response.url());
|
||||||
|
|
@ -201,6 +226,19 @@ test.describe("session review", () => {
|
||||||
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
|
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows supervisor evaluation scope boundaries", async ({ page }) => {
|
||||||
|
await routeSupervisorCapableLearner(page);
|
||||||
|
const sessionId = "supervisor-evaluation-scope";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
|
||||||
|
await page.goto(`/teach/session/${sessionId}/review`);
|
||||||
|
|
||||||
|
await expect(page.getByText("AI 평가 범위", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("현재 구현", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText(/fast-loop.*deep-loop/)).toBeVisible();
|
||||||
|
await expect(page.getByText("임상팀 확정 필요", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("refreshes an ended review from evaluation pending to ready", async ({ page }) => {
|
test("refreshes an ended review from evaluation pending to ready", async ({ page }) => {
|
||||||
await routeSupervisorCapableLearner(page);
|
await routeSupervisorCapableLearner(page);
|
||||||
await routePrepostMeasures(page);
|
await routePrepostMeasures(page);
|
||||||
|
|
@ -376,6 +414,10 @@ test.describe("session review", () => {
|
||||||
|
|
||||||
await page.goto(`/teach/session/${sessionId}/review`);
|
await page.goto(`/teach/session/${sessionId}/review`);
|
||||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("engine_error: evaluator timeout")).toHaveCount(0);
|
||||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
|
||||||
|
|
||||||
|
|
@ -438,7 +480,10 @@ test.describe("session review", () => {
|
||||||
|
|
||||||
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
|
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
|
||||||
|
|
||||||
await expect(page.getByText("engine_error: retry timeout")).toBeVisible();
|
await expect(
|
||||||
|
page.getByText("AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("engine_error: retry timeout")).toHaveCount(0);
|
||||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled();
|
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled();
|
||||||
expect(reevaluateRequests).toBe(1);
|
expect(reevaluateRequests).toBe(1);
|
||||||
|
|
@ -538,6 +583,50 @@ test.describe("session review", () => {
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps masked API transcript while naturalizing transcript placeholders", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await routeLearnerReviewUser(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "review-placeholder-display";
|
||||||
|
const response = filledReviewResponse(sessionId);
|
||||||
|
response.turns[0] = {
|
||||||
|
...response.turns[0],
|
||||||
|
text: "저는 [NAME]이고 [ORG]에서 상담받고 있어요.",
|
||||||
|
};
|
||||||
|
if (!response.caseWorksheet) {
|
||||||
|
throw new Error("filled review fixture must include a worksheet");
|
||||||
|
}
|
||||||
|
response.caseWorksheet.sections[0].items[0].evidence = [
|
||||||
|
{
|
||||||
|
turnId: "t1",
|
||||||
|
speaker: "client",
|
||||||
|
quote: "저는 [NAME]이고 [ORG]에서 상담받고 있어요.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(response),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId));
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
const reviewResponse = await reviewResponsePromise;
|
||||||
|
await expectResponseOk(reviewResponse);
|
||||||
|
const apiReview = await reviewResponse.json();
|
||||||
|
expect(apiReview.turns[0].text).toContain("[NAME]");
|
||||||
|
|
||||||
|
const transcript = page.locator(".sr-card--transcript");
|
||||||
|
await expect(transcript).toContainText(
|
||||||
|
"저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
|
||||||
|
);
|
||||||
|
await expect(transcript).not.toContainText("[NAME]");
|
||||||
|
await expect(page.locator(".sr-ws-evidence").filter({ hasText: "[NAME]" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("stores learner pre/post pilot evidence without effect claims", async ({ page }) => {
|
test("stores learner pre/post pilot evidence without effect claims", async ({ page }) => {
|
||||||
await signInAsLearner(page);
|
await signInAsLearner(page);
|
||||||
await routeFilledSessionReview(page);
|
await routeFilledSessionReview(page);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ function sleep(ms: number) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let e2eEmailCounter = 0;
|
||||||
|
|
||||||
|
function uniqueE2EEmail(prefix: string, domain: string) {
|
||||||
|
e2eEmailCounter += 1;
|
||||||
|
return `${prefix}.${Date.now()}.${e2eEmailCounter}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function withGlobalEngineConfigLock<T>(
|
export async function withGlobalEngineConfigLock<T>(
|
||||||
label: string,
|
label: string,
|
||||||
action: () => Promise<T>,
|
action: () => Promise<T>,
|
||||||
|
|
@ -64,7 +71,7 @@ export async function withGlobalEngineConfigLock<T>(
|
||||||
export async function signInAsLearner(page: Page) {
|
export async function signInAsLearner(page: Page) {
|
||||||
const res = await page.request.post("/api/auth/dev-login", {
|
const res = await page.request.post("/api/auth/dev-login", {
|
||||||
data: {
|
data: {
|
||||||
email: "learner@hs.ac.kr",
|
email: uniqueE2EEmail("learner", "hs.ac.kr"),
|
||||||
role: "learner",
|
role: "learner",
|
||||||
display_name: "E2E Learner",
|
display_name: "E2E Learner",
|
||||||
},
|
},
|
||||||
|
|
@ -83,7 +90,7 @@ export async function signInAsLearner(page: Page) {
|
||||||
export async function signInAsTeacher(page: Page) {
|
export async function signInAsTeacher(page: Page) {
|
||||||
const res = await page.request.post("/api/auth/dev-login", {
|
const res = await page.request.post("/api/auth/dev-login", {
|
||||||
data: {
|
data: {
|
||||||
email: "teacher@hs.ac.kr",
|
email: uniqueE2EEmail("teacher", "hs.ac.kr"),
|
||||||
role: "teacher",
|
role: "teacher",
|
||||||
display_name: "E2E Teacher",
|
display_name: "E2E Teacher",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4006,6 +4006,8 @@ export interface components {
|
||||||
crisis_resource?: components["schemas"]["CrisisResourceResponse"] | null;
|
crisis_resource?: components["schemas"]["CrisisResourceResponse"] | null;
|
||||||
/** Effective Openness */
|
/** Effective Openness */
|
||||||
effective_openness: number;
|
effective_openness: number;
|
||||||
|
/** Output Error */
|
||||||
|
output_error?: string | null;
|
||||||
/**
|
/**
|
||||||
* Safety Flagged
|
* Safety Flagged
|
||||||
* @default false
|
* @default false
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,7 @@ export interface SessionStreamDone {
|
||||||
crisis_kind?: string;
|
crisis_kind?: string;
|
||||||
crisis_resource?: CrisisResource | null;
|
crisis_resource?: CrisisResource | null;
|
||||||
conversation_stopped?: boolean;
|
conversation_stopped?: boolean;
|
||||||
|
output_error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeParse(data: string): unknown {
|
function safeParse(data: string): unknown {
|
||||||
|
|
@ -346,6 +347,7 @@ export async function openSessionStream(
|
||||||
crisis_kind: parsed.crisis_kind,
|
crisis_kind: parsed.crisis_kind,
|
||||||
crisis_resource: parsed.crisis_resource,
|
crisis_resource: parsed.crisis_resource,
|
||||||
conversation_stopped: parsed.conversation_stopped,
|
conversation_stopped: parsed.conversation_stopped,
|
||||||
|
output_error: parsed.output_error,
|
||||||
};
|
};
|
||||||
handlers.onDone?.(donePayload);
|
handlers.onDone?.(donePayload);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1503,6 +1503,11 @@ export default function Session() {
|
||||||
if (done.conversation_stopped || done.crisis_resource) {
|
if (done.conversation_stopped || done.crisis_resource) {
|
||||||
conversationStopped = true;
|
conversationStopped = true;
|
||||||
}
|
}
|
||||||
|
const qualityRetryable = done.output_error === "client_reply_quality_retryable";
|
||||||
|
if (qualityRetryable) {
|
||||||
|
setTurnError("내담자 응답 품질을 확인하지 못했습니다. 발화를 조금 다듬어 다시 시도해 주세요.");
|
||||||
|
pushSignal("warn", "내담자 응답 재시도 필요");
|
||||||
|
}
|
||||||
|
|
||||||
if (clientId != null) {
|
if (clientId != null) {
|
||||||
const id = clientId;
|
const id = clientId;
|
||||||
|
|
@ -1528,12 +1533,12 @@ export default function Session() {
|
||||||
setUtterances((prev) =>
|
setUtterances((prev) =>
|
||||||
prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
|
prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
|
||||||
);
|
);
|
||||||
if (!conversationStopped) {
|
if (!conversationStopped && !qualityRetryable) {
|
||||||
pushSignal("warn", "내담자 응답 없음");
|
pushSignal("warn", "내담자 응답 없음");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAvatarState("listening");
|
setAvatarState("listening");
|
||||||
if (!conversationStopped) {
|
if (!conversationStopped && !qualityRetryable) {
|
||||||
void requestLiveCoach({
|
void requestLiveCoach({
|
||||||
learnerText: text,
|
learnerText: text,
|
||||||
clientReply,
|
clientReply,
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,35 @@ function renderSummary(summary: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayReviewSummary(summary: string) {
|
||||||
|
if (/(?:사유:\s*)?(?:engine_error|session evaluation timeout|timeout|RuntimeError)/i.test(summary)) {
|
||||||
|
return "저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다.";
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayEvaluationRetryError(message: string) {
|
||||||
|
if (!message.trim()) return "AI 평가를 다시 실행하지 못했습니다.";
|
||||||
|
return "AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVIEW_DISPLAY_PLACEHOLDERS: Record<string, string> = {
|
||||||
|
"[NAME]": "익명 내담자",
|
||||||
|
"[ORG]": "소속 기관",
|
||||||
|
"[PHONE]": "연락처",
|
||||||
|
"[EMAIL]": "이메일",
|
||||||
|
"[RRN]": "주민등록번호",
|
||||||
|
"[ADDRESS]": "주소",
|
||||||
|
};
|
||||||
|
|
||||||
|
function displayTranscriptText(text: string) {
|
||||||
|
let next = text;
|
||||||
|
for (const [placeholder, label] of Object.entries(REVIEW_DISPLAY_PLACEHOLDERS)) {
|
||||||
|
next = next.split(placeholder).join(label);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldPollReviewReady(data: SessionReviewResponse) {
|
function shouldPollReviewReady(data: SessionReviewResponse) {
|
||||||
return (
|
return (
|
||||||
!data.reviewReady &&
|
!data.reviewReady &&
|
||||||
|
|
@ -298,6 +327,27 @@ function SupervisorCallout({ note }: { note: ReviewNote }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EvaluationScopeNotice() {
|
||||||
|
return (
|
||||||
|
<div className="sr-eval-scope" aria-label="AI 평가 범위 안내">
|
||||||
|
<div className="sr-eval-scope__title">
|
||||||
|
<Icon name="info" size={14} strokeWidth={2.2} />
|
||||||
|
<b>AI 평가 범위</b>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>현재 구현</dt>
|
||||||
|
<dd>fast-loop는 턴 직후 발화를 점검하고, deep-loop는 종료 후 회기 전체를 재분석합니다.</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>임상팀 확정 필요</dt>
|
||||||
|
<dd>점수 해석 기준, 교수자 최종 판정 문구, 기관별 루브릭 가중치는 아직 확정 전입니다.</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function JumpablePoint({
|
function JumpablePoint({
|
||||||
point,
|
point,
|
||||||
turns,
|
turns,
|
||||||
|
|
@ -838,13 +888,13 @@ export default function SessionReview() {
|
||||||
}
|
}
|
||||||
setReviewReloadSeq((seq) => seq + 1);
|
setReviewReloadSeq((seq) => seq + 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEvaluationRetryError(
|
const message =
|
||||||
err instanceof ApiError
|
err instanceof ApiError
|
||||||
? err.detail
|
? err.detail
|
||||||
: err instanceof Error
|
: err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "AI 평가를 다시 실행하지 못했습니다.",
|
: "";
|
||||||
);
|
setEvaluationRetryError(displayEvaluationRetryError(message));
|
||||||
} finally {
|
} finally {
|
||||||
setEvaluationRetrying(false);
|
setEvaluationRetrying(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1042,7 +1092,7 @@ export default function SessionReview() {
|
||||||
<Card className="sr-overview">
|
<Card className="sr-overview">
|
||||||
<Kicker>리뷰 요약</Kicker>
|
<Kicker>리뷰 요약</Kicker>
|
||||||
<p className="sr-summary">
|
<p className="sr-summary">
|
||||||
{renderSummary(data.summary)}
|
{renderSummary(displayReviewSummary(data.summary))}
|
||||||
</p>
|
</p>
|
||||||
<div className="sr-readiness" aria-label="리뷰 생성 상태">
|
<div className="sr-readiness" aria-label="리뷰 생성 상태">
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -1250,7 +1300,7 @@ export default function SessionReview() {
|
||||||
(turn.speaker === "client" ? " sr-turn__said--client" : "")
|
(turn.speaker === "client" ? " sr-turn__said--client" : "")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{turn.text}
|
{displayTranscriptText(turn.text)}
|
||||||
</p>
|
</p>
|
||||||
{turn.note ? <SupervisorCallout note={turn.note} /> : null}
|
{turn.note ? <SupervisorCallout note={turn.note} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1279,6 +1329,7 @@ export default function SessionReview() {
|
||||||
{teacherReviewStatusLabel}
|
{teacherReviewStatusLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<EvaluationScopeNotice />
|
||||||
<label className="sr-teacher-review__note">
|
<label className="sr-teacher-review__note">
|
||||||
<span>검토 메모</span>
|
<span>검토 메모</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -1491,7 +1542,7 @@ export default function SessionReview() {
|
||||||
<div className={`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`}>
|
<div className={`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`}>
|
||||||
<div className="sr-feedback__kicker">
|
<div className="sr-feedback__kicker">
|
||||||
<span className="sr-technique__dot" aria-hidden="true" />
|
<span className="sr-technique__dot" aria-hidden="true" />
|
||||||
내담자가 남긴 것
|
마지막 내담자 반응
|
||||||
</div>
|
</div>
|
||||||
{data.clientFeedback ? (
|
{data.clientFeedback ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,45 @@
|
||||||
.sr-teacher-review__head--sub {
|
.sr-teacher-review__head--sub {
|
||||||
margin-bottom: var(--sp-2);
|
margin-bottom: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
.sr-eval-scope {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
padding: 10px 11px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface-2);
|
||||||
|
}
|
||||||
|
.sr-eval-scope__title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.sr-eval-scope dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sr-eval-scope dl div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.sr-eval-scope dt {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 740;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.sr-eval-scope dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.sr-teacher-review__worksheet {
|
.sr-teacher-review__worksheet {
|
||||||
margin-top: var(--sp-4);
|
margin-top: var(--sp-4);
|
||||||
padding-top: var(--sp-4);
|
padding-top: var(--sp-4);
|
||||||
|
|
@ -1468,6 +1507,7 @@
|
||||||
[data-theme="dark"] .sr-readiness span,
|
[data-theme="dark"] .sr-readiness span,
|
||||||
[data-theme="dark"] .sr-empty,
|
[data-theme="dark"] .sr-empty,
|
||||||
[data-theme="dark"] .sr-chip-toggle,
|
[data-theme="dark"] .sr-chip-toggle,
|
||||||
|
[data-theme="dark"] .sr-eval-scope,
|
||||||
[data-theme="dark"] .sr-ws-item,
|
[data-theme="dark"] .sr-ws-item,
|
||||||
[data-theme="dark"] .sr-prepost__field input,
|
[data-theme="dark"] .sr-prepost__field input,
|
||||||
[data-theme="dark"] .sr-feedback,
|
[data-theme="dark"] .sr-feedback,
|
||||||
|
|
|
||||||
|
|
@ -906,6 +906,8 @@
|
||||||
}
|
}
|
||||||
.sx-transcript__scroll {
|
.sx-transcript__scroll {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
|
|
@ -913,6 +915,10 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.sx-transcript__scroll::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
.sx-transcript__empty {
|
.sx-transcript__empty {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -3686,6 +3692,9 @@
|
||||||
.sx-mobile-context__row span {
|
.sx-mobile-context__row span {
|
||||||
padding: 7px 6px;
|
padding: 7px 6px;
|
||||||
}
|
}
|
||||||
|
.sx-page--active .sx-transcript__scroll {
|
||||||
|
min-height: 152px;
|
||||||
|
}
|
||||||
.sx-page--active .sx-controlbar {
|
.sx-page--active .sx-controlbar {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -3833,6 +3842,25 @@
|
||||||
.sx-page--active .sx-stage__now {
|
.sx-page--active .sx-stage__now {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.sx-page--active .sx-transcript__scroll {
|
||||||
|
min-height: 110px;
|
||||||
|
}
|
||||||
|
.sx-page--active .sx-transcript__empty {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.sx-page--active .sx-transcript__empty-dot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sx-page--active .sx-transcript__empty b {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.sx-page--active .sx-transcript__empty span {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
.sx-page--active .sx-mic-block__ms {
|
.sx-page--active .sx-mic-block__ms {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
@ -3900,14 +3928,18 @@
|
||||||
height: 58px !important;
|
height: 58px !important;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-transcript {
|
.sx-page--active .sx-transcript {
|
||||||
padding: 6px 8px;
|
padding: 3px 6px;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-transcript__head {
|
.sx-page--active .sx-transcript__head {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.sx-page--active .sx-transcript__empty {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 6px;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-compose {
|
.sx-page--active .sx-compose {
|
||||||
margin-top: 5px;
|
margin-top: 3px;
|
||||||
padding-top: 5px;
|
padding-top: 3px;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-compose textarea {
|
.sx-page--active .sx-compose textarea {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@
|
||||||
| [`ops/public-runtime-watchdog.md`](./ops/public-runtime-watchdog.md) | 공개 런타임 워치독 설치·복구 절차(런북) |
|
| [`ops/public-runtime-watchdog.md`](./ops/public-runtime-watchdog.md) | 공개 런타임 워치독 설치·복구 절차(런북) |
|
||||||
| [`ops/tailscale-vnet-runtime-2026-06-27.md`](./ops/tailscale-vnet-runtime-2026-06-27.md) | Tailnet 런타임 + 공개 vnet 외부 게이트 추적 |
|
| [`ops/tailscale-vnet-runtime-2026-06-27.md`](./ops/tailscale-vnet-runtime-2026-06-27.md) | Tailnet 런타임 + 공개 vnet 외부 게이트 추적 |
|
||||||
| [`ops/hanshin-data-governance-gate.md`](./ops/hanshin-data-governance-gate.md) | 한신대 데이터/SSO 외부 증거 게이트 체크리스트 |
|
| [`ops/hanshin-data-governance-gate.md`](./ops/hanshin-data-governance-gate.md) | 한신대 데이터/SSO 외부 증거 게이트 체크리스트 |
|
||||||
|
| [`ops/hanshin-feedback-improvement-plan-2026-07-03.md`](./ops/hanshin-feedback-improvement-plan-2026-07-03.md) | 한신대 실사용 오류분석 기반 개선 계획(P0~P4·레드팀 반영) |
|
||||||
|
| [`redteam/hanshin-feedback-plan-redteam-2026-07-03.md`](./redteam/hanshin-feedback-plan-redteam-2026-07-03.md) | 한신대 개선 계획 적대 검토(컨텍스트·privacy·fallback·임상 설명 위험) |
|
||||||
|
| [`redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md`](./redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md) | 한신대 개선 계획 대레드팀 검토(과잉 차단 완화·1차 MVP 재정렬) |
|
||||||
| [`ops/code-quality-research-2026-06-26.md`](./ops/code-quality-research-2026-06-26.md) | 코드품질 분석 + 미실행 리팩터 로드맵 |
|
| [`ops/code-quality-research-2026-06-26.md`](./ops/code-quality-research-2026-06-26.md) | 코드품질 분석 + 미실행 리팩터 로드맵 |
|
||||||
| [`ops/postgres-rls-audit-smoke.md`](./ops/postgres-rls-audit-smoke.md) | RLS·감사로그 격리 스모크 절차(재현 런북) |
|
| [`ops/postgres-rls-audit-smoke.md`](./ops/postgres-rls-audit-smoke.md) | RLS·감사로그 격리 스모크 절차(재현 런북) |
|
||||||
| [`ops/engine-rss-smoke-2026-06-28.md`](./ops/engine-rss-smoke-2026-06-28.md) · [`ops/resistance-openness-db-smoke-2026-06-28.md`](./ops/resistance-openness-db-smoke-2026-06-28.md) | 대시보드가 인용하는 live 실측 증거 |
|
| [`ops/engine-rss-smoke-2026-06-28.md`](./ops/engine-rss-smoke-2026-06-28.md) · [`ops/resistance-openness-db-smoke-2026-06-28.md`](./ops/resistance-openness-db-smoke-2026-06-28.md) | 대시보드가 인용하는 live 실측 증거 |
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@
|
||||||
- [ ] **대체 TTS provider 선정·통합** — 상업 라이선스 명확한 provider. dev sample(`VIGNETTE_VOICE_POC_SAMPLE_TTS`)과 서비스 경로 분리 유지.
|
- [ ] **대체 TTS provider 선정·통합** — 상업 라이선스 명확한 provider. dev sample(`VIGNETTE_VOICE_POC_SAMPLE_TTS`)과 서비스 경로 분리 유지.
|
||||||
- [ ] **재귀학습 fine-tuning 파이프라인** — 동의/데이터셋 게이트 충족 후. few-shot 자동갱신은 선행 가능.
|
- [ ] **재귀학습 fine-tuning 파이프라인** — 동의/데이터셋 게이트 충족 후. few-shot 자동갱신은 선행 가능.
|
||||||
- [ ] **헬스 retention 적용·검증** — raw 90일 / rollup 365일. escalation/그룹 자동화는 미도입 고정.
|
- [ ] **헬스 retention 적용·검증** — raw 90일 / rollup 365일. escalation/그룹 자동화는 미도입 고정.
|
||||||
|
- [x] **한신대 실사용 피드백 1차 개선팩** — `ops/hanshin-feedback-improvement-plan-2026-07-03.md`,
|
||||||
|
`redteam/hanshin-feedback-plan-redteam-2026-07-03.md`, `redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md`
|
||||||
|
기준 P0~P4 1차 완료. 완료 범위: role mapping + client-only stateless history injection, review UI 표시 자연어화,
|
||||||
|
raw error display mapper, fast/deep 라벨, 명백한 출력 결함 차단, SSE full-response buffer, 교수자 설명 패널.
|
||||||
|
검증 수치는 SSOT 대시보드만 소유한다. 남은 후속은 저장형 fallback/synthetic 계약, 지연을 줄이는 partial stream buffer,
|
||||||
|
fast/deep reconciliation, resident session namespace, 임상팀 루브릭/CBT/약물·평가 문구 확정.
|
||||||
|
|
||||||
## E. 코드 품질·리팩터 로드맵 [구현] (비게이트 — 여유 시)
|
## E. 코드 품질·리팩터 로드맵 [구현] (비게이트 — 여유 시)
|
||||||
> 출처: `ops/code-quality-research-2026-06-26.md` · `decisions/backend-node-transition.md` next candidates.
|
> 출처: `ops/code-quality-research-2026-06-26.md` · `decisions/backend-node-transition.md` next candidates.
|
||||||
|
|
|
||||||
|
|
@ -837,6 +837,7 @@
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>과거 Live2D demo 자산 제거</b><p>Mao/Haru 샘플, Pixi/Cubism 런타임, 공개 <code>/live2d/*</code> 캐시 잔여 접근을 차단했다.</p></div><div><b>산출물</b><p><code>apps/web/functions/live2d/[[path]].js</code>, P1 PSB raster avatar + SVG fallback</p></div><div><b>검증</b><p>운영 <code>/live2d/mao/*</code>, Cubism core <code>404</code></p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>과거 Live2D demo 자산 제거</b><p>Mao/Haru 샘플, Pixi/Cubism 런타임, 공개 <code>/live2d/*</code> 캐시 잔여 접근을 차단했다.</p></div><div><b>산출물</b><p><code>apps/web/functions/live2d/[[path]].js</code>, P1 PSB raster avatar + SVG fallback</p></div><div><b>검증</b><p>운영 <code>/live2d/mao/*</code>, Cubism core <code>404</code></p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>프로세스 난립 정리</b><p>운영 확인용 프로세스와 로컬/Tailnet 검증용 dev 프로세스를 의도적으로 분리해 유지한다.</p></div><div><b>산출물</b><p>9099 engine, 8001 prod API, 8000/8010 dev API, 5173 web, cloudflared tunnel 1개</p></div><div><b>검증</b><p><code>Get-NetTCPConnection</code>에서 대상 포트별 listener 확인</p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>프로세스 난립 정리</b><p>운영 확인용 프로세스와 로컬/Tailnet 검증용 dev 프로세스를 의도적으로 분리해 유지한다.</p></div><div><b>산출물</b><p>9099 engine, 8001 prod API, 8000/8010 dev API, 5173 web, cloudflared tunnel 1개</p></div><div><b>검증</b><p><code>Get-NetTCPConnection</code>에서 대상 포트별 listener 확인</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-plan">GATE</span></div><div><b>한신대 공문/데이터 거버넌스 게이트 정리</b><p>SSO 클레임, 추가 축어록 수급, 미성년 원본 활용동의, 개인정보 처리방침을 P1 진입 전 외부 의존성으로 명확히 둔다. 로컬 문서 골격은 준비됐지만 한신대/데이터 steward의 written evidence는 아직 필요하다.</p></div><div><b>산출물</b><p><code>docs/ops/hanshin-data-governance-gate.md</code>, 공문 질의 항목, 동의 범위 체크리스트, SSO claim mapping 표</p></div><div><b>검증</b><p>문서 artifact 작성 완료; IRB 게이트가 아니라 데이터/SSO 게이트로 외부 증거 필요 상태 유지</p></div></div>
|
<div class="task-row"><div><span class="task-status s-plan">GATE</span></div><div><b>한신대 공문/데이터 거버넌스 게이트 정리</b><p>SSO 클레임, 추가 축어록 수급, 미성년 원본 활용동의, 개인정보 처리방침을 P1 진입 전 외부 의존성으로 명확히 둔다. 로컬 문서 골격은 준비됐지만 한신대/데이터 steward의 written evidence는 아직 필요하다.</p></div><div><b>산출물</b><p><code>docs/ops/hanshin-data-governance-gate.md</code>, 공문 질의 항목, 동의 범위 체크리스트, SSO claim mapping 표</p></div><div><b>검증</b><p>문서 artifact 작성 완료; IRB 게이트가 아니라 데이터/SSO 게이트로 외부 증거 필요 상태 유지</p></div></div>
|
||||||
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>한신대 실사용 피드백 1차 개선팩</b><p>오류분석 자료를 컨텍스트 보존, 내담자 출력 품질, 리뷰 privacy/오류문구 UX, 교수자 설명 가능성으로 분해했고 1차 구현을 완료했다. P1은 role mapping + client-only history injection을 원자 패치로 닫았고, P2/P3/P4는 저장형 fallback 없이 빠른 UX 개선으로 배포 가능한 상태다.</p></div><div><b>산출물</b><p><code>gateway._split_messages(ai_role=client)</code>, <code>guardrail.sanitize_client_reply()</code> 품질 게이트, SSE full-response buffer, 리뷰 표시 자연어화, raw error mapper, 교수자 <code>AI 평가 범위</code> 패널, 계획/레드팀/대레드팀 문서</p></div><div><b>검증</b><p><code>engine_gateway/test_gateway_model.py app/test_orchestrator_masking.py app/test_client_reply_quality.py app/test_session_turn_persistence.py</code> 84 passed, <code>npm run typecheck</code>, <code>npm run check:api-types</code>, <code>session-review.spec.ts</code> focused 8 passed, <code>layout-visual-gate.spec.ts</code> 12 passed, <code>session-layout.spec.ts</code> 8 passed.</p></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="source-note"><b>팀장 판정:</b> 로컬 회귀와 공개 prod-safe 게이트는 통과했다. 2026-06-29 수동 Cloudflare Pages production 배포가 현재 custom domain asset으로 확인됐고, 운영 반영 전 남은 핵심 증거는 공개 Google OAuth 실제 <code>/turn</code> smoke다. 런타임 mock/demo 자산은 운영 번들에서 제거하고 테스트 fixture만 남긴다.</div>
|
<div class="source-note"><b>팀장 판정:</b> 로컬 회귀와 공개 prod-safe 게이트는 통과했다. 2026-06-29 수동 Cloudflare Pages production 배포가 현재 custom domain asset으로 확인됐고, 운영 반영 전 남은 핵심 증거는 공개 Google OAuth 실제 <code>/turn</code> smoke다. 런타임 mock/demo 자산은 운영 번들에서 제거하고 테스트 fixture만 남긴다.</div>
|
||||||
<div class="tab-subhead"><h4>Phase 0 완료 상세 목록</h4><p>이전 "완료" 카드 내용을 탭 안에서 항목별로 보존한다.</p></div>
|
<div class="tab-subhead"><h4>Phase 0 완료 상세 목록</h4><p>이전 "완료" 카드 내용을 탭 안에서 항목별로 보존한다.</p></div>
|
||||||
|
|
@ -985,6 +986,7 @@
|
||||||
<tr><td>Voice UI synthetic E2E</td><td><code>npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run</code> / <code>PLAYWRIGHT_PORT=5248 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1 --grep "pending voice transcript"</code></td><td>voice-success 2 passed + pending transcript failure UI 1 passed; direct WS cascade, Session mic button path with synthetic browser audio/Web Audio playback, and final transcript followed by persistence failure becoming a visible <code>저장 실패</code> turn instead of a silent successful transcript</td></tr>
|
<tr><td>Voice UI synthetic E2E</td><td><code>npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run</code> / <code>PLAYWRIGHT_PORT=5248 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1 --grep "pending voice transcript"</code></td><td>voice-success 2 passed + pending transcript failure UI 1 passed; direct WS cascade, Session mic button path with synthetic browser audio/Web Audio playback, and final transcript followed by persistence failure becoming a visible <code>저장 실패</code> turn instead of a silent successful transcript</td></tr>
|
||||||
<tr><td>Voice s2s decision memo</td><td><code>docs/decisions/voice-s2s-poc.md</code></td><td>criteria recorded; keep/drop decision remains owner DECIDE</td></tr>
|
<tr><td>Voice s2s decision memo</td><td><code>docs/decisions/voice-s2s-poc.md</code></td><td>criteria recorded; keep/drop decision remains owner DECIDE</td></tr>
|
||||||
<tr><td>Hanshin data/SSO gate</td><td><code>docs/ops/hanshin-data-governance-gate.md</code></td><td>artifact created; written external evidence still required</td></tr>
|
<tr><td>Hanshin data/SSO gate</td><td><code>docs/ops/hanshin-data-governance-gate.md</code></td><td>artifact created; written external evidence still required</td></tr>
|
||||||
|
<tr><td>Hanshin feedback 1차 improvement pack</td><td><code>docs/ops/hanshin-feedback-improvement-plan-2026-07-03.md</code> / <code>docs/redteam/hanshin-feedback-plan-redteam-2026-07-03.md</code> / <code>docs/redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md</code> / backend+web implementation</td><td>1차 implemented: P1 role mapping + client-only history injection, P2 quality retry/no-save gate + SSE full-response buffer, P3 review display humanization/raw error mapper/fast-deep labels/client feedback label, P4 supervisor scope panel. Verification: backend focused 84 passed, web typecheck and API type check passed, session-review focused 8 passed, layout visual gate 12 passed, session-layout 8 passed. Remaining follow-ups are stored fallback metadata, lower-latency partial stream policy, fast/deep reconciliation, resident namespace/prompt caching, and clinical rubric/CBT wording gates.</td></tr>
|
||||||
<tr><td>Resistance engine</td><td><code>app.test_state_machine_resistance</code> + <code>scripts/smoke-resistance-openness-db.py</code></td><td>9 focused tests OK; live DB smoke PASS. Empathy P1 curve <code>0→0→0.095→0.133→0.171</code> and DB final <code>stage=탐색</code>. Advice-jump curve stayed <code>0</code> and DB final <code>stage=라포</code>.</td></tr>
|
<tr><td>Resistance engine</td><td><code>app.test_state_machine_resistance</code> + <code>scripts/smoke-resistance-openness-db.py</code></td><td>9 focused tests OK; live DB smoke PASS. Empathy P1 curve <code>0→0→0.095→0.133→0.171</code> and DB final <code>stage=탐색</code>. Advice-jump curve stayed <code>0</code> and DB final <code>stage=라포</code>.</td></tr>
|
||||||
<tr><td>Python compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr>
|
<tr><td>Python compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr>
|
||||||
<tr><td>Phase 3 artifact gates</td><td><code>py -3.11 -X utf8 scripts\check-phase3-artifacts.py --help</code> + <code>py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_dataset_export.py app/test_phase3_artifact_checker.py -q</code></td><td>16 passed; checker now enforces CSV enums, KPI metric required fields/status, approved export PII/agreement/consent/withdrawal/file-hash gates, and approved/dry-run dataset JSONL required keys, row count, privacy, and PII shape. Actual pilot evidence still external.</td></tr>
|
<tr><td>Phase 3 artifact gates</td><td><code>py -3.11 -X utf8 scripts\check-phase3-artifacts.py --help</code> + <code>py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_dataset_export.py app/test_phase3_artifact_checker.py -q</code></td><td>16 passed; checker now enforces CSV enums, KPI metric required fields/status, approved export PII/agreement/consent/withdrawal/file-hash gates, and approved/dry-run dataset JSONL required keys, row count, privacy, and PII shape. Actual pilot evidence still external.</td></tr>
|
||||||
|
|
|
||||||
318
docs/ops/hanshin-feedback-improvement-plan-2026-07-03.md
Normal file
318
docs/ops/hanshin-feedback-improvement-plan-2026-07-03.md
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
# 한신대 실사용 피드백 개선 계획
|
||||||
|
|
||||||
|
작성일: 2026-07-03
|
||||||
|
상태: 1차 구현 완료 / 레드팀·대레드팀 반영
|
||||||
|
근거 자료: 한신대 실사용 오류분석 DOCX/HWP, 저장소 정적 조사, 서브에이전트 4축 조사, 레드팀 2축 적대 검토, 대레드팀 2축 균형 검토
|
||||||
|
|
||||||
|
> 이 문서는 구현 계획이다. 현재 상태·검증 수치·로드맵 권위 기준은 `docs/dev_dashboard.html`이고, 열린 작업 인덱스는 `docs/TODO.md`가 소유한다.
|
||||||
|
|
||||||
|
## 1. 결론
|
||||||
|
|
||||||
|
한신대 자료에서 나온 문제는 단일 원인이 아니다. 가장 큰 축은 다음 네 가지다.
|
||||||
|
|
||||||
|
1. **대화 컨텍스트가 엔진 입력까지 안정적으로 전달되지 않는 구조**
|
||||||
|
2. **내담자 응답 출력 품질 게이트 부재**
|
||||||
|
3. **리뷰 화면에서 저장/AI용 마스킹·평가 오류 코드가 그대로 노출되는 UX 문제**
|
||||||
|
4. **교수자에게 설명해야 할 작동 원리와 남은 임상팀 게이트가 제품 안에서 충분히 드러나지 않는 문제**
|
||||||
|
|
||||||
|
구현 순서는 대화 컨텍스트 안정화가 먼저다. 다만 레드팀 검토 결과, 컨텍스트 보존은 단독 패치가 아니라 **role mapping 수정과 gateway history 소비 변경을 같은 패치에서 원자적으로 처리**해야 한다. 현재 매핑이 잘못된 상태에서 history만 보존하면 상담자 발화를 내담자 과거 발화처럼 먹이는 역효과가 난다.
|
||||||
|
|
||||||
|
표시 UX도 같은 원칙이다. `[NAME]`을 없애 보이게 만드는 것이 목표가 아니다. **API·저장값·평가 입력·export의 privacy proof는 마스킹 토큰을 유지**하고, 사람에게 보이는 UI 표시층만 별도로 부드럽게 만드는 것이 목표다.
|
||||||
|
|
||||||
|
대레드팀 검토 결과, 레드팀 경고는 유지하되 사용자 가치 기준으로 등급화한다. **P1 내부의 role mapping + history injection은 원자 패치**로 유지한다. 반면 **P2/P3/P4는 독립적으로 빠르게 배포 가능한 1차 개선**으로 분리한다. 안전 조건이 “최종형을 한 번에 닫는 것”으로 비대해지면 한신대 사용자가 당장 겪은 불편을 줄이는 본래 목적을 잃는다.
|
||||||
|
|
||||||
|
2026-07-03 1차 구현 결과:
|
||||||
|
|
||||||
|
- P1: `persona.build_turn_messages()` role mapping을 `counselor -> user`, `client -> assistant`로 수정하고, gateway는 `ai_role=client` 요청에만 `[직전 대화]` history를 주입한다. evaluator/structured 요청은 history를 받지 않는다.
|
||||||
|
- P2: 역할 메타발화, 반복 인사, 직전 내담자 near-duplicate를 저장 전 차단한다. generate는 1회 재생성 후 실패 시 client turn을 저장하지 않고 `client_reply_quality_retryable`로 반환한다. SSE stream은 응답 전체를 buffer한 뒤 품질 가드 통과 시에만 token을 방출한다.
|
||||||
|
- P3: 리뷰 일반 transcript 표시층만 `[NAME]`/`[ORG]` 등을 자연어화하고, quote/evidence/API/export 마스킹 증거는 유지한다. raw 평가 오류는 학습자/교수자 UI에서 재시도 가능 문구로 매핑한다. fast-loop 턴 노트와 deep-loop 요약 라벨을 구분하고 `clientFeedback` 라벨을 “마지막 내담자 반응”으로 바꿨다.
|
||||||
|
- P4: 교수자 리뷰 카드 안에 “AI 평가 범위” 패널을 추가해 현재 구현과 임상팀 확정 필요 범위를 분리했다.
|
||||||
|
- 검증: backend focused 84 passed, `npm run typecheck`, `npm run check:api-types`, session-review focused E2E 8 passed, `layout-visual-gate.spec.ts` 12 passed, `session-layout.spec.ts` 8 passed.
|
||||||
|
|
||||||
|
## 2. 안전 조건과 MVP 등급
|
||||||
|
|
||||||
|
레드팀 차단 조건은 사용자 가치 기준으로 등급화한다.
|
||||||
|
|
||||||
|
| 영역 | 등급 | 적용 |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 컨텍스트 | 1차 하드 게이트 | `persona.build_turn_messages()` role mapping 수정과 gateway client-only history injection은 같은 패치로 처리한다. history 보존만 먼저 배포하지 않는다. |
|
||||||
|
| Gateway 계약 | 1차 하드 게이트 | `_split_messages()` 변경은 기존 테스트 계약 변경이다. 다만 P1 차단 테스트는 persona role mapping, gateway client-only history injection, evaluator no-history 계약으로 한정한다. |
|
||||||
|
| Evaluator 분리 | 1차 하드 게이트 | gateway history 직렬화는 `ai_role=client` 요청에만 적용한다. evaluator/deep-loop/structured output 요청은 client history를 받지 않는다. |
|
||||||
|
| Resident session | 2차 안정화 | 1차 구현은 stateless history injection으로 한정한다. resident session 재사용/prompt caching 복구는 client/eval namespace, cleanup, restart recovery 설계 뒤에만 켠다. |
|
||||||
|
| History window | 1차 단순 구현 + 2차 정교화 | 1차는 최근 대화를 라벨링해 주입하되, 가능하면 완성 turn pair를 우선한다. K-pair window 정교화는 P1 차단 조건이 아니라 2차 안정화다. |
|
||||||
|
| Quality fallback | 1차는 저장하지 않음 | 1차 품질 게이트는 fallback 저장을 목표로 하지 않는다. 재생성 후에도 역할 메타발화가 남으면 client turn을 저장하지 않고 재시도 가능한 오류로 표시한다. 저장형 fallback은 synthetic/fallback 메타데이터와 평가 제외 규칙이 준비된 뒤 도입한다. |
|
||||||
|
| Stream | 1차 안전 구현 완료 + 2차 UX 정교화 | SSE stream은 전체 응답을 buffer한 뒤 품질 가드 통과 시에만 token을 방출한다. 나쁜 출력은 token leak 없이 `client_reply_quality_retryable`로 끝낸다. 2차는 지연을 줄이는 안전한 부분 버퍼링/초기문장 정책이다. |
|
||||||
|
| Review privacy | 1차 하드 게이트 + UX 허용 | 마스킹 토큰 보존은 API·저장·export 계약이다. 리뷰 화면의 일반 transcript 표시는 동일 데이터를 렌더링 단계에서 자연어화할 수 있다. |
|
||||||
|
| Error copy | 1차 UX | DB/schema급 정비 전이라도 raw error를 학습자 UI에 직접 노출하지 않는 display mapper를 먼저 둔다. |
|
||||||
|
| fast-loop 라벨 | 1차 UX | “이 턴 직후 기준” 라벨은 완전한 reconciliation이 아니라 즉시 혼란을 줄이는 1차 개선이다. 후속 해명 반영 배지는 deep-loop 확장으로 분리한다. |
|
||||||
|
| 임상 설명 | 1차 신뢰 회복 | 교수자 설명은 “못 한다”가 아니라 현재 제품이 하는 것과 임상팀 확정이 필요한 것을 분리해 답한다. 확정되지 않은 진단·처방·루브릭 표현만 금지한다. |
|
||||||
|
| SSOT | 항상 유지 | 상태와 검증 수치는 `docs/dev_dashboard.html`만 소유하고, TODO/가이드/계획서는 링크와 요약만 둔다. |
|
||||||
|
|
||||||
|
## 3. 현상 정리
|
||||||
|
|
||||||
|
| 현상 | 분류 | 1차 판단 |
|
||||||
|
|---|---|---|
|
||||||
|
| 내담자가 같은 말을 2~3번 반복 | 대화 컨텍스트 / 출력 품질 | 최근 턴 보존과 중복 응답 게이트 필요 |
|
||||||
|
| 상담자가 질문했는데 내담자가 답하지 않고 다시 상담자 순서 | 대화 컨텍스트 / 턴 처리 | 엔진 입력 맥락과 스트림 완료 저장 경로 재검증 필요 |
|
||||||
|
| 이미 인사했는데 다시 인사 | 출력 품질 | 2턴 이후 반복 인사 차단 가능 |
|
||||||
|
| “내담자 역할로 응답하겠습니다” | 출력 품질 | 역할 메타발화 하드 차단 필요 |
|
||||||
|
| 문맥 오해 후 나중에 해명했는데 피드백은 이전 기준처럼 보임 | 평가 UX | fast-loop는 해당 턴 직후 기준임을 표시하고, 회기 전체 평가는 별도 라벨 필요 |
|
||||||
|
| `[NAME]`이 리뷰 화면에 표시 | 표시 계층 / privacy proof | API 증거는 유지하고 UI 표시층만 자연어화 |
|
||||||
|
| `no_structured_output` 평가 실패 | 평가 UX / 운영 관측 | 내부 오류 코드와 사용자 설명 문구 분리 |
|
||||||
|
| 진단기준·CBT·개방도 산식 질문 | 설명 가능성 / 임상 게이트 | 구현된 구조와 임상팀 확정 잔여를 분리해 설명 |
|
||||||
|
|
||||||
|
## 4. 근거가 된 코드 관찰
|
||||||
|
|
||||||
|
### 4.1 대화 컨텍스트
|
||||||
|
|
||||||
|
- `apps/api/engine_gateway/gateway.py`의 `_split_messages()`는 system 메시지를 합치고 마지막 `user`만 남긴다. 중간 `assistant`/`user` 히스토리는 현재 payload에서 사라진다.
|
||||||
|
- `_split_messages()`의 기존 테스트는 “마지막 user만 보낸다”를 계약처럼 고정하고 있다. 변경 시 테스트 추가가 아니라 계약 갱신이다.
|
||||||
|
- `apps/api/engine_gateway/gateway.py`의 `_resolve_session()`은 같은 `session_id`가 살아 있으면 새 system prompt를 무시하고 resident session을 재사용한다.
|
||||||
|
- `apps/api/app/routes/sessions.py`의 `start_session()`은 DB/app 세션은 만들지만 gateway `/session`을 호출해 resident session을 만들지는 않는다.
|
||||||
|
- `apps/api/app/services/orchestrator.py`는 앱 세션 ID를 `GenerateRequest`/`StreamRequest`의 `session_id`로 넘긴다. 그러나 이 ID는 gateway resident ID와 명시 매핑되어 있지 않다.
|
||||||
|
- `apps/api/app/services/persona.py`는 `recent_turns`를 `EngineMessage`로 추가하지만, 현재 gateway split 구조에서는 그 히스토리가 소비되지 않는다.
|
||||||
|
- 같은 파일의 recent turn role 매핑은 주석과 반대로 보인다. 내담자 AI 관점에서는 `counselor -> user`, `client -> assistant`가 맞다.
|
||||||
|
|
||||||
|
### 4.2 출력 품질
|
||||||
|
|
||||||
|
- `persona.py`의 L0 프롬프트는 “끝까지 내담자”, “메타 대화 금지”, “말투 유지”를 지시한다.
|
||||||
|
- `guardrail.py`의 `sanitize_client_reply()`는 자살수단 차단, ideation cap, 일부 placeholder 자연어화 중심이다.
|
||||||
|
- 역할 메타발화, 반복 인사, 직전 답변 중복, 너무 이른 회복/감사 표현은 실행 후 검사로 막고 있지 않다.
|
||||||
|
- generate 경로는 저장 전 전체 응답을 보고 정제할 수 있다. stream 경로는 이미 token을 내보내는 구조라 첫 문장 또는 일정 문자 수 버퍼링을 넣을 경우 화면/저장/평가 텍스트 동등성을 별도 검증해야 한다.
|
||||||
|
- voice 경로는 stream이 아니다. generate 전체 응답 후 TTS를 시작하므로 검증 포인트는 버퍼링보다 generate 지연, TTS 시작, persistence 실패 시 learner-only 기록 방지다.
|
||||||
|
|
||||||
|
### 4.3 평가와 리뷰
|
||||||
|
|
||||||
|
- fast-loop 평가는 “해당 상담자 발화 + 이어진 내담자 응답 + 제한된 직전 맥락” 중심의 턴 직후 평가다.
|
||||||
|
- deep-loop 평가는 회기 종료 후 전체 축어록을 본다.
|
||||||
|
- fast-loop 평가는 해당 학습자 발화 직후의 스냅샷이며 후속 해명을 소급 반영하지 않는다. 회기 전체 판단은 deep-loop 또는 교수자 검토에서 별도 표시한다.
|
||||||
|
- 리뷰 read-model은 턴 텍스트에 `text_masked`를 사용하므로 `[NAME]`이 표시 화면까지 올라올 수 있다.
|
||||||
|
- 평가 오류는 `no_structured_output`, `engine_error`, `parse_error` 같은 내부 코드가 사용자에게 그대로 보일 수 있다.
|
||||||
|
- `clientFeedback`는 실제 별도 피드백 산출물이 아니라 마지막 client turn으로 읽히는 경로라, 라벨과 포함 조건을 재검토해야 한다.
|
||||||
|
|
||||||
|
## 5. 실행 계획
|
||||||
|
|
||||||
|
### P0. 재현과 계측
|
||||||
|
|
||||||
|
목표: 고친 것처럼 보이는 패치를 막고, 한신대 자료의 주요 현상을 테스트로 고정한다.
|
||||||
|
|
||||||
|
작업:
|
||||||
|
|
||||||
|
- gateway 단위 테스트로 새 `_split_messages()` 계약을 고정한다.
|
||||||
|
- persona 메시지 테스트로 `counselor -> user`, `client -> assistant` 매핑을 검증한다.
|
||||||
|
- sessions/orchestrator fake engine 테스트로 generate/stream 주요 경로의 gateway payload에 최근 대화가 들어가는지 검증한다.
|
||||||
|
- evaluator/structured output 요청에는 client history가 들어가지 않는지 검증한다.
|
||||||
|
- 리뷰 read-model/API 테스트로 raw PII 미포함과 masked token 보존을 동시에 검증한다.
|
||||||
|
- fast-loop 오류 테스트로 내부 오류 코드가 학습자용 문구로 매핑되는지 검증한다.
|
||||||
|
- P2 1차는 fallback 저장이 아니라 명백한 출력 결함 감지/재시도/미저장 오류 응답을 검증한다.
|
||||||
|
|
||||||
|
검증 후보:
|
||||||
|
|
||||||
|
- `Push-Location apps/api; py -3.11 -X utf8 -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py app/test_orchestrator_masking.py app/test_evaluator_model_routing.py -q; Pop-Location`
|
||||||
|
- 통합 검증 단계: `app/test_session_turn_persistence.py`, `app/test_voice_ws.py`, session-review/session-persistence focused E2E
|
||||||
|
- `Push-Location apps/web; npm run typecheck; Pop-Location`
|
||||||
|
- 필요 시 `Push-Location apps/web; npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1; Pop-Location`
|
||||||
|
- DB/engine 가능 시 `Push-Location apps/web; npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "Korean PII|manual AI evaluation retry|case worksheet|selected CBT theory mode"; Pop-Location`
|
||||||
|
|
||||||
|
### P1. 대화 컨텍스트 안정화
|
||||||
|
|
||||||
|
목표: 반복, 재인사, 문맥 오해, 질문 맥락 미반영을 일으킬 수 있는 구조적 원인을 먼저 닫는다.
|
||||||
|
|
||||||
|
작업:
|
||||||
|
|
||||||
|
1. `persona.build_turn_messages()`의 recent turn role 매핑을 수정한다.
|
||||||
|
- `counselor -> user`
|
||||||
|
- `client -> assistant`
|
||||||
|
|
||||||
|
2. gateway `_split_messages()`가 client history를 버리지 않게 한다.
|
||||||
|
- 단기 구현은 `ai_role=client` 요청에만 non-system history를 current user payload 앞에 `[직전 대화]` 블록으로 직렬화한다.
|
||||||
|
- evaluator/deep-loop/structured output 요청에는 client history 직렬화를 적용하지 않는다.
|
||||||
|
- 1차는 최근 대화를 라벨링해 주입하고, 가능하면 완성 turn pair를 우선한다.
|
||||||
|
- 예시:
|
||||||
|
- `상담자: 왜 무슨 일 있어?`
|
||||||
|
- `내담자: 엄마가 가보라고 해서요...`
|
||||||
|
- `[이번 상담자 발화] ...`
|
||||||
|
|
||||||
|
3. resident session 매핑은 2차로 미룬다.
|
||||||
|
- 바로 도입하면 evaluator/deep-loop가 client resident를 재사용할 위험이 있다.
|
||||||
|
- `client:{session_id}`, `eval:{session_id}` 네임스페이스, 프로세스 정리, gateway 재시작 복구가 함께 필요하다.
|
||||||
|
|
||||||
|
검증:
|
||||||
|
|
||||||
|
- gateway payload에 직전 상담자 질문과 내담자 답변이 정확히 1회 포함되는지 unit test.
|
||||||
|
- generate/stream fake engine에서 최근 대화 주입 확인.
|
||||||
|
- fast/deep evaluator 요청이 client history resident를 재사용하지 않는지 회귀 테스트.
|
||||||
|
- `_split_messages()` 기존 골든을 새 계약으로 갱신하고, 계약 변경을 문서화한다.
|
||||||
|
|
||||||
|
### P2. 내담자 출력 품질 게이트
|
||||||
|
|
||||||
|
목표: 모델이 역할을 깨거나 반복·재인사를 출력했을 때 저장/표시 전에 막는다.
|
||||||
|
|
||||||
|
1차 하드 차단:
|
||||||
|
|
||||||
|
- 역할 메타발화
|
||||||
|
- 예: “내담자 역할로 응답하겠습니다”, “AI로서”, “상담자 입장에서”, “제 핵심신념은”
|
||||||
|
- 반복 인사
|
||||||
|
- 2턴 이후 “안녕하세요”, “처음 뵙겠습니다”, “반갑습니다”로 시작하는 응답
|
||||||
|
|
||||||
|
1차 보수적 차단 또는 재생성:
|
||||||
|
|
||||||
|
- 직전 내담자 발화와 거의 같은 응답
|
||||||
|
- exact/subsequence/높은 유사도만 차단한다.
|
||||||
|
- 너무 이른 회복/감사 표현
|
||||||
|
- 라포 초기, 낮은 openness, 회복/종결 뉘앙스가 함께 있을 때만 retryable로 둔다.
|
||||||
|
|
||||||
|
구현 원칙:
|
||||||
|
|
||||||
|
- retry는 1회만 허용한다.
|
||||||
|
- 1차에서는 fallback 저장을 하지 않는다. 재시도 후에도 실패하면 client turn을 저장하지 않고 재시도 가능한 오류로 표시한다.
|
||||||
|
- 저장형 fallback은 2차에서만 도입한다. 이때 `synthetic`/`fallback` 메타데이터를 남기고 평가·워크시트·carry-over·`clientFeedback` 기본 입력에서 제외한다.
|
||||||
|
- 사용자 화면에는 내부 raw reason 대신 “응답을 안정적으로 생성하지 못했어. 다시 시도해줘.”처럼 학습자용 문구를 표시한다.
|
||||||
|
- stream 첫 문장 또는 80~120자 버퍼링 전면 도입은 2차 안정화로 둔다. 1차는 generate 경로와 명백한 출력 결함 차단부터 닫는다.
|
||||||
|
- 품질 게이트 reason은 audit 또는 운영자용 payload에 남긴다. 단, 사용자에게 내부 raw reason을 그대로 노출하지 않는다.
|
||||||
|
|
||||||
|
검증:
|
||||||
|
|
||||||
|
- `guardrail` 또는 신규 품질 모듈의 table-driven unit test.
|
||||||
|
- generate fake engine retry 1회 테스트.
|
||||||
|
- 재생성 후 실패 시 client turn 미저장 + 재시도 가능 오류 응답 테스트.
|
||||||
|
- stream/voice 버퍼링·persistence 경로는 통합 검증 또는 2차 안정화에서 별도 테스트.
|
||||||
|
- 필요 시 Playwright로 역할 메타발화가 화면에 보이지 않는지 focused 확인.
|
||||||
|
|
||||||
|
### P3. 리뷰·평가 UX 개선
|
||||||
|
|
||||||
|
목표: 안전한 저장/평가 경로를 유지하면서 사람에게 보이는 리뷰는 자연스럽고 설명 가능하게 만든다.
|
||||||
|
|
||||||
|
작업:
|
||||||
|
|
||||||
|
1. 리뷰 표시 계층을 분리한다.
|
||||||
|
- 저장값, 상세 API, 평가 입력, 데이터셋 export, privacy proof에는 `[NAME]`/`[ORG]`/`[PHONE]` 유지.
|
||||||
|
- 리뷰 화면의 일반 transcript 표시는 동일 데이터를 렌더링 단계에서 자연어화할 수 있다.
|
||||||
|
- 축어록, 근거 quote, worksheet evidence에는 마스킹 토큰 보존을 기본으로 둔다.
|
||||||
|
- `humanize_pii_placeholders()`는 내담자 응답 표시용 helper라는 현재 목적을 넘어서 남용하지 않는다.
|
||||||
|
|
||||||
|
2. 내부 오류 코드와 사용자 문구를 분리한다.
|
||||||
|
- `errorKind`: `no_structured_output`, `engine_error`, `parse_error`
|
||||||
|
- 학습자용 문구:
|
||||||
|
- “자동 평가를 완료하지 못했어. 다시 시도할 수 있어.”
|
||||||
|
- 교수자/운영자용 문구:
|
||||||
|
- 오류 종류와 raw detail을 별도 접힘/관리자 영역에서 확인
|
||||||
|
- DB/log에는 운영 진단용 raw error를 유지한다.
|
||||||
|
|
||||||
|
3. 평가 범위 라벨을 명확히 한다.
|
||||||
|
- 턴 노트: “이 턴 직후 기준”
|
||||||
|
- 회기 전체 평가: “회기 전체 기준”
|
||||||
|
- 사용자가 후속 해명을 했더라도 fast-loop 카드가 소급 변경되지 않는다는 점을 UI에 반영한다.
|
||||||
|
- 라벨은 완전한 reconciliation이 아니라 즉시 혼란을 줄이는 1차 UX 개선이다. deep-loop 또는 교수자 검토와의 구분을 같이 보여준다.
|
||||||
|
|
||||||
|
4. `clientFeedback` 라벨을 조정한다.
|
||||||
|
- 완전한 의미 재설계 전에도 “내담자가 남긴 것” 대신 “마지막 내담자 반응”처럼 사실에 가까운 표현으로 바꾼다.
|
||||||
|
|
||||||
|
2차 후보:
|
||||||
|
|
||||||
|
- deep-loop schema에 `turn_reconciliations`를 추가한다.
|
||||||
|
- fast-loop 노트 옆에 “후속 회복됨 / 부분 회복 / 미해결” 배지를 붙인다.
|
||||||
|
- 이 작업은 임의 휴리스틱으로 처리하지 않는다. 평가 신뢰도가 떨어질 수 있다.
|
||||||
|
|
||||||
|
검증:
|
||||||
|
|
||||||
|
- `/review` JSON은 raw PII 미포함 + masked token 보존.
|
||||||
|
- UI 표시만 humanized 되는지 별도 테스트.
|
||||||
|
- raw 오류 코드 미노출.
|
||||||
|
- `clientFeedback` 표시 라벨 변경.
|
||||||
|
- session-review E2E의 평가 실패 문구 기대값 갱신.
|
||||||
|
- 저장/상세 API/평가 입력/export에서는 마스킹이 유지되는지 별도 테스트로 보존.
|
||||||
|
- 공유 payload도 원문 축어록과 학습자 식별 정보를 싣지 않는지 확인.
|
||||||
|
|
||||||
|
### P4. 교수자 설명 패널과 SSOT 반영
|
||||||
|
|
||||||
|
목표: 한신대 질문에 제품 안에서 답하고, 구현 완료와 임상팀 게이트를 섞지 않는다. 설명 패널은 “못 한다”는 방어문이 아니라 현재 구현, 교육적 의도, 임상팀 미검수 범위를 분리해 설명하는 신뢰 회복 장치다.
|
||||||
|
|
||||||
|
교수자 질문별 답변 초안:
|
||||||
|
|
||||||
|
1. 진단기준이 입력됐는가?
|
||||||
|
- 진단명을 붙이는 용도는 아니다.
|
||||||
|
- DSM-5-TR 기반 요약과 페르소나별 증상 차원은 seed persona/DB catalog에 연결되어 있다.
|
||||||
|
- seed persona 기준인지 DB catalog 기준인지 UI와 문서에서 구분한다.
|
||||||
|
- 내담자 발화나 학습자 피드백에는 진단명·정답·내부 수치를 직접 노출하지 않고, 관찰 가능한 말·정서·행동으로 번역한다.
|
||||||
|
|
||||||
|
2. 약물치료나 심리평가 권유에 왜 주저하는가?
|
||||||
|
- Vignette는 교육용 시뮬레이션이지 처방·진단·공식 평가 대체 도구가 아니다.
|
||||||
|
- 약물/검사 권유는 바로 제시하기보다 라포, 동의, 안전, 기능 손상 확인을 먼저 하도록 설계한다.
|
||||||
|
- 약물 권유 기준·문안은 임상팀 확정 전 자동화하지 않는다.
|
||||||
|
|
||||||
|
3. CBT는 어디서 오는가?
|
||||||
|
- 계약 원천문서 요구, 페르소나 `theory_target`, 세션 `theory_mode`, 평가 프롬프트에 연결되어 있다.
|
||||||
|
- 현재는 CBT 반응 프레이밍 수준이다.
|
||||||
|
- 임상팀 확정 CBT 단계 프롬프트 체인, citation 구조, 이론부합 루브릭은 아직 남은 게이트다.
|
||||||
|
|
||||||
|
4. 개방도 점수는 어떻게 계산되는가?
|
||||||
|
- LLM 감이 아니라 결정론 상태머신 값이다.
|
||||||
|
- “점수”보다 “시뮬레이션 상태값”으로 설명한다.
|
||||||
|
- 단계 기본값 + 라포 크레딧 * unlock rate - 저항 * decay를 0~1로 clamp한다.
|
||||||
|
- 공감·반영·개방질문은 라포를 올리고, 성급한 조언·평가·유도질문은 저항을 올린다.
|
||||||
|
|
||||||
|
반영 위치:
|
||||||
|
|
||||||
|
- 제품: `SessionReview` 교수자 검토 카드 아래 “AI 평가/코칭 작동 원리와 한계” 패널
|
||||||
|
- 권장 구조: “현재 구현” / “임상팀 확정 필요” 2열 또는 “현재 구현됨” / “제품 한계” / “임상팀 검수 필요” 3단
|
||||||
|
- 문서:
|
||||||
|
- `docs/dev_dashboard.html`: 상태·검증 수치·로드맵 권위 기준
|
||||||
|
- `docs/TODO.md`: 열린 작업 링크/요약
|
||||||
|
- 이 계획 문서: 구현 순서와 차단 조건
|
||||||
|
- `docs/redteam/hanshin-feedback-plan-redteam-2026-07-03.md`: 적대 검토 결과
|
||||||
|
- `docs/redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md`: 대레드팀 균형 검토 결과
|
||||||
|
|
||||||
|
검증:
|
||||||
|
|
||||||
|
- `Push-Location apps/web; npm run typecheck; Pop-Location`
|
||||||
|
- `Push-Location apps/web; npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1; Pop-Location`
|
||||||
|
- `Push-Location apps/web; npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1; Pop-Location`
|
||||||
|
- `py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json`
|
||||||
|
|
||||||
|
## 6. 레드팀에 맡긴 질문과 반영 상태
|
||||||
|
|
||||||
|
| 질문 | 1차 반영 |
|
||||||
|
|---|---|
|
||||||
|
| 컨텍스트 보존 패치가 실제 반복/문맥오해를 줄인다는 가정이 과도하지 않은가? | 컨텍스트 보존은 필요조건일 뿐이라고 명시했다. role mapping, gateway 계약, 출력 품질 게이트, 평가 라벨을 같이 닫는다. |
|
||||||
|
| `_split_messages()`에 history를 넣으면 resident context와 중복되어 역효과가 나지 않는가? | 1차는 stateless injection으로 제한하고 resident session은 별도 설계 전까지 켜지 않는다. |
|
||||||
|
| stream 첫 문장 버퍼링이 UX 지연이나 token 순서 버그를 만들 가능성은? | 화면/저장/평가 텍스트 동등성과 차단 문구 미노출을 검증 조건으로 추가했다. |
|
||||||
|
| `[NAME]`을 표시 계층에서 자연어화하면 개인정보 보호 증거가 약해졌다고 오해될 위험은? | 리뷰 API/quote/export는 masked token을 유지하고 UI 표시만 분리한다. |
|
||||||
|
| fast-loop “이 턴 직후 기준” 라벨이 실제 사용자의 혼란을 충분히 줄이는가? | 라벨만으로 부족할 수 있음을 인정하고 deep-loop/교수자 검토와의 구분을 같이 표시한다. |
|
||||||
|
| 교수자 설명 패널이 임상팀 확정 전 내용을 과장하지 않는가? | DSM/CBT/약물/루브릭/골든셋은 임상팀 검수와 source/citation 범위 안에서만 확정 표현을 쓴다. |
|
||||||
|
| 문서와 SSOT/TODO가 중복되어 어긋날 위험은? | 상태·검증 수치는 dev_dashboard만 소유하고 계획/TODO/가이드는 링크와 요약만 둔다. |
|
||||||
|
|
||||||
|
## 7. 대레드팀 반영 상태
|
||||||
|
|
||||||
|
| 대레드팀 질문 | 반영 |
|
||||||
|
|---|---|
|
||||||
|
| 레드팀이 P2/P3/P4까지 P1처럼 묶어 본래 목적을 약화시켰는가? | P1 내부 원자성만 하드 게이트로 유지하고, P2/P3/P4는 독립 배포 가능한 1차 개선으로 분리했다. |
|
||||||
|
| P1 테스트 범위가 과도한가? | P1 차단 테스트를 persona role mapping, gateway client-only history injection, evaluator no-history 계약으로 한정했다. voice/Playwright/전체 review 회귀는 통합 검증 단계로 내렸다. |
|
||||||
|
| privacy proof가 UI 자연어화를 막는가? | API·저장·export 마스킹 불변식은 유지하되, 리뷰 화면 일반 transcript는 렌더링 단계 자연어화를 허용했다. |
|
||||||
|
| synthetic fallback 요구가 품질 게이트를 늦추는가? | 1차 품질 게이트는 fallback 저장을 하지 않는다. 재생성 실패 시 client turn 미저장 + 재시도 가능 오류로 처리한다. |
|
||||||
|
| 임상 안전 문구가 교수자 설명을 무력화하는가? | 교수자 패널은 현재 구현과 임상팀 확정 필요 범위를 분리해 답하는 신뢰 회복 장치로 먼저 배포한다. |
|
||||||
|
|
||||||
|
## 8. 현재 결정
|
||||||
|
|
||||||
|
구현 완료한 1차 범위:
|
||||||
|
|
||||||
|
- P0/P1a: persona role mapping + client-only stateless history injection + evaluator no-history focused test
|
||||||
|
- P3a: API 마스킹 불변식 보존 + 리뷰 UI 표시 자연어화 + raw error display mapper + fast/deep 라벨 + `clientFeedback` 라벨 변경
|
||||||
|
- P2a: 역할 메타발화, 2턴 이후 재인사, 직전 내담자 발화와 거의 같은 응답만 먼저 차단. fallback 저장 없이 재시도 가능 오류로 처리. SSE stream은 full-response buffer로 token leak을 막음.
|
||||||
|
- P4a: 교수자 설명 패널을 “현재 구현 / 임상팀 확정 필요” 구조로 먼저 배포
|
||||||
|
|
||||||
|
후속 설계 확인 필요:
|
||||||
|
|
||||||
|
- 지연을 줄이는 안전한 partial stream 버퍼링 방식
|
||||||
|
- 저장형 fallback synthetic 메타데이터와 평가 제외 규칙
|
||||||
|
- fast-loop/deep-loop reconciliation
|
||||||
|
- resident session namespace와 prompt caching 복구
|
||||||
|
- 교수자 설명 패널의 최종 임상 문구
|
||||||
|
|
||||||
|
임상팀 게이트:
|
||||||
|
|
||||||
|
- CBT 단계 프롬프트 체인
|
||||||
|
- 이론부합 루브릭
|
||||||
|
- 약물/심리평가/의뢰 권유 기준과 문안
|
||||||
|
- C1 루브릭 anchor·가중치·컷오프
|
||||||
|
- H2 평가 골든셋
|
||||||
116
docs/redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md
Normal file
116
docs/redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# 한신대 개선 계획 대레드팀 검토
|
||||||
|
|
||||||
|
작성일: 2026-07-03
|
||||||
|
대상: `docs/ops/hanshin-feedback-improvement-plan-2026-07-03.md`, `docs/redteam/hanshin-feedback-plan-redteam-2026-07-03.md`
|
||||||
|
상태: 대레드팀 1차 완료 / 계획 균형 보정 반영
|
||||||
|
|
||||||
|
## 1. 판정
|
||||||
|
|
||||||
|
기존 레드팀은 실제 위험을 잘 잡았다. 다만 몇 조건이 “1차 개선을 안전하게 작게 낸다”가 아니라 “최종형을 한 번에 닫는다”로 비대해졌다.
|
||||||
|
|
||||||
|
본래 목적은 한신대 사용자가 실제로 겪은 반복 발화, 문맥 오해, 리뷰 UX 혼란, 교수자 설명 부족을 빠르게 줄이는 것이다. 그래서 레드팀 경고는 유지하되, 사용자 가치 기준으로 등급화해야 한다.
|
||||||
|
|
||||||
|
핵심 판정:
|
||||||
|
|
||||||
|
- P1 내부의 role mapping + history injection은 반드시 원자 패치다.
|
||||||
|
- P2/P3/P4는 P1과 같은 원자 배포 단위가 아니다. 독립적으로 빠르게 배포할 수 있다.
|
||||||
|
- privacy proof는 API·저장·export 계약이지, 리뷰 화면 자연어화를 막는 조건이 아니다.
|
||||||
|
- synthetic fallback은 fallback을 저장할 때 필요한 계약이다. 1차 품질 게이트는 fallback 저장 없이도 낼 수 있다.
|
||||||
|
- 교수자 설명은 임상 확정 표현을 피해야 하지만, 현재 구현과 한계를 설명하는 패널은 오히려 지금 필요하다.
|
||||||
|
|
||||||
|
## 2. 과잉 방어 지점
|
||||||
|
|
||||||
|
### C1. P2 fallback 경고가 품질 게이트 전체를 늦출 위험
|
||||||
|
|
||||||
|
레드팀의 “fallback을 실제 내담자 발화처럼 저장하면 안 된다”는 경고는 맞다. 하지만 이것이 “품질 게이트를 구현하지 말라”로 번지면 과잉이다.
|
||||||
|
|
||||||
|
1차에서는 fallback을 저장하지 않으면 된다. 역할 메타발화, 2턴 이후 재인사, 직전 내담자 발화와 거의 같은 응답처럼 명백한 결함은 먼저 감지하고 1회 재생성한다. 재생성 후에도 실패하면 client turn을 저장하지 않고 사용자에게 재시도 가능한 오류를 보여준다.
|
||||||
|
|
||||||
|
저장형 fallback, synthetic metadata, 평가 제외 규칙은 2차다.
|
||||||
|
|
||||||
|
### C2. P3 privacy proof가 UI 개선을 누르면 안 된다
|
||||||
|
|
||||||
|
API/저장/export에서 `[NAME]`, `[ORG]`, `[PHONE]` 같은 마스킹 토큰을 보존하는 건 맞다. 하지만 사람이 보는 리뷰 화면의 일반 transcript까지 반드시 토큰 그대로 보여야 하는 건 아니다.
|
||||||
|
|
||||||
|
1차에서는 API와 evidence quote는 마스킹을 유지하고, 일반 UI 렌더링에서만 자연어화한다. 이 방식이 privacy proof와 사용자 경험을 동시에 만족한다.
|
||||||
|
|
||||||
|
### C3. P4 임상 경고가 설명 패널을 늦추면 안 된다
|
||||||
|
|
||||||
|
교수자 질문은 제품 신뢰와 직결된다. “임상 확정 전이라 말할 수 없다”만 반복하면 한신대가 궁금해한 지점을 해결하지 못한다.
|
||||||
|
|
||||||
|
설명 패널은 현재 구현과 임상팀 검수 필요 범위를 분리하면 안전하게 먼저 낼 수 있다.
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
- DSM: 진단이 아니라 페르소나 행동 차원과 증상 차원을 구성하는 참고 축.
|
||||||
|
- CBT: 선택 이론 모드가 프롬프트/평가에 반영되는 수준.
|
||||||
|
- 개방도: LLM 감이 아니라 state machine 기반 시뮬레이션 상태값.
|
||||||
|
- 약물/검사: 처방·진단 도구가 아니므로 권유 기준은 임상팀 확정 전 자동화하지 않음.
|
||||||
|
|
||||||
|
### C4. 테스트 제안이 전체 회귀 게이트처럼 읽힌다
|
||||||
|
|
||||||
|
P1 차단 테스트는 다음으로 충분하다.
|
||||||
|
|
||||||
|
- persona role mapping
|
||||||
|
- gateway client-only history injection
|
||||||
|
- evaluator/structured output no-history
|
||||||
|
- 기존 gateway split 계약 갱신
|
||||||
|
|
||||||
|
voice, 전체 review, Playwright, stream buffering 장기 매트릭스는 통합 검증 또는 2차 안정화에 둔다.
|
||||||
|
|
||||||
|
## 3. 유지해야 하는 레드팀 경고
|
||||||
|
|
||||||
|
- P1 내부 원자성은 유지한다. role mapping 또는 gateway history injection 중 하나만 고치면 역효과가 날 수 있다.
|
||||||
|
- resident session은 1차에서 켜지 않는다. client/eval context 혼선 위험이 현실적이다.
|
||||||
|
- API/저장/export 마스킹 불변식은 유지한다.
|
||||||
|
- 저장되는 fallback은 실제 내담자 발화처럼 섞으면 안 된다.
|
||||||
|
- 임상 표현은 보수적으로 둔다. 설명 패널은 진단/처방 판단이 아니라 교육용 시뮬레이션의 현재 구현과 한계를 설명해야 한다.
|
||||||
|
|
||||||
|
## 4. 균형 잡힌 1차 MVP
|
||||||
|
|
||||||
|
### P1a. 컨텍스트
|
||||||
|
|
||||||
|
- `counselor -> user`, `client -> assistant` 매핑 수정.
|
||||||
|
- client 요청에서만 최근 대화 history를 라벨링해 마지막 user payload에 주입.
|
||||||
|
- evaluator/structured 요청에는 client history를 주입하지 않는 단위 테스트.
|
||||||
|
|
||||||
|
### P3a. 리뷰 UX
|
||||||
|
|
||||||
|
- API는 masked token 유지.
|
||||||
|
- 리뷰 화면 일반 transcript는 렌더링 단계 자연어화 허용.
|
||||||
|
- raw error는 학습자용 문구로 매핑.
|
||||||
|
- fast-loop/deep-loop 라벨 추가.
|
||||||
|
- `clientFeedback` 라벨을 “마지막 내담자 반응”처럼 사실에 맞게 수정.
|
||||||
|
|
||||||
|
### P2a. 출력 품질
|
||||||
|
|
||||||
|
- 역할 메타발화 차단.
|
||||||
|
- 2턴 이후 재인사 차단.
|
||||||
|
- 직전 내담자 발화와 거의 동일한 응답 차단.
|
||||||
|
- 1회 재생성 후 실패하면 client turn 미저장 + 재시도 가능 오류.
|
||||||
|
|
||||||
|
### P4a. 교수자 설명
|
||||||
|
|
||||||
|
- `SessionReview`에 “현재 구현 / 임상팀 확정 필요” 2열 또는 “현재 구현됨 / 제품 한계 / 임상팀 검수 필요” 3단 패널 추가.
|
||||||
|
- 임상 루브릭·약물 권유 기준·골든셋은 미확정으로 표시하되, 현재 제품이 실제로 하는 일은 명확히 답한다.
|
||||||
|
|
||||||
|
## 5. 2차로 미룰 것
|
||||||
|
|
||||||
|
- resident session namespace
|
||||||
|
- prompt caching 복구
|
||||||
|
- cleanup/restart recovery
|
||||||
|
- complete K-pair window 정교화
|
||||||
|
- synthetic fallback 저장 스키마와 모든 read-model exclusion
|
||||||
|
- stream 첫 문장 버퍼링 전면 도입
|
||||||
|
- fast-loop/deep-loop 충돌 reconciliation
|
||||||
|
- CBT chain/citation/rubric
|
||||||
|
- H2 골든셋
|
||||||
|
|
||||||
|
## 6. 계획 문서 반영 문장
|
||||||
|
|
||||||
|
- “레드팀 차단 조건은 사용자 가치 기준으로 등급화한다. P1 내부의 role mapping + history injection은 원자 패치로 유지하되, P2/P3/P4는 독립적으로 빠르게 배포 가능한 1차 개선으로 분리한다.”
|
||||||
|
- “P1 차단 테스트는 persona role mapping, gateway client-only history injection, evaluator no-history 계약으로 한정한다. voice/Playwright/전체 review 회귀는 P1 필수 차단이 아니라 통합 검증 단계로 둔다.”
|
||||||
|
- “마스킹 토큰 보존은 API·저장·export 계약이다. 리뷰 화면의 일반 transcript 표시에서는 동일 데이터를 렌더링 단계에서 자연어화할 수 있다.”
|
||||||
|
- “1차 품질 게이트는 fallback 저장을 목표로 하지 않는다. 재생성 후에도 역할 메타발화가 남으면 client turn을 저장하지 않고 사용자에게 재시도 가능한 오류로 표시한다.”
|
||||||
|
- “교수자 설명은 ‘못 한다’가 아니라 ‘현재 제품이 하는 것’과 ‘임상팀 확정이 필요한 것’을 분리해 답한다.”
|
||||||
|
- “resident session, synthetic fallback 저장, stream buffering, fast/deep reconciliation은 P1/P3 이후 2차 안정화 범위로 둔다.”
|
||||||
130
docs/redteam/hanshin-feedback-plan-redteam-2026-07-03.md
Normal file
130
docs/redteam/hanshin-feedback-plan-redteam-2026-07-03.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# 한신대 실사용 피드백 개선 계획 레드팀 검토
|
||||||
|
|
||||||
|
작성일: 2026-07-03
|
||||||
|
대상: `docs/ops/hanshin-feedback-improvement-plan-2026-07-03.md`
|
||||||
|
상태: 1차 적대 검토 완료 / 구현 전 차단 조건 반영 필요
|
||||||
|
|
||||||
|
## 1. 요약
|
||||||
|
|
||||||
|
계획의 방향은 맞다. 한신대 실사용 피드백을 단순 화면 문구 문제가 아니라 컨텍스트, 출력 품질, 리뷰 UX, 설명 가능성 문제로 나눈 판단은 타당하다.
|
||||||
|
|
||||||
|
다만 그대로 구현하면 위험한 지점이 있다. 특히 아래 네 가지는 구현 전 반드시 막아야 한다.
|
||||||
|
|
||||||
|
1. `persona.py` role mapping이 잘못된 상태에서 gateway history만 보존하면 품질이 악화될 수 있다.
|
||||||
|
2. `_split_messages()` 변경은 단순 보강이 아니라 gateway 계약 변경이다.
|
||||||
|
3. 리뷰 API의 마스킹 토큰을 자연어화하면 privacy proof와 기존 E2E 계약이 깨질 수 있다.
|
||||||
|
4. 품질 fallback을 실제 내담자 발화처럼 저장하면 평가·워크시트·carry-over가 오염된다.
|
||||||
|
|
||||||
|
## 2. BLOCKER
|
||||||
|
|
||||||
|
### B1. P1은 원자적으로 처리해야 한다
|
||||||
|
|
||||||
|
`persona.build_turn_messages()`의 recent turn role mapping 수정과 gateway `_split_messages()` history 소비 변경은 같은 패치에서 처리해야 한다. 현재 매핑이 잘못된 상태에서 history만 보존하면 상담자 발화를 내담자 과거 발화처럼 소비할 수 있다.
|
||||||
|
|
||||||
|
반영 문장:
|
||||||
|
|
||||||
|
> P1-1 role mapping 수정과 P1-2 gateway history 소비는 같은 패치에서 원자적으로 처리한다. history 보존만 먼저 배포하지 않는다.
|
||||||
|
|
||||||
|
### B2. Gateway history 보존은 계약 변경이다
|
||||||
|
|
||||||
|
현재 gateway 테스트는 “이전 assistant/user는 버리고 마지막 user만 보낸다”를 명시적으로 고정하고 있다. 따라서 `_split_messages()` 변경은 테스트 추가가 아니라 기존 계약과 골든 변경이다.
|
||||||
|
|
||||||
|
필수 조치:
|
||||||
|
|
||||||
|
- 기존 gateway split 테스트 기대값 갱신
|
||||||
|
- 호출부가 어떤 `ai_role`에서 history를 소비하는지 명시
|
||||||
|
- evaluator/structured output 요청에서 client history가 섞이지 않는지 회귀 테스트
|
||||||
|
|
||||||
|
### B3. Resident session은 1차 범위에서 꺼야 한다
|
||||||
|
|
||||||
|
gateway `_resolve_session()`은 같은 `session_id`가 살아 있으면 새 system prompt를 무시하고 재사용한다. evaluator도 같은 app session id를 보낼 수 있으므로 resident session을 성급히 켜면 client 컨텍스트와 evaluator 컨텍스트가 섞일 수 있다.
|
||||||
|
|
||||||
|
반영 문장:
|
||||||
|
|
||||||
|
> 이번 1차 구현은 stateless history injection으로 한정하며, resident session 재사용/prompt caching 복구는 별도 설계 없이는 켜지 않는다.
|
||||||
|
|
||||||
|
### B4. Fallback은 실제 임상 반응이 아니다
|
||||||
|
|
||||||
|
품질 게이트 실패 후 “말을 고르며 잠시 멈춘다” 같은 fallback을 실제 내담자 발화로 저장하면 deep-loop, 회기 요약, 다음 회기 carry-over, `clientFeedback`, worksheet evidence가 오염된다.
|
||||||
|
|
||||||
|
필수 조치:
|
||||||
|
|
||||||
|
- fallback 저장 시 `synthetic`/`fallback` 메타데이터 부여
|
||||||
|
- 평가·워크시트·carry-over 기본 입력에서 제외
|
||||||
|
- 학습자 UI에는 내부 reason 대신 안전한 표시 문구 제공
|
||||||
|
|
||||||
|
### B5. 리뷰 API 마스킹 토큰은 보존해야 한다
|
||||||
|
|
||||||
|
리뷰/상세 API는 privacy proof로 `[NAME]`, `[ORG]`, `[PHONE]` 같은 마스킹 토큰을 유지해야 한다. 사람용 UI에서 자연어화가 필요하더라도 API 축어록·근거 quote·worksheet evidence·export는 마스킹 불변식을 보존해야 한다.
|
||||||
|
|
||||||
|
반영 문장:
|
||||||
|
|
||||||
|
> 리뷰 API의 축어록·근거 quote는 privacy proof를 위해 `[NAME]` 등 마스킹 토큰을 보존한다. 자연어 치환은 UI 표시 전용 필드 또는 컴포넌트 렌더링에서만 적용한다.
|
||||||
|
|
||||||
|
### B6. 교수자 설명은 임상 확정처럼 쓰면 안 된다
|
||||||
|
|
||||||
|
DSM/CBT/약물·심리평가/루브릭/골든셋 설명은 “현재 구현됨”과 “임상팀 검수 필요”를 같은 톤으로 쓰면 안 된다. 특히 CBT는 현재 `theory_mode`와 evaluator prompt 연결 수준이지, 임상팀 확정 CBT 단계 chain/citation/rubric 구조가 아니다.
|
||||||
|
|
||||||
|
반영 문장:
|
||||||
|
|
||||||
|
> DSM/CBT/약물·검사/루브릭/골든셋 설명은 임상팀 검수 상태와 source/citation이 연결된 범위에서만 확정 표현을 쓴다.
|
||||||
|
|
||||||
|
## 3. MAJOR
|
||||||
|
|
||||||
|
- gateway history 직렬화는 `ai_role=client` 요청에만 적용해야 한다.
|
||||||
|
- `recent_turns(k=6)`은 레코드 수 기준이라 상담자/내담자 pair를 보장하지 않는다. 최근 완성 K쌍 기준으로 자른다.
|
||||||
|
- stream 버퍼링을 넣으면 사용자가 본 텍스트, 저장된 텍스트, 평가된 텍스트가 달라질 수 있다.
|
||||||
|
- voice 경로는 stream이 아니므로 generate 지연, TTS 시작, persistence 실패 시 learner-only 기록 방지를 따로 검증해야 한다.
|
||||||
|
- fast-loop 라벨만으로 “후속 해명을 반영하지 않았다”는 혼란이 닫히지 않을 수 있다. deep-loop/교수자 검토와 구분을 같이 보여줘야 한다.
|
||||||
|
- `clientFeedback`는 실제 피드백 산출물이 아니라 마지막 client turn이므로 라벨과 포함 조건을 재검토해야 한다.
|
||||||
|
- 오류 문구 개선은 단순 문자열 치환이 아니라 `errorKind`, role별 display copy, operator raw error 계약 분리여야 한다.
|
||||||
|
- 공유 payload에도 원문 축어록과 학습자 식별 정보가 들어가지 않는지 확인해야 한다.
|
||||||
|
|
||||||
|
## 4. MINOR
|
||||||
|
|
||||||
|
- 검증 명령은 Windows PowerShell 5.1 기준으로 `Push-Location ...; ...; Pop-Location` 또는 `py -3.11 -X utf8` 형태로 적는다.
|
||||||
|
- “컨텍스트 보존이 반복/문맥오해를 줄인다”는 표현은 필요조건으로 낮춘다. 출력 게이트와 평가 라벨까지 같이 닫아야 한다.
|
||||||
|
- 개방도는 “점수”보다 “시뮬레이션 상태값”으로 설명한다.
|
||||||
|
- seed persona 기준인지 DB catalog 기준인지 교수자 설명에서 구분한다.
|
||||||
|
- 학습자용 오류 문구는 “평가 AI 응답 형식이 맞지 않아”보다 “자동 평가를 완료하지 못했어”처럼 내부 구현을 덜 드러내는 표현이 낫다.
|
||||||
|
|
||||||
|
## 5. 구현 전 테스트 제안
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
- `Push-Location apps/api; py -3.11 -X utf8 -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py app/test_orchestrator_masking.py app/test_session_turn_persistence.py app/test_evaluator_model_routing.py app/test_voice_ws.py -q; Pop-Location`
|
||||||
|
- 신규: `persona.build_turn_messages()`가 `counselor -> user`, `client -> assistant`를 보장하는 단위 테스트.
|
||||||
|
- 신규: gateway `_split_messages()`가 client history를 라벨 포함 payload로 정확히 1회만 넣고 evaluator 요청에는 넣지 않는 테스트.
|
||||||
|
- 신규: REST/stream/voice fake engine 캡처 테스트에서 직전 상담자 질문과 내담자 답변이 실제 gateway user payload까지 도달하는지 검증.
|
||||||
|
- 신규: evaluator fast/deep이 client resident/history를 재사용하지 않는 회귀 테스트.
|
||||||
|
- 신규: quality fallback turn이 synthetic으로 표시되고 `clientFeedback`, worksheet evidence, deep eval에 무표시로 섞이지 않는지 검증.
|
||||||
|
|
||||||
|
Review/privacy:
|
||||||
|
|
||||||
|
- 신규: `/review` JSON은 raw PII 미포함 + masked token 보존, UI 표시만 humanized 되는지 검증.
|
||||||
|
- 신규: `no_structured_output`/`engine_error`/`parse_error`가 role별 display copy로 매핑되고 raw error는 learner UI에 안 나오는지 검증.
|
||||||
|
- 신규: 리뷰 API/detail/eval/export 경로에서 `[NAME]` 마스킹 불변식 보존.
|
||||||
|
- 신규: 공유 payload에 원문 축어록과 학습자 식별 정보가 실리지 않는지 검증.
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- `Push-Location apps/web; npm run typecheck; Pop-Location`
|
||||||
|
- `Push-Location apps/web; npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1; Pop-Location`
|
||||||
|
- DB/engine 가능 시 `Push-Location apps/web; npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "Korean PII|manual AI evaluation retry|case worksheet|selected CBT theory mode"; Pop-Location`
|
||||||
|
|
||||||
|
SSOT:
|
||||||
|
|
||||||
|
- `py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json`
|
||||||
|
|
||||||
|
## 6. 판정
|
||||||
|
|
||||||
|
계획은 진행 가능하다. 단, P1 내부는 표면 개선으로 쪼개면 위험하다. 대레드팀 검토 후 1차 구현 단위는 다음처럼 조정한다.
|
||||||
|
|
||||||
|
- P1: role mapping + client-only stateless history injection + evaluator 분리 focused test
|
||||||
|
- P2 1차: fallback 저장 없이 명백한 출력 결함 감지 + 1회 재생성 + 실패 시 client turn 미저장
|
||||||
|
- P3 1차: API 마스킹 불변식 + 리뷰 UI 렌더링 자연어화 + raw error display mapper + fast/deep 라벨
|
||||||
|
- P4 1차: 현재 구현과 임상팀 확정 필요 범위를 분리한 교수자 설명 패널
|
||||||
|
|
||||||
|
교수자 설명 패널은 제품 신뢰를 높일 수 있지만, 임상팀 확정 전 표현은 보수적으로 써야 한다.
|
||||||
|
|
||||||
|
후속 균형 검토: `docs/redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md`
|
||||||
3
scripts/watch-public-runtime-hidden.vbs
Normal file
3
scripts/watch-public-runtime-hidden.vbs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Set objShell = CreateObject("WScript.Shell")
|
||||||
|
cmd = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""D:\workspace\vignette\scripts\watch-public-runtime.ps1"" -Workspace ""D:\workspace\vignette"""
|
||||||
|
objShell.Run cmd, 0, True
|
||||||
Loading…
Add table
Add a link
Reference in a new issue