대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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
|
|
@ -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 ? "발화 보내기" : "마이크 켜기"}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue