lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон

Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.

Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.

Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.

Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.

Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:32:12 +03:00
commit fadfa3e479
20 changed files with 781 additions and 9 deletions

View file

@ -12,7 +12,13 @@ import { EnergyGate } from "@/shared/audio/levels";
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
import type { CallIncoming, DDSCode, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated";
import type {
CallIncoming,
DDSCode,
ServerToTrainee,
SessionReport,
TimerSnapshot,
} from "@/shared/types/generated";
/** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */
const PATCH_DEBOUNCE_MS = 300;
@ -37,6 +43,7 @@ export function useCall(sessionId: string | null) {
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
const [selfAssessed, setSelfAssessed] = useState(false);
const [scoreReady, setScoreReady] = useState(false);
const [report, setReport] = useState<SessionReport | null>(null);
const [error, setError] = useState<string | null>(null);
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
@ -111,6 +118,10 @@ export function useCall(sessionId: string | null) {
break;
case "score.ready":
setScoreReady(true);
void fetch(`/api/sessions/${sessionId}/report`)
.then((response) => (response.ok ? response.json() : null))
.then(setReport)
.catch(() => setReport(null));
break;
case "error":
setError(event.message);
@ -182,7 +193,7 @@ export function useCall(sessionId: string | null) {
return {
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady,
checklist, selfAssessed, scoreReady, report,
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
};
}

View file

@ -0,0 +1,148 @@
// Разбор после звонка — главный учебный момент занятия.
//
// Ни одной отметки без обоснования: у каждой есть код, факт и норматив.
// По каждому пропущенному пункту видно, каким был эталонный вопрос
// (docs/product/DEBRIEF.md).
import type { SessionReport } from "@/shared/types/generated";
import { Radar } from "./Radar";
export function Debrief({ report, big = false }: { report: SessionReport; big?: boolean }) {
const questions = new Map(report.reference_questions.map((item) => [item.checklist_id, item.question]));
const diff = report.self_assessment_diff;
// Поля со значениями по умолчанию приходят из контракта необязательными.
const unnoticed = diff?.unnoticed ?? [];
const noticed = diff?.noticed ?? [];
const overcautious = diff?.overcautious ?? [];
const notes = report.notes ?? [];
return (
<section className={big ? "debrief debrief-big" : "debrief"}>
<h2>
Разбор · оценка {report.score_final.toFixed(0)} из 100
{report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`}
</h2>
<div className="columns">
<section>
<h3>Нормативы и факты</h3>
<table className="grid">
<tbody>
{report.metrics.map((metric) => (
<tr key={metric.key}>
<th>{metric.title}</th>
<td className={metric.passed ? "state-ok" : "state-violated"}>
{metric.fact} · норматив {metric.norm}
{metric.ref && <span className="ref"> ({metric.ref})</span>}
</td>
</tr>
))}
</tbody>
</table>
<h3>Отметки</h3>
<table className="grid">
<tbody>
{report.findings.map((finding, index) => (
<tr key={index}>
<th>{finding.code}</th>
<td>
{finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
</td>
</tr>
))}
{report.findings.length === 0 && <tr><td colSpan={2}>ошибок не найдено</td></tr>}
</tbody>
</table>
</section>
<section>
<h3>Компетенции</h3>
<Radar values={report.competencies} size={big ? 320 : 220} />
{report.missed_checklist.length > 0 && (
<>
<h3>Пропущенные вопросы</h3>
<table className="grid">
<tbody>
{report.missed_checklist.map((id) => (
<tr key={id}>
<th>{id}</th>
<td>эталонный вопрос: «{questions.get(id)}»</td>
</tr>
))}
</tbody>
</table>
</>
)}
{diff && (
<>
<h3>Самооценка</h3>
<table className="grid">
<tbody>
<tr>
<th>Не заметил, что пропустил</th>
<td className={unnoticed.length ? "state-violated" : ""}>
{unnoticed.map((id) => questions.get(id) ?? id).join("; ") || "—"}
</td>
</tr>
<tr>
<th>Заметил сам</th>
<td>{noticed.map((id) => questions.get(id) ?? id).join("; ") || "—"}</td>
</tr>
<tr>
<th>Отметил зря — спрашивал</th>
<td>{overcautious.map((id) => questions.get(id) ?? id).join("; ") || "—"}</td>
</tr>
{report.self_assessment?.comment && (
<tr><th>Комментарий курсанта</th><td>{report.self_assessment.comment}</td></tr>
)}
</tbody>
</table>
</>
)}
{report.hints_used.length > 0 && (
<>
<h3>Использованные подсказки</h3>
<table className="grid">
<tbody>
{report.hints_used.map((hint, index) => (
<tr key={index}><th>{hint.checklist_id}</th><td>{hint.question}</td></tr>
))}
</tbody>
</table>
</>
)}
</section>
</div>
<h3>Транскрипт</h3>
<table className="grid">
<tbody>
{report.transcript.map((line) => {
const marks = report.findings.filter((finding) => finding.transcript_ref === line.ref);
const note = notes.find((item) => item.transcript_ref === line.ref);
return (
<tr key={line.ref}>
<th>{line.speaker === "caller" ? "звонящий" : "оператор"}</th>
<td>
{line.text}
{marks.map((mark, index) => (
<div key={index} className="state-violated" title={`${mark.fact} · ${mark.norm ?? ""}`}>
{mark.code}: {mark.summary}
</div>
))}
{note && <div className="ref">пометка преподавателя: {note.text}</div>}
</td>
</tr>
);
})}
</tbody>
</table>
</section>
);
}

View file

@ -0,0 +1,48 @@
// Радар компетенций: проекция уже посчитанных метрик, без пересчёта весов.
// Преподаватель читает его за секунду: где провал — там тема следующего занятия
// (docs/product/METHODOLOGY.md).
import type { CompetencyScore } from "@/shared/types/generated";
export const COMPETENCY_LABELS: Record<string, string> = {
intake: "Приём вызова",
interview: "Опрос",
card: "Карточка",
routing: "Маршрутизация",
norms: "Нормативы",
communication: "Коммуникация",
};
export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?: number }) {
if (values.length < 3) return null;
const center = size / 2;
const radius = center - 28;
const point = (index: number, value: number) => {
const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2;
return [center + radius * value * Math.cos(angle), center + radius * value * Math.sin(angle)];
};
const polygon = values.map((item, index) => point(index, item.value).join(",")).join(" ");
return (
<svg width={size} height={size} className="radar" role="img" aria-label="радар компетенций">
{[0.25, 0.5, 0.75, 1].map((ring) => (
<polygon
key={ring}
points={values.map((_, index) => point(index, ring).join(",")).join(" ")}
fill="none"
stroke="var(--line)"
/>
))}
<polygon points={polygon} fill="rgba(31,111,61,0.18)" stroke="var(--ok)" strokeWidth={2} />
{values.map((item, index) => {
const [x, y] = point(index, 1.16);
return (
<text key={item.competency} x={x} y={y} textAnchor="middle" fontSize={10}>
{COMPETENCY_LABELS[item.competency] ?? item.competency}
</text>
);
})}
</svg>
);
}

View file

@ -0,0 +1,98 @@
// Наблюдатель: внешний монитор и пульт преподавателя.
//
// Снимок при подключении обязателен — монитор в классе включают посреди занятия,
// и он должен показать текущее состояние, а не ждать событий. Отправлять в этот
// канал нечего и нельзя: тип `Out` — `never` (docs/arch/CONTRACT.md).
import { useEffect, useState } from "react";
import { type CardState, applyState, empty as emptyCard } from "@/features/kio-card/merge";
import { observeChannel, type ChannelStatus } from "@/shared/api/ws";
import type {
SessionMode,
SessionReport,
TimerSnapshot,
TranscriptEntry,
} from "@/shared/types/generated";
export interface ObservedSession {
status: ChannelStatus;
title: string | null;
mode: SessionMode | undefined;
trainee: string | null;
card: CardState;
requiredFields: string[];
transcript: TranscriptEntry[];
timers: TimerSnapshot[];
hintsUsed: number;
notes: Map<string, string>;
ended: boolean;
report: SessionReport | null;
}
export function useObserve(sessionId: string | null): ObservedSession {
const [status, setStatus] = useState<ChannelStatus>("connecting");
const [title, setTitle] = useState<string | null>(null);
const [mode, setMode] = useState<SessionMode | undefined>(undefined);
const [trainee, setTrainee] = useState<string | null>(null);
const [card, setCard] = useState<CardState>(emptyCard);
const [requiredFields, setRequiredFields] = useState<string[]>([]);
const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
const [hintsUsed, setHintsUsed] = useState(0);
const [notes, setNotes] = useState<Map<string, string>>(new Map());
const [ended, setEnded] = useState(false);
const [report, setReport] = useState<SessionReport | null>(null);
useEffect(() => {
if (!sessionId) return;
const channel = observeChannel(sessionId, {
onStatus: setStatus,
onEvent: (event) => {
switch (event.type) {
case "session.snapshot":
setTitle(event.scenario_title);
setMode(event.mode);
setTrainee(event.trainee_name ?? null);
setCard((prev) => applyState(prev, event.kio as unknown as Record<string, unknown>));
setRequiredFields(event.required_fields ?? []);
setTranscript(event.transcript);
setTimers(event.timers);
setHintsUsed(event.hints_used ?? 0);
setEnded(event.ended ?? false);
break;
case "kio.state":
setCard((prev) => applyState(prev, event.kio as unknown as Record<string, unknown>));
break;
case "transcript.append":
setTranscript((prev) => [...prev, event.entry]);
break;
case "timer.tick":
setTimers(event.timers);
break;
case "mode.set":
setMode(event.mode);
break;
case "hint.shown":
setHintsUsed((count) => count + 1);
break;
case "instructor_note.shown":
setNotes((prev) => new Map(prev).set(event.transcript_ref, event.text));
break;
case "session.ended":
setEnded(true);
break;
case "score.ready":
void fetch(`/api/sessions/${sessionId}/report`)
.then((response) => (response.ok ? response.json() : null))
.then(setReport)
.catch(() => setReport(null));
break;
}
},
}).connect();
return () => channel.close();
}, [sessionId]);
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report };
}