대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -18,7 +18,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import { ClientAvatar } from "../components/avatar/ClientAvatar";
import {
AVATAR_EXPRESSION_LIBRARY,
ClientAvatar,
expressionLabelFor,
} from "../components/avatar/ClientAvatar";
import type { AvatarState, AvatarAffect, AvatarPersona } from "../components/avatar/ClientAvatar";
import { Kicker, Button, Icon } from "../components/ui";
import {
@ -99,6 +103,50 @@ const DIFFICULTY_LABEL: Record<string, string> = {
hard: "고난도",
};
const PERSONA_AVATAR_LOOKS = {
P1: {
skinTone: "#F0DDC4",
hair: { style: "long-straight", color: "#5A4030" },
outfitColor: "#7C8A92",
eyeColor: "#7A5A3C",
accentColor: "#6B5E7D",
rasterArtSet: "seoyeon",
expressionBias: "sad",
},
P4: {
skinTone: "#E5C0A0",
hair: { style: "long-straight", color: "#35261F" },
outfitColor: "#5F6F88",
eyeColor: "#2F3438",
accentColor: "#3B6E8F",
expressionBias: "anxious",
},
P5: {
skinTone: "#D7AD8D",
hair: { style: "messy", color: "#28231F" },
outfitColor: "#6F766F",
eyeColor: "#24292A",
accentColor: "#7D8784",
expressionBias: "guarded",
},
P6: {
skinTone: "#E6BE9A",
hair: { style: "soft-wave", color: "#2D241F" },
outfitColor: "#776F85",
eyeColor: "#2E2B35",
accentColor: "#6B5E7D",
expressionBias: "conflicted",
},
P7: {
skinTone: "#D5AA88",
hair: { style: "side-part", color: "#23211E" },
outfitColor: "#596367",
eyeColor: "#25292B",
accentColor: "#6F7777",
expressionBias: "tired",
},
} satisfies Record<string, Partial<AvatarPersona>>;
let _uid = 100;
const nextId = () => ++_uid;
@ -194,6 +242,8 @@ function buildPersonaUi(
}
const name = shortName(summary.display_name);
const codeKey = summary.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS;
const look = PERSONA_AVATAR_LOOKS[codeKey] ?? {};
const sex = demographicText(summary.demographics.sex);
const isMale = sex === "male" || sex === "남성";
const ageBand = personaAgeBand(summary);
@ -207,15 +257,21 @@ function buildPersonaUi(
return {
avatar: {
code: summary.code,
label: `${name} · ${personaMeta(summary)}`,
ageBand,
skinTone: isMale ? "#D8B18F" : "#E3BE9B",
hair: {
skinTone: look.skinTone ?? (isMale ? "#D8B18F" : "#E3BE9B"),
hair: look.hair ?? {
style: ageBand === "teen" ? "long-straight" : isMale ? "short" : "bob",
color: isMale ? "#25221F" : "#31251E",
},
outfitColor: look.outfitColor ?? (isMale ? "#5E676A" : "#6A6874"),
eyeColor: look.eyeColor ?? "#2B2B2B",
accentColor: look.accentColor,
rasterArtSet: (look as Partial<AvatarPersona>).rasterArtSet,
realism: 0.4,
resistance: summary.difficulty === "hard" ? 0.68 : summary.difficulty === "moderate" ? 0.45 : 0.3,
expressionBias: look.expressionBias,
},
context: {
initial: name.slice(0, 1) || summary.code.slice(0, 1),
@ -256,6 +312,63 @@ function metersFromOpenness(openness: number) {
};
}
function baselineExpressionFor(summary: PersonaSummary | null): AvatarAffect {
if (!summary) return "neutral";
const codeKey = summary.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS;
return PERSONA_AVATAR_LOOKS[codeKey]?.expressionBias ?? "neutral";
}
function expressionForSession({
state,
openness,
paused,
safety,
summary,
stage,
}: {
state: AvatarState;
openness: number;
paused: boolean;
safety: string | null;
summary: PersonaSummary | null;
stage: SessionStage;
}): AvatarAffect {
const o = clamp01(openness);
const code = summary?.code.toUpperCase() ?? "";
const baseline = baselineExpressionFor(summary);
if (safety) return "startled";
if (paused) return "tired";
if (state === "thinking") {
if (code === "P4" && o < 0.38) return "anxious";
if (code === "P6") return o < 0.55 ? "conflicted" : "determined";
if (code === "P7") return o < 0.45 ? "overwhelmed" : "tired";
if (o < 0.32) return "guarded";
return "confused";
}
if (state === "listening") {
if (o < 0.28) return code === "P4" ? "panic" : "guarded";
if (o < 0.48) return baseline === "tired" ? "bored" : baseline;
if (o > 0.72) return "warm";
return "calm";
}
if (state === "speaking") {
if (o < 0.22) return code === "P7" ? "bored" : "resistant";
if (o < 0.38) return code === "P5" ? "skeptical" : baseline;
if (stage === "정리") return "relief";
if (o > 0.78) return "hopeful";
if (o > 0.62) return "warm";
return "neutral";
}
if (o < 0.3) return baseline;
if (o > 0.76) return "relief";
return "neutral";
}
/* ===================================================================== */
export default function Session() {
@ -276,6 +389,7 @@ export default function Session() {
[personaSummary, personaCode],
);
const clientName = personaUi.context.name;
const primaryContext = personaUi.context.rows[0] ?? null;
// ── 세션 진행 상태 ──
const [started, setStarted] = useState(false);
@ -302,6 +416,8 @@ export default function Session() {
const [voiceDetail, setVoiceDetail] = useState(
"마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.",
);
// 음성 캐스케이드 가용성: null=미확인, true=가능, false=provider 키 미설정(버튼 사전 비활성).
const [voiceAvailable, setVoiceAvailable] = useState<boolean | null>(null);
const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false);
const [voiceAnalyser, setVoiceAnalyser] = useState<AnalyserNode | null>(null);
@ -482,7 +598,14 @@ export default function Session() {
setStartError(null);
setResumedSessionLoaded(false);
try {
const res = await sessionApi.start(personaCode, "humanistic");
// 회기 이론모드 = 페르소나 설계 이론(theory_target) 기준. CBT 페르소나는 cbt로 시작.
// (이전엔 'humanistic' 하드코딩 — 평가 이론부합이 항상 인간중심으로 고정됐다.)
const theoryMode =
personaSummary.theory_target.find(
(t): t is "humanistic" | "cbt" | "integrative" =>
t === "humanistic" || t === "cbt" || t === "integrative",
) ?? "humanistic";
const res = await sessionApi.start(personaCode, theoryMode);
setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브
setStage(res.stage);
setOpenness(res.effective_openness);
@ -496,6 +619,17 @@ export default function Session() {
setMicOn(false);
setVoiceStatus("idle");
setVoiceDetail("마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.");
// 음성 가용성 사전 확인 — provider 키 미설정이면 마이크 버튼을 비활성화하고 명확히 안내한다.
// (클릭→권한요청→마이크 깜빡→WS degraded 종료의 혼란스러운 "바로 꺼짐"을 방지.)
void sessionApi.voiceHealth().then(({ available }) => {
setVoiceAvailable(available);
if (!available) {
setVoiceStatus("degraded");
setVoiceDetail(
"음성 기능이 설정되지 않았습니다(STT/TTS provider 키 필요). 아래 텍스트 입력으로 회기를 진행하세요.",
);
}
});
} catch {
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
} finally {
@ -933,6 +1067,10 @@ export default function Session() {
const toggleMic = useCallback(() => {
if (paused || sending) return;
if (voiceAvailable === false) {
pushSignal("warn", "음성 미설정 — 텍스트로 진행");
return;
}
if (
voiceStatus === "requesting" ||
voiceStatus === "connecting" ||
@ -946,7 +1084,7 @@ export default function Session() {
} else {
void startVoiceCapture();
}
}, [finishVoiceUtterance, micOn, paused, sending, startVoiceCapture, voiceStatus]);
}, [finishVoiceUtterance, micOn, paused, sending, startVoiceCapture, voiceAvailable, voiceStatus, pushSignal]);
const togglePause = useCallback(() => {
setPaused((p) => {
@ -1003,12 +1141,22 @@ export default function Session() {
}
}, [liveSessionId, navigate, pushSignal]);
// 아바타 affect: 라포(openness)가 낮으면 저항/우울, 높아지면 중립으로
const avatarAffect: AvatarAffect = useMemo(() => {
if (openness < 0.35) return "resistant";
if (openness < 0.6) return "depressed";
return "neutral";
}, [openness]);
const meters = metersFromOpenness(openness);
const avatarAffect: AvatarAffect = useMemo(
() =>
expressionForSession({
state: avatarState,
openness,
paused,
safety,
summary: personaSummary,
stage,
}),
[avatarState, openness, paused, personaSummary, safety, stage],
);
const avatarExpressionLabel = expressionLabelFor(avatarAffect);
const avatarExpressionCount = AVATAR_EXPRESSION_LIBRARY.length;
// 음성 오브 상태(아바타 state → 오브 data-orb)
const orbState = paused
@ -1021,7 +1169,6 @@ export default function Session() {
? "thinking"
: "idle";
const meters = metersFromOpenness(openness);
const micBusy =
voiceStatus === "requesting" ||
voiceStatus === "connecting" ||
@ -1105,6 +1252,7 @@ export default function Session() {
(started ? "sx-page--active" : "sx-page--prestart") +
(started ? ` sx-feedback-${feedbackMode}` : "")
}
data-avatar-expressions={avatarExpressionCount}
>
{/* ── 페이지 헤드라인 + 가로 단계 미니 ── */}
<header className="sx-head">
@ -1150,7 +1298,7 @@ export default function Session() {
<ClientAvatar
persona={personaUi.avatar}
state="idle"
affect="anxious"
affect={baselineExpressionFor(personaSummary)}
analyser={null}
rapport={0}
size={200}
@ -1236,6 +1384,18 @@ export default function Session() {
<small></small>
</span>
</div>
<div className="sx-mobile-context__brief">
<span>
<b>{currentPhase.desc}</b>
<small>{stage}</small>
</span>
{primaryContext ? (
<span>
<b>{primaryContext.v}</b>
<small>{primaryContext.l}</small>
</span>
) : null}
</div>
<div className="sx-mobile-context__row sx-mobile-context__row--meters">
<span>
<b>{meters.resistance > 0.55 ? "방어 신호" : "완화 신호"}</b>
@ -1367,7 +1527,9 @@ export default function Session() {
/>
</div>
<div className="sx-stage__now">: {stageStateText[avatarState]}</div>
<div className="sx-stage__now">
: {stageStateText[avatarState]} · {avatarExpressionLabel}
</div>
</section>
{/* 실시간 자막(대본 스타일) + 텍스트 입력 폴백 */}
@ -1494,7 +1656,11 @@ export default function Session() {
<span className="sx-signal__one-when"></span>
)}
</div>
) : null}
) : (
<p className="sx-signal__rest">
. .
</p>
)}
<div className="sx-signal__seq">
<span className="sx-signal__seq-label"> </span>
@ -1595,7 +1761,7 @@ export default function Session() {
type="button"
className={"sx-mic " + (micOn ? "is-on" : "is-off")}
onClick={toggleMic}
disabled={paused || micBusy}
disabled={paused || micBusy || voiceAvailable === false}
aria-pressed={micOn}
aria-label={micOn ? "발화 보내기" : "마이크 켜기"}
>