From 93dd8f82d71461476f1650dc77a49fc6cf9b5e5b Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 7 Aug 2026 22:17:20 +0900 Subject: [PATCH] =?UTF-8?q?G8=20=EC=8B=A4=EC=A0=9C=20rollback=20=EC=A6=9D?= =?UTF-8?q?=EB=AA=85=20=EC=A2=85=EB=A3=8C=EC=99=80=20=EB=B9=84-secure=20or?= =?UTF-8?q?igin=20=ED=9A=8C=EA=B8=B0=20=EB=A6=AC=EB=B7=B0=20=ED=81=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G8 마지막 게이트인 receipt-bound 실제 image rollback을 격리 NAS vignette-preview-20260807 에서 실행해 종료했다. Gate6 계약 정정: 감사 대상 current API 이미지가 com.docker.compose.project/service/version image label 을 갖고 있어 "helper 의 compose label 0개" 계약은 감사되지 않은 다른 이미지를 쓰지 않는 한 성립하지 않는다. 계약을 key 부재가 아니라 소속(membership) 으로 바꿔 launch-nas-preview-g8-helpers.py 에 구현했다. image 상속 label 을 baseline 으로 읽고 container 의 모든 compose label 이 baseline 과 같거나 선언된 격리 override 인지 검사하며, 최종 project 는 target 이 아니고 service 는 api/web/db/proxy 가 아니어야 한다. docker run argv 에 target label 을 주입하면 fake-runner 테스트가 먼저 깨진다 (37/37). 실행 결과: - rollback-old receipt nas-g8-723eeef22eab05e63e3fafb0 -> 79ec../c530.. - restore-current receipt nas-g8-2738846cf2cf4fbe8ce0fc26 -> 52e0../6fdb.. - release gate/approval 각 2회 멱등, audit.ci_lifecycle_event rollback/executed 2, audit.ci_human_approval_event authorize_rollback 2, silent auto-promotion 0 - HMAC journal 6-record 체인 검증, health 3/3, OpenAPI 126, auth 401, Web 200 - helper 0, listener 0, 비밀 env 파기. down/volume rm/prune 미실행, 공개 런타임 미접촉 - 계획했던 Windows SSH 터널은 NAS sshd 가 direct-tcpip 를 거부해 사용할 수 없어 sshd 설정 변경 대신 같은 격리 계약의 NAS-side probe 컨테이너로 실행했다 비-secure origin 크래시 수정: 배포된 NAS 프리뷰(평문 HTTP, 비-localhost)에 회기 스펙을 돌려 24건 실패를 확인했고 원인은 하나였다. crypto.randomUUID 는 secure context 전용인데 제품 코드 18곳이 fallback 없이 호출했고 RuptureRepairCard 는 렌더 시점 호출이라 회기 리뷰 라우트 전체가 error boundary 로 떨어졌다. 릴리스 게이트 108/108 은 localhost 후보 스택에서만 돌아 이 경로를 밟은 적이 없다. src/lib/uuid.ts 의 randomUuid() 로 통일하고 fallback 도 crypto.getRandomValues 를 우선 사용해 idempotency key 의 예측 불가능성을 유지했다. 회귀는 insecure-context-uuid.spec.ts 6/6 으로 고정했다(직접 호출 0건 검사 포함). 이 수정은 아직 NAS 에 배포하지 않았다. 검증: API 898, gateway 58, executor 28, probe 11, helper launcher 37, release agent 21, ruff clean, web api-types/typecheck/build, SSOT FAIL 0, SSOT unit 5/5, dashboard E2E 10/10, 학생 폐루프 실 DB 브라우저 4/4(일회용 클론), crypto 수정 후 기존 스펙 회귀 70/70, 복원된 NAS 실제 브라우저 SSE->DB 리뷰 PASS. 부수 발견(열린 항목): 공개 API 가 engine=false 로 degraded 인데 워치독이 이를 감지하지 못한다. engine 판정이 게이트웨이 /health 의 ok 만 보고 claude readiness probe 를 돌리지 않기 때문이다. 같은 .env 와 같은 CLI 로 새 게이트웨이를 다른 포트에 띄우면 즉시 ready 이므로 상주 프로세스의 세션만 죽은 형태다. TODO A절과 대시보드에 기록했다. 이 커밋은 파일 단위로 담겼다. 위 파일들에는 이전 세션의 미커밋 G0~G8 작업이 함께 들어 있으며, hunk 를 쪼개면 대시보드/체커/TODO 정합성이 깨져 SSOT 체커가 실패한다. --- apps/web/e2e/dev-dashboard.spec.ts | 21 +- apps/web/e2e/insecure-context-uuid.spec.ts | 108 ++ apps/web/src/lib/uuid.ts | 40 + apps/web/src/pages/Session.tsx | 839 +++++++-- .../admin/ContinuousImprovementCockpit.tsx | 920 ++++++++++ .../CalibrationTransferCard.tsx | 1231 ++++++++++++++ .../session-review/DeliberatePracticeCard.tsx | 1514 +++++++++++++++++ .../session-review/MultimodalAllianceCard.tsx | 859 ++++++++++ .../session-review/OutcomeTrajectoryCard.tsx | 1022 +++++++++++ .../session-review/RuptureRepairCard.tsx | 806 +++++++++ docs/HANDOFF.md | 542 +++--- docs/README.md | 36 + docs/TODO.md | 305 +++- docs/dev_dashboard.html | 126 +- docs/guides/testing.md | 169 +- docs/ops/backlog-2026-06-26.md | 47 +- ...preview-g8-actual-rollback-2026-08-07.json | 238 +++ ...-preview-deployment-evidence-2026-08-07.md | 166 ++ .../nas-preview-g8-rollback-proof-runbook.md | 136 ++ scripts/check-dev-dashboard-ssot.py | 153 +- scripts/launch-nas-preview-g8-helpers.py | 718 ++++++++ scripts/test_launch_nas_preview_g8_helpers.py | 534 ++++++ 22 files changed, 10057 insertions(+), 473 deletions(-) create mode 100644 apps/web/e2e/insecure-context-uuid.spec.ts create mode 100644 apps/web/src/lib/uuid.ts create mode 100644 apps/web/src/pages/admin/ContinuousImprovementCockpit.tsx create mode 100644 apps/web/src/pages/session-review/CalibrationTransferCard.tsx create mode 100644 apps/web/src/pages/session-review/DeliberatePracticeCard.tsx create mode 100644 apps/web/src/pages/session-review/MultimodalAllianceCard.tsx create mode 100644 apps/web/src/pages/session-review/OutcomeTrajectoryCard.tsx create mode 100644 apps/web/src/pages/session-review/RuptureRepairCard.tsx create mode 100644 docs/ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json create mode 100644 docs/ops/nas-preview-deployment-evidence-2026-08-07.md create mode 100644 docs/ops/nas-preview-g8-rollback-proof-runbook.md create mode 100644 scripts/launch-nas-preview-g8-helpers.py create mode 100644 scripts/test_launch_nas_preview_g8_helpers.py diff --git a/apps/web/e2e/dev-dashboard.spec.ts b/apps/web/e2e/dev-dashboard.spec.ts index 6940c5f..637404d 100644 --- a/apps/web/e2e/dev-dashboard.spec.ts +++ b/apps/web/e2e/dev-dashboard.spec.ts @@ -86,7 +86,7 @@ async function openDashboard(page: Page, url: string) { if (url.startsWith("http")) { expect(response?.ok(), `${url} should load over HTTP`).toBeTruthy(); } - await expect(page.locator("#phase-rail .pr-seg")).toHaveCount(5); + await expect(page.locator("#phase-rail .pr-seg")).toHaveCount(6); expect(errors).toEqual([]); } @@ -144,9 +144,10 @@ async function readMetrics(page: Page): Promise { function expectDerivedMetrics(metrics: DashboardMetrics) { expect(metrics.total).toBeGreaterThan(0); - expect(metrics.done).toBe(metrics.total); - expect(metrics.doing).toBe(0); + expect(metrics.done).toBe(33); + expect(metrics.doing).toBe(2); expect(metrics.planned).toBe(0); + expect(metrics.total).toBe(metrics.done + metrics.doing + metrics.planned); expect(metrics.ownerBoard).toBe( metrics.ownerColumns.block + metrics.ownerColumns.decide + metrics.ownerColumns.ext, ); @@ -169,10 +170,14 @@ function expectDerivedMetrics(metrics: DashboardMetrics) { filterOwner: String(metrics.ownerCards), filterCrit: String(metrics.crit), }); - expect(metrics.phaseSegments).toBe(5); + expect(metrics.phaseSegments).toBe(6); expect(metrics.trackRows).toBeGreaterThan(1); expect(metrics.trackNames).not.toContain(""); - expect(metrics.stackSegments).toEqual([String(metrics.done)]); + expect(metrics.stackSegments).toEqual( + [metrics.done, metrics.doing, metrics.planned] + .filter((count) => count > 0) + .map(String), + ); } async function expectVisibleCards(page: Page, expected: number) { @@ -213,7 +218,7 @@ test.describe("dev dashboard static command center", () => { expectDerivedMetrics(initial); await page.locator('[data-filter="planned"]').click(); - await expectVisibleCards(page, 0); + await expectVisibleCards(page, initial.planned); await expect(page.locator("#board .track-group").first()).not.toBeVisible(); await page.locator('[data-filter="done"]').focus(); await page.keyboard.press("Enter"); @@ -222,7 +227,7 @@ test.describe("dev dashboard static command center", () => { await page.locator('[data-filter="planned"]').focus(); await page.keyboard.press("Space"); await expect(page.locator('[data-filter="planned"]')).toHaveAttribute("aria-pressed", "true"); - await expectVisibleCards(page, 0); + await expectVisibleCards(page, initial.planned); const rapidSequence = ["all", "crit", "doing", "owner", "planned", "done", "all", "crit", "all"]; await page.evaluate((filters) => { @@ -243,7 +248,7 @@ test.describe("dev dashboard static command center", () => { await expect(firstHead).toHaveAttribute("aria-expanded", "true"); await expect(firstCard).toHaveClass(/open/); await page.locator('[data-filter="planned"]').click(); - await expectVisibleCards(page, 0); + await expectVisibleCards(page, initial.planned); await page.locator('[data-filter="all"]').click(); await expect(firstHead).toHaveAttribute("aria-expanded", "true"); await firstHead.click(); diff --git a/apps/web/e2e/insecure-context-uuid.spec.ts b/apps/web/e2e/insecure-context-uuid.spec.ts new file mode 100644 index 0000000..0edb44b --- /dev/null +++ b/apps/web/e2e/insecure-context-uuid.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from "@playwright/test"; + +/** + * `crypto.randomUUID` only exists on secure origins. The isolated NAS preview + * is plain HTTP on a LAN/Tailnet address, where calling it directly threw + * `crypto.randomUUID is not a function` at render time and replaced the whole + * session-review route with the error boundary. + * + * These tests run against the Vite dev server so the module can be imported + * directly, and pin that idempotency keys stay available without a secure + * context. + */ +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +test.describe("insecure-context idempotency keys", () => { + test("randomUuid keeps working when crypto.randomUUID is unavailable", async ({ + page, + }) => { + await page.addInitScript(() => { + // `randomUUID` lives on Crypto.prototype, so shadow it with an own + // undefined property instead of deleting it. + Object.defineProperty(globalThis.crypto, "randomUUID", { + value: undefined, + configurable: true, + }); + }); + await page.goto("/"); + + const probe = await page.evaluate(async () => { + const module = await import("/src/lib/uuid.ts"); + return { + randomUuidPresent: + typeof (globalThis.crypto as { randomUUID?: unknown }).randomUUID, + getRandomValuesPresent: typeof globalThis.crypto.getRandomValues, + values: [module.randomUuid(), module.randomUuid(), module.randomUuid()], + }; + }); + + expect(probe.randomUuidPresent).toBe("undefined"); + expect(probe.getRandomValuesPresent).toBe("function"); + for (const value of probe.values) { + expect(value).toMatch(UUID_V4); + } + expect(new Set(probe.values).size).toBe(3); + }); + + test("randomUuid degrades once more when Web Crypto is missing entirely", async ({ + page, + }) => { + await page.addInitScript(() => { + // `randomUUID` lives on Crypto.prototype, so shadow it with an own + // undefined property instead of deleting it. + Object.defineProperty(globalThis.crypto, "randomUUID", { + value: undefined, + configurable: true, + }); + Object.defineProperty(globalThis.crypto, "getRandomValues", { + value: undefined, + configurable: true, + }); + }); + await page.goto("/"); + + const probe = await page.evaluate(async () => { + const module = await import("/src/lib/uuid.ts"); + const api = globalThis.crypto as { + randomUUID?: unknown; + getRandomValues?: unknown; + }; + return { + randomUuidPresent: typeof api.randomUUID, + getRandomValuesPresent: typeof api.getRandomValues, + values: [module.randomUuid(), module.randomUuid()], + }; + }); + + expect(probe.randomUuidPresent).toBe("undefined"); + expect(probe.getRandomValuesPresent).toBe("undefined"); + const values = probe.values; + for (const value of values) { + expect(value).toMatch(UUID_V4); + } + expect(new Set(values).size).toBe(2); + }); + + test("no product source calls crypto.randomUUID without the fallback", async () => { + const { readFileSync, readdirSync, statSync } = await import("node:fs"); + const path = await import("node:path"); + const root = path.join(process.cwd(), "src"); + const offenders: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + walk(full); + continue; + } + if (!/\.(ts|tsx)$/.test(entry)) continue; + if (full.endsWith(path.join("lib", "uuid.ts"))) continue; + if (readFileSync(full, "utf8").includes("crypto.randomUUID")) { + offenders.push(path.relative(root, full)); + } + } + }; + walk(root); + expect(offenders).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/uuid.ts b/apps/web/src/lib/uuid.ts new file mode 100644 index 0000000..001c2be --- /dev/null +++ b/apps/web/src/lib/uuid.ts @@ -0,0 +1,40 @@ +/** + * RFC 4122 v4 UUID that also works outside a secure context. + * + * `crypto.randomUUID` is only exposed on secure origins (HTTPS or localhost). + * The isolated NAS preview is served as plain HTTP on a LAN/Tailnet address, + * so calling it directly threw `crypto.randomUUID is not a function` and took + * the whole session-review route down with a render-time error boundary. + * + * Idempotency keys must stay unguessable, so the fallback still uses + * `crypto.getRandomValues` and only degrades to `Math.random` when the Web + * Crypto API is missing entirely. + */ +export function randomUuid(): string { + const webCrypto = globalThis.crypto; + if (typeof webCrypto?.randomUUID === "function") { + return webCrypto.randomUUID(); + } + const bytes = new Uint8Array(16); + if (typeof webCrypto?.getRandomValues === "function") { + webCrypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + // Version 4 and RFC 4122 variant bits. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex: string[] = []; + for (let index = 0; index < bytes.length; index += 1) { + hex.push(bytes[index].toString(16).padStart(2, "0")); + } + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); +} diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index ca1dba1..3cfad72 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -16,7 +16,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { AppShell } from "../components/shell/AppShell"; import { AVATAR_EXPRESSION_LIBRARY, @@ -43,6 +43,19 @@ import { } from "../lib/api"; import { useAuth } from "../lib/auth"; import { formatElapsed, formatTimecode, clamp01 } from "../lib/format"; +import { + parseVoicePracticeContext, + voicePracticeSearch, + VOICE_SCENE_LABEL, +} from "../lib/voicePracticeContext"; +import { + parsePracticeLaunchIntent, + practiceCriterionLabel, + practiceLaunchSearch, + PRACTICE_SOURCE_SESSION_LABEL, + PRACTICE_MODE_LABEL, + PRACTICE_NOVELTY_LABEL, +} from "../lib/practiceLaunchIntent"; import { DIFFICULTY_LABEL, PENDING_PERSONA_AVATAR_APPEARANCE, @@ -50,6 +63,7 @@ import { personaAvatarAppearance, personaBaselineExpression, personaMeta, + personaTheoryLabel, shortPersonaName, unavailablePersonaMessage, } from "../lib/personaViewModel"; @@ -67,7 +81,13 @@ import { type VoiceEvent, type VoiceStatus, } from "./session/voiceCapture"; +import { AllianceCheckpointPrompt } from "./session/AllianceCheckpointPrompt"; +import { + multimodalAllianceApi, + type MultimodalConsentRequest, +} from "./session-review/multimodalAllianceApi"; import "./session/session.css"; +import { randomUuid } from "../lib/uuid"; /* ── 도메인 상수/타입 ───────────────────────────────────────────────── */ @@ -76,6 +96,9 @@ 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; @@ -87,6 +110,8 @@ interface Utterance { partial?: boolean; /** 음성 WebSocket이 reply 전에 실패해 DB 턴으로 확정되지 않은 발화 */ failed?: boolean; + /** 실시간 음성 전사의 현재 확정 단계 */ + voiceTranscriptState?: "interim" | "finalizing"; } interface PhaseInfo { @@ -198,11 +223,16 @@ function buildPersonaUi( const name = shortPersonaName(summary.display_name); const difficulty = DIFFICULTY_LABEL[summary.difficulty] ?? summary.difficulty; - const theories = summary.theory_target.length ? summary.theory_target.join(", ") : "공통"; + 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: target, clay: false })), + ...summary.theory_target.slice(0, 2).map((target) => ({ + t: personaTheoryLabel(target), + clay: false, + })), ]; return { @@ -314,8 +344,34 @@ function expressionForSession({ export default function Session() { const { sessionId: routeId } = useParams<{ sessionId: string }>(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { user, acceptConsent } = useAuth(); + const voicePracticeRequested = searchParams.get("mode") === "voice"; + const voicePracticeContext = useMemo( + () => parseVoicePracticeContext(searchParams), + [searchParams], + ); + const practiceLaunchRequested = searchParams.has("launch"); + const practiceLaunchIntent = useMemo( + () => parsePracticeLaunchIntent(searchParams), + [searchParams], + ); + const practiceContextSearch = useMemo( + () => + practiceLaunchIntent + ? practiceLaunchSearch(practiceLaunchIntent) + : voicePracticeContext + ? voicePracticeSearch(voicePracticeContext) + : "", + [practiceLaunchIntent, voicePracticeContext], + ); + const practiceReviewPath = useCallback( + (sessionId: string) => + `/learn/session/${sessionId}/review${practiceContextSearch ? `?${practiceContextSearch}` : ""}`, + [practiceContextSearch], + ); + const routeParam = routeId ?? ""; const routeIsSessionId = useMemo(() => looksLikeSessionId(routeParam), [routeParam]); // 이 화면에서 방금 시작해 URL만 세션 ID로 바뀐 회기 — resume 재로딩 대상에서 제외한다. @@ -347,6 +403,7 @@ export default function Session() { const [reviewReady, setReviewReady] = useState(false); const [starting, setStarting] = useState(false); const [startError, setStartError] = useState(null); + const [alliancePreGateBlocked, setAlliancePreGateBlocked] = useState(false); const [consentChecked, setConsentChecked] = useState(false); const [consentBusy, setConsentBusy] = useState(false); const [selectedTheoryMode, setSelectedTheoryMode] = useState("humanistic"); @@ -381,6 +438,11 @@ export default function Session() { ); // 음성 캐스케이드 가용성: null=미확인, true=가능, false=provider 키 미설정(버튼 사전 비활성). const [voiceAvailable, setVoiceAvailable] = useState(null); + // 음성 입력 선택은 서버 consent ledger와 분리된 현재 회기 메모리 상태다. + const [voiceConsentSessionId, setVoiceConsentSessionId] = useState(null); + const [voiceConsentDialogOpen, setVoiceConsentDialogOpen] = useState(false); + const [voiceConsentSaving, setVoiceConsentSaving] = useState(false); + const [voiceConsentError, setVoiceConsentError] = useState(null); const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false); const [voiceAnalyser, setVoiceAnalyser] = useState(null); const [endDialogOpen, setEndDialogOpen] = useState(false); @@ -388,6 +450,13 @@ export default function Session() { const endCancelRef = useRef(null); const endConfirmRef = useRef(null); const endPreviousFocusRef = useRef(null); + const voiceConsentTextRef = useRef(null); + const voiceConsentAcceptRef = useRef(null); + const voiceConsentPreviousFocusRef = useRef(null); + const voiceConsentSubmissionRef = useRef<{ + sessionId: string; + body: MultimodalConsentRequest; + } | null>(null); // ── 라이브 신호(앰비언트 도트) ── const [liveSignal, setLiveSignal] = useState<{ tone: SignalTone; text: string } | null>(null); @@ -434,11 +503,15 @@ export default function Session() { const fadeTimerRef = useRef(null); const voiceSocketRef = useRef(null); const voiceCaptureRef = useRef(null); + const voiceCaptureAttemptRef = useRef(0); + const voiceCaptureMountedRef = useRef(true); + const activeVoiceSessionRef = useRef(liveSessionId); const micStreamRef = useRef(null); const audioContextRef = useRef(null); const ttsChunksRef = useRef([]); const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null); const ttsPlaybackRequestRef = useRef(0); + const ttsPlaybackActiveRef = useRef(false); const ttsRequestAbortRef = useRef(null); const playTtsAudioRef = useRef<(() => Promise) | null>(null); const pendingVoiceLearnerIdRef = useRef(null); @@ -448,6 +521,8 @@ export default function Session() { const coachCreditSeenRef = useRef>(new Set()); const coachCreditPulseTimerRef = useRef(null); + activeVoiceSessionRef.current = liveSessionId; + const failPendingVoiceLearnerTurn = useCallback(() => { const pendingId = pendingVoiceLearnerIdRef.current; pendingVoiceLearnerIdRef.current = null; @@ -456,7 +531,13 @@ export default function Session() { setUtterances((prev) => prev.map((utterance) => utterance.id === pendingId - ? { ...utterance, partial: false, failed: true, turnSeq: undefined } + ? { + ...utterance, + partial: false, + failed: true, + turnSeq: undefined, + voiceTranscriptState: undefined, + } : utterance, ), ); @@ -485,6 +566,7 @@ export default function Session() { }, [ensureVoiceAudioContext]); const stopTtsPlayback = useCallback(() => { + ttsPlaybackActiveRef.current = false; ttsPlaybackRequestRef.current += 1; ttsRequestAbortRef.current?.abort(); ttsRequestAbortRef.current = null; @@ -579,7 +661,7 @@ export default function Session() { if (!autoScroll) return; const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; - }, [utterances, clientReplyPending, autoScroll]); + }, [utterances, clientReplyPending, turnError, autoScroll]); // ── 라이브 신호 6초 페이드(§5.5) ── useEffect(() => { @@ -945,7 +1027,9 @@ export default function Session() { setVoiceDetail( ended ? "종료된 회기입니다. 대화 기록은 이 화면에 남기고 리뷰로 이동할 수 있습니다." - : "이전 회기 기록을 불러왔습니다. 이어서 말하거나 회기를 종료할 수 있습니다.", + : voicePracticeContext + ? "선택 장면의 음성 재연습입니다. 준비되면 마이크 버튼을 직접 눌러 시작하세요." + : "이전 회기 기록을 불러왔습니다. 이어서 말하거나 회기를 종료할 수 있습니다.", ); setResumedSessionLoaded(true); setCoachSuggestion(null); @@ -978,7 +1062,7 @@ export default function Session() { return () => { alive = false; }; - }, [navigate, pushSignal, routeIsSessionId, routeParam]); + }, [navigate, pushSignal, routeIsSessionId, routeParam, voicePracticeContext]); // 이번 회기 목표 토글 — 1~4개 자유 선택(소유자 지시 2026-07-15). const toggleGoal = useCallback((goal: SessionStage) => { @@ -989,6 +1073,14 @@ export default function Session() { /* ── 세션 시작 ───────────────────────────────────────────────────── */ const handleStart = useCallback(async () => { + if (voicePracticeRequested && !voicePracticeContext) { + setStartError("음성 재연습의 출처를 검증할 수 없습니다. 회기 리뷰에서 장면을 다시 선택해 주세요."); + return; + } + if (practiceLaunchRequested && !practiceLaunchIntent) { + setStartError("연습 처방의 출처를 검증할 수 없습니다. 회기 리뷰에서 처방을 다시 선택해 주세요."); + return; + } if (!personaSummary) { setStartError("페르소나 정보를 확인한 뒤 회기를 시작할 수 있습니다."); return; @@ -1036,9 +1128,15 @@ export default function Session() { setAvatarState("idle"); setMicOn(false); setVoiceStatus("idle"); - setVoiceDetail("마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다."); + setVoiceDetail( + voicePracticeContext + ? "선택 장면의 음성 재연습입니다. 준비되면 마이크 버튼을 직접 눌러 시작하세요." + : "마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.", + ); + if (voicePracticeContext) pushSignal("neutral", "음성 장면 재연습 준비"); justStartedSessionRef.current = res.session_id; - navigate(`/learn/session/${res.session_id}`, { replace: true }); + const practiceSuffix = practiceContextSearch ? `?${practiceContextSearch}` : ""; + navigate(`/learn/session/${res.session_id}${practiceSuffix}`, { replace: true }); // 음성 가용성 사전 확인 — provider 키 미설정이면 마이크 버튼을 비활성화하고 명확히 안내한다. // (클릭→권한요청→마이크 깜빡→WS degraded 종료의 혼란스러운 "바로 꺼짐"을 방지.) void sessionApi.voiceHealth().then(({ available }) => { @@ -1059,7 +1157,11 @@ export default function Session() { } finally { setStarting(false); } - }, [navigate, personaCode, personaSummary, pushSignal, selectedGoals, selectedTheoryMode]); + }, [navigate, personaCode, personaSummary, practiceContextSearch, practiceLaunchIntent, practiceLaunchRequested, pushSignal, selectedGoals, selectedTheoryMode, voicePracticeContext, voicePracticeRequested]); + + const handleAlliancePreGateChange = useCallback((blocked: boolean) => { + setAlliancePreGateBlocked(blocked); + }, []); const handleAcceptConsent = useCallback(async () => { if (!consentChecked) { @@ -1138,6 +1240,11 @@ export default function Session() { const handleSend = useCallback(async () => { const text = composeText.trim(); if (!text || sending || paused || voiceStatus === "thinking") return; + if (alliancePreGateBlocked) { + setTurnError("첫 발화 전에 회기 전 자기점검을 잠가 주세요."); + pushSignal("warn", "회기 전 자기점검 필요"); + return; + } if (sessionEnded) { setTurnError("종료된 회기에서는 새 발화를 보낼 수 없습니다. 리뷰에서 기록을 확인해 주세요."); return; @@ -1297,6 +1404,7 @@ export default function Session() { } }, [ composeText, + alliancePreGateBlocked, sending, paused, voiceStatus, @@ -1338,6 +1446,7 @@ export default function Session() { const shutdownVoice = useCallback( (status: VoiceStatus = "idle", detail = "마이크 꺼짐") => { + voiceCaptureAttemptRef.current += 1; stopTtsPlayback(); const capture = voiceCaptureRef.current; voiceCaptureRef.current = null; @@ -1368,12 +1477,17 @@ export default function Session() { const playTtsAudio = useCallback(async () => { const chunks = ttsChunksRef.current.splice(0); - if (!chunks.length) return; + if (!chunks.length) { + ttsPlaybackActiveRef.current = false; + return; + } const blob = new Blob(chunks, { type: "audio/mpeg" }); stopTtsPlayback(); + ttsPlaybackActiveRef.current = true; const finishPlayback = () => { + ttsPlaybackActiveRef.current = false; ttsPlaybackCleanupRef.current = null; setVoiceAnalyser(null); setAvatarState("listening"); @@ -1383,6 +1497,7 @@ export default function Session() { }; const failPlayback = (detail: string, status: VoiceStatus = "error") => { + ttsPlaybackActiveRef.current = false; ttsPlaybackCleanupRef.current = null; setVoiceAnalyser(null); setAvatarState("listening"); @@ -1497,8 +1612,16 @@ export default function Session() { return; } + setTurnError(null); shutdownVoice("requesting", "Chrome 마이크 권한을 요청합니다."); + const attempt = ++voiceCaptureAttemptRef.current; + const captureSessionId = liveSessionId; + const attemptIsStale = () => + !voiceCaptureMountedRef.current || + attempt !== voiceCaptureAttemptRef.current || + activeVoiceSessionRef.current !== captureSessionId; await primeVoicePlayback(); + if (attemptIsStale()) return; let stream: MediaStream; try { @@ -1506,18 +1629,24 @@ export default function Session() { audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); } catch { + if (attemptIsStale()) return; setVoiceStatus("error"); setVoiceDetail("마이크 권한이 거부됐습니다. Chrome 주소창 왼쪽 권한에서 마이크를 허용해 주세요."); pushSignal("warn", "마이크 권한 필요"); return; } + if (attemptIsStale()) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + micStreamRef.current = stream; setVoiceStatus("connecting"); setVoiceDetail("음성 연결을 준비하는 중입니다."); const ws = new WebSocket( - apiWsUrl(`/voice/ws?session_id=${encodeURIComponent(liveSessionId)}`), + apiWsUrl(`/voice/ws?session_id=${encodeURIComponent(captureSessionId)}`), ); ws.binaryType = "arraybuffer"; voiceSocketRef.current = ws; @@ -1526,6 +1655,8 @@ export default function Session() { setClientReplyPending(false); let pendingCapture: VoiceCaptureController | null = null; + let replyReceived = false; + let preserveNextIdleDetail = false; const capturePromise = (async () => { const ctx = ensureVoiceAudioContext(); if (supportsAudioWorkletCapture(ctx)) { @@ -1600,28 +1731,99 @@ export default function Session() { setAvatarState("speaking"); setVoiceStatus("speaking"); setVoiceDetail(`${clientName} 음성을 받는 중입니다.`); - } else if (payload.state === "idle" && !voiceCaptureRef.current?.isRecording()) { + } else if (payload.state === "listening") { + setAvatarState("listening"); + if (voiceCaptureRef.current?.isRecording()) { + setMicOn(true); + setVoiceStatus("recording"); + setVoiceDetail("계속 듣고 있습니다. 말한 뒤 발화를 보내세요."); + } else { + setMicOn(false); + setVoiceStatus("idle"); + setVoiceDetail("발화 종료를 확인하지 못했습니다. 다시 말하거나 텍스트로 입력하세요."); + } + } else if ( + payload.state === "idle" && + !voiceCaptureRef.current?.isRecording() && + !ttsPlaybackActiveRef.current + ) { setAvatarState("listening"); setVoiceStatus("idle"); - setVoiceDetail("마이크를 다시 켜 발화하세요."); + if (preserveNextIdleDetail) { + preserveNextIdleDetail = false; + } else { + setVoiceDetail("마이크를 다시 켜 발화하세요."); + } } return; } - if (payload.type === "transcript" && payload.final && payload.text) { - failPendingVoiceLearnerTurn(); - const id = nextId(); + if (payload.type === "eot") { + if (payload.ready === false) { + setVoiceDetail("말이 끝나지 않은 것으로 감지했습니다. 다시 말하거나 텍스트로 입력하세요."); + } + return; + } + + if (payload.type === "transcript") { + const transcriptText = (payload.text ?? "").trim(); + const pendingId = pendingVoiceLearnerIdRef.current; + if (!transcriptText) { + if (payload.final) { + if (pendingId != null) { + setUtterances((prev) => prev.filter((utterance) => utterance.id !== pendingId)); + } + pendingVoiceLearnerIdRef.current = null; + pendingVoiceLearnerTextRef.current = ""; + setClientReplyPending(false); + setAvatarState("listening"); + setVoiceStatus("idle"); + preserveNextIdleDetail = true; + setVoiceDetail("음성을 인식하지 못했습니다. 다시 말하거나 텍스트로 입력하세요."); + } + return; + } + + const id = pendingId ?? nextId(); pendingVoiceLearnerIdRef.current = id; - pendingVoiceLearnerTextRef.current = payload.text; - setClientReplyPending(true); - setUtterances((prev) => [ - ...prev, - { id, speaker: "learner", text: payload.text ?? "", at: elapsed, partial: true }, - ]); + pendingVoiceLearnerTextRef.current = transcriptText; + setUtterances((prev) => { + const existing = prev.some((utterance) => utterance.id === id); + const nextUtterance: Utterance = { + id, + speaker: "learner", + text: transcriptText, + at: elapsed, + partial: true, + failed: false, + voiceTranscriptState: payload.final ? "finalizing" : "interim", + }; + return existing + ? prev.map((utterance) => + utterance.id === id + ? { + ...utterance, + text: transcriptText, + partial: true, + failed: false, + voiceTranscriptState: nextUtterance.voiceTranscriptState, + } + : utterance, + ) + : [...prev, nextUtterance]; + }); + if (payload.final) { + setClientReplyPending(true); + setMicOn(false); + setAvatarState("thinking"); + setVoiceStatus("thinking"); + setVoiceDetail("전사를 확정했습니다. 내담자 응답을 기다리는 중입니다."); + } return; } if (payload.type === "reply") { + replyReceived = true; setClientReplyPending(false); if (payload.stage) setStage(payload.stage); if (typeof payload.effective_openness === "number") { @@ -1640,7 +1842,13 @@ export default function Session() { setUtterances((prev) => prev.map((u) => u.id === pendingId - ? { ...u, partial: false, failed: false, turnSeq: payload.turn_seq } + ? { + ...u, + partial: false, + failed: false, + turnSeq: payload.turn_seq, + voiceTranscriptState: undefined, + } : u, ), ); @@ -1671,10 +1879,21 @@ export default function Session() { if (payload.type === "degraded") { setClientReplyPending(false); + if (replyReceived) { + const detail = + "내담자 응답은 저장됐지만 음성 재생 연결이 끊겼습니다. 텍스트로 계속하거나 음성을 다시 연결해 주세요."; + shutdownVoice("degraded", detail); + pushSignal("warn", "음성 재생 연결 끊김"); + return; + } const hadPendingLearner = failPendingVoiceLearnerTurn(); const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다."; - const reason = - payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason; + const providerReason = payload.reason?.trim() ?? ""; + const reason = providerReason && !/(openai|stt|tts|provider|engine|failed|unavailable)/i.test(providerReason) + ? providerReason + : providerReason + ? userFacingVoiceError({ ...payload, detail: providerReason }) + : fallbackReason; if (hadPendingLearner) setTurnError(fallbackReason); shutdownVoice("degraded", reason); pushSignal("warn", "음성 기능 미설정"); @@ -1683,6 +1902,13 @@ export default function Session() { if (payload.type === "error") { setClientReplyPending(false); + if (replyReceived) { + const detail = + "내담자 응답은 저장됐지만 음성 재생 연결이 끊겼습니다. 텍스트로 계속하거나 음성을 다시 연결해 주세요."; + shutdownVoice("degraded", detail); + pushSignal("warn", "음성 재생 연결 끊김"); + return; + } failPendingVoiceLearnerTurn(); const detail = userFacingVoiceError(payload); setTurnError(detail); @@ -1693,6 +1919,13 @@ export default function Session() { ws.onerror = () => { setClientReplyPending(false); + if (replyReceived) { + const detail = + "내담자 응답은 저장됐지만 음성 재생 연결이 끊겼습니다. 텍스트로 계속하거나 음성을 다시 연결해 주세요."; + shutdownVoice("degraded", detail); + pushSignal("warn", "음성 재생 연결 끊김"); + return; + } failPendingVoiceLearnerTurn(); setTurnError(VOICE_CONNECTION_SAVE_FAILED); shutdownVoice("error", VOICE_CONNECTION_SAVE_FAILED); @@ -1701,6 +1934,7 @@ export default function Session() { ws.onclose = () => { const hadPendingLearner = failPendingVoiceLearnerTurn(); + if (voiceSocketRef.current === ws) voiceSocketRef.current = null; const capture = voiceCaptureRef.current; voiceCaptureRef.current = null; capture?.abort(); @@ -1711,23 +1945,24 @@ export default function Session() { stopMicStream(); setMicOn(false); setClientReplyPending(false); + if (replyReceived) { + const detail = + "내담자 응답은 저장됐지만 음성 재생 연결이 끊겼습니다. 텍스트로 계속하거나 음성을 다시 연결해 주세요."; + setVoiceStatus("degraded"); + setVoiceDetail(detail); + pushSignal("warn", "음성 재생 연결 끊김"); + return; + } if (hadPendingLearner) { setTurnError(VOICE_CONNECTION_SAVE_FAILED); setVoiceStatus("error"); setVoiceDetail(VOICE_CONNECTION_SAVE_FAILED); return; } - setVoiceStatus((current) => (current === "degraded" || current === "error" ? current : "idle")); - setVoiceDetail((current) => { - if ( - current.includes("비활성화") || - current.includes("오류") || - current.includes("권한") - ) { - return current; - } - return "마이크 꺼짐"; - }); + setTurnError(VOICE_CONNECTION_SAVE_FAILED); + setVoiceStatus("error"); + setVoiceDetail(VOICE_CONNECTION_SAVE_FAILED); + pushSignal("warn", "음성 연결 종료"); }; }, [ clientName, @@ -1748,6 +1983,76 @@ export default function Session() { stopMicStream, ]); + const dismissVoiceConsent = useCallback(() => { + if (voiceConsentSaving) return; + setVoiceConsentDialogOpen(false); + setVoiceConsentError(null); + setVoiceDetail("음성 입력을 사용하지 않았습니다. 텍스트로 계속 진행할 수 있습니다."); + pushSignal("neutral", "텍스트 입력으로 계속"); + }, [pushSignal, voiceConsentSaving]); + + const requestVoiceCapture = useCallback(() => { + if (!liveSessionId) return; + if (voiceConsentSessionId !== liveSessionId) { + voiceConsentPreviousFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + setVoiceConsentError(null); + setVoiceConsentDialogOpen(true); + return; + } + void startVoiceCapture(); + }, [liveSessionId, startVoiceCapture, voiceConsentSessionId]); + + const acceptVoiceConsent = useCallback(async () => { + if (!liveSessionId || voiceConsentSaving) return; + const consentSessionId = liveSessionId; + if (voiceConsentSubmissionRef.current?.sessionId !== consentSessionId) { + voiceConsentSubmissionRef.current = { + sessionId: consentSessionId, + body: { + submission_id: randomUuid(), + consent_status: "granted", + retain_audio: false, + retain_derived_features: true, + transcript_retained: true, + retention_days: 30, + policy_version: "vignette.multimodal-consent.v1", + reason_code: "learner_voice_session_opt_in", + }, + }; + } + const consentRequest = voiceConsentSubmissionRef.current.body; + setVoiceConsentSaving(true); + setVoiceConsentError(null); + try { + await multimodalAllianceApi.saveConsent( + consentSessionId, + consentRequest, + ); + } catch { + if (activeVoiceSessionRef.current === consentSessionId) { + setVoiceConsentError( + "동의 원장을 기록하지 못해 마이크를 열지 않았습니다. 다시 시도하거나 텍스트로 계속해 주세요.", + ); + } + return; + } finally { + if (voiceCaptureMountedRef.current) { + setVoiceConsentSaving(false); + } + } + if ( + !voiceCaptureMountedRef.current || + activeVoiceSessionRef.current !== consentSessionId + ) { + return; + } + voiceConsentSubmissionRef.current = null; + setVoiceConsentSessionId(consentSessionId); + setVoiceConsentDialogOpen(false); + await startVoiceCapture(); + }, [liveSessionId, startVoiceCapture, voiceConsentSaving]); + const skipVoicePlayback = useCallback(() => { ttsChunksRef.current = []; stopTtsPlayback(); @@ -1761,6 +2066,10 @@ export default function Session() { const toggleMic = useCallback(() => { if (paused || sending || sessionEnded) return; + if (alliancePreGateBlocked) { + pushSignal("warn", "회기 전 자기점검 필요"); + return; + } if (voiceAvailable === false) { pushSignal("warn", "음성 미설정 — 텍스트로 진행"); return; @@ -1771,9 +2080,9 @@ export default function Session() { if (micOn || voiceStatus === "recording") { finishVoiceUtterance(); } else { - void startVoiceCapture(); + requestVoiceCapture(); } - }, [finishVoiceUtterance, micOn, paused, sending, sessionEnded, startVoiceCapture, voiceAvailable, voiceStatus, pushSignal]); + }, [alliancePreGateBlocked, finishVoiceUtterance, micOn, paused, requestVoiceCapture, sending, sessionEnded, voiceAvailable, voiceStatus, pushSignal]); const togglePause = useCallback(() => { if (sessionEnded) return; @@ -1791,17 +2100,22 @@ export default function Session() { }); }, [sessionEnded, shutdownVoice, voiceStatus]); - // ── 키보드 단축키 (§5.6: Space=마이크, P=일시정지). 입력 중엔 무시 ── + // ── 키보드 단축키: Alt+M=마이크, P=일시정지. 입력 중엔 무시 ── useEffect(() => { if (!started) return; const onKey = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement | null)?.tagName; - const typing = tag === "TEXTAREA" || tag === "INPUT"; - if (typing) return; - if (e.code === "Space") { + if (e.repeat) return; + if (e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === "m") { e.preventDefault(); void toggleMic(); - } else if (e.key === "p" || e.key === "P") { + return; + } + const target = e.target instanceof HTMLElement ? e.target : null; + const interactive = target?.closest( + "button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='link']", + ); + if (interactive) return; + if (e.key === "p" || e.key === "P") { togglePause(); } }; @@ -1810,12 +2124,47 @@ export default function Session() { }, [started, toggleMic, togglePause]); useEffect(() => { + voiceCaptureMountedRef.current = true; return () => { + voiceCaptureMountedRef.current = false; shutdownVoice(); audioContextRef.current?.close().catch(() => undefined); }; }, [shutdownVoice]); + useEffect(() => { + if (!voiceConsentDialogOpen) return; + window.setTimeout(() => voiceConsentTextRef.current?.focus(), 0); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !voiceConsentSaving) { + 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(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [dismissVoiceConsent, voiceConsentDialogOpen, voiceConsentSaving]); + + useEffect(() => { + if (voiceConsentDialogOpen) return; + voiceConsentPreviousFocusRef.current?.focus(); + voiceConsentPreviousFocusRef.current = null; + }, [voiceConsentDialogOpen]); + useEffect(() => { if (!coachEvidenceOpen) return; window.setTimeout(() => coachEvidenceCloseRef.current?.focus(), 0); @@ -1906,8 +2255,8 @@ export default function Session() { setPaused(true); setAvatarState("idle"); pushSignal("neutral", "회기 종료"); - navigate(`/learn/session/${liveSessionId}/review`, { replace: true }); - }, [ending, liveSessionId, navigate, pushSignal, sessionEnded]); + navigate(practiceReviewPath(liveSessionId), { replace: true }); + }, [ending, liveSessionId, navigate, practiceReviewPath, pushSignal, sessionEnded]); const meters = metersFromOpenness(openness); @@ -1959,6 +2308,12 @@ export default function Session() { voiceAvailable, micOn, }); + const voiceRecoveryAvailable = + (voiceStatus === "error" || voiceStatus === "degraded") && + voiceAvailable !== false && + !paused && + !sending && + !sessionEnded; const elapsedLabel = formatTimecode(elapsed); const remainingLabel = formatTimecode(remainingSeconds); const limitMinutesLabel = Math.round(sessionLimitSeconds / 60); @@ -1980,13 +2335,28 @@ export default function Session() { primaryContext?.v ?? `${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`; const safetyStatusText = safety ? "확인 필요" : "안전"; - const turnErrorTitle = turnError?.startsWith("회기 종료") ? "회기 종료" : "엔진 연결"; + const turnErrorTitle = turnError?.startsWith("회기 종료") + ? "회기 종료" + : turnError?.includes("음성") + ? "음성 연결" + : "엔진 연결"; const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false; const consentRequired = user?.role === "learner" && user.consentAt == null; - const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired; + const voicePracticeIsValid = !voicePracticeRequested || voicePracticeContext != null; + const practiceLaunchIsValid = !practiceLaunchRequested || practiceLaunchIntent != null; + const canStartSession = + personaLoadState === "ready" && + personaIsUsable && + !consentRequired && + voicePracticeIsValid && + practiceLaunchIsValid; const prestartTitle = canStartSession ? `${clientName}님과의 회기를 시작할까요?` - : consentRequired + : !voicePracticeIsValid + ? "음성 재연습의 출처를 다시 확인해 주세요." + : !practiceLaunchIsValid + ? "연습 처방의 출처를 다시 확인해 주세요." + : consentRequired ? "동의 확인 후 회기를 시작할 수 있습니다." : personaLoadState === "ready" && personaSummary ? "이 내담자는 현재 연습에 사용할 수 없습니다." @@ -2067,9 +2437,11 @@ export default function Session() { "sx-page " + (started ? "sx-page--active" : "sx-page--prestart") + (started ? ` sx-feedback-${feedbackMode}` : "") + + (started && alliancePreGateBlocked ? " sx-alliance-gated" : "") + (started && safety ? " sx-has-safety" : "") } data-avatar-expressions={avatarExpressionCount} + data-practice-mode={practiceLaunchIntent?.mode ?? voicePracticeContext?.mode ?? "standard"} > {/* ── 페이지 헤드라인 + 가로 단계 미니 ── */}
@@ -2129,7 +2501,7 @@ export default function Session() {
- {sessionStatusLabel} + {resumedSessionLoaded ? "이전 회기 기록을 불러왔습니다." : sessionStatusLabel} {/* D3 — "서연 · 라포 단계 · 0:03" 처럼 · 가 2개였던 줄을 "서연 · 라포 단계" + "0:03" 두 칸으로 분리한다. */} @@ -2149,7 +2521,7 @@ export default function Session() { - ))} +
+ 이론모드 +
+ {THEORY_MODE_OPTIONS.map((option) => ( + + ))} +
+
+
+ + 이번 회기 목표 1~4개 선택 + +
+ {SESSION_PHASES.map((phase) => { + const selected = selectedGoals.includes(phase.key); + return ( + + ); + })} +
+

+ 실제 상담처럼 한 회기에 모든 단계를 이루지 않아도 됩니다. 회기는{" "} + {Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}분 + 기준으로 진행되고, 목표를 이뤄도 시간이 남으면 계속 이어갈 수 있어요. +

+
- -
- - 이번 회기 목표 1~4개 선택 - -
- {SESSION_PHASES.map((phase) => { - const selected = selectedGoals.includes(phase.key); - return ( - - ); - })} -
-

- 실제 상담처럼 한 회기에 모든 단계를 이루지 않아도 됩니다. 회기는{" "} - {Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}분 - 기준으로 진행되고, 목표를 이뤄도 시간이 남으면 계속 이어갈 수 있어요. -

-
+ {personaStatusMessage ? (

{personaStatusMessage}

) : null} @@ -2282,6 +2724,10 @@ export default function Session() { ) : null} +

+ + {AI_VOICE_DISCLOSURE} +

{!autoScroll ? ( @@ -2762,28 +3247,18 @@ export default function Session() { ? "일시정지 중입니다." : "학습자 발화를 입력하세요." } - disabled={textComposerDisabled} + disabled={textComposerDisabled || alliancePreGateBlocked} aria-label="학습자 발화 입력" /> - {turnError ? ( -
- - - - - {turnErrorTitle} · {turnError} - -
- ) : null} @@ -3104,12 +3579,12 @@ export default function Session() { {/* ── 하단 컨트롤 바 ── */}
-
+
) : null} + {voiceRecoveryAvailable ? ( + + ) : null} + {voiceRecoveryAvailable ? ( + + {voiceDetail} + + ) : null}
+

+ + {AI_VOICE_DISCLOSURE} +

+
@@ -3187,7 +3690,7 @@ export default function Session() {
) : null} + {voiceConsentDialogOpen ? ( +
{ + if (event.target === event.currentTarget) dismissVoiceConsent(); + }} + > +
event.stopPropagation()} + > +
+ +
+ + +
+
+
+
+
+
처리 목적
+
상담 훈련 입력을 전사하고 AI 내담자 응답을 만드는 데 사용합니다.
+
+
+
파생 신호
+
침묵 길이, 발화 속도, 끼어듦 같은 상호작용 신호를 계산할 수 있습니다.
+
+
+
보관 범위
+
원음은 보존하지 않습니다. 전사문과 파생 신호는 30일 동안 회기 기록에 남습니다.
+
+
+
철회와 삭제
+
음성을 끄면 캡처가 중단됩니다. 저장된 전사와 회기 기록 삭제는 기록 관리 절차로 요청할 수 있습니다.
+
+
+ + {voiceConsentError ? ( +

+ {voiceConsentError} +

+ ) : null} +
+
+ + +
+
+
+ ) : null} + {endDialogOpen ? (
= { + baseline: "기준선", + threshold: "임계값", + provenance: "출처", + rollback: "롤백", +}; + +const NODE_LABELS: Record = { + reproduction_test: "재현 테스트", + implementation: "구현", + e2e: "E2E", + runtime_proof: "런타임 증명", +}; + +const EVENT_LABELS: Record = { + approved: "승인 원장 기록", + requested: "롤백 실행 대기 · 실행기 미구성", + executed: "롤백 실행", + failed: "롤백 실행 실패", + healthy: "모니터 정상", + drift_detected: "드리프트 감지", + rollback_recommended: "롤백 권고", + rollback_verified: "롤백 검증 완료", +}; + +const CONTENT_KIND_LABELS: Record = { + case: "사례", + rupture: "균열 수선", + practice: "의도적 수련", + benchmark: "벤치마크", +}; + +type GateItem = + | { kind: "model_change_gate"; value: ModelChangeGateView } + | { kind: "release_gate"; value: ReleaseGateView }; + +function shortId(value: string, length = 10): string { + return value.length > length ? `${value.slice(0, length)}…` : value; +} + +function dateTimeLabel(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "기록 시각 미상"; + return new Intl.DateTimeFormat("ko-KR", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function gateId(item: GateItem): string { + return item.value.gate_id; +} + +function gateTitle(item: GateItem): string { + if (item.kind === "model_change_gate") return "모델 변경 게이트"; + return `릴리스 ${item.value.release_id}`; +} + +function gateDecision(item: GateItem): HumanApprovalRequest["decision"] { + if (item.kind === "release_gate") { + return item.value.qualified ? "approve_promotion" : "reject"; + } + if (item.value.gate_decision === "promote") return "approve_promotion"; + if (item.value.gate_decision === "rollback") return "authorize_rollback"; + return "keep_quarantine"; +} + +function decisionLabel(item: GateItem): string { + if (item.kind === "release_gate") { + return item.value.qualified ? "승격 후보" : "게이트 미통과"; + } + if (item.value.gate_decision === "promote") return "승격 후보"; + if (item.value.gate_decision === "rollback") return "롤백 후보"; + return "격리 유지"; +} + +function actionLabel(item: GateItem): string { + const decision = gateDecision(item); + if (decision === "approve_promotion") return "사람 승인 기록"; + if (decision === "authorize_rollback") return "롤백 승인 기록"; + if (decision === "keep_quarantine") return "격리 결정 기록"; + return "거부 결정 기록"; +} + +function artifactsFor( + view: ContinuousImprovementViewResponse, + kind: ContinuousImprovementGateKind, + ownerId: string, +): GateArtifactView[] { + return view.gate_artifacts.filter( + (artifact) => artifact.owner_kind === kind && artifact.owner_id === ownerId, + ); +} + +function hasCompleteArtifacts(artifacts: GateArtifactView[]): boolean { + return ARTIFACT_KINDS.every((kind) => + artifacts.some((artifact) => artifact.artifact_kind === kind), + ); +} + +function isBoundarySafe(view: ContinuousImprovementViewResponse): boolean { + return ( + view.data_classification === "synthetic_replay_red_team_coverage_drift" && + !view.silent_auto_promotion_allowed && + !view.raw_transcript_included && + !view.pii_included && + !view.clinical_claim_allowed + ); +} + +function BoundaryStrip({ view }: { view: ContinuousImprovementViewResponse }) { + const boundaries = [ + ["입력", "합성 replay · red-team · coverage drift"], + ["승격", "silent auto-promotion 금지"], + ["효과", "사람 승인 후 append-only"], + ["데이터", "원문 · PII · 임상 주장 미포함"], + ]; + const safe = isBoundarySafe(view); + + return ( +
+ + {boundaries.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ ); +} + +function ArtifactRail({ artifacts }: { artifacts: GateArtifactView[] }) { + return ( +
    + {ARTIFACT_KINDS.map((kind, index) => { + const matches = artifacts.filter((artifact) => artifact.artifact_kind === kind); + const ready = matches.length > 0; + return ( +
  1. + +
    + {ARTIFACT_LABELS[kind]} + {ready ? `${matches.length}개 고정` : "증거 없음"} +
    +
  2. + ); + })} +
+ ); +} + +function ContentQualificationCard({ + item, + view, + reason, + onReasonChange, + onApprove, + approving, +}: { + item: ContentQualificationView; + view: ContinuousImprovementViewResponse; + reason: string; + onReasonChange: (value: string) => void; + onApprove: () => void; + approving: boolean; +}) { + const payload = item.draft_payload; + const catalog = view.catalog_entries.find( + (entry) => entry.qualification_id === item.qualification_id, + ); + const approval = view.approvals.find( + (candidate) => + candidate.target_kind === "content_qualification" && + candidate.target_id === item.qualification_id, + ); + const boundarySafe = isBoundarySafe(view); + const evidenceReady = item.source_provenance_uris.length > 0; + const canApprove = + boundarySafe && Boolean(payload) && evidenceReady && !approval && !catalog && reason.trim().length > 0; + const requirementId = `content-approval-requirement-${item.qualification_id}`; + const titleId = `content-qualification-title-${item.qualification_id}`; + const blockedReason = !boundarySafe + ? "데이터 경계 위반으로 승인 차단" + : !payload + ? "검수 가능한 visible payload가 없어 승인 차단" + : !evidenceReady + ? "repo provenance가 없어 승인 차단" + : !reason.trim() + ? "승인 사유를 먼저 입력해야 함" + : "visible payload와 repo provenance 검토 준비됨"; + + return ( +
+
+
+ {CONTENT_KIND_LABELS[item.content_kind]} +

{payload?.title ?? item.catalog_entry_id}

+ {item.catalog_entry_id} +
+ + {catalog ? "카탈로그 승인" : "사람 검토 대기"} + +
+ +
+
+
난이도
+
{item.difficulty_level}/5
+
+
+
독립 검토
+
{item.red_team_review_count} agents
+
+
+
변형 통과
+
+ {Math.round(item.benchmark_pass_rate * 100)}% · {item.benchmark_variant_count} variants +
+
+
+
출처
+
{item.source_count} repo source
+
+
+ + {payload ? ( +
+ 검수 payload 펼쳐 보기 +
+
+

합성 프로필

+

{payload.synthetic_profile}

+
+
+

상황과 도전

+

{payload.scenario}

+

{payload.rupture_or_challenge}

+
+
+

학습자 과제

+

{payload.learner_task}

+
    + {payload.success_criteria.map((criterion) => ( +
  • {criterion}
  • + ))} +
+
+
+

근거 추적

+
    + {payload.grounded_claims.map((claim) => ( +
  • + {claim.claim} {claim.source_ref} +
  • + ))} +
+
+
+
+ ) : ( +
+ 메타데이터 후보라 visible payload를 검수할 수 없어. 카탈로그 승인은 닫혀 있어. +
+ )} + + {approval ? ( +
+ +
+ {catalog ? "카탈로그 승인 원장 기록됨" : "후보 결정 원장 기록됨"} + + {approval.decision} · {dateTimeLabel(approval.created_at)} + +
+
+ ) : ( +
{ + event.preventDefault(); + if (canApprove && !approving) onApprove(); + }} + > + + +

+ {blockedReason} +

+
+ )} +
+ ); +} + +function PipelineSection({ + view, + reasons, + setReason, + approve, + approvingId, +}: { + view: ContinuousImprovementViewResponse; + reasons: Record; + setReason: (id: string, value: string) => void; + approve: (item: ContentQualificationView) => void; + approvingId: string | null; +}) { + const latest = view.content_qualifications.slice(0, 4); + return ( +
+
+
+ CONTENT QUALIFICATION +

합성 콘텐츠 검증 파이프라인

+

생성물은 독립 red-team과 변형 benchmark를 통과해도 카탈로그에 자동 게시되지 않아.

+
+ 사람 검토 대기 +
+ +
+ {[ + ["01", "Synthetic replay", "비식별 합성 입력"], + ["02", "Independent red-team", "안전 · 누출 · 편향"], + ["03", "Coverage drift", "변형 benchmark"], + ["04", "Human catalog gate", "append-only 승인"], + ].map(([step, title, detail]) => ( +
+ {step} + {title} + {detail} +
+ ))} +
+ + {latest.length === 0 ? ( +
+ +
+ 검토할 콘텐츠 후보가 없어 +

에이전트 파이프라인이 qualified candidate를 적재하면 여기에 나타나.

+
+
+ ) : ( +
+ {latest.map((item) => ( + setReason(item.qualification_id, value)} + onApprove={() => approve(item)} + approving={approvingId === item.qualification_id} + /> + ))} +
+ )} +
+ ); +} + +function GateCard({ + item, + view, + reason, + onReasonChange, + onApprove, + approving, + boundarySafe, +}: { + item: GateItem; + view: ContinuousImprovementViewResponse; + reason: string; + onReasonChange: (value: string) => void; + onApprove: () => void; + approving: boolean; + boundarySafe: boolean; +}) { + const id = gateId(item); + const artifacts = artifactsFor(view, item.kind, id); + const complete = hasCompleteArtifacts(artifacts); + const approval = view.approvals.find( + (candidate) => candidate.target_kind === item.kind && candidate.target_id === id, + ); + const effect = view.lifecycle_events.find( + (event) => event.target_kind === item.kind && event.target_id === id, + ); + const canSubmit = boundarySafe && complete && !approval && reason.trim().length > 0; + const blockedReason = !boundarySafe + ? "데이터 경계 위반으로 승인 차단" + : !complete + ? "필수 증거 4종이 모두 있어야 기록 가능" + : !reason.trim() + ? "승인 사유를 먼저 입력해야 함" + : undefined; + const requirementId = `approval-requirement-${id}`; + const requirementCopy = blockedReason ?? "증거 4종과 승인 사유가 준비됨"; + + return ( +
+
+
+ + {item.kind === "model_change_gate" ? "MODEL" : "RELEASE"} + +

{gateTitle(item)}

+ {shortId(id, 14)} +
+ + {decisionLabel(item)} + +
+ + {item.kind === "model_change_gate" && item.value.reasons.length > 0 ? ( +
    + {item.value.reasons.slice(0, 3).map((value) => ( +
  • {value}
  • + ))} +
+ ) : null} + + + + {approval ? ( +
+ +
+ append-only 승인 원장 기록됨 + + {approval.decision} · {dateTimeLabel(approval.created_at)} + {effect ? ` · effect ${shortId(effect.lifecycle_event_id, 8)}` : ""} + +
+
+ ) : ( +
{ + event.preventDefault(); + if (canSubmit && !approving) onApprove(); + }} + > + + +

+ {requirementCopy} +

+
+ )} +
+ ); +} + +function GateSection({ + view, + reasons, + setReason, + approve, + approvingId, +}: { + view: ContinuousImprovementViewResponse; + reasons: Record; + setReason: (id: string, value: string) => void; + approve: (item: GateItem) => void; + approvingId: string | null; +}) { + const items: GateItem[] = [ + ...view.model_change_gates.map((value) => ({ kind: "model_change_gate" as const, value })), + ...view.release_gates.map((value) => ({ kind: "release_gate" as const, value })), + ].sort( + (left, right) => + new Date(right.value.created_at).getTime() - new Date(left.value.created_at).getTime(), + ); + const boundarySafe = isBoundarySafe(view); + + return ( +
+
+
+ PROMOTION CONTROL +

모델 변경 · 릴리스 게이트

+

계산 결과는 제안일 뿐이야. 4종 증거와 명시적 사람 승인 없이는 효과가 발생하지 않아.

+
+
+ {items.length === 0 ? ( +
+ +
+ 대기 중인 게이트가 없어 +

모델 변경 또는 에이전틱 릴리스 후보가 생성되면 여기서 검토해.

+
+
+ ) : ( +
+ {items.map((item) => ( + setReason(gateId(item), value)} + onApprove={() => approve(item)} + approving={approvingId === gateId(item)} + boundarySafe={boundarySafe} + /> + ))} +
+ )} +
+ ); +} + +function IncidentDag({ view }: { view: ContinuousImprovementViewResponse }) { + const incidents = view.incidents.slice(0, 3); + return ( +
+
+
+ INCIDENT → REGRESSION +

사건 회귀 DAG

+

운영 사건을 재현 테스트부터 런타임 증명까지 끊김 없이 추적해.

+
+
+ {incidents.length === 0 ? ( +
+ +
+ 등록된 운영 사건이 없어 +

합성 증거 기반 사건이 적재되면 회귀 DAG가 생성돼.

+
+
+ ) : ( +
+ {incidents.map((incident) => { + const nodes = view.regression_dag_nodes.filter( + (node) => node.incident_record_id === incident.incident_record_id, + ); + return ( +
+
+
+ {incident.affected_contract} + {incident.incident_id} +
+ {dateTimeLabel(incident.created_at)} +
+
    + {(["reproduction_test", "implementation", "e2e", "runtime_proof"] as const).map( + (kind) => { + const node = nodes.find((candidate) => candidate.node_type === kind); + return ( +
  1. + + {NODE_LABELS[kind]} + {node?.node_status ?? "pending"} +
  2. + ); + }, + )} +
+
+ ); + })} +
+ )} +
+ ); +} + +function LifecycleLedger({ events }: { events: LifecycleEventView[] }) { + return ( +
+
+
+ APPEND-ONLY EFFECTS +

모니터 · 롤백 원장

+

승인 이후 효과와 사후 검증은 별도 이벤트로 남아.

+
+
+ {events.length === 0 ? ( +
+ +
+ 아직 발생한 효과가 없어 +

사람 승인 전에는 정상적인 빈 상태야.

+
+
+ ) : ( +
    + {events.slice(0, 8).map((event) => { + const rollbackVerificationPending = + event.event_type === "rollback" && + event.event_status === "executed" && + !events.some( + (candidate) => + candidate.target_kind === event.target_kind && + candidate.target_id === event.target_id && + candidate.event_status === "rollback_verified" && + new Date(candidate.created_at) > new Date(event.created_at), + ); + return ( +
  1. +
  2. + ); + })} +
+ )} +
+ ); +} + +export function ContinuousImprovementCockpit() { + const [view, setView] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [approvingId, setApprovingId] = useState(null); + const [reasons, setReasons] = useState>({}); + + const load = useCallback(async (signal?: AbortSignal) => { + setLoading(true); + setError(null); + try { + const next = await continuousImprovementApi.read(signal); + setView(next); + } catch (cause) { + if (signal?.aborted) return; + setError(cause instanceof Error ? cause.message : "개선 원장을 불러오지 못했어."); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load]); + + const approve = useCallback( + async (item: GateItem) => { + if (!view) return; + const id = gateId(item); + const artifacts = artifactsFor(view, item.kind, id); + const reason = reasons[id]?.trim() ?? ""; + const alreadyApproved = view.approvals.some( + (candidate) => candidate.target_kind === item.kind && candidate.target_id === id, + ); + if (!isBoundarySafe(view) || !hasCompleteArtifacts(artifacts) || alreadyApproved || !reason) { + return; + } + setApprovingId(id); + setError(null); + setNotice(null); + try { + const evidenceRefs = [...new Set(artifacts.map((artifact) => artifact.provenance_uri))]; + const result = await continuousImprovementApi.appendApproval({ + submission_id: randomUuid(), + approval_event_id: randomUuid(), + effect_record_id: randomUuid(), + target_kind: item.kind, + target_id: id, + decision: gateDecision(item), + reason_code: reason, + evidence_refs: evidenceRefs, + }); + setNotice(`append-only 승인과 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`); + setReasons((current) => ({ ...current, [id]: "" })); + await load(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "승인 원장을 기록하지 못했어."); + } finally { + setApprovingId(null); + } + }, + [load, reasons, view], + ); + + const approveQualification = useCallback( + async (item: ContentQualificationView) => { + if (!view) return; + const id = item.qualification_id; + const reason = reasons[id]?.trim() ?? ""; + const alreadyApproved = view.approvals.some( + (candidate) => + candidate.target_kind === "content_qualification" && candidate.target_id === id, + ); + const alreadyCataloged = view.catalog_entries.some( + (candidate) => candidate.qualification_id === id, + ); + if ( + !isBoundarySafe(view) || + !item.draft_payload || + item.source_provenance_uris.length === 0 || + alreadyApproved || + alreadyCataloged || + !reason + ) { + return; + } + setApprovingId(id); + setError(null); + setNotice(null); + try { + const evidenceRefs = [ + ...new Set([ + ...item.source_provenance_uris, + `audit://continuous-improvement/human-review/${id}`, + ]), + ]; + const result = await continuousImprovementApi.appendApproval({ + submission_id: randomUuid(), + approval_event_id: randomUuid(), + effect_record_id: randomUuid(), + target_kind: "content_qualification", + target_id: id, + decision: "approve_content", + reason_code: reason, + evidence_refs: evidenceRefs, + }); + setNotice(`카탈로그 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`); + setReasons((current) => ({ ...current, [id]: "" })); + await load(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "콘텐츠 승인 원장을 기록하지 못했어."); + } finally { + setApprovingId(null); + } + }, + [load, reasons, view], + ); + + const orderedEvents = useMemo( + () => + [...(view?.lifecycle_events ?? [])].sort( + (left, right) => new Date(right.created_at).getTime() - new Date(left.created_at).getTime(), + ), + [view], + ); + + if (!view && loading) { + return ( +
+
+ ); + } + + if (!view) { + return ( +
+ +
+ DEGRADED READ MODEL +

개선 원장을 확인할 수 없어

+

{error ?? "운영 원장이 응답하지 않았어."} 승인 동작은 닫힌 상태로 유지돼.

+ +
+
+ ); + } + + return ( +
+
+
+ 관리자 · CONTINUOUS IMPROVEMENT OS +

승격보다 근거를 먼저 본다

+

+ 합성 콘텐츠, 모델 변경, 에이전틱 릴리스를 한 원장에서 검토하고 사람 승인 이후의 + 효과와 롤백 검증까지 닫아. +

+
+ +
+ + {error ? ( +
+ + 최신 동기화 실패 · 이전 원장을 읽기 전용으로 표시 중 · {error} +
+ ) : null} + {notice ? ( +
+ + {notice} +
+ ) : null} + + + setReasons((current) => ({ ...current, [id]: value }))} + approve={(item) => void approveQualification(item)} + approvingId={approvingId} + /> + setReasons((current) => ({ ...current, [id]: value }))} + approve={(item) => void approve(item)} + approvingId={approvingId} + /> + +
+ + +
+ +
+ +

+ 이 표면은 관리자 전용 운영 메타데이터만 표시해. 학습자·교수자 화면, 원문 트랜스크립트, + PII, 임상 주장, 합산 총점은 이 계약에 포함하지 않아. +

+
+
+ ); +} diff --git a/apps/web/src/pages/session-review/CalibrationTransferCard.tsx b/apps/web/src/pages/session-review/CalibrationTransferCard.tsx new file mode 100644 index 0000000..8001cf8 --- /dev/null +++ b/apps/web/src/pages/session-review/CalibrationTransferCard.tsx @@ -0,0 +1,1231 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type FormEvent, +} from "react"; +import { Link } from "react-router-dom"; +import { Badge, Button, Card, Kicker } from "../../components/ui"; +import { ApiError } from "../../lib/api"; +import { + practiceLaunchPath, + type CalibrationPracticeMode, + type TransferPracticeLaunchIntent, +} from "../../lib/practiceLaunchIntent"; +import { + calibrationTransferApi, + type ActualTransferAssessment, + type ActualTransferExecutionResponse, + type CalibrationAssessmentItem, + type CalibrationBias, + type CalibrationTransferReadModelResponse, + type DriftReportItem, + type PerformanceStatus, + type PredictionHistoryItem, + type PredictionLockRequest, + type PredictionRevisionRequest, + type TeacherCorrectionRequest, + type TeacherReviewItem, + type TransferAssessmentItem, + type TransferSuiteItem, + type TransferTrialItem, +} from "./calibrationTransferApi"; +import "./calibration-transfer.css"; +import { randomUuid } from "../../lib/uuid"; + +interface CalibrationTransferCardProps { + sessionId: string; + isSupervisorView: boolean; + practiceLaunchIntent: TransferPracticeLaunchIntent | null; +} + +type LoadState = "loading" | "ready" | "error"; +type ActionState = "idle" | "saving" | "locking" | "success" | "error"; + +const COMPETENCIES = [ + ["competency.empathic_reflection", "공감적 반영"], + ["competency.open_question", "개방형 질문"], + ["competency.rupture_repair", "관계 균열 수선"], + ["competency.collaborative_goal", "협력적 목표 합의"], +] as const; + +const COMPETENCY_LABELS = new Map(COMPETENCIES); + +const BIAS_COPY: Record< + CalibrationBias, + { label: string; tone: "warn" | "info" | "pos" | "neutral"; note: string } +> = { + overconfident: { + label: "과신 경향", + tone: "warn", + note: "예측이 관찰보다 높았어. 다음 예측 전에 실패 가능 근거부터 찾는다.", + }, + underconfident: { + label: "과소신 경향", + tone: "info", + note: "관찰된 수행보다 자신을 낮게 봤어. 성공한 행동 근거를 먼저 회상한다.", + }, + aligned: { + label: "보정 정렬", + tone: "pos", + note: "현재 근거 안에서 예측과 관찰이 가까워. 불확실성 범위를 함께 기록한다.", + }, + insufficient_evidence: { + label: "근거 더 필요", + tone: "neutral", + note: "독립 관찰이 더 쌓이기 전에는 보정 방향을 단정하지 않는다.", + }, +}; + +const PERFORMANCE_COPY: Record = { + passed: "관찰됨", + failed: "관찰되지 않음", + insufficient_evidence: "근거 부족", +}; + +const COVERAGE_COPY: Record = { + contexts: "상황 맥락", + relationship_styles: "관계 스타일", + difficulty_levels: "난도", + expression_variants: "표현군", + scenario_families: "장면군", + synthetic_subgroups: "합성 subgroup", +}; + +const RELATIONSHIP_COPY = { + collaborative: "협력적", + withdrawn: "위축", + confrontational: "대립", + ambivalent: "양가적", +} as const; + +const PRACTICE_MODE_COPY: Record = { + counterevidence_forecast: "반대 근거 먼저 예측", + evidence_recall: "성공 행동 근거 회상", + uncertainty_range: "불확실성 범위 기록", + collect_more_evidence: "독립 근거 더 모으기", +}; + +function uuid(): string { + return randomUuid(); +} + +function formatPercent(value: number | null | undefined): string { + return value == null ? "근거 대기" : `${Math.round(value * 100)}%`; +} + +function competencyLabel(competencyId: string): string { + return ( + COMPETENCY_LABELS.get(competencyId) ?? + competencyId.replace(/^competency\./, "").replaceAll("_", " ") + ); +} + +function compactError(error: unknown, fallback: string): string { + if (error instanceof ApiError) return `API ${error.status}: ${error.detail}`; + if (error instanceof Error) return error.message; + return fallback; +} + +function latestRevision(history: PredictionHistoryItem) { + return history.revisions.at(-1) ?? null; +} + +function latestByCreatedAt(items: T[]): T | null { + return [...items].sort((a, b) => a.created_at.localeCompare(b.created_at)).at(-1) ?? null; +} + +function blockerCopy(blocker: string): string { + if (blocker === "memorized_training_phrase_reused") { + return "훈련에서 외운 문장군이 다시 검출되어 전이로 인정하지 않음"; + } + if (blocker.startsWith("observed_trials_below_minimum:")) { + return `미지 장면 관찰 수 부족 · ${blocker.split(":")[1] ?? "기준 미충족"}`; + } + if (blocker.startsWith("transfer_coverage_missing:")) { + const key = blocker.split(":")[1] ?? ""; + return `${COVERAGE_COPY[key] ?? key} 변주가 두 종류 미만`; + } + if (blocker === "transfer_trial_uncertainty_above_boundary") { + return "일부 관찰의 불확실성이 판정 경계를 넘음"; + } + if (blocker.startsWith("transfer_success_rate_below_target:")) { + return "관찰된 수행이 현재 전이 검증 기준에 닿지 않음"; + } + return blocker.replaceAll("_", " "); +} + +const ACTUAL_STATUS_COPY: Record< + ActualTransferAssessment["actual_transfer_status"], + { label: string; tone: "pos" | "warn" | "neutral" } +> = { + verified: { label: "새 장면 전이 확인", tone: "pos" }, + not_verified: { label: "전이 기준 아직 미달", tone: "warn" }, + insufficient_evidence: { label: "실제 회기 근거 더 필요", tone: "neutral" }, +}; + +function actualBlockerCopy(blocker: string): string { + if (blocker.startsWith("actual_independent_phrase_families_below_minimum:")) { + return "서로 다른 표현으로 수행한 회기가 더 필요해"; + } + if (blocker.startsWith("actual_observed_executions_below_minimum:")) { + return "판정 가능한 완료 회기가 더 필요해"; + } + if (blocker.startsWith("actual_transfer_coverage_missing:")) { + const key = blocker.split(":")[1] ?? ""; + return `${COVERAGE_COPY[key] ?? "장면"} 변주가 아직 충분하지 않아`; + } + if (blocker === "training_phrase_family_reused") { + return "훈련에서 쓴 표현과 겹쳐 독립 전이 근거로 세지 않았어"; + } + if (blocker === "actual_transfer_uncertainty_above_boundary") { + return "일부 회기의 판정 불확실성이 높아 추가 확인이 필요해"; + } + if (blocker.startsWith("actual_transfer_success_rate_below_target:")) { + return "관찰된 수행이 현재 전이 확인 기준에 아직 닿지 않았어"; + } + return "추가 전이 근거를 확인해야 해"; +} + +function actualExecutionCopy( + response: ActualTransferExecutionResponse | null, +): string | null { + if (!response) return null; + if (response.execution.status === "passed") return "목표 행동이 관찰됨"; + if (response.execution.status === "failed") return "목표 행동을 다시 연습해야 함"; + return "이번 회기만으로 판정하기 어려움"; +} + +function ActualTransferExecutionObservation({ + sessionId, + intent, + sourceIsValid, + data, + ledgerRefreshState, + onRecorded, +}: { + sessionId: string; + intent: TransferPracticeLaunchIntent; + sourceIsValid: boolean; + data: CalibrationTransferReadModelResponse; + ledgerRefreshState: "idle" | "refreshing" | "complete"; + onRecorded: () => void; +}) { + const currentAssessment = + (data.actual_transfer_assessments ?? []).find( + (item) => item.competency_id === intent.criterionId, + ) ?? null; + const existingExecution = (data.actual_executions ?? []).find( + (item) => + item.practice_session_id === sessionId && + item.original_transfer_trial_record_id === intent.trialId, + ); + const [state, setState] = useState< + "idle" | "saving" | "waiting" | "success" | "error" + >("idle"); + const [message, setMessage] = useState(""); + const [result, setResult] = useState(null); + const [beforeAssessment, setBeforeAssessment] = + useState(null); + + useEffect(() => { + setState("idle"); + setMessage(""); + setResult(null); + setBeforeAssessment(null); + }, [intent.trialId, sessionId]); + + async function recordActualExecution() { + if (!sourceIsValid || sessionId === intent.sourceSessionId) return; + setBeforeAssessment(currentAssessment); + setState("saving"); + setMessage(""); + try { + const response = await calibrationTransferApi.appendActualTransferExecution({ + original_transfer_trial_record_id: intent.trialId, + practice_session_id: sessionId, + }); + setResult(response); + setState("success"); + setMessage( + response.idempotent_replay + ? "이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어." + : "이번 완료 회기를 실제 전이 근거로 기록했어.", + ); + onRecorded(); + } catch (cause) { + if ( + cause instanceof ApiError && + cause.status === 422 && + cause.detail.includes( + "actual transfer practice evaluation must be ready at session_end", + ) + ) { + setState("waiting"); + setMessage( + "회기는 끝났지만 평가 정리가 아직 진행 중이야. 잠시 뒤 완료 여부를 다시 확인해줘.", + ); + return; + } + setState("error"); + setMessage( + "이번 회기를 전이 근거로 연결하지 못했어. 출처와 평가 상태를 다시 확인한 뒤 재시도해줘.", + ); + } + } + + const afterAssessment = result?.assessment ?? currentAssessment; + const afterStatus = afterAssessment + ? ACTUAL_STATUS_COPY[afterAssessment.actual_transfer_status] + : null; + const executionCopy = actualExecutionCopy(result); + const actionLabel = + state === "saving" + ? "전이 근거 확인 중" + : state === "waiting" + ? "평가 완료 여부 다시 확인" + : state === "error" + ? "전이 근거 연결 다시 시도" + : existingExecution || result + ? "같은 회기 기록 다시 확인" + : "이 회기를 전이 근거로 확인"; + + if (!sourceIsValid || sessionId === intent.sourceSessionId) { + return ( +
+
+ 실제 회기 전이 +

전이 근거의 출처를 다시 확인해야 해

+

+ 원본 과제와 분리된 후속 연습 회기에서만 실제 전이 근거를 만들 수 있어. 현재 연결로는 기록 요청을 보내지 않아. +

+
+ 기록 보류 +
+ ); + } + + return ( +
+
+
+ 실제 회기 전이 +

이번 연습을 실제 전이 근거로 연결

+

+ 종료된 이번 회기의 평가만 읽어, 처음 보는 장면에서 목표 행동을 수행했는지 확인해. +

+
+ + {afterStatus?.label ?? "아직 기록 전"} + +
+ + {result && afterAssessment ? ( +
+
+ 실제 실행 + + {beforeAssessment?.execution_count ?? 0} → {afterAssessment.execution_count}회 + +
+
+ 독립 표현 실행 + + {beforeAssessment?.independent_execution_count ?? 0} → {afterAssessment.independent_execution_count}회 + +
+
+ 판정 가능한 실행 + + {beforeAssessment?.observed_execution_count ?? 0} → {afterAssessment.observed_execution_count}회 + +
+
+ ) : currentAssessment ? ( +

+ 현재 실제 실행 {currentAssessment.execution_count}회 · 독립 표현 {currentAssessment.independent_execution_count}회 · 판정 가능 {currentAssessment.observed_execution_count}회 +

+ ) : ( +

+ 아직 이 회기의 실제 전이 관찰은 원장에 기록되지 않았어. +

+ )} + + {result && afterAssessment ? ( +
+ {Object.entries(COVERAGE_COPY).slice(0, 5).map(([key, label]) => ( +
+
{label}
+
+ {beforeAssessment?.coverage[key] ?? 0} → {afterAssessment.coverage[key] ?? 0}종 +
+
+ ))} +
+ ) : null} + + {executionCopy ?

이번 회기 · {executionCopy}

: null} + {afterAssessment && afterAssessment.blockers.length > 0 ? ( +
    + {[...new Set(afterAssessment.blockers.map(actualBlockerCopy))].map((blocker) => ( +
  • {blocker}
  • + ))} +
+ ) : null} + +
+ + {ledgerRefreshState === "refreshing" ? ( + 최신 원장을 다시 확인하는 중 + ) : result && ledgerRefreshState === "complete" ? ( + 최신 원장과 다시 맞춰 봤어 + ) : null} +
+ {message ? ( +

+ {message} +

+ ) : null} +
+ ); +} + +function CalibrationMirror({ history }: { history: PredictionHistoryItem }) { + const revision = latestRevision(history); + if (!revision) return null; + const observation = history.external_observation; + + return ( +
+
+
+ {history.practice_block_id} +

+ {competencyLabel(history.competency_id)} +

+
+ + {history.lock ? "예측 잠금 완료" : "잠금 전"} + +
+ +
+
+ 내가 먼저 남긴 예측 + {formatPercent(revision.predicted_success_probability)} +

확신 {formatPercent(revision.confidence)} · revision {revision.revision_no}

+
+ +
+ 독립 관찰 + {observation ? ( + <> + {PERFORMANCE_COPY[observation.status]} +

불확실성 {formatPercent(observation.uncertainty)}

+ + ) : ( + <> + 아직 공개하지 않음 +

{history.lock ? "외부 관찰을 기다리는 중" : "예측을 잠가야 비교 가능"}

+ + )} +
+
+ +

“{revision.revision_reason}”

+ {history.lock ? ( +

+ 잠근 예측은 수정할 수 없어. 이 경계가 외부 결과를 본 뒤 예측을 맞춰 쓰는 일을 막아. +

+ ) : null} + {history.revisions.length > 1 ? ( +
+ 이전 예측 revision {history.revisions.length - 1}개 보기 +
    + {history.revisions.map((item) => ( +
  1. + revision {item.revision_no} · 성공 가능성 {formatPercent(item.predicted_success_probability)} · {item.revision_reason} +
  2. + ))} +
+
+ ) : null} +
+ ); +} + +function CalibrationAssessment({ item }: { item: CalibrationAssessmentItem }) { + const assessment = item.assessment_payload; + const prescription = item.prescription_payload; + const bias = BIAS_COPY[assessment.bias]; + + return ( +
+
+
+ 역량별 calibration snapshot +

+ {competencyLabel(item.competency_id)} +

+
+ {bias.label} +
+ +
+
+ 평균 예측 오차 + {formatPercent(assessment.mean_absolute_error)} +
+
+ 95% 오차 구간 + + {assessment.error_interval + ? `${formatPercent(assessment.error_interval.lower)}–${formatPercent(assessment.error_interval.upper)}` + : "근거 대기"} + +
+
+ 독립 비교 쌍 + {assessment.pair_count}개 +
+
+ +

{bias.note}

+
+ 다음에 직접 할 일 + {prescription.instruction_ko} +
    + {prescription.completion_evidence.map((evidence) => ( +
  • {evidence.replaceAll("_", " ")}
  • + ))} +
+
+
+ ); +} + +function nextTransferTrial( + trials: TransferTrialItem[], +): TransferTrialItem | null { + return ( + trials.find((trial) => trial.status === "failed") ?? + trials.find((trial) => trial.status === "insufficient_evidence") ?? + trials[0] ?? + null + ); +} + +function TransferCoverage({ + suite, + sourceSessionId, + calibrationAssessments, + isSupervisorView, +}: { + suite: TransferSuiteItem; + sourceSessionId: string; + calibrationAssessments: CalibrationAssessmentItem[]; + isSupervisorView: boolean; +}) { + return ( +
+
+
+ Unseen transfer +

외운 문장이 아닌 새 장면에서 확인

+
+ 미지 장면만 +
+ +

+ 상황·관계 스타일·난도·표현군을 바꾼 독립 장면만 전이 근거로 사용해. 훈련 문장군을 재사용하면 자동으로 판정을 막아. +

+ +
+ {suite.assessments.map((item) => { + const assessment = item.assessment_payload; + const memorized = assessment.blockers.includes( + "memorized_training_phrase_reused", + ); + const relationships = [ + ...new Set( + suite.trials + .filter((trial) => trial.competency_id === item.competency_id) + .map((trial) => trial.relationship_style), + ), + ]; + const trial = nextTransferTrial( + suite.trials.filter( + (candidate) => candidate.competency_id === item.competency_id, + ), + ); + const calibrationPrescription = calibrationAssessments.find( + (candidate) => candidate.competency_id === item.competency_id, + ); + const launchPath = + trial && calibrationPrescription + ? practiceLaunchPath({ + kind: "transfer", + prescriptionId: calibrationPrescription.prescription_id, + suiteId: suite.transfer_suite_record_id, + trialId: trial.transfer_trial_record_id, + sourceSessionId, + criterionId: item.competency_id, + novelty: trial.scenario_novelty, + mode: calibrationPrescription.prescription_payload.practice_mode, + }) + : null; + return ( +
+
+ {competencyLabel(item.competency_id)} + + {assessment.transfer_verified ? "전이 근거 충족" : "전이 보류"} + +
+ +
+ {Object.entries(COVERAGE_COPY).slice(0, 4).map(([key, label]) => ( +
+
{label}
+
{assessment.coverage[key] ?? 0}종
+
+ ))} +
+ +
+ {relationships.map((style) => ( + {RELATIONSHIP_COPY[style]} + ))} +
+ +

+ + {memorized + ? "암기 문장 재사용 감지 · 전이 인정 차단" + : "훈련 문장군과 분리된 표현 근거"} +

+ + {assessment.blockers.length > 0 ? ( +
    + {assessment.blockers.map((blocker) => ( +
  • {blockerCopy(blocker)}
  • + ))} +
+ ) : ( +

현재 역량의 전이 판정 차단 조건이 없어.

+ )} + + {!isSupervisorView ? ( + launchPath && trial && calibrationPrescription ? ( +
+
+ 다음 미지 전이 과제 + {trial.context_variant} · {RELATIONSHIP_COPY[trial.relationship_style]} +

{PRACTICE_MODE_COPY[calibrationPrescription.prescription_payload.practice_mode]}

+
+
+
난도
{trial.difficulty_level}
+
표현
{trial.expression_variant}
+
+
판정 기준
+
{competencyLabel(item.competency_id)}
+
+
+ + 이 미지 전이 과제로 연습 + + +
+ ) : ( +

+ 전이 trial과 자기보정 처방의 연결을 확인할 수 없어 실행 인계를 보류했어. 결과 원장을 다시 불러오면 연습 링크가 열려. +

+ ) + ) : null} +
+ ); + })} +
+ +
+
+ Synthetic subgroup drift +

교육용 합성 장면의 평가 민감도 점검

+

+ 실제 인구집단·진단·임상 결과에 대한 주장이 아니야. 합성 시나리오 사이에서 평가기가 다르게 반응하는지만 살펴봐. +

+
+
+ {suite.drift_reports.map((item) => { + const report = item.report_payload; + return ( +
+
+ {competencyLabel(item.competency_id)} + {report.notice_ko} +
+ + {report.status === "drift_flagged" + ? "민감도 차이 점검" + : report.status === "stable" + ? "현재 신호 안정" + : "표본 더 필요"} + +
+ ); + })} +
+
+
+ ); +} + +interface CorrectionTarget { + kind: TeacherCorrectionRequest["target_kind"]; + id: string; + label: string; +} + +function TeacherCorrectionPanel({ + targets, + reviews, + onSaved, +}: { + targets: CorrectionTarget[]; + reviews: TeacherReviewItem[]; + onSaved: () => void; +}) { + const [targetValue, setTargetValue] = useState(""); + const [note, setNote] = useState(""); + const [state, setState] = useState<"idle" | "saving" | "success" | "error">("idle"); + const [message, setMessage] = useState(""); + const pendingRef = useRef<{ key: string; body: TeacherCorrectionRequest } | null>(null); + const selected = targets.find((target) => `${target.kind}:${target.id}` === targetValue); + + useEffect(() => { + if (!targetValue && targets[0]) { + setTargetValue(`${targets[0].kind}:${targets[0].id}`); + } + }, [targetValue, targets]); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!selected || note.trim().length < 4) return; + const key = `${selected.kind}:${selected.id}:${note.trim()}`; + const body = + pendingRef.current?.key === key + ? pendingRef.current.body + : { + submission_id: uuid(), + review_id: uuid(), + target_kind: selected.kind, + target_id: selected.id, + disposition: "corrected" as const, + correction_payload: { teacher_note: note.trim() }, + review_reason: note.trim(), + evidence_turn_ids: [], + counterevidence: [], + }; + pendingRef.current = { key, body }; + setState("saving"); + setMessage(""); + try { + await calibrationTransferApi.appendTeacherCorrection(body); + pendingRef.current = null; + setState("success"); + setMessage("교수자 교정을 append-only 원장에 추가했어."); + setNote(""); + onSaved(); + } catch (error) { + setState("error"); + setMessage(compactError(error, "교수자 교정을 기록하지 못했습니다.")); + } + } + + return ( +
+
+
+ 교수자 권한 +

읽기와 교정만 가능

+
+ cohort-scoped +
+

+ 원래 판정을 덮어쓰지 않고 교정 revision만 추가해. 학습자의 예측·잠금이나 평가기 관찰은 수정할 수 없어. +

+ {targets.length === 0 ? ( +

교정할 역량별 판정이 아직 없어.

+ ) : ( +
+ +