first commit
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

This commit is contained in:
2026-07-23 02:38:05 +03:00
commit 8757bec8ac
335 changed files with 61527 additions and 0 deletions

0
docs/deploy/.gitkeep Normal file
View File

268
docs/deploy/capacity.md Normal file
View File

@@ -0,0 +1,268 @@
# Нагрузочное тестирование SFU (LiveKit): методика и ёмкость
Оценивает,
сколько одновременных издателей аудио+видео и подписчиков выдерживает
LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`),
и даёт формулу прикидки ёмкости для прод-железа из таблицы пресетов
ADR-004 (`docs/architecture/adr/004-ai-tier-matrix.md`).
## ВАЖНО: окружение — это dev-Mac, не прод-референс
Все цифры ниже получены на разработческом macOS-хосте (Apple Silicon,
10 физических ядер CPU, 16 ГБ RAM), где Docker Desktop выделяет под
контейнеры лёгкую VM: **10 vCPU / 7.75 ГБ RAM** (`docker info`). Это НЕ
прод-железо и НЕ Linux-хост — абсолютные цифры (число участников,
момент деградации) **нельзя** переносить на прод напрямую. Причины:
1. Docker Desktop на macOS обрабатывает часть сетевого стека (в первую
очередь высокочастотный UDP-трафик WebRTC) вне cgroup контейнера —
в собственном сетевом прокси VM (`vpnkit`/`gvisor-tap-vsock`). Эта
нагрузка **не видна** в `docker stats` (CPU% измеряется только внутри
контейнера `livekit`), но реально потребляет ресурсы хоста. На Linux
(прод, bare-metal или облачная VM с host-networking) этого прокси-слоя
нет — CPU-профиль SFU там будет заметно легче при той же нагрузке.
2. На хосте параллельно во время части прогонов выполнялась одна и та же
Docker VM с другими контейнерами разработческого стека (LLM-сервер
`llm`, Celery `worker`) — при их одновременной активности они отъедали
до 49 vCPU из тех же 10 (см. раздел «Артефакт: конкуренция за CPU» —
первый прогон 20×20 был контаминирован, повторён после `docker stop
llm worker`).
3. Диапазон UDP-портов SFU в dev-compose узкий: `54000-54100` (101 порт,
`deploy/docker-compose.yml`, комментарий про конфликты с занятыми
портами хоста на macOS) — на проде обычно шире.
**Вывод:** используйте этот документ для МЕТОДИКИ и ОТНОСИТЕЛЬНОЙ формы
кривой деградации (как растут CPU/потери с числом участников), а не как
источник абсолютного «сколько человек выдержит прод-сервер». Перед
реальным релизом — обязательно повторить те же ступени на целевом
Linux-хосте (см. `docs/architecture/adr/004-ai-tier-matrix.md`, таблица
требований по пресетам).
## Методика
### Инструмент
`lk load-test` из `livekit-cli` (проверено через find-docs,
github.com/livekit/livekit-cli README, актуальная версия **2.18.0**,
`brew install livekit-cli`). Команда и параметры:
```bash
lk load-test \
--room <имя> --duration <N>s \
--video-publishers <N> --audio-publishers <N> --subscribers <M> \
--layout 5x5 --num-per-second 30
```
Ключевые флаги:
- `--video-publishers` / `--audio-publishers` — при **равном** числе оба
флага применяются к ОДНОМУ и тому же набору тестовых identity (не
удваивают число участников): N издателей публикуют по одному
аудио- и видеотреку каждый (видео — с simulcast, 3 слоя `q`/`h`/`f`
quarter/half/full, включён по умолчанию, флаг `--no-simulcast`
отключает).
- `--subscribers` — M подписчиков, каждый подписывается на все треки,
доступные в комнате НА МОМЕНТ его подключения.
- `--layout` (`speaker`/`3x3`/`4x4`/`5x5`, по умолчанию `speaker`) —
**важно**: это не жёсткий лимит числа подписок, а имитация того, какое
разрешение (какой simulcast-слой) подписчик запрашивает под сетку
такого размера. Дефолтный `speaker` в тестах ограничивал реальное число
подписок (см. «Находка» ниже) — для честного all-to-all fan-out
используйте `5x5` (или `4x4`/`3x3` под нужный размер комнаты).
- `--num-per-second` (по умолчанию 5) — темп присоединения тестовых
участников; при большом числе участников и низком значении подписчики
успевают подключиться раньше, чем опубликуются все треки, и видят
усечённый набор — не итог деградации SFU, а гонка старта теста. В
прогонах ниже установлен `30`.
### Находка 1: `--layout speaker` — не «весь мэш»
Первый прогон 10×10 с дефолтным layout дал устойчиво 12/20 треков на
подписчика (не деградация, а имитация UI «спикер + несколько миниатюр» —
подписчик просто не запрашивает все треки комнаты). Для честной оценки
верхней границы ёмкости SFU (все видят всех — pessimistic case) использован
`--layout 5x5` (до 25 видимых плиток) во всех ступенях ниже.
### Находка 2: `lk load-test` требует TURN/ICE-сервер, доступный БЫСТРО
Со штатным dev-конфигом `deploy/livekit/livekit.yaml` (`turn.enabled: false`,
`rtc.turn_servers` не задан) LiveKit отдаёт клиенту **пустой** список ICE-
серверов. При пустом списке клиентский SDK (`livekit-cli`/pion) молча
подставляет свой дефолтный публичный STUN-список
(`stun:global.stun.twilio.com`, `stun:stun.l.google.com`,
`stun:stun1.l.google.com`). В песочнице этого запуска исходящий путь к этим
публичным STUN-серверам идёт через виртуальную сеть Docker Desktop с
заметной задержкой (наблюдались ICE-соединения за 57 с), а у
`lk load-test` **фиксированный** `ConnectTimeout: 5s` — часть подключений
(в первую очередь PUBLISHER-транспорт) не укладывалась и обрывалась с
`could not connect after timeout`, даже когда сама SFU была не при делах.
Обход для теста: временно включить встроенный TURN-сервер LiveKit
(`turn.enabled: true`, `udp_port: 3478`, `tls_port: 0` — без TLS-варианта,
т.к. сертификат не нужен для локального UDP-теста) через ВРЕМЕННЫЙ
compose-override (не коммитился, не трогает `deploy/livekit/livekit.yaml`).
Тогда сервер начинает отдавать клиенту непустой список ICE-серверов
(собственный TURN вместо пустого списка), клиентский SDK не откатывается на
публичные STUN, и ICE устанавливается за <1.5 с.
**Рекомендация для прода** (не реализована в рамках этой задачи — вне
скоупа нагрузочного теста, требует правки `deploy/livekit/livekit.yaml`
отдельным изменением): зарегистрировать существующий standalone-coturn
(`deploy/coturn/`) в `rtc.turn_servers` LiveKit, чтобы клиенты ВСЕГДА
получали явный ICE-сервер и не откатывались на публичные Google/Twilio
STUN — это одновременно (а) быстрее устанавливает соединение за NAT и
(б) не отдаёт IP участников третьим сторонам без необходимости (тот же дух
приватности данных, что и требование локальных AI-моделей — идея та же).
### Ступени
Ступени росли до чёткой деградации: 5×5 → 10×10 → 20×20 → 30×30 (число
издателей аудио+видео × число подписчиков, оба со флагом `--layout 5x5`,
`--num-per-second 30`, длительность 4555 c на ступень). Параллельно
каждые 2 с снимался `docker stats --no-stream` по всем контейнерам
dev-стека.
## Результаты по ступеням
| Ступень | Участников всего | Треков на подписчика (успешно) | Пик CPU `livekit` (ядер) | Пик RAM `livekit` | Суммарный трафик подписчиков | Потери пакетов (агрегат) | Статус |
|---|---|---|---|---|---|---|---|
| 5×5 | 10 | 10/10 | 0.42 | 145 МиБ | 7.0 Мбит/с | 0.016% | OK |
| 10×10 | 20 | 20/20 | 1.55 | 226 МиБ | 45.3 Мбит/с | 0.010% | OK |
| 20×20 (после устранения конкуренции за CPU, см. ниже) | 40 | 40/40 | 2.42 | 574 МиБ | 176.1 Мбит/с | 0.075% | OK, лёгкий рост |
| 30×30 | 60 | 50/60 (9 из 30 подписчиков вообще не подключились) | 6.46 | 815 МиБ | 192.3 Мбит/с (частично) | **2.30%** | **ДЕГРАДАЦИЯ** |
Промежуточная точка (для полноты): первый прогон 20×20 БЕЗ остановки
конкурентных контейнеров `llm`/`worker` (у пользователя в этот момент
выполнялась суммаризация/фоновая LLM-нагрузка, до 863% CPU у контейнера
`llm` — почти весь бюджет VM) дал 2.111% потерь при том же трафике — то
есть выглядел как «деградация на 20×20», хотя на деле это была конкуренция
за CPU хоста с посторонним процессом, а не предел самого SFU. После
`docker stop vidconf-llm-1 vidconf-worker-1` и повторного прогона потери на
20×20 упали до 0.075% (таблица выше — уже чистый прогон). Это
задокументировано как явное предупреждение: **на разработческой машине
любой параллельный AI-воркер (транскрибация/суммаризация/LLM) искажает
результаты SFU-теста** — на проде эти роли обычно разнесены по разным
хостам/профилям (ADR-004), поэтому конкуренции не будет, но при тестировании
на одной машине (например, пресет 3 «+AI min» на одном сервере) это стоит
учитывать при планировании ёмкости.
## Выводы
1. **CPU SFU растёт сублинейно, затем резко (колено кривой) —** удельная
стоимость ядра на 1000 подписчик-треков падает с 5×5 к 20×20 (амортизация
фиксированных издержек процесса), но на 30×30 подскакивает: 6.46 ядра на
1050 успешных подписок против 2.42 ядра на 800 на предыдущей ступени —
явный признак приближения к пределу однопроцессного узла LiveKit на этой
VM. Часть подписчиков (9 из 30) не успели установить соединение за
`ConnectTimeout` — при близкой к пределу загрузке CPU ICE/DTLS-хендшейк
новых участников начинает конкурировать с уже идущей пересылкой медиа
существующих и не укладывается в таймаут.
2. **Память не является узким местом** ни на одной ступени (пик 815 МиБ на
30×30 при лимите VM 7.75 ГБ) — планировать ёмкость по CPU и сетевой
пропускной способности, не по RAM.
3. **Битрейты, полученные в тесте** (ориентир для планирования аплинка/
даунлинка, реальные величины при `--layout 5x5`, simulcast включён по
умолчанию у клиентского SDK LiveKit):
- Аудио (Opus): стабильно **~1921 кбит/с** на трек — использовать
20 кбит/с как плановую цифру на одного говорящего участника.
- Видео, «сеточный» (не приоритетный) слой simulcast, который SFU
форвардит подписчикам при 10+ видимых плитках: **~200350 кбит/с** на
трек — это нижний/средний слой (`q`/`h` в терминах rid). Годится как
плановая цифра для комнат с сеткой ≥3×3.
- Видео, верхний слой simulcast (форвардится, когда подписчик один/
мало плиток, либо трек — «в фокусе»/спикер): **~1.32.2 Мбит/с** на
трек — плановая цифра для 1:1 звонков и «пришпиленного» видео.
- **Рекомендация:** simulcast (3 слоя, дефолт LiveKit JS/Go SDK) уже
покрывает оба сценария автоматически — адаптивная подписка (LiveKit
`AdaptiveStream`) на фронтенде должна запрашивать нижний слой в
сеточных раскладках и верхний — в раскладке «спикер»/pinned, что
соответствует наблюдаемому поведению теста. Специальных ручных
профилей битрейта заводить не требуется; при необходимости ограничить
верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде
(клиентский SDK, вне скоупа devops-части).
4. **UDP-диапазон 54000-54100 (101 порт)** не был узким местом ни на одной
ступени (максимум 60 участников в тесте) — при планировании прод-узла с
ожидаемым бОльшим числом одновременных участников across все комнаты
узла держать `port_range_end - port_range_start` заметно больше пикового
числа участников на узле (LiveKit резервирует пару портов на участника
на медиа-транспорт).
5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей
зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на
проде, а не только для теста — иначе клиенты в вырожденном случае
(или при сбое конфигурации) будут по умолчанию уходить на публичные
Google/Twilio STUN.
## Формула прикидки ёмкости для прод-железа
Ёмкость SFU для конкретного узла оценивается ПО CPU (наблюдение п.1-2), не
по RAM/диску. Использовать данные ступеней **до колена кривой** (5×5,
10×10, 20×20 — линейный участок) как основу, оставляя запас до колена, а
не экстраполировать до него линейно.
```
vCPU_на_SFU ≈ 0.35 (базовые издержки процесса LiveKit)
+ 0.006 × T_total
где T_total = подписчики × видимыхреков_на_подписчика
(видимыхреков = 2 × издателей при полном мэше,
или меньше — при layout speaker/3x3/4x4/реальном UI)
```
Коэффициент 0.006 ядра/трек — среднее по трём чистым линейным ступеням
(5×5: 0.0084; 10×10: 0.0078; 20×20: 0.0030 — усреднено консервативно в
пользу меньшего числа участников, т.к. именно там эффективность на трек
ниже). Прикидка ДЛЯ ЭТОГО compose/host-стека; на bare-metal Linux
допустимо ожидать меньший коэффициент (нет прокси-сети Docker Desktop) —
подтверждать реальным прогоном.
**Правило безопасного запаса:** держать целевую загрузку `vCPU_на_SFU`
**не выше 60% от доступных ядер узла** — в тесте деградация проявилась
уже на ~65% формальной квоты VM (6.46 из 10 vCPU), с учётом невидимых
docker-stats издержек виртуализации сети реальный физический потолок был
ещё ближе. На bare-metal Linux запас может быть меньше, но без отдельной
валидации закладывать 60% как консервативный ориентир.
**Пример применения** к таблице пресетов ADR-004 (`docs/architecture/adr/
004-ai-tier-matrix.md`) — эти vCPU общие на весь стек (backend, БД,
AI-воркеры и SFU), поэтому реальный бюджет SFU меньше указанного в таблице
на объём, потребляемый остальными сервисами:
| Пресет | vCPU узла | Ориентир бюджета SFU (после вычета backend/БД/AI, груб.) | T_total (при коэф. 0.006 и запасе 60%) | Примерно участников (2 трека/чел., полный мэш) |
|---|---|---|---|---|
| 1/2 (MVP/+чат, без AI) | 4 | ~3.0 vCPU | ≈ (3.0×0.60.35)/0.006 ≈ 240 | ≈ 120 |
| 3 (+AI min) | 8 | ~4.0 vCPU (доля с транскрибацией/суммаризацией) | ≈ (4.0×0.60.35)/0.006 ≈ 342 | ≈ 170 |
| 4 (+AI medium) | 1216 | ~5.0 vCPU | ≈ (5.0×0.60.35)/0.006 ≈ 458 | ≈ 230 |
| 5 (+AI max) | 16+ | ~6.0 vCPU (AI забирает GPU, CPU на SFU свободнее) | ≈ (6.0×0.60.35)/0.006 ≈ 575 | ≈ 285 |
Это цифры участников **на весь узел суммарно по всем одновременным
комнатам**, не на одну комнату — при типичных размерах комнат VidConf
(рабочие созвоны, не масштабные вебинары) практический потолок числа
одновременных КОМНАТ на узел определяется делением на среднее число
участников в комнате. Таблица — грубая прикидка для первичного sizing;
обязательна проверка реальным `lk load-test` на целевом железе перед
production-релизом крупной инсталляции (пресеты 4/5).
## Как повторить
```bash
# 1. Инструмент
brew install livekit-cli # или бинарь с github.com/livekit/livekit-cli/releases
# 2. Локальный стек (без AI-профилей)
docker compose -f deploy/docker-compose.yml --profile media up -d livekit coturn
# 3. Прогон одной ступени (пример 10×10)
export LIVEKIT_URL=ws://localhost:7880
export LIVEKIT_API_KEY=devkey
export LIVEKIT_API_SECRET=<значение LIVEKIT_API_SECRET из .env>
lk load-test --room capacity-10x10 --duration 45s \
--video-publishers 10 --audio-publishers 10 --subscribers 10 \
--layout 5x5 --num-per-second 30
# Параллельно в соседнем терминале — снимать нагрузку:
watch -n2 'docker stats --no-stream'
```
Для честного измерения ICE-задержки на macOS-хосте с Docker Desktop —
см. «Находка 2» выше: либо временно включить `turn.enabled: true` (без TLS,
`udp_port: 3478`) через compose-override, либо тестировать на Linux-хосте,
где артефакт не проявляется.

194
docs/deploy/dev-setup.md Normal file
View File

@@ -0,0 +1,194 @@
# Настройка Dev окружения
## 1. Требования
- Docker + Docker Compose v2
- `uv` (инструменты backend): `brew install uv`
- Node 20+ (frontend)
## 2. Переменные окружения
```bash
cp .env.example .env
# отредактируйте .env если нужно (defaults работают для локальной разработки)
```
Пример переменных окружения:
```env
POSTGRES_USER=vidconf
POSTGRES_PASSWORD=dev_password
POSTGRES_DB=vidconf
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-key-here
SEED_ADMIN_EMAIL=admin@example.com
SEED_ADMIN_PASSWORD=admin123
```
## 3. Запуск базового стека (без видеоконференций)
```bash
docker compose -f deploy/docker-compose.yml up -d
docker compose -f deploy/docker-compose.yml ps
```
Это запустит:
- `postgres` — База данных PostgreSQL
- `redis` — Redis (очередь Celery)
- `backend` — FastAPI сервис
- `worker` — Celery worker + beat (асинхронные задачи)
- `nginx` — Reverse proxy
Backend API доступен:
- Прямой: `http://localhost:8000/api/health`
- Через Nginx: `http://localhost/api/health`
## 3a. Добавить видеоконференции (профиль media: LiveKit + Coturn)
Для включения видеоконференций с LiveKit SFU + Coturn TURN сервером:
```bash
docker compose -f deploy/docker-compose.yml --profile media up -d
```
Это добавит:
- `livekit` — SFU сервер (слушает на `ws://localhost:7880` для signaling, UDP 52000-52100 для media)
- `coturn` — TURN/STUN relay сервер
**Проверка LiveKit:**
```bash
# Проверить что LiveKit доступен
curl http://localhost:7880/health
# Проверить что Coturn слушает
nc -uz localhost 3478 # STUN
```
**Ручная проверка видео:**
Откройте два браузерных окна (или вкладки) на `http://localhost:5173` (frontend):
1. Первое окно: зарегистрируйтесь и войдите
2. Оба окна: отройте страницу лобби (комнаты)
3. Оба окна: нажмите "Войти" в одну и ту же комнату
4. Проверьте видео-потоки в обоих окнах (должны видеть друг друга)
**Структура портов:**
- `7880/tcp` — LiveKit WebSocket signaling (через nginx `/livekit/`)
- `7881/tcp` — LiveKit HTTPS (опционально)
- `52000-52100/udp` — Media stream (RTP/RTCP)
- `3478/tcp,udp` — Coturn STUN
- `3479/tcp,udp` — Coturn альтернативный
- `5349/tcp,udp` — Coturn TURNS (TLS)
## 4. Миграции БД и тестовые данные
```bash
cd backend
uv run alembic upgrade head
uv run python -m scripts.seed
```
Миграции:
- Создают все 14 таблиц (users, teams, email_verification_tokens, conferences, conference_invitees, guest_access, conference_sessions, conference_participants, session_audio_tracks, phrases, chat_messages, email_deliveries, instance_settings, livekit_webhook_events)
- Включают расширение PostgreSQL `btree_gist` (установлено, но текущей схемой не используется)
Идемпотентный сид (`scripts.seed`):
- Создаёт только 1 админ-пользователя (учётные данные из `.env`, `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`)
- Конференции создаются пользователями динамически — предустановленных данных не требуется
## 5. Запуск Frontend (Разработка)
```bash
cd frontend
npm install
npm run dev
```
Frontend запущен на `http://localhost:5173` с включённым hot-reload.
**CORS:** Frontend подключается к backend через прокси Vite:
- Запрос `/api/*` → перенаправляется на `http://localhost:8000/api/*`
- WebSocket `/ws/*` → перенаправляется на `http://localhost:8000/ws/*`
- Это настроено в `frontend/vite.config.ts` (режим разработки)
## 6. Проверка
Проверить, что всё запущено:
```bash
# Backend здоров
curl http://localhost:8000/api/health
# Миграции БД применены
cd backend && uv run alembic current
# Frontend доступен
curl http://localhost:5173
# Worker жив (если запущен)
docker compose -f deploy/docker-compose.yml exec worker celery -A workers.celery_app inspect ping
```
## 7. Тестирование и проверка качества
```bash
# Backend — lint + форматирование
cd backend
uv run ruff check . && uv run ruff format --check .
# Backend — проверка типов
uv run mypy .
# Backend — unit тесты
uv run pytest -q
# Frontend — lint
cd frontend
npm run lint
# Frontend — проверка сборки
npm run build
# Валидация docker-compose
docker compose -f deploy/docker-compose.yml config -q
```
## 8. Пресеты инсталлятора
VidConf поддерживает **5 пресетов инсталлятора**:
1. **MVP-ядро** (лобби, конференции, календарь, закреплённые, гости) — минимум функций
2. **+чат** — текстовое общение в конференции (WebSocket + Redis pub/sub)
3. **+AI min** (CPU) — транскрибация + суммаризация на уровне `min`
4. **+AI средний** (CPU опционально GPU) — уровень `medium`
5. **+AI макс** (GPU обязателен) — уровень `max` для высокой нагрузки
Для локальной разработки используйте пресет 1 или 3 (с инсталлятором `./install.sh --preset 3`).
**Детали:** [docs/deploy/install.md](install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](../architecture/adr/004-ai-tier-matrix.md)
## 9. Решение проблем
**Backend не может подключиться к БД:**
```bash
docker compose -f deploy/docker-compose.yml logs postgres
```
**Ошибка подключения Redis:**
```bash
docker compose -f deploy/docker-compose.yml logs redis
```
**Сборка Frontend не удаётся:**
```bash
cd frontend
npm install --force # Повтор установки зависимостей
npm run build
```
**Миграции не выполняются:**
```bash
cd backend
uv run alembic downgrade base
uv run alembic upgrade head
```
For more details, see [README.md](../../README.md).

526
docs/deploy/env.md Normal file
View File

@@ -0,0 +1,526 @@
# Переменные окружения (.env)
Полный справочник переменных окружения VidConf. Все секреты хранятся **только** в `.env` и никогда не коммитятся в git.
## Соглашение
Значения читаются из файла `.env` (или переменных окружения) при старте приложения. Dev-шаблон см. в `.env.example`.
---
## База данных
### DATABASE_URL
**Тип:** `str` | **Default:** `postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf`
**Описание:** Connection string для асинхронного драйвера SQLAlchemy (asyncpg).
**Пример:**
```bash
DATABASE_URL=postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf
```
**Проде:** Используйте отдельного пользователя с минимальными привилегиями (только SELECT/INSERT/UPDATE на нужные таблицы).
### POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB
**Для Docker Compose:** переменные инициализации контейнера PostgreSQL.
```bash
POSTGRES_USER=vidconf
POSTGRES_PASSWORD=change-me-secure
POSTGRES_DB=vidconf
```
---
## Redis (очередь Celery)
### REDIS_URL
**Тип:** `str` | **Default:** `redis://localhost:6379/0`
**Описание:** URL Redis для брокера Celery (очередь задач) и кэша сеансов.
**Пример:**
```bash
REDIS_URL=redis://localhost:6379/0
REDIS_URL=redis://:password@redis.example.com:6379/1 # с паролем, БД 1
```
**Проде:** Используйте Redis с паролем и настройте мониторинг/резервные копии.
---
## Конфигурация приложения
### Бутстрап настроек инстанса (первый старт backend, `BOOTSTRAP_*`)
Три переменные определяют начальное состояние настроек модулей при первом
запуске backend. Используются инсталлятором `install.sh` для синхронизации
пресетов поставки (пресеты 15 → матрица `BOOTSTRAP_*`).
#### BOOTSTRAP_CHAT_ENABLED
**Тип:** `bool` (строка: `true` | `false`) | **Default:** `false`
**Описание:** Включить чат в конференциях при первом старте backend.
```bash
BOOTSTRAP_CHAT_ENABLED=false # пресеты 1, 3
BOOTSTRAP_CHAT_ENABLED=true # пресеты 2, 4, 5
```
**Применение:** однократно при первом старте (lifespan backend), затем игнорируется
(настройка хранится в БД, `instance_settings.chat.enabled`). Изменение переменной
на живой инсталляции не действует — меняйте через админ-API `PUT /api/v1/admin/settings`.
---
#### BOOTSTRAP_TRANSCRIPTION_ENABLED
**Тип:** `bool` | **Default:** `false`
**Описание:** Включить транскрибацию и суммаризацию при первом старте backend.
```bash
BOOTSTRAP_TRANSCRIPTION_ENABLED=false # пресеты 1, 2
BOOTSTRAP_TRANSCRIPTION_ENABLED=true # пресеты 3, 4, 5
```
**Применение:** однократно при первом старте (lifespan backend), затем игнорируется.
Выключение транскрибации = AI-уровень не применяется (см. ниже). На живой
инсталляции меняйте через `PUT /api/v1/admin/settings?transcription_enabled=...`.
**Guard:** если транскрибация включена в настройках (`transcription_enabled=true`),
но ни один Celery-воркер `transcriber` не обслуживает очередь, в админке
отображается предупреждение (поле `transcription_queue_served` в ответе
`GET /api/v1/admin/settings`).
---
#### BOOTSTRAP_AI_LEVEL
**Тип:** `str` | **Default:** `min` | **Допустимые:** `min`, `medium`, `max`
**Описание:** Уровень качества AI-обработки при первом старте backend
(требует `BOOTSTRAP_TRANSCRIPTION_ENABLED=true`).
```bash
BOOTSTRAP_AI_LEVEL=min # пресеты 13
BOOTSTRAP_AI_LEVEL=medium # пресет 4
BOOTSTRAP_AI_LEVEL=max # пресет 5
```
**Матрица инсталлятора (пресет → `BOOTSTRAP_*`):**
| Пресет | `BOOTSTRAP_CHAT_ENABLED` | `BOOTSTRAP_TRANSCRIPTION_ENABLED` | `BOOTSTRAP_AI_LEVEL` |
|--------|:---:|:---:|---|
| 1 (MVP) | `false` | `false` | `min` |
| 2 (+чат) | `true` | `false` | `min` |
| 3 (+AI мин) | `true` | `true` | `min` |
| 4 (+AI средний) | `true` | `true` | `medium` |
| 5 (+AI макс) | `true` | `true` | `max` |
**Применение:** однократно при первом старте, затем игнорируется
(настройка в `instance_settings.ai_level`). На живой инсталляции меняйте
через `PUT /api/v1/admin/settings?ai_level=...` (проверяется доступность уровня,
недоступный уровень → 400).
---
### PLUGINS_CONFIG_PATH
**Тип:** `str` | **Default:** `config/plugins.yaml`
**Описание:** Путь к конфигурационному файлу плагинов (от корня проекта или абсолютный).
```bash
PLUGINS_CONFIG_PATH=config/plugins.yaml
```
Содержит профили AI: transcriber (faster-whisper small/medium/large-v3), summarizer (Qwen3.5 4B/9B/35B-A3B), chat (отключён/включён).
---
## Пользователь seed (начальный администратор)
### SEED_ADMIN_EMAIL
**Тип:** `str` | **Default:** `admin@vidconf.example`
**Описание:** Email администратора, создаваемого при инициализации БД (`uv run python -m scripts.seed`, см. [backend/README.md](../../backend/README.md)).
```bash
SEED_ADMIN_EMAIL=admin@example.com
```
### SEED_ADMIN_PASSWORD
**Тип:** `str` | **Default:** `change-me`
**Описание:** Пароль администратора. **Измените на боевом инстансе сразу после развёртывания!**
```bash
SEED_ADMIN_PASSWORD=SuperSecurePassword123!
```
---
## Аутентификация и сессии (JWT)
### JWT_SECRET
**Тип:** `str` | **Default:** `dev-only-insecure-secret-change-me`
**Описание:** Секретный ключ для подписи JWT токенов (HS256).
**Требование:** Минимум 32 символа случайных данных для проде.
```bash
# Генерировать:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
JWT_SECRET=your-long-random-secret-generated-above
```
### ACCESS_TOKEN_TTL_MINUTES
**Тип:** `int` | **Default:** `15`
**Описание:** Срок действия access-токена в минутах.
```bash
ACCESS_TOKEN_TTL_MINUTES=15
```
### REFRESH_TOKEN_TTL_DAYS
**Тип:** `int` | **Default:** `14`
**Описание:** Срок действия refresh-токена в днях. При истечении пользователь должен перелогиниться.
```bash
REFRESH_TOKEN_TTL_DAYS=14
```
### EMAIL_VERIFICATION_TTL_HOURS
**Тип:** `int` | **Default:** `24`
**Описание:** Срок действия email-верификационного токена в часах (при регистрации).
```bash
EMAIL_VERIFICATION_TTL_HOURS=24
```
### AUTH_COOKIE_SECURE
**Тип:** `bool` | **Default:** `true`
**Описание:** Флаг `Secure` для cookies, содержащих refresh-токены.
- **true** (проде) — cookies отправляются только по HTTPS
- **false** (dev на localhost) — cookies отправляются и по HTTP (необходимо для Safari на localhost без HTTPS)
```bash
AUTH_COOKIE_SECURE=false # только для dev
AUTH_COOKIE_SECURE=true # проде обязательно
```
### FRONTEND_URL
**Тип:** `str` | **Default:** `http://localhost:5173`
**Описание:** URL фронтенда. Используется для формирования ссылок в email'ах подтверждения (обычно не требуется, фронтенд за nginx'ом).
```bash
FRONTEND_URL=http://localhost:5173
FRONTEND_URL=https://vidconf.example.com
```
---
## LiveKit SFU
### LIVEKIT_URL
**Тип:** `str` | **Default:** `ws://localhost:7880`
**Описание:** Внутренний server-to-server URL LiveKit (для Celery задач, вызовы RoomService). Не проксируется через Nginx.
```bash
LIVEKIT_URL=ws://localhost:7880
LIVEKIT_URL=http://livekit:7880 # docker-сеть
```
### LIVEKIT_PUBLIC_URL
**Тип:** `str` | **Default:** `ws://localhost:7880`
**Описание:** Публичный URL LiveKit (браузер клиента). Обычно за nginx'ом с TLS.
```bash
LIVEKIT_PUBLIC_URL=ws://localhost:7880 # dev
LIVEKIT_PUBLIC_URL=wss://livekit.example.com # проде (WSS)
```
### LIVEKIT_API_KEY
**Тип:** `str` | **Default:** `devkey`
**Описание:** API ключ LiveKit (для генерации токенов комнат).
```bash
LIVEKIT_API_KEY=your-api-key
```
### LIVEKIT_API_SECRET
**Тип:** `str` | **Default:** `change-me-livekit-secret`
**Описание:** API секрет LiveKit (для подписи токенов).
```bash
LIVEKIT_API_SECRET=your-secret-key
```
Оба найдите в конфигурации LiveKit сервера (`livekit.conf`).
---
## TURN (Coturn)
### TURN_REALM
**Тип:** `str` | **Default:** `vidconf.local`
**Описание:** Realm для TURN сервера (Coturn). Испльзуется в credentials для WebRTC NAT traversal.
```bash
TURN_REALM=vidconf.example.com
```
### TURN_STATIC_AUTH_SECRET
**Тип:** `str` | **Default:** `change-me-turn-secret`
**Описание:** Секрет для TURN сервера (для временных credentials).
```bash
TURN_STATIC_AUTH_SECRET=your-secret-auth-key
```
---
## Аватары
### MEDIA_ROOT
**Тип:** `str` | **Default:** `/app/media`
**Описание:** Абсолютный путь к каталогу хранения загруженных аватаров пользователей.
```bash
MEDIA_ROOT=/app/media # Docker Compose (по умолчанию)
MEDIA_ROOT=/var/lib/vidconf/media # альтернативный путь на сервере
```
**Структура:**
```
MEDIA_ROOT/
└── avatars/
├── 550e8400-e29b-41d4-a716-446655440000.jpg
├── 6ba7b811-9dad-11d1-80b4-00c04fd430c8.png
└── ...
```
**Docker Compose:** том `media` монтируется в backend и nginx. Nginx раздаёт файлы напрямую по `location /media/` в обход backend'а (для performance).
---
## Транскрибация
### RECORDINGS_DIR
**Тип:** `str` | **Default:** `/recordings`
**Описание:** Абсолютный путь к тому с записями (LiveKit Egress пишет .ogg треки сюда, Celery воркер их читает).
```bash
RECORDINGS_DIR=/recordings
RECORDINGS_DIR=/mnt/recordings # альтернативный путь
```
**Docker Compose:** том `recordings` монтируется в обе сервиса (egress, transcriber). Менять путь внутри контейнера обычно не нужно (он совпадает с точкой монтирования).
---
## Email
Все SMTP-реквизиты — **только в `.env`, никогда не попадают в БД или API**.
### EMAIL_BACKEND
**Тип:** `str` | **Default:** `console`
**Допустимые значения:**
- `console` — dev: письма только логируются в stdout, ссылки берутся из логов
- `smtp` — реальная отправка через aiosmtplib
```bash
EMAIL_BACKEND=console # dev
EMAIL_BACKEND=smtp # проде
```
### SMTP_HOST
**Тип:** `str` | **Default:** `localhost`
**Описание:** Хост SMTP сервера (для `EMAIL_BACKEND=smtp`).
```bash
SMTP_HOST=smtp.gmail.com
SMTP_HOST=mail.example.com
```
### SMTP_PORT
**Тип:** `int` | **Default:** `587`
**Описание:** Порт SMTP (обычно 587 для TLS, 465 для SSL, 25 без шифрования).
```bash
SMTP_PORT=587 # TLS (STARTTLS)
SMTP_PORT=465 # SSL
SMTP_PORT=25 # plain
```
### SMTP_USERNAME
**Тип:** `str | None` | **Default:** `None`
**Описание:** Логин SMTP (если требуется аутентификация).
```bash
SMTP_USERNAME=noreply@vidconf.example.com
SMTP_USERNAME=
```
### SMTP_PASSWORD
**Тип:** `str | None` | **Default:** `None`
**Описание:** Пароль SMTP.
```bash
SMTP_PASSWORD=your-app-password
SMTP_PASSWORD=
```
### SMTP_START_TLS
**Тип:** `bool` | **Default:** `true`
**Описание:** Использовать STARTTLS (команда TLS после SMTP HELLO). Обычно для порта 587.
```bash
SMTP_START_TLS=true # порт 587
SMTP_START_TLS=false # если уже SSL или plain
```
### SMTP_USE_TLS
**Тип:** `bool` | **Default:** `false`
**Описание:** Использовать implicit TLS (сразу шифрованное соединение). Обычно для порта 465.
```bash
SMTP_USE_TLS=false # для STARTTLS (587)
SMTP_USE_TLS=true # для implicit TLS (465)
```
### SMTP_FROM
**Тип:** `str` | **Default:** `VidConf <no-reply@vidconf.example>`
**Описание:** From адрес в письмах (имя + email).
```bash
SMTP_FROM=VidConf <no-reply@vidconf.example>
SMTP_FROM=noreply@example.com
```
### SMTP_TIMEOUT_S
**Тип:** `int` | **Default:** `30`
**Описание:** Таймаут SMTP операций в секундах.
```bash
SMTP_TIMEOUT_S=30
```
---
## Примеры конфигурации
### Dev (console backend)
```bash
# Database & Redis
DATABASE_URL=postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf
REDIS_URL=redis://localhost:6379/0
# App
PLUGINS_CONFIG_PATH=config/plugins.yaml
JWT_SECRET=dev-only-secret-change-me
FRONTEND_URL=http://localhost:5173
# LiveKit
LIVEKIT_URL=ws://localhost:7880
LIVEKIT_PUBLIC_URL=ws://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=...
# Seed
SEED_ADMIN_EMAIL=admin@vidconf.example
SEED_ADMIN_PASSWORD=change-me
# Email (только логирование)
EMAIL_BACKEND=console
# Media (аватары)
MEDIA_ROOT=/app/media
```
### Проде (SMTP, TLS)
```bash
# Database & Redis
DATABASE_URL=postgresql+asyncpg://vidconf:secure-pass@db.example.com:5432/vidconf
REDIS_URL=redis://:redis-password@redis.example.com:6379/0
# App
PLUGINS_CONFIG_PATH=config/plugins.yaml
JWT_SECRET=<generated-long-random-key>
ACCESS_TOKEN_TTL_MINUTES=15
REFRESH_TOKEN_TTL_DAYS=14
AUTH_COOKIE_SECURE=true
FRONTEND_URL=https://vidconf.example.com
# LiveKit
LIVEKIT_URL=http://livekit:7880
LIVEKIT_PUBLIC_URL=wss://livekit.example.com
LIVEKIT_API_KEY=your-key
LIVEKIT_API_SECRET=your-secret
# Seed
SEED_ADMIN_EMAIL=admin@vidconf.example
SEED_ADMIN_PASSWORD=<strong-password>
# Email
EMAIL_BACKEND=smtp
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USERNAME=apikey
SMTP_PASSWORD=SG.xxxxx...
SMTP_START_TLS=true
SMTP_USE_TLS=false
SMTP_FROM=VidConf <noreply@vidconf.example>
SMTP_TIMEOUT_S=30
# Media (аватары)
MEDIA_ROOT=/var/lib/vidconf/media
# Recordings
RECORDINGS_DIR=/mnt/recordings
# Transcription
```
---
## Безопасность
1. **Никогда не коммитьте `.env` в git** — используйте `.gitignore`
2. **Регулярно ротируйте JWT_SECRET** (потребует переlogin'ования пользователей)
3. **Используйте окружение для production credentials** (никогда не hardcod'ьте)
4. **Проверьте SMTP_PASSWORD в логах** — их быть не должно (логирование маскирует пароли)
5. **Для SMTP используйте app passwords** (не основной пароль аккаунта)
---
## Ссылки
- [.env.example](../../.env.example) — шаблон переменных для dev
- [backend/core/config.py](../../backend/core/config.py) — парсинг Settings в коде

View File

@@ -0,0 +1,64 @@
# Профили оборудования и пресеты инсталлятора
VidConf использует **5 пресетов инсталлятора** (на основе 2 измерений: чат вкл/выкл и уровень AI мин/средний/макс) и **3 уровня AI качества** для настройки под разные сценарии развёртывания.
Вся информация здесь основана на источниках истины:
- **[docs/deploy/install.md](install.md)** — описание 5 пресетов, использование `install.sh`, автодетект железа
- **[docs/architecture/adr/004-ai-tier-matrix.md](../architecture/adr/004-ai-tier-matrix.md)** — матрица уровней AI (модели, кванты, требования железа, параметры генерации)
## Пресеты инсталлятора (5 вариантов)
| # | Состав | Композе-профили | Модели | Сложность |
|---|--------|---|--------|-----------|
| 1 | MVP-ядро (лобби, конференции, календарь, закреплённые, гости) | `media` | — | Минимум |
| 2 | 1 + чат (WebSocket, Redis pub/sub) | `media` | — | Низкая |
| 3 | 2 + AI «мин» (CPU) | `media,transcribe,llm` | faster-whisper `small` + Qwen3.5-4B | Средняя |
| 4 | 2 + AI «средний» (CPU опционально GPU) | `media,transcribe,llm` | faster-whisper `medium` + Qwen3.5-9B | Средняя |
| 5 | 2 + AI «макс» (GPU обязателен) | `media,transcribe-gpu,llm-gpu` | faster-whisper `large-v3` + Qwen3.5-35B-A3B | Высокая |
**Использование:** `./install.sh --preset N` или интерактивный опросник для автоматического выбора пресета на основе детекта железа.
## Уровни AI (матрица уровней)
Три уровня качества обработки, применяемые к пресетам 35:
| Уровень | Транскрибация | Суммаризация | Требования (мин) | GPU |
|---------|---|---|---|---|
| **min** | faster-whisper `small` | Qwen3.5-4B (GGUF Q4_K_M, ~2,8 ГБ) | 8 vCPU, 16 ГБ RAM | нет |
| **medium** | faster-whisper `medium` | Qwen3.5-9B (GGUF Q4_K_M, ~6,2 ГБ) | 1216 vCPU, 32 ГБ RAM | опционально ≥8 ГБ |
| **max** | faster-whisper `large-v3` | Qwen3.5-35B-A3B (GGUF Q4_K_M, ~2022 ГБ) | 16+ vCPU, 64 ГБ RAM | **обязательно ≥16 ГБ** |
Детальная матрица с параметрами модели (режимы compute, max_tokens per-tier, CTX) — [ADR-004](../architecture/adr/004-ai-tier-matrix.md).
## Быстрый старт
### Установка с автодетектом
```bash
# Интерактивный опросник (рекомендуемый вариант)
./install.sh
# Или неинтерактивно: пресет 3 (AI min)
./install.sh --preset 3
# Или без подтверждений (CI/scripts, warning: пресет 5 на non-GPU)
./install.sh --preset 3 --yes
```
`install.sh` автоматически:
1. Детектирует CPU/RAM/GPU (nvidia-smi, sysctl, free)
2. Рекомендует пресет
3. Генерирует/обновляет `.env` (секреты сохраняются)
4. Запускает `docker compose` с нужными профилями
5. Выполняет миграции и seed
Полные детали: [docs/deploy/install.md](install.md).
## Ссылки
- **[Инсталлятор](install.md)** — подробное описание `install.sh` и пресетов
- **[ADR-004: Матрица уровней AI](../architecture/adr/004-ai-tier-matrix.md)** — модели, требования, параметры
- **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей
- **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты
- **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS

134
docs/deploy/install.md Normal file
View File

@@ -0,0 +1,134 @@
# Инсталлятор (`install.sh`, релиз v0.0.1)
Один скрипт в корне репозитория — автодетект железа, опросник из 5
пресетов поставки, идемпотентная запись `.env`, сборка образов, миграции и
seed, подъём стека. По завершении `http://localhost/` отдаёт рабочий
фронтенд-SPA (React + Vite; статика вкомпилирована в образ nginx,
`frontend/Dockerfile`) — во ВСЕХ пресетах. Источник истины по
моделям/квантам/требованиям железа — ADR-004
(`docs/architecture/adr/004-ai-tier-matrix.md`); по пресетам поставки —
сам `install.sh` (см. таблицу ниже).
## Пресеты
| # | Состав | Профили compose | Модели |
|---|---|---|---|
| 1 | MVP-ядро (лобби, конференции, календарь, закреплённые, гости) | `media` | — |
| 2 | 1 + чат | `media` | — |
| 3 | 2 + AI «мин» (CPU) | `media,transcribe,llm` | faster-whisper `small` + Qwen3.5-4B |
| 4 | 2 + AI «средний» (CPU) | `media,transcribe,llm` | faster-whisper `medium` + Qwen3.5-9B |
| 5 | 2 + AI «макс» (GPU обязателен) | `media,transcribe-gpu,llm-gpu` | faster-whisper `large-v3` + Qwen3.5-35B-A3B |
Чат (2) и уровень AI (3/4/5) — переключатели плагинов в админке
(`instance_settings`, БД); `install.sh` поднимает НЕОБХОДИМЫЕ для
выбранного пресета контейнеры/модели И синхронизирует настройки инстанса
в соответствии с матрицей пресета (см. раздел «Синхронизация настроек
модулей»). Уровень `medium` в текущей матрице (`backend/services/ai_tiers.py`)
CPU-only — отдельного GPU-варианта профилей для пресета 4 нет (GPU для
`medium` — ручная настройка администратора вне детекта).
Запись конференций НЕ входит в пресеты (появится в v0.1.0) — вопрос
про неё инсталлятор не задаёт.
## Синхронизация настроек модулей (бутстрап)
При **первом старте** backend (lifespan) применяет пресет настроек через
три env-переменные `BOOTSTRAP_*`:
- `BOOTSTRAP_CHAT_ENABLED` (`true` | `false`) — включить чат в конференциях
- `BOOTSTRAP_TRANSCRIPTION_ENABLED` (`true` | `false`) — включить транскрибацию и суммаризацию
- `BOOTSTRAP_AI_LEVEL` (`min` | `medium` | `max`) — уровень AI (игнорируется, если транскрибация выключена)
**Матрица пресет → настройки модулей:**
| Пресет | Чат | AI | Уровень | `BOOTSTRAP_CHAT_ENABLED` | `BOOTSTRAP_TRANSCRIPTION_ENABLED` | `BOOTSTRAP_AI_LEVEL` |
|--------|-----|-----|---------|:---:|:---:|---|
| 1 (MVP-ядро) | — | — | — | `false` | `false` | `min` |
| 2 (+чат) | ✓ | — | — | `true` | `false` | `min` |
| 3 (+AI мин) | ✓ | ✓ | мин | `true` | `true` | `min` |
| 4 (+AI средний) | ✓ | ✓ | средний | `true` | `true` | `medium` |
| 5 (+AI макс) | ✓ | ✓ | макс | `true` | `true` | `max` |
Бутстрап **идемпотентен** при первом старте: дефолты из `config/plugins.yaml`
(всё `enabled: true`) переопределяются переменными `BOOTSTRAP_*` только если
БД ещё не содержит ключи настроек инстанса (проверка `INSERT ... ON CONFLICT DO NOTHING`).
Повторное включение контейнера backend не перетирает существующие настройки.
**При повторном запуске** инсталлятора на живой инсталляции:
```bash
./install.sh --preset 3 # спросит: обновить настройки? [Y/n]
./install.sh --preset 3 --yes # без подтверждения, применит пресет 3
```
Поведение:
- **Дефолт (Enter):** применяет матрицу выбранного пресета к настройкам БД
(defs: `docker compose exec -T backend uv run python -m scripts.apply_preset_settings --force`)
- **Отказ (`n`):** сохраняет ручные правки админа; переменные `BOOTSTRAP_*` в `.env`
обновляются, но скрипт применения настроек НЕ запускается
- **Флаг `--yes`:** автоматически применяет пресет без вопроса (для CI/CD)
Скрипт обновления: upsert четырёх ключей в `instance_settings` БД:
`chat` (`enabled`), `transcriber` (`enabled`), `summarizer` (`enabled`), `ai_level`.
## Использование
```bash
./install.sh # интерактивный опросник + рекомендация по железу
./install.sh --preset 3 # неинтерактивно
./install.sh --preset 3 --yes # без подтверждений (скрипты/CI, пресет 5 без GPU)
```
Повторный запуск с другим `--preset` — апгрейд/даунгрейд на месте: модели
уровня докачиваются (старые с диска не удаляются — занимают место, но не
мешают), `COMPOSE_PROFILES`/`WHISPER_MODEL`/`LLM_MODEL_*` в `.env`
обновляются точечно, секреты (JWT/БД/TURN/LiveKit/Grafana) и любые
пользовательские правки `.env` — сохраняются (генерация только при полном
отсутствии значения, см. `ensure_secret` в `install.sh`).
## Что делает скрипт
1. Автодетект `nproc`/`free -m` (или `sysctl` на macOS)/`nvidia-smi`
пишет `HW_CPUS`/`HW_RAM_MB`/`HW_GPU_NAME`/`HW_VRAM_MB` в `.env` — их
читает `backend/services/ai_levels.py` (детект доступности уровней AI в
админке, причины недоступности).
2. Точечно обновляет `.env` (создаёт из `.env.example` при первом запуске,
на первом запуске сразу генерирует РЕАЛЬНЫЕ секреты вместо
плейсхолдеров `change-me...` из шаблона в git).
3. Собирает флаги `--profile` из `COMPOSE_PROFILES` в `.env` (эта версия
docker compose НЕ подхватывает `COMPOSE_PROFILES` автоматически при `up`,
поэтому профили media/transcribe/llm передаются командам явно) и собирает
образы: `docker compose pull --ignore-buildable` (best-effort) →
`docker compose build` (backend, worker и nginx — образ nginx multi-stage
собирает фронтенд-SPA и вкомпилирует статику, `frontend/Dockerfile`).
4. Поднимает `postgres`/`redis` (`up -d --wait`) и применяет **до старта
backend** миграции и seed одноразовыми контейнерами:
`docker compose run --rm backend uv run alembic upgrade head` +
`... python -m scripts.seed`. Порядок критичен: `backend.lifespan`
бутстрапит `instance_settings` при каждом старте приложения, поэтому на
чистой БД таблицы обязаны существовать до первого запуска backend — иначе
healthcheck (`--wait`) никогда не проходит. Seed идемпотентен (админ;
справочник `teams` и ключи `instance_settings`, включая
`registration_team_choice`/`registration_email_domain`, бутстрапятся
backend'ом при старте, `INSERT ... ON CONFLICT DO NOTHING` — существующие
настройки инстанса не перетираются).
5. Поднимает остальной стек: `docker compose <--profile ...> up -d --wait`
(backend, worker, nginx с фронтом + сервисы активных профилей). Команда
идемпотентна и обёрнута в ретрай (до 3 попыток): первый старт backend/worker
включает `uv run` (синхронизация окружения + компиляция байткода), и на
слабой/загруженной машине healthcheck может не успеть за отведённые
retries — повтор лишь дожидается уже стартующих контейнеров.
6. Печатает сводку: URL фронтенда/бэкенда, учётные данные администратора,
команда для `--profile monitoring`.
## Проверка
- Чистая установка (пресеты 1 и 3) на пустом `.env`/чистых volume.
- Апгрейд 1 → 3 (повторный запуск с другим `--preset`, секреты сохранены).
- Рекомендация детекта соответствует реальному железу текущей машины.
- `bash -n install.sh`, `docker compose -f deploy/docker-compose.yml config -q`
для всех сочетаний профилей — минимальная валидация без реального подъёма.
Полную установку на чистой машине/VM гоняют вручную (использование сети
для скачивания GGUF-моделей ~2,620,5 ГБ, GPU-хост для пресета 5) — вне
рамок автоматической проверки.

121
docs/deploy/llm-setup.md Normal file
View File

@@ -0,0 +1,121 @@
# Настройка локального LLM-сервера (Qwen3.5, профили compose `llm`/`llm-gpu`)
Плагин суммаризации `qwen_local` (`backend/core/plugins/qwen_local.py`)
обращается к OpenAI-совместимому серверу `llama.cpp`. Модель и параметры —
единый источник истины ADR-004 (`docs/architecture/adr/004-ai-tier-matrix.md`)
и константная матрица `backend/services/ai_tiers.py`. В обычной установке всё
описанное ниже делает `install.sh` (см. `docs/deploy/dev-setup.md`) — этот
раздел актуален для ручного/точечного запуска профиля без инсталлятора.
## 1. Компоненты
| Сервис | Образ | Профиль | Назначение |
|---|---|---|---|
| `llm-models-init` | `busybox` | `llm`/`llm-gpu` | фиксирует владельца тома `llm-models` (обходит дефект прав доступа) |
| `llm-model-init` | `curlimages/curl` | `llm`/`llm-gpu` | однократное скачивание GGUF-модели и tokenizer.json уровня AI |
| `llm` | `ghcr.io/ggml-org/llama.cpp:server-b<build>` | `llm` | CPU-инференс (`/v1/chat/completions`), уровни `min`/`medium` |
| `llm-gpu` | `ghcr.io/ggml-org/llama.cpp:server-cuda-b<build>` | `llm-gpu` | GPU-инференс, уровень `max` (ADR-004: GPU обязателен) |
Файлы:
- `deploy/llm/download-model.sh` — генерализованный скрипт скачивания
(параметризован `LLM_MODEL_FILE`/`LLM_MODEL_URL`/`LLM_MODEL_MIN_SIZE`/
`LLM_TOKENIZER_FILE`/`LLM_TOKENIZER_URL`; их пишет `install.sh` по
выбранному пресету — см. `install.sh --help`).
- `deploy/docker-compose.yml` — сервисы `llm-models-init`/`llm-model-init`/
`llm`/`llm-gpu`, volume `llm-models`, монтирование
`llm-models:/models/qwen:ro` в сервис `worker` (там же считаются токены
чанкером — `QwenTokenCounter`).
## 2. Модели по уровням AI (ADR-004)
| Уровень | Модель | Квант | Файл (`LLM_MODEL_FILE`) | Источник GGUF (`LLM_MODEL_URL`) |
|---|---|---|---|---|
| `min` (пресет 3) | Qwen3.5-4B-Instruct | Q4_K_M ≈ 2,6 ГиБ | `qwen3.5-4b-instruct-q4_k_m.gguf` | `unsloth/Qwen3.5-4B-GGUF` |
| `medium` (пресет 4) | Qwen3.5-9B-Instruct | Q4_K_M ≈ 5,3 ГиБ | `qwen3.5-9b-instruct-q4_k_m.gguf` | `unsloth/Qwen3.5-9B-GGUF` |
| `max` (пресет 5) | Qwen3.5-35B-A3B-Instruct (MoE) | Q4_K_M ≈ 20,5 ГиБ | `qwen3.5-35b-a3b-instruct-q4_k_m.gguf` | `unsloth/Qwen3.5-35B-A3B-GGUF` |
`tokenizer.json` (переименован под `LLM_TOKENIZER_FILE` — нужен
`QwenTokenCounter`, `backend/core/summarization/tokens.py`) берётся из
официальных репозиториев `Qwen/Qwen3.5-<размер>` (публичные, без gate;
GGUF-репозитории `Qwen/…-GGUF` — gated, поэтому источник GGUF — публичное
зеркало `unsloth/…-GGUF`, файлы идентичны по содержимому квантизации).
Имена файлов на диске (`LLM_MODEL_FILE`/`LLM_TOKENIZER_FILE`) — КОНТРАКТ с
детектом доступности уровня AI (`backend/services/ai_levels.py` через
`backend/services/ai_tiers.py::TIERS[level].model_files`): именно эти пути
проверяются на «модель скачана» в админке.
Скачивание идемпотентно (проверка по наличию и минимальному размеру файла,
`LLM_MODEL_MIN_SIZE`) — повторный запуск на уже заполненном томе ничего не
перекачивает. Ручной запуск (прогреть volume заранее):
```bash
LLM_MODEL_FILE=qwen3.5-4b-instruct-q4_k_m.gguf \
LLM_MODEL_URL=https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf \
LLM_TOKENIZER_FILE=qwen3.5-4b-instruct.tokenizer.json \
LLM_TOKENIZER_URL=https://huggingface.co/Qwen/Qwen3.5-4B/resolve/main/tokenizer.json \
docker compose -f deploy/docker-compose.yml --profile llm run --rm llm-model-init
```
## 3. Запуск профиля
CPU (уровни `min`/`medium`, пресеты 3/4):
```bash
docker compose -f deploy/docker-compose.yml \
--profile media --profile transcribe --profile llm up -d
```
GPU (уровень `max`, пресет 5 — GPU обязателен):
```bash
docker compose -f deploy/docker-compose.yml \
--profile media --profile transcribe-gpu --profile llm-gpu up -d
```
Проверка готовности (порт `8080``llm`, `8081``llm-gpu` на хосте;
внутри docker-сети оба доступны как `llm:8080`/`llm-gpu:8080`, `llm-gpu`
также отвечает под алиасом `llm` — см. комментарий в `deploy/monitoring/prometheus.yml`):
```bash
curl http://localhost:8080/health
# пока модель грузится: {"error":{"code":503,"message":"Loading model",...}}
# сервер готов: {"status":"ok"}
```
## 4. Включение провайдера `qwen_local`
Дефолт репозитория в `config/plugins.yaml``summarizer.provider: "null"`
(безопасно для dev-окружений без LLM-сервера). Уровень `min` включается по
образцу закомментированного примера в `config/plugins.yaml`; уровни
`medium`/`max` собираются автоматически из `TIERS` (`backend/services/ai_tiers.py`)
при выборе уровня AI в админке — руками их прописывать не нужно (см.
`backend/services/instance_settings.py::_apply_tier_overrides`).
После правки `config/plugins.yaml` (уровень `min`, ручной dev-сценарий)
перезапустить `worker`:
```bash
docker compose -f deploy/docker-compose.yml up -d --force-recreate worker
```
## 5. Проверка вручную
1. Поднять профиль `llm`/`llm-gpu`, дождаться `docker compose ps` → healthy.
2. `curl http://localhost:8080/health``{"status":"ok"}`.
3. Включить уровень AI в админке (или `qwen_local` в `config/plugins.yaml`
для ручного dev-сценария уровня `min`).
4. Прогнать пайплайн на тестовом сеансе (см. `docs/plugins/summarizer.md`)
и убедиться, что `conference_sessions.summary_data` заполняется.
## 6. Ресурсы, thinking-режим и мониторинг
- Требования CPU/RAM/GPU по пресетам — `docs/architecture/adr/004-ai-tier-matrix.md`.
- `LLAMA_ARG_CTX_SIZE=16384`с запасом на чанк до 8000 токенов + промпт +
вывод (per-tier `max_tokens_map`/`max_tokens_reduce`, ADR-004).
- Thinking-режим (семейство Qwen3.5) отключается флагом `LLAMA_ARG_REASONING=off`
(актуальная замена `--chat-template-kwargs '{"enable_thinking":false}'` из
ADR-004 — та же семантика, но без деприкейшен-варнинга в логе на каждый
запуск, проверено через find-docs по `common/arg.cpp` проекта llama.cpp).
- `/metrics` (`LLAMA_ARG_ENDPOINT_METRICS=1`) собирает Prometheus, профиль
compose `monitoring``deploy/monitoring/prometheus.yml`, job `llm`.

90
docs/deploy/monitoring.md Normal file
View File

@@ -0,0 +1,90 @@
# Мониторинг (Prometheus + Grafana, профиль compose `monitoring`)
Независимый compose-профиль — можно поднимать вместе
с любым пресетом инсталлятора (15) или отдельно.
## 1. Компоненты
| Сервис | Образ | Порт (хост) | Назначение |
|---|---|---|---|
| `prometheus` | `prom/prometheus:v3.13.1` | `9090` | сбор и хранение метрик, оценка правил алертинга |
| `postgres-exporter` | `quay.io/prometheuscommunity/postgres-exporter:v0.20.1` | — (внутренний) | метрики PostgreSQL |
| `redis-exporter` | `oliver006/redis_exporter:v1.87.0-alpine` | — (внутренний) | метрики Redis |
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборд «Пайплайны пост-обработки» |
Файлы: `deploy/monitoring/prometheus.yml`, `deploy/monitoring/alerts.yml`,
`deploy/monitoring/grafana/provisioning/` (datasource + провайдер
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`.
## 2. Запуск
```bash
docker compose -f deploy/docker-compose.yml --profile monitoring up -d
```
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3001 (логин/пароль — `.env`,
`GRAFANA_ADMIN_USER`/`GRAFANA_ADMIN_PASSWORD`; `install.sh` генерирует
пароль при первой установке)
Дашборд «Пайплайны пост-обработки» (папка VidConf в Grafana) появляется
сразу — источник данных и дашборд провижинятся из файлов, без ручной
настройки.
## 3. Метрики backend
`GET /metrics` (`backend/api/metrics.py`, без авторизации внутри
приложения — снаружи периметра закрыт явным `return 403` в
`deploy/nginx/nginx.conf`; Prometheus ходит в backend напрямую по docker-сети,
`backend:8000/metrics`, минуя nginx):
- `vidconf_http_request_duration_seconds` (histogram, `method`/`path`/`status`) —
латентность HTTP по шаблону маршрута.
- `vidconf_pipeline_sessions` (gauge, `status`) — число сеансов конференций в
каждом статусе `pipeline_status`
(recording→transcribing→summarizing→notified|failed).
- `vidconf_celery_queue_depth` (gauge, `queue`) — глубина очередей Celery
(`transcription`/`summarize`/`notify`/`celery`, redis `LLEN`), карта
очередей — `docs/deploy/scaling.md`.
Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics`
(`LLAMA_ARG_ENDPOINT_METRICS=1`) — этот адрес резолвится ЛИБО сервисом
`llm` (CPU, профиль `llm`), ЛИБО `llm-gpu` (у него есть сетевой алиас `llm`,
см. `deploy/docker-compose.yml`) — профили `llm`/`llm-gpu` взаимоисключающи
по пресету, поэтому один job без дублирования.
## 4. Алерты (`deploy/monitoring/alerts.yml`)
| Алерт | Условие | severity |
|---|---|---|
| `PipelineFailed` | рост числа сеансов в статусе `failed` за 15 минут | critical |
| `QueueGrowing` | глубина очереди растёт 15 минут подряд и превышает 10 задач | warning |
| `LlmDown` | `up{job="llm"} == 0` дольше 2 минут | critical |
`LlmDown` актуален только на инсталляциях с профилем `llm`/`llm-gpu`
(пресеты 35) — на пресетах 1/2 (без AI) таргет `llm:8080` в принципе не
резолвится и алерт будет постоянно активен, если профиль `monitoring`
включён без AI-профиля; в таком случае правило можно закомментировать в
локальной копии `alerts.yml`.
Проверка — искусственно завалить пайплайн и убедиться, что алерт срабатывает:
```bash
# Стек с профилями media, transcribe, llm, monitoring уже поднят,
# идёт активная суммаризация (сеанс в статусе summarizing).
docker compose -f deploy/docker-compose.yml stop llm
# Подождать > 2 минут → Prometheus (http://localhost:9090/alerts)
# должен показать LlmDown в состоянии firing, следом — QueueGrowing
# (очередь summarize перестаёт разбираться) и, если сеанс не восстановится
# за 15 минут (recover_stuck_summaries переставит задачу, workers/celery_app.py),
# PipelineFailed.
docker compose -f deploy/docker-compose.yml start llm
```
## 5. Хранение
`prometheus_data`/`grafana_data` — именованные тома, переживают
пересоздание контейнеров. Ретеншен Prometheus — дефолт образа (15 дней);
для прод-инсталляций с длинным горизонтом донастраивается флагом
`--storage.tsdb.retention.time` (не задан в `deploy/docker-compose.yml`
осознанный dev/small-prod дефолт, донастраивается отдельно при необходимости).

View File

@@ -0,0 +1,294 @@
# Оценка качества суммаризации по уровням AI
Методика и результаты прогона утверждённых промптов суммаризации
(`workers/summarizer/prompts/summary_map_ru.txt`,
`workers/summarizer/prompts/summary_reduce_ru.txt`) на всех трёх уровнях AI
(ADR-004, `docs/architecture/adr/004-ai-tier-matrix.md`). Тексты промптов
едины для всех уровней и правятся только осознанным решением команды по
результатам прогона на этом корпусе — этот документ и есть тот прогон,
базис для будущих итераций.
## 1. Методика
- Скрипт: `workers/summarizer/eval/run_tiers.py` — создаёт плагин `QwenLocal`
штатной фабрикой (`backend/core/plugins/factory.py::create_summarizer`) из
`TierSpec` (`backend/services/ai_tiers.py`) для каждого уровня, прогоняет
весь корпус через `Summarizer.summarize()`, пишет результаты в
`workers/summarizer/eval/results/<уровень>/<файл>.md` + метаданные прогона
`_run_meta.json` (модель, base_url, лимиты токенов, время на файл, статус).
- Доступность уровня определяется автоматически: `max` требует GPU
(`nvidia-smi` в PATH) — на dev-машине без NVIDIA пропускается сразу; для
`min`/`medium` скрипт проверяет `GET /health` LLM-сервера и сверяет
реально загруженную модель через `GET /v1/models` (официальный
OpenAI-совместимый эндпоинт llama.cpp — id ответа содержит путь к файлу
модели) с ожидаемой по `TierSpec`, чтобы не приписать результат не тому
уровню (в одной docker-сети `min` и `medium` по умолчанию делят один и тот
же compose-сервис `llm`, отличаются только тем, какой `LLM_MODEL_FILE`
сервис фактически загрузил).
- Промпты скрипт не читает и не редактирует — только передаёт `QwenLocal`
абсолютный путь к штатному каталогу `workers/summarizer/prompts/`.
Проверка инварианта: `git diff --stat workers/summarizer/prompts/` пуст.
- Оценка качества — ручная экспертная по каждому файлу результата, по трём
критериям:
1. **Полнота тем** — все ключевые темы/решения/цифры транскрипта попали в
соответствующие разделы формата, ничего существенного не потеряно на
границах чанков (особенно у длинного многотемного транскрипта — 3 чанка
и reduce);
2. **Галлюцинации** — модель не добавляет фактов, имён, цифр, сроков,
которых нет в транскрипте;
3. **Следование формату** — ровно 4 раздела (`## Ключевые тезисы`,
`## Принятые решения и задачи`, `## Открытые вопросы`, `## Цифры и
факты`), пустой раздел — одна строка `—`, без вступлений/заключений.
## 2. Корпус
`workers/summarizer/eval/corpus/` — 5 транскриптов на русском в штатном
формате входа `summarize` (`[Имя MM:SS] текст`), разного объёма и профиля:
| Файл | Профиль | Длительность | Спикеров | Особенность для оценки |
|---|---|---|---|---|
| `01-short-standup.txt` | короткий дейлик | ~10 мин | 3 | один чанк — map без reduce |
| `02-long-multitopic.txt` | планирование, 4 темы | ~60 мин | 4 | 3 чанка по 20 мин → map-reduce, объединение дублей и снятие закрытых вопросов между чанками |
| `03-dialogue-1on1.txt` | ревью 1-на-1 | ~12 мин | 2 | плотный диалог одних и тех же двух имён |
| `04-multispeaker-guest.txt` | демо клиенту | ~14 мин | 3 внутр. + 1 гость | имя гостя из двух слов (`Виктор Соколов`), различение внутренних/внешнего |
| `05-metrics-heavy.txt` | квартальный обзор метрик | ~13 мин | 3 | много чисел/процентов/сумм — точность раздела «Цифры и факты» |
## 3. Результаты: уровень `min` (Qwen3.5-4B-Instruct, Q4_K_M, CPU)
Прогон на dev-машине (macOS, без NVIDIA GPU) — единственный уровень,
доступный локально.
**Как запускался:**
```bash
docker compose -f deploy/docker-compose.yml --profile llm up -d \
llm-models-init llm-model-init llm
# дождаться docker compose ps → llm healthy (LLM_MODEL_FILE не задан в .env
# — дефолт download-model.sh совпадает с уровнем min, Qwen3.5-4B)
cd backend && uv run python ../workers/summarizer/eval/run_tiers.py \
--levels min --base-url http://localhost:8080/v1
```
**Параметры уровня (ADR-004):** `temperature=0.2`, `max_tokens_map=1024`,
`max_tokens_reduce=1536`, `chunk_minutes=20`, `CTX_SIZE=16384`.
Прогон реально выполнен (`workers/summarizer/eval/results/min/`, метаданные —
`_run_meta.json`, отдельные результаты — `<файл>.md`). Первая попытка
прогона всего корпуса словила инфраструктурный сбой — LLM-контейнер `llm`
был убит хостом (`exit 137`, SIGKILL) на пятом файле: Docker Desktop на этой
машине выделяет VM всего 7,75 ГиБ (`docker info`), из них ~3,4 ГиБ уже
занимал сам `llm` (веса 2,7 ГБ + KV-кэш на `CTX_SIZE=16384`), а ещё ~1 ГиБ —
параллельно работающий dev-стек (`postgres`/`redis`/`livekit`/`coturn`/
`backend-manual`); после `docker compose ... up -d llm` (перезапуск) второй
прогон всего корпуса прошёл 5/5 без сбоев. Это не проблема плагина/промптов,
а сайзинг хоста — ADR-004 закладывает под пресет 3 (`min`) минимум 16 ГБ RAM
именно ПОД ЭТОТ уровень, не под уровень поверх произвольного количества
параллельных dev-контейнеров; отмечено как риск в разделе 6.
**Длительность прогона (второй, чистый запуск, 5/5 успешно):**
| Файл | Длительность записи | Чанков (map/reduce) | Время прогона |
|---|---|---|---|
| `01-short-standup.txt` | ~10 мин | 1 (без reduce) | 52,6 с |
| `02-long-multitopic.txt` | ~60 мин | 3 + reduce | 479,6 с (~8 мин) |
| `03-dialogue-1on1.txt` | ~12 мин | 1 (без reduce) | 65,9 с |
| `04-multispeaker-guest.txt` | ~14 мин | 1 (без reduce) | 76,7 с |
| `05-metrics-heavy.txt` | ~13 мин | 1 (без reduce) | 114,5 с |
Ориентир ADR-004 «часовой транскрипт на CPU ≈ 6,5 мин» — реально получено
~8 мин на `02-long-multitopic.txt` (3 map-вызова + 1 reduce), в пределах
разумного расхождения для другого CPU и разделяемого с dev-стеком хоста.
**Экспертная оценка по корпусу (полнота / галлюцинации / формат):**
- **Формат** — соблюдён на 5/5: ровно 4 раздела с точными заголовками, без
вступлений и заключений, ни одного нарушения структуры вывода.
- **Цифры и факты** — очень высокая точность: на `05-metrics-heavy.txt`
(стресс-тест на числа) сверены вручную ВСЕ 19 пунктов раздела «Цифры и
факты» с исходным транскриптом — ни одного искажённого или выдуманного
числа. На `02-long-multitopic.txt` (map-reduce, 3 чанка) все денежные
суммы, проценты и сроки из раздела тоже подтвердились дословно.
- **Галлюцинации фактов** — единственный найденный случай: в
`04-multispeaker-guest.txt` раздел «Ключевые тезисы» смешал два факта —
в транскрипте отложена в релиз 0.1.0 только ЗАПИСЬ конференций
(«Запись — в базовом релизе только анонс...»), а саммари в тезисах
сформулировало это как отложенные «запись встреч И генерации саммари»,
хотя суммаризация в демо явно показана как уже работающая функция
(«Работают, покажу на примере...», тот же транскрипт). При этом раздел
«Цифры и факты» ТОГО ЖЕ файла формулирует факт верно («Релиз с функцией
записи запланирован на 0.1.0») — то есть внутри одного вывода модель сама
себе противоречит между разделами.
- **Незакрытые вопросы, на которые ответ уже прозвучал** — систематическая
проблема, найдена в 2 из 5 файлов:
- `01-short-standup.txt` (один чанк, без reduce): вопрос Ирины «кто-нибудь
смотрел баг про дубли уведомлений» остался в «Открытых вопросах», хотя
Павел в том же транскрипте на него ответил («Я смотрел, там
идемпотентность сломана...»). Причина — у map-промпта НЕТ инструкции
снимать вопрос, отвеченный в том же фрагменте (только у reduce-промпта
есть инструкция снимать вопросы, закрытые МЕЖДУ фрагментами) — это
структурный пробел самого промпта, не только слабость модели.
- `02-long-multitopic.txt` (map-reduce, 3 чанка): ДВА вопроса остались в
«Открытых», хотя реально были закрыты в более позднем чанке — «кто
будет проводить техническое интервью» (позже: «назначаю тебя» Игорю) и
«сколько часов записей на 250 ГБ» (позже: «примерно 200 часов»). Здесь
reduce-промпт ЯВНО требует «если вопрос... был закрыт в позднем —
оставь только итоговое состояние», но 4B-модель это правило не
применила — прямое подтверждение вывода ADR-004: `min` работает
на пределе инструктивной сложности, к reduce это относится сильнее, чем
к map.
- **Атрибуция ответственных при reduce** — 1 случай неверного приписывания
автора решения не тому спикеру: пункт «Добавить в заявку резервный SSD на
1 ТБ для бэкапов базы» приписан Марине, хотя в транскрипте это сказала
Дарья («Ещё добавлю в заявку резервный SSD...»); аналогично пункт «Завести
задачу на тестовое восстановление бэкапа» приписан Марине, хотя в
транскрипте это её ПОРУЧЕНИЕ Игорю, а исполнитель — Игорь (что видно по
соседнему, отдельно возникшему пункту «Выполнить настройку тестового
восстановления... — ответственный: Игорь» — то есть один и тот же пункт
задвоился на два с разной атрибуцией вместо объединения дублей, как
требует reduce-промпт).
- **Спутывание завершённого действия с будущей задачей** — 2 случая в
`05-metrics-heavy.txt`: фразы Романа и Артёма о том, что действие УЖЕ
сделано в прошлом («обновил документ вчера», «начиная с прошлой недели
лимит reduce минимум 1536 токенов» — прошедшее время) модель занесла в
«Принятые решения и задачи» как будто это предстоящие задачи, с явно
нелогичным полем «срок: вчера» в одном из пунктов.
- **Потеря точного срока в пользу обобщения** — в `04-multispeaker-guest.txt`
конкретный срок «к среде» (когда гость пришлёт список сотрудников)
переформулирован в размытое «до начала пилота», а в поле «срок» пункта
указано «не указан», хотя конкретная дата в транскрипте была.
**Вывод по `min`:** формат и числовая точность — сильная сторона уже на
самой маленькой модели уровня; систематическая слабость — согласованность
между разделами и через reduce (незакрытые вопросы, задвоенные решения,
неверная атрибуция, путаница «сделано» vs «сделать»). Это ожидаемо и
согласуется с выводом ADR-004 («Qwen ~3B/4B на пределе инструктивной
сложности», сильнее всего проявляется на reduce) — рекомендация: НЕ трогать
промпты по этим находкам (правка промптов — отдельное осознанное решение с
повторным прогоном корпуса после правки, не автоматическая реакция на
единичный прогон), ожидать улучшения именно от роста модели на
`medium`/`max` и сравнить те же файлы/те же найденные проблемы после
прогона на этих уровнях (раздел 6).
## 4. Результаты: уровень `medium` (Qwen3.5-9B-Instruct, Q4_K_M)
Не запускался на этой машине — на dev-хосте (macOS, без NVIDIA GPU) для
`medium` поднят тот же CPU-сервис `llm`, что и для `min` (оба используют
`base_url: http://llm:8080/v1`, ADR-004); чтобы прогнать `medium`, нужен
СВОЙ сервер с моделью Qwen3.5-9B — либо второй `llm`-сервис на другом
порту/томе с `LLM_MODEL_FILE=qwen3.5-9b-instruct-q4_k_m.gguf`, либо GPU-хост.
**Ручной шаг (на CPU-хосте с ≥32 ГБ RAM или GPU-хосте):**
```bash
LLM_MODEL_FILE=qwen3.5-9b-instruct-q4_k_m.gguf \
LLM_MODEL_URL=https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/resolve/main/Qwen3.5-9B-Q4_K_M.gguf \
LLM_TOKENIZER_FILE=qwen3.5-9b-instruct.tokenizer.json \
LLM_TOKENIZER_URL=https://huggingface.co/Qwen/Qwen3.5-9B/resolve/main/tokenizer.json \
docker compose -f deploy/docker-compose.yml --profile llm up -d
cd backend && uv run python ../workers/summarizer/eval/run_tiers.py \
--levels medium --base-url http://localhost:8080/v1
```
`run_tiers.py` сверит загруженную модель через `/v1/models` и откажется
писать результат в каталог `medium`, если сервер поднят с другой моделью —
скрипт сам подскажет, что не так.
<!-- TODO: результаты после ручного прогона на GPU/большом CPU-хосте. -->
## 5. Результаты: уровень `max` (Qwen3.5-35B-A3B-Instruct MoE, Q4_K_M, GPU)
Не запускался — `run_tiers.py` пропускает `max` автоматически на этой
машине (`nvidia-smi` не найден), GPU обязателен (ADR-004, требование ≥16 ГБ
VRAM, рекомендовано 24 ГБ).
**Ручной шаг (на GPU-хосте):**
```bash
LLM_MODEL_FILE=qwen3.5-35b-a3b-instruct-q4_k_m.gguf \
LLM_MODEL_URL=https://huggingface.co/unsloth/Qwen3.5-35B-A3B-GGUF/resolve/main/Qwen3.5-35B-A3B-Q4_K_M.gguf \
LLM_TOKENIZER_FILE=qwen3.5-35b-a3b-instruct.tokenizer.json \
LLM_TOKENIZER_URL=https://huggingface.co/Qwen/Qwen3.5-35B-A3B/resolve/main/tokenizer.json \
docker compose -f deploy/docker-compose.yml --profile llm-gpu up -d
cd backend && uv run python ../workers/summarizer/eval/run_tiers.py \
--levels max --base-url http://localhost:8081/v1
```
(порт `8081` — хостовый порт `llm-gpu` в `deploy/docker-compose.yml`, см.
`docs/deploy/llm-setup.md`, раздел 3).
<!-- TODO: результаты после ручного прогона на GPU-хосте. -->
## 6. Наблюдения и рекомендации
По результатам реального прогона `min` (раздел 3; `medium`/`max` — пока
только методика, реальных данных нет, см. разделы 45):
1. **Полнота по типам транскриптов.** Короткие однотемные записи
(`01`, `03`) и однотемные записи среднего размера с одним чанком
(`04`, `05`) обрабатываются полнее и надёжнее, чем длинный многотемный
транскрипт с map-reduce (`02`) — там, где нужен reduce, у 4B-модели
заметно чаще не срабатывают инструкции промпта про снятие закрытых
вопросов и объединение дублей (раздел 3). Диалог с цифрами (`05`) и
гостем (`04`) сами по себе не оказались сложнее для `min` — проблема
именно в reduce-стадии, а не в теме/числе спикеров.
2. **Систематических галлюцинаций фактов/цифр не найдено** — числа
воспроизводятся дословно даже в «числовом» стресс-тесте (`05`, все 19
пунктов сверены вручную). Единственная найденная смысловая ошибка —
конфляция двух разных фактов в одном разделе одного файла (`04`), не
выдумывание нового факта из ничего.
3. **Систематическая, а не случайная проблема** — это несогласованность
между разделами и через reduce: незакрытые вопросы, на которые уже
прозвучал ответ (2 из 5 файлов, включая случай БЕЗ reduce — структурный
пробел в самом map-промпте, не только слабость модели), задвоенные
решения с разной атрибуцией ответственного при reduce (1 файл), и
путаница «уже сделано» / «предстоит сделать» (2 случая в одном файле).
4. **Длительность на CPU** совпадает по порядку величины с ориентиром
ADR-004 («часовой транскрипт ≈ 6,5 мин»): реально получено ~8 мин на
`02-long-multitopic.txt` (3 map + 1 reduce). Расхождение объясняется
разделяемым с dev-стеком хостом, не архитектурной проблемой.
5. **Инфраструктурный риск, не связанный с моделью/промптами:** на dev-
машине (Docker Desktop, VM 7,75 ГиБ) `llm`-контейнер был убит хостом
(OOM на уровне VM, не cgroup — `OOMKilled: false`, но `exit 137`)
при параллельной работе полного dev-стека. ADR-004 требует 16 ГБ RAM
под пресет 3 — это бюджет ПОД уровень `min`, не поверх производного
dev-окружения с БД/Redis/LiveKit/Coturn/др. Рекомендация: в
`docs/deploy/install.md`/чек-листе явно указывать, что 16 ГБ — это
помимо памяти, занятой остальным dev/prod-стеком, если он совмещён на
одной машине.
**Рекомендация по промптам:** правки текстов промптов НЕ
делались и не рекомендуются по итогам этого прогона в одностороннем
порядке. Найденные проблемы (пп. 3) касаются reduce-инструкций, которые в
промпте УЖЕ явно прописаны («если вопрос... был закрыт в позднем — оставь
только итоговое состояние», «объединяй дубли») — модель `min` (4B) их не
всегда выполняет, что согласуется с выводом ADR-004 о пределе
инструктивной сложности на этом размере. Ожидаемая гипотеза (проверяется
прогоном `medium`/`max` на ЭТОМ ЖЕ корпусе после того, как появится
GPU/большой CPU-хост, разделы 45): более крупная модель должна снять
именно эти reduce-ошибки без изменения текста промпта. Если после прогона
`medium`/`max` те же ошибки останутся систематическими и на большей модели —
это станет основанием для отдельного осознанного решения команды о правке
промптов, с повторным прогоном этого же корпуса до и после правки.
## Как повторить и добавить уровень
```bash
cd backend && uv run python ../workers/summarizer/eval/run_tiers.py --help
```
- `--levels min,medium,max` — какие уровни пробовать (недоступные — пропуск
с причиной в stdout, скрипт не падает).
- `--base-url` — переопределить адрес LLM-сервера для ВСЕХ уровней (нужно
при запуске с хоста вне docker-сети — штатные `http://llm:8080/v1`/
`http://llm-gpu:8080/v1` из `backend/services/ai_tiers.py` резолвятся
только внутри неё).
- Результаты и `_run_meta.json` — в `workers/summarizer/eval/results/`,
перезаписываются при повторном прогоне того же уровня.
## Смотрите также
- ADR-004 — `docs/architecture/adr/004-ai-tier-matrix.md`.
- `docs/deploy/llm-setup.md` — установка LLM-сервера по уровням.

146
docs/deploy/scaling.md Normal file
View File

@@ -0,0 +1,146 @@
# Горизонтальное масштабирование очередей Celery
Описывает, как развести обработку задач по отдельным
репликам/сервисам под нагрузкой, когда одного контейнера `worker`
недостаточно. Здесь — инструкция для оператора, применимая к текущей и
будущей конфигурации `deploy/docker-compose.yml`.
## 1. Карта очередей
Маршрутизация задач по очередям задана в `workers/celery_app.py`
(`app.conf.task_routes`) — приоритет между семьями задач реализован
**изоляцией очередей**, а не Redis-priorities Celery (транспорт `redis`
эмулирует приоритеты ненадёжно, без строгих гарантий порядка — в отличие от
RabbitMQ).
| Очередь | Задачи | Кто слушает по умолчанию |
|----------------|-------------------------------------------------------------------------|---------------------------|
| `transcription`| `run_pipeline` (faster-whisper) | `worker-transcriber` (профиль `transcribe`), `--pool=solo` — ctranslate2/faster-whisper несовместимы с prefork |
| `summarize` | `summarize_session` (Qwen map-reduce) | базовый `worker` |
| `notify` | `notify_session`, `send_invitations` (.ics-приглашения) | базовый `worker` |
| `celery` (default) | `cleanup_conferences`, `recover_stuck_summaries`, `recover_stuck_notifications` (маршрута не имеют, обслуживание) | базовый `worker` |
Базовый сервис `worker` слушает `celery,summarize,notify` — для малых
пресетов поставки (13) это один контейнер, обрабатывающий и суммаризацию, и
уведомления, и обслуживание. `worker-transcriber` — всегда отдельный процесс
(независимо от пресета), т.к. пул `solo` несовместим с остальными задачами
в том же процессе.
Глубину каждой очереди в реальном времени видно в `GET /metrics`
(`vidconf_celery_queue_depth{queue=...}`, `backend/api/metrics.py`) и в
Grafana-дашборде «Пайплайны пост-обработки» (алерт `QueueGrowing`,
`deploy/monitoring/alerts.yml`) — по этим показателям и принимается решение
о вынесении очереди в отдельную реплику.
## 2. Общее правило: `celery beat` — только один экземпляр
Периодические задачи (`app.conf.beat_schedule`) планирует `celery beat`.
Базовый `worker` запускается с флагом `-B` (worker + встроенный beat в одном
процессе, `deploy/docker-compose.yml`). При масштабировании **нельзя** просто
поднять несколько реплик сервиса с `-B` — каждая реплика завела бы
собственный планировщик, и periodic-задачи (`cleanup_conferences`,
`recover_stuck_*`) ставились бы в очередь многократно на каждом тике.
Правило: ровно один процесс во всей инсталляции запускается с `-B`
(встроенным или отдельным `celery -A workers.celery_app beat`); все
дополнительные реплики — только `worker` без `-B`, с явным `-Q` на нужные
очереди.
## 3. Вынос очереди в отдельную реплику
Пример: суммаризация (`summarize`) стала узким местом — очередь растёт,
базовый `worker` не успевает. Решение — отдельный сервис-потребитель только
этой очереди, без встроенного beat (он остаётся на базовом `worker`).
Добавить в `deploy/docker-compose.override.yml` (или новый профиль по
аналогии с `worker-transcriber`):
```yaml
services:
worker-summarize:
build:
context: ../backend
dockerfile: Dockerfile
restart: unless-stopped
# Без -B: beat уже запущен на базовом worker (см. правило выше).
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
"-Q", "summarize", "--hostname=worker-summarize-%h@%h", "--loglevel=info"]
env_file:
- ../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
PYTHONPATH: /app
volumes:
- ../workers:/app/workers:ro
- ../config:/app/config:ro
- llm-models:/models/qwen:ro
depends_on:
redis:
condition: service_healthy
```
и одновременно убрать `summarize` из списка очередей базового `worker`
(его команда сужается до `-Q celery,notify`), чтобы задачи не выполнялись
дважды разными процессами (Celery доставляет задачу ровно одному
consumer'у одной и той же очереди — дублирования не будет, но держать
лишний неиспользуемый consumer незачем).
Аналогично можно выделить `notify` в `worker-notify` (та же схема,
`-Q notify`) — например, если рассылка приглашений/уведомлений на большую
аудиторию (SMTP-латентность) начинает задерживать саммаризацию соседних
сеансов при их совместном обслуживании базовым `worker`.
## 4. Масштабирование реплик через `docker compose up --scale`
Если одной выделенной очереди тоже мало (несколько ядер CPU для
суммаризации/уведомлений), реплицируем сервис командой `--scale`:
```bash
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.override.yml \
up -d --scale worker-summarize=3
```
Требования для корректного масштабирования сервиса:
1. **Без `-B`** на масштабируемом сервисе (правило §2).
2. **Без фиксированного `--hostname`** на весь сервис — при нескольких
репликах одинаковое имя узла Celery приведёт к конфликту регистрации в
кластере (соединения будут путаться, `celery inspect` начнёт видеть
произвольного из реплик). Использовать `%h` (имя контейнера, уникальное
у каждой реплики Compose) — см. `--hostname=worker-summarize-%h@%h` в
примере выше. Это же ограничение действует и для `worker-transcriber`:
его текущий healthcheck (`--destination worker-transcriber@localhost`)
жёстко завязан на единственную реплику; при `--scale worker-transcriber=N`
healthcheck и `--hostname` в `deploy/docker-compose.yml` потребуется
поменять на шаблон `%h` (и убрать `--destination`, либо адресовать
каждую реплику отдельно) — вне рамок этого документа, т.к. правит
основной `deploy/docker-compose.yml` (devops-часть).
3. **Без `container_name`** и без фиксированных host-портов на
масштабируемом сервисе (у Celery-воркеров портов нет — ограничение не
актуально для `worker*`, но актуально, если аналогичный приём
применяется к `backend` за `nginx upstream`).
4. Ресурсные лимиты (`cpus`/`mem_limit`) в Compose-файле применяются к
КАЖДОЙ реплике, а не разделяются между ними — планировать суммарное
потребление хоста (`N × cpus`).
Для `worker-transcriber` тот же приём уже применим на уровне профиля:
```bash
docker compose -f deploy/docker-compose.yml --profile transcribe \
up -d --scale worker-transcriber=2
```
(после снятия ограничения фиксированного `--hostname`, см. пункт 2 выше).
## 5. Когда масштабировать
Ориентир — глубина очереди (`vidconf_celery_queue_depth`) растущая дольше
15 минут (алерт `QueueGrowing`, `deploy/monitoring/alerts.yml`) либо
устойчиво положительная под обычной нагрузкой инстанса. Для `summarize` и
`transcription` также ориентир — доля CPU/GPU (транскрибация и
суммаризация — тяжёлые по вычислениям шаги, `docs/deploy/hardware-profiles.md`);
`notify` почти всегда I/O-bound (SMTP) — реплики полезны в первую очередь
при большой аудитории рассылок (закреплённые конференции с длинным списком
участников/приглашённых).