Целый раздел ТЗ, от которого не было ни одной функции.
- Учётные записи: завести, сменить роль и службу, заблокировать, сбросить
пароль. Обучающемуся заводится и карточка курсанта — на ней висят профиль
и проверка «это твой разбор».
- Состояние компонентов: база, модели речи, эмбеддинги, провайдер LLM, версия
классификатора, число живых занятий. Отдельной строкой — секрет сессии:
значение по умолчанию не ошибка запуска, но на стенде это дыра, и увидеть
её должен администратор, а не проверяющий на защите.
- Журнал действий с фильтром, только на чтение.
- Резервные копии: кнопка и make backup для cron. pg_dump берётся локальный,
а если его нет — из контейнера базы; если нет ни того ни другого,
администратор видит, чего не хватает, а кнопка не молчит.
Границы роли выражены отсутствием маршрутов, а не проверками внутри них:
в разделе нет ни одной точки, трогающей оценки или пишущей в аудит. Два теста
проверяют это перебором маршрутов роутера — так запрет нельзя ослабить,
случайно добавив обработчик.
Найдено по ходу: тесты с базой падали в компании и проходили поодиночке.
Движок SQLAlchemy кэшировался на процесс, а каждый TestClient поднимает свой
событийный цикл — пул привязан к первому. Появился db.base.reset() и фикстура,
дающая движок на тест.
193 теста зелёных (12 новых).
Получен формальный документ заказчика (Департамент по делам ГО, ЧС и ПБ
города Москвы, ГБУ «Система 112»), двадцать страниц. То, по чему строилась
вся документация, было коротким описанием с лендинга — примерно десятая
часть требований.
Текст ТЗ положен в docs/spec/TZ-FULL.md под версию, соответствие
реализации разобрано в docs/spec/GAP.md по коду, а не по памяти.
Закрыто уже многое, включая главное требование — работу без доступа
к внешним сетям. Но появились требования, которых в коротком описании
не было: аутентификация и роли с аудитом, администратор системы, VoIP
через локальный SIP, двадцать одновременных сессий, второй режим
занятия с потоком карточек и вводом текста, проверка грамматики,
настраиваемые преподавателем тайминги, экспорт отчётов.
Два расхождения требуют решения людей, а не разработчика: делать ли SIP
(в стеке записано обратное) и сколько одновременных голосовых занятий
должен держать стенд — двадцать это другой класс железа.
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.