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:
parent
76d0b9ae9b
commit
93dd8f82d7
22 changed files with 10057 additions and 473 deletions
40
apps/web/src/lib/uuid.ts
Normal file
40
apps/web/src/lib/uuid.ts
Normal 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
920
apps/web/src/pages/admin/ContinuousImprovementCockpit.tsx
Normal file
920
apps/web/src/pages/admin/ContinuousImprovementCockpit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1231
apps/web/src/pages/session-review/CalibrationTransferCard.tsx
Normal file
1231
apps/web/src/pages/session-review/CalibrationTransferCard.tsx
Normal file
File diff suppressed because it is too large
Load diff
1514
apps/web/src/pages/session-review/DeliberatePracticeCard.tsx
Normal file
1514
apps/web/src/pages/session-review/DeliberatePracticeCard.tsx
Normal file
File diff suppressed because it is too large
Load diff
859
apps/web/src/pages/session-review/MultimodalAllianceCard.tsx
Normal file
859
apps/web/src/pages/session-review/MultimodalAllianceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1022
apps/web/src/pages/session-review/OutcomeTrajectoryCard.tsx
Normal file
1022
apps/web/src/pages/session-review/OutcomeTrajectoryCard.tsx
Normal file
File diff suppressed because it is too large
Load diff
806
apps/web/src/pages/session-review/RuptureRepairCard.tsx
Normal file
806
apps/web/src/pages/session-review/RuptureRepairCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue