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

@ -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 };
}