대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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

@ -80,11 +80,60 @@ function SettingsLoadingState({
);
}
/**
* (/ )
* · . testId·role .
*/
function SettingsSkeleton({
children,
testId,
variant = "lines",
}: {
children: string;
testId: string;
variant?: "lines" | "rows" | "voice";
}) {
return (
<div
className={`vg-set__skel vg-set__skel--${variant}`}
role="status"
aria-live="polite"
data-testid={testId}
>
<span className="vg-set__skel-sr">{children}</span>
{variant === "rows" ? (
<>
<span className="vg-set__skel-row" aria-hidden="true">
<span className="vg-set__skel-bar" />
<span className="vg-set__skel-pill" />
</span>
<span className="vg-set__skel-row" aria-hidden="true">
<span className="vg-set__skel-bar" />
<span className="vg-set__skel-pill" />
</span>
</>
) : variant === "voice" ? (
<>
<span className="vg-set__skel-card" aria-hidden="true" />
<span className="vg-set__skel-card" aria-hidden="true" />
</>
) : (
<>
<span className="vg-set__skel-line vg-set__skel-line--label" aria-hidden="true" />
<span className="vg-set__skel-line" aria-hidden="true" />
<span className="vg-set__skel-line vg-set__skel-line--short" aria-hidden="true" />
</>
)}
</div>
);
}
export default function Settings() {
const { user } = useAuth();
const role = user?.role ?? "learner";
const isAdmin = role === "admin";
const [dark, setDark] = useTheme();
const [activeSection, setActiveSection] = useState("account");
const [profile, setProfile] = useState<UserProfileResponse | null>(null);
const [displayName, setDisplayName] = useState(user?.name ?? "");
@ -169,8 +218,8 @@ export default function Settings() {
const base: NavItem[] = [
{ id: "account", label: "계정", icon: "users" },
{ id: "appearance", label: "테마", icon: "settings" },
{ id: "voice", label: "음성", icon: "mic" },
{ id: "notify", label: "알림", icon: "info" },
{ id: "voice", label: "음성", icon: "mic" },
];
if (isAdmin) {
base.splice(1, 0, { id: "engine", label: "AI 운영", icon: "shield", adminOnly: true });
@ -178,6 +227,30 @@ export default function Settings() {
return base;
}, [isAdmin]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible?.target.id) {
setActiveSection(visible.target.id.replace(/^set-/, ""));
}
},
{
rootMargin: "-18% 0px -62% 0px",
threshold: [0.2, 0.55],
},
);
for (const item of navItems) {
const section = document.getElementById(`set-${item.id}`);
if (section) observer.observe(section);
}
return () => observer.disconnect();
}, [navItems]);
const visibleNotifications = useMemo(
() => [
{
@ -206,6 +279,7 @@ export default function Settings() {
);
const goSection = (id: string) => {
setActiveSection(id);
document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
@ -274,7 +348,9 @@ export default function Settings() {
}) : cur);
};
const initials = (displayName || profile?.email || "U").trim().slice(0, 1).toUpperCase();
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
const accountEmail = profile?.email ?? user?.email ?? "";
const initials = accountName.trim().slice(0, 1).toUpperCase();
const engineHasRequiredFields = engineConfig
? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0
: false;
@ -290,12 +366,20 @@ export default function Settings() {
return (
<AppShell contextLabel="설정">
<div style={{ marginBottom: "var(--sp-6)" }}>
<div className="vg-set__mast">
<SectionHead
kicker="설정"
title="계정과 학습 환경을 관리합니다"
desc="계정 표시 정보, 음성, 알림, 운영 설정을 한 곳에서 관리합니다."
/>
<div className="vg-set__mast-meta" aria-label="설정 상태">
<span className="vg-set__pill">{roleLabel(role)}</span>
{isAdmin ? (
<span className={`vg-set__pill vg-set__pill--${engineServiceStatus}`}>
AI {healthStatusLabel(engineService?.status)}
</span>
) : null}
</div>
</div>
{error ? (
@ -308,26 +392,47 @@ export default function Settings() {
) : null}
<div className="vg-set" aria-busy={loading}>
<nav className="vg-set__nav" aria-label="설정 섹션">
<div className="vg-set__nav-kicker">SETTINGS</div>
{navItems.map((item) => (
<button
key={item.id}
type="button"
className="vg-set__nav-item"
onClick={() => goSection(item.id)}
>
<Icon name={item.icon} size={18} />
{item.label}
</button>
))}
</nav>
<aside className="vg-set__rail">
<div className="vg-set__rail-card" aria-label="계정 요약">
<div className="vg-set__rail-avatar" aria-hidden="true">
{initials}
</div>
<div className="vg-set__rail-copy">
<div className="vg-set__rail-name">{accountName}</div>
<div className="vg-set__rail-email">{accountEmail}</div>
<div className="vg-set__rail-badges">
<Badge tone="accent">{roleLabel(role)}</Badge>
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
</div>
</div>
</div>
<nav className="vg-set__nav" aria-label="설정 섹션">
<div className="vg-set__nav-kicker">SECTIONS</div>
{navItems.map((item) => (
<button
key={item.id}
type="button"
className={
"vg-set__nav-item" + (activeSection === item.id ? " is-active" : "")
}
aria-current={activeSection === item.id ? "location" : undefined}
onClick={() => goSection(item.id)}
>
<Icon name={item.icon} size={17} />
<span>{item.label}</span>
</button>
))}
</nav>
</aside>
<div className="vg-set__forms">
<Panel id="set-account" className="vg-set__group">
<Panel id="set-account" className="vg-set__group vg-set__group--wide">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
<div className="vg-set__profile">
@ -335,8 +440,8 @@ export default function Settings() {
{initials}
</div>
<div className="vg-set__profile-meta">
<div className="n">{displayName || profile?.email}</div>
<div className="e">{profile?.email ?? user?.email}</div>
<div className="n">{accountName}</div>
<div className="e">{accountEmail}</div>
<div className="badges">
<Badge tone="accent">{roleLabel(role)}</Badge>
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
@ -344,12 +449,12 @@ export default function Settings() {
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__field-grid vg-set__field-grid--account">
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={displayNameInputRef}
@ -359,30 +464,32 @@ export default function Settings() {
displayNameValueRef.current = event.target.value;
setDisplayName(event.target.value);
}}
placeholder="표시할 이름을 입력하세요"
aria-label="표시 이름"
/>
</Field>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
</div>
<Field>
<Input value={profile?.email ?? ""} disabled aria-label="이메일" />
<Input
value={profile?.email ?? ""}
disabled
placeholder="로그인 계정 이메일"
aria-label="이메일"
/>
</Field>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__field vg-set__field--wide">
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={affiliationInputRef}
@ -392,6 +499,7 @@ export default function Settings() {
affiliationValueRef.current = event.target.value;
setAffiliation(event.target.value);
}}
placeholder="소속을 입력하세요"
aria-label="소속"
/>
</Field>
@ -405,43 +513,53 @@ export default function Settings() {
</span>
) : null}
<Button onClick={() => void saveProfile()} disabled={loading || !profileReady}>
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void saveProfile()}
disabled={loading || !profileReady}
>
</Button>
</div>
</Panel>
{isAdmin ? (
<Panel id="set-engine" className="vg-set__group">
<div className="vg-set__group-head">
<Kicker></Kicker>
<div className="vg-set__group-title" style={{ marginTop: 6 }}>
AI
</div>
<div className="vg-set__group-desc">
.
</div>
</div>
<div className={`vg-set__ops vg-set__ops--${engineServiceStatus}`} role="status">
<span className="vg-set__ops-dot" aria-hidden="true" />
<Panel
id="set-engine"
className="vg-set__group vg-set__group--wide vg-set__group--engine"
>
<div className="vg-set__group-head vg-set__group-head--split">
<div>
<b> {healthStatusLabel(engineService?.status)}</b>
<span>
{engineService
? `${engineService.detail} · ${engineService.metric}`
: "운영 상태를 확인하는 중입니다."}
</span>
<Kicker></Kicker>
<div className="vg-set__group-title vg-set__group-title--kicker">
AI
</div>
<div className="vg-set__group-desc">
.
</div>
</div>
<div className={`vg-set__ops vg-set__ops--${engineServiceStatus}`} role="status">
<span className="vg-set__ops-dot" aria-hidden="true" />
<div>
<b> {healthStatusLabel(engineService?.status)}</b>
<span>
{engineService
? `${engineService.detail} · ${engineService.metric}`
: "운영 상태를 확인하는 중입니다."}
</span>
</div>
</div>
</div>
{engineConfig ? (
<>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__engine-grid">
<div className="vg-set__control-block">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__seg" role="radiogroup" aria-label="AI 운영 방식">
{ENGINE_MODES.map((mode) => (
<button
@ -461,46 +579,46 @@ export default function Settings() {
))}
</div>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<Field>
<Input
ref={engineUrlInputRef}
value={engineConfig.engine_url}
onChange={(event) =>
updateEngineConfig({ engine_url: event.target.value })
}
placeholder="운영 연결 주소를 입력하세요"
aria-label="AI 연결 주소"
/>
</Field>
</div>
</div>
<div className="vg-set__field-grid vg-set__field-grid--engine">
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={engineUrlInputRef}
value={engineConfig.engine_url}
onChange={(event) =>
updateEngineConfig({ engine_url: event.target.value })
}
placeholder="운영 연결 주소를 입력하세요"
aria-label="AI 연결 주소"
/>
</Field>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<Field>
<Input
ref={engineModelInputRef}
value={engineConfig.model}
onChange={(event) => updateEngineConfig({ model: event.target.value })}
placeholder="기본 모델 이름을 입력하세요"
aria-label="모델"
/>
</Field>
<div className="vg-set__meta">
<span> : {engineConfig.updated_by ?? "-"}</span>
<span> : {engineStorageLabel}</span>
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={engineModelInputRef}
value={engineConfig.model}
onChange={(event) =>
updateEngineConfig({ model: event.target.value })
}
placeholder="기본 모델 이름을 입력하세요"
aria-label="모델"
/>
</Field>
<div className="vg-set__meta">
<span> : {engineConfig.updated_by ?? "-"}</span>
<span> : {engineStorageLabel}</span>
</div>
</div>
</div>
</div>
@ -513,6 +631,8 @@ export default function Settings() {
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void saveEngine()}
disabled={loading || !engineHasRequiredFields}
>
@ -521,17 +641,19 @@ export default function Settings() {
</div>
</>
) : (
<SettingsLoadingState testId="settings-engine-loading">
<SettingsSkeleton testId="settings-engine-loading" variant="lines">
AI .
</SettingsLoadingState>
</SettingsSkeleton>
)}
</Panel>
) : null}
<Panel id="set-appearance" className="vg-set__group">
<Panel id="set-appearance" className="vg-set__group vg-set__group--compact">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
{preferences ? (
<>
@ -540,13 +662,7 @@ export default function Settings() {
<div className="l"> </div>
<div className="h"> .</div>
</div>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "var(--sp-3)",
}}
>
<span className="vg-set__toggle-line">
<Icon name={dark ? "moon" : "sun"} size={16} />
<Toggle checked={dark} onChange={setDark} label="다크 모드" />
</span>
@ -559,6 +675,8 @@ export default function Settings() {
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void savePreferences("appearance")}
disabled={loading || !preferencesReady}
>
@ -567,16 +685,18 @@ export default function Settings() {
</div>
</>
) : (
<SettingsLoadingState testId="settings-preferences-loading">
<SettingsSkeleton testId="settings-preferences-loading" variant="rows">
.
</SettingsLoadingState>
</SettingsSkeleton>
)}
</Panel>
<Panel id="set-voice" className="vg-set__group">
<Panel id="set-voice" className="vg-set__group vg-set__group--wide">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
{preferences ? (
@ -619,12 +739,12 @@ export default function Settings() {
})}
</div>
<div className="vg-set__row" style={{ marginTop: "var(--sp-5)" }}>
<div className="vg-set__row-label">
<div className="vg-set__range-row">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__range-control">
<input
type="range"
min={0.8}
@ -642,9 +762,8 @@ export default function Settings() {
)
}
aria-label="말하기 속도"
style={{ accentColor: "var(--accent)", width: "100%" }}
/>
<div className="vg-set__meta">
<div className="vg-set__meta vg-set__meta--range">
<span>0.8x</span>
<span>{preferences.voice_rate.toFixed(2)}x</span>
<span>1.2x</span>
@ -660,6 +779,8 @@ export default function Settings() {
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void savePreferences("voice")}
disabled={loading || !preferencesReady}
>
@ -673,37 +794,41 @@ export default function Settings() {
</SettingsLoadingState>
)
) : (
<SettingsLoadingState testId="settings-voice-loading">
<SettingsSkeleton testId="settings-voice-loading" variant="voice">
.
</SettingsLoadingState>
</SettingsSkeleton>
)}
</Panel>
<Panel id="set-notify" className="vg-set__group">
<Panel id="set-notify" className="vg-set__group vg-set__group--compact">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
{preferences ? (
<>
{visibleNotifications.map((item) => (
<div className="vg-set__opt" key={item.id}>
<div className="vg-set__opt-text">
<div className="l">{item.label}</div>
<div className="h">{item.hint}</div>
<div className="vg-set__opt-grid">
{visibleNotifications.map((item) => (
<div className="vg-set__opt" key={item.id}>
<div className="vg-set__opt-text">
<div className="l">{item.label}</div>
<div className="h">{item.hint}</div>
</div>
<Toggle
checked={Boolean(
preferences.notifications[item.id as keyof NotificationPreferences],
)}
onChange={(next) =>
updateNotification(item.id as keyof NotificationPreferences, next)
}
label={item.label}
/>
</div>
<Toggle
checked={Boolean(
preferences.notifications[item.id as keyof NotificationPreferences],
)}
onChange={(next) =>
updateNotification(item.id as keyof NotificationPreferences, next)
}
label={item.label}
/>
</div>
))}
))}
</div>
<div className="vg-set__foot">
{savedKey === "notify" ? (
@ -713,6 +838,8 @@ export default function Settings() {
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void savePreferences("notify")}
disabled={loading || !preferencesReady}
>
@ -721,9 +848,9 @@ export default function Settings() {
</div>
</>
) : (
<SettingsLoadingState testId="settings-notify-loading">
<SettingsSkeleton testId="settings-notify-loading" variant="rows">
.
</SettingsLoadingState>
</SettingsSkeleton>
)}
</Panel>
</div>