G8 실제 rollback 증명 종료와 비-secure origin 회기 리뷰 크래시 수정

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 체커가 실패한다.
This commit is contained in:
Yun Chan 2026-08-07 22:17:20 +09:00
parent 76d0b9ae9b
commit 93dd8f82d7
22 changed files with 10057 additions and 473 deletions

View file

@ -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<DashboardMetrics> {
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();

View file

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

40
apps/web/src/lib/uuid.ts Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,920 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Icon, Kicker } from "../../components/ui";
import { randomUuid } from "../../lib/uuid";
import {
continuousImprovementApi,
type ContentQualificationView,
type ContinuousImprovementGateKind,
type ContinuousImprovementViewResponse,
type GateArtifactView,
type HumanApprovalRequest,
type LifecycleEventView,
type ModelChangeGateView,
type RegressionDagNodeView,
type ReleaseGateView,
} from "./continuousImprovementApi";
const ARTIFACT_KINDS = ["baseline", "threshold", "provenance", "rollback"] as const;
const ARTIFACT_LABELS: Record<(typeof ARTIFACT_KINDS)[number], string> = {
baseline: "기준선",
threshold: "임계값",
provenance: "출처",
rollback: "롤백",
};
const NODE_LABELS: Record<RegressionDagNodeView["node_type"], string> = {
reproduction_test: "재현 테스트",
implementation: "구현",
e2e: "E2E",
runtime_proof: "런타임 증명",
};
const EVENT_LABELS: Record<LifecycleEventView["event_status"], string> = {
approved: "승인 원장 기록",
requested: "롤백 실행 대기 · 실행기 미구성",
executed: "롤백 실행",
failed: "롤백 실행 실패",
healthy: "모니터 정상",
drift_detected: "드리프트 감지",
rollback_recommended: "롤백 권고",
rollback_verified: "롤백 검증 완료",
};
const CONTENT_KIND_LABELS: Record<ContentQualificationView["content_kind"], string> = {
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 (
<section className={`cic-boundary ${safe ? "is-safe" : "is-blocked"}`} aria-label="운영 경계">
<div className="cic-boundary__mark" aria-hidden="true">
<Icon name={safe ? "shield" : "alert"} size={19} />
</div>
{boundaries.map(([label, value]) => (
<div className="cic-boundary__item" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</section>
);
}
function ArtifactRail({ artifacts }: { artifacts: GateArtifactView[] }) {
return (
<ol className="cic-artifacts" aria-label="게이트 필수 증거">
{ARTIFACT_KINDS.map((kind, index) => {
const matches = artifacts.filter((artifact) => artifact.artifact_kind === kind);
const ready = matches.length > 0;
return (
<li className={ready ? "is-ready" : "is-missing"} key={kind}>
<span className="cic-artifacts__index" aria-hidden="true">
{ready ? <Icon name="check" size={14} /> : index + 1}
</span>
<div>
<strong>{ARTIFACT_LABELS[kind]}</strong>
<span>{ready ? `${matches.length}개 고정` : "증거 없음"}</span>
</div>
</li>
);
})}
</ol>
);
}
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 (
<article
className="cic-content-card"
data-qualification-id={item.qualification_id}
aria-labelledby={titleId}
>
<header>
<div>
<span className="cic-content-card__kind">{CONTENT_KIND_LABELS[item.content_kind]}</span>
<h3 id={titleId}>{payload?.title ?? item.catalog_entry_id}</h3>
<code>{item.catalog_entry_id}</code>
</div>
<span className={`cic-decision ${catalog ? "is-approved" : ""}`}>
{catalog ? "카탈로그 승인" : "사람 검토 대기"}
</span>
</header>
<dl className="cic-content-card__facts" aria-label="후보 검증 요약">
<div>
<dt></dt>
<dd>{item.difficulty_level}/5</dd>
</div>
<div>
<dt> </dt>
<dd>{item.red_team_review_count} agents</dd>
</div>
<div>
<dt> </dt>
<dd>
{Math.round(item.benchmark_pass_rate * 100)}% · {item.benchmark_variant_count} variants
</dd>
</div>
<div>
<dt></dt>
<dd>{item.source_count} repo source</dd>
</div>
</dl>
{payload ? (
<details className="cic-content-review">
<summary> payload </summary>
<div className="cic-content-review__body">
<section>
<h4> </h4>
<p>{payload.synthetic_profile}</p>
</section>
<section>
<h4> </h4>
<p>{payload.scenario}</p>
<p>{payload.rupture_or_challenge}</p>
</section>
<section>
<h4> </h4>
<p>{payload.learner_task}</p>
<ul>
{payload.success_criteria.map((criterion) => (
<li key={criterion}>{criterion}</li>
))}
</ul>
</section>
<section>
<h4> </h4>
<ul>
{payload.grounded_claims.map((claim) => (
<li key={`${claim.source_ref}:${claim.claim}`}>
{claim.claim} <code>{claim.source_ref}</code>
</li>
))}
</ul>
</section>
</div>
</details>
) : (
<div className="cic-content-card__blocked" role="alert">
visible payload를 . .
</div>
)}
{approval ? (
<div className="cic-effect" role="status">
<Icon name={catalog ? "check" : "alert"} size={17} />
<div>
<strong>{catalog ? "카탈로그 승인 원장 기록됨" : "후보 결정 원장 기록됨"}</strong>
<span>
{approval.decision} · {dateTimeLabel(approval.created_at)}
</span>
</div>
</div>
) : (
<form
className="cic-approval-form"
onSubmit={(event) => {
event.preventDefault();
if (canApprove && !approving) onApprove();
}}
>
<label htmlFor={`content-reason-${item.qualification_id}`}>
<span> </span>
<input
id={`content-reason-${item.qualification_id}`}
name={`content-approval-reason-${item.qualification_id}`}
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
aria-describedby={requirementId}
placeholder="예: 합성 경계와 수련 목표를 직접 검수함"
autoComplete="off"
maxLength={180}
/>
</label>
<Button
size="sm"
variant="primary"
type="submit"
disabled={!canApprove || approving}
aria-describedby={requirementId}
>
{approving ? "승인 원장 기록 중…" : "카탈로그 승인"}
</Button>
<p
id={requirementId}
className={`cic-approval-requirement ${canApprove ? "is-ready" : !boundarySafe ? "is-blocked" : ""}`}
>
{blockedReason}
</p>
</form>
)}
</article>
);
}
function PipelineSection({
view,
reasons,
setReason,
approve,
approvingId,
}: {
view: ContinuousImprovementViewResponse;
reasons: Record<string, string>;
setReason: (id: string, value: string) => void;
approve: (item: ContentQualificationView) => void;
approvingId: string | null;
}) {
const latest = view.content_qualifications.slice(0, 4);
return (
<section className="cic-panel cic-pipeline" aria-labelledby="cic-pipeline-title">
<header className="cic-section-head">
<div>
<Kicker dot={false}>CONTENT QUALIFICATION</Kicker>
<h2 id="cic-pipeline-title"> </h2>
<p> red-team과 benchmark를 .</p>
</div>
<span className="cic-state-chip"> </span>
</header>
<div className="cic-pipeline__flow" aria-label="콘텐츠 검증 단계">
{[
["01", "Synthetic replay", "비식별 합성 입력"],
["02", "Independent red-team", "안전 · 누출 · 편향"],
["03", "Coverage drift", "변형 benchmark"],
["04", "Human catalog gate", "append-only 승인"],
].map(([step, title, detail]) => (
<div className="cic-pipeline__step" key={step}>
<span>{step}</span>
<strong>{title}</strong>
<small>{detail}</small>
</div>
))}
</div>
{latest.length === 0 ? (
<div className="cic-empty" role="status">
<Icon name="hourglass" size={20} />
<div>
<strong> </strong>
<p> qualified candidate를 .</p>
</div>
</div>
) : (
<div className="cic-content-grid">
{latest.map((item) => (
<ContentQualificationCard
key={item.qualification_id}
item={item}
view={view}
reason={reasons[item.qualification_id] ?? ""}
onReasonChange={(value) => setReason(item.qualification_id, value)}
onApprove={() => approve(item)}
approving={approvingId === item.qualification_id}
/>
))}
</div>
)}
</section>
);
}
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 (
<article className="cic-gate-card" data-gate-id={id}>
<header>
<div>
<span className="cic-gate-card__kind">
{item.kind === "model_change_gate" ? "MODEL" : "RELEASE"}
</span>
<h3>{gateTitle(item)}</h3>
<code>{shortId(id, 14)}</code>
</div>
<span className={`cic-decision cic-decision--${gateDecision(item)}`}>
{decisionLabel(item)}
</span>
</header>
{item.kind === "model_change_gate" && item.value.reasons.length > 0 ? (
<ul className="cic-gate-card__reasons">
{item.value.reasons.slice(0, 3).map((value) => (
<li key={value}>{value}</li>
))}
</ul>
) : null}
<ArtifactRail artifacts={artifacts} />
{approval ? (
<div className="cic-effect" role="status">
<Icon name="check" size={17} />
<div>
<strong>append-only </strong>
<span>
{approval.decision} · {dateTimeLabel(approval.created_at)}
{effect ? ` · effect ${shortId(effect.lifecycle_event_id, 8)}` : ""}
</span>
</div>
</div>
) : (
<form
className="cic-approval-form"
onSubmit={(event) => {
event.preventDefault();
if (canSubmit && !approving) onApprove();
}}
>
<label htmlFor={`reason-${id}`}>
<span> </span>
<input
id={`reason-${id}`}
name={`approval-reason-${id}`}
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
aria-describedby={requirementId}
placeholder="예: 기준선과 롤백 절차를 독립 검토함…"
autoComplete="off"
maxLength={180}
/>
</label>
<Button
size="sm"
variant={gateDecision(item) === "authorize_rollback" ? "danger" : "primary"}
type="submit"
disabled={!canSubmit || approving}
aria-describedby={requirementId}
>
{approving ? "원장 기록 중…" : complete ? actionLabel(item) : "증거 4종 미완료"}
</Button>
<p
id={requirementId}
className={`cic-approval-requirement ${!boundarySafe ? "is-blocked" : canSubmit ? "is-ready" : ""}`}
>
{requirementCopy}
</p>
</form>
)}
</article>
);
}
function GateSection({
view,
reasons,
setReason,
approve,
approvingId,
}: {
view: ContinuousImprovementViewResponse;
reasons: Record<string, string>;
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 (
<section className="cic-gates" aria-labelledby="cic-gates-title">
<header className="cic-section-head">
<div>
<Kicker dot={false}>PROMOTION CONTROL</Kicker>
<h2 id="cic-gates-title"> · </h2>
<p> . 4 .</p>
</div>
</header>
{items.length === 0 ? (
<div className="cic-empty" role="status">
<Icon name="shield" size={20} />
<div>
<strong> </strong>
<p> .</p>
</div>
</div>
) : (
<div className="cic-gates__grid">
{items.map((item) => (
<GateCard
key={`${item.kind}:${gateId(item)}`}
item={item}
view={view}
reason={reasons[gateId(item)] ?? ""}
onReasonChange={(value) => setReason(gateId(item), value)}
onApprove={() => approve(item)}
approving={approvingId === gateId(item)}
boundarySafe={boundarySafe}
/>
))}
</div>
)}
</section>
);
}
function IncidentDag({ view }: { view: ContinuousImprovementViewResponse }) {
const incidents = view.incidents.slice(0, 3);
return (
<section className="cic-panel" aria-labelledby="cic-incidents-title">
<header className="cic-section-head cic-section-head--compact">
<div>
<Kicker dot={false}>INCIDENT REGRESSION</Kicker>
<h2 id="cic-incidents-title"> DAG</h2>
<p> .</p>
</div>
</header>
{incidents.length === 0 ? (
<div className="cic-empty cic-empty--plain" role="status">
<Icon name="check" size={19} />
<div>
<strong> </strong>
<p> DAG가 .</p>
</div>
</div>
) : (
<div className="cic-incident-list">
{incidents.map((incident) => {
const nodes = view.regression_dag_nodes.filter(
(node) => node.incident_record_id === incident.incident_record_id,
);
return (
<article key={incident.incident_record_id}>
<header>
<div>
<strong>{incident.affected_contract}</strong>
<code>{incident.incident_id}</code>
</div>
<span>{dateTimeLabel(incident.created_at)}</span>
</header>
<ol className="cic-dag" aria-label={`${incident.incident_id} 회귀 DAG`}>
{(["reproduction_test", "implementation", "e2e", "runtime_proof"] as const).map(
(kind) => {
const node = nodes.find((candidate) => candidate.node_type === kind);
return (
<li className={`is-${node?.node_status ?? "pending"}`} key={kind}>
<span aria-hidden="true">
{node?.node_status === "passed" ? <Icon name="check" size={14} /> : null}
</span>
<strong>{NODE_LABELS[kind]}</strong>
<small>{node?.node_status ?? "pending"}</small>
</li>
);
},
)}
</ol>
</article>
);
})}
</div>
)}
</section>
);
}
function LifecycleLedger({ events }: { events: LifecycleEventView[] }) {
return (
<section className="cic-panel" aria-labelledby="cic-lifecycle-title">
<header className="cic-section-head cic-section-head--compact">
<div>
<Kicker dot={false}>APPEND-ONLY EFFECTS</Kicker>
<h2 id="cic-lifecycle-title"> · </h2>
<p> .</p>
</div>
</header>
{events.length === 0 ? (
<div className="cic-empty cic-empty--plain" role="status">
<Icon name="hourglass" size={19} />
<div>
<strong> </strong>
<p> .</p>
</div>
</div>
) : (
<ol className="cic-ledger">
{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 (
<li className={`is-${event.event_status}`} key={event.lifecycle_event_id}>
<span className="cic-ledger__line" aria-hidden="true" />
<span className="cic-ledger__dot" aria-hidden="true" />
<div>
<strong>{EVENT_LABELS[event.event_status]}</strong>
<span>
{event.target_kind === "model_change_gate" ? "모델" : "릴리스"} · {shortId(event.target_id)}
</span>
{rollbackVerificationPending ? (
<em> verification </em>
) : null}
</div>
<time dateTime={event.created_at}>{dateTimeLabel(event.created_at)}</time>
</li>
);
})}
</ol>
)}
</section>
);
}
export function ContinuousImprovementCockpit() {
const [view, setView] = useState<ContinuousImprovementViewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [approvingId, setApprovingId] = useState<string | null>(null);
const [reasons, setReasons] = useState<Record<string, string>>({});
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 (
<div className="cic-loading" role="status" aria-live="polite">
<span aria-hidden="true" />
</div>
);
}
if (!view) {
return (
<section className="cic-degraded" role="alert">
<Icon name="alert" size={22} />
<div>
<Kicker dot={false}>DEGRADED READ MODEL</Kicker>
<h1> </h1>
<p>{error ?? "운영 원장이 응답하지 않았어."} .</p>
<Button size="sm" variant="secondary" onClick={() => void load()}>
</Button>
</div>
</section>
);
}
return (
<div className="cic" data-testid="continuous-improvement-cockpit" aria-busy={loading}>
<header className="cic-hero">
<div>
<Kicker> · CONTINUOUS IMPROVEMENT OS</Kicker>
<h1> </h1>
<p>
, ,
.
</p>
</div>
<Button
size="sm"
variant="secondary"
leading={<Icon name="refresh" size={16} />}
disabled={loading}
onClick={() => void load()}
>
</Button>
</header>
{error ? (
<div className="cic-alert" role="alert">
<Icon name="alert" size={17} />
<span> · · {error}</span>
</div>
) : null}
{notice ? (
<div className="cic-notice" role="status" aria-live="polite">
<Icon name="check" size={17} />
<span>{notice}</span>
</div>
) : null}
<BoundaryStrip view={view} />
<PipelineSection
view={view}
reasons={reasons}
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
approve={(item) => void approveQualification(item)}
approvingId={approvingId}
/>
<GateSection
view={view}
reasons={reasons}
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
approve={(item) => void approve(item)}
approvingId={approvingId}
/>
<div className="cic-lower-grid">
<IncidentDag view={view} />
<LifecycleLedger events={orderedEvents} />
</div>
<footer className="cic-footnote">
<Icon name="info" size={16} />
<p>
. · , ,
PII, , .
</p>
</footer>
</div>
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,859 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge, Button, Card, Icon, Kicker } from "../../components/ui";
import { ApiError } from "../../lib/api";
import {
multimodalAllianceApi,
rawAudioPlaybackUrl,
type AllianceAxis,
type MultimodalAllianceReadModel,
type MultimodalConsentRequest,
type MultimodalDeletionRequest,
type MultimodalFusionDecision,
type MultimodalMeasurement,
type MultimodalVoiceEvent,
type RawAudioAsset,
} from "./multimodalAllianceApi";
import "./multimodal-alliance.css";
import { randomUuid } from "../../lib/uuid";
interface MultimodalAllianceCardProps {
sessionId: string;
isSupervisorView: boolean;
}
type LoadState = "loading" | "ready" | "empty" | "error";
type ActionState = "idle" | "saving" | "success" | "error";
type RawAudioState = "idle" | "loading" | "ready" | "error";
type PlaybackState = "loading" | "ready" | "playing" | "error";
type DeletionScope = "audio" | "derived_features";
const POLICY_VERSION = "vignette.multimodal-consent.v1";
const AXES: AllianceAxis[] = ["goal", "task", "bond"];
const AXIS_LABEL: Record<AllianceAxis, string> = {
goal: "목표 합의",
task: "과업 합의",
bond: "관계적 유대",
};
const EVENT_LABEL: Record<MultimodalVoiceEvent["event_type"], string> = {
silence: "침묵",
overlap: "발화 겹침",
interruption: "끼어듦",
prosody: "운율 변화",
pace: "말 속도",
audio_quality: "음질",
};
const ACTOR_LABEL: Record<MultimodalVoiceEvent["actor"], string> = {
learner: "학습자",
client: "내담자",
both: "두 화자",
channel: "오디오 채널",
};
function uuid(): string {
return randomUuid();
}
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 formatClock(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}:${String(remainder).padStart(2, "0")}`;
}
function formatPercent(value: number | null, empty = "산출 안 됨"): string {
return value == null ? empty : `${Math.round(value * 100)}%`;
}
function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value || "기록 없음";
return new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
function formatBytes(value: number): string {
if (value <= 0) return "크기 미상";
if (value < 1024 * 1024) return `${Math.round(value / 1024)}KB`;
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
}
function SceneAudioPlayer({
sessionId,
asset,
scene,
}: {
sessionId: string;
asset: RawAudioAsset;
scene: MultimodalVoiceEvent | null;
}) {
const audioRef = useRef<HTMLAudioElement>(null);
const [playbackState, setPlaybackState] = useState<PlaybackState>("loading");
const sceneLabel = scene ? EVENT_LABEL[scene.event_type] : "전체 회기";
const startSeconds = (scene?.start_ms ?? 0) / 1000;
const endSeconds = (scene?.end_ms ?? asset.duration_ms) / 1000;
const statusId = `mma-playback-status-${asset.audio_asset_id}`;
useEffect(() => () => audioRef.current?.pause(), []);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.pause();
if (audio.readyState > 0) audio.currentTime = startSeconds;
}, [asset.audio_asset_id, scene?.event_id, startSeconds]);
const playScene = async () => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = startSeconds;
try {
await audio.play();
} catch {
setPlaybackState("error");
}
};
return (
<section className="mma-scene-player" aria-label={`${sceneLabel} 장면 오디오`}>
<div className="mma-scene-player__head">
<div>
<strong>{sceneLabel} </strong>
<span>{formatClock(startSeconds * 1000)}{formatClock(endSeconds * 1000)}</span>
</div>
<Button
variant="secondary"
size="sm"
leading={<Icon name="play" size={14} />}
disabled={playbackState === "loading" || playbackState === "error"}
onClick={() => void playScene()}
>
</Button>
</div>
<audio
ref={audioRef}
controls
preload="metadata"
crossOrigin="use-credentials"
src={rawAudioPlaybackUrl(sessionId, asset.audio_asset_id)}
aria-label={`${sceneLabel} 장면 원본 음성 플레이어`}
aria-describedby={statusId}
onLoadStart={() => setPlaybackState("loading")}
onCanPlay={() => setPlaybackState("ready")}
onPlaying={() => setPlaybackState("playing")}
onPause={() => setPlaybackState((current) => current === "error" ? current : "ready")}
onError={() => setPlaybackState("error")}
onTimeUpdate={(event) => {
if (event.currentTarget.currentTime >= endSeconds) event.currentTarget.pause();
}}
>
.
</audio>
<p id={statusId} className={`mma-playback-status is-${playbackState}`} role={playbackState === "error" ? "alert" : "status"}>
{playbackState === "loading"
? "원본 음성을 불러오는 중입니다."
: playbackState === "playing"
? `${sceneLabel} 장면을 재생 중입니다.`
: playbackState === "error"
? "원본 음성을 재생하지 못했습니다. 동의·보존 상태 또는 네트워크를 확인해 주세요."
: "재생 준비가 됐습니다. 기본 오디오 조작 또는 선택 장면 듣기를 사용할 수 있습니다."}
</p>
</section>
);
}
function position(startMs: number, durationMs: number): number {
if (durationMs <= 0) return 0;
return Math.max(0, Math.min(100, (startMs / durationMs) * 100));
}
function segmentWidth(startMs: number, endMs: number, durationMs: number): number {
if (durationMs <= 0) return 0.5;
return Math.max(0.5, Math.min(100, ((endMs - startMs) / durationMs) * 100));
}
function latestByCreatedAt<T extends { created_at: string }>(items: T[]): T | null {
return [...items]
.sort((a, b) => a.created_at.localeCompare(b.created_at))
.at(-1) ?? null;
}
function safeObservedFeature(value: string): string {
const forbidden =
/(진단|우울증|불안장애|자살\s*위험|감정(?:은|이)|기분이|슬픔을\s*느|불안을\s*느|화가\s*났|diagnos|depress|anxiety disorder|is sad|is angry|feels anxious)/i;
if (forbidden.test(value)) {
return "비임상 관찰 경계를 벗어난 해석 문구는 표시하지 않았습니다.";
}
return value || "관찰 설명이 기록되지 않았습니다.";
}
function counterevidenceCopy(value: string): string {
if (value === "voice_incremental_gain_not_demonstrated") {
return "검증셋에서 음성 추가 이득이 최소 기준을 넘지 않아 텍스트 측정만 유지했습니다.";
}
if (value === "voice_measurement_not_ready") {
return "음성 측정이 준비되지 않아 텍스트 결과에 값을 덧씌우지 않았습니다.";
}
if (value === "voice_measurement_error") {
return "음성 측정 오류를 정상값으로 채우지 않고 텍스트 결과만 유지했습니다.";
}
return value.replaceAll("_", " ");
}
function measurementStatusCopy(item: MultimodalMeasurement | null): string {
if (!item || item.status === "missing") return "근거 없음";
if (item.status === "error") return "측정 오류";
return formatPercent(item.value);
}
function MeasurementCell({
item,
modality,
}: {
item: MultimodalMeasurement | null;
modality: "text" | "voice";
}) {
const isReady = item?.status === "ready";
return (
<section
className={`mma-measurement ${isReady ? "is-ready" : "is-unavailable"}`}
aria-label={`${modality === "text" ? "텍스트" : "음성"} 독립 측정`}
>
<span className="mma-measurement__label">
<Icon name={modality === "text" ? "review" : "mic"} size={15} />
{modality === "text" ? "텍스트" : "음성"}
</span>
<strong>{measurementStatusCopy(item)}</strong>
<small>
{item?.status === "ready"
? `불확실성 ${formatPercent(item.uncertainty)}`
: item?.error_code
? `오류 코드 · ${item.error_code}`
: "점수를 만들지 않음"}
</small>
{item ? (
<details>
<summary> </summary>
<dl>
<div>
<dt></dt>
<dd>{item.instrument_id || "미기록"}</dd>
</div>
<div>
<dt></dt>
<dd>{item.instrument_version || "미기록"}</dd>
</div>
<div>
<dt></dt>
<dd>{item.model_name || "미기록"}</dd>
</div>
</dl>
</details>
) : null}
</section>
);
}
function FusionCell({ decision }: { decision: MultimodalFusionDecision | null }) {
if (!decision) {
return (
<section className="mma-fusion is-pending" aria-label="결합 판정 대기">
<span></span>
<strong></strong>
<small> .</small>
</section>
);
}
const isTextOnly = !decision.fusion_applied;
return (
<section
className={`mma-fusion ${isTextOnly ? "is-text-only" : "is-fused"}`}
aria-label={isTextOnly ? "텍스트 단독 유지 판정" : "보정 융합 판정"}
>
<span>{isTextOnly ? "텍스트 단독" : "보정 융합"}</span>
<strong>{formatPercent(decision.value)}</strong>
<small>
{isTextOnly
? "음성을 자동 가산하지 않음"
: `추가 이득 ${formatPercent(decision.incremental_gain, "미기록")}`}
</small>
{isTextOnly && decision.counterevidence.length > 0 ? (
<p>{decision.counterevidence.map(counterevidenceCopy).join(" ")}</p>
) : null}
{!isTextOnly ? (
<p>
{decision.calibration_id ?? "미기록"} · {" "}
{decision.benchmark_version ?? "미기록"}
</p>
) : null}
</section>
);
}
function PrivacyLedger({
sessionId,
data,
isSupervisorView,
rawAudio,
rawAudioState,
rawAudioError,
selectedScene,
retentionDays,
setRetentionDays,
retainAudio,
setRetainAudio,
consentAcknowledged,
setConsentAcknowledged,
withdrawalAcknowledged,
setWithdrawalAcknowledged,
deletionScopes,
setDeletionScopes,
actionState,
actionMessage,
onConsent,
onWithdraw,
onDelete,
}: {
sessionId: string;
data: MultimodalAllianceReadModel;
isSupervisorView: boolean;
rawAudio: RawAudioAsset[];
rawAudioState: RawAudioState;
rawAudioError: string;
selectedScene: MultimodalVoiceEvent | null;
retentionDays: number;
setRetentionDays: (value: number) => void;
retainAudio: boolean;
setRetainAudio: (value: boolean) => void;
consentAcknowledged: boolean;
setConsentAcknowledged: (value: boolean) => void;
withdrawalAcknowledged: boolean;
setWithdrawalAcknowledged: (value: boolean) => void;
deletionScopes: DeletionScope[];
setDeletionScopes: (value: DeletionScope[]) => void;
actionState: ActionState;
actionMessage: string;
onConsent: () => void;
onWithdraw: () => void;
onDelete: () => void;
}) {
const latestConsent = [...data.consent_snapshots]
.sort((a, b) => a.sequence_no - b.sequence_no)
.at(-1) ?? null;
const status = latestConsent?.consent_status ?? "not_granted";
const scopeChecked = (scope: DeletionScope) => deletionScopes.includes(scope);
const toggleScope = (scope: DeletionScope) => {
setDeletionScopes(
scopeChecked(scope)
? deletionScopes.filter((item) => item !== scope)
: [...deletionScopes, scope],
);
};
if (isSupervisorView) {
return (
<section className="mma-privacy" aria-labelledby="mma-privacy-title">
<div className="mma-section-head">
<div>
<span className="mma-eyebrow"> </span>
<h3 id="mma-privacy-title"> </h3>
</div>
<Badge tone="neutral"> </Badge>
</div>
<p className="mma-boundary-copy">
, , .
API는 .
</p>
</section>
);
}
return (
<section className="mma-privacy" aria-labelledby="mma-privacy-title">
<div className="mma-section-head">
<div>
<span className="mma-eyebrow"> </span>
<h3 id="mma-privacy-title">·· </h3>
</div>
<Badge tone={status === "granted" ? "pos" : status === "withdrawn" ? "neutral" : "warn"}>
{status === "granted" ? "동의 활성" : status === "withdrawn" ? "철회 완료" : "동의 없음"}
</Badge>
</div>
<dl className="mma-privacy-ledger">
<div>
<dt></dt>
<dd> </dd>
</div>
<div>
<dt> </dt>
<dd>{latestConsent?.retain_audio ? "기한 보존" : "보존 안 함"}</dd>
</div>
<div>
<dt> </dt>
<dd>{latestConsent?.retain_derived_features ? "기한 보존" : "보존 안 함"}</dd>
</div>
<div>
<dt> </dt>
<dd>{latestConsent?.retention_days ? `${latestConsent.retention_days}` : "해당 없음"}</dd>
</div>
</dl>
{status === "not_granted" ? (
<div className="mma-consent-form">
<label>
<span>· </span>
<select value={retentionDays} onChange={(event) => setRetentionDays(Number(event.target.value))}>
<option value={7}>7</option>
<option value={30}>30</option>
<option value={90}>90</option>
</select>
</label>
<label className="mma-check">
<input type="checkbox" checked={retainAudio} onChange={(event) => setRetainAudio(event.target.checked)} />
<span> </span>
</label>
<label className="mma-check">
<input type="checkbox" checked={consentAcknowledged} onChange={(event) => setConsentAcknowledged(event.target.checked)} />
<span> · </span>
</label>
<Button size="sm" leading={<Icon name="mic" size={15} />} disabled={!consentAcknowledged || actionState === "saving"} onClick={onConsent}>
{actionState === "saving" ? "동의 기록 중" : "음성 분석 동의 기록"}
</Button>
</div>
) : null}
{status === "granted" ? (
<div className="mma-privacy-actions">
<div className="mma-raw-access">
<strong> </strong>
{rawAudioState === "loading" ? (
<p role="status" aria-live="polite"> .</p>
) : rawAudioError ? (
<p role="alert">{rawAudioError}</p>
) : rawAudio.length > 0 ? (
rawAudio.map((item) => (
<div className="mma-raw-asset" key={item.audio_asset_id}>
<p>
· {formatBytes(item.byte_size)} · {formatDate(item.retained_until)}
</p>
<SceneAudioPlayer sessionId={sessionId} asset={item} scene={selectedScene} />
</div>
))
) : (
<p> .</p>
)}
</div>
<label className="mma-check">
<input type="checkbox" checked={withdrawalAcknowledged} onChange={(event) => setWithdrawalAcknowledged(event.target.checked)} />
<span> </span>
</label>
<Button variant="secondary" size="sm" disabled={!withdrawalAcknowledged || actionState === "saving"} onClick={onWithdraw}>
</Button>
<fieldset className="mma-delete-fieldset">
<legend> </legend>
<label className="mma-check">
<input type="checkbox" checked={scopeChecked("audio")} onChange={() => toggleScope("audio")} />
<span> </span>
</label>
<label className="mma-check">
<input type="checkbox" checked={scopeChecked("derived_features")} onChange={() => toggleScope("derived_features")} />
<span> </span>
</label>
<Button variant="danger" size="sm" disabled={deletionScopes.length === 0 || actionState === "saving"} onClick={onDelete}>
</Button>
</fieldset>
</div>
) : null}
{data.deletion_requests.length > 0 ? (
<div className="mma-deletion-history" aria-label="삭제 요청 상태">
{data.deletion_requests.map((request) => {
const completed = request.scopes.every((scope) => request.completed_scopes.includes(scope));
return (
<div key={request.deletion_request_id}>
<Badge tone={completed ? "pos" : "warn"}>{completed ? "삭제 증명 완료" : "삭제 처리 중"}</Badge>
<span>{request.scopes.map((scope) => scope === "audio" ? "원본 음성" : "파생 특징").join(" · ")}</span>
<small>{formatDate(request.requested_at)}</small>
</div>
);
})}
</div>
) : null}
{actionMessage ? (
<p className={`mma-action-message is-${actionState}`} role="status">{actionMessage}</p>
) : null}
</section>
);
}
export function MultimodalAllianceCard({ sessionId, isSupervisorView }: MultimodalAllianceCardProps) {
const navigate = useNavigate();
const [data, setData] = useState<MultimodalAllianceReadModel | null>(null);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [loadError, setLoadError] = useState("");
const [reloadSeq, setReloadSeq] = useState(0);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [rawAudio, setRawAudio] = useState<RawAudioAsset[]>([]);
const [rawAudioState, setRawAudioState] = useState<RawAudioState>("idle");
const [rawAudioError, setRawAudioError] = useState("");
const [retentionDays, setRetentionDays] = useState(30);
const [retainAudio, setRetainAudio] = useState(false);
const [consentAcknowledged, setConsentAcknowledged] = useState(false);
const [withdrawalAcknowledged, setWithdrawalAcknowledged] = useState(false);
const [deletionScopes, setDeletionScopes] = useState<DeletionScope[]>(["audio"]);
const [actionState, setActionState] = useState<ActionState>("idle");
const [actionMessage, setActionMessage] = useState("");
const pendingConsentRef = useRef<MultimodalConsentRequest | null>(null);
const pendingConsentSignatureRef = useRef("");
const pendingWithdrawalRef = useRef<{
submission_id: string;
policy_version: string;
reason_code: string;
transcript_retained: true;
} | null>(null);
const pendingDeletionRef = useRef<MultimodalDeletionRequest | null>(null);
const pendingDeletionSignatureRef = useRef("");
useEffect(() => {
const controller = new AbortController();
setLoadState((current) => current === "ready" ? "ready" : "loading");
setLoadError("");
void multimodalAllianceApi.getMetadata(sessionId, controller.signal)
.then((payload) => {
setData(payload);
setLoadState("ready");
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
if (error instanceof ApiError && error.status === 404) {
setData(null);
setLoadState("empty");
return;
}
setLoadState("error");
setLoadError(compactError(error, "멀티모달 동맹 원장을 불러오지 못했습니다."));
});
return () => controller.abort();
}, [reloadSeq, sessionId]);
const latestConsent = useMemo(() => data
? [...data.consent_snapshots].sort((a, b) => a.sequence_no - b.sequence_no).at(-1) ?? null
: null, [data]);
useEffect(() => {
const controller = new AbortController();
if (isSupervisorView || latestConsent?.consent_status !== "granted" || !latestConsent.retain_audio) {
setRawAudio([]);
setRawAudioState("idle");
setRawAudioError("");
return () => controller.abort();
}
setRawAudioState("loading");
setRawAudioError("");
void multimodalAllianceApi.getRawAudio(sessionId, controller.signal)
.then((items) => {
setRawAudio(items);
setRawAudioState("ready");
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setRawAudio([]);
setRawAudioState("error");
setRawAudioError(compactError(error, "원본 음성 보존 상태를 확인하지 못했습니다."));
});
return () => controller.abort();
}, [isSupervisorView, latestConsent, sessionId]);
const latestTimeline = useMemo(() => latestByCreatedAt(data?.timelines ?? []), [data?.timelines]);
const timelineWords = useMemo(() => latestTimeline
? (data?.word_timestamps ?? []).filter((item) => item.timeline_id === latestTimeline.timeline_id)
: [], [data?.word_timestamps, latestTimeline]);
const timelineEvents = useMemo(() => latestTimeline
? (data?.voice_events ?? []).filter((item) => item.timeline_id === latestTimeline.timeline_id)
: [], [data?.voice_events, latestTimeline]);
const selectedEvent = timelineEvents.find((item) => item.event_id === selectedEventId) ?? timelineEvents[0] ?? null;
const measurementsByAxis = useMemo(() => {
const result = new Map<AllianceAxis, { text: MultimodalMeasurement | null; voice: MultimodalMeasurement | null }>();
for (const axis of AXES) {
const axisItems = (data?.measurements ?? []).filter((item) => item.axis === axis);
result.set(axis, {
text: latestByCreatedAt(axisItems.filter((item) => item.modality === "text")),
voice: latestByCreatedAt(axisItems.filter((item) => item.modality === "voice")),
});
}
return result;
}, [data?.measurements]);
const fusionByAxis = useMemo(() => {
const result = new Map<AllianceAxis, MultimodalFusionDecision | null>();
for (const axis of AXES) {
result.set(axis, latestByCreatedAt((data?.fusion_decisions ?? []).filter((item) => item.axis === axis)));
}
return result;
}, [data?.fusion_decisions]);
async function runAction(
action: () => Promise<unknown>,
successMessage: string,
): Promise<boolean> {
setActionState("saving");
setActionMessage("");
try {
await action();
setActionState("success");
setActionMessage(successMessage);
setReloadSeq((value) => value + 1);
return true;
} catch (error) {
setActionState("error");
setActionMessage(compactError(error, "요청을 기록하지 못했습니다. 같은 요청으로 다시 시도할 수 있습니다."));
return false;
}
}
function saveConsent() {
const signature = `${retentionDays}:${retainAudio}`;
if (!pendingConsentRef.current || pendingConsentSignatureRef.current !== signature) {
pendingConsentRef.current = {
submission_id: uuid(),
consent_status: "granted",
retain_audio: retainAudio,
retain_derived_features: true,
transcript_retained: true,
retention_days: retentionDays,
policy_version: POLICY_VERSION,
};
pendingConsentSignatureRef.current = signature;
}
const body = pendingConsentRef.current;
if (!body) return;
void runAction(
() => multimodalAllianceApi.saveConsent(sessionId, body),
"음성 분석 동의를 원장에 기록했습니다.",
).then((succeeded) => {
if (succeeded) {
pendingConsentRef.current = null;
pendingConsentSignatureRef.current = "";
}
});
}
function withdrawConsent() {
pendingWithdrawalRef.current ??= {
submission_id: uuid(),
policy_version: POLICY_VERSION,
reason_code: "learner_withdrawal",
transcript_retained: true,
};
const body = pendingWithdrawalRef.current;
void runAction(
() => multimodalAllianceApi.withdraw(sessionId, body),
"동의를 철회했고 원본 음성·파생 특징 삭제를 요청했습니다.",
).then((succeeded) => {
if (succeeded) pendingWithdrawalRef.current = null;
});
}
function requestDeletion() {
const signature = [...deletionScopes].sort().join(":");
if (!pendingDeletionRef.current || pendingDeletionSignatureRef.current !== signature) {
pendingDeletionRef.current = {
submission_id: uuid(),
scopes: deletionScopes,
};
pendingDeletionSignatureRef.current = signature;
}
const body = pendingDeletionRef.current;
if (!body) return;
void runAction(
() => multimodalAllianceApi.requestDeletion(sessionId, body),
"선택한 데이터의 삭제 요청을 원장에 기록했습니다.",
).then((succeeded) => {
if (succeeded) {
pendingDeletionRef.current = null;
pendingDeletionSignatureRef.current = "";
}
});
}
if (loadState === "loading") {
return (
<Card className="mma-card is-loading" aria-busy="true">
<Kicker dot={false}> </Kicker>
<p> .</p>
</Card>
);
}
if (loadState === "empty") {
return (
<Card className="mma-card is-empty">
<Kicker dot={false}> </Kicker>
<div className="mma-state-copy">
<Icon name="mic-off" size={22} />
<div>
<h2> </h2>
<p> . .</p>
</div>
</div>
</Card>
);
}
if (loadState === "error" || !data) {
return (
<Card className="mma-card is-error">
<Kicker dot={false}> </Kicker>
<div className="mma-state-copy">
<Icon name="alert" size={22} />
<div>
<h2> </h2>
<p>{loadError}</p>
<Button variant="secondary" size="sm" leading={<Icon name="refresh" size={14} />} onClick={() => setReloadSeq((value) => value + 1)}> </Button>
</div>
</div>
</Card>
);
}
const duration = latestTimeline?.audio_duration_ms ?? 0;
const tickPositions = [0, 0.25, 0.5, 0.75, 1];
return (
<Card className="mma-card">
<header className="mma-header">
<div>
<Kicker dot={false}> </Kicker>
<h2> </h2>
<p> , .</p>
</div>
<div className="mma-header__badges">
<Badge tone="info"> </Badge>
<Badge tone="neutral">· </Badge>
</div>
</header>
<section className="mma-clock" aria-labelledby="mma-clock-title">
<div className="mma-section-head">
<div>
<span className="mma-eyebrow">Audio clock · {formatClock(duration)}</span>
<h3 id="mma-clock-title"> </h3>
</div>
{latestTimeline ? <Badge tone={latestTimeline.derived_features_available ? "accent" : "neutral"}>{latestTimeline.derived_features_available ? "파생 특징 사용 가능" : "파생 특징 삭제됨"}</Badge> : null}
</div>
<p id="mma-clock-desc" className="mma-clock__privacy-note">
. ·· .
</p>
{latestTimeline && latestTimeline.derived_features_available ? (
<div className="mma-clock__viewport" tabIndex={0} aria-describedby="mma-clock-desc">
<div className="mma-clock__canvas">
<div className="mma-clock__ticks" aria-hidden="true">
{tickPositions.map((ratio) => <span key={ratio} style={{ left: `${ratio * 100}%` }}>{formatClock(duration * ratio)}</span>)}
</div>
<div className="mma-clock__lane">
<span className="mma-clock__lane-label"> </span>
<div className="mma-clock__track">
{timelineWords.filter((word) => word.speaker === "client").map((word) => (
<span key={`${word.timeline_id}-${word.word_index}`} role="img" className="mma-word is-client" style={{ left: `${position(word.start_ms, duration)}%`, width: `${segmentWidth(word.start_ms, word.end_ms, duration)}%` }} title={`내담자 익명 자막 ${word.word_index + 1} · ${formatClock(word.start_ms)}`} aria-label={`내담자 익명 자막 토큰 ${word.word_index + 1}, ${formatClock(word.start_ms)}부터 ${formatClock(word.end_ms)}까지`} />
))}
</div>
</div>
<div className="mma-clock__lane">
<span className="mma-clock__lane-label"> </span>
<div className="mma-clock__track">
{timelineWords.filter((word) => word.speaker === "learner").map((word) => (
<span key={`${word.timeline_id}-${word.word_index}`} role="img" className="mma-word is-learner" style={{ left: `${position(word.start_ms, duration)}%`, width: `${segmentWidth(word.start_ms, word.end_ms, duration)}%` }} title={`학습자 익명 자막 ${word.word_index + 1} · ${formatClock(word.start_ms)}`} aria-label={`학습자 익명 자막 토큰 ${word.word_index + 1}, ${formatClock(word.start_ms)}부터 ${formatClock(word.end_ms)}까지`} />
))}
</div>
</div>
<div className="mma-clock__lane mma-clock__lane--events">
<span className="mma-clock__lane-label"> </span>
<div className="mma-clock__track" role="group" aria-label="시간 정렬 음성 관찰 이벤트">
{timelineEvents.map((event) => (
<button key={event.event_id} type="button" className={`mma-event is-${event.event_type} ${selectedEvent?.event_id === event.event_id ? "is-selected" : ""}`} style={{ left: `${position(event.start_ms, duration)}%`, width: `${segmentWidth(event.start_ms, event.end_ms, duration)}%` }} aria-label={`${formatClock(event.start_ms)} ${EVENT_LABEL[event.event_type]}, ${ACTOR_LABEL[event.actor]}`} aria-pressed={selectedEvent?.event_id === event.event_id} onClick={() => setSelectedEventId(event.event_id)}>
<span>{EVENT_LABEL[event.event_type]}</span>
</button>
))}
</div>
</div>
{selectedEvent ? <span className="mma-playhead" aria-hidden="true" style={{ left: `calc(108px + (100% - 120px) * ${position(selectedEvent.start_ms, duration) / 100})` }} /> : null}
</div>
</div>
) : (
<div className="mma-clock__empty">
<Icon name="mic-off" size={20} />
<p>{latestTimeline ? "삭제 증명에 따라 자막 시간과 음성 파생 특징을 표시하지 않습니다." : "정렬된 오디오 시계가 아직 생성되지 않았습니다."}</p>
</div>
)}
{selectedEvent ? (
<article className="mma-event-inspector" aria-live="polite">
<div>
<span>{formatClock(selectedEvent.start_ms)}{formatClock(selectedEvent.end_ms)}</span>
<h4>{EVENT_LABEL[selectedEvent.event_type]} · {ACTOR_LABEL[selectedEvent.actor]}</h4>
</div>
<p>{safeObservedFeature(selectedEvent.observed_feature)}</p>
<small> {formatPercent(selectedEvent.uncertainty)} · · </small>
</article>
) : null}
</section>
<section className="mma-axis-board" aria-labelledby="mma-axis-title">
<div className="mma-section-head">
<div>
<span className="mma-eyebrow">Independent calibrated</span>
<h3 id="mma-axis-title"> </h3>
</div>
<span className="mma-no-total"> </span>
</div>
<div className="mma-axis-board__head" aria-hidden="true"><span> </span><span> </span><span></span></div>
{AXES.map((axis) => {
const items = measurementsByAxis.get(axis) ?? { text: null, voice: null };
return (
<article className="mma-axis" key={axis}>
<div className="mma-axis__title"><span>{axis.toUpperCase()}</span><h4>{AXIS_LABEL[axis]}</h4></div>
<div className="mma-axis__modalities"><MeasurementCell item={items.text} modality="text" /><MeasurementCell item={items.voice} modality="voice" /></div>
<FusionCell decision={fusionByAxis.get(axis) ?? null} />
</article>
);
})}
</section>
<PrivacyLedger sessionId={sessionId} data={data} isSupervisorView={isSupervisorView} rawAudio={rawAudio} rawAudioState={rawAudioState} rawAudioError={rawAudioError} selectedScene={selectedEvent} retentionDays={retentionDays} setRetentionDays={setRetentionDays} retainAudio={retainAudio} setRetainAudio={setRetainAudio} consentAcknowledged={consentAcknowledged} setConsentAcknowledged={setConsentAcknowledged} withdrawalAcknowledged={withdrawalAcknowledged} setWithdrawalAcknowledged={setWithdrawalAcknowledged} deletionScopes={deletionScopes} setDeletionScopes={setDeletionScopes} actionState={actionState} actionMessage={actionMessage} onConsent={saveConsent} onWithdraw={withdrawConsent} onDelete={requestDeletion} />
{!isSupervisorView ? (
<footer className="mma-practice-cta">
<div><Icon name="mic" size={19} /><div><strong> </strong><p> , .</p></div></div>
<Button
variant="secondary"
size="sm"
trailing={<Icon name="chevron-right" size={15} />}
onClick={() => {
const params = new URLSearchParams({ mode: "voice", source_session: sessionId });
if (selectedEvent) {
params.set("source_scene", selectedEvent.event_id);
params.set("scene_type", selectedEvent.event_type);
params.set("scene_start_ms", String(selectedEvent.start_ms));
params.set("scene_end_ms", String(selectedEvent.end_ms));
}
navigate(`/learn/practice?${params.toString()}`);
}}
>
</Button>
</footer>
) : null}
</Card>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,806 @@
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type FormEvent,
} from "react";
import { Badge, Button, Card, Kicker } from "../../components/ui";
import { ApiError } from "../../lib/api";
import {
ruptureRepairApi,
type HumanRuptureCorrectionRequest,
type RuptureEpisode,
type RuptureObservation,
type RuptureReconciliation,
type RuptureRepairReadModel,
type RuptureSafetyReference,
} from "./ruptureRepairApi";
import "./rupture-repair.css";
import { randomUuid } from "../../lib/uuid";
interface RuptureTurn {
id: string;
turn_id?: string | null;
ts: string;
speaker: string;
who: string;
text: string;
}
interface RuptureRepairCardProps {
sessionId: string;
turns: RuptureTurn[];
isSupervisorView: boolean;
onJumpToTurn: (turnId: string) => void;
}
type LoadState = "loading" | "ready" | "empty" | "error";
type RuptureType = NonNullable<RuptureEpisode["rupture_type"]>;
type RuptureStatus = NonNullable<RuptureEpisode["current_status"]>;
type CorrectionStatus = HumanRuptureCorrectionRequest["corrected_status"];
type SubmissionState = "idle" | "submitting" | "success" | "error";
const RUPTURE_TYPE_COPY: Record<RuptureType, string> = {
withdrawal: "철수형 균열",
confrontation: "대립형 균열",
goal_mismatch: "목표 불일치",
task_mismatch: "과업 불일치",
empathic_miss: "공감 놓침",
cultural_miss: "문화적 맥락 놓침",
boundary_tension: "경계 긴장",
premature_advice: "이른 조언",
over_disclosure: "과도한 자기개방",
};
const STATUS_COPY: Record<
RuptureStatus,
{ label: string; tone: "neutral" | "pos" | "warn" | "info" }
> = {
onset: { label: "균열 시작", tone: "warn" },
recognized: { label: "균열 인식", tone: "info" },
repair_attempted: { label: "수선 시도", tone: "info" },
missed: { label: "수선 놓침", tone: "warn" },
partial: { label: "부분 수선", tone: "warn" },
resolved: { label: "수선 확인", tone: "pos" },
not_applicable: { label: "판정 제외", tone: "neutral" },
insufficient_evidence: { label: "근거 부족", tone: "neutral" },
};
const SOURCE_COPY: Record<RuptureEpisode["status_source"], string> = {
lifecycle_event: "관찰 이력",
deep_reconciliation: "깊은 재조정",
human_correction: "사람 판정",
};
const DISPOSITION_COPY: Record<RuptureReconciliation["disposition"], string> = {
confirmed: "빠른 경고 확인",
superseded_resolved: "수선 확인으로 갱신",
superseded_partial: "부분 수선으로 갱신",
dismissed: "경고 기각",
};
const EVENT_COPY: Record<RuptureObservation["event_kind"], string> = {
"rupture.detected": "균열 탐지",
"rupture.recognized": "균열 인식",
"rupture.missed": "균열 놓침",
"repair.attempted": "수선 시도",
"repair.partial": "부분 수선",
"repair.resolved": "수선 확인",
"repair.missed": "수선 놓침",
"human.corrected": "사람 판정",
};
const PRACTICE_ITEMS = [
"균열이 시작된 발화를 다시 확인하기",
"수선 문장을 한 문장으로 써 보기",
"내담자 반응까지 확인할 기준 정하기",
] as const;
function formatPercent(value: number | null | undefined): string {
if (value == null) return "자료 없음";
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
}
function compactId(value: string): string {
return value.length > 18 ? `${value.slice(0, 8)}${value.slice(-6)}` : value;
}
function latestObservation(episode: RuptureEpisode): RuptureObservation | null {
return [...(episode.observations ?? [])].sort(
(a, b) => b.sequence_no - a.sequence_no,
)[0] ?? null;
}
function latestReconciliation(
episode: RuptureEpisode,
): RuptureReconciliation | null {
return [...(episode.reconciliation_revisions ?? [])].sort(
(a, b) => b.revision_no - a.revision_no,
)[0] ?? null;
}
function turnForUuid(uuid: string, turns: RuptureTurn[]): RuptureTurn | null {
return turns.find((turn) => turn.turn_id === uuid) ?? null;
}
function EvidenceList({
refs,
turns,
onJumpToTurn,
emptyCopy = "연결된 발화 근거가 없습니다.",
}: {
refs: string[];
turns: RuptureTurn[];
onJumpToTurn: (turnId: string) => void;
emptyCopy?: string;
}) {
const uniqueRefs = [...new Set(refs)];
if (uniqueRefs.length === 0) {
return <p className="rr-muted">{emptyCopy}</p>;
}
return (
<ul className="rr-evidence-list">
{uniqueRefs.map((ref) => {
const turn = turnForUuid(ref, turns);
return (
<li key={ref}>
{turn ? (
<button
type="button"
onClick={() => onJumpToTurn(turn.id)}
aria-label={`${turn.ts} ${turn.who} 발화로 이동`}
>
<span>{turn.ts} · {turn.who}</span>
<small>{turn.text}</small>
</button>
) : (
<code title={ref}>{compactId(ref)}</code>
)}
</li>
);
})}
</ul>
);
}
function PracticeChecklist({
sessionId,
episodeId,
}: {
sessionId: string;
episodeId: string;
}) {
const storageKey = `vignette:rupture-practice:${sessionId}:${episodeId}`;
const [checked, setChecked] = useState<boolean[]>(() =>
PRACTICE_ITEMS.map(() => false),
);
useEffect(() => {
try {
const stored = window.localStorage.getItem(storageKey);
if (!stored) return;
const parsed = JSON.parse(stored) as unknown;
if (Array.isArray(parsed)) {
setChecked(PRACTICE_ITEMS.map((_, index) => parsed[index] === true));
}
} catch {
// 손상된 개인 로컬 상태는 기본값으로 안전하게 복구한다.
}
}, [storageKey]);
const completed = checked.filter(Boolean).length;
const toggle = (index: number) => {
setChecked((current) => {
const next = current.map((value, currentIndex) =>
currentIndex === index ? !value : value,
);
try {
window.localStorage.setItem(storageKey, JSON.stringify(next));
} catch {
// 저장소 제한이 있어도 현재 탭의 체크 동작은 유지한다.
}
return next;
});
};
return (
<fieldset className="rr-practice">
<legend> </legend>
<div className="rr-practice__head">
<p> , , .</p>
<Badge tone={completed === PRACTICE_ITEMS.length ? "pos" : "neutral"}>
{completed}/{PRACTICE_ITEMS.length}
</Badge>
</div>
<div className="rr-practice__items">
{PRACTICE_ITEMS.map((item, index) => (
<label key={item}>
<input
type="checkbox"
checked={checked[index]}
onChange={() => toggle(index)}
/>
<span>{item}</span>
</label>
))}
</div>
</fieldset>
);
}
function SafetyLedger({
episodeId,
items,
turns,
onJumpToTurn,
}: {
episodeId: string;
items: RuptureSafetyReference[];
turns: RuptureTurn[];
onJumpToTurn: (turnId: string) => void;
}) {
const titleId = `rr-safety-${episodeId}`;
return (
<section className="rr-safety" aria-labelledby={titleId}>
<div className="rr-safety__head">
<div>
<h4 id={titleId}> </h4>
<p>· .</p>
</div>
<Badge tone={items.some((item) => item.escalated) ? "crit" : "neutral"}>
{items.length > 0 ? `${items.length}건 연결` : "연결 없음"}
</Badge>
</div>
{items.length > 0 ? (
<ul className="rr-safety__items">
{items.map((item) => {
const turn = item.turn_id ? turnForUuid(item.turn_id, turns) : null;
return (
<li key={item.safety_event_id}>
<div>
<strong> #{item.safety_event_id}</strong>
<span>
{item.ko_risk_level == null
? "위험 수준 미기록"
: `K-O 위험 수준 ${item.ko_risk_level}`}
</span>
</div>
<Badge tone={item.escalated ? "crit" : "neutral"}>
{item.escalated ? "상향 확인" : "별도 기록"}
</Badge>
{turn ? (
<button type="button" onClick={() => onJumpToTurn(turn.id)}>
</button>
) : item.turn_id ? (
<code title={item.turn_id}>{compactId(item.turn_id)}</code>
) : null}
</li>
);
})}
</ul>
) : (
<p className="rr-muted"> .</p>
)}
</section>
);
}
function CorrectionForm({
sessionId,
episode,
turns,
onRefresh,
}: {
sessionId: string;
episode: RuptureEpisode;
turns: RuptureTurn[];
onRefresh: () => Promise<void>;
}) {
const formId = useId();
const latest = latestObservation(episode);
const availableTurns = useMemo(
() => turns.filter((turn): turn is RuptureTurn & { turn_id: string } => Boolean(turn.turn_id)),
[turns],
);
const currentEvidence = latest?.evidence_turn_ids ?? [];
const defaultEvidence = currentEvidence.filter((uuid) =>
availableTurns.some((turn) => turn.turn_id === uuid),
);
const initialEvidence = defaultEvidence.length > 0
? defaultEvidence
: availableTurns[0]?.turn_id
? [availableTurns[0].turn_id]
: [];
const initialStatus: CorrectionStatus =
episode.current_status === "missed" ||
episode.current_status === "partial" ||
episode.current_status === "resolved"
? episode.current_status
: "partial";
const [correctedStatus, setCorrectedStatus] =
useState<CorrectionStatus>(initialStatus);
const [uncertainty, setUncertainty] = useState(
latest?.uncertainty ?? latestReconciliation(episode)?.uncertainty ?? 0.25,
);
const [evidenceIds, setEvidenceIds] = useState<string[]>(initialEvidence);
const [counterevidence, setCounterevidence] = useState(
(latest?.counterevidence ?? []).join("\n"),
);
const [reason, setReason] = useState("");
const [submissionState, setSubmissionState] =
useState<SubmissionState>("idle");
const [message, setMessage] = useState("");
const idempotencyKey = useRef(randomUuid());
const latestObservationId = useRef(latest?.observation_id ?? null);
useEffect(() => {
if (latestObservationId.current === (latest?.observation_id ?? null)) return;
latestObservationId.current = latest?.observation_id ?? null;
idempotencyKey.current = randomUuid();
setReason("");
}, [latest?.observation_id]);
const toggleEvidence = (uuid: string) => {
setEvidenceIds((current) =>
current.includes(uuid)
? current.filter((item) => item !== uuid)
: [...current, uuid],
);
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (
submissionState === "submitting" ||
!latest ||
!episode.rupture_type ||
evidenceIds.length === 0 ||
!reason.trim()
) {
return;
}
setSubmissionState("submitting");
setMessage("");
const body: HumanRuptureCorrectionRequest = {
idempotency_key: idempotencyKey.current,
supersedes_observation_id: latest.observation_id,
rupture_type: episode.rupture_type,
corrected_status: correctedStatus,
uncertainty,
evidence_turn_ids: evidenceIds,
counterevidence: counterevidence
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
correction_reason: reason.trim(),
};
try {
await ruptureRepairApi.correct(sessionId, episode.episode_id, body);
await onRefresh();
setSubmissionState("success");
setMessage("사람 판정을 새 관찰로 추가하고 최신 원장을 다시 불러왔습니다.");
} catch (error) {
setSubmissionState("error");
setMessage(
error instanceof Error ? error.message : "정정을 저장하지 못했습니다.",
);
}
};
const cannotCorrect = !latest || !episode.rupture_type || availableTurns.length === 0;
return (
<details className="rr-correction">
<summary> </summary>
{cannotCorrect ? (
<p className="rr-muted">
, , UUID .
</p>
) : (
<form onSubmit={submit} aria-labelledby={`${formId}-title`}>
<div className="rr-correction__intro">
<div>
<h4 id={`${formId}-title`}> </h4>
<p> , .</p>
</div>
<code title={latest.observation_id}>{compactId(latest.observation_id)}</code>
</div>
<div className="rr-correction__grid">
<label>
<span> </span>
<select
value={correctedStatus}
onChange={(event) =>
setCorrectedStatus(event.currentTarget.value as CorrectionStatus)
}
>
<option value="missed"> </option>
<option value="partial"> </option>
<option value="resolved"> </option>
</select>
</label>
<label>
<span> {formatPercent(uncertainty)}</span>
<input
type="range"
min="0"
max="1"
step="0.05"
value={uncertainty}
onChange={(event) => setUncertainty(Number(event.currentTarget.value))}
/>
</label>
</div>
<fieldset className="rr-correction__evidence">
<legend> </legend>
{availableTurns.map((turn) => (
<label key={turn.turn_id}>
<input
type="checkbox"
checked={evidenceIds.includes(turn.turn_id)}
onChange={() => toggleEvidence(turn.turn_id)}
/>
<span>
<b>{turn.ts} · {turn.who}</b>
<small>{turn.text}</small>
</span>
</label>
))}
</fieldset>
<label className="rr-correction__field">
<span> </span>
<textarea
rows={3}
value={counterevidence}
onChange={(event) => setCounterevidence(event.currentTarget.value)}
placeholder="한 줄에 하나씩 입력"
/>
</label>
<label className="rr-correction__field">
<span> </span>
<textarea
rows={3}
required
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
placeholder="근거와 판정이 달라진 이유를 기록"
/>
</label>
<div className="rr-correction__actions">
<Button
type="submit"
size="sm"
disabled={
submissionState === "submitting" ||
evidenceIds.length === 0 ||
!reason.trim()
}
>
{submissionState === "submitting"
? "정정 추가 중"
: submissionState === "error"
? "같은 요청 다시 제출"
: "정정 기록 추가"}
</Button>
<span
className={`rr-correction__message rr-correction__message--${submissionState}`}
role={submissionState === "error" ? "alert" : "status"}
aria-live="polite"
>
{message}
</span>
</div>
</form>
)}
</details>
);
}
function ProvenanceRail({
episode,
reconciliation,
}: {
episode: RuptureEpisode;
reconciliation: RuptureReconciliation | null;
}) {
const latest = latestObservation(episode);
const humanCorrection = [...(episode.observations ?? [])]
.reverse()
.find((observation) => observation.event_kind === "human.corrected");
return (
<section className="rr-provenance" aria-label="빠른 경고와 깊은 재조정 출처">
<div className="rr-provenance__node">
<span className="rr-provenance__step">1</span>
<div>
<small>FAST WARNING</small>
<strong> </strong>
<p>
{reconciliation
? `${STATUS_COPY[reconciliation.provisional_status].label} · ${reconciliation.fast_warning_id}`
: latest
? `${EVENT_COPY[latest.event_kind]} 관찰에서 시작`
: "빠른 관찰 대기"}
</p>
</div>
</div>
<span className="rr-provenance__connector" aria-hidden="true"></span>
<div className="rr-provenance__node">
<span className="rr-provenance__step">2</span>
<div>
<small>DEEP RECONCILIATION</small>
<strong> </strong>
<p>
{reconciliation
? `${DISPOSITION_COPY[reconciliation.disposition]} · ${STATUS_COPY[reconciliation.deep_status].label}`
: "깊은 재조정 기록 대기"}
</p>
</div>
{reconciliation ? (
<code title={reconciliation.model_run_id}>
run {compactId(reconciliation.model_run_id)}
</code>
) : null}
</div>
{humanCorrection ? (
<div className="rr-provenance__human">
<Badge tone="info"> </Badge>
<span>{humanCorrection.correction_reason}</span>
</div>
) : null}
</section>
);
}
function EpisodePanel({
sessionId,
episode,
turns,
isSupervisorView,
onJumpToTurn,
onRefresh,
}: {
sessionId: string;
episode: RuptureEpisode;
turns: RuptureTurn[];
isSupervisorView: boolean;
onJumpToTurn: (turnId: string) => void;
onRefresh: () => Promise<void>;
}) {
const observation = latestObservation(episode);
const reconciliation = latestReconciliation(episode);
const currentRefs = episode.status_source === "deep_reconciliation"
? reconciliation?.evidence_turn_ids ?? []
: observation?.evidence_turn_ids ?? [];
const currentCounterevidence = episode.status_source === "deep_reconciliation"
? reconciliation?.counterevidence ?? []
: observation?.counterevidence ?? [];
const uncertainty = episode.status_source === "deep_reconciliation"
? reconciliation?.uncertainty
: observation?.uncertainty;
const status = episode.current_status;
return (
<article className="rr-episode" aria-labelledby={`rr-episode-${episode.episode_id}`}>
<header className="rr-episode__head">
<div>
<span className="rr-episode__eyebrow"> · {episode.episode_key}</span>
<h3 id={`rr-episode-${episode.episode_id}`}>
{episode.rupture_type
? RUPTURE_TYPE_COPY[episode.rupture_type]
: "유형 판정 대기"}
</h3>
</div>
<div className="rr-episode__badges">
<Badge tone={status ? STATUS_COPY[status].tone : "neutral"}>
{status ? STATUS_COPY[status].label : "상태 대기"}
</Badge>
<Badge tone="neutral">{SOURCE_COPY[episode.status_source]}</Badge>
</div>
</header>
<div className="rr-current">
<div>
<small> </small>
<strong>{status ? STATUS_COPY[status].label : "판정 대기"}</strong>
</div>
<div>
<small></small>
<strong>{formatPercent(uncertainty)}</strong>
</div>
<div>
<small> </small>
<strong>{SOURCE_COPY[episode.status_source]}</strong>
</div>
</div>
<ProvenanceRail episode={episode} reconciliation={reconciliation} />
<div className="rr-evidence-grid">
<section>
<div className="rr-section-head">
<h4> </h4>
<span>{currentRefs.length} </span>
</div>
<EvidenceList
refs={currentRefs}
turns={turns}
onJumpToTurn={onJumpToTurn}
/>
</section>
<section>
<div className="rr-section-head">
<h4> </h4>
<span>{currentCounterevidence.length} </span>
</div>
{currentCounterevidence.length > 0 ? (
<ul className="rr-counterevidence">
{currentCounterevidence.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
</ul>
) : (
<p className="rr-muted"> .</p>
)}
</section>
</div>
<SafetyLedger
episodeId={episode.episode_id}
items={episode.safety_references ?? []}
turns={turns}
onJumpToTurn={onJumpToTurn}
/>
{!isSupervisorView ? (
<PracticeChecklist sessionId={sessionId} episodeId={episode.episode_id} />
) : null}
{isSupervisorView ? (
<CorrectionForm
sessionId={sessionId}
episode={episode}
turns={turns}
onRefresh={onRefresh}
/>
) : null}
</article>
);
}
function LoadingCard() {
return (
<Card className="rr-card rr-card--loading" aria-busy="true" aria-label="균열과 수선 원장 불러오는 중">
<div className="rr-skeleton rr-skeleton--heading" />
<div className="rr-skeleton rr-skeleton--rail" />
<div className="rr-skeleton-grid">
<div className="rr-skeleton" />
<div className="rr-skeleton" />
</div>
</Card>
);
}
function EmptyCard({ isSupervisorView }: { isSupervisorView: boolean }) {
return (
<Card className="rr-card rr-card--state" aria-labelledby="rr-empty-title">
<Kicker dot={false}> </Kicker>
<h2 id="rr-empty-title"> </h2>
<p>
{isSupervisorView
? "역할에 허용된 균열·수선 관찰이 생기면 근거와 재조정 이력을 여기서 검토할 수 있습니다."
: "회기에서 관계 균열과 수선 근거가 확인되면, 다시 연습할 장면을 여기서 준비할 수 있습니다."}
</p>
</Card>
);
}
function ErrorCard({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<Card className="rr-card rr-card--state rr-card--error" role="alert">
<Kicker dot={false}> </Kicker>
<h2> </h2>
<p>{message}</p>
<div>
<Button variant="secondary" size="sm" onClick={onRetry}>
</Button>
</div>
</Card>
);
}
export function RuptureRepairCard({
sessionId,
turns,
isSupervisorView,
onJumpToTurn,
}: RuptureRepairCardProps) {
const [loadState, setLoadState] = useState<LoadState>("loading");
const [data, setData] = useState<RuptureRepairReadModel | null>(null);
const [error, setError] = useState("");
const load = useCallback(
async (signal?: AbortSignal, quiet = false) => {
if (!quiet) setLoadState("loading");
setError("");
try {
const next = await ruptureRepairApi.get(sessionId, signal);
setData(next);
setLoadState(next.episodes.length > 0 ? "ready" : "empty");
} catch (loadError) {
if (signal?.aborted) return;
if (loadError instanceof ApiError && loadError.status === 404) {
setData(null);
setLoadState("empty");
return;
}
const message =
loadError instanceof Error
? loadError.message
: "관계 장면 원장을 불러오지 못했습니다.";
setError(message);
if (!quiet) setLoadState("error");
throw loadError;
}
},
[sessionId],
);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal).catch(() => undefined);
return () => controller.abort();
}, [load]);
if (loadState === "loading") return <LoadingCard />;
if (loadState === "empty") return <EmptyCard isSupervisorView={isSupervisorView} />;
if (loadState === "error") {
return <ErrorCard message={error} onRetry={() => void load().catch(() => undefined)} />;
}
if (!data) return null;
return (
<Card className="rr-card" aria-labelledby="rr-card-title">
<header className="rr-card__head">
<div>
<Kicker dot={false}> </Kicker>
<h2 id="rr-card-title"> </h2>
<p>
, ··· .
</p>
</div>
<div className="rr-card__flags">
<Badge tone="info">
{data.requested_view === "supervisor" ? "교수자 역할 보기" : "학습자 역할 보기"}
</Badge>
<Badge tone="neutral"> </Badge>
</div>
</header>
<div className="rr-contract">
<strong> </strong>
<p> , .</p>
<code>clinical_claim_allowed: {String(data.clinical_claim_allowed)}</code>
</div>
<div className="rr-episodes">
{data.episodes.map((episode) => (
<EpisodePanel
key={episode.episode_id}
sessionId={sessionId}
episode={episode}
turns={turns}
isSupervisorView={isSupervisorView}
onJumpToTurn={onJumpToTurn}
onRefresh={() => load(undefined, true)}
/>
))}
</div>
</Card>
);
}