2026-09-17 21:13:41 +03:00
|
|
|
|
// АРМ курсанта: приём вызова и разговор голосом.
|
|
|
|
|
|
// Карточка КИО — lct-10, таймеры — lct-11.
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
import { KioCard } from "@/features/kio-card/KioCard";
|
lct-21: make demo, честный README, готовность стенда
make demo поднимает стенд целиком: проверяет модели, базу, миграции,
сценарии, бэкенд с голосом в офлайн-режиме и фронт. Обкатан заранее,
как и требует карточка, — и сразу поймал ошибку: `cd backend && команда &`
уводит в фон всю связку, родительская оболочка остаётся на месте,
и фронт запускался не из репозитория.
Пять диалогов подряд через make demo: без сбоев, ход 965–1210 мс,
первая реплика 78–115 мс. make dev проверен целиком — образ фронта
собирается, страница отдаётся.
README переписан под факт: два шага до стенда и таблица того, что
работает, вместо обещаний. Экран готовности показывает курсанту, почему
звонящий молчит, вместо тишины без объяснений.
В Makefile записано, что make test и make demo нельзя запускать
одновременно: они просят у uv разные группы зависимостей, uv пересобирает
одно окружение, и второй запуск молча ждёт блокировку.
2026-09-17 22:24:48 +03:00
|
|
|
|
import { StandStatus } from "@/features/health/StandStatus";
|
2026-09-17 21:18:28 +03:00
|
|
|
|
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
import { Debrief } from "@/features/debrief/Debrief";
|
2026-09-17 21:24:34 +03:00
|
|
|
|
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
|
2026-09-17 21:18:28 +03:00
|
|
|
|
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
import { display } from "@/features/kio-card/merge";
|
2026-09-17 21:13:41 +03:00
|
|
|
|
import { useCall } from "@/features/call/useCall";
|
|
|
|
|
|
import { sessionIdFromUrl } from "@/shared/api/session";
|
2026-09-21 17:40:54 +03:00
|
|
|
|
import type { DDSCode } from "@/shared/types/generated";
|
2026-09-17 21:13:41 +03:00
|
|
|
|
|
|
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
|
|
|
|
connecting: "подключение",
|
|
|
|
|
|
open: "на связи",
|
|
|
|
|
|
reconnecting: "связь потеряна, переподключение",
|
|
|
|
|
|
closed: "отключено",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
const RESOLVE_LABELS = {
|
|
|
|
|
|
consultation: "справочно-консультационная помощь",
|
|
|
|
|
|
transfer_region: "передан в систему-112 другого региона",
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
function ddsCode(value: unknown): DDSCode | undefined {
|
|
|
|
|
|
return value === "01" || value === "02" || value === "03" || value === "04" || value === "gkh"
|
|
|
|
|
|
? value : undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 21:13:41 +03:00
|
|
|
|
export function Call() {
|
2026-09-26 18:12:27 +03:00
|
|
|
|
const [textTurn, setTextTurn] = useState("");
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
const [resolveComment, setResolveComment] = useState("");
|
|
|
|
|
|
const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null);
|
2026-09-17 21:13:41 +03:00
|
|
|
|
const sessionId = sessionIdFromUrl();
|
|
|
|
|
|
const call = useCall(sessionId);
|
2026-09-26 18:12:27 +03:00
|
|
|
|
const location = useLocation();
|
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const isCompletionScreen = location.pathname === "/trainee/completed";
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (call.report && sessionId && !isCompletionScreen) {
|
|
|
|
|
|
navigate(`/trainee/completed?session=${sessionId}`, { replace: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [call.report, isCompletionScreen, navigate, sessionId]);
|
2026-09-17 21:13:41 +03:00
|
|
|
|
|
|
|
|
|
|
if (!sessionId) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<main className="page">
|
|
|
|
|
|
<h1>АРМ курсанта</h1>
|
|
|
|
|
|
<p className="warn">В адресе нет номера занятия: откройте ссылку вида /trainee?session=…</p>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if (isCompletionScreen) {
|
|
|
|
|
|
if (!call.report) return <main className="page completion-page">
|
|
|
|
|
|
<h1>Занятие завершено</h1>
|
|
|
|
|
|
{call.reportError
|
|
|
|
|
|
? <p role="alert" className="state-violated">{call.reportError}</p>
|
|
|
|
|
|
: <p role="status">Получаем итог занятия…</p>}
|
|
|
|
|
|
<Link to="/profile">Вернуться к профилю</Link>
|
|
|
|
|
|
</main>;
|
|
|
|
|
|
const report = call.report;
|
|
|
|
|
|
const nextSteps = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
|
|
|
|
|
|
const completedCount = Math.max(0, report.metrics.length - report.failed_metrics);
|
|
|
|
|
|
return <main className="page completion-page">
|
|
|
|
|
|
<header className="completion-heading">
|
|
|
|
|
|
<span>Упражнение завершено</span>
|
|
|
|
|
|
<h1>{report.passed ? "Зачёт" : "Не зачтено"}</h1>
|
|
|
|
|
|
<p>{report.exercise === "card" ? "Заполнение карточки КИО" : report.exercise === "dds" ? "Работа диспетчера ДДС" : "Приём вызова 112"} · {report.scenario_id}</p>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<section className={`completion-score ${report.passed ? "is-passed" : "is-failed"}`} aria-label="Итог занятия">
|
|
|
|
|
|
<div><span>Ваш результат</span><strong>{report.score_final.toFixed(0)}<small> / 100</small></strong></div>
|
|
|
|
|
|
<p>{completedCount} критериев выполнено · {report.failed_metrics} требуют внимания</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
{nextSteps.length > 0 && <section className="completion-focus">
|
|
|
|
|
|
<h2>Что потренировать в следующий раз</h2>
|
|
|
|
|
|
<ol>{nextSteps.map((metric) => <li key={metric.key}><strong>{metric.title}</strong><span>{metric.fact}</span></li>)}</ol>
|
|
|
|
|
|
</section>}
|
|
|
|
|
|
<details className="completion-details">
|
|
|
|
|
|
<summary>Подробный разбор занятия</summary>
|
|
|
|
|
|
<Debrief report={report} />
|
|
|
|
|
|
</details>
|
|
|
|
|
|
<nav className="completion-actions" aria-label="Действия после занятия">
|
|
|
|
|
|
<Link to="/profile">Вернуться к профилю</Link>
|
|
|
|
|
|
<a href={`/api/sessions/${report.session_id}/report.pdf`}>Скачать отчёт (PDF)</a>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
</main>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if (call.briefing) {
|
2026-09-21 19:56:13 +03:00
|
|
|
|
const services = display(call.card, "notify");
|
|
|
|
|
|
const notifiedServices = Array.isArray(services) ? services.map(String) : [];
|
2026-09-26 18:12:27 +03:00
|
|
|
|
// Some already-open demo backends still send the old multi-line briefing.
|
|
|
|
|
|
// Keep only the caller's opening line; never dump the answer key into view.
|
|
|
|
|
|
const openingLine = call.briefing.text.split(/\r?\n/)[1]
|
|
|
|
|
|
?? call.briefing.text.split(/\r?\n/)[0]
|
|
|
|
|
|
?? "Алло? Вы меня слышите?";
|
2026-09-21 19:56:13 +03:00
|
|
|
|
return <main className="page arm-page">
|
2026-09-21 17:40:54 +03:00
|
|
|
|
<ModeBanner mode={call.briefing.mode} />
|
2026-09-21 19:56:13 +03:00
|
|
|
|
<header className="arm-topbar arm-topbar-text">
|
|
|
|
|
|
<div className="arm-topbar-mark">112</div>
|
|
|
|
|
|
<div className="arm-topbar-title"><strong>Карточка информационного обмена</strong><span>Текстовое упражнение · сценарий {call.briefing.scenario_id}</span></div>
|
|
|
|
|
|
<div className="arm-topbar-state"><span className={call.status === "open" ? "state-ok" : "state-warn"}>● {call.status === "open" ? "АРМ подключён" : STATUS_LABEL[call.status]}</span><small>{call.cardSubmitted ? "Карточка отправлена" : "Карточка заполняется"}</small></div>
|
|
|
|
|
|
</header>
|
2026-09-26 18:12:27 +03:00
|
|
|
|
<div className="arm-text-layout">
|
|
|
|
|
|
<section className="arm-text-dialogue" aria-label="Текстовый диалог с заявителем">
|
|
|
|
|
|
<header><strong>Диалог с заявителем</strong><span>{call.cardSubmitted ? "Диалог завершён" : "Задайте вопрос"}</span></header>
|
|
|
|
|
|
<div className="arm-text-lines" aria-live="polite">
|
|
|
|
|
|
{!call.lines.length && <p className="arm-text-line caller"><b>Заявитель</b>{openingLine}</p>}
|
|
|
|
|
|
{call.lines.map((line, index) => <p key={`${index}-${line.speaker}`} className={`arm-text-line ${line.speaker}`}>
|
|
|
|
|
|
<b>{line.speaker === "caller" ? "Заявитель" : "Вы"}</b>{line.text}
|
|
|
|
|
|
{line.speaker === "caller" && <small className="arm-line-source">{line.source === "local_llm" ? "Qwen · ответ модели" : "Сценарный ответ"}</small>}
|
|
|
|
|
|
</p>)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<form onSubmit={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
if (call.sendText(textTurn)) setTextTurn("");
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000}
|
|
|
|
|
|
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
|
|
|
|
|
|
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
|
|
|
|
|
|
</form>
|
2026-09-27 16:35:13 +03:00
|
|
|
|
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
|
2026-09-26 18:12:27 +03:00
|
|
|
|
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<div className="arm-card-wrap">
|
|
|
|
|
|
<KioCard state={call.card} required={call.briefing.required_fields}
|
|
|
|
|
|
readOnly={call.cardSubmitted} referenceArm onChange={call.patchKio} />
|
|
|
|
|
|
</div>
|
2026-09-21 19:56:13 +03:00
|
|
|
|
</div>
|
2026-09-26 18:12:27 +03:00
|
|
|
|
<section className="arm-support" aria-label="Норматив заполнения карточки">
|
|
|
|
|
|
<Timers timers={call.timers} />
|
|
|
|
|
|
</section>
|
2026-09-21 19:56:13 +03:00
|
|
|
|
<footer className="arm-footer">
|
|
|
|
|
|
<span className="arm-footer-services">Службы:
|
|
|
|
|
|
{notifiedServices.length
|
|
|
|
|
|
? notifiedServices.map((service) => <b key={service}>{service}</b>)
|
|
|
|
|
|
: <em>появятся после выбора признаков</em>}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"}
|
|
|
|
|
|
onClick={call.submitCard}>Сохранить и проверить</button>}
|
2026-09-21 20:10:57 +03:00
|
|
|
|
{call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
|
2026-09-21 19:56:13 +03:00
|
|
|
|
</footer>
|
|
|
|
|
|
{call.error && <p className="state-violated arm-message" role="alert">{call.error}</p>}
|
2026-09-21 20:10:57 +03:00
|
|
|
|
{call.cardSubmitted && !call.report && !call.reportError && <p className="arm-message">
|
|
|
|
|
|
{call.briefing.handoff_to_dds
|
|
|
|
|
|
? <>Карточка передана в ДДС. <a href={`/dds?session=${sessionId}&role=dds_01`}>Открыть АРМ ДДС</a> и завершить реагирование для итоговой оценки.</>
|
|
|
|
|
|
: "Карточка отправлена. Ожидаем итоговую оценку…"}
|
|
|
|
|
|
</p>}
|
2026-09-21 19:56:13 +03:00
|
|
|
|
{call.reportError && <p className="state-violated arm-message" role="alert">{call.reportError}</p>}
|
2026-09-21 17:40:54 +03:00
|
|
|
|
{call.report && <Debrief report={call.report} />}
|
|
|
|
|
|
</main>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const service = ddsCode(display(call.card, "dds"));
|
|
|
|
|
|
const notify = display(call.card, "notify");
|
|
|
|
|
|
const autoRoute = Boolean(display(call.card, "incident_code")) && Array.isArray(notify) && notify.length > 0;
|
|
|
|
|
|
|
2026-09-17 21:13:41 +03:00
|
|
|
|
return (
|
2026-09-21 19:56:13 +03:00
|
|
|
|
<main className="page arm-page">
|
lct-21: make demo, честный README, готовность стенда
make demo поднимает стенд целиком: проверяет модели, базу, миграции,
сценарии, бэкенд с голосом в офлайн-режиме и фронт. Обкатан заранее,
как и требует карточка, — и сразу поймал ошибку: `cd backend && команда &`
уводит в фон всю связку, родительская оболочка остаётся на месте,
и фронт запускался не из репозитория.
Пять диалогов подряд через make demo: без сбоев, ход 965–1210 мс,
первая реплика 78–115 мс. make dev проверен целиком — образ фронта
собирается, страница отдаётся.
README переписан под факт: два шага до стенда и таблица того, что
работает, вместо обещаний. Экран готовности показывает курсанту, почему
звонящий молчит, вместо тишины без объяснений.
В Makefile записано, что make test и make demo нельзя запускать
одновременно: они просят у uv разные группы зависимостей, uv пересобирает
одно окружение, и второй запуск молча ждёт блокировку.
2026-09-17 22:24:48 +03:00
|
|
|
|
<StandStatus />
|
2026-09-17 21:18:28 +03:00
|
|
|
|
<ModeBanner mode={call.incoming?.mode} />
|
2026-09-21 19:56:13 +03:00
|
|
|
|
<header className="arm-topbar">
|
|
|
|
|
|
<div className="arm-topbar-mark">112</div>
|
|
|
|
|
|
<div className="arm-phone-cell"><small>Канал связи</small><strong className={call.status === "open" ? "state-ok" : "state-warn"}>{STATUS_LABEL[call.status]}</strong><span>{call.micOn ? "● Микрофон включён" : "Микрофон выключен"}</span></div>
|
|
|
|
|
|
<div className="arm-phone-cell arm-phone-main"><small>Входящий вызов</small><strong>{call.incoming?.caller_number || "Ожидание вызова"}</strong><span>{call.incoming ? `Сценарий ${call.incoming.scenario_id} · ${call.callerSpeaking ? "заявитель говорит" : "заявитель молчит"}` : "Карточка не назначена"}</span></div>
|
|
|
|
|
|
<div className="arm-topbar-title"><strong>АРМ оператора 112</strong><span>Карточка информационного обмена</span></div>
|
|
|
|
|
|
<div className="arm-topbar-clock"><InterviewTimer timers={call.timers} /></div>
|
|
|
|
|
|
</header>
|
2026-09-17 21:13:41 +03:00
|
|
|
|
|
2026-09-21 19:56:13 +03:00
|
|
|
|
<div className="arm-call-actions">
|
2026-09-26 18:12:27 +03:00
|
|
|
|
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>
|
|
|
|
|
|
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
|
|
|
|
|
|
</button>}
|
2026-09-21 19:56:13 +03:00
|
|
|
|
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
|
|
|
|
|
|
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>}
|
2026-09-26 18:12:27 +03:00
|
|
|
|
<span className="arm-call-actions-note">{call.phase === "ended"
|
|
|
|
|
|
? "Вызов завершён"
|
|
|
|
|
|
: !call.micOn && call.phase === "incoming"
|
|
|
|
|
|
? "Поля КИО разблокируются после ответа на входящий вызов"
|
|
|
|
|
|
: !call.micOn && call.phase === "talking"
|
|
|
|
|
|
? "Разговор продолжается. Нажмите кнопку, чтобы включить звук и микрофон на этом устройстве"
|
|
|
|
|
|
: call.micOn
|
|
|
|
|
|
? "Заполните адрес и признаки происшествия со слов заявителя"
|
|
|
|
|
|
: "Ожидайте входящего вызова"}</span>
|
2026-09-21 19:56:13 +03:00
|
|
|
|
</div>
|
2026-09-17 21:13:41 +03:00
|
|
|
|
|
2026-09-27 16:35:13 +03:00
|
|
|
|
{call.paused && <p className="state-warn arm-message" role="status">Пауза, ждите преподавателя</p>}
|
2026-09-21 19:56:13 +03:00
|
|
|
|
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
|
2026-09-17 21:24:34 +03:00
|
|
|
|
|
2026-09-21 19:56:13 +03:00
|
|
|
|
<div className="arm-card-wrap">
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
<KioCard
|
|
|
|
|
|
state={call.card}
|
|
|
|
|
|
required={call.incoming?.required_fields ?? []}
|
|
|
|
|
|
readOnly={!call.micOn}
|
2026-09-21 19:56:13 +03:00
|
|
|
|
referenceArm
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
onChange={call.patchKio}
|
|
|
|
|
|
/>
|
2026-09-21 19:56:13 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
<footer className="arm-footer">
|
|
|
|
|
|
<span>Службы · список оповещения рассчитывается по признакам</span>
|
|
|
|
|
|
<div className="arm-footer-actions">
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-21 19:56:13 +03:00
|
|
|
|
className="arm-primary"
|
2026-09-21 17:40:54 +03:00
|
|
|
|
disabled={!call.micOn || (!service && !autoRoute)}
|
|
|
|
|
|
onClick={() => call.dispatch(service)}
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
>
|
|
|
|
|
|
Передать в ДДС
|
|
|
|
|
|
</button>
|
2026-09-21 19:56:13 +03:00
|
|
|
|
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
<div className="arm-secondary">
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
{/* Не каждый вызов заканчивается карточкой: справка и чужой регион —
|
|
|
|
|
|
отдельные исходы, и курсант выбирает их сам (docs/spec/TICKETS.md). */}
|
2026-09-21 19:56:13 +03:00
|
|
|
|
<details className="arm-resolve">
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
<summary>Закрыть вызов без карточки</summary>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
2026-09-26 18:12:27 +03:00
|
|
|
|
aria-label="Комментарий при закрытии вызова без карточки"
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
value={resolveComment}
|
|
|
|
|
|
placeholder="что сказали заявителю, куда передали"
|
|
|
|
|
|
onChange={(event) => setResolveComment(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={!call.micOn || resolved !== null}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
call.resolve("consultation", resolveComment);
|
|
|
|
|
|
setResolved("consultation");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Справка, выезд не нужен
|
|
|
|
|
|
</button>{" "}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={!call.micOn || resolved !== null}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
call.resolve("transfer_region", resolveComment);
|
|
|
|
|
|
setResolved("transfer_region");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Передать в другой регион
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{resolved && <p className="warn">Вызов закрыт: {RESOLVE_LABELS[resolved]}</p>}
|
|
|
|
|
|
</details>
|
2026-09-21 19:56:13 +03:00
|
|
|
|
<section className="arm-support">
|
|
|
|
|
|
<h2>Нормативы и разговор</h2>
|
2026-09-17 21:18:28 +03:00
|
|
|
|
<Timers timers={call.timers} />
|
2026-09-21 19:56:13 +03:00
|
|
|
|
<table className="grid">
|
2026-09-17 21:13:41 +03:00
|
|
|
|
<tbody>
|
|
|
|
|
|
{call.lines.map((line, index) => (
|
|
|
|
|
|
<tr key={index}>
|
|
|
|
|
|
<th>{line.speaker === "caller" ? "звонящий" : "оператор"}</th>
|
|
|
|
|
|
<td className={line.partial ? "warn" : ""}>{line.text}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{call.lines.length === 0 && <tr><td colSpan={2}>—</td></tr>}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
2026-09-21 19:56:13 +03:00
|
|
|
|
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
|
|
|
|
|
|
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{call.selfAssessed && !call.report && <p className="arm-message">Самооценка принята, оценка считается…</p>}
|
|
|
|
|
|
{call.report && <Debrief report={call.report} />}
|
2026-09-17 21:13:41 +03:00
|
|
|
|
</main>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|