회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
This commit is contained in:
parent
a479db7a5a
commit
a0311c5957
100 changed files with 4884 additions and 11210 deletions
|
|
@ -23,7 +23,7 @@ import {
|
|||
ClientAvatar,
|
||||
expressionLabelFor,
|
||||
} from "../components/avatar/ClientAvatar";
|
||||
import type { AvatarState, AvatarAffect, AvatarPersona } from "../components/avatar/ClientAvatar";
|
||||
import type { AvatarState, AvatarAffect } from "../components/avatar/ClientAvatar";
|
||||
import { Kicker, Button, Icon, surfaceClassName } from "../components/ui";
|
||||
import {
|
||||
ApiError,
|
||||
|
|
@ -37,7 +37,6 @@ import {
|
|||
type LiveCoachQuota,
|
||||
type LiveCoachSuggestion,
|
||||
type PersonaSummary,
|
||||
type SessionDetailResponse,
|
||||
type SessionProgress,
|
||||
type SessionStage,
|
||||
type SessionStartMode,
|
||||
|
|
@ -45,6 +44,7 @@ import {
|
|||
import { useAuth } from "../lib/auth";
|
||||
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
|
||||
import { displayPiiSafeText } from "../lib/piiDisplay";
|
||||
import { getFocusableControls, trapTabKey } from "../lib/focusTrap";
|
||||
import {
|
||||
parseVoicePracticeContext,
|
||||
voicePracticeSearch,
|
||||
|
|
@ -58,17 +58,7 @@ import {
|
|||
PRACTICE_MODE_LABEL,
|
||||
PRACTICE_NOVELTY_LABEL,
|
||||
} from "../lib/practiceLaunchIntent";
|
||||
import {
|
||||
DIFFICULTY_LABEL,
|
||||
PENDING_PERSONA_AVATAR_APPEARANCE,
|
||||
isUsablePersona,
|
||||
personaAvatarAppearance,
|
||||
personaBaselineExpression,
|
||||
personaMeta,
|
||||
personaTheoryLabel,
|
||||
shortPersonaName,
|
||||
unavailablePersonaMessage,
|
||||
} from "../lib/personaViewModel";
|
||||
import { isUsablePersona, unavailablePersonaMessage } from "../lib/personaViewModel";
|
||||
import {
|
||||
VOICE_CONNECTION_SAVE_FAILED,
|
||||
createAudioWorkletCapture,
|
||||
|
|
@ -84,6 +74,28 @@ import {
|
|||
type VoiceStatus,
|
||||
} from "./session/voiceCapture";
|
||||
import { AllianceCheckpointPrompt } from "./session/AllianceCheckpointPrompt";
|
||||
import {
|
||||
AI_VOICE_DISCLOSURE,
|
||||
DEFAULT_COACH_QUOTA,
|
||||
SESSION_PHASES,
|
||||
THEORY_MODE_OPTIONS,
|
||||
buildPersonaUi,
|
||||
elapsedFromSession,
|
||||
expressionForSession,
|
||||
formatCoachTimestamp,
|
||||
looksLikeSessionId,
|
||||
metersFromOpenness,
|
||||
normalizePersonaCode,
|
||||
preferredTheoryMode,
|
||||
primaryGoalActionLabel,
|
||||
secondsBetween,
|
||||
splitMetaLines,
|
||||
type FeedbackMode,
|
||||
type PhaseInfo,
|
||||
type SignalTone,
|
||||
type TheoryMode,
|
||||
type Utterance,
|
||||
} from "./session/sessionViewModel";
|
||||
import {
|
||||
multimodalAllianceApi,
|
||||
type MultimodalConsentRequest,
|
||||
|
|
@ -91,85 +103,6 @@ import {
|
|||
import "./session/session.css";
|
||||
import { randomUuid } from "../lib/uuid";
|
||||
|
||||
/* ── 도메인 상수/타입 ───────────────────────────────────────────────── */
|
||||
|
||||
type FeedbackMode = "immersive" | "ambient" | "coached";
|
||||
type TheoryMode = "humanistic" | "cbt" | "integrative";
|
||||
type Speaker = "client" | "learner";
|
||||
type SignalTone = "pos" | "warn" | "neutral";
|
||||
|
||||
const AI_VOICE_DISCLOSURE =
|
||||
"내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다.";
|
||||
|
||||
interface Utterance {
|
||||
id: number;
|
||||
speaker: Speaker;
|
||||
text: string;
|
||||
turnSeq?: number;
|
||||
/** 발화 시작 시점(세션 경과 초) */
|
||||
at: number;
|
||||
/** 진행 중(스트리밍/타이핑) 발화 여부 */
|
||||
partial?: boolean;
|
||||
/** 음성 WebSocket이 reply 전에 실패해 DB 턴으로 확정되지 않은 발화 */
|
||||
failed?: boolean;
|
||||
/** 실시간 음성 전사의 현재 확정 단계 */
|
||||
voiceTranscriptState?: "interim" | "finalizing";
|
||||
}
|
||||
|
||||
interface PhaseInfo {
|
||||
key: SessionStage;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface ClientContext {
|
||||
initial: string;
|
||||
name: string;
|
||||
meta: string;
|
||||
rows: { l: string; v: string }[];
|
||||
chips: { t: string; clay: boolean }[];
|
||||
}
|
||||
|
||||
const SESSION_PHASES: PhaseInfo[] = [
|
||||
{ key: "라포", desc: "첫 인사와 안전감 형성" },
|
||||
{ key: "탐색", desc: "호소 문제와 일상을 함께 이해" },
|
||||
{ key: "개입", desc: "감정과 생각을 다루는 기법 적용" },
|
||||
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
|
||||
];
|
||||
|
||||
function primaryGoalActionLabel(goal: SessionStage): string {
|
||||
const lastCode = goal.charCodeAt(goal.length - 1);
|
||||
const hasFinalConsonant =
|
||||
lastCode >= 0xac00 && lastCode <= 0xd7a3 && (lastCode - 0xac00) % 28 !== 0;
|
||||
return `${goal}${hasFinalConsonant ? "을" : "를"} 핵심 초점으로 설정`;
|
||||
}
|
||||
|
||||
const THEORY_MODE_OPTIONS: {
|
||||
value: TheoryMode;
|
||||
label: string;
|
||||
detail: string;
|
||||
focus: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "humanistic",
|
||||
label: "인간중심",
|
||||
detail: "공감·반영",
|
||||
focus: "감정과 욕구를 반영한 뒤 열린 질문으로 이어갑니다.",
|
||||
},
|
||||
{
|
||||
value: "cbt",
|
||||
label: "CBT",
|
||||
detail: "생각·행동",
|
||||
focus: "상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
|
||||
},
|
||||
{
|
||||
value: "integrative",
|
||||
label: "통합",
|
||||
detail: "혼합 접근",
|
||||
focus: "공감적 반영을 먼저 두고 필요한 지점만 구조화합니다.",
|
||||
},
|
||||
];
|
||||
const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
|
||||
|
||||
interface CoachChatMessage {
|
||||
id: string;
|
||||
sender: "learner" | "coach";
|
||||
|
|
@ -178,186 +111,8 @@ interface CoachChatMessage {
|
|||
createdAt: number;
|
||||
}
|
||||
|
||||
function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
|
||||
const firstSupported = summary?.theory_target.find(
|
||||
(target): target is TheoryMode =>
|
||||
target === "humanistic" || target === "cbt" || target === "integrative",
|
||||
);
|
||||
return firstSupported ?? "humanistic";
|
||||
}
|
||||
|
||||
let _uid = 100;
|
||||
const nextId = () => ++_uid;
|
||||
|
||||
function normalizePersonaCode(raw?: string): string {
|
||||
return (raw ?? "").trim().toUpperCase();
|
||||
}
|
||||
|
||||
function looksLikeSessionId(raw?: string): boolean {
|
||||
const value = (raw ?? "").trim();
|
||||
return (
|
||||
/^[0-9a-f]{32}$/i.test(value) ||
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function secondsBetween(startedAt: string, createdAt: string): number {
|
||||
const start = Date.parse(startedAt);
|
||||
const created = Date.parse(createdAt);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(created)) return 0;
|
||||
return Math.max(0, Math.round((created - start) / 1000));
|
||||
}
|
||||
|
||||
function elapsedFromSession(detail: SessionDetailResponse): number {
|
||||
const start = Date.parse(detail.started_at);
|
||||
const end = detail.ended_at ? Date.parse(detail.ended_at) : Date.now();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
|
||||
return Math.max(0, Math.round((end - start) / 1000));
|
||||
}
|
||||
|
||||
function buildPersonaUi(
|
||||
summary: PersonaSummary | null,
|
||||
code: string,
|
||||
): { avatar: AvatarPersona; context: ClientContext } {
|
||||
if (!summary) {
|
||||
return {
|
||||
avatar: {
|
||||
label: "내담자 정보 확인 중",
|
||||
...PENDING_PERSONA_AVATAR_APPEARANCE,
|
||||
realism: 0.3,
|
||||
},
|
||||
context: {
|
||||
initial: "?",
|
||||
name: "내담자 확인 중",
|
||||
meta: code ? `${code} · DB 카탈로그 확인 중` : "DB 카탈로그 확인 중",
|
||||
rows: [
|
||||
{ l: "상태", v: "실제 연습 대상 정보를 확인하고 있습니다." },
|
||||
],
|
||||
chips: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const name = shortPersonaName(summary.display_name);
|
||||
const difficulty = DIFFICULTY_LABEL[summary.difficulty] ?? summary.difficulty;
|
||||
const theories = summary.theory_target.length
|
||||
? summary.theory_target.map(personaTheoryLabel).join(" · ")
|
||||
: "공통";
|
||||
const chips = [
|
||||
{ t: summary.code, clay: false },
|
||||
{ t: difficulty, clay: summary.difficulty === "hard" },
|
||||
...summary.theory_target.slice(0, 2).map((target) => ({
|
||||
t: personaTheoryLabel(target),
|
||||
clay: false,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
avatar: {
|
||||
code: summary.code,
|
||||
label: `${name} · ${personaMeta(summary)}`,
|
||||
...personaAvatarAppearance(summary),
|
||||
},
|
||||
context: {
|
||||
initial: name.slice(0, 1) || summary.code.slice(0, 1),
|
||||
name,
|
||||
meta: personaMeta(summary),
|
||||
rows: [
|
||||
{ l: "호소", v: summary.presenting_summary || "요약 정보 없음" },
|
||||
{ l: "대상", v: theories },
|
||||
{ l: "난도", v: difficulty },
|
||||
],
|
||||
chips,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* D3(가운뎃점 정리) — "16-18 · 고2 · 자퇴 고민 · 교육용 가상 내담자" 처럼 한 줄에 · 가
|
||||
2개 이상 몰린 소개 문자열을, 줄당 · 1개 이하가 되도록 두 항목씩 묶어 여러 줄로 쪼갠다.
|
||||
원본 personaMeta() 는 다른 화면도 함께 쓰는 공용 유틸이라 건드리지 않고 표시 단계에서만 나눈다. */
|
||||
function splitMetaLines(meta: string): string[] {
|
||||
const parts = meta
|
||||
.split("·")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length <= 2) return [meta];
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < parts.length; i += 2) {
|
||||
lines.push(parts.slice(i, i + 2).join(" · "));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/* ── 작은 표현 유틸 ─────────────────────────────────────────────────── */
|
||||
|
||||
// 백엔드가 반환한 effective_openness(0~1)를 실시간 관찰 신호로만 변환한다.
|
||||
// 점수/정밀 평가는 회기 종료 후 리뷰 화면에서 다룬다.
|
||||
function metersFromOpenness(openness: number) {
|
||||
const o = clamp01(openness);
|
||||
return {
|
||||
rapport: o, // 마음 열림(라포)
|
||||
resistance: clamp01(1 - o * 0.9), // 저항감(반비례)
|
||||
anxiety: clamp01(0.7 - o * 0.4), // 불안·위축(완만히 감소)
|
||||
};
|
||||
}
|
||||
|
||||
function baselineExpressionFor(summary: PersonaSummary | null): AvatarAffect {
|
||||
return personaBaselineExpression(summary);
|
||||
}
|
||||
|
||||
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() {
|
||||
const { sessionId: routeId } = useParams<{ sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -1710,11 +1465,14 @@ export default function Session() {
|
|||
: detail.includes("engine unavailable") || detail.includes("engine stream")
|
||||
? "AI 엔진이 응답하지 않습니다. 입력 내용은 복원했습니다. 잠시 뒤 다시 시도하고, 반복되면 관리자에게 내담자 응답 엔진 상태 확인을 요청하세요."
|
||||
: "내담자 응답을 생성하지 못했습니다. 입력 내용은 복원했습니다. 같은 발화로 다시 시도해 주세요.";
|
||||
setComposeText(text);
|
||||
setTurnError(normalized);
|
||||
pushSignal("warn", "AI 엔진 연결 실패");
|
||||
setAvatarState("listening");
|
||||
} finally {
|
||||
setSending(false);
|
||||
setClientReplyPending(false);
|
||||
setVoiceStatus((prev) => (prev === "thinking" ? "idle" : prev));
|
||||
}
|
||||
}, [
|
||||
composeText,
|
||||
|
|
@ -2474,6 +2232,9 @@ export default function Session() {
|
|||
const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey;
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
const isInput = target?.closest("input, textarea, select, [contenteditable='true']");
|
||||
const isInteractiveControl = target?.closest(
|
||||
"button, a[href], summary, [role='button'], [role='link']",
|
||||
);
|
||||
|
||||
// Alt+M: 마이크 토글
|
||||
if (isAlt && e.key.toLowerCase() === "m") {
|
||||
|
|
@ -2558,7 +2319,8 @@ export default function Session() {
|
|||
}
|
||||
|
||||
// 입력창 밖 단축키
|
||||
if (!isInput) {
|
||||
// 일반 단축키는 컨트롤의 기본 키보드 동작을 가로채지 않는다.
|
||||
if (!isInput && !isInteractiveControl) {
|
||||
if (e.key === "p" || e.key === "P" || e.key === " ") {
|
||||
if (e.key === " ") e.preventDefault();
|
||||
togglePause();
|
||||
|
|
@ -2605,20 +2367,10 @@ export default function Session() {
|
|||
dismissVoiceConsent();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = [voiceConsentTextRef.current, voiceConsentAcceptRef.current].filter(
|
||||
(control): control is HTMLButtonElement => control !== null && !control.disabled,
|
||||
);
|
||||
if (controls.length === 0) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
trapTabKey(event, controls);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
|
|
@ -2637,21 +2389,7 @@ export default function Session() {
|
|||
window.setTimeout(() => shortcutHelpConfirmRef.current?.focus(), 0);
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = Array.from(
|
||||
shortcutHelpPanelRef.current?.querySelectorAll<HTMLElement>(
|
||||
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||
) ?? [],
|
||||
).filter((control) => control.getClientRects().length > 0);
|
||||
if (controls.length === 0) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
trapTabKey(event, getFocusableControls(shortcutHelpPanelRef.current));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
|
|
@ -2673,21 +2411,7 @@ export default function Session() {
|
|||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = Array.from(
|
||||
coachEvidencePanelRef.current?.querySelectorAll<HTMLElement>(
|
||||
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||
) ?? [],
|
||||
).filter((control) => control.getClientRects().length > 0);
|
||||
if (controls.length === 0) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
trapTabKey(event, getFocusableControls(coachEvidencePanelRef.current));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
|
|
@ -2702,21 +2426,7 @@ export default function Session() {
|
|||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = Array.from(
|
||||
coachHistoryPanelRef.current?.querySelectorAll<HTMLElement>(
|
||||
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||
) ?? [],
|
||||
).filter((control) => control.getClientRects().length > 0);
|
||||
if (controls.length === 0) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
trapTabKey(event, getFocusableControls(coachHistoryPanelRef.current));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
|
|
@ -2746,21 +2456,10 @@ export default function Session() {
|
|||
setEndDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
const controls = [endCancelRef.current, endConfirmRef.current].filter(
|
||||
(control): control is HTMLButtonElement => control !== null && !control.disabled,
|
||||
);
|
||||
if (controls.length === 0) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
const controls = [endCancelRef.current, endConfirmRef.current].filter(
|
||||
(control): control is HTMLButtonElement => control !== null && !control.disabled,
|
||||
);
|
||||
trapTabKey(event, controls);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
|
|
@ -3964,6 +3663,29 @@ export default function Session() {
|
|||
<span className="sx-safety__text">
|
||||
<b>{turnErrorTitle}</b> · {turnError}
|
||||
</span>
|
||||
<div className="sx-turn-error__actions">
|
||||
{composeText.trim() && !sessionEnded && !paused ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sx-turn-error__retry-btn"
|
||||
onClick={() => {
|
||||
setTurnError(null);
|
||||
setVoiceStatus("idle");
|
||||
void handleSend();
|
||||
}}
|
||||
>
|
||||
다시 전송
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="sx-turn-error__dismiss-btn"
|
||||
onClick={() => setTurnError(null)}
|
||||
aria-label="오류 알림 닫기"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -5187,23 +4909,6 @@ export default function Session() {
|
|||
);
|
||||
}
|
||||
|
||||
function formatCoachTimestamp(value: string | null | undefined) {
|
||||
if (!value) return "방금";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "방금";
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
month: sameDay ? undefined : "numeric",
|
||||
day: sameDay ? undefined : "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/* ── 로컬 게이지 (매우 천천히 채움, 2s — §5.5 산만함 방지) ──────────── */
|
||||
function Gauge({ value, tone }: { value: number; tone: "clay" | "accent" }) {
|
||||
const pct = Math.round(clamp01(value) * 100);
|
||||
|
|
|
|||
|
|
@ -302,6 +302,46 @@ function DecisionSelector({
|
|||
);
|
||||
}
|
||||
|
||||
function ApprovalSubmitControls({
|
||||
approving,
|
||||
canSubmit,
|
||||
intent,
|
||||
approvalDecision,
|
||||
approvalAllowed,
|
||||
requirementId,
|
||||
requirementCopy,
|
||||
}: {
|
||||
approving: boolean;
|
||||
canSubmit: boolean;
|
||||
intent: DecisionIntent | null;
|
||||
approvalDecision: ApprovalDecision;
|
||||
approvalAllowed: boolean;
|
||||
requirementId: string;
|
||||
requirementCopy: string;
|
||||
}) {
|
||||
const variant = submitVariant(intent, approvalDecision);
|
||||
const label = submitLabel(intent, approvalDecision);
|
||||
const requirementClassName = `cic-approval-requirement ${
|
||||
canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""
|
||||
}`;
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={variant}
|
||||
type="submit"
|
||||
disabled={!canSubmit || approving}
|
||||
aria-describedby={requirementId}
|
||||
>
|
||||
{approving ? "결정 기록 중…" : label}
|
||||
</Button>
|
||||
<p id={requirementId} className={requirementClassName}>
|
||||
{requirementCopy}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentQualificationCard({
|
||||
item,
|
||||
view,
|
||||
|
|
@ -485,21 +525,15 @@ function ContentQualificationCard({
|
|||
maxLength={180}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={submitVariant(intent, approvalDecision)}
|
||||
type="submit"
|
||||
disabled={!canSubmit || approving}
|
||||
aria-describedby={requirementId}
|
||||
>
|
||||
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
|
||||
</Button>
|
||||
<p
|
||||
id={requirementId}
|
||||
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
|
||||
>
|
||||
{requirementCopy}
|
||||
</p>
|
||||
<ApprovalSubmitControls
|
||||
approving={approving}
|
||||
canSubmit={canSubmit}
|
||||
intent={intent}
|
||||
approvalDecision={approvalDecision}
|
||||
approvalAllowed={approvalAllowed}
|
||||
requirementId={requirementId}
|
||||
requirementCopy={requirementCopy}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
|
|
@ -708,21 +742,15 @@ function GateCard({
|
|||
maxLength={180}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={submitVariant(intent, approvalDecision)}
|
||||
type="submit"
|
||||
disabled={!canSubmit || approving}
|
||||
aria-describedby={requirementId}
|
||||
>
|
||||
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
|
||||
</Button>
|
||||
<p
|
||||
id={requirementId}
|
||||
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
|
||||
>
|
||||
{requirementCopy}
|
||||
</p>
|
||||
<ApprovalSubmitControls
|
||||
approving={approving}
|
||||
canSubmit={canSubmit}
|
||||
intent={intent}
|
||||
approvalDecision={approvalDecision}
|
||||
approvalAllowed={approvalAllowed}
|
||||
requirementId={requirementId}
|
||||
requirementCopy={requirementCopy}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type AlliancePulse,
|
||||
type AllianceScores,
|
||||
} from "./alliancePulseApi";
|
||||
import { ALLIANCE_SCALE } from "./allianceScale";
|
||||
import "./alliance-pulse.css";
|
||||
|
||||
interface PulseTurn {
|
||||
|
|
@ -76,14 +77,6 @@ const PERSPECTIVE_COPY: Record<
|
|||
supervisor_human: { label: "교수자 판정", provenance: "교수자 근거 판정" },
|
||||
};
|
||||
|
||||
const SCALE = [
|
||||
{ value: 0, number: "1", label: "전혀 아니다" },
|
||||
{ value: 0.25, number: "2", label: "조금 아니다" },
|
||||
{ value: 0.5, number: "3", label: "보통이다" },
|
||||
{ value: 0.75, number: "4", label: "대체로 그렇다" },
|
||||
{ value: 1, number: "5", label: "매우 그렇다" },
|
||||
] as const;
|
||||
|
||||
const EMPTY_DRAFT: ScoreDraft = { goal: null, task: null, bond: null };
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
|
|
@ -175,7 +168,7 @@ function AxisAssessmentControl({
|
|||
<small>{copy.prompt}</small>
|
||||
</legend>
|
||||
<div className="ap-scale" role="radiogroup" aria-label={`${copy.label} 평가`}>
|
||||
{SCALE.map((option) => (
|
||||
{ALLIANCE_SCALE.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`ap-scale__option ${value === option.value ? "is-selected" : ""}`}
|
||||
|
|
@ -330,6 +323,19 @@ function PerspectiveCell({
|
|||
);
|
||||
}
|
||||
|
||||
function LockedScores({ scores }: { scores: AllianceScores | null | undefined }) {
|
||||
return (
|
||||
<dl className="ap-locked-scores">
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<div key={dimension}>
|
||||
<dt>{DIMENSION_COPY[dimension].short}</dt>
|
||||
<dd>{scoreText(scores?.[dimension] ?? null)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
|
||||
const terminalWithoutReveal =
|
||||
pulse.status === "error" ||
|
||||
|
|
@ -359,14 +365,7 @@ function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
|
|||
<span />
|
||||
</div>
|
||||
)}
|
||||
<dl className="ap-locked-scores">
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<div key={dimension}>
|
||||
<dt>{DIMENSION_COPY[dimension].short}</dt>
|
||||
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<LockedScores scores={pulse.self_scores} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -378,14 +377,7 @@ function SelfOnlyState({ pulse }: { pulse: AlliancePulse }) {
|
|||
<b>저장한 내 치료 동맹 자기평가</b>
|
||||
<p>내가 직접 잠근 세 축만 보존합니다. AI·교수자 파생 비교는 표시하지 않습니다.</p>
|
||||
</div>
|
||||
<dl className="ap-locked-scores">
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<div key={dimension}>
|
||||
<dt>{DIMENSION_COPY[dimension].short}</dt>
|
||||
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<LockedScores scores={pulse.self_scores} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -968,7 +968,8 @@ export function CalibrationTransferCard({
|
|||
session_id: sessionId,
|
||||
competency_id: editableHistory?.competency_id ?? competencyId,
|
||||
practice_block_id:
|
||||
editableHistory?.practice_block_id ?? `oas-g5-block-session-${sessionId.toLowerCase()}`,
|
||||
editableHistory?.practice_block_id ??
|
||||
`oas-g5-block-${sessionId.toLowerCase()}-${(editableHistory?.competency_id ?? competencyId).replace(/^competency\./, "").toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
scenario_variant_id: editableHistory?.scenario_variant_id ?? `session-review-${sessionId}`,
|
||||
phrase_family_id:
|
||||
editableHistory?.phrase_family_id ?? `self-forecast-${competencyId.replace(/^competency\./, "")}`,
|
||||
|
|
@ -978,7 +979,7 @@ export function CalibrationTransferCard({
|
|||
confidence: confidence / 100,
|
||||
recorded_sequence: latest ? latest.recorded_sequence + 1 : 1,
|
||||
revision_reason: reason.trim(),
|
||||
instrument_id: "vignette.calibration-self-prediction",
|
||||
instrument_id: "calibration-mirror-g5",
|
||||
instrument_version: "1.0.0",
|
||||
evidence_turn_ids: [] as string[],
|
||||
};
|
||||
|
|
|
|||
7
apps/web/src/pages/session-review/allianceScale.ts
Normal file
7
apps/web/src/pages/session-review/allianceScale.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const ALLIANCE_SCALE = [
|
||||
{ value: 0, number: "1", label: "전혀 아니다" },
|
||||
{ value: 0.25, number: "2", label: "조금 아니다" },
|
||||
{ value: 0.5, number: "3", label: "보통이다" },
|
||||
{ value: 0.75, number: "4", label: "대체로 그렇다" },
|
||||
{ value: 1, number: "5", label: "매우 그렇다" },
|
||||
] as const;
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type AlliancePulse,
|
||||
type AllianceScores,
|
||||
} from "../session-review/alliancePulseApi";
|
||||
import { ALLIANCE_SCALE } from "../session-review/allianceScale";
|
||||
import "./alliance-checkpoint-prompt.css";
|
||||
|
||||
type ScoreDraft = Record<AllianceDimension, number | null>;
|
||||
|
|
@ -21,14 +22,6 @@ interface AllianceCheckpointPromptProps {
|
|||
|
||||
const DIMENSIONS: AllianceDimension[] = ["goal", "task", "bond"];
|
||||
const EMPTY_DRAFT: ScoreDraft = { goal: null, task: null, bond: null };
|
||||
const SCALE = [
|
||||
{ value: 0, number: "1", label: "전혀 아니다" },
|
||||
{ value: 0.25, number: "2", label: "조금 아니다" },
|
||||
{ value: 0.5, number: "3", label: "보통이다" },
|
||||
{ value: 0.75, number: "4", label: "대체로 그렇다" },
|
||||
{ value: 1, number: "5", label: "매우 그렇다" },
|
||||
] as const;
|
||||
|
||||
const CHECKPOINT_COPY: Record<
|
||||
Exclude<AllianceCheckpoint, "post">,
|
||||
{
|
||||
|
|
@ -286,7 +279,7 @@ export function AllianceCheckpointPrompt({
|
|||
<span>{prompt.prompt}</span>
|
||||
</legend>
|
||||
<div className="sx-alliance-checkpoint__scale">
|
||||
{SCALE.map((option) => (
|
||||
{ALLIANCE_SCALE.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={draft[dimension] === option.value ? "is-selected" : ""}
|
||||
|
|
|
|||
|
|
@ -1400,6 +1400,43 @@
|
|||
.sx-turn-error {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.sx-turn-error__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.sx-turn-error__retry-btn {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sx-turn-error__retry-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.sx-turn-error__dismiss-btn {
|
||||
background: transparent;
|
||||
color: var(--neutral-600);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sx-turn-error__dismiss-btn:hover {
|
||||
background: var(--neutral-100);
|
||||
}
|
||||
/* 보내기 = 1차 액션. 입력창(틴트) 대비 또렷한 진한 accent + 미세 elevation.
|
||||
비활성(입력 없음)은 표면 톤으로 낮춰 활성 상태와 위계를 분명히. */
|
||||
|
|
|
|||
280
apps/web/src/pages/session/sessionViewModel.ts
Normal file
280
apps/web/src/pages/session/sessionViewModel.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import type { AvatarAffect, AvatarPersona, AvatarState } from "../../components/avatar/ClientAvatar";
|
||||
import type {
|
||||
LiveCoachQuota,
|
||||
PersonaSummary,
|
||||
SessionDetailResponse,
|
||||
SessionStage,
|
||||
} from "../../lib/api";
|
||||
import { clamp01 } from "../../lib/format";
|
||||
import {
|
||||
DIFFICULTY_LABEL,
|
||||
PENDING_PERSONA_AVATAR_APPEARANCE,
|
||||
personaAvatarAppearance,
|
||||
personaBaselineExpression,
|
||||
personaMeta,
|
||||
personaTheoryLabel,
|
||||
shortPersonaName,
|
||||
} from "../../lib/personaViewModel";
|
||||
|
||||
export type FeedbackMode = "immersive" | "ambient" | "coached";
|
||||
export type TheoryMode = "humanistic" | "cbt" | "integrative";
|
||||
export type Speaker = "client" | "learner";
|
||||
export type SignalTone = "pos" | "warn" | "neutral";
|
||||
|
||||
export interface Utterance {
|
||||
id: number;
|
||||
speaker: Speaker;
|
||||
text: string;
|
||||
turnSeq?: number;
|
||||
/** 발화 시작 시점(세션 경과 초) */
|
||||
at: number;
|
||||
/** 진행 중(스트리밍/타이핑) 발화 여부 */
|
||||
partial?: boolean;
|
||||
/** 음성 WebSocket이 reply 전에 실패해 DB 턴으로 확정되지 않은 발화 */
|
||||
failed?: boolean;
|
||||
/** 실시간 음성 전사의 현재 확정 단계 */
|
||||
voiceTranscriptState?: "interim" | "finalizing";
|
||||
}
|
||||
|
||||
export interface PhaseInfo {
|
||||
key: SessionStage;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface ClientContext {
|
||||
initial: string;
|
||||
name: string;
|
||||
meta: string;
|
||||
rows: { l: string; v: string }[];
|
||||
chips: { t: string; clay: boolean }[];
|
||||
}
|
||||
|
||||
export const AI_VOICE_DISCLOSURE =
|
||||
"내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다.";
|
||||
|
||||
export const SESSION_PHASES: PhaseInfo[] = [
|
||||
{ key: "라포", desc: "첫 인사와 안전감 형성" },
|
||||
{ key: "탐색", desc: "호소 문제와 일상을 함께 이해" },
|
||||
{ key: "개입", desc: "감정과 생각을 다루는 기법 적용" },
|
||||
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
|
||||
];
|
||||
|
||||
export function primaryGoalActionLabel(goal: SessionStage): string {
|
||||
const lastCode = goal.charCodeAt(goal.length - 1);
|
||||
const hasFinalConsonant =
|
||||
lastCode >= 0xac00 && lastCode <= 0xd7a3 && (lastCode - 0xac00) % 28 !== 0;
|
||||
return `${goal}${hasFinalConsonant ? "을" : "를"} 핵심 초점으로 설정`;
|
||||
}
|
||||
|
||||
export const THEORY_MODE_OPTIONS: {
|
||||
value: TheoryMode;
|
||||
label: string;
|
||||
detail: string;
|
||||
focus: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "humanistic",
|
||||
label: "인간중심",
|
||||
detail: "공감·반영",
|
||||
focus: "감정과 욕구를 반영한 뒤 열린 질문으로 이어갑니다.",
|
||||
},
|
||||
{
|
||||
value: "cbt",
|
||||
label: "CBT",
|
||||
detail: "생각·행동",
|
||||
focus: "상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
|
||||
},
|
||||
{
|
||||
value: "integrative",
|
||||
label: "통합",
|
||||
detail: "혼합 접근",
|
||||
focus: "공감적 반영을 먼저 두고 필요한 지점만 구조화합니다.",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
|
||||
|
||||
export function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
|
||||
const firstSupported = summary?.theory_target.find(
|
||||
(target): target is TheoryMode =>
|
||||
target === "humanistic" || target === "cbt" || target === "integrative",
|
||||
);
|
||||
return firstSupported ?? "humanistic";
|
||||
}
|
||||
|
||||
export function normalizePersonaCode(raw?: string): string {
|
||||
return (raw ?? "").trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function looksLikeSessionId(raw?: string): boolean {
|
||||
const value = (raw ?? "").trim();
|
||||
return (
|
||||
/^[0-9a-f]{32}$/i.test(value) ||
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function secondsBetween(startedAt: string, createdAt: string): number {
|
||||
const start = Date.parse(startedAt);
|
||||
const created = Date.parse(createdAt);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(created)) return 0;
|
||||
return Math.max(0, Math.round((created - start) / 1000));
|
||||
}
|
||||
|
||||
export function elapsedFromSession(detail: SessionDetailResponse): number {
|
||||
const start = Date.parse(detail.started_at);
|
||||
const end = detail.ended_at ? Date.parse(detail.ended_at) : Date.now();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
|
||||
return Math.max(0, Math.round((end - start) / 1000));
|
||||
}
|
||||
|
||||
export function buildPersonaUi(
|
||||
summary: PersonaSummary | null,
|
||||
code: string,
|
||||
): { avatar: AvatarPersona; context: ClientContext } {
|
||||
if (!summary) {
|
||||
return {
|
||||
avatar: {
|
||||
label: "내담자 정보 확인 중",
|
||||
...PENDING_PERSONA_AVATAR_APPEARANCE,
|
||||
realism: 0.3,
|
||||
},
|
||||
context: {
|
||||
initial: "?",
|
||||
name: "내담자 확인 중",
|
||||
meta: code ? `${code} · DB 카탈로그 확인 중` : "DB 카탈로그 확인 중",
|
||||
rows: [{ l: "상태", v: "실제 연습 대상 정보를 확인하고 있습니다." }],
|
||||
chips: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const name = shortPersonaName(summary.display_name);
|
||||
const difficulty = DIFFICULTY_LABEL[summary.difficulty] ?? summary.difficulty;
|
||||
const theories = summary.theory_target.length
|
||||
? summary.theory_target.map(personaTheoryLabel).join(" · ")
|
||||
: "공통";
|
||||
const chips = [
|
||||
{ t: summary.code, clay: false },
|
||||
{ t: difficulty, clay: summary.difficulty === "hard" },
|
||||
...summary.theory_target.slice(0, 2).map((target) => ({
|
||||
t: personaTheoryLabel(target),
|
||||
clay: false,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
avatar: {
|
||||
code: summary.code,
|
||||
label: `${name} · ${personaMeta(summary)}`,
|
||||
...personaAvatarAppearance(summary),
|
||||
},
|
||||
context: {
|
||||
initial: name.slice(0, 1) || summary.code.slice(0, 1),
|
||||
name,
|
||||
meta: personaMeta(summary),
|
||||
rows: [
|
||||
{ l: "호소", v: summary.presenting_summary || "요약 정보 없음" },
|
||||
{ l: "대상", v: theories },
|
||||
{ l: "난도", v: difficulty },
|
||||
],
|
||||
chips,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function splitMetaLines(meta: string): string[] {
|
||||
const parts = meta
|
||||
.split("·")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length <= 2) return [meta];
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < parts.length; i += 2) {
|
||||
lines.push(parts.slice(i, i + 2).join(" · "));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// 백엔드가 반환한 effective_openness(0~1)를 실시간 관찰 신호로만 변환한다.
|
||||
// 점수/정밀 평가는 회기 종료 후 리뷰 화면에서 다룬다.
|
||||
export function metersFromOpenness(openness: number) {
|
||||
const o = clamp01(openness);
|
||||
return {
|
||||
rapport: o, // 마음 열림(라포)
|
||||
resistance: clamp01(1 - o * 0.9), // 저항감(반비례)
|
||||
anxiety: clamp01(0.7 - o * 0.4), // 불안·위축(완만히 감소)
|
||||
};
|
||||
}
|
||||
|
||||
export function baselineExpressionFor(summary: PersonaSummary | null): AvatarAffect {
|
||||
return personaBaselineExpression(summary);
|
||||
}
|
||||
|
||||
export 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 function formatCoachTimestamp(value: string | null | undefined) {
|
||||
if (!value) return "방금";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "방금";
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
month: sameDay ? undefined : "numeric",
|
||||
day: sameDay ? undefined : "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue