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

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