회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s

This commit is contained in:
Yun Chan 2026-09-08 23:28:06 +09:00
parent a479db7a5a
commit a0311c5957
100 changed files with 4884 additions and 11210 deletions

View file

@ -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);

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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[],
};

View 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;

View file

@ -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" : ""}

View file

@ -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.
비활성(입력 없음) 표면 톤으로 낮춰 활성 상태와 위계를 분명히. */

View 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);
}