lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный руками, он разошёлся бы с фактами при первой же правке сценария, и курсанта оштрафовали бы за правильный ответ. Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый недобытый факт с эталонным вопросом, расхождение самооценки — что курсант заметил сам, чего не заметил, что отметил зря. Не заметил — самое ценное для разбора. Внешний монитор — не отдельное приложение, а другой режим отрисовки тех же событий: крупный таймер опроса, ход разговора, карточка, после оценки разбор на весь экран. Коррекция преподавателем сохраняет автооценку рядом: видно, что скорректировано и кем. Найдено: все метрики весили одинаково, и курсант, не задавший ни одного вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные веса (полнота опроса — 4) дают 74; окончательные утверждает методист, вопрос записан в DEBRIEF.md.
This commit is contained in:
parent
25143645d3
commit
fadfa3e479
20 changed files with 781 additions and 9 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
|
||||
import { Call } from "@/pages/trainee/Call";
|
||||
import { Wall } from "@/pages/wall/Wall";
|
||||
import { Stub } from "@/shared/ui/Stub";
|
||||
|
||||
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
|
||||
|
|
@ -9,6 +10,6 @@ export const router = createBrowserRouter([
|
|||
{ path: "/", element: <Navigate to="/trainee" replace /> },
|
||||
{ path: "/trainee", element: <Call /> },
|
||||
{ path: "/instructor", element: <Stub title="Пульт преподавателя" card="lct-17" /> },
|
||||
{ path: "/wall", element: <Stub title="Внешний монитор" card="lct-16" /> },
|
||||
{ path: "/wall", element: <Wall /> },
|
||||
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
148
frontend/src/features/debrief/Debrief.tsx
Normal file
148
frontend/src/features/debrief/Debrief.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
frontend/src/features/debrief/Radar.tsx
Normal file
48
frontend/src/features/debrief/Radar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/features/observe/useObserve.ts
Normal file
98
frontend/src/features/observe/useObserve.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { KioCard } from "@/features/kio-card/KioCard";
|
||||
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
|
||||
import { Debrief } from "@/features/debrief/Debrief";
|
||||
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
|
||||
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
||||
import { display } from "@/features/kio-card/merge";
|
||||
|
|
@ -58,9 +59,8 @@ export function Call() {
|
|||
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
|
||||
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
|
||||
)}
|
||||
{call.selfAssessed && (
|
||||
<p>{call.scoreReady ? "Оценка готова — разбор открывает преподаватель." : "Самооценка принята, оценка считается…"}</p>
|
||||
)}
|
||||
{call.selfAssessed && !call.report && <p>Самооценка принята, оценка считается…</p>}
|
||||
{call.report && <Debrief report={call.report} />}
|
||||
|
||||
<div className="columns">
|
||||
<section>
|
||||
|
|
|
|||
74
frontend/src/pages/wall/Wall.tsx
Normal file
74
frontend/src/pages/wall/Wall.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Внешний монитор: группа смотрит, как идёт занятие, и разбирает вместе с ним.
|
||||
//
|
||||
// Это не отдельное приложение, а другой режим отрисовки тех же событий: крупный
|
||||
// шрифт, высокий контраст, минимум деталей. Никакого взаимодействия — экран висит
|
||||
// и показывает (docs/arch/FRONTEND.md).
|
||||
|
||||
import { Debrief } from "@/features/debrief/Debrief";
|
||||
import { KioCard } from "@/features/kio-card/KioCard";
|
||||
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
|
||||
import { InterviewTimer } from "@/features/timers/Timers";
|
||||
import { useObserve } from "@/features/observe/useObserve";
|
||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||
|
||||
export function Wall() {
|
||||
const sessionId = sessionIdFromUrl();
|
||||
const session = useObserve(sessionId);
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<main className="page wall">
|
||||
<h1>Внешний монитор</h1>
|
||||
<p className="warn">Откройте ссылку вида /wall?session=…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.report) {
|
||||
return (
|
||||
<main className="page wall">
|
||||
<ModeBanner mode={session.mode} />
|
||||
<h1>{session.title} · разбор</h1>
|
||||
<Debrief report={session.report} big />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page wall">
|
||||
<ModeBanner mode={session.mode} />
|
||||
<h1>
|
||||
{session.title ?? "Ожидание занятия"}
|
||||
{session.trainee && ` · ${session.trainee}`}
|
||||
</h1>
|
||||
<InterviewTimer timers={session.timers} big />
|
||||
|
||||
<div className="columns">
|
||||
<section>
|
||||
<h2>Ход разговора</h2>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{session.transcript.slice(-12).map((line) => (
|
||||
<tr key={line.ref}>
|
||||
<th>{line.speaker === "caller" ? "звонящий" : "оператор"}</th>
|
||||
<td>
|
||||
{line.text}
|
||||
{session.notes.has(line.ref) && (
|
||||
<div className="ref">пометка: {session.notes.get(line.ref)}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{session.transcript.length === 0 && <tr><td colSpan={2}>—</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="ref">подсказок использовано: {session.hintsUsed}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Карточка</h2>
|
||||
<KioCard state={session.card} required={session.requiredFields} readOnly />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -338,6 +338,13 @@ export interface SelfAssessment {
|
|||
submitted_at: string;
|
||||
}
|
||||
|
||||
/** Расхождение самооценки с автооценкой — отдельный материал для преподавателя. Курсант, не заметивший, что пропустил вопрос о пострадавших, — более важный случай, чем сама ошибка (docs/product/DEBRIEF.md). */
|
||||
export interface SelfAssessmentDiff {
|
||||
noticed?: Array<string>;
|
||||
unnoticed?: Array<string>;
|
||||
overcautious?: Array<string>;
|
||||
}
|
||||
|
||||
/** Самооценка до показа автооценки. */
|
||||
export interface SelfAssessmentSubmit {
|
||||
type: "self_assessment.submit";
|
||||
|
|
@ -367,6 +374,8 @@ export interface SessionReport {
|
|||
missed_checklist: Array<string>;
|
||||
hints_used: Array<HintUsage>;
|
||||
self_assessment?: SelfAssessment | null;
|
||||
self_assessment_diff?: SelfAssessmentDiff | null;
|
||||
notes?: Array<InstructorNoteShown>;
|
||||
score_auto: number;
|
||||
score_final: number;
|
||||
overridden_by?: string | null;
|
||||
|
|
|
|||
|
|
@ -69,3 +69,16 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||
.mode-training { background: #e8f0fb; }
|
||||
.mode-exam { background: #fde8e8; }
|
||||
.mode-self { background: #eef3ea; }
|
||||
|
||||
/* Разбор. Ссылка на норматив — мелким, но рядом с фактом: отметка без
|
||||
обоснования не появляется ни в одном интерфейсе. */
|
||||
.debrief h3 { font-size: 15px; margin: 16px 0 6px; }
|
||||
.ref { color: #5a6068; font-size: 12px; }
|
||||
.radar { display: block; margin: 4px 0 8px; }
|
||||
|
||||
/* Внешний монитор: читается с последнего ряда. */
|
||||
.wall { font-size: 20px; }
|
||||
.wall h1 { font-size: 30px; }
|
||||
.wall h2 { font-size: 24px; }
|
||||
.wall .grid th { width: 160px; }
|
||||
.debrief-big { font-size: 20px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue