vignette/apps/web/src/components/avatar/persona.ts
Yun Chan 085460b5e0 대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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
2026-06-27 02:30:46 +09:00

705 lines
18 KiB
TypeScript

/* =====================================================================
ClientAvatar — 페르소나 / 표정 파라미터 타입 + Live2D식 상태머신 테이블
출처: docs/DESIGN_CONCEPT.md §4.5(6파라미터 정서 상태머신) / §4.6(페르소나 시스템)
── Rive/Live2D 2차 교체 지점 ─────────────────────────────────────
SVG 1차안은 아래 파라미터를 매 프레임 transform 으로 인코딩한다.
Cubism/Rive 전환 시: 이 파일의 (state, expression, resistance, rapport)
입력 계약은 그대로 두고, AffectParams → 모델 파라미터 입력으로 wiring 한다.
===================================================================== */
/* ── 음성 UI 4-state (§4.2) ─────────────────────────────────────────── */
export type AvatarState = "idle" | "listening" | "thinking" | "speaking";
/* ── 내담자 표정 프리셋: 모든 캐릭터가 최소 20개 이상 사용 가능 ─────── */
export type AvatarExpression =
| "neutral"
| "calm"
| "warm"
| "joy"
| "delight"
| "relief"
| "hopeful"
| "sad"
| "grief"
| "lonely"
| "anxious"
| "panic"
| "confused"
| "conflicted"
| "startled"
| "embarrassed"
| "ashamed"
| "guilty"
| "resistant"
| "guarded"
| "skeptical"
| "irritated"
| "angry"
| "rage"
| "tired"
| "bored"
| "overwhelmed"
| "determined";
/* 기존 import 표면 호환용 이름. */
export type AvatarAffect = AvatarExpression;
export interface AvatarExpressionMeta {
id: AvatarExpression;
label: string;
group: "positive" | "negative" | "defensive" | "cognitive" | "energy";
}
export const AVATAR_EXPRESSION_LIBRARY: readonly AvatarExpressionMeta[] = [
{ id: "neutral", label: "중립", group: "cognitive" },
{ id: "calm", label: "차분함", group: "positive" },
{ id: "warm", label: "온화함", group: "positive" },
{ id: "joy", label: "기쁨", group: "positive" },
{ id: "delight", label: "반가움", group: "positive" },
{ id: "relief", label: "안도", group: "positive" },
{ id: "hopeful", label: "희망", group: "positive" },
{ id: "sad", label: "슬픔", group: "negative" },
{ id: "grief", label: "비탄", group: "negative" },
{ id: "lonely", label: "외로움", group: "negative" },
{ id: "anxious", label: "불안", group: "negative" },
{ id: "panic", label: "공황", group: "negative" },
{ id: "confused", label: "혼란", group: "cognitive" },
{ id: "conflicted", label: "갈등", group: "cognitive" },
{ id: "startled", label: "놀람", group: "negative" },
{ id: "embarrassed", label: "당황", group: "negative" },
{ id: "ashamed", label: "수치심", group: "negative" },
{ id: "guilty", label: "죄책감", group: "negative" },
{ id: "resistant", label: "저항", group: "defensive" },
{ id: "guarded", label: "경계", group: "defensive" },
{ id: "skeptical", label: "회의", group: "defensive" },
{ id: "irritated", label: "짜증", group: "defensive" },
{ id: "angry", label: "분노", group: "defensive" },
{ id: "rage", label: "격노", group: "defensive" },
{ id: "tired", label: "피곤", group: "energy" },
{ id: "bored", label: "무심함", group: "energy" },
{ id: "overwhelmed", label: "압도감", group: "negative" },
{ id: "determined", label: "결심", group: "positive" },
];
const EXPRESSION_LABELS: Record<AvatarExpression, string> = Object.fromEntries(
AVATAR_EXPRESSION_LIBRARY.map((expression) => [expression.id, expression.label]),
) as Record<AvatarExpression, string>;
export function expressionLabelFor(expression: AvatarExpression): string {
return EXPRESSION_LABELS[expression];
}
/* ── 연령대 (§4.6 외형 규칙) ───────────────────────────────────────── */
export type AvatarAgeBand = "teen" | "youngAdult" | "adult" | "senior";
export type AvatarHairStyle =
| "short"
| "bob"
| "long-straight"
| "soft-wave"
| "messy"
| "side-part";
export interface AvatarPersona {
/** "서연 · 17세 · 고2" 같은 메타 1줄 라벨 */
label: string;
/** DB persona code. 외형 시드와 테스트 식별에 사용 */
code?: string;
/** 연령대 (외형 규칙 §4.6) */
ageBand: AvatarAgeBand;
/** 피부톤 hex */
skinTone: string;
/** 헤어 스타일/색 */
hair: { style?: AvatarHairStyle; color: string };
/** 의상/상반신 실루엣 색 */
outfitColor?: string;
/** 눈동자 색 */
eyeColor?: string;
/** 캐릭터 고유 보조색 */
accentColor?: string;
/**
* 래스터(비트맵) 아트셋 코드. 지정하면 SVG 파라미터 리그 대신
* `/avatar/<artSet>/<variant>.png` 레이어 합성(Live2D식)으로 렌더링한다.
* 미지정 시 기존 SVG 리그. asset 은 imagegen(gpt-image-2)+BiRefNet 누끼로 생성.
*/
rasterArtSet?: string;
/** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */
realism: number;
/**
* 저항도 0~1 (§4.6). 라포가 쌓이면 감소한다.
* 미지정 시 defensive expression → 0.6, 그 외 0 으로 본다.
*/
resistance?: number;
/** 해당 페르소나의 기본 정서 경향 */
expressionBias?: AvatarExpression;
}
/* ── 표정 파라미터 상태머신 (§4.5) ──────────────────────────────────
Live2D/Cubism 파라미터처럼 작은 수치들을 조합한다. 과장 금지:
눈물/홍조/땀/만화식 효과 없이 "감지되되 단언되지 않는" 수준. */
export interface AffectParams {
/** 눈꺼풀 하강 px (우울 1~2px, 놀람은 음수) */
eyelidDrop: number;
/** 눈 세로 개방 배율 */
eyeOpen: number;
/** 동공/홍채 배율 */
pupilScale: number;
/** 시선 회피 x 오프셋 px */
gazeAvert: number;
/** 머리 기울기 deg */
headTilt: number;
/** 어깨 돌아선 각도 deg (저항 8° → 라포로 0°) */
shoulderTurn: number;
/** 호흡 주기 초 (= breathRate 역수 표현) */
breathPeriod: number;
/** 호흡 진폭 px (translateY 1.5~2.5) */
breathAmp: number;
/** 광배 색조 (CSS 색/변수) */
auraHue: string;
/** 광배 기본 opacity */
auraOpacity: number;
/** 깜빡임 최소 간격 초 */
blinkMin: number;
/** 깜빡임 최대 간격 초 */
blinkMax: number;
/** 입꼬리 곡률 (양수=미소, 음수=처짐) */
mouthCurve: number;
/** 기본 입 열림 0~1. 발화 립싱크와 max 처리 */
mouthOpen: number;
/** 입 너비 px 보정 */
mouthWidth: number;
/** 입술 긴장도 0~1 */
mouthTension: number;
/** 눈썹 안쪽 끝 기울기 px (양수=안쪽 처짐, 음수=안쪽 올라감) */
browTilt: number;
/** 눈썹 전체 들어올림 px (양수=위로) */
browLift: number;
/** 눈썹 안쪽 모임 px */
browPinch: number;
}
const DEFAULT_PARAMS: AffectParams = {
eyelidDrop: 0,
eyeOpen: 1,
pupilScale: 1,
gazeAvert: 0,
headTilt: 0,
shoulderTurn: 0,
breathPeriod: 4,
breathAmp: 2,
auraHue: "#3E7A6E",
auraOpacity: 0.04,
blinkMin: 4,
blinkMax: 7,
mouthCurve: 1.2,
mouthOpen: 0,
mouthWidth: 0,
mouthTension: 0.2,
browTilt: 0,
browLift: 0,
browPinch: 0,
};
const AFFECT_TABLE: Record<AvatarExpression, Partial<AffectParams>> = {
neutral: {},
calm: {
eyelidDrop: 0.4,
eyeOpen: 0.96,
breathPeriod: 4.8,
breathAmp: 1.7,
auraHue: "#4F837A",
mouthCurve: 0.8,
blinkMin: 4.8,
blinkMax: 7.5,
},
warm: {
eyeOpen: 1.02,
breathPeriod: 4.2,
auraHue: "#6E8B82",
auraOpacity: 0.055,
mouthCurve: 2.2,
mouthWidth: 1,
browLift: 0.4,
},
joy: {
eyeOpen: 1.06,
pupilScale: 1.04,
breathPeriod: 3.7,
breathAmp: 2.3,
auraHue: "#5F968B",
auraOpacity: 0.07,
mouthCurve: 3.2,
mouthOpen: 0.08,
mouthWidth: 1.5,
browLift: 0.9,
},
delight: {
eyeOpen: 1.12,
pupilScale: 1.08,
breathPeriod: 3.3,
breathAmp: 2.4,
auraHue: "#7A9464",
auraOpacity: 0.075,
blinkMin: 3.3,
blinkMax: 5.5,
mouthCurve: 3.8,
mouthOpen: 0.16,
mouthWidth: 2,
browLift: 1.3,
},
relief: {
eyelidDrop: 0.7,
eyeOpen: 0.94,
headTilt: 1,
breathPeriod: 5.2,
breathAmp: 1.9,
auraHue: "#6E8B82",
auraOpacity: 0.06,
mouthCurve: 1.7,
browLift: 0.2,
},
hopeful: {
eyeOpen: 1.08,
pupilScale: 1.04,
gazeAvert: -0.8,
breathPeriod: 4,
auraHue: "#4F8C78",
auraOpacity: 0.065,
mouthCurve: 2.3,
mouthWidth: 1,
browLift: 1,
},
sad: {
eyelidDrop: 1.5,
eyeOpen: 0.88,
gazeAvert: 1.5,
headTilt: -1,
shoulderTurn: 1,
breathPeriod: 5.5,
breathAmp: 1.5,
auraHue: "#5B6B73",
auraOpacity: 0.05,
blinkMin: 5,
blinkMax: 8,
mouthCurve: -1.2,
browTilt: 1.2,
browPinch: 0.5,
},
grief: {
eyelidDrop: 2.2,
eyeOpen: 0.82,
gazeAvert: 2,
headTilt: -1.6,
shoulderTurn: 1.5,
breathPeriod: 6.2,
breathAmp: 1.25,
auraHue: "#56636C",
auraOpacity: 0.055,
blinkMin: 5.5,
blinkMax: 8.5,
mouthCurve: -2.2,
mouthWidth: -1,
browTilt: 1.8,
browPinch: 1,
},
lonely: {
eyelidDrop: 1.4,
eyeOpen: 0.9,
gazeAvert: 2.8,
headTilt: -2,
shoulderTurn: 2.5,
breathPeriod: 5.8,
breathAmp: 1.35,
auraHue: "#66727A",
auraOpacity: 0.045,
mouthCurve: -0.8,
mouthWidth: -0.8,
browTilt: 1,
},
anxious: {
eyeOpen: 1.1,
pupilScale: 0.96,
gazeAvert: 2,
headTilt: 0.8,
shoulderTurn: 2,
breathPeriod: 3,
breathAmp: 1.5,
auraHue: "#3B6E8F",
auraOpacity: 0.05,
blinkMin: 2,
blinkMax: 4,
mouthCurve: -0.1,
mouthTension: 0.6,
browTilt: 0.4,
browLift: 0.5,
browPinch: 1,
},
panic: {
eyelidDrop: -0.8,
eyeOpen: 1.24,
pupilScale: 0.9,
gazeAvert: 3,
headTilt: 1.6,
shoulderTurn: 3.5,
breathPeriod: 2.35,
breathAmp: 1.35,
auraHue: "#2E5A7A",
auraOpacity: 0.075,
blinkMin: 1.6,
blinkMax: 3,
mouthCurve: -0.8,
mouthOpen: 0.2,
mouthWidth: 0.5,
mouthTension: 0.85,
browTilt: -0.8,
browLift: 2.4,
browPinch: 1.4,
},
confused: {
eyeOpen: 1.02,
gazeAvert: 2.4,
headTilt: 3,
shoulderTurn: 1.5,
breathPeriod: 4.2,
auraHue: "#5B6B73",
auraOpacity: 0.045,
blinkMin: 4.5,
blinkMax: 7,
mouthCurve: -0.2,
mouthOpen: 0.04,
browTilt: -0.8,
browLift: 0.8,
browPinch: 0.8,
},
conflicted: {
eyelidDrop: 0.6,
eyeOpen: 0.96,
gazeAvert: 2.2,
headTilt: -2.6,
shoulderTurn: 2,
breathPeriod: 4.8,
breathAmp: 1.45,
auraHue: "#776F85",
auraOpacity: 0.045,
mouthCurve: -0.4,
mouthWidth: -0.4,
mouthTension: 0.55,
browTilt: 0.6,
browPinch: 1.2,
},
startled: {
eyelidDrop: -1.2,
eyeOpen: 1.22,
pupilScale: 0.95,
headTilt: 1.2,
shoulderTurn: 1,
breathPeriod: 2.8,
breathAmp: 1.7,
auraHue: "#8A6B42",
auraOpacity: 0.07,
blinkMin: 2.2,
blinkMax: 4.2,
mouthCurve: -0.2,
mouthOpen: 0.24,
browLift: 2.2,
},
embarrassed: {
eyelidDrop: 0.9,
eyeOpen: 0.9,
gazeAvert: 3.2,
headTilt: 2.2,
shoulderTurn: 2.4,
breathPeriod: 3.4,
auraHue: "#8B6F6B",
auraOpacity: 0.05,
blinkMin: 2.7,
blinkMax: 4.6,
mouthCurve: 0.6,
mouthWidth: -0.5,
browTilt: 0.5,
browLift: -0.2,
},
ashamed: {
eyelidDrop: 1.8,
eyeOpen: 0.82,
gazeAvert: 4,
headTilt: -2.8,
shoulderTurn: 4,
breathPeriod: 5.5,
breathAmp: 1.2,
auraHue: "#716873",
auraOpacity: 0.04,
mouthCurve: -1.5,
mouthWidth: -1,
mouthTension: 0.5,
browTilt: 1.4,
browPinch: 0.9,
},
guilty: {
eyelidDrop: 1.2,
eyeOpen: 0.88,
gazeAvert: 3.5,
headTilt: -1.8,
shoulderTurn: 3,
breathPeriod: 5.2,
breathAmp: 1.25,
auraHue: "#6D7074",
auraOpacity: 0.045,
mouthCurve: -1.1,
mouthWidth: -0.6,
browTilt: 1,
browPinch: 1.1,
},
resistant: {
eyelidDrop: 0.5,
eyeOpen: 0.96,
gazeAvert: 4,
headTilt: 0.6,
shoulderTurn: 8,
breathPeriod: 5,
breathAmp: 1,
auraHue: "#93A09C",
auraOpacity: 0.03,
blinkMin: 4,
blinkMax: 7,
mouthCurve: -0.4,
mouthWidth: -0.8,
mouthTension: 0.65,
browTilt: -0.4,
browPinch: 0.6,
},
guarded: {
eyelidDrop: 0.3,
eyeOpen: 0.92,
gazeAvert: 3.4,
headTilt: 1.5,
shoulderTurn: 6,
breathPeriod: 4.8,
breathAmp: 1.1,
auraHue: "#7D8784",
auraOpacity: 0.035,
mouthCurve: -0.3,
mouthWidth: -1.2,
mouthTension: 0.75,
browTilt: -0.2,
browPinch: 1,
},
skeptical: {
eyelidDrop: 0.6,
eyeOpen: 0.9,
gazeAvert: 1.8,
headTilt: 2.8,
shoulderTurn: 3,
breathPeriod: 4.6,
auraHue: "#7E8176",
auraOpacity: 0.035,
mouthCurve: -0.6,
mouthWidth: -0.3,
mouthTension: 0.7,
browTilt: -1.2,
browLift: -0.5,
browPinch: 0.8,
},
irritated: {
eyelidDrop: 0.4,
eyeOpen: 0.95,
gazeAvert: 1.2,
headTilt: -1.2,
shoulderTurn: 3.5,
breathPeriod: 3.6,
breathAmp: 1.25,
auraHue: "#8A5A00",
auraOpacity: 0.055,
blinkMin: 3,
blinkMax: 5.5,
mouthCurve: -0.9,
mouthWidth: -0.5,
mouthTension: 0.85,
browTilt: -1.4,
browPinch: 1.4,
},
angry: {
eyelidDrop: 0.1,
eyeOpen: 1.0,
pupilScale: 0.95,
gazeAvert: 0.6,
headTilt: -1,
shoulderTurn: 4,
breathPeriod: 3.1,
breathAmp: 1.6,
auraHue: "#9A5A3D",
auraOpacity: 0.07,
blinkMin: 3.2,
blinkMax: 5,
mouthCurve: -1.2,
mouthOpen: 0.06,
mouthWidth: 0.6,
mouthTension: 0.9,
browTilt: -2,
browPinch: 1.8,
},
rage: {
eyelidDrop: -0.2,
eyeOpen: 1.12,
pupilScale: 0.9,
gazeAvert: 0.2,
headTilt: -1.4,
shoulderTurn: 5,
breathPeriod: 2.7,
breathAmp: 1.9,
auraHue: "#A8352C",
auraOpacity: 0.09,
blinkMin: 2.8,
blinkMax: 4.5,
mouthCurve: -1.8,
mouthOpen: 0.22,
mouthWidth: 1.2,
mouthTension: 1,
browTilt: -2.4,
browPinch: 2,
},
tired: {
eyelidDrop: 2,
eyeOpen: 0.78,
gazeAvert: 1,
headTilt: -1.8,
shoulderTurn: 1,
breathPeriod: 6,
breathAmp: 1,
auraHue: "#6F7777",
auraOpacity: 0.035,
blinkMin: 5.8,
blinkMax: 9,
mouthCurve: -0.7,
mouthWidth: -0.8,
browTilt: 0.3,
browLift: -0.6,
},
bored: {
eyelidDrop: 1.6,
eyeOpen: 0.82,
gazeAvert: 2,
headTilt: 2,
shoulderTurn: 2,
breathPeriod: 5.4,
breathAmp: 1,
auraHue: "#7B827C",
auraOpacity: 0.03,
blinkMin: 5,
blinkMax: 8.5,
mouthCurve: -0.2,
mouthWidth: -1,
mouthTension: 0.5,
browLift: -0.8,
},
overwhelmed: {
eyelidDrop: 1,
eyeOpen: 0.94,
gazeAvert: 3,
headTilt: -2.4,
shoulderTurn: 3.5,
breathPeriod: 3.2,
breathAmp: 1.15,
auraHue: "#6B5E7D",
auraOpacity: 0.06,
blinkMin: 2.4,
blinkMax: 4,
mouthCurve: -1.1,
mouthOpen: 0.05,
mouthTension: 0.65,
browTilt: 1.1,
browPinch: 1.5,
},
determined: {
eyeOpen: 1.03,
gazeAvert: 0,
headTilt: 0,
shoulderTurn: 0.4,
breathPeriod: 3.8,
breathAmp: 1.8,
auraHue: "#3E7A6E",
auraOpacity: 0.06,
mouthCurve: 0.4,
mouthWidth: 0.2,
mouthTension: 0.75,
browTilt: -0.8,
browPinch: 0.8,
},
};
/* ── 연령대 외형 규칙 (§4.6) ────────────────────────────────────────── */
export interface AgeBandLook {
/** 눈 크기 배율 */
eyeSize: number;
/** 턱 폭 배율 (작을수록 좁은 턱) */
jawWidth: number;
/** 광배 채도 가중(생기) */
auraSaturation: number;
}
const AGE_TABLE: Record<AvatarAgeBand, AgeBandLook> = {
teen: { eyeSize: 1.15, jawWidth: 0.92, auraSaturation: 1.1 },
youngAdult: { eyeSize: 1.0, jawWidth: 1.0, auraSaturation: 1.0 },
adult: { eyeSize: 0.95, jawWidth: 1.06, auraSaturation: 0.9 },
senior: { eyeSize: 0.92, jawWidth: 1.0, auraSaturation: 0.85 },
};
export function ageLookFor(ageBand: AvatarAgeBand): AgeBandLook {
return AGE_TABLE[ageBand];
}
/* ── 표정 → 파라미터 해석 (라포 완화 + 상태 보정 포함) ─────────────────
rapport 0~1: 세션 진행으로 쌓이는 라포. 저항 파라미터를 서서히 푼다. */
export function resolveAffectParams(
affect: AvatarAffect,
state: AvatarState,
rapport: number,
): AffectParams {
const out: AffectParams = { ...DEFAULT_PARAMS, ...AFFECT_TABLE[affect] };
const r = Math.min(1, Math.max(0, rapport));
if (r > 0) {
out.shoulderTurn = out.shoulderTurn * (1 - r);
out.gazeAvert = out.gazeAvert * (1 - 0.85 * r);
out.eyelidDrop = out.eyelidDrop * (1 - 0.35 * r);
out.browPinch = out.browPinch * (1 - 0.45 * r);
if (affect === "resistant" || affect === "guarded" || affect === "skeptical") {
out.auraHue = r > 0.5 ? "#6E8B82" : out.auraHue;
out.mouthCurve += r * 1.2;
}
}
switch (state) {
case "thinking":
// 생각 중 표현을 또렷하게: 시선 회피(위/옆 응시) + 고개 기울임 + 사색 눈썹.
out.gazeAvert = Math.max(out.gazeAvert, 3.5);
out.blinkMin = out.blinkMin * 1.35;
out.blinkMax = out.blinkMax * 1.35;
out.auraOpacity *= 0.85;
out.breathAmp *= 0.6;
out.headTilt += out.headTilt >= 0 ? 1.6 : -1.6;
out.browLift += 0.3;
break;
case "listening":
out.gazeAvert = out.gazeAvert * 0.4;
out.browLift += 0.25;
break;
case "speaking":
out.gazeAvert = out.gazeAvert * 0.35;
out.mouthOpen = Math.max(out.mouthOpen, 0.02);
break;
case "idle":
out.breathPeriod = Math.max(out.breathPeriod, 3.5);
break;
}
return out;
}
/** persona.resistance → 초기 저항도. 미지정 시 defensive expression 으로 추정. */
export function baseResistanceOf(persona: AvatarPersona, affect: AvatarAffect): number {
if (typeof persona.resistance === "number") return persona.resistance;
const expression = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === affect);
return expression?.group === "defensive" ? 0.6 : 0;
}