G0~G8 성과·동맹 측정 OS 작업 일괄 고정

8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -0,0 +1,709 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Badge, Button, Card, Icon, Kicker } from "../../components/ui";
import { ApiError } from "../../lib/api";
import {
alliancePulseApi,
type AllianceDimension,
type AllianceMeasurement,
type AlliancePerspective,
type AlliancePulse,
type AllianceScores,
} from "./alliancePulseApi";
import "./alliance-pulse.css";
interface PulseTurn {
id: string;
ts: string;
speaker: string;
who: string;
text: string;
}
interface AlliancePulseCardProps {
sessionId: string;
turns: PulseTurn[];
isSupervisorView: boolean;
onJumpToTurn: (turnId: string) => void;
}
type ScoreDraft = Record<AllianceDimension, number | null>;
type LoadState = "loading" | "ready" | "error";
const DIMENSIONS: AllianceDimension[] = ["goal", "task", "bond"];
const DIMENSION_COPY: Record<
AllianceDimension,
{ label: string; short: string; prompt: string }
> = {
goal: {
label: "목표 합의",
short: "목표",
prompt: "이번 회기에서 무엇을 다루려는지 서로 이해했나요?",
},
task: {
label: "과업 합의",
short: "과업",
prompt: "목표를 위해 사용한 질문과 활동이 도움이 됐나요?",
},
bond: {
label: "정서적 유대",
short: "유대",
prompt: "내담자가 존중받고 안전하다고 느꼈을까요?",
},
};
const CORE_PERSPECTIVES: AlliancePerspective[] = [
"learner_self_report",
"client_agent_report",
"independent_observer",
];
const PERSPECTIVE_COPY: Record<
AlliancePerspective,
{ label: string; provenance: string }
> = {
learner_self_report: { label: "내 판단", provenance: "잠긴 자기평가" },
client_agent_report: { label: "내담자 관점", provenance: "AI 역할 추론" },
independent_observer: { label: "관찰자 관점", provenance: "AI 축어록 추론" },
supervisor_human: { label: "교수자 판정", provenance: "교수자 근거 판정" },
};
const SCALE = [
{ value: 0, number: "1", label: "전혀 아니다" },
{ value: 0.25, number: "2", label: "조금 아니다" },
{ value: 0.5, number: "3", label: "보통이다" },
{ value: 0.75, number: "4", label: "대체로 그렇다" },
{ value: 1, number: "5", label: "매우 그렇다" },
] as const;
const EMPTY_DRAFT: ScoreDraft = { goal: null, task: null, bond: null };
const POLL_INTERVAL_MS = 2_000;
function scoreText(value: number | null): string {
if (value == null || !Number.isFinite(value)) return "근거 없음";
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}`;
}
function scoreDescriptor(value: number | null): string {
if (value == null || !Number.isFinite(value)) return "판단 불가";
if (value >= 0.8) return "강하게 형성됨";
if (value >= 0.6) return "대체로 형성됨";
if (value >= 0.4) return "혼재함";
if (value >= 0.2) return "약한 편";
return "거의 형성되지 않음";
}
function pulseStatusCopy(pulse: AlliancePulse): {
label: string;
tone: "neutral" | "accent" | "warn" | "crit" | "info";
body: string;
} {
if (pulse.status === "awaiting_agents" || pulse.status === "processing") {
return {
label: "관점 분석 중",
tone: "info",
body: "자기평가는 잠겼습니다. 내담자와 관찰자 관점이 모두 준비된 뒤 함께 공개합니다.",
};
}
if (pulse.status === "degraded") {
return {
label: "일부 근거 부족",
tone: "warn",
body: "확인 가능한 관점만 표시합니다. 생성되지 않은 점수를 임의로 채우지 않습니다.",
};
}
if (pulse.status === "error" || pulse.error_code) {
return {
label: "분석 보류",
tone: "crit",
body: "자기평가는 보존됐지만 AI 관점은 준비되지 않았습니다. 점수를 추정해 표시하지 않습니다.",
};
}
return {
label: "관점 비교 준비됨",
tone: "accent",
body: "세 축을 평균내지 않고 관점별 차이와 발화 근거를 확인합니다.",
};
}
function isCompleteDraft(draft: ScoreDraft): draft is AllianceScores {
return DIMENSIONS.every((dimension) => draft[dimension] != null);
}
function latestPostPulse(items: AlliancePulse[]): AlliancePulse | null {
const postItems = items.filter((item) => item.checkpoint === "post");
return postItems.length > 0 ? postItems[postItems.length - 1] : null;
}
function selectedMeasurement(
pulse: AlliancePulse,
dimension: AllianceDimension,
perspective: AlliancePerspective,
): AllianceMeasurement | null {
const candidates = pulse.measurements.filter(
(item) => item.dimension === dimension && item.perspective === perspective,
);
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
}
function AxisAssessmentControl({
dimension,
value,
onChange,
disabled = false,
prefix,
}: {
dimension: AllianceDimension;
value: number | null;
onChange: (value: number) => void;
disabled?: boolean;
prefix: string;
}) {
const copy = DIMENSION_COPY[dimension];
return (
<fieldset className="ap-axis" disabled={disabled}>
<legend>
<span>{copy.label}</span>
<small>{copy.prompt}</small>
</legend>
<div className="ap-scale" role="radiogroup" aria-label={`${copy.label} 평가`}>
{SCALE.map((option) => (
<label
key={option.value}
className={`ap-scale__option ${value === option.value ? "is-selected" : ""}`}
>
<input
type="radio"
name={`${prefix}-${dimension}`}
value={option.value}
aria-label={`${option.number} ${option.label}`}
checked={value === option.value}
onChange={() => onChange(option.value)}
/>
<span aria-hidden="true">{option.number}</span>
<small>{option.label}</small>
</label>
))}
</div>
</fieldset>
);
}
function EvidencePicker({
turns,
selected,
onChange,
scope,
}: {
turns: PulseTurn[];
selected: string[];
onChange: (turnIds: string[]) => void;
scope: "learner" | "supervisor";
}) {
const eligible = useMemo(() => turns.slice(-12), [turns]);
const summaryLabel =
scope === "learner" ? "내 판단의 근거 장면 선택" : "교수자 판정 근거 장면 선택";
const toggle = (turnId: string) => {
onChange(
selected.includes(turnId)
? selected.filter((item) => item !== turnId)
: [...selected, turnId],
);
};
return (
<details className="ap-evidence-picker">
<summary>
{summaryLabel}
<span>{selected.length} </span>
</summary>
{eligible.length > 0 ? (
<div className="ap-evidence-picker__list">
{eligible.map((turn) => (
<label key={turn.id}>
<input
type="checkbox"
checked={selected.includes(turn.id)}
onChange={() => toggle(turn.id)}
/>
<span className="ap-evidence-picker__time">{turn.ts}</span>
<span>
<b>{turn.who}</b>
{turn.text}
</span>
</label>
))}
</div>
) : (
<p> .</p>
)}
</details>
);
}
function MeasurementEvidence({
measurement,
onJumpToTurn,
}: {
measurement: AllianceMeasurement;
onJumpToTurn: (turnId: string) => void;
}) {
const evidence = measurement.evidence ?? [];
if (!measurement.rationale && evidence.length === 0) return null;
return (
<details className="ap-measurement-evidence">
<summary> {evidence.length}</summary>
{measurement.rationale ? <p>{measurement.rationale}</p> : null}
{evidence.length > 0 ? (
<div className="ap-measurement-evidence__turns">
{evidence.map((item) => (
<button
type="button"
key={`${measurement.measurement_id}-${item.turn_id}-${item.seq}`}
onClick={() => onJumpToTurn(item.turn_id)}
>
<span>{item.seq} </span>
{item.text}
</button>
))}
</div>
) : null}
</details>
);
}
function PerspectiveCell({
pulse,
dimension,
perspective,
onJumpToTurn,
}: {
pulse: AlliancePulse;
dimension: AllianceDimension;
perspective: AlliancePerspective;
onJumpToTurn: (turnId: string) => void;
}) {
const measurement =
perspective === "learner_self_report"
? null
: selectedMeasurement(pulse, dimension, perspective);
const value =
perspective === "learner_self_report"
? (pulse.self_scores?.[dimension] ?? null)
: (measurement?.value ?? null);
const failed = measurement != null && measurement.status !== "ready";
return (
<div
className={`ap-perspective-cell ${failed || value == null ? "is-unavailable" : ""}`}
>
<span className="ap-perspective-cell__label">
<b>{PERSPECTIVE_COPY[perspective].label}</b>
<small>{PERSPECTIVE_COPY[perspective].provenance}</small>
</span>
<strong>{scoreText(value)}</strong>
<span>{scoreDescriptor(value)}</span>
{measurement ? (
<MeasurementEvidence measurement={measurement} onJumpToTurn={onJumpToTurn} />
) : (
<small> </small>
)}
</div>
);
}
function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
const terminalWithoutReveal =
pulse.status === "error" ||
pulse.status === "degraded" ||
Boolean(pulse.error_code);
return (
<div className="ap-waiting" role="status" aria-live="polite">
<div className="ap-waiting__lock">
<Icon name="shield" size={18} strokeWidth={1.8} />
</div>
<div>
<b>
{terminalWithoutReveal
? "내 판단은 보존됐습니다"
: "내 판단이 잠겼습니다"}
</b>
<p>
{terminalWithoutReveal
? "공개 가능한 AI 근거를 확정하지 못했습니다. 준비되지 않은 점수는 추정해 표시하지 않습니다."
: "AI 관점은 아직 화면에 표시하지 않습니다. 두 관점이 준비되면 한 번에 공개합니다."}
</p>
</div>
{terminalWithoutReveal ? null : (
<div className="ap-waiting__skeleton" aria-hidden="true">
<span />
<span />
<span />
</div>
)}
<dl className="ap-locked-scores">
{DIMENSIONS.map((dimension) => (
<div key={dimension}>
<dt>{DIMENSION_COPY[dimension].short}</dt>
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
</div>
))}
</dl>
</div>
);
}
function ComparisonView({
pulse,
onJumpToTurn,
}: {
pulse: AlliancePulse;
onJumpToTurn: (turnId: string) => void;
}) {
const supervisorExists = pulse.measurements.some(
(measurement) => measurement.perspective === "supervisor_human",
);
const perspectives = supervisorExists
? [...CORE_PERSPECTIVES, "supervisor_human" as const]
: CORE_PERSPECTIVES;
return (
<div className="ap-comparison" aria-label="치료 동맹 관점 비교">
<div
className={`ap-comparison__head ${supervisorExists ? "has-supervisor" : ""}`}
aria-hidden="true"
>
<span> </span>
{perspectives.map((perspective) => (
<span key={perspective}>
<b>{PERSPECTIVE_COPY[perspective].label}</b>
<small>{PERSPECTIVE_COPY[perspective].provenance}</small>
</span>
))}
</div>
{DIMENSIONS.map((dimension) => (
<section
className={`ap-comparison__row ${supervisorExists ? "has-supervisor" : ""}`}
key={dimension}
aria-labelledby={`ap-dimension-${dimension}`}
>
<div className="ap-comparison__axis">
<b id={`ap-dimension-${dimension}`}>{DIMENSION_COPY[dimension].label}</b>
<span>{DIMENSION_COPY[dimension].prompt}</span>
</div>
{perspectives.map((perspective) => (
<PerspectiveCell
key={perspective}
pulse={pulse}
dimension={dimension}
perspective={perspective}
onJumpToTurn={onJumpToTurn}
/>
))}
</section>
))}
<p className="ap-comparison__note">
. .
</p>
</div>
);
}
function SupervisorRatingForm({
sessionId,
pulse,
turns,
onRecorded,
}: {
sessionId: string;
pulse: AlliancePulse;
turns: PulseTurn[];
onRecorded: () => Promise<void>;
}) {
const [draft, setDraft] = useState<ScoreDraft>(EMPTY_DRAFT);
const [evidenceTurnIds, setEvidenceTurnIds] = useState<string[]>([]);
const [note, setNote] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recorded, setRecorded] = useState(false);
const hasExisting = pulse.measurements.some(
(measurement) => measurement.perspective === "supervisor_human",
);
const submit = async () => {
if (
!isCompleteDraft(draft) ||
evidenceTurnIds.length === 0 ||
!note.trim()
) return;
setSaving(true);
setError(null);
try {
await alliancePulseApi.addSupervisorRating(sessionId, pulse.pulse_id, {
scores: draft,
evidence_turn_ids: evidenceTurnIds,
note: note.trim(),
});
setRecorded(true);
setDraft(EMPTY_DRAFT);
setEvidenceTurnIds([]);
setNote("");
await onRecorded();
} catch (err) {
setError(
err instanceof Error ? err.message : "교수자 동맹 판정을 저장하지 못했습니다.",
);
} finally {
setSaving(false);
}
};
return (
<details className="ap-supervisor" open={!hasExisting}>
<summary>{hasExisting ? "교수자 판정 추가" : "교수자 판정 기록"}</summary>
<div className="ap-supervisor__body">
<p>
. .
</p>
{DIMENSIONS.map((dimension) => (
<AxisAssessmentControl
key={dimension}
dimension={dimension}
value={draft[dimension]}
prefix="supervisor-alliance"
disabled={saving}
onChange={(value) =>
setDraft((current) => ({ ...current, [dimension]: value }))
}
/>
))}
<EvidencePicker
turns={turns}
selected={evidenceTurnIds}
onChange={setEvidenceTurnIds}
scope="supervisor"
/>
<label className="ap-note-field">
<span> </span>
<textarea
rows={3}
value={note}
maxLength={2000}
placeholder="관점 차이를 해석하고 다음 지도에서 확인할 점을 남깁니다."
onChange={(event) => setNote(event.target.value)}
/>
</label>
{error ? <p className="ap-inline-error" role="alert">{error}</p> : null}
{recorded ? (
<p className="ap-inline-success" role="status"> .</p>
) : null}
<Button
size="sm"
disabled={
saving ||
!isCompleteDraft(draft) ||
evidenceTurnIds.length === 0 ||
!note.trim()
}
onClick={() => void submit()}
>
{saving ? "판정 저장 중" : "근거와 함께 판정 추가"}
</Button>
</div>
</details>
);
}
export function AlliancePulseCard({
sessionId,
turns,
isSupervisorView,
onJumpToTurn,
}: AlliancePulseCardProps) {
const [loadState, setLoadState] = useState<LoadState>("loading");
const [pulse, setPulse] = useState<AlliancePulse | null>(null);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<ScoreDraft>(EMPTY_DRAFT);
const [evidenceTurnIds, setEvidenceTurnIds] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const load = useCallback(
async (signal?: AbortSignal) => {
try {
const response = await alliancePulseApi.list(sessionId, signal);
setPulse(latestPostPulse(response.items ?? []));
setLoadState("ready");
setError(null);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setLoadState("error");
setError(
err instanceof Error ? err.message : "치료 동맹 펄스를 불러오지 못했습니다.",
);
}
},
[sessionId],
);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
useEffect(() => {
if (!pulse || (pulse.status !== "awaiting_agents" && pulse.status !== "processing")) {
return;
}
const timer = window.setTimeout(() => void load(), POLL_INTERVAL_MS);
return () => window.clearTimeout(timer);
}, [load, pulse]);
const submitSelfAssessment = async () => {
if (!isCompleteDraft(draft) || submitting) return;
setSubmitting(true);
setError(null);
try {
const scores: AllianceScores = draft;
const created = await alliancePulseApi.create(sessionId, {
checkpoint: "post",
scores,
evidence_turn_ids: evidenceTurnIds,
});
setPulse({
pulse_id: created.pulse_id,
checkpoint: "post",
status: created.status,
learner_locked_at: new Date().toISOString(),
revealed_at: null,
error_code: null,
self_scores: scores,
measurements: [],
});
setLoadState("ready");
await load();
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
await load();
setError("이미 잠긴 자기평가가 있어 저장된 기록을 불러왔습니다.");
} else {
setError(
err instanceof Error ? err.message : "자기평가를 잠그지 못했습니다.",
);
}
} finally {
setSubmitting(false);
}
};
const revealReady = Boolean(
pulse?.learner_locked_at && pulse?.revealed_at,
);
const status = pulse ? pulseStatusCopy(pulse) : null;
const learnerEvidenceRequired = turns.length > 0;
const canSubmit =
isCompleteDraft(draft) &&
(!learnerEvidenceRequired || evidenceTurnIds.length > 0) &&
!submitting;
let content: ReactNode;
if (loadState === "loading") {
content = (
<div className="ap-loading" role="status" aria-label="치료 동맹 펄스 불러오는 중">
<span />
<span />
<span />
</div>
);
} else if (loadState === "error" && !pulse) {
content = (
<div className="ap-load-error" role="alert">
<p>{error ?? "치료 동맹 펄스를 불러오지 못했습니다."}</p>
<Button variant="secondary" size="sm" onClick={() => void load()}>
</Button>
</div>
);
} else if (!pulse && isSupervisorView) {
content = (
<div className="ap-empty">
<b> </b>
<p> AI .</p>
</div>
);
} else if (!pulse) {
content = (
<div className="ap-self-form">
<div className="ap-self-form__intro">
<b>AI </b>
<p> . .</p>
</div>
{DIMENSIONS.map((dimension) => (
<AxisAssessmentControl
key={dimension}
dimension={dimension}
value={draft[dimension]}
prefix="learner-alliance"
disabled={submitting}
onChange={(value) =>
setDraft((current) => ({ ...current, [dimension]: value }))
}
/>
))}
<EvidencePicker
turns={turns}
selected={evidenceTurnIds}
onChange={setEvidenceTurnIds}
scope="learner"
/>
{error ? <p className="ap-inline-error" role="alert">{error}</p> : null}
<div className="ap-self-form__commit">
<span>
<Icon name="shield" size={15} strokeWidth={1.8} />
AI .
</span>
<Button size="sm" disabled={!canSubmit} onClick={() => void submitSelfAssessment()}>
{submitting ? "판단 잠그는 중" : "내 판단 잠그고 관점 비교"}
</Button>
</div>
</div>
);
} else if (!revealReady) {
content = <LockedWaitingState pulse={pulse} />;
} else {
content = (
<>
<ComparisonView pulse={pulse} onJumpToTurn={onJumpToTurn} />
{isSupervisorView ? (
<SupervisorRatingForm
sessionId={sessionId}
pulse={pulse}
turns={turns}
onRecorded={() => load()}
/>
) : null}
</>
);
}
return (
<Card className="sr-card ap-card" aria-busy={loadState === "loading"}>
<div className="ap-card__head">
<div>
<Kicker dot={false}> </Kicker>
<h2>, , </h2>
</div>
{status ? <Badge tone={status.tone}>{status.label}</Badge> : null}
</div>
{status ? <p className="ap-card__status-copy">{status.body}</p> : null}
{content}
</Card>
);
}

View file

@ -0,0 +1,795 @@
/* G1 치료 동맹 펄스. 기존 Vignette 임상 워크벤치 토큰을 그대로 따른다. */
.ap-card {
display: grid;
gap: var(--sp-4);
padding: var(--sp-5);
overflow: hidden;
border-color: color-mix(in srgb, var(--accent) 22%, var(--border-subtle));
background: color-mix(in srgb, var(--bg-surface) 94%, var(--accent-tint));
}
.ap-card__head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: var(--sp-4);
}
.ap-card__head h2 {
margin: 5px 0 0;
color: var(--text-strong);
font-size: clamp(1.15rem, 1.7vw, var(--fs-h2));
font-weight: 720;
line-height: 1.25;
letter-spacing: -0.018em;
}
.ap-card__status-copy {
max-width: 72ch;
margin: -6px 0 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.6;
}
.ap-self-form,
.ap-waiting,
.ap-comparison,
.ap-supervisor,
.ap-empty,
.ap-load-error {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--bg-surface-2);
box-shadow: var(--glass-inset-shadow);
}
.ap-self-form {
display: grid;
gap: var(--sp-4);
padding: var(--sp-4);
}
.ap-self-form__intro {
display: grid;
gap: 4px;
}
.ap-self-form__intro b {
color: var(--text-strong);
font-size: var(--fs-body);
}
.ap-self-form__intro p,
.ap-empty p,
.ap-load-error p,
.ap-waiting p,
.ap-supervisor__body > p {
margin: 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.6;
}
.ap-axis {
min-width: 0;
display: grid;
grid-template-columns: minmax(180px, 0.75fr) minmax(360px, 1.8fr);
align-items: center;
gap: var(--sp-4);
margin: 0;
padding: var(--sp-3) 0;
border: 0;
border-top: 1px solid var(--hair);
}
.ap-axis legend {
display: contents;
}
.ap-axis legend > span {
align-self: end;
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 720;
}
.ap-axis legend > small {
grid-column: 1;
align-self: start;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.45;
}
.ap-scale {
grid-column: 2;
grid-row: 1 / span 2;
display: grid;
grid-template-columns: repeat(5, minmax(68px, 1fr));
gap: 6px;
}
.ap-scale__option {
position: relative;
min-width: 0;
min-height: 62px;
display: grid;
place-content: center;
gap: 3px;
padding: 8px 5px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-body);
background: var(--bg-surface);
text-align: center;
cursor: pointer;
transition:
transform var(--dur-fast) var(--ease-out),
border-color var(--dur-base) var(--ease-out),
background-color var(--dur-base) var(--ease-out);
}
.ap-scale__option:hover {
border-color: color-mix(in srgb, var(--accent) 44%, var(--border-subtle));
transform: translateY(-1px);
}
.ap-scale__option:active {
transform: translateY(1px);
}
.ap-scale__option.is-selected {
border-color: var(--accent);
color: var(--text-accent);
background: var(--accent-tint);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
.ap-scale__option input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.ap-scale__option:has(input:focus-visible) {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.ap-scale__option > span {
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 760;
}
.ap-scale__option > small {
overflow: hidden;
color: inherit;
font-size: 11px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-evidence-picker {
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface);
}
.ap-evidence-picker > summary,
.ap-supervisor > summary,
.ap-measurement-evidence > summary {
cursor: pointer;
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 690;
}
.ap-evidence-picker > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: 11px 13px;
}
.ap-evidence-picker > summary span {
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 620;
}
.ap-evidence-picker > summary:focus-visible,
.ap-supervisor > summary:focus-visible,
.ap-measurement-evidence > summary:focus-visible,
.ap-measurement-evidence__turns button:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.ap-evidence-picker__list {
max-height: 260px;
overflow: auto;
display: grid;
gap: 6px;
padding: 0 10px 10px;
}
.ap-evidence-picker__list label {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: start;
gap: 9px;
padding: 9px 10px;
border-radius: var(--radius-sm);
color: var(--text-body);
background: var(--bg-surface-2);
font-size: var(--fs-xs);
line-height: 1.45;
cursor: pointer;
}
.ap-evidence-picker__list label:has(input:checked) {
color: var(--text-strong);
background: var(--accent-tint);
}
.ap-evidence-picker__list input {
width: 16px;
height: 16px;
margin: 2px 0 0;
accent-color: var(--accent);
}
.ap-evidence-picker__list b {
display: block;
margin-bottom: 2px;
color: var(--text-strong);
}
.ap-evidence-picker__time {
padding-top: 1px;
color: var(--text-muted);
font-family: var(--font-num);
}
.ap-self-form__commit {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
}
.ap-self-form__commit > span {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.45;
}
.ap-waiting {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(170px, 0.45fr);
align-items: center;
gap: var(--sp-4);
padding: var(--sp-4);
}
.ap-waiting__lock {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--text-accent);
background: var(--accent-tint);
}
.ap-waiting b,
.ap-empty b {
color: var(--text-strong);
font-size: var(--fs-body);
}
.ap-waiting__skeleton,
.ap-loading {
display: grid;
gap: 8px;
}
.ap-waiting__skeleton span,
.ap-loading span {
height: 10px;
border-radius: var(--radius-sm);
background: var(--neutral-150);
transform-origin: left center;
}
.ap-waiting__skeleton span:nth-child(2),
.ap-loading span:nth-child(2) {
width: 72%;
}
.ap-waiting__skeleton span:nth-child(3),
.ap-loading span:nth-child(3) {
width: 86%;
}
@media (prefers-reduced-motion: no-preference) {
.ap-waiting__skeleton span,
.ap-loading span {
animation: ap-breathe 1.8s var(--ease-in-out) infinite alternate;
}
}
@keyframes ap-breathe {
from { opacity: 0.42; transform: scaleX(0.94); }
to { opacity: 0.84; transform: scaleX(1); }
}
.ap-locked-scores {
grid-column: 2 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin: 0;
overflow: hidden;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--border-subtle);
}
.ap-locked-scores > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 9px 11px;
background: var(--bg-surface);
}
.ap-locked-scores dt {
color: var(--text-muted);
font-size: var(--fs-xs);
}
.ap-locked-scores dd {
margin: 0;
color: var(--text-strong);
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 730;
}
.ap-comparison {
padding: var(--sp-3);
}
.ap-comparison__head,
.ap-comparison__row {
display: grid;
grid-template-columns: minmax(172px, 0.8fr) repeat(3, minmax(0, 1fr));
gap: 1px;
}
.ap-comparison__head.has-supervisor,
.ap-comparison__row.has-supervisor {
grid-template-columns: minmax(172px, 0.8fr) repeat(4, minmax(0, 1fr));
}
.ap-comparison__head {
margin-bottom: 1px;
color: var(--text-muted);
background: var(--border-subtle);
}
.ap-comparison__head > span {
display: grid;
gap: 2px;
padding: 9px 11px;
background: var(--bg-surface-2);
font-size: var(--fs-xs);
}
.ap-comparison__head b {
color: var(--text-strong);
font-size: var(--fs-xs);
}
.ap-comparison__head small {
color: var(--text-muted);
font-size: 11px;
}
.ap-comparison__row {
margin-top: 1px;
background: var(--border-subtle);
}
.ap-comparison__axis,
.ap-perspective-cell {
min-width: 0;
padding: 13px 11px;
background: var(--bg-surface);
}
.ap-comparison__axis {
display: grid;
align-content: start;
gap: 4px;
background: color-mix(in srgb, var(--bg-surface) 80%, var(--accent-tint));
}
.ap-comparison__axis b {
color: var(--text-strong);
font-size: var(--fs-sm);
}
.ap-comparison__axis span {
color: var(--text-muted);
font-size: 11px;
line-height: 1.45;
}
.ap-perspective-cell {
display: grid;
align-content: start;
gap: 3px;
}
.ap-perspective-cell__label {
display: none;
gap: 2px;
}
.ap-perspective-cell__label b {
color: var(--text-strong);
font-size: var(--fs-xs);
}
.ap-perspective-cell__label small {
color: var(--text-muted);
font-size: 11px;
}
.ap-perspective-cell > strong {
color: var(--text-strong);
font-family: var(--font-num);
font-size: var(--fs-h3);
font-weight: 720;
}
.ap-perspective-cell > span,
.ap-perspective-cell > small {
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.4;
}
.ap-perspective-cell.is-unavailable > strong,
.ap-perspective-cell.is-unavailable > span {
color: var(--text-muted);
}
.ap-measurement-evidence {
margin-top: 8px;
}
.ap-measurement-evidence > summary {
color: var(--text-accent);
font-size: var(--fs-xs);
}
.ap-measurement-evidence > p {
margin: 8px 0 0;
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.5;
}
.ap-measurement-evidence__turns {
display: grid;
gap: 5px;
margin-top: 7px;
}
.ap-measurement-evidence__turns button {
min-width: 0;
padding: 7px 8px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-body);
background: var(--bg-surface-2);
font: inherit;
font-size: 11px;
line-height: 1.4;
text-align: left;
cursor: pointer;
transition:
transform var(--dur-fast) var(--ease-out),
border-color var(--dur-base) var(--ease-out);
}
.ap-measurement-evidence__turns button:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.ap-measurement-evidence__turns button:active {
transform: translateY(1px);
}
.ap-measurement-evidence__turns button span {
display: block;
margin-bottom: 2px;
color: var(--text-accent);
font-family: var(--font-num);
}
.ap-comparison__note {
margin: var(--sp-3) 2px 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.5;
}
.ap-supervisor {
overflow: hidden;
}
.ap-supervisor > summary {
padding: 12px 14px;
background: var(--bg-surface-2);
}
.ap-supervisor[open] > summary {
border-bottom: 1px solid var(--border-subtle);
}
.ap-supervisor__body {
display: grid;
gap: var(--sp-4);
padding: var(--sp-4);
background: var(--bg-surface);
}
.ap-note-field {
display: grid;
gap: 7px;
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 650;
}
.ap-note-field textarea {
width: 100%;
resize: vertical;
padding: 10px 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-strong);
background: var(--bg-surface-2);
font: inherit;
font-weight: 450;
line-height: 1.55;
}
.ap-note-field textarea::placeholder {
color: var(--neutral-400);
}
.ap-note-field textarea:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
border-color: var(--border-focus);
}
.ap-loading {
min-height: 130px;
align-content: center;
padding: var(--sp-5);
}
.ap-empty,
.ap-load-error {
display: grid;
justify-items: start;
gap: 7px;
padding: var(--sp-4);
}
.ap-inline-error,
.ap-inline-success {
margin: 0;
padding: 9px 11px;
border-radius: var(--radius-sm);
font-size: var(--fs-xs);
line-height: 1.5;
}
.ap-inline-error {
color: var(--crit-text);
background: var(--crit-tint);
}
.ap-inline-success {
color: var(--pos-text);
background: var(--pos-tint);
}
@media (max-width: 980px) {
.ap-axis {
grid-template-columns: 1fr;
gap: 7px;
}
.ap-axis legend > small,
.ap-scale {
grid-column: 1;
}
.ap-scale {
grid-row: auto;
margin-top: 4px;
}
.ap-comparison__head {
display: none;
}
.ap-comparison__row,
.ap-comparison__row.has-supervisor {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin-top: var(--sp-3);
}
.ap-comparison__axis {
grid-column: 1 / -1;
}
.ap-comparison__row.has-supervisor .ap-perspective-cell:last-child {
grid-column: 1 / -1;
}
.ap-perspective-cell__label {
display: grid;
}
}
@media (max-width: 680px) {
.ap-card {
padding: var(--sp-4);
}
.ap-card__head {
grid-template-columns: 1fr;
gap: var(--sp-2);
}
.ap-card__head .vg-badge {
justify-self: start;
}
.ap-self-form {
padding: var(--sp-3);
}
.ap-scale {
width: 100%;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.ap-scale__option {
min-height: 52px;
padding: 7px 2px;
}
.ap-scale__option > small {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
}
.ap-self-form__commit {
align-items: stretch;
flex-direction: column;
}
.ap-self-form__commit .vg-btn {
width: 100%;
}
.ap-waiting {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
}
.ap-waiting__skeleton {
grid-column: 1 / -1;
}
.ap-locked-scores {
grid-column: 1 / -1;
}
.ap-comparison {
padding: var(--sp-2);
}
.ap-comparison__row,
.ap-comparison__row.has-supervisor {
grid-template-columns: 1fr;
}
.ap-comparison__axis,
.ap-comparison__row.has-supervisor .ap-perspective-cell:last-child {
grid-column: 1;
}
.ap-perspective-cell {
display: grid;
grid-template-columns: minmax(90px, auto) minmax(0, 1fr);
align-items: baseline;
column-gap: var(--sp-3);
}
.ap-perspective-cell__label {
grid-column: 1 / -1;
}
.ap-perspective-cell > strong {
font-size: var(--fs-body);
}
.ap-perspective-cell .ap-measurement-evidence,
.ap-perspective-cell > small {
grid-column: 1 / -1;
}
.ap-evidence-picker__list label {
grid-template-columns: auto minmax(0, 1fr);
}
.ap-evidence-picker__time {
display: none;
}
}
@media (max-width: 360px) {
.ap-scale {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.ap-scale__option {
grid-column: span 2;
}
.ap-scale__option:nth-child(4) {
grid-column: 2 / span 2;
}
.ap-scale__option:nth-child(5) {
grid-column: 4 / span 2;
}
}
@media (prefers-reduced-motion: reduce) {
.ap-scale__option,
.ap-measurement-evidence__turns button {
transition: none;
}
}

View file

@ -0,0 +1,90 @@
import { api, supplementalApi } from "../../lib/api";
export type AllianceDimension = "goal" | "task" | "bond";
export type AllianceCheckpoint = "pre" | "mid" | "post";
export type AlliancePerspective =
| "learner_self_report"
| "client_agent_report"
| "independent_observer"
| "supervisor_human";
export type AllianceScores = Record<AllianceDimension, number>;
export interface AllianceEvidence {
turn_id: string;
seq: number;
speaker: string;
text: string;
}
export interface AllianceMeasurement {
measurement_id: string;
dimension: AllianceDimension;
perspective: AlliancePerspective;
source_kind: string;
value: number | null;
confidence: number | null;
status: string;
error_code: string | null;
rationale: string | null;
evidence: AllianceEvidence[];
created_at: string;
}
export interface AlliancePulse {
pulse_id: string;
checkpoint: AllianceCheckpoint;
status: string;
learner_locked_at: string | null;
revealed_at: string | null;
error_code: string | null;
self_scores: AllianceScores | null;
measurements: AllianceMeasurement[];
}
export interface AlliancePulseListResponse {
items: AlliancePulse[];
}
export interface CreateAlliancePulseRequest {
checkpoint: AllianceCheckpoint;
scores: AllianceScores;
evidence_turn_ids: string[];
}
export interface CreateAlliancePulseResponse {
pulse_id: string;
status: "awaiting_agents" | string;
}
export interface SupervisorAllianceRatingRequest {
scores: AllianceScores;
evidence_turn_ids: string[];
note: string;
}
export interface SupervisorAllianceRatingResponse {
status: "recorded" | string;
}
function pulsePath(sessionId: string): string {
return `/sessions/${encodeURIComponent(sessionId)}/alliance-pulses`;
}
export const alliancePulseApi = {
list: (sessionId: string, signal?: AbortSignal) =>
supplementalApi.get<AlliancePulseListResponse>(pulsePath(sessionId), {
signal,
}),
create: (sessionId: string, body: CreateAlliancePulseRequest) =>
api.post<CreateAlliancePulseResponse>(pulsePath(sessionId), body),
addSupervisorRating: (
sessionId: string,
pulseId: string,
body: SupervisorAllianceRatingRequest,
) =>
api.post<SupervisorAllianceRatingResponse>(
`${pulsePath(sessionId)}/${encodeURIComponent(pulseId)}/supervisor-rating`,
body,
),
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,120 @@
import { api, supplementalApi } from "../../lib/api";
import type { components } from "../../lib/api.gen";
type ApiSchema<Name extends keyof components["schemas"]> =
components["schemas"][Name];
/** G5 wire DTO는 생성된 FastAPI OpenAPI 계약을 그대로 사용한다. */
export type CalibrationConfidenceInterval = ApiSchema<"ConfidenceInterval">;
export type CalibrationPair = ApiSchema<"CalibrationPair">;
export type CompetencyCalibrationAssessment =
ApiSchema<"CompetencyCalibrationAssessment">;
export type MetacognitivePrescription = ApiSchema<"MetacognitivePrescription">;
export type PredictionRevisionItem = ApiSchema<"PredictionRevisionItem">;
export type PredictionLockItem = ApiSchema<"PredictionLockItem">;
export type PerformanceObservationItem = ApiSchema<"PerformanceObservationItem">;
export type PredictionHistoryItem = ApiSchema<"PredictionHistoryItem">;
export type CalibrationAssessmentItem = ApiSchema<"CalibrationAssessmentItem">;
export type TransferAssessment = ApiSchema<"TransferAssessment">;
export type TransferTrialItem = ApiSchema<"TransferTrialItem">;
export type TransferAssessmentItem = ApiSchema<"TransferAssessmentItem">;
export type SyntheticSubgroupResult = ApiSchema<"SyntheticSubgroupResult">;
export type SubgroupDriftReport = ApiSchema<"SubgroupDriftReport">;
export type DriftReportItem = ApiSchema<"DriftReportItem">;
export type TransferSuiteItem = ApiSchema<"TransferSuiteItem">;
export type TeacherReviewItem = ApiSchema<"TeacherReviewItem">;
export type CalibrationTransferReadModelResponse =
ApiSchema<"CalibrationTransferReadModelResponse">;
export type PredictionRevisionRequest = ApiSchema<"PredictionRevisionRequest">;
export type PredictionRevisionResponse = ApiSchema<"PredictionRevisionResponse">;
export type PredictionLockRequest = ApiSchema<"PredictionLockRequest">;
export type PredictionLockResponse = ApiSchema<"PredictionLockResponse">;
export type TeacherCorrectionResponse = ApiSchema<"TeacherReviewResponse">;
export type ActualTransferAssessment = ApiSchema<"ActualTransferAssessment">;
export type ActualTransferExecutionRequest =
ApiSchema<"ActualTransferExecutionRequest">;
export type ActualTransferExecutionResponse =
ApiSchema<"ActualTransferExecutionResponse">;
export type CalibrationBias = CompetencyCalibrationAssessment["bias"];
export type CalibrationImprovement =
CompetencyCalibrationAssessment["improvement"];
export type PerformanceStatus = PerformanceObservationItem["status"];
export type RelationshipStyle = TransferTrialItem["relationship_style"];
export type DriftStatus = SubgroupDriftReport["status"];
/** 교수자 UI는 원판정 쓰기를 열지 않고 append-only 교정만 허용한다. */
export type TeacherCorrectionRequest = Omit<
ApiSchema<"TeacherReviewRequest">,
"disposition" | "correction_payload"
> & {
disposition: "corrected";
correction_payload: { teacher_note: string };
};
interface TeacherSessionRef {
session_id: string;
learner_id: string;
}
interface TeacherDashboardLookup {
pending_reviews?: TeacherSessionRef[];
recent_sessions?: TeacherSessionRef[];
}
function learnerPath(learnerId?: string): string {
return learnerId
? `/calibration/learners/${encodeURIComponent(learnerId)}`
: "/calibration/learners/me";
}
export class CalibrationLearnerContextError extends Error {
constructor() {
super("이 회기의 학습자 캘리브레이션 원장을 찾지 못했습니다.");
this.name = "CalibrationLearnerContextError";
}
}
export const calibrationTransferApi = {
getForLearner: (signal?: AbortSignal) =>
supplementalApi.get<CalibrationTransferReadModelResponse>(learnerPath(), {
signal,
}),
getForTeacherSession: async (sessionId: string, signal?: AbortSignal) => {
const dashboard = await supplementalApi.get<TeacherDashboardLookup>(
"/teacher/dashboard",
{ signal },
);
const session = [
...(dashboard.pending_reviews ?? []),
...(dashboard.recent_sessions ?? []),
].find((item) => item.session_id === sessionId);
if (!session) throw new CalibrationLearnerContextError();
return supplementalApi.get<CalibrationTransferReadModelResponse>(
learnerPath(session.learner_id),
{ signal },
);
},
appendPredictionRevision: (body: PredictionRevisionRequest) =>
api.post<PredictionRevisionResponse>(
"/calibration/predictions/revisions",
body,
),
lockPrediction: (historyId: string, body: PredictionLockRequest) =>
api.post<PredictionLockResponse>(
`/calibration/predictions/${encodeURIComponent(historyId)}/lock`,
body,
),
appendTeacherCorrection: (body: TeacherCorrectionRequest) =>
api.post<TeacherCorrectionResponse>("/calibration/reviews", body),
appendActualTransferExecution: (body: ActualTransferExecutionRequest) =>
api.post<ActualTransferExecutionResponse>(
"/calibration/transfer-executions",
body,
),
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,124 @@
import {
api,
ApiError,
apiFetch,
apiUrl,
supplementalApi,
} from "../../lib/api";
import type { components, paths } from "../../lib/api.gen";
type ApiSchema<Name extends keyof components["schemas"]> =
components["schemas"][Name];
export type DeliberatePracticeReadModel =
ApiSchema<"DeliberatePracticeReadModelResponse">;
export type PracticePrescriptionItem = ApiSchema<"PracticePrescriptionItem">;
export type PracticeEpisodeItem = ApiSchema<"PracticeEpisodeItem">;
export type PracticeAttemptItem = ApiSchema<"PracticeAttemptItem">;
export type PracticeEvidenceRef = ApiSchema<"PracticeEvidenceRef">;
export type PracticeAttemptSubmissionRequest =
ApiSchema<"PracticeAttemptSubmissionRequest">;
export type PracticeAttemptSubmissionResponse =
ApiSchema<"PracticeAttemptSubmissionResponse">;
type ObserveCompletedPracticeSessionOperation = NonNullable<
paths["/practice/{prescription_id}/attempts/from-session/{practice_session_id}"]["post"]
>;
type ObserveCompletedPracticeSessionPath =
ObserveCompletedPracticeSessionOperation["parameters"]["path"];
export type ObserveCompletedPracticeSessionResponse =
ObserveCompletedPracticeSessionOperation["responses"][201]["content"]["application/json"];
export type PracticeTeacherCorrectionRequest =
ApiSchema<"PracticeTeacherCorrectionRequest">;
export type PracticeTeacherCorrectionResponse =
ApiSchema<"PracticeTeacherCorrectionResponse">;
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
function learnerPracticePath(learnerId?: string): string {
return learnerId
? `/practice/learners/${encodeURIComponent(learnerId)}`
: "/practice/learners/me";
}
function dashboardSessions(dashboard: TeacherDashboardResponse) {
return [
...(dashboard.pending_reviews ?? []),
...(dashboard.recent_sessions ?? []),
];
}
async function getTeacherDashboard(signal?: AbortSignal) {
const response = await fetch(apiUrl("/teacher/dashboard"), {
method: "GET",
credentials: "include",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
let body: unknown;
let detail =
response.statusText || "교수자 학습자 맥락을 불러오지 못했습니다.";
try {
body = await response.json();
const candidate = (body as { detail?: unknown }).detail;
if (typeof candidate === "string") detail = candidate;
} catch {
body = undefined;
}
// 이 보조 조회의 실패는 전체 회기 리뷰의 인증 성공을 뒤집지 않는다.
// 후속 역할 보호 API는 공통 client를 사용하므로 실제 세션 만료는 그대로 전파된다.
throw new ApiError(response.status, detail, body);
}
return (await response.json()) as TeacherDashboardResponse;
}
export class PracticeLearnerContextError extends Error {
constructor() {
super("이 회기의 학습자 연습 원장을 찾지 못했습니다.");
this.name = "PracticeLearnerContextError";
}
}
export const deliberatePracticeApi = {
getForLearner: (signal?: AbortSignal) =>
supplementalApi.get<DeliberatePracticeReadModel>(learnerPracticePath(), {
signal,
}),
getForTeacherSession: async (sessionId: string, signal?: AbortSignal) => {
const dashboard = await getTeacherDashboard(signal);
const session = dashboardSessions(dashboard).find(
(item) => item.session_id === sessionId,
);
if (!session) throw new PracticeLearnerContextError();
return supplementalApi.get<DeliberatePracticeReadModel>(
learnerPracticePath(session.learner_id),
{ signal },
);
},
submitAttempt: (
prescriptionId: string,
body: PracticeAttemptSubmissionRequest,
) =>
api.post<PracticeAttemptSubmissionResponse>(
`/practice/${encodeURIComponent(prescriptionId)}/attempts`,
body,
),
observeCompletedSession: (
prescriptionId: ObserveCompletedPracticeSessionPath["prescription_id"],
practiceSessionId: ObserveCompletedPracticeSessionPath["practice_session_id"],
) =>
api.post<ObserveCompletedPracticeSessionResponse>(
`/practice/${encodeURIComponent(prescriptionId)}/attempts/from-session/${encodeURIComponent(practiceSessionId)}`,
),
appendCorrection: (
attemptRecordId: string,
body: PracticeTeacherCorrectionRequest,
) =>
apiFetch<PracticeTeacherCorrectionResponse>(
`/practice/attempts/${encodeURIComponent(attemptRecordId)}/correction`,
{ method: "PATCH", body },
),
};

View file

@ -0,0 +1,815 @@
.mma-card {
display: grid;
gap: var(--sp-5);
padding: var(--sp-5);
overflow: hidden;
border: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.mma-header,
.mma-section-head,
.mma-practice-cta,
.mma-event-inspector {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-4);
}
.mma-header h2 {
max-width: 760px;
margin: 7px 0 0;
color: var(--text-strong);
font-size: clamp(1.3rem, 2.1vw, 1.7rem);
font-weight: 720;
line-height: 1.3;
text-wrap: balance;
}
.mma-header p,
.mma-boundary-copy,
.mma-state-copy p,
.mma-practice-cta p {
margin: 8px 0 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.65;
}
.mma-header__badges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--sp-2);
}
.mma-clock,
.mma-axis-board,
.mma-privacy {
min-width: 0;
padding: var(--sp-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
}
.mma-section-head {
align-items: center;
}
.mma-section-head h3,
.mma-state-copy h2,
.mma-axis h4,
.mma-event-inspector h4 {
margin: 3px 0 0;
color: var(--text-strong);
font-weight: 690;
line-height: 1.35;
}
.mma-section-head h3 {
font-size: var(--fs-h3);
}
.mma-eyebrow,
.mma-axis__title > span,
.mma-measurement__label,
.mma-fusion > span,
.mma-clock__lane-label,
.mma-no-total {
color: var(--text-muted);
font-family: var(--font-num);
font-size: var(--fs-kicker);
font-weight: 720;
letter-spacing: 0.03em;
}
.mma-clock__privacy-note {
margin: var(--sp-3) 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.55;
}
.mma-clock__viewport {
width: 100%;
overflow-x: auto;
padding: 3px 0 var(--sp-2);
border-radius: var(--radius-sm);
scrollbar-color: var(--neutral-200) transparent;
}
.mma-clock__viewport:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 3px;
}
.mma-clock__canvas {
position: relative;
min-width: 720px;
padding: 30px 12px 4px 108px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background-color: var(--bg-surface);
background-image:
linear-gradient(to right, color-mix(in srgb, var(--border-subtle) 48%, transparent) 1px, transparent 1px),
linear-gradient(to right, color-mix(in srgb, var(--border-strong) 60%, transparent) 1px, transparent 1px);
background-size: calc((100% - 120px) / 20) 100%, calc((100% - 120px) / 4) 100%;
background-position: 108px 0, 108px 0;
}
.mma-clock__ticks {
position: absolute;
top: 8px;
right: 12px;
left: 108px;
height: 16px;
color: var(--text-muted);
font-family: var(--font-num);
font-size: 11px;
}
.mma-clock__ticks span {
position: absolute;
transform: translateX(-50%);
}
.mma-clock__ticks span:first-child {
transform: none;
}
.mma-clock__ticks span:last-child {
transform: translateX(-100%);
}
.mma-clock__lane {
position: relative;
height: 42px;
}
.mma-clock__lane + .mma-clock__lane {
border-top: 1px solid color-mix(in srgb, var(--border-subtle) 72%, transparent);
}
.mma-clock__lane-label {
position: absolute;
right: calc(100% + 12px);
top: 50%;
width: 92px;
transform: translateY(-50%);
text-align: right;
white-space: nowrap;
}
.mma-clock__track {
position: relative;
height: 100%;
}
.mma-word {
position: absolute;
top: 13px;
min-width: 3px;
height: 14px;
border-radius: 2px;
opacity: 0.86;
}
.mma-word.is-client {
background: var(--clay);
}
.mma-word.is-learner {
background: var(--accent);
}
.mma-event {
position: absolute;
top: 8px;
min-width: 24px;
height: 26px;
overflow: hidden;
padding: 0 5px;
border: 1px solid var(--border-strong);
border-radius: 3px;
color: var(--text-body);
background: var(--bg-surface);
font: 680 11px/1 var(--font-sans);
white-space: nowrap;
cursor: pointer;
}
.mma-event:hover,
.mma-event.is-selected {
color: var(--text-accent);
border-color: var(--border-focus);
background: var(--accent-tint);
}
.mma-event.is-overlap,
.mma-event.is-interruption {
color: var(--warn-text);
background: var(--warn-tint);
}
.mma-event:focus-visible {
z-index: 3;
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.mma-playhead {
position: absolute;
z-index: 2;
top: 25px;
bottom: 4px;
width: 2px;
background: var(--info-solid);
pointer-events: none;
transition: left var(--dur-base) var(--ease-out);
}
.mma-playhead::before {
position: absolute;
top: -2px;
left: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--info-solid);
content: "";
transform: translate(-50%, -50%);
}
.mma-event-inspector {
align-items: center;
margin-top: var(--sp-3);
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.mma-event-inspector > div {
flex: 0 0 190px;
}
.mma-event-inspector span,
.mma-event-inspector small {
color: var(--text-muted);
font-family: var(--font-num);
font-size: var(--fs-xs);
}
.mma-event-inspector h4 {
font-size: var(--fs-sm);
}
.mma-event-inspector p {
flex: 1;
margin: 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.55;
}
.mma-event-inspector small {
flex: 0 0 180px;
line-height: 1.45;
text-align: right;
}
.mma-clock__empty,
.mma-state-copy {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
margin-top: var(--sp-3);
padding: var(--sp-4);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text-muted);
background: var(--bg-surface);
}
.mma-clock__empty p {
margin: 0;
font-size: var(--fs-sm);
}
.mma-axis-board {
display: grid;
gap: var(--sp-3);
}
.mma-axis-board__head,
.mma-axis {
display: grid;
grid-template-columns: minmax(120px, 0.55fr) minmax(320px, 1.4fr) minmax(210px, 0.8fr);
gap: var(--sp-3);
align-items: stretch;
}
.mma-axis-board__head {
padding: 0 var(--sp-3);
color: var(--text-muted);
font-size: var(--fs-kicker);
font-weight: 700;
}
.mma-axis {
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.mma-axis__title {
display: flex;
flex-direction: column;
justify-content: center;
}
.mma-axis__title h4 {
font-size: var(--fs-body);
}
.mma-axis__modalities {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
.mma-measurement,
.mma-fusion {
min-width: 0;
padding: 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
}
.mma-measurement__label {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-sans);
letter-spacing: 0;
}
.mma-measurement strong,
.mma-fusion strong {
display: block;
margin-top: 7px;
color: var(--text-strong);
font-family: var(--font-num);
font-size: var(--fs-h3);
}
.mma-measurement small,
.mma-fusion small {
display: block;
margin-top: 2px;
color: var(--text-muted);
font-size: var(--fs-xs);
}
.mma-measurement.is-unavailable strong {
color: var(--text-muted);
font-size: var(--fs-body);
}
.mma-measurement details {
margin-top: var(--sp-2);
color: var(--text-muted);
font-size: var(--fs-xs);
}
.mma-measurement summary {
cursor: pointer;
}
.mma-measurement dl {
display: grid;
gap: 3px;
margin: 7px 0 0;
}
.mma-measurement dl div {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 5px;
}
.mma-measurement dt,
.mma-measurement dd {
margin: 0;
overflow-wrap: anywhere;
}
.mma-fusion {
border-color: var(--border-strong);
}
.mma-fusion.is-fused {
background: var(--info-tint);
}
.mma-fusion.is-text-only {
background: var(--accent-tint);
}
.mma-fusion p {
margin: 8px 0 0;
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.5;
}
.mma-no-total {
padding: 5px 8px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.mma-privacy {
display: grid;
gap: var(--sp-3);
}
.mma-boundary-copy {
max-width: 850px;
margin-bottom: 0;
}
.mma-privacy-ledger {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-2);
margin: 0;
}
.mma-privacy-ledger > div {
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.mma-privacy-ledger dt {
color: var(--text-muted);
font-size: var(--fs-xs);
}
.mma-privacy-ledger dd {
margin: 4px 0 0;
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 680;
}
.mma-consent-form,
.mma-privacy-actions,
.mma-delete-fieldset {
display: grid;
gap: var(--sp-3);
}
.mma-consent-form,
.mma-privacy-actions {
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.mma-consent-form > label:first-child {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
gap: var(--sp-3);
align-items: center;
color: var(--text-body);
font-size: var(--fs-sm);
font-weight: 650;
}
.mma-consent-form select {
min-height: 38px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text-strong);
background: var(--bg-surface-2);
font: inherit;
}
.mma-check {
display: flex;
align-items: flex-start;
gap: 9px;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.5;
}
.mma-check input {
width: 17px;
height: 17px;
margin: 2px 0 0;
accent-color: var(--accent);
flex: none;
}
.mma-consent-form .vg-btn,
.mma-privacy-actions > .vg-btn,
.mma-delete-fieldset .vg-btn {
justify-self: start;
}
.mma-raw-access strong,
.mma-practice-cta strong {
color: var(--text-strong);
font-size: var(--fs-sm);
}
.mma-raw-access p {
margin: 4px 0 0;
color: var(--text-muted);
font-size: var(--fs-xs);
}
.mma-raw-asset {
display: grid;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.mma-scene-player {
display: grid;
gap: var(--sp-2);
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
}
.mma-scene-player__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
}
.mma-scene-player__head > div {
display: grid;
gap: 2px;
min-width: 0;
}
.mma-scene-player__head span {
color: var(--text-muted);
font-family: var(--font-num);
font-size: var(--fs-xs);
}
.mma-scene-player audio {
width: 100%;
min-height: 42px;
color-scheme: light dark;
}
.mma-scene-player audio:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 3px;
}
.mma-playback-status.is-error {
color: var(--crit-text);
}
.mma-delete-fieldset {
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center;
margin: 0;
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
.mma-delete-fieldset legend {
padding: 0 5px;
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 700;
}
.mma-delete-fieldset .vg-btn {
justify-self: end;
}
.mma-deletion-history {
display: grid;
gap: var(--sp-2);
}
.mma-deletion-history > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--sp-2);
color: var(--text-body);
font-size: var(--fs-sm);
}
.mma-deletion-history small {
color: var(--text-muted);
}
.mma-action-message {
margin: 0;
padding: 9px 11px;
border-radius: var(--radius-sm);
color: var(--text-body);
background: var(--bg-surface);
font-size: var(--fs-sm);
}
.mma-action-message.is-success {
color: var(--pos-text);
background: var(--pos-tint);
}
.mma-action-message.is-error {
color: var(--crit-text);
background: var(--crit-tint);
}
.mma-practice-cta {
align-items: center;
padding: var(--sp-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--accent-tint);
}
.mma-practice-cta > div {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
}
.mma-practice-cta p {
margin-top: 3px;
}
.mma-card.is-loading > p {
margin: 0;
color: var(--text-muted);
font-size: var(--fs-sm);
}
.mma-card.is-error .mma-state-copy {
color: var(--crit-text);
}
.mma-state-copy {
margin-top: 0;
}
.mma-state-copy h2 {
font-size: var(--fs-h3);
}
.mma-state-copy .vg-btn {
margin-top: var(--sp-3);
}
@media (max-width: 980px) {
.mma-axis-board__head {
display: none;
}
.mma-axis {
grid-template-columns: 120px minmax(0, 1fr);
}
.mma-fusion {
grid-column: 2;
}
.mma-event-inspector {
align-items: flex-start;
flex-wrap: wrap;
}
.mma-event-inspector > div,
.mma-event-inspector small {
flex: 1 1 180px;
}
.mma-event-inspector p {
flex: 1 1 100%;
order: 3;
}
.mma-event-inspector small {
text-align: right;
}
}
@media (max-width: 720px) {
.mma-card {
gap: var(--sp-4);
padding: var(--sp-4);
}
.mma-header,
.mma-section-head,
.mma-practice-cta {
align-items: flex-start;
flex-direction: column;
}
.mma-header__badges {
justify-content: flex-start;
}
.mma-clock,
.mma-axis-board,
.mma-privacy {
padding: var(--sp-3);
}
.mma-axis {
grid-template-columns: minmax(0, 1fr);
}
.mma-axis__title,
.mma-fusion {
grid-column: 1;
}
.mma-axis__title {
flex-direction: row;
align-items: baseline;
justify-content: flex-start;
gap: var(--sp-2);
}
.mma-privacy-ledger {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mma-delete-fieldset {
grid-template-columns: 1fr;
}
.mma-delete-fieldset .vg-btn {
justify-self: start;
}
.mma-practice-cta .vg-btn {
width: 100%;
}
.mma-scene-player__head {
align-items: stretch;
flex-direction: column;
}
.mma-scene-player__head .vg-btn {
width: 100%;
}
}
@media (max-width: 460px) {
.mma-card {
padding: var(--sp-3);
}
.mma-axis__modalities,
.mma-privacy-ledger {
grid-template-columns: minmax(0, 1fr);
}
.mma-consent-form > label:first-child,
.mma-deletion-history > div {
grid-template-columns: minmax(0, 1fr);
}
.mma-deletion-history small {
justify-self: start;
}
.mma-event-inspector small {
text-align: left;
}
}
@media (prefers-reduced-motion: reduce) {
.mma-playhead {
transition: none;
}
}

View file

@ -0,0 +1,385 @@
import { api, apiUrl, supplementalApi } from "../../lib/api";
import type { components } from "../../lib/api.gen";
type ApiSchema<Name extends keyof components["schemas"]> =
components["schemas"][Name];
type WireMetadata = ApiSchema<"MultimodalSessionMetadataResponse">;
type WireRawAudio = ApiSchema<"RawAudioAccessResponse">;
export type MultimodalConsentRequest = ApiSchema<"MultimodalConsentRequest">;
export type MultimodalConsentResponse = ApiSchema<"MultimodalConsentResponse">;
export type MultimodalWithdrawalRequest =
ApiSchema<"MultimodalWithdrawalRequest">;
export type MultimodalDeletionRequest =
ApiSchema<"MultimodalDeletionRequest">;
export type MultimodalDeletionResponse =
ApiSchema<"MultimodalDeletionRequestResponse">;
export type AllianceAxis = "goal" | "task" | "bond";
export type AllianceModality = "text" | "voice";
export type MeasurementStatus = "ready" | "missing" | "error";
export type ConsentStatus = "granted" | "withdrawn" | "not_granted";
export type VoiceEventType =
| "silence"
| "overlap"
| "interruption"
| "prosody"
| "pace"
| "audio_quality";
export interface MultimodalConsentSnapshot {
consent_snapshot_id: string;
sequence_no: number;
consent_status: ConsentStatus;
retain_audio: boolean;
retain_derived_features: boolean;
transcript_retained: boolean;
retention_days: number | null;
policy_version: string;
reason_code: string | null;
created_at: string;
}
export interface MultimodalTimeline {
timeline_id: string;
audio_duration_ms: number;
clock_version: string;
word_count: number;
event_count: number;
created_at: string;
derived_features_available: boolean;
}
export interface MultimodalWordTimestamp {
timeline_id: string;
word_index: number;
start_ms: number;
end_ms: number;
speaker: "learner" | "client";
token_hash: string;
}
export interface MultimodalVoiceEvent {
timeline_id: string;
event_id: string;
event_type: VoiceEventType;
start_ms: number;
end_ms: number;
actor: "learner" | "client" | "both" | "channel";
observed_feature: string;
uncertainty: number;
source: "observed_audio_runtime" | "stt_word_timestamps";
claim_scope: "interaction_signal";
clinical_claim_allowed: false;
}
export interface MultimodalMeasurement {
measurement_id: string;
axis: AllianceAxis;
modality: AllianceModality;
status: MeasurementStatus;
value: number | null;
confidence: number | null;
uncertainty: number;
evidence_refs: string[];
model_run_id: string | null;
instrument_id: string;
instrument_version: string;
model_name: string;
prompt_version: string;
source_kind: string;
error_code: string | null;
created_at: string;
}
export interface MultimodalFusionDecision {
fusion_record_id: string;
axis: AllianceAxis;
status: MeasurementStatus;
value: number | null;
uncertainty: number;
modalities_used: AllianceModality[];
measurement_ids: string[];
fusion_applied: boolean;
calibration_id: string | null;
benchmark_version: string | null;
incremental_gain: number | null;
counterevidence: string[];
created_at: string;
}
export interface MultimodalDeletionRecord {
deletion_request_id: string;
scopes: Array<"audio" | "derived_features">;
request_reason: string;
requested_at: string;
completed_scopes: Array<"audio" | "derived_features">;
}
export interface RawAudioAsset {
audio_asset_id: string;
session_id: string;
media_type: string;
byte_size: number;
duration_ms: number;
retained_until: string;
created_at: string;
}
export interface MultimodalAllianceReadModel {
session_id: string;
learner_id: string;
clinical_claim_allowed: false;
consent_snapshots: MultimodalConsentSnapshot[];
timelines: MultimodalTimeline[];
word_timestamps: MultimodalWordTimestamp[];
voice_events: MultimodalVoiceEvent[];
measurements: MultimodalMeasurement[];
fusion_decisions: MultimodalFusionDecision[];
deletion_requests: MultimodalDeletionRecord[];
}
type Row = Record<string, unknown>;
function objectRows(value: unknown): Row[] {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is Row => Boolean(item) && typeof item === "object",
);
}
function text(row: Row, key: string, fallback = ""): string {
const value = row[key];
return typeof value === "string" ? value : fallback;
}
function optionalText(row: Row, key: string): string | null {
const value = row[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
function numberValue(row: Row, key: string, fallback = 0): number {
const value = row[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
function optionalNumber(row: Row, key: string): number | null {
const value = row[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function bool(row: Row, key: string): boolean {
return row[key] === true;
}
function stringArray(row: Row, key: string): string[] {
const value = row[key];
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function isAllianceAxis(value: string): value is AllianceAxis {
return value === "goal" || value === "task" || value === "bond";
}
function isModality(value: string): value is AllianceModality {
return value === "text" || value === "voice";
}
function measurementStatus(value: string): MeasurementStatus {
return value === "ready" || value === "error" ? value : "missing";
}
function consentStatus(value: string): ConsentStatus {
if (value === "granted" || value === "withdrawn") return value;
return "not_granted";
}
function voiceEventType(value: string): VoiceEventType {
const allowed: VoiceEventType[] = [
"silence",
"overlap",
"interruption",
"prosody",
"pace",
"audio_quality",
];
return allowed.includes(value as VoiceEventType)
? (value as VoiceEventType)
: "audio_quality";
}
function normalizeMetadata(payload: WireMetadata): MultimodalAllianceReadModel {
const consentSnapshots = objectRows(payload.consent_snapshots).map((row) => ({
consent_snapshot_id: text(row, "consent_snapshot_id"),
sequence_no: numberValue(row, "sequence_no"),
consent_status: consentStatus(text(row, "consent_status")),
retain_audio: bool(row, "retain_audio"),
retain_derived_features: bool(row, "retain_derived_features"),
transcript_retained: bool(row, "transcript_retained"),
retention_days: optionalNumber(row, "retention_days"),
policy_version: text(row, "policy_version"),
reason_code: optionalText(row, "reason_code"),
created_at: text(row, "created_at"),
}));
const timelines = objectRows(payload.timelines).map((row) => ({
timeline_id: text(row, "timeline_id"),
audio_duration_ms: numberValue(row, "audio_duration_ms"),
clock_version: text(row, "clock_version"),
word_count: numberValue(row, "word_count"),
event_count: numberValue(row, "event_count"),
created_at: text(row, "created_at"),
derived_features_available: bool(row, "derived_features_available"),
}));
const words = objectRows(payload.word_timestamps)
.map((row) => ({
timeline_id: text(row, "timeline_id"),
word_index: numberValue(row, "word_index"),
start_ms: numberValue(row, "start_ms"),
end_ms: numberValue(row, "end_ms"),
speaker: text(row, "speaker") === "learner" ? "learner" as const : "client" as const,
token_hash: text(row, "token_hash"),
}))
.sort((a, b) => a.word_index - b.word_index);
const events = objectRows(payload.voice_events).map((row) => ({
timeline_id: text(row, "timeline_id"),
event_id: text(row, "event_id"),
event_type: voiceEventType(text(row, "event_type")),
start_ms: numberValue(row, "start_ms"),
end_ms: numberValue(row, "end_ms"),
actor: (["learner", "client", "both", "channel"].includes(text(row, "actor"))
? text(row, "actor")
: "channel") as MultimodalVoiceEvent["actor"],
observed_feature: text(row, "observed_feature"),
uncertainty: numberValue(row, "uncertainty", 1),
source: text(row, "source") === "stt_word_timestamps"
? "stt_word_timestamps" as const
: "observed_audio_runtime" as const,
claim_scope: "interaction_signal" as const,
clinical_claim_allowed: false as const,
}));
const measurements = objectRows(payload.measurements).flatMap((row) => {
const axis = text(row, "axis");
const modality = text(row, "modality");
if (!isAllianceAxis(axis) || !isModality(modality)) return [];
return [{
measurement_id: text(row, "measurement_id"),
axis,
modality,
status: measurementStatus(text(row, "status")),
value: optionalNumber(row, "value"),
confidence: optionalNumber(row, "confidence"),
uncertainty: numberValue(row, "uncertainty", 1),
evidence_refs: stringArray(row, "evidence_refs"),
model_run_id: optionalText(row, "model_run_id"),
instrument_id: text(row, "instrument_id"),
instrument_version: text(row, "instrument_version"),
model_name: text(row, "model_name"),
prompt_version: text(row, "prompt_version"),
source_kind: text(row, "source_kind"),
error_code: optionalText(row, "error_code"),
created_at: text(row, "created_at"),
}];
});
const fusions = objectRows(payload.fusion_decisions).flatMap((row) => {
const axis = text(row, "axis");
if (!isAllianceAxis(axis)) return [];
return [{
fusion_record_id: text(row, "fusion_record_id"),
axis,
status: measurementStatus(text(row, "status")),
value: optionalNumber(row, "value"),
uncertainty: numberValue(row, "uncertainty", 1),
modalities_used: stringArray(row, "modalities_used").filter(isModality),
measurement_ids: stringArray(row, "measurement_ids"),
fusion_applied: bool(row, "fusion_applied"),
calibration_id: optionalText(row, "calibration_id"),
benchmark_version: optionalText(row, "benchmark_version"),
incremental_gain: optionalNumber(row, "incremental_gain"),
counterevidence: stringArray(row, "counterevidence"),
created_at: text(row, "created_at"),
}];
});
const deletionRequests = objectRows(payload.deletion_requests).map((row) => ({
deletion_request_id: text(row, "deletion_request_id"),
scopes: stringArray(row, "scopes").filter(
(scope): scope is "audio" | "derived_features" =>
scope === "audio" || scope === "derived_features",
),
request_reason: text(row, "request_reason"),
requested_at: text(row, "requested_at"),
completed_scopes: stringArray(row, "completed_scopes").filter(
(scope): scope is "audio" | "derived_features" =>
scope === "audio" || scope === "derived_features",
),
}));
return {
session_id: payload.session_id,
learner_id: payload.learner_id,
clinical_claim_allowed: false,
consent_snapshots: consentSnapshots,
timelines,
word_timestamps: words,
voice_events: events,
measurements,
fusion_decisions: fusions,
deletion_requests: deletionRequests,
};
}
function normalizeRawAudio(payload: WireRawAudio): RawAudioAsset[] {
return objectRows(payload.items).map((row) => ({
audio_asset_id: text(row, "audio_asset_id"),
session_id: text(row, "session_id"),
media_type: text(row, "media_type"),
byte_size: numberValue(row, "byte_size"),
duration_ms: numberValue(row, "duration_ms"),
retained_until: text(row, "retained_until"),
created_at: text(row, "created_at"),
}));
}
function basePath(sessionId: string): string {
return `/sessions/${encodeURIComponent(sessionId)}/multimodal-alliance`;
}
export function rawAudioPlaybackUrl(
sessionId: string,
audioAssetId: string,
): string {
return apiUrl(
`${basePath(sessionId)}/raw-audio/${encodeURIComponent(audioAssetId)}`,
);
}
export const multimodalAllianceApi = {
getMetadata: (sessionId: string, signal?: AbortSignal) =>
supplementalApi
.get<WireMetadata>(basePath(sessionId), { signal })
.then(normalizeMetadata),
getRawAudio: (sessionId: string, signal?: AbortSignal) =>
supplementalApi
.get<WireRawAudio>(`${basePath(sessionId)}/raw-audio`, { signal })
.then(normalizeRawAudio),
saveConsent: (sessionId: string, body: MultimodalConsentRequest) =>
api.post<MultimodalConsentResponse>(`${basePath(sessionId)}/consent`, body),
withdraw: (sessionId: string, body: MultimodalWithdrawalRequest) =>
api.post<MultimodalConsentResponse>(`${basePath(sessionId)}/withdraw`, body),
requestDeletion: (sessionId: string, body: MultimodalDeletionRequest) =>
api.post<MultimodalDeletionResponse>(
`${basePath(sessionId)}/deletion-requests`,
body,
),
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
import { api, supplementalApi } from "../../lib/api";
import type { components } from "../../lib/api.gen";
type ApiSchema<Name extends keyof components["schemas"]> =
components["schemas"][Name];
/** Wire DTO는 생성된 OpenAPI 계약을 그대로 사용한다. */
export type OutcomeAxisValues = ApiSchema<"OutcomeAxisValues">;
export type OutcomeAxis = keyof OutcomeAxisValues;
export type TrajectoryStatus = ApiSchema<"AxisTrajectoryAssessment">["status"];
export type ObservationStatus = ApiSchema<"OutcomeObservationResponse">["status"];
export type RelationshipEventType = ApiSchema<"RelationshipMemoryProjection">["event_type"];
export type SyntheticExpectedDistribution =
ApiSchema<"SyntheticExpectedDistribution">;
export type SyntheticExpectedArc = ApiSchema<"ExpectedArcLabelResponse">;
export type OutcomeAxisObservation = ApiSchema<"OutcomeObservationResponse">;
export type SafetySignalReference = ApiSchema<"SafetySignalReference">;
export type AxisTrajectoryAssessment = ApiSchema<"AxisTrajectoryAssessment">;
export type SessionTrajectoryAssessment = ApiSchema<"SessionTrajectoryAssessment">;
export type LongitudinalOutcomeAssessment =
ApiSchema<"LongitudinalOutcomeAssessment">;
export type RelationshipMemoryProjection =
ApiSchema<"RelationshipMemoryProjection">;
export type OutcomeTrajectoryResponse = ApiSchema<"OutcomeTrajectoryResponse">;
export type OutcomeObservationSubmissionRequest =
ApiSchema<"OutcomeObservationSubmissionRequest">;
export type OutcomeObservationSubmissionResponse =
ApiSchema<"OutcomeObservationSubmissionResponse">;
function trajectoryPath(sessionId: string): string {
return `/sessions/${encodeURIComponent(sessionId)}`;
}
export const outcomeTrajectoryApi = {
get: (sessionId: string, signal?: AbortSignal) =>
supplementalApi.get<OutcomeTrajectoryResponse>(
`${trajectoryPath(sessionId)}/outcome-trajectory`,
{ signal },
),
submitObservation: (
sessionId: string,
body: OutcomeObservationSubmissionRequest,
) =>
api.post<OutcomeObservationSubmissionResponse>(
`${trajectoryPath(sessionId)}/outcome-observations`,
body,
),
};

View file

@ -0,0 +1,775 @@
/* G3 균열·수선 원장. 기존 Vignette 임상 워크벤치 토큰을 보존해 확장한다. */
.rr-card {
min-width: 0;
display: grid;
gap: var(--sp-5);
padding: var(--sp-5);
overflow: hidden;
border-color: color-mix(in srgb, var(--info-solid) 18%, var(--border-subtle));
background: color-mix(in srgb, var(--bg-surface) 97%, var(--info-tint));
box-shadow: var(--shadow-sm);
}
.rr-card__head {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: var(--sp-4);
}
.rr-card__head h2,
.rr-card--state h2 {
margin: 5px 0 0;
color: var(--text-strong);
font-size: clamp(1.18rem, 1.7vw, var(--fs-h2));
font-weight: 730;
letter-spacing: -0.018em;
line-height: 1.26;
}
.rr-card__head p,
.rr-card--state p {
max-width: 72ch;
margin: 8px 0 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.62;
}
.rr-card__flags {
min-width: 0;
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
.rr-card--state {
align-content: start;
}
.rr-card--state > div:last-child {
margin-top: var(--sp-2);
}
.rr-card--error {
border-color: color-mix(in srgb, var(--warn-solid) 34%, var(--border-subtle));
}
.rr-contract {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--sp-3);
align-items: center;
padding: var(--sp-4);
border: 1px solid color-mix(in srgb, var(--info-solid) 24%, var(--border-subtle));
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--info-tint) 70%, var(--bg-surface-2));
box-shadow: var(--glass-inset-shadow);
}
.rr-contract strong {
color: var(--text-strong);
font-size: var(--fs-sm);
}
.rr-contract p {
margin: 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.5;
}
.rr-card code {
max-width: 100%;
padding: 4px 7px;
overflow-wrap: anywhere;
border-radius: var(--radius-sm);
color: var(--info-text);
background: color-mix(in srgb, var(--info-tint) 76%, var(--bg-surface));
font-family: var(--font-num);
font-size: 11px;
line-height: 1.35;
}
.rr-episodes {
min-width: 0;
display: grid;
gap: var(--sp-5);
}
.rr-episode {
min-width: 0;
display: grid;
gap: var(--sp-4);
padding: var(--sp-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--bg-surface);
box-shadow: var(--glass-inset-shadow);
}
.rr-episode__head {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-4);
}
.rr-episode__eyebrow {
display: block;
overflow-wrap: anywhere;
color: var(--text-muted);
font-family: var(--font-num);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.035em;
}
.rr-episode__head h3 {
margin: 5px 0 0;
color: var(--text-strong);
font-size: clamp(1.08rem, 1.4vw, 1.32rem);
font-weight: 740;
letter-spacing: -0.016em;
line-height: 1.3;
}
.rr-episode__badges {
min-width: 0;
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
.rr-current {
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--bg-surface-2);
overflow: hidden;
}
.rr-current > div {
min-width: 0;
display: grid;
gap: 4px;
padding: var(--sp-3) var(--sp-4);
}
.rr-current > div + div {
border-left: 1px solid var(--border-subtle);
}
.rr-current small {
color: var(--text-muted);
font-size: 11px;
font-weight: 650;
}
.rr-current strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 730;
}
.rr-provenance {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: var(--sp-3);
align-items: stretch;
padding: var(--sp-4);
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle));
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--accent-tint) 50%, var(--bg-surface-2));
}
.rr-provenance__node {
min-width: 0;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 9px;
align-items: start;
padding: var(--sp-3);
border: 1px solid color-mix(in srgb, var(--accent) 16%, var(--border-subtle));
border-radius: var(--radius);
background: var(--bg-surface);
box-shadow: var(--shadow-xs);
}
.rr-provenance__step {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--accent-contrast);
background: var(--accent);
font-family: var(--font-num);
font-size: 12px;
font-weight: 760;
}
.rr-provenance__node small {
display: block;
color: var(--text-muted);
font-family: var(--font-num);
font-size: 9px;
font-weight: 720;
letter-spacing: 0.06em;
}
.rr-provenance__node strong {
display: block;
margin-top: 2px;
color: var(--text-strong);
font-size: var(--fs-sm);
}
.rr-provenance__node p {
margin: 4px 0 0;
overflow-wrap: anywhere;
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.48;
}
.rr-provenance__node > code {
grid-column: 2;
justify-self: start;
}
.rr-provenance__connector {
align-self: center;
color: var(--text-accent);
font-size: 1.2rem;
font-weight: 750;
}
.rr-provenance__human {
grid-column: 1 / -1;
min-width: 0;
display: flex;
align-items: center;
gap: var(--sp-3);
padding-top: var(--sp-3);
border-top: 1px solid color-mix(in srgb, var(--accent) 16%, var(--border-subtle));
}
.rr-provenance__human > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-body);
font-size: var(--fs-xs);
}
.rr-evidence-grid {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-4);
}
.rr-evidence-grid > section {
min-width: 0;
padding: var(--sp-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--bg-surface-2);
}
.rr-section-head,
.rr-safety__head,
.rr-practice__head,
.rr-correction__intro {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
}
.rr-section-head h4,
.rr-safety h4,
.rr-correction h4 {
margin: 0;
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 730;
line-height: 1.35;
}
.rr-section-head > span {
flex: 0 0 auto;
color: var(--text-muted);
font-size: 11px;
font-weight: 650;
}
.rr-evidence-list,
.rr-counterevidence,
.rr-safety__items {
min-width: 0;
display: grid;
gap: 7px;
margin: var(--sp-3) 0 0;
padding: 0;
list-style: none;
}
.rr-evidence-list button {
width: 100%;
min-width: 0;
display: grid;
gap: 3px;
padding: 9px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
color: var(--text-body);
background: var(--bg-surface);
font: inherit;
text-align: left;
cursor: pointer;
transition:
transform var(--dur-fast) var(--ease-out),
border-color var(--dur-base) var(--ease-out),
background-color var(--dur-base) var(--ease-out);
}
.rr-evidence-list button:hover {
border-color: color-mix(in srgb, var(--accent) 38%, var(--border-subtle));
background: var(--accent-tint);
transform: translateY(-1px);
}
.rr-evidence-list button:active {
transform: translateY(1px);
}
.rr-evidence-list button span {
color: var(--text-accent);
font-size: 11px;
font-weight: 720;
}
.rr-evidence-list button small {
min-width: 0;
display: -webkit-box;
overflow: hidden;
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.rr-counterevidence li {
position: relative;
padding-left: 14px;
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.5;
}
.rr-counterevidence li::before {
content: "";
position: absolute;
top: 0.62em;
left: 0;
width: 6px;
height: 6px;
border: 1px solid color-mix(in srgb, var(--warn-solid) 62%, var(--border-subtle));
border-radius: 50%;
background: var(--warn-tint);
}
.rr-muted {
margin: var(--sp-3) 0 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.5;
}
.rr-safety {
min-width: 0;
padding: var(--sp-4);
border: 1px solid color-mix(in srgb, var(--crit-solid) 24%, var(--border-subtle));
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--crit-tint) 46%, var(--bg-surface-2));
}
.rr-safety__head p,
.rr-practice__head p,
.rr-correction__intro p {
margin: 4px 0 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.48;
}
.rr-safety__items li {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: var(--sp-3);
align-items: center;
padding: 9px 10px;
border: 1px solid color-mix(in srgb, var(--crit-solid) 18%, var(--border-subtle));
border-radius: var(--radius);
background: var(--bg-surface);
}
.rr-safety__items li > div {
min-width: 0;
display: grid;
gap: 2px;
}
.rr-safety__items strong {
color: var(--text-strong);
font-size: var(--fs-xs);
}
.rr-safety__items li > div span {
color: var(--text-muted);
font-size: 11px;
}
.rr-safety__items button {
padding: 4px 0;
border: 0;
color: var(--text-accent);
background: transparent;
font: inherit;
font-size: 11px;
font-weight: 720;
cursor: pointer;
}
.rr-practice {
min-width: 0;
margin: 0;
padding: var(--sp-4);
border: 1px solid color-mix(in srgb, var(--pos-solid) 24%, var(--border-subtle));
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--pos-tint) 44%, var(--bg-surface-2));
}
.rr-practice legend,
.rr-correction__evidence legend {
padding: 0 6px;
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 740;
}
.rr-practice__items {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: var(--sp-3);
}
.rr-practice__items label,
.rr-correction__evidence label {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 9px;
padding: 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
color: var(--text-body);
background: var(--bg-surface);
font-size: var(--fs-xs);
line-height: 1.45;
cursor: pointer;
}
.rr-practice input,
.rr-correction__evidence input {
flex: 0 0 auto;
width: 16px;
height: 16px;
margin: 1px 0 0;
accent-color: var(--accent);
}
.rr-practice__items label:has(input:checked) {
border-color: color-mix(in srgb, var(--pos-solid) 44%, var(--border-subtle));
color: var(--text-strong);
background: color-mix(in srgb, var(--pos-tint) 58%, var(--bg-surface));
}
.rr-correction {
min-width: 0;
padding: var(--sp-4);
border: 1px solid color-mix(in srgb, var(--info-solid) 24%, var(--border-subtle));
border-radius: var(--radius-lg);
background: var(--bg-surface-2);
}
.rr-correction > summary {
width: fit-content;
color: var(--text-accent);
font-size: var(--fs-sm);
font-weight: 730;
cursor: pointer;
}
.rr-correction form {
display: grid;
gap: var(--sp-4);
margin-top: var(--sp-4);
padding-top: var(--sp-4);
border-top: 1px solid var(--border-subtle);
}
.rr-correction__intro code {
flex: 0 1 auto;
}
.rr-correction__grid {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.rr-correction__grid > label,
.rr-correction__field {
min-width: 0;
display: grid;
gap: 6px;
}
.rr-correction__grid label > span,
.rr-correction__field > span {
color: var(--text-strong);
font-size: var(--fs-xs);
font-weight: 700;
}
.rr-correction select,
.rr-correction textarea {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 9px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
color: var(--text-strong);
background: var(--bg-surface);
font: inherit;
font-size: var(--fs-sm);
}
.rr-correction textarea {
resize: vertical;
line-height: 1.5;
}
.rr-correction input[type="range"] {
width: 100%;
min-height: 32px;
accent-color: var(--accent);
}
.rr-correction__evidence {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0;
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
}
.rr-correction__evidence legend {
grid-column: 1 / -1;
}
.rr-correction__evidence label > span {
min-width: 0;
display: grid;
gap: 2px;
}
.rr-correction__evidence b {
color: var(--text-strong);
font-size: 11px;
}
.rr-correction__evidence small {
display: -webkit-box;
overflow: hidden;
color: var(--text-body);
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.rr-correction__actions {
min-width: 0;
display: flex;
align-items: center;
gap: var(--sp-3);
}
.rr-correction__message {
min-width: 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.45;
}
.rr-correction__message--success {
color: var(--pos-text);
}
.rr-correction__message--error {
color: var(--crit-text);
}
.rr-correction summary:focus-visible,
.rr-evidence-list button:focus-visible,
.rr-safety__items button:focus-visible,
.rr-practice input:focus-visible,
.rr-correction input:focus-visible,
.rr-correction select:focus-visible,
.rr-correction textarea:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.rr-skeleton {
min-height: 98px;
border-radius: var(--radius);
background: linear-gradient(
90deg,
var(--bg-surface-2) 20%,
color-mix(in srgb, var(--bg-surface-2) 72%, var(--border-subtle)) 50%,
var(--bg-surface-2) 80%
);
background-size: 220% 100%;
animation: rr-shimmer 1.45s linear infinite;
}
.rr-skeleton--heading {
width: min(560px, 84%);
min-height: 62px;
}
.rr-skeleton--rail {
min-height: 126px;
}
.rr-skeleton-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-4);
}
@keyframes rr-shimmer {
to {
background-position: -220% 0;
}
}
@media (max-width: 760px) {
.rr-card {
gap: var(--sp-4);
padding: var(--sp-4);
}
.rr-card__head,
.rr-episode__head,
.rr-section-head,
.rr-safety__head,
.rr-practice__head,
.rr-correction__intro {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.rr-card__flags,
.rr-episode__badges {
justify-content: flex-start;
}
.rr-contract {
grid-template-columns: minmax(0, 1fr);
}
.rr-contract code {
justify-self: start;
}
.rr-episode {
padding: var(--sp-4);
}
.rr-current {
grid-template-columns: minmax(0, 1fr);
}
.rr-current > div + div {
border-top: 1px solid var(--border-subtle);
border-left: 0;
}
.rr-provenance,
.rr-evidence-grid,
.rr-practice__items,
.rr-correction__grid,
.rr-correction__evidence,
.rr-skeleton-grid {
grid-template-columns: minmax(0, 1fr);
}
.rr-provenance__connector {
justify-self: center;
transform: rotate(90deg);
}
.rr-safety__items li {
grid-template-columns: minmax(0, 1fr) auto;
}
.rr-safety__items li > button,
.rr-safety__items li > code {
grid-column: 1 / -1;
justify-self: start;
}
.rr-correction__actions {
align-items: stretch;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
.rr-skeleton {
animation: none;
}
.rr-evidence-list button {
transition: none;
}
.rr-evidence-list button:hover,
.rr-evidence-list button:active {
transform: none;
}
}

View file

@ -0,0 +1,39 @@
import { api, supplementalApi } from "../../lib/api";
import type { components } from "../../lib/api.gen";
type ApiSchema<Name extends keyof components["schemas"]> =
components["schemas"][Name];
/** Wire DTO는 generated OpenAPI 계약을 그대로 사용한다. */
export type RuptureObservation = ApiSchema<"RuptureObservationResponse">;
export type RuptureReconciliation =
ApiSchema<"RuptureReconciliationResponse">;
export type RuptureSafetyReference =
ApiSchema<"RuptureSafetyReferenceResponse">;
export type RuptureEpisode = ApiSchema<"RuptureEpisodeResponse">;
export type RuptureRepairReadModel =
ApiSchema<"RuptureRepairReadModelResponse">;
export type HumanRuptureCorrectionRequest =
ApiSchema<"HumanRuptureCorrectionRequest">;
export type HumanRuptureCorrectionResponse =
ApiSchema<"HumanRuptureCorrectionResponse">;
function rupturePath(sessionId: string): string {
return `/sessions/${encodeURIComponent(sessionId)}/ruptures`;
}
export const ruptureRepairApi = {
get: (sessionId: string, signal?: AbortSignal) =>
supplementalApi.get<RuptureRepairReadModel>(rupturePath(sessionId), {
signal,
}),
correct: (
sessionId: string,
episodeId: string,
body: HumanRuptureCorrectionRequest,
) =>
api.post<HumanRuptureCorrectionResponse>(
`${rupturePath(sessionId)}/${encodeURIComponent(episodeId)}/corrections`,
body,
),
};

View file

@ -101,6 +101,11 @@
grid-template-columns: repeat(2, minmax(130px, auto));
gap: var(--sp-2);
}
.sr-practice-result-jump {
grid-column: 1 / -1;
width: 100%;
min-height: 44px;
}
.sr-stat {
min-width: 0;
display: grid;
@ -134,6 +139,72 @@
color: var(--accent-deep);
}
/* 실행 인계로 시작한 회기는 리뷰에서도 출처와 다음 행동을 한 번만 유지한다. */
.sr-practice-return {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(300px, 0.9fr) auto;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5);
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border-subtle));
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent-tint) 82%, var(--bg-surface));
}
.sr-practice-return__copy {
min-width: 0;
}
.sr-practice-return__copy h2 {
margin: 5px 0 0;
color: var(--text-strong);
font-size: var(--fs-h3);
line-height: 1.35;
}
.sr-practice-return__copy p {
max-width: 64ch;
margin: 7px 0 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.6;
}
.sr-practice-return__facts {
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-2);
margin: 0;
}
.sr-practice-return__facts > div {
min-width: 0;
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.sr-practice-return__facts dt {
color: var(--text-muted);
font-size: var(--fs-xs);
}
.sr-practice-return__facts dd {
margin: 4px 0 0;
color: var(--text-strong);
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
}
.sr-practice-return > .vg-btn {
min-height: 44px;
justify-self: end;
white-space: nowrap;
}
.sr-practice-return.is-error {
grid-template-columns: minmax(0, 1fr) auto;
border-color: color-mix(in srgb, var(--crit-text) 45%, var(--border-subtle));
background: var(--crit-tint);
}
/* 레이아웃 코어 (2026-07-15 재설계 같은 전환)
리뷰 요약은 상단 전폭 밴드(.sr-overview--band) 읽고 지나간다
(sticky 도킹/스크롤 추종 제거). 본문은 모든 폭에서 상단 가로 탭이
@ -206,16 +277,17 @@
margin: 0;
}
.sr-card--worksheet {
.sr-worksheet-panel {
grid-area: worksheet;
min-width: 0;
}
.sr-worksheet-panel > .sr-card--worksheet {
grid-area: auto;
}
.sr-card--transcript {
grid-area: transcript;
align-self: start;
}
.sr-session-id {
grid-area: session;
}
.sr-card {
min-width: 0;
box-shadow: var(--shadow-sm);
@ -246,6 +318,7 @@
font-size: var(--fs-sm);
font-weight: 700;
cursor: pointer;
min-height: 44px;
}
/* D6: 비활성 탭이 배경과 붙어 보이지 않으므로 사이마다 얇은 구분선을 세워
"여러 개 중 하나"임을 드러낸다. 가운데 탭이 활성일 때도 구분이 남도록 항상 그리고,
@ -1331,7 +1404,7 @@
/* Filled state: only a real client quote earns the high-contrast stage card. */
.sr-feedback--filled {
border-color: transparent;
background: linear-gradient(135deg, rgba(30, 39, 36, .95), rgba(30, 39, 36, .82));
background: var(--bg-stage);
box-shadow: var(--shadow-sm);
}
/* D1·D2: 장식 점과 영문용 넓은 자간을 빼고 한글 섹션 라벨로 맞춘다 */
@ -1356,7 +1429,7 @@
line-height: 1.65;
}
.sr-feedback--filled .sr-feedback__quote {
color: #d7ddda;
color: var(--text-on-stage);
}
.sr-feedback__src {
display: flex;
@ -1370,8 +1443,8 @@
font-size: 11px;
}
.sr-feedback--filled .sr-feedback__src {
border-top-color: rgba(255, 255, 255, 0.12);
color: #8a9794;
border-top-color: color-mix(in srgb, var(--text-on-stage) 12%, transparent);
color: var(--text-muted-on-stage);
}
.sr-points {
@ -1437,28 +1510,10 @@
border-radius: var(--radius-sm);
background: var(--accent-tint);
}
/* 배경 잎사귀 이미지 위에 그냥 얹히면 대비가 부족해 읽히지 않는다.
내용만큼만 차지하는 불투명 칩으로 감싸 본문 대비를 확보한다. */
.sr-session-id {
min-width: 0;
width: fit-content;
max-width: 100%;
margin: 0;
padding: 6px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-body);
font-family: var(--font-num);
font-size: var(--fs-xs);
line-height: 1.45;
overflow-wrap: anywhere;
}
[data-theme="dark"] .sr-head,
[data-theme="dark"] .sr-card,
[data-theme="dark"] .sr-overview {
background: linear-gradient(180deg, rgba(27, 42, 38, 0.92), rgba(18, 31, 28, 0.96));
background: var(--surface-gradient);
border-color: rgba(203, 227, 220, 0.13);
box-shadow:
0 16px 42px rgba(3, 9, 8, 0.24),
@ -1510,12 +1565,37 @@
[data-theme="dark"] .sr-turn--active .sr-turn__said {
background: rgba(125, 162, 148, 0.14);
}
[data-theme="dark"] .sr-practice-return {
border-color: rgba(125, 162, 148, 0.32);
background: rgba(32, 50, 45, 0.88);
}
[data-theme="dark"] .sr-practice-return__facts > div {
border-color: rgba(203, 227, 220, 0.13);
background: rgba(255, 255, 255, 0.055);
}
[data-theme="dark"] .sr-practice-return.is-error {
border-color: rgba(224, 139, 130, 0.42);
background: rgba(93, 42, 42, 0.3);
}
[data-theme="dark"] .sr-feedback--filled {
border-color: rgba(203, 227, 220, 0.12);
background: linear-gradient(135deg, rgba(12, 24, 21, 0.98), rgba(30, 44, 40, 0.92));
}
@media (max-width: 1180px) {
.sr-practice-return {
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr);
}
.sr-practice-return > .vg-btn {
grid-column: 1 / -1;
justify-self: start;
}
.sr-practice-return.is-error {
grid-template-columns: minmax(0, 1fr) auto;
}
.sr-practice-return.is-error > .vg-btn {
grid-column: auto;
}
.sr-overview {
grid-template-columns: minmax(0, 1fr);
}
@ -1541,6 +1621,17 @@
.sr-head__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sr-practice-return,
.sr-practice-return.is-error {
grid-template-columns: minmax(0, 1fr);
padding: var(--sp-4);
}
.sr-practice-return > .vg-btn,
.sr-practice-return.is-error > .vg-btn {
grid-column: auto;
justify-self: start;
min-height: 44px;
}
.sr-cols,
.sr-cols--supervisor,
.sr-root--empty .sr-cols,
@ -1582,6 +1673,19 @@
}
.sr-ws-toolbar .vg-btn {
width: 100%;
min-height: 44px;
}
.sr-actions .vg-btn,
.sr-prepost__actions .vg-btn,
.sr-teacher-review__actions .vg-btn {
min-height: 44px;
}
.sr-ws-evidence,
.sr-point__at {
display: inline-flex;
align-items: center;
min-width: 44px;
min-height: 44px;
}
.sr-chart__plot {
height: 156px;
@ -1614,6 +1718,12 @@
.sr-head__stats {
grid-template-columns: 1fr;
}
.sr-practice-return__facts {
grid-template-columns: 1fr;
}
.sr-practice-return > .vg-btn {
width: 100%;
}
.sr-stat {
padding: 8px 10px;
}
@ -1631,7 +1741,7 @@
}
.sr-actions .vg-btn {
flex-basis: 100%;
min-height: 38px;
min-height: 44px;
}
.sr-teacher-review__actions {
grid-template-columns: 1fr;
@ -1660,7 +1770,7 @@
}
.sr-chip-toggle {
flex: 1;
min-height: 34px;
min-height: 44px;
}
.sr-turn {
grid-template-columns: 1fr;
@ -1699,28 +1809,23 @@
}
.sr-cols--tab-transcript {
grid-template-areas:
"transcript"
"session";
grid-template-areas: "transcript";
}
.sr-cols--tab-insights {
grid-template-areas:
"insight"
"feedback"
"session";
"feedback";
}
.sr-cols--tab-worksheet {
grid-template-areas:
"worksheet"
"session";
grid-template-areas: "worksheet";
}
.sr-cols:not(.sr-cols--tab-transcript) .sr-card--transcript,
.sr-cols:not(.sr-cols--tab-insights) .sr-left,
.sr-cols:not(.sr-cols--tab-insights) .sr-right,
.sr-cols:not(.sr-cols--tab-worksheet) .sr-card--worksheet {
.sr-cols:not(.sr-cols--tab-worksheet) .sr-worksheet-panel {
display: none;
}