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 = { rejecting: "거부가 이어짐", withdrawn: "더 물러남", compliance_only: "겉으로만 따름", mixed: "엇갈린 반응", engaged: "대화에 참여함", explicit_alignment: "의도와 과업을 명시적으로 확인함", }; const OUTCOME_COPY: Record = { passed: "근거로 확인됨", needs_retry: "다시 확인 필요", insufficient_evidence: "근거 부족", }; const UNCERTAINTY: Record = { 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 { 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; 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

연결된 근거 발화가 없습니다.

; } return (
{evidenceIds.map((evidenceId) => { const turn = turnForEvidence(evidenceId, turns); if (!turn) { return ( 현재 회기 밖의 근거 · 원본 회기 참조 ); } return ( ); })}
); } function ModeRail({ activeModes }: { activeModes: Set }) { return (
    {Object.entries(MODE_COPY).map(([mode, copy]) => (
  1. {copy.short} {copy.description}
  2. ))}
); } function AttemptFields({ legend, draft, onChange, transfer, }: { legend: string; draft: AttemptDraft; onChange: (draft: AttemptDraft) => void; transfer: boolean; }) { return (
{legend}

{transfer ? "처음 보는 장면에서 같은 행동을 다른 문장으로 적용한 기록입니다." : "근거 장면을 다시 다룬 뒤 관찰한 내용을 남깁니다."}