lct-hack/Makefile

86 lines
4.7 KiB
Makefile
Raw Normal View History

# Подъём стенда и рутина. Цели, которых ещё нет, честно падают
# и называют карточку, которая их закрывает, — README не должен врать.
UV ?= uv
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
# `make test` и `make demo` не запускать одновременно: они просят у uv разные
# группы зависимостей, и uv пересобирает одно и то же окружение, а второй запуск
# молча ждёт блокировку. Один раз поставить оба набора:
# cd backend && uv sync --extra dev --extra voice
COMPOSE ?= docker compose
.DEFAULT_GOAL := help
help: ## Список целей
@grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t22
dev: ## Поднять стенд: postgres + backend --reload + frontend
$(COMPOSE) up --build
offline: ## Поднять карточки/ДДС из заранее собранных локальных образов, без скачивания
$(COMPOSE) up --pull never --no-build
down: ## Погасить стенд
$(COMPOSE) down
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
back: ## Бэкенд нативно, с голосовым контуром (порт 8000: сначала docker compose stop backend)
cd backend && $(UV) run --extra voice uvicorn app.main:app --reload --port 8000 --workers 1
front: ## Только фронтенд, локально
npm --prefix frontend install && npm --prefix frontend run dev
types: ## domain/events.py → frontend/src/shared/types/generated.ts
cd backend && $(UV) run --no-project --with 'pydantic>=2.7' python scripts/export_types.py
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
test: ## Тесты бэкенда (голосовой контур пропускается)
cd backend && $(UV) run --extra dev pytest -q
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
test-voice: ## Тест голосового контура на настоящих моделях: задержка и перебивание
cd backend && $(UV) run --extra dev --extra voice pytest tests/test_voice_pipeline.py -q -s
typecheck: ## Проверить фронтенд компилятором
npm --prefix frontend run typecheck
lct-02: замер задержки голосового контура, make latency Замер на машине разработки (Ryzen 7 8845HS, WSL2), не на демо-машине — там make latency надо прогнать трижды, разброс между прогонами до 20%. Распознавание — GigaAM v3 RNNT int8: фраза на 2 с за ~285 мс, WER 0% на 99 словах живой речи Golos. CTC не быстрее ни в одном из двух прогонов и ошибается чаще: время съедает общий энкодер, поэтому ступень деградации «RNNT не успевает → CTC» по скорости ничего не даёт. Синтез — Silero v5: в 10 раз быстрее реального времени, а не в 40, как заложено в STACK.md; первая фраза 100–250 мс в зависимости от длины. Частота синтеза на скорость не влияет, без профилирующего компилятора — на 13% быстрее. Сквозной бюджет: 600 мс endpointing + ~285 STT + ~150 TTS = ~1 с без LLM. Цель ≤ 1.5 с держится, только если LLM отдаёт первое предложение быстрее ~465 мс. Вопрос, считается ли филлер «алло?..» первым звуком, записан в LATENCY.md для людей. make models качает GigaAM и Silero VAD с Hugging Face. Silero TTS лежит на российском хосте, недоступном из-под VPN: скрипт не лезет на зеркала (.pt грузится через pickle — это чужой код) и честно говорит, откуда скачать вручную.
2026-09-17 14:44:02 +03:00
models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную
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
cd backend && $(UV) run python scripts/models.py
feat: АРМ администратора — записи, состояние стенда, аудит, копии (lct-24) Целый раздел ТЗ, от которого не было ни одной функции. - Учётные записи: завести, сменить роль и службу, заблокировать, сбросить пароль. Обучающемуся заводится и карточка курсанта — на ней висят профиль и проверка «это твой разбор». - Состояние компонентов: база, модели речи, эмбеддинги, провайдер LLM, версия классификатора, число живых занятий. Отдельной строкой — секрет сессии: значение по умолчанию не ошибка запуска, но на стенде это дыра, и увидеть её должен администратор, а не проверяющий на защите. - Журнал действий с фильтром, только на чтение. - Резервные копии: кнопка и make backup для cron. pg_dump берётся локальный, а если его нет — из контейнера базы; если нет ни того ни другого, администратор видит, чего не хватает, а кнопка не молчит. Границы роли выражены отсутствием маршрутов, а не проверками внутри них: в разделе нет ни одной точки, трогающей оценки или пишущей в аудит. Два теста проверяют это перебором маршрутов роутера — так запрет нельзя ослабить, случайно добавив обработчик. Найдено по ходу: тесты с базой падали в компании и проходили поодиночке. Движок SQLAlchemy кэшировался на процесс, а каждый TestClient поднимает свой событийный цикл — пул привязан к первому. Появился db.base.reset() и фикстура, дающая движок на тест. 193 теста зелёных (12 новых).
2026-09-20 09:11:12 +03:00
backup: ## Резервная копия базы в backend/backups/ (для cron: раз в сутки)
cd backend && $(UV) run python scripts/backup.py
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
users: ## Завести учётные записи ролей и напечатать пароли: make users force=1
cd backend && $(UV) run python scripts/users.py
users-docker: ## Завести учётные записи внутри локального контейнера (после make offline)
$(COMPOSE) exec backend python scripts/users.py
seed: ## Залить сценарии из /scenarios в БД
lct-03 и lct-04: журнал сессий и библиотека сценариев БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены сразу, даже пустыми — размечать накопленные сессии задним числом значит делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы попыток нет: дельта считается запросом по (trainee_id, scenario_id). Сценарии: строгая схема — опечатка в имени поля падает на старте, а не игнорируется молча. ground_truth собирается кодом, попытка задать incident_type, dds или required_facts в YAML отвергается: иначе генератор разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками задаются только нормализованные адрес и число пострадавших — из фразы «улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14» не достать. GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое подсказок: отдать его целиком значит выдать в контрольном режиме то, чего там быть не должно, в обход выдачи по одному пункту. Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
cd backend && $(UV) run python scripts/seed.py
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
lesson: ## Запустить занятие и напечатать ссылки: make lesson s=<сценарий> m=<режим>
cd backend && $(UV) run --no-project --with websockets python scripts/start_lesson.py "$(s)" "$(m)"
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация Что можно сказать, решает слот-автомат, а не модель: в промпт попадают только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия. Отказ провайдера не роняет занятие: звонящий откатывается на заготовки. Молчащий звонящий хуже шаблонной фразы. Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат, поэтому таблица индексируется парой «событие × настроение» и выходит небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации. Формулировки в ней от облачной модели, а задержка на занятии нулевая — локальная 3–4B дала бы формулировки хуже и за 2.5 секунды. Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят сотни токенов на размышление и при малом бюджете возвращают пустой content с finish_reason=length. Пустой ответ теперь честный отказ, бюджет токенов поднят, рассуждение из ответа исключается. Живые запросы к LLM вынесены из общего прогона: они требуют сети, а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
test-llm: ## Живые проверки LLM по backend/.env.test (медленно: рассуждающая модель)
cd backend && $(UV) run --extra dev pytest tests/test_llm.py -m llm -q -s
llm-check: ## Один запрос к LLM: проверить ключ и адрес из backend/.env
cd backend && $(UV) run python scripts/llm_check.py
lct-02: замер задержки голосового контура, make latency Замер на машине разработки (Ryzen 7 8845HS, WSL2), не на демо-машине — там make latency надо прогнать трижды, разброс между прогонами до 20%. Распознавание — GigaAM v3 RNNT int8: фраза на 2 с за ~285 мс, WER 0% на 99 словах живой речи Golos. CTC не быстрее ни в одном из двух прогонов и ошибается чаще: время съедает общий энкодер, поэтому ступень деградации «RNNT не успевает → CTC» по скорости ничего не даёт. Синтез — Silero v5: в 10 раз быстрее реального времени, а не в 40, как заложено в STACK.md; первая фраза 100–250 мс в зависимости от длины. Частота синтеза на скорость не влияет, без профилирующего компилятора — на 13% быстрее. Сквозной бюджет: 600 мс endpointing + ~285 STT + ~150 TTS = ~1 с без LLM. Цель ≤ 1.5 с держится, только если LLM отдаёт первое предложение быстрее ~465 мс. Вопрос, считается ли филлер «алло?..» первым звуком, записан в LATENCY.md для людей. make models качает GigaAM и Silero VAD с Hugging Face. Silero TTS лежит на российском хосте, недоступном из-под VPN: скрипт не лезет на зеркала (.pt грузится через pickle — это чужой код) и честно говорит, откуда скачать вручную.
2026-09-17 14:44:02 +03:00
latency: ## Замер задержки голосового контура по этапам — запускать на демо-машине
cd backend && $(UV) run --extra voice python scripts/latency.py
lct-03 и lct-04: журнал сессий и библиотека сценариев БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены сразу, даже пустыми — размечать накопленные сессии задним числом значит делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы попыток нет: дельта считается запросом по (trainee_id, scenario_id). Сценарии: строгая схема — опечатка в имени поля падает на старте, а не игнорируется молча. ground_truth собирается кодом, попытка задать incident_type, dds или required_facts в YAML отвергается: иначе генератор разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками задаются только нормализованные адрес и число пострадавших — из фразы «улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14» не достать. GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое подсказок: отдать его целиком значит выдать в контрольном режиме то, чего там быть не должно, в обход выдачи по одному пункту. Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
migrate: ## Накатить миграции
cd backend && $(UV) run alembic upgrade head
revision: ## Создать миграцию: make revision m="что изменилось"
cd backend && $(UV) run alembic revision --autogenerate -m "$(m)"
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
repl: ## Текстовый диалог со звонящим без голоса: make repl s=<сценарий>
cd backend && $(UV) run python scripts/repl.py "$(s)"
2026-09-20 10:05:43 +03:00
pregen: ## Офлайн-таблицы реплик: make pregen [s=<сценарий>] [t=<билет>] [force=1]
cd backend && s="$(s)" t="$(t)" force="$(force)" $(UV) run python scripts/pregenerate.py
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
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
./scripts/demo.sh
.PHONY: help dev offline down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo