1508 lines
49 KiB
TypeScript
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>
|
|
);
|
|
}
|