대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -37,8 +37,6 @@ from .state_machine import SessionState, Stage
|
|||
# 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict)
|
||||
# Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만).
|
||||
EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
|
||||
# 로깅 훅: TurnContext + 내담자응답 → None (turns insert/임베딩은 주입측 책임)
|
||||
LogHook = Callable[["TurnContext", str], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -59,6 +57,8 @@ class TurnContext:
|
|||
pinned_facts: list[str] = field(default_factory=list)
|
||||
recent_turns: list[dict[str, str]] = field(default_factory=list)
|
||||
kb_behavior_cues: list[str] = field(default_factory=list)
|
||||
# 회기 이론모드(학습자 선택: humanistic|cbt|integrative). 평가 이론부합·생성 프레이밍에 사용.
|
||||
theory_mode: Optional[str] = None
|
||||
|
||||
def to_state_context(self) -> PersonaStateContext:
|
||||
st = self.state_after or self.state_before
|
||||
|
|
@ -84,6 +84,11 @@ class TurnResult:
|
|||
state_after: SessionState
|
||||
evaluation: Optional[dict] = None
|
||||
crisis_kind: str = "none"
|
||||
llm_provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
tokens_in: int = 0
|
||||
tokens_out: int = 0
|
||||
cost_usd: float = 0.0
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -100,6 +105,7 @@ def prepare_turn(
|
|||
pinned_facts: Optional[list[str]] = None,
|
||||
recent_turns: Optional[list[dict[str, str]]] = None,
|
||||
kb_behavior_cues: Optional[list[str]] = None,
|
||||
theory_mode: Optional[str] = None,
|
||||
eval_rapport_signal: Optional[float] = None,
|
||||
) -> TurnContext:
|
||||
"""엔진 호출 전 결정론 전처리(1~3단계). 순수 — IO/LLM 없음.
|
||||
|
|
@ -113,10 +119,11 @@ def prepare_turn(
|
|||
persona=card,
|
||||
state_before=state,
|
||||
learner_text_raw=learner_text,
|
||||
recall_summary=recall_summary,
|
||||
pinned_facts=list(pinned_facts or []),
|
||||
recent_turns=list(recent_turns or []),
|
||||
recall_summary=_mask_optional_text(recall_summary),
|
||||
pinned_facts=_mask_text_list(pinned_facts),
|
||||
recent_turns=_mask_recent_turns(recent_turns),
|
||||
kb_behavior_cues=list(kb_behavior_cues or []),
|
||||
theory_mode=theory_mode,
|
||||
)
|
||||
|
||||
# 1) 입력 가드레일 — PII 마스킹 + 위기분류
|
||||
|
|
@ -130,11 +137,19 @@ def prepare_turn(
|
|||
if eval_rapport_signal is not None
|
||||
else state_machine.estimate_rapport_signal(ctx.learner_text_masked)
|
||||
)
|
||||
# 위기분류가 관측한 risk_level(>0)을 상태머신에 ideation_observed 로 전달 →
|
||||
# ideation_stage 보수적 상향(절대 하향 안 함, 안전 R5). C2 위기 관측 반영.
|
||||
crisis_ideation = (
|
||||
ctx.crisis.risk_level
|
||||
if ctx.crisis is not None and ctx.crisis.risk_level > 0
|
||||
else None
|
||||
)
|
||||
ctx.state_after = state_machine.evolve(
|
||||
state,
|
||||
rapport_signal=signal,
|
||||
unlock_rate=card.unlock_rate(),
|
||||
decay_floor=card.decay_floor(),
|
||||
ideation_observed=crisis_ideation,
|
||||
)
|
||||
|
||||
# 3) 페르소나 컨텍스트 — L0~L6 messages 조립 (CCD 는 행동으로만, L0 가 강제)
|
||||
|
|
@ -150,6 +165,25 @@ def prepare_turn(
|
|||
return ctx
|
||||
|
||||
|
||||
def _mask_optional_text(text: Optional[str]) -> Optional[str]:
|
||||
if text is None:
|
||||
return None
|
||||
return guardrail.mask_pii(text).text_masked
|
||||
|
||||
|
||||
def _mask_text_list(values: Optional[list[str]]) -> list[str]:
|
||||
return [guardrail.mask_pii(value).text_masked for value in (values or [])]
|
||||
|
||||
|
||||
def _mask_recent_turns(turns: Optional[list[dict[str, str]]]) -> list[dict[str, str]]:
|
||||
masked: list[dict[str, str]] = []
|
||||
for turn in turns or []:
|
||||
item = dict(turn)
|
||||
item["text"] = guardrail.mask_pii(str(item.get("text", ""))).text_masked
|
||||
masked.append(item)
|
||||
return masked
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 4~8단계 — 동기 생성 경로 (폴백/테스트)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -158,11 +192,10 @@ async def run_turn_generate(
|
|||
engine: EngineClient,
|
||||
*,
|
||||
eval_hook: Optional[EvalHook] = None,
|
||||
log_hook: Optional[LogHook] = None,
|
||||
) -> TurnResult:
|
||||
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가·로깅 훅 순차 적용.
|
||||
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가 순차 적용.
|
||||
|
||||
eval_hook/log_hook 은 Features 가 주입(없으면 생략). 엔진 장애는 EngineError 전파.
|
||||
eval_hook 은 Features 가 주입(없으면 생략). 엔진 장애는 EngineError 전파.
|
||||
"""
|
||||
assert ctx.state_after is not None
|
||||
st = ctx.state_after
|
||||
|
|
@ -170,7 +203,6 @@ async def run_turn_generate(
|
|||
# 4) 내담자 AI 생성
|
||||
req = GenerateRequest(
|
||||
ai_role="client",
|
||||
tier="client",
|
||||
messages=ctx.messages,
|
||||
session_id=ctx.session_id,
|
||||
metadata={"stage": st.stage.value},
|
||||
|
|
@ -193,13 +225,6 @@ async def run_turn_generate(
|
|||
except Exception:
|
||||
evaluation = None # 평가 실패가 상담 루프를 막지 않게(비치명적)
|
||||
|
||||
# 8) 로깅 훅(주입형) — turns insert + 임베딩
|
||||
if log_hook is not None:
|
||||
try:
|
||||
await log_hook(ctx, reply)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return TurnResult(
|
||||
turn_seq=st.turn_seq,
|
||||
stage=st.stage.value,
|
||||
|
|
@ -209,6 +234,11 @@ async def run_turn_generate(
|
|||
state_after=st,
|
||||
evaluation=evaluation,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -226,21 +256,17 @@ class StreamEvent:
|
|||
async def run_turn_stream(
|
||||
ctx: TurnContext,
|
||||
engine: EngineClient,
|
||||
*,
|
||||
log_hook: Optional[LogHook] = None,
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""스트리밍 턴 실행(4~8). 게이트웨이 SSE 를 받아 token/done/safety/error 로 재방출.
|
||||
|
||||
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 감지(스트림 중 발견 시 safety 이벤트 +
|
||||
재생성 신호). 토큰 단위 완벽 차단은 후속(현재는 누적 스캔).
|
||||
로깅 훅은 done 직전 최종 텍스트로 1회 호출.
|
||||
"""
|
||||
assert ctx.state_after is not None
|
||||
st = ctx.state_after
|
||||
|
||||
req = StreamRequest(
|
||||
ai_role="client",
|
||||
tier="client",
|
||||
messages=ctx.messages,
|
||||
session_id=ctx.session_id,
|
||||
metadata={"stage": st.stage.value},
|
||||
|
|
@ -248,15 +274,34 @@ async def run_turn_stream(
|
|||
|
||||
accumulated = ""
|
||||
flagged = False
|
||||
stream_meta: dict[str, Any] = {}
|
||||
if ctx.crisis is not None and ctx.crisis.escalate:
|
||||
flagged = True
|
||||
yield StreamEvent("safety", {"reason": "learner_real_crisis", "level": ctx.crisis.risk_level})
|
||||
|
||||
try:
|
||||
current_event = "message"
|
||||
async for raw in engine.stream(req):
|
||||
# engine_client.stream 은 게이트웨이 SSE 의 *원시 라인*을 그대로 yield 한다.
|
||||
# 게이트웨이 프레이밍: "event: token\ndata: {\"text\": ...}" 형식.
|
||||
text_piece = _extract_sse_text(raw)
|
||||
# 게이트웨이 프레이밍: "event: token|done|error" + "data: {...}".
|
||||
line = raw.strip()
|
||||
if line.startswith("event:"):
|
||||
current_event = line[len("event:"):].strip() or "message"
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
payload = _extract_sse_payload(line)
|
||||
if current_event == "error":
|
||||
detail = _payload_detail(payload, "engine stream error")
|
||||
yield StreamEvent("error", {"detail": detail})
|
||||
return
|
||||
if current_event == "done":
|
||||
if isinstance(payload, dict):
|
||||
stream_meta = payload
|
||||
break
|
||||
|
||||
text_piece = _payload_text(payload)
|
||||
if text_piece is None:
|
||||
continue
|
||||
accumulated += text_piece
|
||||
|
|
@ -272,13 +317,6 @@ async def run_turn_stream(
|
|||
|
||||
yield StreamEvent("token", {"text": text_piece})
|
||||
|
||||
# 8) 로깅 훅 — 최종 텍스트
|
||||
if log_hook is not None:
|
||||
try:
|
||||
await log_hook(ctx, accumulated)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
yield StreamEvent(
|
||||
"done",
|
||||
{
|
||||
|
|
@ -287,18 +325,22 @@ async def run_turn_stream(
|
|||
"effective_openness": round(st.effective_openness, 4),
|
||||
"turn_seq": st.turn_seq,
|
||||
"safety_flagged": flagged,
|
||||
"llm_provider": str(stream_meta.get("provider") or engine.engine_mode),
|
||||
"model": str(stream_meta.get("model") or engine.default_model or "gateway-default"),
|
||||
"tokens_in": _safe_int(stream_meta.get("tokens_in")),
|
||||
"tokens_out": _safe_int(stream_meta.get("tokens_out")),
|
||||
"cost_usd": _safe_float(stream_meta.get("cost_usd")),
|
||||
},
|
||||
)
|
||||
except EngineError as e:
|
||||
yield StreamEvent("error", {"detail": str(e)})
|
||||
|
||||
|
||||
def _extract_sse_text(raw_line: str) -> Optional[str]:
|
||||
"""게이트웨이 SSE 원시 라인에서 텍스트 델타를 추출.
|
||||
def _extract_sse_payload(raw_line: str) -> Any:
|
||||
"""게이트웨이 SSE data 라인의 JSON payload를 추출.
|
||||
|
||||
게이트웨이 /v1/stream 은 'event: token' + 'data: {"text": "..."}' 를 보낸다.
|
||||
engine_client.stream 은 빈 줄을 필터링하고 비어있지 않은 라인만 흘리므로
|
||||
여기서 data: 라인의 JSON 만 해석한다. token 이외 이벤트(done/error)는 None.
|
||||
token은 {"text": "..."}이고, done/error도 JSON 객체다. 구형/테스트 fixture가
|
||||
plain text data를 보내면 문자열 그대로 반환한다.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
|
|
@ -309,17 +351,43 @@ def _extract_sse_text(raw_line: str) -> Optional[str]:
|
|||
if not payload or payload == "[DONE]":
|
||||
return None
|
||||
try:
|
||||
obj = _json.loads(payload)
|
||||
return _json.loads(payload)
|
||||
except _json.JSONDecodeError:
|
||||
return None
|
||||
if isinstance(obj, dict) and "text" in obj:
|
||||
return obj["text"]
|
||||
return payload
|
||||
|
||||
|
||||
def _payload_text(payload: Any) -> Optional[str]:
|
||||
if isinstance(payload, dict) and "text" in payload:
|
||||
return str(payload["text"])
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def _payload_detail(payload: Any, fallback: str) -> str:
|
||||
if isinstance(payload, dict) and payload.get("detail"):
|
||||
return str(payload["detail"])
|
||||
if isinstance(payload, str) and payload:
|
||||
return payload
|
||||
return fallback
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EvalHook",
|
||||
"LogHook",
|
||||
"TurnContext",
|
||||
"TurnResult",
|
||||
"StreamEvent",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue