Commit graph lct-hack/backend/app/api/ws/call.py
Author SHA1 Message Date
kaifarikman
6e7c4f4596 fix: пауза исключена из реакции на доклад 2026-09-27 23:33:46 +03:00
GGlamer
20acef6ad8 chore: правки пауз вне строк из main, чтобы ветку обновить слиянием 2026-09-27 23:23:05 +03:00
GGlamer
73c7ca6db2 chore: слияние с main, разрешены конфликты в реестре занятий, сокете call и тесте checkpoint 2026-09-27 23:14:35 +03:00
kaifarikman
88bb54fc24 lct-39: пауза останавливает действия и голос 2026-09-27 18:06:49 +03:00
kaifarikman
2304d94877 lct-39: пауза не искажает оценку ДДС и не блокирует завершённое занятие 2026-09-27 17:18:27 +03:00
kaifarikman
b77d65c82e lct-39: пауза занятия с пульта, таймеры и очередь ДДС стоят на время простоя 2026-09-27 16:35:13 +03:00
gglamer
36a9862a94 refactor: общий вход в сокет занятия и выполнение команды с fencing вместо копий в четырёх каналах 2026-09-27 07:56:12 +00:00
gglamer
f9b6fafb7c refactor: завершение занятия — одна операция end_session вместо четырёх копий в сокетах 2026-09-27 07:40:31 +00:00
gglamer
47cb85ee02 refactor: запись хода занятия — один commit SessionStore на операцию вместо журнала и checkpoint
Правка балла с пульта и из отчёта идёт одной доменной операцией: раньше WS-путь не обновлял full_report живой сессии.
2026-09-26 22:48:24 +00:00
gglamer
5a3761a830 refactor: пульт ДДС владеет живыми карточками, сессия не копирует их поля
Связка 112→ДДС больше не подменяет таймеры оператора таймерами первой
карточки ДДС, поэтому время заполнения карточки 112 снова попадает в оценку.
2026-09-26 21:38:09 +00:00
gglamer
83b7f8ffdd fix: timer.tick доходит до пульта ДДС 2026-09-26 19:57:43 +00:00
andreysk0304
7237265833 Complete training workflow and acceptance hardening 2026-09-26 17:13:45 +00:00
andreysk0304
4c4b91064f Complete DDS training workflow and delivery package 2026-09-24 01:10:49 +03:00
andreysk0304
68dd83c7c2 Complete incident selection and 112 to DDS workflow 2026-09-21 20:10:57 +03:00
andreysk0304
cec84ffcd0 Implement local task 09 training workflows 2026-09-21 17:40:54 +03:00
Ivan Gerasimov
d55c8bc8cd feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.

- Таблицы 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 чистый.
2026-09-20 09:01:05 +03:00
Ivan Gerasimov
b8dc39fd7d 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
Ivan Gerasimov
d4f0d0a0f8 feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.

- domain/statuses.py: девять статусов реагирования с автоматом переходов,
  семь статусов карточки, обязательные комментарии к отказам. Формулировки
  из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
  отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
  от профильного происшествия) проверяется по списку оповещения из ЕКП —
  без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
  журнал отметок, статус карточки красным в трёх случаях из семи.

Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.

Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.

148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
Ivan Gerasimov
ee2a526831 lct-20: АРМ диспетчера ДДС и цепочка 112 → ДДС
Возврат карточки — самая ценная механика цепочки: неполнота КИО
перестаёт быть процентом в отчёте и становится сорванным выездом
с конкретной причиной. В разборе это отметка E6: «не заполнено floor,
victims_count — выезд сорван».

Снимок карточки не меняется после передачи: оператор не дописывает
задним числом поле, которое забыл. Тест правит карточку после передачи
и проверяет, что в снимке этого нет.

Диспетчер садится за АРМ, когда вызов уже идёт, — станция, подключённая
после передачи, всё равно получает карточку.

У станции нет аудио вообще: ни VAD, ни распознавания, ни синтеза.
2026-09-17 21:45:47 +03:00
Ivan Gerasimov
57f4c858b6 lct-22: пульт директив — преподаватель ведёт ситуацию
Мягкие директивы перекрывают дугу сценария и применяются со следующей
реплики: разговор не дёргается от того, что преподаватель что-то нажал.
Жёсткие правят ситуацию — обрыв связи рвёт звук тем же механизмом, что
перебивание, и запускает норматив обратного дозвона; второй пострадавший
правит эталон; неточный адрес снимает раскрытый факт, и оператор обязан
переспросить.

Своя копия сценария на занятие: директивы правят факты и эталон, а
сценарий был общим на библиотеку — правка в одной группе протекла бы во
все остальные. Тест проверяет, что библиотека не изменилась.

Свободный текст честно отказывает: офлайн-дерево предгенерировано,
произвольную фразу взять неоткуда, и преподаватель видит это на пульте.

Ни одна директива не трогает карточку курсанта — он управляет ситуацией,
а не работой обучаемого.

Занятие теперь собирается целиком и только потом регистрируется:
наблюдатель мог увидеть его без слот-автомата и звонящего.
2026-09-17 21:38:43 +03:00
Ivan Gerasimov
fadfa3e479 lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.

Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.

Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.

Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.

Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
Ivan Gerasimov
25143645d3 lct-15: самооценка курсанта до показа автооценки
Правило сервера, а не интерфейса: score.ready курсанту уходит только
после self_assessment.submit, обойти его через DevTools нельзя.
Преподаватель и монитор получают оценку сразу — им ждать нечего.

Чек-лист для самооценки отдаётся отдельной точкой и только после конца
звонка: во время разговора это содержимое подсказок, и открыть его
значило бы выдать в контрольном режиме то, чего там быть не должно.

Попутно подключён расчёт оценки при завершении звонка — детерминированный
слой был написан в lct-12, но его никто не вызывал.

Тест на отсутствие события смотрит очередь курсанта, а не ждёт из сокета:
ожидание того, чего не будет, вешает прогон навсегда.
2026-09-17 21:24:34 +03:00
Ivan Gerasimov
456f3cd34d lct-06: голосовой контур VAD → STT → звонящий → TTS, с перебиванием
Контур замкнут без 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.
2026-09-17 15:04:17 +03:00
Ivan Gerasimov
f24031b5b2 lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.

Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.

Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.

Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.

make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.

Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
Ivan Gerasimov
7c97310938 lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.

Главная находка — матчинг по порогу близости не работает. На
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 с кэшем.
2026-09-17 13:57:43 +03:00
Ivan Gerasimov
e7a0798ba7 lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.

Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.

Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.

Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.

Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00