Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -1,10 +1,22 @@
// Экран входа. Ставится перед всеми остальными: номер занятия перестал быть
// пропуском, по ссылке пускает роль, а не знание адреса (lct-23).
import { useState, type MouseEvent, type ReactNode } from "react";
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
import { DEMO_MODE, SCREENS, useDemoLogin, useLogin, useLogout, useMe } from "./useAuth";
const preloadScreenModule: Record<string, () => Promise<unknown>> = {
"/trainee": () => import("@/pages/trainee/Call"),
"/instructor": () => import("@/pages/instructor/Instructor"),
"/wall": () => import("@/pages/wall/Wall"),
"/profile": () => import("@/pages/profile/Profile"),
"/groups": () => import("@/pages/groups/Groups"),
"/dds": () => import("@/pages/dds/Dds"),
"/phone": () => import("@/pages/phone/Phone"),
"/materials": () => import("@/pages/materials/Materials"),
"/admin": () => import("@/pages/admin/Admin"),
};
function DemoLinks() {
const openScreen = (event: MouseEvent<HTMLAnchorElement>, path: string) => {
event.preventDefault();
@ -33,6 +45,12 @@ export function RequireAuth({ screen, children }: { screen: string; children: Re
const me = useMe();
const logout = useLogout();
// Fetch only static route code while the signed cookie is being checked;
// user data and the screen itself remain gated by the role check below.
useEffect(() => {
void preloadScreenModule[screen]?.().catch(() => undefined);
}, [screen]);
if (me.isLoading) {
return <main className="page"><p>Проверяем доступ…</p></main>;
}
@ -76,7 +94,7 @@ function Login() {
<tr>
<th>Логин</th>
<td>
<input value={name} autoFocus onChange={(event) => setName(event.target.value)} />
<input aria-label="Логин" value={name} autoFocus onChange={(event) => setName(event.target.value)} />
</td>
</tr>
<tr>
@ -84,6 +102,7 @@ function Login() {
<td>
<input
type="password"
aria-label="Пароль"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>

View file

@ -73,7 +73,7 @@ export function useLogout() {
/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий
* и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */
export const SCREENS: Record<Role, string[]> = {
admin: ["/admin", "/profile", "/groups", "/materials"],
admin: ["/admin", "/wall", "/profile", "/groups", "/materials"],
instructor: ["/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials"],
trainee: ["/trainee", "/dds", "/phone", "/profile", "/materials"],
};

View file

@ -28,6 +28,7 @@ const PATCH_DEBOUNCE_MS = 300;
export interface Line {
speaker: "caller" | "operator";
text: string;
source?: "local_llm" | "scenario";
partial?: boolean;
}
@ -85,6 +86,10 @@ export function useCall(sessionId: string | null) {
},
onBinary: (frame) => audio.current?.playback.enqueue(frame),
onEvent: (event: ServerToTrainee) => {
if (event.type === "text.turn.accepted") {
append({ speaker: "operator", text: event.text });
return;
}
if (event.type === "card.briefing") {
const briefing = event;
textMode.current = true;
@ -110,7 +115,7 @@ export function useCall(sessionId: string | null) {
append({ speaker: "operator", text: event.text });
break;
case "caller.utterance":
append({ speaker: "caller", text: event.text });
append({ speaker: "caller", text: event.text, source: event.source });
break;
case "tts.begin":
setCallerSpeaking(true);
@ -222,6 +227,12 @@ export function useCall(sessionId: string | null) {
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
}, []);
const sendText = useCallback((text: string) => {
const clean = text.trim();
if (!clean || clean.length > 1000) return false;
return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
}, []);
const submitCard = useCallback(() => {
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
@ -255,6 +266,6 @@ export function useCall(sessionId: string | null) {
resolve,
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady, report, reportError,
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment,
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
};
}

View file

@ -16,39 +16,102 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
const noticed = diff?.noticed ?? [];
const overcautious = diff?.overcautious ?? [];
const notes = report.notes ?? [];
const passedMetrics = Math.max(0, report.metrics.length - report.failed_metrics);
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
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>
<h2>Результаты занятия</h2>
<div className="debrief-summary" aria-label="Краткие итоги занятия">
<article className={report.passed ? "debrief-stat is-passed" : "debrief-stat is-failed"}>
<span>Итог</span><strong>{report.passed ? "Зачёт" : "Не зачтено"}</strong>
<small>Критерии: выполнено {passedMetrics} из {report.metrics.length}</small>
<small>Допустимо не выполнить: {report.criteria.allowed_errors}</small>
</article>
<article className="debrief-stat debrief-score">
<span>Ваша итоговая оценка</span><strong>{report.score_final.toFixed(0)}<small> из 100</small></strong>
{report.overridden_by && <small>Изменено: {report.overridden_by}</small>}
</article>
<article className="debrief-stat">
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
</article>
<article className="debrief-stat">
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
<small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small>
</article>
</div>
<details className="debrief-tech-details">
<summary>Служебные сведения</summary>
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>
</details>
<section className={`debrief-next-step ${report.passed ? "is-passed" : "is-failed"}`} aria-label="Что обсудить на разборе">
<strong>{report.passed ? "Что получилось" : "На чём сосредоточиться"}</strong>
{report.passed ? (
<p>Курсант выполнил критерии зачёта. Разберите сильные решения и закрепите порядок действий.</p>
) : improvementPoints.length ? (
<>
<p>Начните с этих критериев — по ним нужно разобрать решение курсанта:</p>
<ul>{improvementPoints.map((metric) => <li key={metric.key}>{metric.title} — {metric.fact}</li>)}</ul>
</>
) : (
<p>Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия.</p>
)}
</section>
<p className="ref">
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>таблица (CSV)</a>
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>документ (PDF)</a>
</p>
<p className={report.passed ? "state-ok" : "state-violated"}>
{report.passed ? "Зачёт" : "Не зачтено"}: нарушено {report.failed_metrics} метрик,
допустимо {report.criteria.allowed_errors}. Лимит решения ДДС —
{` ${report.criteria.decision_time_limit_seconds} с`}; грамматика
{report.criteria.require_correct_grammar ? " входит" : " не входит"} в оценку.
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
{` ${report.criteria.decision_time_limit_seconds} с`}</>}
{report.exercise === "dds" && <>; норматив отработки карточки ДДС —
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
{report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
{report.exercise && report.exercise !== "dds" && <>
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
</>}.
</p>
{Object.keys(report.criteria.score_weights ?? {}).length > 0 && <p className="ref">
Преподаватель переопределил веса: {Object.entries(report.criteria.score_weights ?? {})
.map(([key, weight]) => `${key} — ${weight}`).join("; ")}.
</p>}
{Boolean(report.card_results?.length) && <section className="debrief-card-results">
<h3>Карточки занятия ({report.card_results!.length})</h3>
{report.card_results!.map((card, index) => <details key={card.card_id}>
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100
{card.address ? ` · ${card.address}` : ""}</summary>
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
{card.metrics.some((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)) && <ul>
{card.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
<li key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
<strong>{metric.title}:</strong> {metric.fact} · норматив {metric.norm}
{metric.credit != null && ` · вклад ${Math.round(metric.credit * 100)}%`}
</li>)}
</ul>}
{card.findings.map((finding, findingIndex) => <p className="state-violated" key={`${finding.code}-${findingIndex}`}>
{finding.code}: {finding.summary}{finding.fact ? ` — ${finding.fact}` : ""}
</p>)}
</details>)}
</section>}
<div className="columns">
<section>
<h3>Нормативы и факты</h3>
<h3>Разбор по критериям</h3>
<p className="ref">Сравните, что сделал курсант, с ожидаемым порядком действий.</p>
<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.weight ?? 1}`}
<strong className={`debrief-metric-status ${metric.passed ? "is-ok" : "is-needs-work"}`}>
{metric.passed ? "Выполнено" : "Разобрать"}
</strong>
<span>{metric.fact} · ожидалось: {metric.norm}</span>
{metric.ref && <span className="ref"> ({metric.ref})</span>}
</td>
</tr>
@ -56,6 +119,14 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
</tbody>
</table>
{report.metrics.some((metric) => metric.weight != null || metric.credit != null) && <details className="debrief-scoring-details">
<summary>Как критерии повлияли на балл</summary>
<ul>{report.metrics.filter((metric) => metric.weight != null || metric.credit != null).map((metric) => <li key={metric.key}>
<strong>{metric.title}:</strong> вес {metric.weight ?? 1}
{metric.credit != null && ` · вклад в оценку ${Math.round(metric.credit * 100)}%`}
</li>)}</ul>
</details>}
<h3>Отметки</h3>
<table className="grid">
<tbody>
@ -68,7 +139,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
</td>
</tr>
))}
{report.findings.length === 0 && <tr><td colSpan={2}>ошибок не найдено</td></tr>}
{report.findings.length === 0 && <tr><td colSpan={2}>Замечаний по ходу занятия нет.</td></tr>}
</tbody>
</table>
</section>
@ -135,7 +206,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
</section>
</div>
<h3>Транскрипт</h3>
<details className="debrief-transcript">
<summary>Весь ход разговора ({report.transcript.length} реплик)</summary>
<p className="ref">Отметки и комментарии преподавателя привязаны к соответствующим репликам.</p>
<table className="grid">
<tbody>
{report.transcript.map((line) => {
@ -158,6 +231,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
})}
</tbody>
</table>
</details>
</section>
);
}

View file

@ -15,17 +15,21 @@ export const COMPETENCY_LABELS: Record<string, string> = {
export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?: number }) {
if (values.length < 3) return null;
const center = size / 2;
const radius = center - 28;
// The chart needs horizontal gutters: the left/right competency names are
// outside the polygon and used to be clipped by the square SVG viewport.
const width = size + 160;
const centerX = width / 2;
const centerY = size / 2;
const radius = Math.min(centerY - 28, centerX - 100);
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)];
return [centerX + radius * value * Math.cos(angle), centerY + 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="радар компетенций">
<svg width={width} height={size} viewBox={`0 0 ${width} ${size}`} className="radar" role="img" aria-label="радар компетенций">
{[0.25, 0.5, 0.75, 1].map((ring) => (
<polygon
key={ring}
@ -36,9 +40,11 @@ export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?
))}
<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);
const [x, y] = point(index, 1.08);
const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2;
const anchor = Math.cos(angle) > 0.25 ? "start" : Math.cos(angle) < -0.25 ? "end" : "middle";
return (
<text key={item.competency} x={x} y={y} textAnchor="middle" fontSize={10}>
<text key={item.competency} x={x} y={y} textAnchor={anchor} fontSize={11}>
{COMPETENCY_LABELS[item.competency] ?? item.competency}
</text>
);

View file

@ -61,7 +61,7 @@ export function Director({
рвёт звук немедленно.
</p>
<p>
<input type="text" value={free} placeholder="своя реплика — нужна локальная модель"
<input type="text" aria-label="Своя реплика звонящему" value={free} placeholder="своя реплика — нужна локальная модель"
onChange={(event) => setFree(event.target.value)} />{" "}
<button type="button" disabled={disabled || !free.trim()}
onClick={() => { fire(`своя реплика: ${free.trim()}`, free.trim()); setFree(""); }}>

View file

@ -10,6 +10,9 @@ type Draft = {
body: JsonObject;
};
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
type GrammarCheck = { valid: boolean; checks: Array<{
field: string; passed: boolean; errors: string[]; source: string;
}> };
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(path, {
@ -21,7 +24,7 @@ async function api<T>(path: string, method = "GET", body?: unknown): Promise<T>
const data: unknown = await response.json().catch(() => null);
if (!response.ok) {
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
throw new Error(typeof detail === "string" ? detail : "Не удалось выполнить действие. Проверьте поля и повторите попытку.");
}
return data as T;
}
@ -49,10 +52,6 @@ function diff(before: JsonObject, after: JsonObject): JsonObject {
return patch;
}
function pretty(body: JsonObject): string {
return JSON.stringify(body, null, 2);
}
function stringField(body: JsonObject, key: string): string {
return typeof body[key] === "string" ? body[key] : "";
}
@ -63,6 +62,40 @@ function generationLabel(generation: Draft["generation"]): string {
return "копия шаблона";
}
const SCENARIO_CONTENT_LABELS: Record<string, string> = {
f_address: "Адрес", f_caller: "Данные заявителя", f_conscious: "Состояние сознания",
f_fled: "Сведения о скрывшемся", f_gas: "Запах газа", f_observer: "Сведения очевидца",
f_people: "Люди на месте", f_scale: "Масштаб происшествия", f_smoke: "Дым",
f_suspects: "Сведения о подозреваемых", f_threat: "Угроза", f_vehicle: "Транспортное средство",
f_victims: "Пострадавшие", f_what_burns: "Что горит", f_what_happened: "Что произошло",
f_who: "Кто пострадал или участвует", q_address: "Уточнить адрес",
q_address_check: "Проверить адрес", q_caller: "Уточнить данные заявителя",
q_can_move: "Может ли человек передвигаться", q_conscious: "Проверить сознание",
q_fled: "Уточнить сведения о скрывшемся", q_gas: "Уточнить запах газа",
q_observer: "Уточнить сведения очевидца", q_people: "Спросить о людях на месте",
q_scale: "Уточнить масштаб происшествия", q_smoke: "Уточнить сведения о дыме",
q_suspects: "Уточнить сведения о подозреваемых", q_threat: "Уточнить угрозу",
q_vehicle: "Уточнить сведения о транспорте", q_victims: "Спросить о пострадавших",
q_what_burns: "Уточнить, что горит", q_what_happened: "Уточнить, что произошло",
q_who: "Уточнить, кто пострадал или участвует",
};
function contentLabel(id: unknown, kind: "fact" | "question", index: number): string {
const key = typeof id === "string" ? id : "";
return SCENARIO_CONTENT_LABELS[key]
?? `${kind === "fact" ? "Дополнительный факт" : "Дополнительный вопрос"} ${index + 1}`;
}
function grammarFieldLabel(field: string): string {
if (field === "first_line") return "Первая реплика звонящего";
const fact = /^facts\.([^.]+)\.(value|refined)$/.exec(field);
if (fact) {
const subject = SCENARIO_CONTENT_LABELS[fact[1]] ?? "Факт сценария";
return `${fact[2] === "refined" ? "Уточнённый ответ" : "Ответ звонящего"}: ${subject}`;
}
return "Текст сценария";
}
export function ScenarioEditor({
scenarios,
selectedScenarioId,
@ -80,8 +113,10 @@ export function ScenarioEditor({
const [revisionComment, setRevisionComment] = useState("");
const [draftId, setDraftId] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [bodyText, setBodyText] = useState("");
const [editorBody, setEditorBody] = useState<JsonObject | null>(null);
const [validation, setValidation] = useState<Validation | null>(null);
const [grammarCheck, setGrammarCheck] = useState<GrammarCheck | null>(null);
const [manualEdits, setManualEdits] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
@ -93,8 +128,10 @@ export function ScenarioEditor({
function openDraft(value: Draft) {
setDraft(value);
setDraftId(value.id);
setBodyText(pretty(value.body));
setEditorBody(value.body);
setValidation(null);
setGrammarCheck(null);
setManualEdits(false);
setError("");
setNotice("");
}
@ -155,23 +192,51 @@ export function ScenarioEditor({
async function save(): Promise<Draft> {
if (!draft) throw new Error("Сначала создайте или откройте черновик");
let parsed: unknown;
try {
parsed = JSON.parse(bodyText);
} catch (cause) {
throw new Error(`Некорректный JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
}
if (!isObject(parsed)) throw new Error("Тело сценария должно быть JSON-объектом");
if (parsed.id !== draft.id) throw new Error("id черновика менять нельзя");
const patch = diff(draft.body, parsed);
if (!editorBody) throw new Error("Откройте черновик заново.");
const patch = diff(draft.body, editorBody);
if (!Object.keys(patch).length) return draft;
const updated = await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draft.id)}`, "PATCH", patch);
setDraft(updated);
setBodyText(pretty(updated.body));
setEditorBody(updated.body);
setValidation(null);
return updated;
}
function editBodyField(key: string, value: unknown) {
setEditorBody((previous) => previous ? { ...previous, [key]: value } : previous);
setValidation(null);
setGrammarCheck(null);
setManualEdits(true);
}
function editArrayField(arrayKey: string, index: number, key: string, value: unknown) {
setEditorBody((previous) => {
if (!previous || !Array.isArray(previous[arrayKey])) return previous;
const items = [...previous[arrayKey] as unknown[]];
if (!isObject(items[index])) return previous;
items[index] = { ...items[index], [key]: value };
return { ...previous, [arrayKey]: items };
});
setValidation(null);
setGrammarCheck(null);
setManualEdits(true);
}
function editDdsDecision(key: "expected" | "reason", value: string) {
setEditorBody((previous) => {
if (!previous) return previous;
const current = isObject(previous.dds_decision)
? previous.dds_decision : { expected: "accept" };
const decision = { ...current, [key]: value };
if (key === "expected" && value === "accept") delete decision.reason;
if (key === "expected" && value === "decline" && typeof decision.reason !== "string") {
decision.reason = "";
}
return { ...previous, dds_decision: decision };
});
setValidation(null);
}
async function validate() {
await perform(async () => {
const current = await save();
@ -181,6 +246,20 @@ export function ScenarioEditor({
});
}
async function checkGrammar() {
await perform(async () => {
const current = await save();
const result = await api<GrammarCheck>(
`/api/scenarios/drafts/${encodeURIComponent(current.id)}/grammar-check`, "POST",
);
setGrammarCheck(result);
setManualEdits(!result.valid);
setNotice(result.valid
? "Языковая проверка пройдена для первой реплики и текстовых фактов."
: "Языковая проверка нашла замечания; исправьте их и запустите проверку повторно.");
});
}
async function revise() {
await perform(async () => {
setNotice("Qwen перерабатывает черновик по комментарию…");
@ -232,6 +311,16 @@ export function ScenarioEditor({
});
}
const workingBody = editorBody ?? draft?.body ?? {};
const ddsDecision = isObject(workingBody.dds_decision) ? workingBody.dds_decision : {};
const expectedDdsDecision = ddsDecision.expected === "decline" ? "decline" : "accept";
const editableFacts = Array.isArray(workingBody.facts)
? workingBody.facts.map((item, index) => ({ item, index })).filter(({ item }) => isObject(item)) as Array<{ item: JsonObject; index: number }>
: [];
const editableChecklist = Array.isArray(workingBody.checklist)
? workingBody.checklist.map((item, index) => ({ item, index })).filter(({ item }) => isObject(item)) as Array<{ item: JsonObject; index: number }>
: [];
return (
<section className="scenario-editor" id="ai-scenario-editor" aria-busy={busy}>
<h2>ИИ-формирователь и редактор сценариев</h2>
@ -290,15 +379,72 @@ export function ScenarioEditor({
</div>
{draft && (
<>
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Веса критериев занятия настраиваются отдельно при запуске.</p>
<div className="scenario-editor-result">
<strong>Предпросмотр: {stringField(draft.body, "title")}</strong>
<p>Первая реплика: «{stringField(draft.body, "first_line")}»</p>
<p className="ref">Сюжетные факты — проверьте, что они согласуются друг с другом и с эталоном:</p>
<ul>{Array.isArray(draft.body.facts) && draft.body.facts.map((item, index) =>
isObject(item) ? <li key={`${index}-${String(item.id)}`}>{String(item.id)}: {String(item.value ?? "")}</li> : null
<strong>Предпросмотр: {stringField(workingBody, "title")}</strong>
<p>Первая реплика: «{stringField(workingBody, "first_line")}»</p>
<p>Эталон первичного решения ДДС: {expectedDdsDecision === "decline"
? `отказать — ${stringField(ddsDecision, "reason") || "нужно указать причину"}`
: "принять карточку"}</p>
<p className="ref">Проверьте, что сюжетные факты и ответы согласуются друг с другом.</p>
<ul>{editableFacts.map(({ item, index }) =>
<li key={`${index}-${String(item.id)}`}><strong>{contentLabel(item.id, "fact", index)}:</strong> {String(item.value ?? "")}</li>
)}</ul>
</div>
<div className="scenario-human-editor">
<h3>Содержание сценария</h3>
<label>Название занятия
<input value={stringField(workingBody, "title")} maxLength={200}
onChange={(event) => editBodyField("title", event.target.value)} />
</label>
<label>Первая реплика звонящего
<textarea rows={3} value={stringField(workingBody, "first_line")}
onChange={(event) => editBodyField("first_line", event.target.value)} />
</label>
<fieldset className="scenario-dds-decision">
<legend>Эталон первичного решения ДДС</legend>
<p className="ref">Оставьте «Принять», если карточка относится к компетенции службы. Выберите отказ только для подтверждённого исключения: например, дубль или неподведомственная территория.</p>
<label>Какое решение считается правильным
<select value={expectedDdsDecision}
onChange={(event) => editDdsDecision("expected", event.target.value)}>
<option value="accept">Принять карточку</option>
<option value="decline">Не принять карточку</option>
</select>
</label>
{expectedDdsDecision === "decline" && <label>Почему отказ здесь правильный
<input aria-label="Причина эталонного отказа ДДС" value={stringField(ddsDecision, "reason")}
maxLength={500} placeholder="Например: дубликат уже передан по карточке…"
onChange={(event) => editDdsDecision("reason", event.target.value)} />
<small className="ref">Причина обязательна; она войдёт в норму оценки и отчёт курсанта.</small>
</label>}
</fieldset>
<h4>Факты и ответы</h4>
{editableFacts.map(({ item, index }) => <fieldset key={`${index}-${String(item.id)}`}>
<legend>{contentLabel(item.id, "fact", index)}</legend>
<label>Что сообщает звонящий
<textarea rows={2} value={stringField(item, "value")}
onChange={(event) => editArrayField("facts", index, "value", event.target.value)} />
</label>
{typeof item.refined === "string" && <label>Уточнённый ответ после переспроса
<input value={item.refined} onChange={(event) => editArrayField("facts", index, "refined", event.target.value)} />
</label>}
</fieldset>)}
{editableChecklist.length > 0 && <>
<h4>Контрольные вопросы и эталонные ответы</h4>
{editableChecklist.map(({ item, index }) => <fieldset key={`${index}-${String(item.id)}`}>
<legend>{contentLabel(item.id, "question", index)}</legend>
<label>Ожидаемый вопрос оператора
<input value={stringField(item, "question")}
onChange={(event) => editArrayField("checklist", index, "question", event.target.value)} />
</label>
<label>Факт, который нужно выяснить
<textarea rows={2} value={stringField(item, "fact")}
onChange={(event) => editArrayField("checklist", index, "fact", event.target.value)} />
</label>
</fieldset>)}
</>}
<p className="ref">После правок сохраните черновик, проверьте сценарий и повторите языковую проверку перед утверждением.</p>
</div>
<div className="scenario-editor-create">
<label>Комментарий для переработки
<input value={revisionComment} onChange={(event) => setRevisionComment(event.target.value)}
@ -308,20 +454,34 @@ export function ScenarioEditor({
<button type="button" onClick={revise}
disabled={busy || revisionComment.trim().length < 10}>Переработать с ИИ</button>
</div>
<label className="scenario-editor-json">JSON тела сценария
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
</label>
<div className="scenario-editor-actions">
<button type="button" onClick={() => void perform(async () => { await save(); setNotice("Черновик сохранён."); })} disabled={busy}>Сохранить</button>
<button type="button" onClick={validate} disabled={busy}>Проверить</button>
<button type="button" className="start" onClick={approve} disabled={busy || !validation?.valid}>Утвердить</button>
{manualEdits && <button type="button" onClick={checkGrammar} disabled={busy}>Проверить грамматику после правок</button>}
<button type="button" className="start" onClick={approve}
disabled={busy || !validation?.valid || manualEdits}>Утвердить</button>
</div>
</>
)}
{validation && <div className={validation.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
{validation.valid ? <><strong>Проверка пройдена</strong><pre>{pretty(validation.ground_truth ?? {})}</pre></> :
{validation.valid ? <><strong>Проверка пройдена</strong><dl className="scenario-ground-truth">
{typeof validation.ground_truth?.incident_type === "string" && <div><dt>Тип происшествия</dt><dd>{validation.ground_truth.incident_type}</dd></div>}
{typeof validation.ground_truth?.dds === "string" && <div><dt>Ответственная служба</dt><dd>{validation.ground_truth.dds}</dd></div>}
{typeof validation.ground_truth?.incident_code === "string" && <div><dt>Код ЕКП</dt><dd>{validation.ground_truth.incident_code}</dd></div>}
{typeof validation.ground_truth?.address === "string" && <div><dt>Адрес</dt><dd>{validation.ground_truth.address}</dd></div>}
{typeof validation.ground_truth?.victims === "number" && <div><dt>Пострадавших</dt><dd>{validation.ground_truth.victims}</dd></div>}
{Array.isArray(validation.ground_truth?.notify) && <div><dt>Оповещаемые службы</dt><dd>{validation.ground_truth.notify.map(String).join(", ") || "не назначены"}</dd></div>}
{Array.isArray(validation.ground_truth?.required_facts) && <div><dt>Обязательные факты</dt><dd>{validation.ground_truth.required_facts.map(String).join(", ") || "не заданы"}</dd></div>}
</dl></> :
<><strong>Ошибки проверки</strong><ul>{validation.errors.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}</ul></>}
</div>}
{grammarCheck && <div className={grammarCheck.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
<strong>{grammarCheck.valid ? "Грамматика: замечаний нет" : "Замечания к грамматике"}</strong>
<ul>{grammarCheck.checks.flatMap((item) => item.errors.map((message, index) =>
<li key={`${item.field}-${index}`}><strong>{grammarFieldLabel(item.field)}:</strong> {message}</li>
))}</ul>
<small className="ref">Проверены первая реплика и текстовые значения фактов; код не переписывает текст. Источник проверки для каждого поля: правила или Vikhr.</small>
</div>}
{error && <p className="violated" role="alert">{error}</p>}
{notice && <p className="state-ok" role="status">{notice}</p>}
</section>

View file

@ -0,0 +1,105 @@
import { useState } from "react";
import type { Exercise } from "@/shared/types/generated";
type LessonExercise = Exercise | "card_dds";
type MetricOption = { key: string; label: string; hint: string };
const OPERATOR_METRICS: MetricOption[] = [
{ key: "answer_time", label: "Ответ на вызов", hint: "Скорость принятия звонка." },
{ key: "interview_time", label: "Длительность опроса", hint: "Соблюдение норматива опроса." },
{ key: "checklist_completeness", label: "Полнота опроса", hint: "Получение обязательных сведений из сценария." },
{ key: "incident_signs", label: "Признаки происшествия", hint: "Верность признаков и кода ЕКП." },
{ key: "incident_type", label: "Тип происшествия", hint: "Верность типа, если код ЕКП не задан." },
{ key: "dds_choice", label: "Выбор службы", hint: "Маршрутизация для сценариев без кода ЕКП." },
{ key: "outcome", label: "Исход вызова", hint: "Карточка, справка или иной предусмотренный итог." },
{ key: "address_refined", label: "Уточнение адреса", hint: "Подтверждение уточняемых адресных ориентиров." },
{ key: "address", label: "Адрес в карточке", hint: "Совпадение адреса с эталоном по компонентам." },
{ key: "victims_count", label: "Пострадавшие", hint: "Точность числа, если оно известно из сценария." },
{ key: "required_fields", label: "Обязательные поля КИО", hint: "Полнота заполнения полей карточки." },
{ key: "dds_chain", label: "Передача карточки в ДДС", hint: "Успешная передача, если она предусмотрена." },
{ key: "callback", label: "Обратный дозвон", hint: "Норматив повторного вызова при обрыве." },
{ key: "description_grammar", label: "Грамматика описания", hint: "Учитывается, если включён критерий занятия." },
{ key: "card_fill_time", label: "Время заполнения КИО", hint: "Соблюдение заданного преподавателем норматива." },
];
const DDS_METRICS: MetricOption[] = [
{ key: "dds_primary", label: "Первичное решение", hint: "Зафиксирован ли статус «Принята» или «Не принята»." },
{ key: "dds_ack", label: "Срок первичного решения", hint: "Соблюдение установленного лимита реакции." },
{ key: "dds_decision", label: "Решение по эталону", hint: "Соответствие принятию или обоснованному отказу." },
{ key: "dds_crew", label: "Назначение бригады", hint: "Выбор необходимой бригады ДДС." },
{ key: "dds_progress", label: "Ход реагирования", hint: "Статусы по фактическому ходу работ или отказу." },
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
];
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
"incident_signs", "incident_type", "dds_choice", "address", "victims_count",
"required_fields", "description_grammar", "card_fill_time",
].includes(key));
const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time");
export function ScoreWeightsEditor({
exercise,
values,
onChange,
}: {
exercise: LessonExercise;
values: Record<string, number>;
onChange: (weights: Record<string, number>) => void;
}) {
const [drafts, setDrafts] = useState<Record<string, string>>({});
const groups: [string, MetricOption[]][] = exercise === "dds"
? [["Реагирование ДДС", DDS_METRICS]]
: exercise === "card"
? [["Карточка КИО", CARD_METRICS]]
: exercise === "card_dds"
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
function setWeight(key: string, raw: string) {
const next = { ...values };
if (raw === "") delete next[key];
else {
const value = Number(raw);
if (!Number.isFinite(value)) return;
next[key] = Math.max(0, Math.min(10, value));
}
onChange(next);
}
return <details className="lesson-score-weight-editor">
<summary>Настроить веса оценки (необязательно)</summary>
<p className="ref">
Пустое поле сохраняет вес сценария или стандартный вес. Значение 0 исключает
метрику из расчёта; допустимый диапазон — от 0 до 10.
</p>
{groups.map(([group, metrics]) => <fieldset key={group}>
<legend>{group}</legend>
{metrics.map((metric) => <label className="lesson-criterion" key={metric.key}>
<span title={metric.hint}>{metric.label}
<small className="weight-metric-hint" id={`weight-hint-${metric.key}`}>{metric.hint}</small>
</span>
<input type="number" min={0} max={10} step={0.5}
aria-label={`Вес метрики «${metric.label}»`}
aria-describedby={`weight-hint-${metric.key}`}
value={drafts[metric.key] ?? values[metric.key] ?? ""} placeholder="—"
onChange={(event) => setDrafts((previous) => ({ ...previous, [metric.key]: event.target.value }))}
onBlur={(event) => {
const raw = event.target.value.trim();
if (!raw) {
setDrafts((previous) => ({ ...previous, [metric.key]: "" }));
setWeight(metric.key, "");
return;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
const normalized = Math.max(0, Math.min(10, parsed));
setDrafts((previous) => ({ ...previous, [metric.key]: String(normalized) }));
setWeight(metric.key, String(normalized));
}} />
</label>)}
</fieldset>)}
</details>;
}

View file

@ -12,20 +12,33 @@ import type { LessonCriteria, SessionMode } from "@/shared/types/generated";
export function useControl(sessionId: string | null) {
const [status, setStatus] = useState<ChannelStatus>("closed");
const channel = useRef<ReturnType<typeof controlChannel> | null>(null);
const [openSessionId, setOpenSessionId] = useState<string | null>(null);
const channel = useRef<{ sessionId: string; socket: ReturnType<typeof controlChannel> } | null>(null);
useEffect(() => {
if (!sessionId) return;
const ch = controlChannel(sessionId, { onStatus: setStatus }).connect();
channel.current = ch;
channel.current = null;
setOpenSessionId(null);
if (!sessionId) {
setStatus("closed");
return;
}
let active = true;
setStatus("connecting");
const ch = controlChannel(sessionId, { onStatus: (next) => {
if (!active) return;
setStatus(next);
setOpenSessionId(next === "open" ? sessionId : null);
} }).connect();
channel.current = { sessionId, socket: ch };
return () => {
active = false;
ch.close();
channel.current = null;
if (channel.current?.socket === ch) channel.current = null;
};
}, [sessionId]);
const start = useCallback(
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, ddsService?: string, scenarioIds?: string[], ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, handoffToDds = false, criteria?: LessonCriteria) => {
(targetSessionId: string, scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, ddsService?: string, scenarioIds?: string[], ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, handoffToDds = false, criteria?: LessonCriteria, randomScenarioIds?: string[]) => {
const message = {
type: "scenario.start",
scenario_id: scenarioId,
@ -37,24 +50,27 @@ export function useControl(sessionId: string | null) {
exercise,
handoff_to_dds: handoffToDds,
scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
random_scenario_ids: randomScenarioIds?.length ? randomScenarioIds : undefined,
dds_arrival_interval_seconds: ddsArrivalIntervalSeconds,
dds_max_waiting: ddsMaxWaiting,
criteria,
} as const;
channel.current?.send(message);
const current = channel.current;
if (!current || current.sessionId !== targetSessionId) return false;
return current.socket.send(message);
},
[],
);
const note = useCallback(
(transcriptRef: string, text: string) =>
channel.current?.send({ type: "instructor_note.add", transcript_ref: transcriptRef, text }),
channel.current?.socket.send({ type: "instructor_note.add", transcript_ref: transcriptRef, text }),
[],
);
const inject = useCallback(
(directive: string, immediate: boolean) =>
channel.current?.send({
channel.current?.socket.send({
type: "director.inject",
directive,
// `immediate` рвёт звук на полуслове и годится только для обрыва связи:
@ -64,8 +80,8 @@ export function useControl(sessionId: string | null) {
[],
);
const stop = useCallback(() => channel.current?.send({ type: "session.stop" }), []);
const playReference = useCallback(() => channel.current?.send({ type: "reference.play" }), []);
const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []);
const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []);
return { status, start, note, inject, stop, playReference };
return { status, openSessionId, start, note, inject, stop, playReference };
}

View file

@ -20,7 +20,9 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
const groups = visibleGroups(dds);
const section = (group: FieldGroup) => (
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
{(!referenceArm || group.area !== "header") && <div className="kio-arm-section-title">{group.title}</div>}
{(!referenceArm || group.area !== "header") && <div className="kio-arm-section-title">
{referenceArm && group.area === "incident" ? "Введите тип происшествия" : group.title}
</div>}
<div className="kio-arm-fields">
{(referenceArm && group.area === "incident"
? [...group.fields].sort((a, b) => Number(b.path === "incident_type") - Number(a.path === "incident_type"))
@ -198,6 +200,7 @@ function SignsPicker({
{levels.map((level) => (
<SignSelect
key={level}
level={level}
prefix={value.slice(0, level)}
group={group}
chosen={value[level] ?? ""}
@ -218,8 +221,9 @@ function SignsPicker({
}
function SignSelect({
prefix, group, chosen, onPick,
level, prefix, group, chosen, onPick,
}: {
level: number;
prefix: string[];
group: number | null;
chosen: string;
@ -230,7 +234,7 @@ function SignSelect({
// Уровня может не быть: часть происшествий классифицируется двумя признаками.
if (!page.isLoading && values.length === 0) return null;
return (
<select aria-label={`Признак происшествия ${prefix.length + 1}`} value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
<select aria-label={`Признак происшествия ${level + 1}`} value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
<option value="">—</option>
{values.map((sign) => (
<option key={sign} value={sign}>{sign}</option>

View file

@ -15,6 +15,24 @@ export interface CardState {
export const empty: CardState = { server: {}, pending: {} };
function sameValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left) && Array.isArray(right)
&& left.length === right.length
&& left.every((item, index) => sameValue(item, right[index]));
}
if (left && right && typeof left === "object" && typeof right === "object") {
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const keys = Object.keys(leftRecord);
return keys.length === Object.keys(rightRecord).length
&& keys.every((key) => key in rightRecord
&& sameValue(leftRecord[key], rightRecord[key]));
}
return false;
}
export function setNested(card: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...card, [head]: value };
@ -42,7 +60,7 @@ export function applyPatch(
const pending = { ...state.pending };
for (const [path, value] of Object.entries(fields)) {
server = setNested(server, path, value);
if (source === "auto" || pending[path] === value) delete pending[path];
if (source === "auto" || sameValue(pending[path], value)) delete pending[path];
}
return { server, pending };
}
@ -51,7 +69,7 @@ export function applyPatch(
export function applyState(state: CardState, card: Record<string, unknown>): CardState {
const pending = { ...state.pending };
for (const path of Object.keys(pending)) {
if (readPath(card, path) === pending[path]) delete pending[path];
if (sameValue(readPath(card, path), pending[path])) delete pending[path];
}
return { server: card, pending };
}

View file

@ -47,16 +47,46 @@ export function useObserve(sessionId: string | null): ObservedSession {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setStatus(sessionId ? "connecting" : "closed");
setTitle(null);
setMode(undefined);
setTrainee(null);
setCard({ server: { ...emptyCard.server }, pending: { ...emptyCard.pending } });
setRequiredFields([]);
setTranscript([]);
setTimers([]);
setHintsUsed(0);
setNotes(new Map());
setEnded(false);
setReport(null);
setError(null);
if (!sessionId) return;
// A report may already be archived when an instructor opens a history link;
// the observe socket is then intentionally unavailable after process restart.
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" })
.then((response) => (response.ok ? response.json() : null))
.then((saved) => { if (saved) setReport(saved as SessionReport); })
.catch(() => undefined);
let active = true;
// Active monitors used to query this on every open. Almost all such requests
// could only return score_not_ready, competing with the snapshot under load.
// Ask for the report only after completion or when no live session exists
// (e.g. history opened after a backend restart).
let reportLoading = false;
let reportLoaded = false;
const loadReport = () => {
if (reportLoading || reportLoaded) return;
reportLoading = true;
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" })
.then((response) => (response.ok ? response.json() : null))
.then((saved) => {
if (!active) return;
if (saved) {
reportLoaded = true;
setReport(saved as SessionReport);
}
})
.catch(() => undefined)
.finally(() => { reportLoading = false; });
};
const channel = observeChannel(sessionId, {
onStatus: setStatus,
onStatus: (next) => { if (active) setStatus(next); },
onEvent: (event) => {
if (!active) return;
switch (event.type) {
case "session.snapshot":
setTitle(event.scenario_title);
@ -68,6 +98,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
setTimers(event.timers);
setHintsUsed(event.hints_used ?? 0);
setEnded(event.ended ?? false);
if (event.ended) loadReport();
break;
case "kio.state":
setCard((prev) => applyState(prev, event.kio as unknown as Record<string, unknown>));
@ -89,20 +120,19 @@ export function useObserve(sessionId: string | null): ObservedSession {
break;
case "session.ended":
setEnded(true);
loadReport();
break;
case "error":
setError(event.message);
if (event.code === "session_not_found") loadReport();
break;
case "score.ready":
void fetch(`/api/sessions/${sessionId}/report`)
.then((response) => (response.ok ? response.json() : null))
.then(setReport)
.catch(() => setReport(null));
loadReport();
break;
}
},
}).connect();
return () => channel.close();
return () => { active = false; channel.close(); };
}, [sessionId]);
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error };

View file

@ -47,6 +47,7 @@ export function SelfAssessment({
<td>
<input
type="text"
aria-label="Комментарий к самооценке"
value={comment}
placeholder="например: запнулся на адресе, растерялся"
onChange={(event) => setComment(event.target.value)}

View file

@ -13,6 +13,8 @@ const TITLES: Record<string, string> = {
interview: "Опрос",
dds_notify: "Оповещение ДДС",
dds_ack: "Подтверждение ДДС",
dds_work: "Отработка карточки ДДС",
card_fill: "Заполнение карточки КИО",
zone_check: "Зона ответственности",
callback: "Обратный дозвон",
close: "Снятие с контроля",