Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
Возврат карточки — самая ценная механика цепочки: неполнота КИО
перестаёт быть процентом в отчёте и становится сорванным выездом
с конкретной причиной. В разборе это отметка E6: «не заполнено floor,
victims_count — выезд сорван».
Снимок карточки не меняется после передачи: оператор не дописывает
задним числом поле, которое забыл. Тест правит карточку после передачи
и проверяет, что в снимке этого нет.
Диспетчер садится за АРМ, когда вызов уже идёт, — станция, подключённая
после передачи, всё равно получает карточку.
У станции нет аудио вообще: ни VAD, ни распознавания, ни синтеза.
Работа над ошибками становится измеримым циклом: посмотрел разбор →
попробовал снова → увидел дельту по оценке, времени опроса и добытым
фактам.
В отчёт занятия добавлена сводка числами — время опроса, факты,
подсказки, коды ошибок. Без неё дельту пришлось бы вытаскивать разбором
текста метрик («94 с»), а это путь к тихим ошибкам.
Радар — среднее по попыткам: одна неудачная попытка не определяет
курсанта.
Проверено сквозным тестом на живой базе: два занятия одного курсанта
дают две попытки и посчитанную дельту. Проверка доступности базы в тесте
была ложной — синхронного драйвера в проекте нет, и тест молча
пропускался.
Контур замкнут без LLM: звонящий отвечает заготовками через слот-автомат.
Живой диалог через WebSocket прошёл 5 раз подряд без сбоев: первая
реплика после снятия трубки — 87–154 мс, ход — 1.2–1.4 с, перебивание —
88–90 мс. На LLM до цели 1.5 с остаётся 100–300 мс.
Задача ответа живёт, пока у курсанта доигрывает звук: перебивание —
это отмена одной задачи, и tts.end приходит, когда звонящий
действительно замолчал.
Что нашлось при сборке:
- Silero молча выбрасывает цифры: «улица Ленина, дом… квартира…» без
номеров. Числа и сокращения разворачиваются в слова до синтеза.
- onnxruntime брал все 16 ядер и вытеснял синтез, первый вызов был
холодным: распознавание 576 мс вместо ~210. Прогрев на старте и по
4 потока на модель.
- Весь адрес одним предложением — ~455 мс синтеза до первого звука.
Длинное предложение режется по запятым, номер от улицы не отрывается:
~250 мс.
- Первая реплика «Алло! Помогите!» запускалась мимо задачи ответа и не
перебивалась вовсе.
Отдельно: сломал make dev — num2words попал в pyproject, а Dockerfile
ставил зависимости ручным списком, и бэкенд в контейнере упал на
импорте. Образ теперь ставит зависимости из pyproject.toml.
Голоса в контейнере нет: под WSL модели работают нативно, make back.
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.
Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».
Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.
Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.
Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.
Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.