vignette/apps/web/src/pages/session-review/DeliberatePracticeCard.tsx
2026-08-09 19:38:04 +09:00

1508 lines
49 KiB
TypeScript

import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import { Badge, Button, Card, Kicker } from "../../components/ui";
import { ApiError } from "../../lib/api";
import {
practiceCounterevidenceLabel,
practiceCriterionLabel,
practiceLaunchPath,
type PracticeLaunchIntent,
} from "../../lib/practiceLaunchIntent";
import {
deliberatePracticeApi,
type DeliberatePracticeReadModel,
type PracticeAttemptItem,
type PracticeAttemptSubmissionRequest,
type PracticeAttemptSubmissionResponse,
type PracticeEpisodeItem,
type PracticePrescriptionItem,
type PracticeTeacherCorrectionRequest,
} from "./deliberatePracticeApi";
import "./deliberate-practice.css";
import { randomUuid } from "../../lib/uuid";
import { sha256Hex } from "../../lib/sha256";
interface PracticeTurn {
id: string;
turn_id?: string | null;
ts: string;
speaker: string;
who: string;
text: string;
}
interface DeliberatePracticeCardProps {
sessionId: string;
turns: PracticeTurn[];
isSupervisorView: boolean;
practiceLaunchIntent: PracticeLaunchIntent | null;
onJumpToTurn: (turnId: string) => void;
}
type LoadState = "loading" | "ready" | "empty" | "error";
type PracticeMode = PracticePrescriptionItem["activity_mode"];
type PracticeProgress = PracticeEpisodeItem["progress"];
type ClientResponse =
| "rejecting"
| "withdrawn"
| "compliance_only"
| "mixed"
| "engaged"
| "explicit_alignment";
type CriterionStatus = "observed" | "not_observed";
type Certainty = "high" | "medium" | "low";
type AttemptInput =
PracticeAttemptSubmissionRequest["episode"]["attempts"][number];
interface AttemptDraft {
criterionStatus: CriterionStatus;
clientResponse: ClientResponse;
phrase: string;
learnerClaimedSuccess: boolean;
certainty: Certainty;
counterevidence: string;
}
interface SubmissionIds {
submissionId: string;
episodeId: string;
familiarAttemptId: string;
transferAttemptId: string;
}
const MODE_COPY: Record<
PracticeMode,
{ index: string; label: string; short: string; description: string }
> = {
replay: {
index: "01",
label: "근거 장면 되감기",
short: "되감기",
description: "근거 발화 직전으로 돌아가 같은 장면을 다시 다룹니다.",
},
branch: {
index: "02",
label: "반응 분기",
short: "분기",
description: "내담자의 여러 반응을 미리 보지 않고 대응을 선택합니다.",
},
constrained_response: {
index: "03",
label: "제약 응답",
short: "제약 응답",
description: "정해진 길이와 필수 행동 안에서 개입을 더 선명하게 만듭니다.",
},
voice_retry: {
index: "04",
label: "음성 재시도",
short: "음성",
description: "같은 문장을 속도·쉼·억양 근거와 함께 다시 말합니다.",
},
difficulty_ladder: {
index: "05",
label: "난도 단계",
short: "난도 단계",
description: "행동은 유지하고 장면의 난도만 차례로 높입니다.",
},
};
const BAND_COPY = {
unassessed: "아직 관찰되지 않음",
fragile: "근거가 불안정함",
developing: "형성 중",
consistent_local: "익숙한 장면에서 일관됨",
transfer_verified: "새 장면 전이 확인",
} as const;
const BAND_ORDER = {
unassessed: 0,
fragile: 1,
developing: 2,
consistent_local: 3,
transfer_verified: 4,
} as const;
const PROGRESS_COPY: Record<
PracticeProgress,
{ label: string; title: string; body: string }
> = {
practicing: {
label: "연습 중",
title: "행동 근거를 더 확인해야 합니다",
body: "익숙한 장면에서 목표 행동과 내담자 반응이 함께 관찰되어야 다음 문이 열립니다.",
},
transfer_pending: {
label: "새 장면 확인 대기",
title: "익숙한 장면은 확인됐지만 전이는 아직입니다",
body: "암기한 문장을 반복하지 않고, 처음 보는 장면에서 같은 행동을 새 문장으로 보여 주세요.",
},
mastered: {
label: "새 장면 전이 확인",
title: "익숙한 장면과 새 장면에서 행동이 확인됐습니다",
body: "이 판정은 교육용 연습 근거이며 총점이나 임상적 숙련도 판정으로 합산하지 않습니다.",
},
};
const CLIENT_RESPONSE_COPY: Record<ClientResponse, string> = {
rejecting: "거부가 이어짐",
withdrawn: "더 물러남",
compliance_only: "겉으로만 따름",
mixed: "엇갈린 반응",
engaged: "대화에 참여함",
explicit_alignment: "의도와 과업을 명시적으로 확인함",
};
const OUTCOME_COPY: Record<PracticeAttemptItem["outcome"], string> = {
passed: "근거로 확인됨",
needs_retry: "다시 확인 필요",
insufficient_evidence: "근거 부족",
};
const UNCERTAINTY: Record<Certainty, number> = {
high: 0.2,
medium: 0.45,
low: 0.7,
};
function newSubmissionIds(): SubmissionIds {
const episodeUuid = randomUuid();
const familiarUuid = randomUuid();
const transferUuid = randomUuid();
return {
submissionId: randomUuid(),
episodeId: `oas-g4-episode-${episodeUuid}`,
familiarAttemptId: `oas-g4-attempt-${familiarUuid}`,
transferAttemptId: `oas-g4-attempt-${transferUuid}`,
};
}
function blankAttemptDraft(): AttemptDraft {
return {
criterionStatus: "not_observed",
clientResponse: "mixed",
phrase: "",
learnerClaimedSuccess: false,
certainty: "medium",
counterevidence: "",
};
}
function splitCounterevidence(value: string): string[] {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
async function phraseFingerprint(phrase: string): Promise<string> {
const normalized = phrase.normalize("NFKC").trim().replace(/\s+/g, " ");
return `utterance-sha256:${await sha256Hex(normalized)}`;
}
function turnForEvidence(
refId: string,
turns: PracticeTurn[],
): PracticeTurn | null {
return (
turns.find((turn) => turn.turn_id === refId || turn.id === refId) ?? null
);
}
function compactPercent(value: number): string {
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
}
function latestEpisodeFor(
prescription: PracticePrescriptionItem,
episodes: PracticeEpisodeItem[],
): PracticeEpisodeItem | null {
const matching = episodes.filter((episode) => {
const payload = episode.assessment_payload as Record<string, unknown>;
return (
payload.prescription_id === prescription.prescription_key ||
payload.prescription_id ===
prescription.prescription_payload.prescription_id ||
payload.competency_id === prescription.competency_id
);
});
return matching.at(-1) ?? null;
}
interface RuntimeObservationSnapshot {
progress: PracticeProgress;
attemptCount: number;
familiarDemonstrations: number;
unseenTransferDemonstrations: number;
}
function prescriptionForLaunchIntent(
data: DeliberatePracticeReadModel,
intent: PracticeLaunchIntent,
): PracticePrescriptionItem | null {
if (intent.kind !== "deliberate") return null;
return (
data.prescriptions.find(
(item) =>
item.prescription_key === intent.prescriptionId ||
item.prescription_payload.prescription_id === intent.prescriptionId,
) ?? null
);
}
function runtimeObservationSnapshot(
data: DeliberatePracticeReadModel,
intent: PracticeLaunchIntent,
): RuntimeObservationSnapshot {
const prescription = prescriptionForLaunchIntent(data, intent);
const episode = prescription
? latestEpisodeFor(prescription, data.episodes)
: null;
const competencyState = prescription
? (data.competency_graph?.states ?? []).find(
(item) => item.competency_id === prescription.competency_id,
)
: null;
return {
progress: episode?.progress ?? "practicing",
attemptCount: competencyState?.attempt_count ?? 0,
familiarDemonstrations:
competencyState?.familiar_demonstrations ?? 0,
unseenTransferDemonstrations:
competencyState?.unseen_transfer_demonstrations ?? 0,
};
}
function prioritizedPrescriptions(
data: DeliberatePracticeReadModel,
): PracticePrescriptionItem[] {
const states = new Map(
(data.competency_graph?.states ?? []).map((state) => [
state.competency_id,
state,
]),
);
const selectedId = data.next_practice?.selected_prescription_id;
return [...data.prescriptions].sort((left, right) => {
const leftSelected =
left.prescription_key === selectedId ||
left.prescription_payload.prescription_id === selectedId;
const rightSelected =
right.prescription_key === selectedId ||
right.prescription_payload.prescription_id === selectedId;
if (leftSelected !== rightSelected) return leftSelected ? -1 : 1;
const leftState = states.get(left.competency_id);
const rightState = states.get(right.competency_id);
const bandDelta =
BAND_ORDER[leftState?.band ?? "unassessed"] -
BAND_ORDER[rightState?.band ?? "unassessed"];
if (bandDelta !== 0) return bandDelta;
return (
(rightState?.forgetting_risk ?? 0) - (leftState?.forgetting_risk ?? 0)
);
});
}
function launchPathForPrescription(
prescription: PracticePrescriptionItem,
sourceSessionId: string,
): string | null {
return practiceLaunchPath({
kind: "deliberate",
prescriptionId: prescription.prescription_payload.prescription_id,
suiteId: null,
trialId: null,
sourceSessionId,
criterionId: prescription.criterion_id,
novelty: prescription.scenario_novelty,
mode: prescription.activity_mode,
});
}
function humanizeSelectionBasis(value: string): string {
const [key, raw = ""] = value.split(":", 2);
if (key === "weakest_available_band") {
return `현재 가장 약한 단계 · ${BAND_COPY[raw as keyof typeof BAND_COPY] ?? raw}`;
}
if (key === "forgetting_risk") {
return `망각 위험 · ${compactPercent(Number(raw))}`;
}
if (key === "uncertainty") {
return `판정 불확실성 · ${compactPercent(Number(raw))}`;
}
if (key === "scenario_novelty") {
return raw === "unseen_transfer"
? "새 장면 전이가 필요한 처방"
: "익숙한 장면에서 먼저 확인할 처방";
}
return value;
}
function EvidenceButtons({
evidenceIds,
turns,
onJumpToTurn,
}: {
evidenceIds: string[];
turns: PracticeTurn[];
onJumpToTurn: (turnId: string) => void;
}) {
if (evidenceIds.length === 0) {
return <p className="dp-muted"> .</p>;
}
return (
<div className="dp-evidence__list">
{evidenceIds.map((evidenceId) => {
const turn = turnForEvidence(evidenceId, turns);
if (!turn) {
return (
<span className="dp-evidence__missing" key={evidenceId}>
·
</span>
);
}
return (
<button
type="button"
key={evidenceId}
onClick={() => onJumpToTurn(turn.id)}
aria-label={`${turn.ts} ${turn.who} 발화로 이동`}
>
<span>{turn.ts}</span>
<b>{turn.who}</b>
<small>{turn.text}</small>
</button>
);
})}
</div>
);
}
function ModeRail({ activeModes }: { activeModes: Set<PracticeMode> }) {
return (
<ol className="dp-mode-rail" aria-label="숙의 연습 방식 다섯 가지">
{Object.entries(MODE_COPY).map(([mode, copy]) => (
<li
key={mode}
className={activeModes.has(mode as PracticeMode) ? "is-active" : ""}
>
<span aria-hidden="true">{copy.index}</span>
<div>
<b>{copy.short}</b>
<small>{copy.description}</small>
</div>
</li>
))}
</ol>
);
}
function AttemptFields({
legend,
draft,
onChange,
transfer,
}: {
legend: string;
draft: AttemptDraft;
onChange: (draft: AttemptDraft) => void;
transfer: boolean;
}) {
return (
<fieldset className={`dp-attempt-fields ${transfer ? "is-transfer" : ""}`}>
<legend>{legend}</legend>
<p>
{transfer
? "처음 보는 장면에서 같은 행동을 다른 문장으로 적용한 기록입니다."
: "근거 장면을 다시 다룬 뒤 관찰한 내용을 남깁니다."}
</p>
<div className="dp-form-grid">
<label>
<span> </span>
<select
value={draft.criterionStatus}
onChange={(event) =>
onChange({
...draft,
criterionStatus: event.target.value as CriterionStatus,
})
}
>
<option value="not_observed"> </option>
<option value="observed"></option>
</select>
</label>
<label>
<span> </span>
<select
value={draft.clientResponse}
onChange={(event) =>
onChange({
...draft,
clientResponse: event.target.value as ClientResponse,
})
}
>
{Object.entries(CLIENT_RESPONSE_COPY).map(([value, label]) => (
<option value={value} key={value}>
{label}
</option>
))}
</select>
</label>
<label>
<span> </span>
<select
value={draft.certainty}
onChange={(event) =>
onChange({
...draft,
certainty: event.target.value as Certainty,
})
}
>
<option value="high"> · </option>
<option value="medium"> · </option>
<option value="low"> · </option>
</select>
</label>
<label className="dp-form-grid__wide">
<span> </span>
<textarea
value={draft.phrase}
onChange={(event) =>
onChange({ ...draft, phrase: event.target.value })
}
rows={2}
maxLength={600}
placeholder={
transfer
? "새 장면에 맞춰 바꿔 말한 문장"
: "근거 장면에서 다시 사용한 문장"
}
required
/>
</label>
<label className="dp-form-grid__wide">
<span> </span>
<textarea
value={draft.counterevidence}
onChange={(event) =>
onChange({ ...draft, counterevidence: event.target.value })
}
rows={2}
placeholder="한 줄에 하나씩 남길 수 있습니다."
required={draft.criterionStatus === "not_observed"}
/>
</label>
</div>
<label className="dp-self-claim">
<input
type="checkbox"
checked={draft.learnerClaimedSuccess}
onChange={(event) =>
onChange({
...draft,
learnerClaimedSuccess: event.target.checked,
})
}
/>
<span>
<small>
.
</small>
</span>
</label>
</fieldset>
);
}
function LearnerAttemptForm({
prescription,
onSubmitted,
}: {
prescription: PracticePrescriptionItem;
onSubmitted: () => void;
}) {
const [familiar, setFamiliar] = useState(blankAttemptDraft);
const [transfer, setTransfer] = useState(blankAttemptDraft);
const [includeTransfer, setIncludeTransfer] = useState(
prescription.scenario_novelty === "unseen_transfer",
);
const [ids, setIds] = useState(newSubmissionIds);
const [state, setState] = useState<
"idle" | "submitting" | "success" | "error"
>("idle");
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
setFamiliar(blankAttemptDraft());
setTransfer(blankAttemptDraft());
setIncludeTransfer(prescription.scenario_novelty === "unseen_transfer");
setIds(newSubmissionIds());
setState("idle");
setMessage(null);
}, [prescription.prescription_record_id, prescription.scenario_novelty]);
function revise(setter: (value: AttemptDraft) => void, value: AttemptDraft) {
setter(value);
setIds(newSubmissionIds());
setState("idle");
setMessage(null);
}
const evidenceRefs = prescription.prescription_payload.evidence_refs;
const behaviorEvidence = evidenceRefs.filter(
(item) => item.kind === "learner_behavior",
);
const impactEvidence = evidenceRefs.filter(
(item) => item.kind !== "learner_behavior",
);
const evidenceKinds = new Set(evidenceRefs.map((item) => item.kind));
const hasRequiredEvidence =
evidenceKinds.has("learner_behavior") &&
evidenceKinds.has("client_response") &&
(prescription.activity_mode !== "voice_retry" ||
evidenceKinds.has("voice_feature"));
const draftReady = (draft: AttemptDraft) =>
draft.phrase.trim().length > 0 &&
(draft.criterionStatus === "observed" ||
splitCounterevidence(draft.counterevidence).length > 0);
const ready =
draftReady(familiar) &&
(!includeTransfer || draftReady(transfer)) &&
hasRequiredEvidence;
async function inputFor(
draft: AttemptDraft,
novelty: "familiar" | "unseen_transfer",
attemptId: string,
sequenceNo: number,
): Promise<AttemptInput> {
const uncertainty = UNCERTAINTY[draft.certainty];
const counterevidence = splitCounterevidence(draft.counterevidence);
const baseVariant = prescription.scenario_variant_id;
const transferSuffix = `-${novelty}-${ids.episodeId.slice(-8)}`;
const scenarioVariantId =
novelty === prescription.scenario_novelty
? baseVariant
: `${baseVariant.slice(0, 180 - transferSuffix.length)}${transferSuffix}`;
return {
attempt_id: attemptId,
prescription_id: prescription.prescription_payload.prescription_id,
competency_id: prescription.competency_id,
criterion: {
criterion_id: prescription.criterion_id,
status: draft.criterionStatus,
source_kind: "learner_reported",
perspective: "learner_self_report",
uncertainty,
evidence_refs: behaviorEvidence,
counterevidence,
error_code: null,
model_run_id: null,
},
scenario_variant_id: scenarioVariantId,
scenario_novelty: novelty,
difficulty_level: prescription.difficulty_level,
client_response: draft.clientResponse,
learner_claimed_success: draft.learnerClaimedSuccess,
utterance_template_id: await phraseFingerprint(draft.phrase),
uncertainty,
evidence_refs: impactEvidence,
counterevidence,
sequence_no: sequenceNo,
error_code: null,
};
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!ready || state === "submitting") return;
setState("submitting");
setMessage(null);
try {
const attempts: AttemptInput[] = [
await inputFor(familiar, "familiar", ids.familiarAttemptId, 1),
];
if (includeTransfer) {
attempts.push(
await inputFor(transfer, "unseen_transfer", ids.transferAttemptId, 2),
);
}
const result = await deliberatePracticeApi.submitAttempt(
prescription.prescription_payload.prescription_id,
{
submission_id: ids.submissionId,
episode: {
episode_id: ids.episodeId,
prescription_id: prescription.prescription_payload.prescription_id,
attempts,
},
},
);
setState("success");
setMessage(
result.progress === "mastered"
? "익숙한 장면과 새 장면의 근거가 함께 확인됐습니다."
: result.progress === "transfer_pending"
? "익숙한 장면 기록을 추가했습니다. 새 장면 전이가 남아 있습니다."
: "연습 기록을 추가했습니다. 다음 시도에서 근거를 더 확인해 주세요.",
);
onSubmitted();
} catch (error) {
setState("error");
setMessage(
error instanceof ApiError
? error.detail
: "연습 기록을 원장에 추가하지 못했습니다.",
);
}
}
return (
<form className="dp-attempt-form" onSubmit={submit}>
<div className="dp-attempt-form__head">
<div>
<h4> </h4>
<p>
. .
</p>
</div>
<p className="dp-attempt-form__retry">
.
</p>
</div>
<AttemptFields
legend="A · 익숙한 장면 재연습"
draft={familiar}
onChange={(value) => revise(setFamiliar, value)}
transfer={false}
/>
<label className="dp-transfer-toggle">
<input
type="checkbox"
checked={includeTransfer}
onChange={(event) => {
setIncludeTransfer(event.target.checked);
setIds(newSubmissionIds());
setState("idle");
setMessage(null);
}}
/>
<span>
<small>
· .
</small>
</span>
</label>
{includeTransfer ? (
<AttemptFields
legend="B · 처음 보는 장면 전이"
draft={transfer}
onChange={(value) => revise(setTransfer, value)}
transfer
/>
) : null}
{!hasRequiredEvidence ? (
<p className="dp-form-message dp-form-message--error" role="alert">
{prescription.activity_mode === "voice_retry" &&
!evidenceKinds.has("voice_feature")
? "음성 특징 근거가 연결되지 않아 음성 재시도를 제출할 수 없습니다."
: "행동과 직후 내담자 반응 근거가 함께 연결되지 않아 이 시도를 제출할 수 없습니다."}
</p>
) : null}
{message ? (
<p
className={`dp-form-message dp-form-message--${state}`}
role={state === "error" ? "alert" : "status"}
aria-live="polite"
>
{message}
</p>
) : null}
<div className="dp-form-actions">
<Button type="submit" disabled={!ready || state === "submitting"}>
{state === "submitting" ? "원장에 추가 중" : "근거와 함께 시도 추가"}
</Button>
</div>
</form>
);
}
function TeacherCorrectionForm({
attempt,
onRecorded,
}: {
attempt: PracticeAttemptItem;
onRecorded: () => void;
}) {
const [submissionId, setSubmissionId] = useState(() => randomUuid());
const [outcome, setOutcome] = useState<
PracticeTeacherCorrectionRequest["corrected_outcome"]
>(attempt.outcome);
const [reason, setReason] = useState("");
const [counterevidence, setCounterevidence] = useState("");
const [state, setState] = useState<
"idle" | "submitting" | "success" | "error"
>("idle");
const [message, setMessage] = useState<string | null>(null);
function revised() {
setSubmissionId(randomUuid());
setState("idle");
setMessage(null);
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!reason.trim() || state === "submitting") return;
setState("submitting");
setMessage(null);
try {
await deliberatePracticeApi.appendCorrection(attempt.attempt_record_id, {
submission_id: submissionId,
corrected_outcome: outcome,
correction_reason: reason.trim(),
evidence_turn_ids: attempt.evidence_turn_ids,
counterevidence: splitCounterevidence(counterevidence),
});
setState("success");
setMessage(
"기존 판정을 바꾸지 않고 교수자 정정을 새 원장 항목으로 추가했습니다.",
);
onRecorded();
} catch (error) {
setState("error");
setMessage(
error instanceof ApiError
? error.detail
: "교수자 정정을 원장에 추가하지 못했습니다.",
);
}
}
return (
<details className="dp-correction">
<summary> </summary>
<form onSubmit={submit}>
<p>
.
.
</p>
<label>
<span> </span>
<select
value={outcome}
onChange={(event) => {
setOutcome(
event.target
.value as PracticeTeacherCorrectionRequest["corrected_outcome"],
);
revised();
}}
>
<option value="passed"> </option>
<option value="needs_retry"> </option>
<option value="insufficient_evidence"> </option>
</select>
</label>
<label>
<span> </span>
<textarea
value={reason}
onChange={(event) => {
setReason(event.target.value);
revised();
}}
rows={3}
maxLength={1000}
required
placeholder="어떤 관찰 근거 때문에 판정을 달리 보는지 적습니다."
/>
</label>
<label>
<span> </span>
<textarea
value={counterevidence}
onChange={(event) => {
setCounterevidence(event.target.value);
revised();
}}
rows={2}
placeholder="한 줄에 하나씩 남길 수 있습니다."
/>
</label>
{message ? (
<p
className={`dp-form-message dp-form-message--${state}`}
role={state === "error" ? "alert" : "status"}
>
{message}
</p>
) : null}
<Button
type="submit"
size="sm"
disabled={!reason.trim() || state === "submitting"}
>
{state === "submitting" ? "추가 중" : "정정 원장에 추가"}
</Button>
</form>
</details>
);
}
function TeacherLedger({
episodes,
turns,
onJumpToTurn,
onRecorded,
}: {
episodes: PracticeEpisodeItem[];
turns: PracticeTurn[];
onJumpToTurn: (turnId: string) => void;
onRecorded: () => void;
}) {
const attempts = episodes.flatMap((episode) => episode.attempts ?? []);
return (
<section
className="dp-teacher-ledger"
aria-labelledby="dp-teacher-ledger-title"
>
<div className="dp-section-heading">
<div>
<Kicker dot={false}> </Kicker>
<h3 id="dp-teacher-ledger-title">
</h3>
</div>
<Badge tone="neutral"> + </Badge>
</div>
{attempts.length === 0 ? (
<p className="dp-muted"> .</p>
) : (
<div className="dp-attempt-ledger">
{attempts.map((attempt) => (
<article key={attempt.attempt_record_id}>
<header>
<div>
<span>{attempt.sequence_no} </span>
<h4>{OUTCOME_COPY[attempt.outcome]}</h4>
</div>
<Badge
tone={attempt.outcome === "passed" ? "accent" : "neutral"}
>
{attempt.scenario_novelty === "unseen_transfer"
? "새 장면"
: "익숙한 장면"}
</Badge>
</header>
<dl>
<div>
<dt> </dt>
<dd>
{attempt.criterion_status === "observed"
? "관찰됨"
: attempt.criterion_status === "error"
? "판정 오류"
: "관찰되지 않음"}
</dd>
</div>
<div>
<dt></dt>
<dd>{compactPercent(attempt.uncertainty)}</dd>
</div>
<div>
<dt> </dt>
<dd>
{attempt.client_response &&
attempt.client_response in CLIENT_RESPONSE_COPY
? CLIENT_RESPONSE_COPY[
attempt.client_response as ClientResponse
]
: "기록 없음"}
</dd>
</div>
</dl>
<EvidenceButtons
evidenceIds={attempt.evidence_turn_ids}
turns={turns}
onJumpToTurn={onJumpToTurn}
/>
{(attempt.corrections ?? []).length > 0 ? (
<div className="dp-correction-history">
<b> </b>
{(attempt.corrections ?? []).map((correction) => (
<p key={correction.correction_id}>
{correction.correction_no} ·{" "}
{OUTCOME_COPY[correction.corrected_outcome]} ·{" "}
{correction.correction_reason}
</p>
))}
</div>
) : null}
<TeacherCorrectionForm
attempt={attempt}
onRecorded={onRecorded}
/>
</article>
))}
</div>
)}
</section>
);
}
type RuntimeObservationState =
| "idle"
| "submitting"
| "waiting"
| "success"
| "error";
function RuntimePracticeObservation({
sessionId,
intent,
data,
onObserved,
}: {
sessionId: string;
intent: PracticeLaunchIntent;
data: DeliberatePracticeReadModel;
onObserved: () => void;
}) {
const [baseline, setBaseline] = useState<RuntimeObservationSnapshot>(() =>
runtimeObservationSnapshot(data, intent),
);
const [state, setState] = useState<RuntimeObservationState>("idle");
const [result, setResult] =
useState<PracticeAttemptSubmissionResponse | null>(null);
useEffect(() => {
setBaseline(runtimeObservationSnapshot(data, intent));
setState("idle");
setResult(null);
// read model reload는 반영 전 기준을 바꾸지 않는다. 새 회기나 새 처방만 초기화한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [intent.kind, intent.prescriptionId, sessionId]);
const connectedPrescription = prescriptionForLaunchIntent(data, intent);
const current = runtimeObservationSnapshot(data, intent);
const currentProgress = result?.progress ?? current.progress;
if (intent.kind === "transfer") {
return (
<section
className="dp-runtime-observation dp-runtime-observation--unavailable"
aria-labelledby="dp-runtime-transfer-title"
>
<div>
<Kicker dot={false}> </Kicker>
<h3 id="dp-runtime-transfer-title">
</h3>
<p>
.
.
</p>
</div>
<Badge tone="neutral"> </Badge>
</section>
);
}
if (!connectedPrescription || sessionId === intent.sourceSessionId) {
return (
<section
className="dp-runtime-observation dp-runtime-observation--unavailable"
aria-labelledby="dp-runtime-unavailable-title"
role="status"
>
<div>
<Kicker dot={false}> </Kicker>
<h3 id="dp-runtime-unavailable-title">
</h3>
<p>
. .
</p>
</div>
<Badge tone="neutral"> </Badge>
</section>
);
}
async function observe() {
if (state === "submitting") return;
setState("submitting");
try {
const response = await deliberatePracticeApi.observeCompletedSession(
intent.prescriptionId,
sessionId,
);
setResult(response);
setState("success");
onObserved();
} catch (cause) {
setResult(null);
if (cause instanceof ApiError && cause.status === 422) {
setState("waiting");
return;
}
setState("error");
}
}
const actionLabel =
state === "submitting"
? "서버 근거 확인 중"
: state === "waiting"
? "평가 상태 다시 확인"
: state === "success"
? "반영 상태 다시 확인"
: state === "error"
? "독립 관찰 다시 시도"
: "이번 회기를 독립 관찰로 반영";
return (
<section
className={`dp-runtime-observation is-${state}`}
aria-labelledby="dp-runtime-observation-title"
aria-busy={state === "submitting"}
>
<div className="dp-runtime-observation__head">
<div>
<Kicker dot={false}> </Kicker>
<h3 id="dp-runtime-observation-title">
</h3>
<p>
.
</p>
</div>
<Badge tone={state === "success" ? "accent" : "neutral"}>
{state === "success" ? "원장 반영됨" : "학습자만 실행"}
</Badge>
</div>
<div className="dp-runtime-observation__comparison">
<section>
<span> </span>
<strong>{PROGRESS_COPY[baseline.progress].title}</strong>
<small>
{baseline.familiarDemonstrations}, {" "}
{baseline.unseenTransferDemonstrations}
</small>
</section>
{result ? (
<section className="is-after">
<span> </span>
<strong>{PROGRESS_COPY[currentProgress].label}</strong>
<small>
{current.familiarDemonstrations}, {" "}
{current.unseenTransferDemonstrations}
</small>
</section>
) : null}
</div>
{state === "waiting" ? (
<div className="dp-runtime-observation__message is-waiting" role="status">
<strong> </strong>
<p>
.
.
</p>
</div>
) : null}
{state === "success" ? (
<div className="dp-runtime-observation__message is-success" role="status">
<strong> </strong>
<p>
{result?.idempotent_replay
? "같은 회기 근거를 중복 없이 확인했습니다."
: "새 관찰 근거를 원장에 반영하고 최신 진행 상태를 다시 불러왔습니다."}
</p>
</div>
) : null}
{state === "error" ? (
<div className="dp-runtime-observation__message is-error" role="alert">
<strong> </strong>
<p>
.
.
</p>
</div>
) : null}
<div className="dp-runtime-observation__actions">
<Button
type="button"
variant={state === "success" ? "secondary" : "primary"}
onClick={() => void observe()}
disabled={state === "submitting"}
>
{actionLabel}
</Button>
</div>
</section>
);
}
function StateCard({
kind,
isSupervisorView,
message,
onRetry,
}: {
kind: "loading" | "empty" | "error";
isSupervisorView: boolean;
message?: string;
onRetry?: () => void;
}) {
const title =
kind === "loading"
? "숙의 연습 원장을 불러오는 중"
: kind === "empty"
? "아직 연결된 숙의 연습이 없습니다"
: "숙의 연습 원장을 표시할 수 없습니다";
const body =
message ??
(kind === "empty"
? isSupervisorView
? "학습자에게 처방된 연습이나 제출된 시도가 생기면 이 회기에서 검토할 수 있습니다."
: "평가 근거가 준비되면 한 번에 한 행동만 다루는 연습이 이곳에 연결됩니다."
: "연습 API 응답을 받지 못했습니다. 다른 리뷰 자료로 성공 상태를 대신 만들지 않습니다.");
return (
<Card
className={`dp-card dp-card--state dp-card--${kind}`}
aria-busy={kind === "loading"}
role={kind === "error" ? "alert" : undefined}
>
<Kicker> </Kicker>
<h2>{title}</h2>
<p>{body}</p>
{onRetry ? (
<Button variant="secondary" size="sm" onClick={onRetry}>
</Button>
) : null}
</Card>
);
}
export function DeliberatePracticeCard({
sessionId,
turns,
isSupervisorView,
practiceLaunchIntent,
onJumpToTurn,
}: DeliberatePracticeCardProps) {
const [data, setData] = useState<DeliberatePracticeReadModel | null>(null);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [error, setError] = useState<string | null>(null);
const [reloadSeq, setReloadSeq] = useState(0);
useEffect(() => {
const controller = new AbortController();
let alive = true;
setLoadState((current) => (current === "ready" ? "ready" : "loading"));
setError(null);
const request = isSupervisorView
? deliberatePracticeApi.getForTeacherSession(sessionId, controller.signal)
: deliberatePracticeApi.getForLearner(controller.signal);
void request
.then((response) => {
if (!alive) return;
setData(response);
setLoadState(response.prescriptions.length > 0 ? "ready" : "empty");
})
.catch((cause: unknown) => {
if (!alive || controller.signal.aborted) return;
if (cause instanceof ApiError && cause.status === 404) {
setData(null);
setLoadState("empty");
return;
}
setData(null);
setLoadState("error");
setError(
cause instanceof ApiError
? cause.detail
: cause instanceof Error
? cause.message
: "숙의 연습 API를 사용할 수 없습니다.",
);
});
return () => {
alive = false;
controller.abort();
};
}, [isSupervisorView, reloadSeq, sessionId]);
const ordered = useMemo(
() => (data ? prioritizedPrescriptions(data) : []),
[data],
);
const selected = ordered[0] ?? null;
const competencyState = selected
? (data?.competency_graph?.states ?? []).find(
(state) => state.competency_id === selected.competency_id,
)
: null;
const competencyDefinition = selected
? (data?.competency_graph?.definitions ?? []).find(
(definition) => definition.competency_id === selected.competency_id,
)
: null;
const latestEpisode =
selected && data ? latestEpisodeFor(selected, data.episodes) : null;
const progress = latestEpisode?.progress ?? "practicing";
const activeModes = new Set(ordered.map((item) => item.activity_mode));
const selectedLaunchPath = selected
? launchPathForPrescription(selected, sessionId)
: null;
if (loadState === "loading") {
return <StateCard kind="loading" isSupervisorView={isSupervisorView} />;
}
if (loadState === "error") {
return (
<StateCard
kind="error"
isSupervisorView={isSupervisorView}
message={error ?? undefined}
onRetry={() => setReloadSeq((value) => value + 1)}
/>
);
}
if (loadState === "empty" || !data || !selected) {
return <StateCard kind="empty" isSupervisorView={isSupervisorView} />;
}
return (
<Card className="dp-card" aria-labelledby="dp-card-title">
<header className="dp-card__head">
<div>
<Kicker> </Kicker>
<h2 id="dp-card-title">
</h2>
<p>
{isSupervisorView
? "학습자의 연습 원장을 읽고, 근거가 다를 때만 별도 정정을 추가합니다."
: "현재 가장 약하거나 잊힐 위험이 큰 역량부터 다룹니다. 반복 횟수나 보상 점수는 만들지 않습니다."}
</p>
</div>
<div className="dp-card__flags">
<Badge tone="accent">
{isSupervisorView ? "교수자 보기" : "학습자 보기"}
</Badge>
<Badge tone="neutral"> </Badge>
</div>
</header>
<aside className="dp-contract" aria-label="숙의 연습 판정 원칙">
<b> .</b>
<p>
.
,
.
</p>
</aside>
{!isSupervisorView && practiceLaunchIntent ? (
<RuntimePracticeObservation
sessionId={sessionId}
intent={practiceLaunchIntent}
data={data}
onObserved={() => setReloadSeq((value) => value + 1)}
/>
) : null}
<ModeRail activeModes={activeModes} />
<section className="dp-priority" aria-labelledby="dp-priority-title">
<div className="dp-priority__marker" aria-hidden="true">
<span>NOW</span>
<i />
</div>
<div className="dp-priority__body">
<div className="dp-section-heading">
<div>
<Kicker dot={false}> </Kicker>
<h3 id="dp-priority-title">
{competencyDefinition?.label_ko ??
(isSupervisorView
? selected.competency_id
: "연습 역량 확인")}
</h3>
<p>{competencyDefinition?.description}</p>
</div>
<Badge tone="accent">
{MODE_COPY[selected.activity_mode].label}
</Badge>
</div>
<div className="dp-priority__signals">
<div>
<span> </span>
<b>{BAND_COPY[competencyState?.band ?? "unassessed"]}</b>
</div>
<div>
<span> </span>
<b>{compactPercent(competencyState?.forgetting_risk ?? 0)}</b>
</div>
<div>
<span> </span>
<b>{compactPercent(selected.uncertainty)}</b>
</div>
<div>
<span> </span>
<b>
{selected.scenario_novelty === "unseen_transfer"
? "새 장면 전이"
: "익숙한 장면"}
</b>
</div>
</div>
<div className="dp-atomic">
<span> </span>
<strong>{selected.observable_behavior}</strong>
<small>
· {isSupervisorView
? selected.criterion_id
: practiceCriterionLabel(selected.criterion_id)}
</small>
</div>
{!isSupervisorView ? (
selectedLaunchPath ? (
<section className="dp-launch-ticket" aria-labelledby="dp-launch-title">
<div className="dp-launch-ticket__copy">
<span className="dp-launch-ticket__eyebrow"> </span>
<h4 id="dp-launch-title"> </h4>
<p>
· · .
</p>
</div>
<dl className="dp-launch-ticket__contract" aria-label="연습 실행 계약">
<div><dt></dt><dd>{MODE_COPY[selected.activity_mode].short}</dd></div>
<div><dt></dt><dd>{selected.scenario_novelty === "unseen_transfer" ? "미지 전이" : "익숙한 장면"}</dd></div>
<div>
<dt></dt>
<dd>{practiceCriterionLabel(selected.criterion_id)}</dd>
</div>
</dl>
<Link className="dp-launch-ticket__cta" to={selectedLaunchPath}>
<span aria-hidden="true"></span>
</Link>
</section>
) : (
<p className="dp-launch-degraded" role="status">
. .
</p>
)
) : null}
<div className={`dp-gate dp-gate--${progress}`}>
<span aria-hidden="true" />
<div>
<b>{PROGRESS_COPY[progress].label}</b>
<h4>{PROGRESS_COPY[progress].title}</h4>
<p>{PROGRESS_COPY[progress].body}</p>
</div>
</div>
<div className="dp-evidence">
<div className="dp-section-heading dp-section-heading--compact">
<div>
<h4> </h4>
<p>{selected.coach_claim}</p>
</div>
</div>
<EvidenceButtons
evidenceIds={selected.evidence_turn_ids}
turns={turns}
onJumpToTurn={onJumpToTurn}
/>
</div>
<div className="dp-reason-grid">
<section>
<h4> </h4>
<ul>
{(data.next_practice?.selection_basis ?? []).map((basis) => (
<li key={basis}>{humanizeSelectionBasis(basis)}</li>
))}
</ul>
</section>
<section>
<h4> </h4>
{selected.counterevidence.length > 0 ? (
<ul>
{selected.counterevidence.map((item) => (
<li key={item}>
{isSupervisorView
? item
: practiceCounterevidenceLabel(item)}
</li>
))}
</ul>
) : (
<p className="dp-muted"> .</p>
)}
</section>
</div>
</div>
</section>
{ordered.length > 1 ? (
<details className="dp-queue">
<summary> {ordered.length - 1}</summary>
<ol>
{ordered.slice(1).map((item) => {
const launchPath = launchPathForPrescription(item, sessionId);
return (
<li key={item.prescription_record_id}>
<span>{MODE_COPY[item.activity_mode].short}</span>
<b>{item.observable_behavior}</b>
<small> {compactPercent(item.uncertainty)}</small>
{!isSupervisorView && launchPath ? (
<Link
to={launchPath}
aria-label={`${MODE_COPY[item.activity_mode].label} 연습 열기`}
>
</Link>
) : null}
</li>
);
})}
</ol>
</details>
) : null}
{isSupervisorView ? (
<TeacherLedger
episodes={data.episodes}
turns={turns}
onJumpToTurn={onJumpToTurn}
onRecorded={() => setReloadSeq((value) => value + 1)}
/>
) : (
<LearnerAttemptForm
prescription={selected}
onSubmitted={() => setReloadSeq((value) => value + 1)}
/>
)}
</Card>
);
}