Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

152
docs/README.md Normal file
View File

@@ -0,0 +1,152 @@
# Documentation Index
Полная документация проекта VidConf. Начните с нужного вам раздела.
## Быстрый старт
- **[README проекта](../README.md)** — обзор VidConf, стек, структура монорепо
- **[Design System](../design/DESIGN_SYSTEM.md)** — цветовые токены, типографика, компоненты, мокапы
- **[Dev Setup](deploy/dev-setup.md)** — локальная разработка (Docker, миграции, тесты)
- **[API Reference](api/README.md)** — HTTP endpoints, примеры запросов
## Архитектура
- **[Architecture Overview](architecture/README.md)** — система компонентов, data flow, design decisions
- **[Database Schema](db/schema.md)** — ER-диаграмма, описание 14 таблиц, ключевые решения
- **[Plugin Contracts](plugins/contracts.md)** — как работают плагины AI (Transcriber/Summarizer), как добавить свой
- **[Пресеты инсталлятора & профили оборудования](deploy/hardware-profiles.md)** — 5 вариантов, автодетект железа, требования ресурсов
## Разработка
- **[Backend README](../backend/README.md)** — FastAPI, модели, миграции, тесты, плагины
- **[Frontend README](../frontend/README.md)** — React SPA, компоненты, стайлинг
- **[Workers README](../workers/README.md)** — Celery, транскрибация, суммаризация, уведомления
## ADR (Architectural Decision Records)
- **[000-template.md](architecture/adr/000-template.md)** — стандартный шаблон ADR для проекта
- Направление: добавлять ADR для крупных архитектурных решений
## По темам
### Интерфейс комнаты конференции
- Настройки устройств (микрофон, камера, персист в localStorage) — [Conference Room UI](architecture/conference-room-ui.md)
- Аватары участников (или инициалы при отсутствии)
- Fullscreen API (кнопка)
- Document Picture-in-Picture для сетки (Chrome/Edge 116+)
- Video Picture-in-Picture для спикера (Safari/Firefox фолбэк)
- Демонстрация экрана/окна/вкладки (любой участник, focus-раскладка, last-wins)
- LiveKit-метаданные с avatar_url
### Авторизация & безопасность
- Argon2 хэширование паролей [Auth API](api/auth.md)
- JWT токены (access + refresh)
- Роли: admin, user
- HTTPS/TLS (Nginx Reverse Proxy)
- Email верификация
### Видеоконференции
- LiveKit SFU + Coturn TURN — [Dev Setup с профилем media](deploy/dev-setup.md)
- Per-track audio (без диаризации)
- WebRTC via LiveKit JS SDK
- Webhook обработка событий — [Conferences API](api/conferences.md)
- [Architecture](architecture/README.md#4-livekit-sfu)
### Чат конференции
- WebSocket чат в реальном времени — [Chat API](api/chat.md)
- Аутентификация по LiveKit-токену
- Гостевой доступ (отправка под гостевым именем)
- Broadcast через Redis pub/sub
- Тоггл `chat.enabled` из настроек инстанса
### Транскрибация & суммаризация
- Plugin contracts (Transcriber, Summarizer)
- NullTranscriber, NullSummarizer (no-op по умолчанию)
- faster-whisper для STT (3 модели: small/medium/large-v3, уровни min/medium/max)
- Qwen3.5 для LLM (3 модели: 4B/9B/35B-A3B, Q4_K_M, уровни min/medium/max)
- Матрица уровней AI (ADR-004), инсталлятор с автодетектом, раздельные очереди Celery, метрики, eval-корпус
- Pluggable реализации via [Plugin Contracts](plugins/contracts.md)
### Динамические конференции
- Конференции как пользовательские сущности (номер, slug, владелец)
- Номер (9 цифр) и постоянная ссылка (base64url slug)
- Мгновенные и плановые конференции — [Conferences API](api/conferences.md)
- Закреплённые конференции с повторением (weekly/biweekly/monthly/every_n_days)
- Гостевой доступ (display_name обязателен, email факультативен)
- [ADR-001](architecture/adr/001-dynamic-conferences-pivot.md), [Schema](db/schema.md#conferences)
### Асинхронные задачи
- Redis broker + Celery worker/beat инфраструктура — [Workers README](../workers/README.md)
- Периодические задачи (beat): очистка зависших сеансов/конференций, восстановление зависших саммари/уведомлений
- Celery-задачи (транскрибация, суммаризация, уведомления, приглашения)
- Идемпотентный pipeline (recording → transcribing → summarizing → notified)
- [Workers README](../workers/README.md)
### Deployment & инсталляция
- Инсталлятор `install.sh` с автодетектом железа (5 пресетов)
- 5 пресетов: MVP, +чат, +AI min/medium/max
- Раздельные очереди Celery (transcription/summarize/notify)
- Мониторинг (Prometheus + Grafana)
- [Инсталлятор & пресеты](deploy/hardware-profiles.md)
- [Мониторинг](deploy/monitoring.md)
- [Масштабирование](deploy/scaling.md)
- [Ёмкость](deploy/capacity.md)
## Файловая структура docs/
```
docs/
├── architecture/ Архитектурные решения
│ ├── README.md Обзор системы
│ ├── conference-room-ui.md Интерфейс комнаты (PiP, аватары, настройки устройств)
│ ├── frontend-themes.md Светлая/тёмная тема оболочки
│ └── adr/ Architectural Decision Records
│ └── 000-template.md
├── api/ HTTP & WebSocket API
│ ├── README.md Endpoints, примеры, коды ошибок
│ ├── auth.md Аутентификация
│ ├── users.md Профиль пользователя, поиск
│ ├── teams.md Справочник команд
│ ├── conferences.md Управление конференциями
│ ├── chat.md Текстовый чат
│ └── admin.md Администраторский API
├── db/ База данных
│ └── schema.md ER-диаграмма, таблицы, миграции
├── plugins/ AI-плагины
│ ├── contracts.md Интерфейсы, фабрика, как расширить
│ ├── transcriber.md Transcriber плагины
│ └── summarizer.md Summarizer плагины
├── deploy/ Deployment & инструменты
│ ├── dev-setup.md Локальная разработка
│ ├── hardware-profiles.md Пресеты инсталлятора (5 вариантов)
│ ├── install.md Инсталлятор `install.sh` (автодетект железа)
│ ├── env.md Справочник переменных окружения
│ ├── llm-setup.md Установка модели LLM
│ ├── monitoring.md Prometheus + Grafana
│ ├── scaling.md Горизонтальное масштабирование
│ ├── capacity.md Калькулятор нагрузки
│ └── quality-tiers.md Методика и результаты оценки качества суммаризации по уровням AI
└── README.md Этот файл
```
## Правила
- **Без документации не мёржится** — каждое изменение функциональности включает обновление docs
- **По факту кода** — docs читают из реального кода, не выдумывают
- **Язык** — русский для описания, имена кода как в коде
- **Mermaid диаграммы** — ER, архитектура, data flow
- **Ссылки** — всегда абсолютные, между файлами в docs/
## Начните отсюда
1. Читаете проект впервые? → [Architecture Overview](architecture/README.md)
2. Ставите локально? → [Dev Setup](deploy/dev-setup.md)
3. Добавляете фичу? → нужный раздел (backend/frontend/db/plugins) + обновить docs
4. Запускаете в prod? → [Hardware Profiles](deploy/hardware-profiles.md)
5. Расширяете AI? → [Plugin Contracts](plugins/contracts.md)
## Контакты & вопросы
- Issues: GitHub Issues
- Обсуждение архитектуры: ADR в `docs/architecture/adr/`
- Код без docs: не мёржится по политике проекта

0
docs/api/.gitkeep Normal file
View File

184
docs/api/README.md Normal file
View File

@@ -0,0 +1,184 @@
# Документация API
VidConf REST + WebSocket API: аутентификация, профиль, динамические конференции, чат, администрирование, health check. Конференции создаются динамически — предустановленных комнат и бронирований нет (см. [ADR-001](../architecture/adr/001-dynamic-conferences-pivot.md)).
## Интерактивная документация
FastAPI автоматически генерирует интерактивные документы:
- **Swagger UI:** `http://localhost:8000/docs`
- **ReDoc:** `http://localhost:8000/redoc`
- **OpenAPI JSON:** `http://localhost:8000/openapi.json`
## Аутентификация & авторизация
**[Полная документация: docs/api/auth.md](auth.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/auth/registration-options` | GET | Публичные опции регистрации (выбор команды) |
| `/api/v1/auth/register` | POST | Регистрация нового пользователя |
| `/api/v1/auth/verify-email` | POST | Подтверждение email по токену |
| `/api/v1/auth/token` | POST | OAuth2 вход (email + пароль) → access + refresh токены |
| `/api/v1/auth/refresh` | POST | Ротация refresh-токена → новый access-токен |
| `/api/v1/auth/logout` | POST | Отзыв refresh-токена |
## Профиль пользователя
**[Полная документация: docs/api/users.md](users.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/users/me` | GET | Профиль текущего пользователя |
| `/api/v1/users/me` | PATCH | Обновить ФИО и команду |
| `/api/v1/users/me/avatar` | POST | Загрузить аватар (JPEG/PNG/WebP, ≤2 МБ) |
| `/api/v1/users/me/avatar` | DELETE | Удалить аватар |
| `/api/v1/users` | GET | Поиск пользователей для пикера участников |
## Справочник команд
**[Полная документация: docs/api/teams.md](teams.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/teams` | GET | Список всех команд (для выбора в профиле) |
**Для CRUD операций над командами см. [Администраторский API](./admin.md#команды).**
## LiveKit интеграция
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/livekit/webhook` | POST | Приёмник webhook-событий от LiveKit |
## GET /api/health
Проверка доступности backend и зависимостей.
**Request:**
```bash
curl http://localhost:8000/api/health
```
**Response (200 OK):**
```json
{
"status": "ok",
"db": true,
"redis": true
}
```
---
## Динамические конференции
### Управление конференциями
**[Полная документация: docs/api/conferences.md](conferences.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/conferences` | POST | Создать конференцию (мгновенную без scheduled_at или плановую) |
| `/api/v1/conferences/my` | GET | Мои конференции: закреплённые + предстоящие разовые |
| `/api/v1/conferences/calendar` | GET | Развёртка вхождений (occurrences) в диапазоне дат (≤62 дней) |
| `/api/v1/conferences/resolve` | GET | Найти конференцию по номеру или ссылке (публичный, rate limit 10/мин) |
| `/api/v1/conferences/{id}/join` | POST | Вход зарегистрированного пользователя (с пароль-опцион) |
| `/api/v1/conferences/{id}/guest-join` | POST | Вход гостем (публичный, rate limit, display_name обязателен) |
| `/api/v1/conferences/{id}` | PATCH | Изменить конференцию (владелец/администратор) |
| `/api/v1/conferences/{id}` | DELETE | Удалить конференцию (запрещено для активной) |
**Ключевые особенности** (в отличие от прежней модели бронирования переговорных комнат, см. ADR-001):
- Конференция имеет 9-значный номер (вместо room + booking)
- Вход по номеру или постоянной ссылке (slug), без привязки к комнате
- Гости представляются (display_name + email опционально) и участвуют в пайплайне
- Закреплённые конференции могут повторяться (weekly/biweekly/monthly/every_n_days)
- Нет конкуренции за ресурсы → EXCLUDE constraint не используется, конфликты — на уровне бизнес-логики
---
## Администраторский API
**[Полная документация: docs/api/admin.md](admin.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/admin/conferences` | GET | Список всех конференций с фильтрацией и пагинацией (только админ) |
| `/api/v1/admin/conferences/{id}` | PATCH | Изменить конференцию (только админ) |
| `/api/v1/admin/conferences/{id}` | DELETE | Удалить конференцию (только админ, запрещено для активной) |
| `/api/v1/admin/conferences/{id}/invitations` | POST | Поставить рассылку .ics-приглашений (202, асинхронно) |
| `/api/v1/admin/users` | GET | Список пользователей с пагинацией (только админ) |
| `/api/v1/admin/users/{id}` | PATCH | Изменить роль/блокировку/команду пользователя (только админ, запрет самоизменения роли/блокировки) |
| `/api/v1/admin/teams` | GET | Список всех команд (только админ) |
| `/api/v1/admin/teams` | POST | Создать команду (только админ, 409 при дубле названия) |
| `/api/v1/admin/teams/{id}` | PATCH | Переименовать команду (только админ) |
| `/api/v1/admin/teams/{id}` | DELETE | Удалить команду (только админ, у пользователей обнулится team_id) |
| `/api/v1/admin/settings` | GET | Текущие настройки инстанса (только админ) |
| `/api/v1/admin/settings` | PUT | Обновить настройки инстанса (только админ; AI-уровень/таймзона/домен регистрации валидируются) |
Все эндпоинты требуют JWT токен администратора (403 иначе).
---
## Чат конференции
### Обмен сообщениями в реальном времени
**[Полная документация: docs/api/chat.md](chat.md)**
| Эндпоинт | Протокол | Описание |
|----------|----------|---------|
| `/api/v1/conferences/{id}/chat` | WebSocket | Текстовый чат (auth по LiveKit-токену, история, broadcast через Redis pub/sub) |
**Особенности:**
- Аутентификация на уровне протокола WS (первое сообщение `{"type":"auth","token":<LiveKit-токен>}`)
- История: последние 50 сообщений открытой сессии конференции
- Участники: зарегистрированные пользователи + гости (с пометкой `is_guest`)
- Тоггл `chat.enabled` из настроек инстанса в БД, проверяется на каждом подключении
- Отправитель видит своё сообщение через pub/sub (echo)
**Close-коды:** 4401 (невалидный auth), 4403 (чужая комната), 4404 (чат выключен / конференция не найдена / завершена).
**Frontend:** JoinOut содержит `chat_enabled` для отображения/скрытия UI панели без рестарта.
---
## Планируемый API
- Прямой доступ к транскрипту сеанса через API (`GET /api/sessions/{id}/transcript`) — сейчас фразы доступны только внутри пайплайна и итогового саммари, отдельного read-эндпоинта нет.
Транскрибация, суммаризация и рассылка саммари/приглашений уже реализованы
асинхронно через Celery-воркеры (статус — `pipeline_status` в
`conference_sessions`, см. [workers/README.md](../../workers/README.md)).
---
## Соглашения
**Все timestamps в UTC:**
```json
"created_at": "2026-07-15T14:30:00Z"
```
**Авторизация:** JWT Bearer token в заголовке `Authorization` (для защищённых эндпоинтов)
**Публичные эндпоинты (без auth):**
- `GET /api/v1/conferences/resolve` — поиск по номеру/ссылке (rate limit)
- `POST /api/v1/conferences/{id}/guest-join` — вход гостем (rate limit)
**CORS:** Frontend настроен в dev-прокси (vite.config.ts: `/api``localhost:8000`)
**Плагины:** Конфигурация AI сервисов через `config/plugins.yaml`. См. [docs/plugins/contracts.md](../plugins/contracts.md).
---
## Тестирование
```bash
# Health check
curl http://localhost:8000/api/health
# Swagger docs
open http://localhost:8000/docs
```
Полная спецификация — в Swagger UI/ReDoc (см. выше) и в файлах этого каталога.

578
docs/api/admin.md Normal file
View File

@@ -0,0 +1,578 @@
# Администраторский API
Эндпоинты управления инстансом VidConf — конференции, пользователи, команды, настройки. **Все эндпоинты требуют роль `admin` (403 иначе).**
## Авторизация
Все запросы должны включать JWT Bearer токен администратора в заголовке `Authorization`:
```bash
Authorization: Bearer <access_token>
```
Попытка обращения без роли `admin`**403 Forbidden** с `detail="Forbidden"`.
## Эндпоинты
### Конференции
#### GET /api/v1/admin/conferences
**Список всех конференций инстанса с фильтрацией и пагинацией.**
**Query параметры:**
- `status` (опционально) — фильтр по статусу: `scheduled`, `active`, `ended`
- `q` (опционально) — текстовый поиск по названию/номеру/slug
- `limit` (опционально, default=50, max=200) — строк на странице
- `offset` (опционально, default=0) — смещение (пагинация)
**Response (200 OK):**
```json
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"number": "123456789",
"slug": "abc123456",
"title": "Встреча Q3",
"status": "scheduled",
"is_pinned": true,
"is_closed": false,
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"recurrence": { "type": "weekly", "weekdays": [0, 2, 4] },
"summary_recipients": "all",
"created_at": "2026-07-16T12:00:00Z",
"owner_name": "Иван Иванов",
"owner_email": "ivan@example.com"
}
],
"total": 42
}
```
**Ключевые поля:**
- `owner_name` / `owner_email` — имя и email владельца конференции (null если владельца нет)
- `summary_recipients` — текущий режим рассылки (null = дефолт инстанса)
---
#### PATCH /api/v1/admin/conferences/{conference_id}
**Изменить конференцию (реюз `ConferenceService.update`, те же правила валидации).**
**Path параметры:**
- `conference_id` (UUID)
**Request body:** (все поля опциональны)
```json
{
"title": "Новое название",
"scheduled_at": "2026-07-25T15:00:00Z",
"duration_minutes": 90,
"is_pinned": true,
"recurrence": null,
"is_closed": false,
"password": null,
"summary_recipients": "owner"
}
```
**Response (200 OK):** обновленная конференция (как GET /conferences)
**Коды ошибок:**
- `404` — conference_not_found
- `422` — invalid_conference_state (попытка добавить recurrence к неживой конференции и т.п.)
**Примечание:** админ проходит проверку владения как "владелец ИЛИ админ" — можно менять чужие конференции.
---
#### DELETE /api/v1/admin/conferences/{conference_id}
**Удалить конференцию (409 для активной).**
**Path параметры:**
- `conference_id` (UUID)
**Response (204 No Content)**
**Коды ошибок:**
- `404` — conference_not_found
- `409` — conference_active (активную конференцию удалить нельзя)
---
#### POST /api/v1/admin/conferences/{conference_id}/invitations
**Поставить в очередь ручную рассылку .ics-приглашений (202 Accepted, отправка идёт в Celery).**
**Path параметры:**
- `conference_id` (UUID)
**Request body:** (опционально)
```json
{
"emails": ["alice@example.com", "bob@example.com"]
}
```
**Response (202 Accepted)** — без тела (задача поставлена в очередь)
**Логика:**
- Если `emails` опущена/null — получатели по умолчанию: владелец конференции + для закреплённых — участники прошлых сеансов
- Если `emails` передан — явный список адресов
- Каждое приглашение добавляется в `email_deliveries` с `kind='invitation'` (журнал без unique, дубликаты допускаются)
**Коды ошибок:**
- `404` — conference_not_found
---
### Пользователи
#### GET /api/v1/admin/users
**Список всех пользователей инстанса с пагинацией.**
**Query параметры:**
- `q` (опционально) — текстовый поиск по email/имени
- `limit` (опционально, default=50, max=200)
- `offset` (опционально, default=0)
**Response (200 OK):**
```json
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@example.com",
"name_user": "Администратор",
"role": "admin",
"email_verified": true,
"is_blocked": false,
"team_id": null,
"created_at": "2026-07-16T12:00:00Z"
}
],
"total": 15
}
```
---
#### POST /api/v1/admin/users
**Создать пользователя от имени администратора.**
Email сразу считается подтверждённым (письмо на верификацию не отправляется); роль по умолчанию — `user`. Дубль email → **409** `email_already_registered`. Несуществующая команда → **404** `team_not_found`.
**Request body:**
```json
{
"name_user": "Иван Петров",
"email": "ivan@example.com",
"password": "securePassword123",
"team_id": "550e8400-e29b-41d4-a716-446655440001"
}
```
**Поля:**
- `name_user` (str, 1..255) — отображаемое имя (обязательно)
- `email` (EmailStr) — адрес электронной почты (обязательно, уникален)
- `password` (str, ≥8) — пароль пользователя (политика как при регистрации)
- `team_id` (UUID или null) — опциональная привязка к команде (не обязана быть выбрана, в отличие от публичной регистрации)
**Response (201 Created):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"email": "ivan@example.com",
"name_user": "Иван Петров",
"role": "user",
"is_blocked": false,
"email_verified": true,
"created_at": "2026-07-21T10:30:00Z",
"team_id": "550e8400-e29b-41d4-a716-446655440001",
"avatar_url": null,
"team_name": "Backend"
}
```
**Коды ошибок:**
- `409` — email_already_registered (email уже зарегистрирован)
- `404` — team_not_found (передан несуществующий `team_id`)
- `422` — Unprocessable Entity (пароль короче 8 символов, валидация email и т.д.)
**Примечание:** В отличие от самостоятельной регистрации (`POST /auth/register`), пользователь не проходит верификацию email — роль назначается как `user`, а `email_verified` сразу `true`.
---
#### GET /api/v1/admin/users/{user_id}
**Карточка профиля пользователя — те же данные, что в пользовательском профиле + роль и статус блокировки.**
**Path параметры:**
- `user_id` (UUID)
**Response (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name_user": "Иван Петров",
"role": "user",
"is_blocked": false,
"team_id": "550e8400-e29b-41d4-a716-446655440001",
"team_name": "Backend",
"avatar_path": "avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
"avatar_url": "/media/avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
"created_at": "2026-07-18T12:00:00Z"
}
```
**Поля:**
- `avatar_path` — путь к файлу аватара (относительно `MEDIA_ROOT`), `null` если нет
- `avatar_url` — готовый URL для отображения (`/media/avatars/...`)
- `team_name` — имя команды (null если не привязан)
**Коды ошибок:**
- `404` — user_not_found
---
#### PATCH /api/v1/admin/users/{user_id}
**Изменить роль, статус блокировки, ФИО, команду пользователя.**
**Path параметры:**
- `user_id` (UUID)
**Request body:** (все поля опциональны)
```json
{
"role": "admin",
"is_blocked": true,
"name_user": "Новое имя",
"team_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Response (200 OK):** обновленный пользователь (как GET /admin/users/{id})
**Коды ошибок:**
- `404` — user_not_found (пользователь) или team_not_found (несуществующий `team_id`)
- `409` — cannot_modify_self (попытка изменить свою `role`/`is_blocked`; своё `name_user` и `team_id` менять можно)
**Семантика:**
- `is_blocked=true` — пользователь получит 401 на любом защищённом эндпоинте (проверка в `api/deps.py::_user_from_token`), немедленно, без ожидания истечения access-токена
- `role` может быть `"admin"` или `"user"`
- `name_user` — новое ФИО пользователя
- `team_id` — явное `null` снимает привязку к команде; отсутствие поля в теле запроса команду не трогает (различается через `model_fields_set`); запрет самоизменения (409) на `team_id` **не распространяется**
---
#### POST /api/v1/admin/users/{user_id}/avatar
**Загрузить аватар пользователю.**
**Path параметры:**
- `user_id` (UUID)
**Request body:** `multipart/form-data`
- `file` — файл изображения (обязателен)
**Требования:**
- Формат: JPEG, PNG или WebP (проверяется по magic bytes)
- Размер: максимум 2 МБ
**Response (200 OK):** обновленный пользователь (как GET /admin/users/{id}, с новым `avatar_url`)
**Коды ошибок:**
- `404` — user_not_found
- `413` — avatar_too_large (файл больше 2 МБ)
- `415` — avatar_invalid_type (не JPEG/PNG/WebP)
**Примечание:** Старый аватар автоматически удаляется при загрузке нового.
---
### Команды
#### GET /api/v1/admin/teams
**Список всех команд, отсортированный по названию.**
**Response (200 OK):**
```json
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Backend",
"created_at": "2026-07-18T12:00:00Z"
}
],
"total": 1
}
```
---
#### POST /api/v1/admin/teams
**Создать команду.**
**Request body:**
```json
{ "name": "Backend" }
```
`name` обрезается от пробелов (1..255 символов после обрезки).
**Response (201 Created):** созданная команда
**Коды ошибок:**
- `409` — team_name_taken (команда с таким названием уже существует)
---
#### PATCH /api/v1/admin/teams/{team_id}
**Переименовать команду.**
**Path параметры:**
- `team_id` (UUID)
**Request body:**
```json
{ "name": "Backend & Platform" }
```
**Response (200 OK):** обновлённая команда
**Коды ошибок:**
- `404` — team_not_found
- `409` — team_name_taken
---
#### DELETE /api/v1/admin/teams/{team_id}
**Удалить команду.**
**Path параметры:**
- `team_id` (UUID)
**Response (204 No Content)**
У пользователей, состоявших в удалённой команде, `team_id` автоматически обнуляется (`ON DELETE SET NULL`) — сами пользователи не удаляются.
**Коды ошибок:**
- `404` — team_not_found
---
### Настройки инстанса
#### GET /api/v1/admin/settings
**Текущие эффективные настройки инстанса (собрань из БД + дефолты).**
**Response (200 OK):**
```json
{
"chat_enabled": false,
"transcription_enabled": true,
"ai_level": "min",
"ai_levels": [
{"level": "min", "available": true, "reason": null},
{"level": "medium", "available": false, "reason": "Недостаточно памяти"},
{"level": "max", "available": false, "reason": "Нет GPU"}
],
"summary_recipients": "all",
"display_timezone": "Europe/Moscow",
"registration_team_choice": false,
"registration_email_domain_enabled": false,
"registration_email_domain": null,
"transcription_queue_served": true
}
```
**Поля:**
- `chat_enabled` — включен ли чат в конференциях
- `transcription_enabled` — включены ли транскрибация и суммаризация (единый переключатель)
- `ai_level` — текущий уровень AI (`min`, `medium`, `max`)
- `ai_levels` — доступность всех уровней с причинами (например, `medium` может быть недоступен при недостатке памяти, `max` требует GPU)
- `summary_recipients` — режим рассылки саммри по умолчанию (`all` или `owner`)
- `display_timezone` — таймзона отображения времени в письмах и .ics (IANA, например `Europe/Moscow`)
- `registration_team_choice` — разрешён ли выбор команды на публичной форме регистрации (дефолт `false`); см. `GET /api/v1/auth/registration-options`
- `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
- `transcription_queue_served``true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже)
---
#### PUT /api/v1/admin/settings
**Частично обновить настройки инстанса (недоступный уровень AI/неверная таймзона → 400).**
**Request body:** (все поля опциональны, PATCH-семантика)
```json
{
"chat_enabled": true,
"transcription_enabled": false,
"ai_level": "min",
"summary_recipients": "owner",
"display_timezone": "Europe/London",
"registration_team_choice": true,
"registration_email_domain_enabled": true,
"registration_email_domain": "example.com"
}
```
**Response (200 OK):** обновленные настройки (как GET /settings)
**Коды ошибок:**
- `400` — invalid AI level (попытка установить недоступный уровень, например `medium` без нужного железа), неверная таймзона (не IANA) или некорректная настройка верификации домена email (включение без домена либо домен не проходит валидацию паттерном)
**Логика:**
- `chat_enabled`: пишет `enabled` в хранилище настроек (`instance_settings.chat`)
- `transcription_enabled`: пишет `enabled` сразу в обе секции (`transcriber`, `summarizer`) — единый переключатель; **guard:** если установлено `true`, но `transcription_queue_served=false` в ответе `GET /settings`, админка отображает предупреждение «Очередь транскрибации не обслуживается ни одним Celery-воркером» (воркеры не запущены, обслуживают другую очередь или недоступны)
- `ai_level`: проверяется `detect_ai_levels()` на доступность перед сохранением
- `display_timezone`: валидируется как IANA (через `ZoneInfo`)
- `summary_recipients`: может быть `"all"` или `"owner"` (дефолт для новых конференций)
- `registration_team_choice`: включает/выключает выбор команды на публичной форме регистрации (`instance_settings.registration_team_choice`); влияет на `GET /api/v1/auth/registration-options` и допустимость `team_id` в `POST /api/v1/auth/register`
- `registration_email_domain_enabled` / `registration_email_domain`: включают верификацию регистрирующихся по домену email (`instance_settings.registration_email_domain`); включение (`enabled=true`) при пустом/отсутствующем домене → `400`; домен нормализуется (`strip`, без ведущей `@`, нижний регистр) и валидируется простым паттерном доменного имени, иначе `400`; влияет на `GET /api/v1/auth/registration-options` (`email_domain`) и `POST /api/v1/auth/register` (`400 invalid_email_domain` при несовпадении домена)
**Примечание:** Изменение настроек **не требует рестарта** ни backend'а, ни воркеров Celery (они читают конфиг на старте каждой задачи).
### Предупреждение об очереди транскрибации
При включённой транскрибации (`transcription_enabled=true`) админка проверяет
доступность очереди через поле `transcription_queue_served` в ответе
`GET /api/v1/admin/settings`:
- **`transcription_queue_served=true`:** хотя бы один Celery-воркер `transcriber`
активен и обслуживает очередь — новые конференции будут обработаны
- **`transcription_queue_served=false`:** ни один воркер не подключён к очереди —
**все** сеансы конференций по завершении зависнут в статусе `transcribing`
(задачи накопятся в Redis, никто их не возьмёт)
**Решение:** запустить хотя бы один воркер транскрибации:
```bash
docker compose up -d transcriber
# или
cd workers && celery -A transcriber worker -q
```
После запуска воркера поле `transcription_queue_served` обновится на `true`
(проверяется при каждом запросе к `/api/v1/admin/settings`).
---
## Примеры запросов
### Получить все конференции с фильтром по статусу
```bash
curl -X GET \
'http://localhost:8000/api/v1/admin/conferences?status=scheduled&limit=20&offset=0' \
-H "Authorization: Bearer $TOKEN"
```
### Изменить режим рассылки саммри конференции
```bash
curl -X PATCH \
'http://localhost:8000/api/v1/admin/conferences/550e8400-e29b-41d4-a716-446655440000' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"summary_recipients": "owner"}'
```
### Поставить ручную рассылку приглашений
```bash
curl -X POST \
'http://localhost:8000/api/v1/admin/conferences/550e8400-e29b-41d4-a716-446655440000/invitations' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"emails": ["alice@example.com", "bob@example.com"]}'
```
### Создать пользователя
```bash
curl -X POST \
'http://localhost:8000/api/v1/admin/users' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name_user": "Иван Петров",
"email": "ivan@example.com",
"password": "securePassword123",
"team_id": "550e8400-e29b-41d4-a716-446655440001"
}'
```
### Заблокировать пользователя
```bash
curl -X PATCH \
'http://localhost:8000/api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"is_blocked": true}'
```
### Назначить пользователю команду
```bash
curl -X PATCH \
'http://localhost:8000/api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"team_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"}'
```
### Изменить настройки инстанса
```bash
curl -X PUT \
'http://localhost:8000/api/v1/admin/settings' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"chat_enabled": true,
"ai_level": "min",
"display_timezone": "Europe/London",
"summary_recipients": "all"
}'
```
---
## Безопасность и ограничения
- **Только для админов:** все эндпоинты блокируют 403 для роли `user`
- **Самоизменение роли/блокировки запрещено:** PATCH собственных `role`/`is_blocked` → 409 (`cannot_modify_self`); свою команду (`team_id`) администратор менять может
- **Секреты SMTP не видны:** почтовые реквизиты хранятся только в `.env`, в API не попадают
- **Блокировка немедленна:** `is_blocked=true` действует на любом защищённом эндпоинте без ожидания истечения токена
- **Рассылка в очереди:** отправка писем идёт асинхронно через Celery (202, не 200), ошибки логируются в воркере
---
## Примечания
1. **Изменение расписания конференции** (PATCH `scheduled_at`/`duration_minutes`/`recurrence`/`title`) инкрементирует `ics_sequence` и ставит в очередь `send_invitations` — календарные клиенты получат обновление по UID события.
2. **Переопределение рассылки саммри** (`summary_recipients` в конференции) сильнее дефолта инстанса — если в конференции установлено, оно используется; иначе берётся настройка из `instance_settings.summary_recipients`.
3. **AI-уровни:** три уровня качества `min`/`medium`/`max` (Qwen3.5-4B/9B/35B-A3B, faster-whisper small/medium/large-v3). Доступность уровня определяется детектом железа (HW_CPUS/HW_RAM_MB/HW_GPU_NAME/HW_VRAM_MB в `.env`). Недоступный уровень → 400 Bad Request с объяснением. ADR-004: [docs/architecture/adr/004-ai-tier-matrix.md](../architecture/adr/004-ai-tier-matrix.md)
---
## Ссылки
- [Конфигурация инстанса](../architecture/README.md#Компоненты) — общее описание системы
- [Схема БД](../db/schema.md) — таблицы `instance_settings`, `email_deliveries`
- [API конференций](./conferences.md) — пользовательский API (без админа)

513
docs/api/auth.md Normal file
View File

@@ -0,0 +1,513 @@
# Аутентификация и авторизация
Эндпоинты для регистрации, верификации email, входа и управления JWT-токенами.
## Быстрый старт
```bash
# 1. Регистрация
curl -X POST http://localhost:8000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","name_user":"John","password":"securepass123"}'
# 2. Проверить письмо, скопировать токен из логов (ConsoleEmailBackend)
# 3. Подтвердить email
curl -X POST http://localhost:8000/api/v1/auth/verify-email \
-H "Content-Type: application/json" \
-d '{"token":"ТОКЕНЗ_ПИСЬМА"}'
# 4. Войти и получить access-токен
curl -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'username=user@example.com&password=securepass123' \
-i # -i чтобы увидеть Set-Cookie с refresh-токеном
# 5. Использовать access-токен
curl http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer ТВОЙ_ACCESS_TOKEN"
# 6. Обновить access-токен (refresh-токен в cookie автоматический)
curl -X POST http://localhost:8000/api/v1/auth/refresh \
-b "refresh_token=ТВОЙ_REFRESH_TOKEN" \
-i
# 7. Выход (отозвать refresh-токен)
curl -X POST http://localhost:8000/api/v1/auth/logout \
-b "refresh_token=ТВОЙ_REFRESH_TOKEN" \
-i
```
## Эндпоинты
### GET /api/v1/auth/registration-options
Публичные опции карточки регистрации (без авторизации) — доступен ли выбор команды и список команд для селектора.
**Ответ (200 OK):**
```json
{
"team_choice_enabled": true,
"teams": [
{"id": "550e8400-e29b-41d4-a716-446655440000", "name": "Alpha"},
{"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "name": "Backend"}
],
"email_domain": "example.com"
}
```
**Поля:**
- `team_choice_enabled` — значение настройки инстанса `registration_team_choice` (админка, `PUT /api/v1/admin/settings`); дефолт `false`
- `teams` — список команд, отсортированный по названию; **пустой массив**, если `team_choice_enabled=false` (справочник команд не раскрывается, пока выбор выключен)
- `email_domain` — эталонный домен email при включённой настройке инстанса `registration_email_domain_enabled`, иначе `null`
---
### POST /api/v1/auth/register
Зарегистрировать нового пользователя и отправить письмо для подтверждения email.
**Тело запроса:**
```json
{
"email": "user@example.com",
"name_user": "Иван Петров",
"password": "securepass123",
"team_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Поля:**
- `email` (строка, email): Адрес электронной почты; уникален в системе
- `name_user` (строка, 1-255 символов): Отображаемое имя пользователя
- `password` (строка, минимум 8 символов): Пароль (хэшируется с argon2)
- `team_id` (UUID, опционально): Команда пользователя; допустим только когда `registration_team_choice` включена в настройках инстанса (см. `GET /registration-options`) и `team_id` ссылается на существующую команду
**Ответ (201 Created):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name_user": "Иван Петров",
"role": "user"
}
```
**Ошибки:**
- `409 Conflict` (`email_already_registered`) — пользователь с таким email уже зарегистрирован
- `400 Bad Request` (`invalid_team_selection`) — передан `team_id`, а выбор команды выключен в настройках, либо команда с таким id не существует (единая ошибка для обоих случаев — публичный эндпоинт не перебирает id команд)
- `400 Bad Request` (`invalid_email_domain`) — включена верификация домена email (`registration_email_domain_enabled`), а домен в `email` (часть после `@`, без учёта регистра) не совпадает с эталонным `registration_email_domain`; проверяется до создания пользователя
- `422 Unprocessable Entity` — валидация (пароль < 8 символов, некорректный email и т.д.)
**Побочный эффект:**
- На указанный email отправляется письмо с ссылкой на верификацию (консоль в dev)
---
### POST /api/v1/auth/verify-email
Подтвердить email пользователя по одноразовому токену из письма.
**Тело запроса:**
```json
{
"token": "ДЛИННЫЙ_ТОКЕНЗ_ПИСЬМА"
}
```
**Поля:**
- `token` (строка): Одноразовый токен подтверждения (256 бит, URL-safe base64)
**Ответ (204 No Content):** — пустой ответ при успехе
**Ошибки:**
- `400 Bad Request` (`invalid_or_expired_token`) — токен не найден, уже использован или истёк (TTL по `.env`: `EMAIL_VERIFICATION_TTL_HOURS`, по умолчанию 24 часа)
**Побочный эффект:**
- Устанавливает `email_verified = true` для пользователя
- Отмечает токен как использованный (`used_at = now`)
---
### POST /api/v1/auth/token
OAuth2 password flow: аутентификация по email и паролю. Выдаёт пару токенов (access + refresh).
**Тело запроса:** (form-data или application/x-www-form-urlencoded)
```
username=user@example.com&password=securepass123
```
**Параметры:**
- `username` (строка): Email пользователя (OAuth2 соглашение использует `username`)
- `password` (строка): Пароль в открытом виде
**Ответ (200 OK):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
```
**Поля:**
- `access_token` (строка): JWT access-токен; используется в заголовке `Authorization: Bearer <token>`
- `token_type` (строка): Всегда `"bearer"`
**Заголовок ответа:**
```
Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; HttpOnly; Secure; SameSite=Strict; Max-Age=2592000
```
**Ошибки:**
- `401 Unauthorized` (`invalid_credentials`) — email не найден или пароль неверный
- `403 Forbidden` (`email_not_verified`) — email ещё не подтвержден (требуется `/verify-email`)
**Описание токенов:**
| Параметр | Тип | TTL | Место | Ротация |
|----------|-----|-----|-------|---------|
| access_token | JWT | 15 минут | Тело ответа | Не ротируется; истекает автоматически |
| refresh_token | JWT | 30 дней | httpOnly cookie | Ротируется при каждом использовании `/refresh` |
**Содержимое access-токена (payload):**
```json
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"role": "user",
"type": "access",
"exp": 1234567890,
"iat": 1234567200
}
```
**Содержимое refresh-токена (payload):**
```json
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"jti": "UNIQUE_ID",
"type": "refresh",
"exp": 1234567890,
"iat": 1234567200
}
```
---
### POST /api/v1/auth/refresh
Ротировать refresh-токен и выдать новый access-токен.
**Параметры:**
- Cookie: `refresh_token=ТВОЙ_REFRESH_TOKEN` (httpOnly, отправляется браузером автоматически)
**Ответ (200 OK):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
```
**Заголовок ответа:**
```
Set-Cookie: refresh_token=NEW_JWT; HttpOnly; Secure; SameSite=Strict; Max-Age=2592000
```
**Ошибки:**
- `401 Unauthorized` (`missing_refresh_token`) — cookie `refresh_token` не передана
- `401 Unauthorized` (`invalid_refresh_token`) — токен невалиден, просрочен, отозван или уже был использован (reuse)
**Механика ротации:**
1. Backend декодирует refresh-токен и проверяет его `jti` в Redis (`refresh:{jti}``user_id`)
2. Если `jti` есть в Redis → токен валиден, пользователь существует
3. Если `jti` отсутствует → reuse или отозван → `401`
4. Старый `jti` немедленно удаляется из Redis
5. Выдаётся новый refresh-токен с новым `jti`, добавляется в Redis
6. Повторное использование уже потраченного refresh-токена автоматически блокируется
---
### POST /api/v1/auth/logout
Отозвать refresh-токен (удалить его из Redis) и погасить httpOnly cookie.
**Параметры:**
- Cookie: `refresh_token=ТВОЙ_REFRESH_TOKEN` (опционально; если отсутствует, просто удалится cookie)
**Ответ (204 No Content):** — пустой ответ при успехе
**Побочный эффект:**
- Удаляет `jti` из Redis → последующие попытки использовать этот refresh-токен дадут `401`
- Удаляет cookie `refresh_token` (устанавливает в пустое значение)
---
### GET /api/v1/users/me
Получить профиль текущего аутентифицированного пользователя.
**Параметры:**
- Заголовок: `Authorization: Bearer <access_token>` (обязателен)
**Ответ (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name_user": "Иван Петров",
"role": "user"
}
```
**Ошибки:**
- `401 Unauthorized` (`not_authenticated`) — access-токен отсутствует, невалиден или истёк
---
## RBAC (Role-Based Access Control)
### Роли
- `admin` — администратор (полный доступ, требуется require_admin)
- `user` — обычный пользователь (доступ к основным функциям)
- `guest` — не аутентифицированный пользователь (отсутствие JWT)
### Зависимости FastAPI для авторизации
**`get_current_user` — требуется аутентификация**
```python
@router.get("/profile")
async def profile(user: Annotated[User, Depends(get_current_user)]) -> UserOut:
"""Доступно только аутентифицированным пользователям."""
return user
```
- Возвращает объект User
- 401 если токен отсутствует, невалиден или истёк
**`get_current_user_optional` — опциональная аутентификация (guest = None)**
```python
@router.get("/public")
async def public_data(user: Annotated[User | None, Depends(get_current_user_optional)]) -> dict:
"""Доступно всем; guest может прочитать, но не будет знать о себе."""
if user is None:
return {"message": "hello guest"}
return {"message": f"hello {user.name_user}"}
```
- Возвращает User или None
- Никогда не выбрасывает 401; отсутствие JWT → `None`
- **Важно:** Guest в системе — это отсутствие JWT, а не отдельное enum-значение в БД
**`require_admin` — требуется роль admin**
```python
@router.delete("/users/{id}")
async def delete_user(id: uuid.UUID, admin: Annotated[User, Depends(require_admin)]) -> None:
"""Доступно только администраторам."""
# ...
```
- 401 если не аутентифицирован
- 403 если роль не `admin`
---
## Архитектурные решения
### Refresh-токены в httpOnly cookies
Refresh-токены хранятся в httpOnly cookie (недоступно из JavaScript), чтобы защитить их от XSS. Браузер отправляет cookie автоматически на каждый запрос к `/api/v1/auth/refresh`.
**Почему httpOnly + Secure + SameSite=Strict:**
- **httpOnly** — недоступно из JavaScript (защита от XSS)
- **Secure** — передаётся только по HTTPS (защита от MITM)
- **SameSite=Strict** — не отправляется при кросс-сайтовых запросах (защита от CSRF)
### Server-side refresh-token validation
Refresh-токены проверяются через Redis-хранилище (`refresh:{jti} → user_id`):
- **Отзыв:** удаление из Redis на logout или reuse
- **Ротация:** старый jti удаляется немедленно после использования → reuse → 401
- **TTL:** Redis-ключ имеет TTL, равный сроку жизни токена
Это позволяет:
1. Отозвать токены без пересоздания ключей подписи
2. Обнаружить replay-атаки (повторное использование старого токена)
3. Контролировать количество активных сессий per user (если реализовать)
### Сравнение с альтернативами
| Подход | Плюсы | Минусы | Используется |
|--------|-------|--------|-------------|
| httpOnly cookie | Защита от XSS | Требует SameSite для защиты CSRF | ✅ Refresh-токен |
| Bearer token в теле | Явный контроль | Уязвимо для XSS | ✅ Access-токен (одноразовый) |
| Opaque tokens (session ID) | Компактно | Требует БД на каждый запрос | ❌ Не используется |
---
## Примеры использования
### JavaScript / TypeScript (frontend)
```typescript
// Регистрация
async function register(email: string, name: string, password: string) {
const res = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name_user: name, password }),
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
return await res.json()
}
// Подтверждение email
async function verifyEmail(token: string) {
const res = await fetch('/api/v1/auth/verify-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
}
// Вход
async function login(email: string, password: string) {
const formData = new URLSearchParams()
formData.append('username', email)
formData.append('password', password)
const res = await fetch('/api/v1/auth/token', {
method: 'POST',
body: formData,
credentials: 'include', // ВАЖНО: отправлять cookies
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
const data = await res.json()
localStorage.setItem('access_token', data.access_token) // Храним access-токен
// refresh-токен в cookie автоматический (httpOnly, браузер управляет)
return data
}
// Использование access-токена
async function getProfile() {
const token = localStorage.getItem('access_token')
const res = await fetch('/api/v1/users/me', {
headers: { 'Authorization': `Bearer ${token}` },
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
return await res.json()
}
// Обновление access-токена (refresh-токен в cookie отправляется автоматически)
async function refreshToken() {
const res = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include', // ВАЖНО: отправлять cookies
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
const data = await res.json()
localStorage.setItem('access_token', data.access_token)
return data
}
// Выход
async function logout() {
const res = await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include', // ВАЖНО: отправлять cookies
})
localStorage.removeItem('access_token') // Удаляем access-токен
// refresh-токен будет удалён серверноvim (cookie)
}
```
### curl примеры
```bash
# Полный цикл регистрации и входа
# 1. Регистрация
curl -X POST http://localhost:8000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","name_user":"Alice","password":"securepass123"}'
# Ответ: {"id":"...", "email":"alice@example.com", "name_user":"Alice", "role":"user"}
# 2. Проверить логи консоли (ConsoleEmailBackend выведет токен подтверждения)
# Из вывода скопировать token, например: "...token=abc123..."
# 3. Подтвердить email
curl -X POST http://localhost:8000/api/v1/auth/verify-email \
-H "Content-Type: application/json" \
-d '{"token":"abc123"}'
# Ответ: (204 No Content)
# 4. Вход
curl -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'username=alice@example.com&password=securepass123' \
-i
# Ответ:
# HTTP/1.1 200 OK
# Set-Cookie: refresh_token=eyJ...; HttpOnly; Secure; SameSite=Strict; ...
# {"access_token":"eyJ...", "token_type":"bearer"}
# 5. Сохранить access-токен из ответа, использовать его
export ACCESS_TOKEN="eyJ..."
# 6. Получить профиль
curl http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Ответ: {"id":"...", "email":"alice@example.com", "name_user":"Alice", "role":"user"}
# 7. Обновить access-токен (refresh_token в cookie отправляется автоматически)
curl -X POST http://localhost:8000/api/v1/auth/refresh \
-b "refresh_token=eyJ..." \
-i
# Ответ:
# HTTP/1.1 200 OK
# Set-Cookie: refresh_token=NEW_TOKEN; HttpOnly; Secure; SameSite=Strict; ...
# {"access_token":"NEW_ACCESS_TOKEN", "token_type":"bearer"}
# 8. Выход
curl -X POST http://localhost:8000/api/v1/auth/logout \
-b "refresh_token=eyJ..." \
-i
# Ответ: HTTP/1.1 204 No Content (cookie удалена)
# Попытка использовать refresh-токен после logout → 401
curl -X POST http://localhost:8000/api/v1/auth/refresh \
-b "refresh_token=eyJ..." \
-i
# Ответ: HTTP/1.1 401 Unauthorized {"detail":"invalid_refresh_token"}
```
---
## Константы и конфигурация
Все TTL и сроки хранения настраиваются через `.env`:
```env
# Access-токен (JWT)
ACCESS_TOKEN_TTL_MINUTES=15
# Refresh-токен (JWT + Redis)
REFRESH_TOKEN_TTL_DAYS=30
# Токен подтверждения email
EMAIL_VERIFICATION_TTL_HOURS=24
# JWT secret (используется для подписи всех токенов)
JWT_SECRET=your-secret-key-here
# Алгоритм хэширования паролей (argon2)
ARGON2_TIME_COST=2
ARGON2_MEMORY_COST=19 # 2^19 КБ = 512 МБ
ARGON2_PARALLELISM=1
```
---
## Связанные файлы
- **Реализация:** `backend/api/auth.py`, `backend/api/users.py`, `backend/api/deps.py`
- **Бизнес-логика:** `backend/services/auth.py`
- **Безопасность:** `backend/core/security.py` (token creation/verification, password hashing)
- **Модели:** `backend/models/user.py`, `backend/models/email_verification.py`
- **Схемы:** `backend/schemas/auth.py`
- **Тесты:** `backend/tests/test_auth.py`, `backend/tests/test_rbac.py`

272
docs/api/chat.md Normal file
View File

@@ -0,0 +1,272 @@
# API Чата
Текстовый чат в реальном времени для участников конференции. Реализован через WebSocket с аутентификацией по LiveKit-токену и broadcast через Redis pub/sub.
## WS-эндпоинт
```
WS /api/v1/conferences/{conference_id}/chat
```
Подключение требует явной аутентификации на уровне протокола (первое сообщение).
## Жизненный цикл соединения
### 1. Accept и ожидание auth-сообщения (таймаут 10 с)
Сервер принимает WebSocket-соединение и ждёт первого (и только первого) сообщения типа `auth`:
```json
{
"type": "auth",
"token": "<LiveKit access-token>"
}
```
**Внимание:** токен не передаётся в query-параметре и не логируется nginx'ом (требование безопасности — токены не должны попадать в логи доступа).
### 2. Проверка допуска
Сервер выполняет последовательность проверок (порядок важен для единообразной обработки ошибок):
1. **Тоггл чата:** флаг `chat.enabled` из настроек инстанса в БД (`instance_settings`). Проверяется **на каждом подключении**, без рестарта backend.
2. **LiveKit-токен:** верификация подписи и целостности (`livekit.api.TokenVerifier`)
3. **Идентичность:**
- Зарегистрированный пользователь: `identity` = строка UUID пользователя; grant `video.room` должен совпадать с `conference.slug`
- Гость: `identity` = `guest:{guest_access_id}` (UUID гостевой записи); grant `video.room` должен совпадать с `conference.slug`
4. **Конференция:** существует и статус ≠ `ended`
### 3. Отправка истории
При успешной аутентификации сервер отправляет последние **50 сообщений** открытой сессии конференции:
```json
{
"type": "history",
"messages": [
{
"id": 1,
"author_id": "550e8400-e29b-41d4-a716-446655440000",
"author_name": "Иван Петров",
"is_guest": false,
"text": "Привет, все слышат?",
"created_at": "2026-07-18T14:30:00Z"
},
...
]
}
```
**Порядок:** Подписка на Redis pub/sub канал происходит **перед** отправкой истории. Это гарантирует, что сообщения, пришедшие от других клиентов в окне между SELECT истории и subscribe, не будут потеряны. На стыке возможен дубликат (одно сообщение и в history, и в первом pub/sub); дедуплицирование по `id`.
### 4. Двусторонний обмен
Клиент отправляет сообщения:
```json
{
"type": "message",
"text": "Мое сообщение"
}
```
Сервер пишет сообщение в БД, затем публикует его в Redis pub/sub. **Отправитель получает своё сообщение обратно через pub/sub** (echo). Порядок доставки единый для всех подписчиков канала.
Полученное сообщение (для всех участников, в т.ч. отправителя):
```json
{
"type": "message",
"message": {
"id": 2,
"author_id": "550e8400-e29b-41d4-a716-446655440001",
"author_name": "Анна Смирнова",
"is_guest": true,
"text": "Отлично, видно хорошо!",
"created_at": "2026-07-18T14:30:15Z"
}
}
```
### 5. Обработка ошибок протокола
Если клиент отправит невалидное JSON или нарушит протокол (невалидный `type`, отсутствующие поля), сервер отправляет:
```json
{
"type": "error",
"code": "invalid_message"
}
```
Соединение остаётся открытым; клиент может отправить следующее сообщение. Полный разрыв соединения происходит только по close-кодам ниже.
## Close-коды
Сервер закрывает WebSocket соединение со следующими кодами:
| Код | Причина | Детали |
|-----|---------|--------|
| **4401** | Невалидный auth-токен | Таймаут ожидания auth-сообщения, невалидный JSON, невалидная подпись LiveKit-токена, отсутствие identity/room в token claims или невалидный UUID. Детали причины не раскрываются клиенту. |
| **4403** | Неправильная комната | Токен валиден, но выдан не для этой конференции (grant `video.room``conference.slug`). |
| **4404** | Чат недоступен | Чат выключен в настройках инстанса (`chat.enabled = false`), или конференция не найдена, или конференция завершена (`status = ended`). Единый код для всех случаев — чтобы по коду закрытия нельзя было перебором отличить существующую конференцию от несуществующей. |
**Штатное закрытие:** когда клиент сам закрывает соединение — это не ошибка, обработчик корректно завершается.
## Защита от фантомных сессий
Если клиент пытается отправить сообщение уже после завершения конференции (`room_finished` от LiveKit):
1. Сервер перечитывает статус конференции свежим SELECT (не полагаясь на закэшированный объект)
2. Если статус = `ended`, отправляет close 4404; сообщение **не записывается** в БД
3. Если статус ≠ `ended` и открытой сессии нет, автоматически создаёт её
Это гарантирует, что "фантомные" сообщения (отправленные после `room_finished`, когда сеанс уже завершается) не создаются.
## Схема сообщения чата
### ChatMessageOut (выход)
```typescript
{
id: number, // BIGINT Identity(always=True), уникален в пределах инстанса
author_id: string | null, // UUID пользователя ИЛИ UUID гостевой записи; null не возможно (см. schema.md)
author_name: string, // Снапшот отображаемого имени из LiveKit-токена на момент отправки (макс 255 символов)
is_guest: boolean, // true если guest_access_id заполнен
text: string, // Текст сообщения (1..2000 символов)
created_at: string // UTC ISO-8601 с суффиксом Z
}
```
**Особенности:**
- `author_name` — снапшот, не ссылка на текущее имя пользователя (переживает переименование и удаление гостевой записи)
- `author_id` никогда не NULL (проверка на уровне БД: CHECK `user_id IS NOT NULL OR guest_access_id IS NOT NULL`)
## Конфигурация
### Настройка чата в инстансе
Флаг `chat.enabled` хранится в таблице `instance_settings` (key = `"chat"`, value = JSONB):
```json
{
"enabled": true
}
```
**Доступ клиенту:** флаг приходит в ответе `join` / `guest-join` (`JoinOut.chat_enabled`), по нему фронт показывает/скрывает UI панели чата.
**Изменение:** через админ-панель (`PATCH /api/v1/admin/settings`); без рестарта backend.
### Бутстрап из config/plugins.yaml
При первом запуске backend загружает `config/plugins.yaml` и создаёт запись в `instance_settings` (однократный идемпотентный импорт):
```yaml
chat:
enabled: true
```
Если `instance_settings['chat']` уже существует, бутстрап не перезаписывает.
## Примеры использования
### JavaScript/TypeScript (frontend)
```typescript
// Подключение
const ws = new WebSocket('ws://localhost:8000/api/v1/conferences/550e8400-e29b-41d4-a716-446655440000/chat')
// Отправка auth-токена (должен быть получен с backend при join)
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'auth',
token: livekit_token
}))
}
// Получение истории и новых сообщений
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'history') {
// Отрисовать историю из msg.messages
} else if (msg.type === 'message') {
// Добавить новое сообщение msg.message
} else if (msg.type === 'error') {
console.error('Chat error:', msg.code)
}
}
// Отправка сообщения
function sendMessage(text: string) {
ws.send(JSON.stringify({
type: 'message',
text: text
}))
}
// Закрытие
ws.onclose = (event) => {
if (event.code === 4403) {
console.error('Wrong room')
} else if (event.code === 4404) {
console.error('Chat unavailable or conference ended')
}
}
```
### curl (тестирование)
WebSocket из curl поддерживается ограниченно; рекомендуется использовать утилиту `wscat`:
```bash
npm install -g wscat
# Подключение и отправка auth
wscat -c ws://localhost:8000/api/v1/conferences/550e8400-e29b-41d4-a716-446655440000/chat
# Затем вручную:
{"type":"auth","token":"eyJ..."}
{"type":"message","text":"Hello"}
```
## Интеграция с остальной системой
### Redis pub/sub
Канал: `chat:{conference_id}` (UUID конференции).
**Publisher:** `services/chat.py:ChatService.persist_and_publish()` после INSERT в БД.
**Subscribers:** все подключённые WebSocket-клиенты конференции (несколько инстансов backend в балансировке).
### Таблица chat_messages
См. [docs/db/schema.md](../db/schema.md#chat_messages).
Строка: `(id, session_id, user_id, guest_access_id, author_name, text, created_at)`.
### Тоггл в JoinOut
При входе в конференцию (`POST /api/v1/conferences/{id}/join` или `/guest-join`) ответ включает:
```json
{
"livekit_url": "...",
"token": "...",
"room_name": "...",
"conference_id": "550e8400-e29b-41d4-a716-446655440000",
"chat_enabled": true
}
```
Фронт проверяет `chat_enabled` перед рендерингом UI панели чата.
## Ссылки
- [Database Schema — chat_messages](../db/schema.md#chat_messages)
- [Instance Settings](../db/schema.md#instance_settings)
- [Backend Code — api/chat.py](../../backend/api/chat.py)
- [Backend Code — services/chat.py](../../backend/services/chat.py)
- [Frontend Component — useChat.ts](../../frontend/src/hooks/useChat.ts)
- [Frontend Component — ChatPanel.tsx](../../frontend/src/components/room/ChatPanel.tsx)

540
docs/api/conferences.md Normal file
View File

@@ -0,0 +1,540 @@
# API конференций
Полная справка по эндпоинтам динамических конференций (см. ADR-001). Конференции создаются мгновенно или планируются; вместо предустановленных комнат и бронирований.
## Обзор
**Конференции** — это постоянные сущности (номер, ссылка, владелец). Каждый запуск создаёт сеанс (`ConferenceSession`) — единицу AI-пайплайна.
- **Номер:** 9 десятичных цифр, уникален, генерируется с retry
- **Ссылка:** `slug = secrets.token_urlsafe(8)` (11 base64url-символов), URL: `/j/{slug}`
- **Доступ:** По номеру или ссылке (публичные эндпоинты без auth, rate limit 10/мин)
- **Гости:** Представляются при входе (имя обязательно, email факультативен для саммари)
## Эндпоинты
### POST /api/v1/conferences
**Создать конференцию.**
Без `scheduled_at` → мгновенная (создатель входит сразу); с `scheduled_at` → плановая.
**Требует auth:** JWT Bearer token
**Request body:**
```json
{
"title": "Встреча Q3",
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"is_pinned": true,
"recurrence": {
"type": "weekly",
"weekdays": [0, 2, 4],
"time_local": "14:00",
"timezone": "Europe/Moscow",
"anchor_date": "2026-07-20",
"duration_minutes": 60
},
"is_closed": false,
"password": null,
"summary_recipients": null
}
```
**Поля:**
- `title` (str, опционально, ≤255 символов) — название конференции
- `scheduled_at` (ISO 8601 UTC, опционально) — время запуска; если пусто → мгновенная (status=active)
- `duration_minutes` (int, опционально, >0) — ожидаемая длительность (не обязывает)
- `is_pinned` (bool, опционально, default=false) — закреплённая ли (видна в "Моих конференциях")
- `recurrence` (RecurrenceRule, опционально) — только если is_pinned=true; см. ниже
- `is_closed` (bool, опционально, default=false) — требуется пароль для входа
- `password` (str, опционально, ≥4 символа) — обязателен если is_closed=true
- `summary_recipients` (str, опционально: `'all'`, `'owner'`, или null) — переопределение рассылки саммари конкретной конференции; null = использовать дефолт инстанса
- `participants` (array, опционально) — список приглашённых; каждый элемент: `{"user_id": "uuid" или null, "email": "string" или null}`. Организатор добавляется автоматически (не требуется в массиве, но если передан — дедуплицируется). Внешние email'ы приглашаются отдельно
**RecurrenceRule (JSONB в БД):**
```json
{
"type": "weekly|biweekly|monthly|every_n_days",
"weekdays": [0, 1, 2, 3, 4, 5, 6],
"day_of_month": null,
"interval_days": null,
"anchor_date": "2026-07-20",
"time_local": "HH:MM",
"timezone": "IANA (e.g. Europe/Moscow)",
"duration_minutes": 60
}
```
- **weekly:** `weekdays` обязателен (0=пн, 6=вс), список непустой
- **biweekly:** `weekdays` обязателен, повтор через 2 недели от anchor_date
- **monthly:** `day_of_month` обязателен (1..31; 31 → последний день короткого месяца)
- **every_n_days:** `interval_days` обязателен (≥1)
**Response (201 Created):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"number": "123456789",
"slug": "abc123456",
"title": "Встреча Q3",
"status": "active|scheduled|ended",
"is_pinned": true,
"is_closed": false,
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"recurrence": { /* RecurrenceRule */ },
"summary_recipients": null,
"next_occurrence": "2026-07-22T14:00:00Z",
"created_at": "2026-07-16T12:00:00Z",
"join": {
"livekit_url": "wss://livekit.example.com",
"token": "eyJh...",
"room_name": "abc123456",
"conference_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
Мгновенная конференция: `status=active`, `join` заполнено. Плановая: `status=scheduled`, `join=null`.
**Коды ошибок:**
- `422``closed_conference_requires_password` (is_closed но нет password)
- `422``recurrence_requires_pinned` (recurrence но is_pinned=false)
- `422``scheduled_at_in_the_past` (дата в прошлом)
---
### GET /api/v1/conferences/my
**Мои конференции: закреплённые + предстоящие разовые владельца ИЛИ приглашённого.**
С 2026-07-20 (решение поверх ADR-003) в выдачу попадает конференция, если
текущий пользователь:
- владелец (`owner_id == user.id`), **ИЛИ**
- приглашён по `user_id` (строка `conference_invitees` с `user_id == user.id`), **ИЛИ**
- приглашён по email (строка `conference_invitees` с `lower(email) == lower(email пользователя)`
— внешнее приглашение на адрес, под которым человек впоследствии зарегистрировался).
Критерии показа не меняются (закреплённая — безусловно; разовая — `status=scheduled`
и `scheduled_at` в будущем), меняется только круг «чья» конференция. Для
приглашённого `is_owner=false`, `organizer_name` — имя фактического владельца;
`participants` не заполняется (как и для владельца — список не раздувает состав).
**Требует auth:** JWT Bearer token
**Query parameters:** нет
**Response (200 OK):**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"number": "123456789",
"slug": "abc123456",
"title": "Встреча Q3",
"status": "scheduled",
"is_pinned": true,
"is_closed": false,
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"recurrence": { /* RecurrenceRule */ },
"summary_recipients": null,
"next_occurrence": "2026-07-22T14:00:00Z",
"created_at": "2026-07-16T12:00:00Z",
"join": null
}
]
```
---
### GET /api/v1/conferences/calendar
**Развёртка вхождений (occurrences) конференций в диапазоне дат — владельца ИЛИ приглашённого.**
Тот же принцип видимости, что у `GET /conferences/my` (см. выше, решение от
2026-07-20): вхождения строятся по конференциям, где пользователь владелец
или приглашён (по `user_id` или по `lower(email)`).
**Требует auth:** JWT Bearer token
**Query parameters:**
- `from` (ISO 8601 UTC) — начало диапазона
- `to` (ISO 8601 UTC) — конец диапазона
- Максимальная ширина: 62 дня; иначе 422
**Response (200 OK):**
```json
[
{
"conference_id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Встреча Q3",
"starts_at": "2026-07-20T14:00:00Z",
"ends_at": "2026-07-20T15:00:00Z",
"number": "123456789",
"slug": "abc123456",
"is_pinned": true,
"is_closed": false
}
]
```
---
### GET /api/v1/conferences/resolve
**Найти конференцию по номеру или ссылке — публичный эндпоинт без auth.**
**Rate limit:** 10 запросов в минуту на IP
**Query parameters:**
- `q` (str, ≥1 символ) — номер (9 цифр) или slug
**Response (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Встреча Q3",
"status": "active|scheduled|ended",
"is_closed": false,
"requires_password": false
}
```
**Коды ошибок:**
- `404` — не найдена (живая, мёртвая или несуществующая — единообразный ответ для безопасности)
- `429` — exceeded rate limit
---
### POST /api/v1/conferences/{conference_id}/join
**Вход зарегистрированного пользователя в конференцию.**
**Требует auth:** JWT Bearer token
**Path parameters:**
- `conference_id` (UUID)
**Request body:**
```json
{
"password": "optional_password"
}
```
**Response (200 OK):**
```json
{
"livekit_url": "wss://livekit.example.com",
"token": "eyJh...",
"room_name": "abc123456",
"conference_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Коды ошибок:**
- `404` — conference_not_found
- `403` — password_required (закрытая конференция без пароля в теле запроса)
- `403` — invalid_password
- `410` — conference_ended (статус=ended)
---
### POST /api/v1/conferences/{conference_id}/guest-join
**Вход гостем: представиться (имя обязательно) — публичный, без auth.**
**Rate limit:** 10 запросов в минуту на IP
**Path parameters:**
- `conference_id` (UUID)
**Request body:**
```json
{
"display_name": "Иван Иванов",
"email": "ivan@example.com",
"password": "optional_password"
}
```
**Поля:**
- `display_name` (str, 1..255) — обязателен
- `email` (EmailStr, опционально) — для рассылки саммари
- `password` (str, опционально) — для закрытых конференций
**Response (200 OK):**
```json
{
"livekit_url": "wss://livekit.example.com",
"token": "eyJh...",
"room_name": "abc123456",
"conference_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Коды ошибок:**
- `404` — conference_not_found
- `403` — password_required
- `403` — invalid_password
- `410` — conference_ended
- `429` — exceeded rate limit
- `422` — validation_error (display_name пуст, email некорректен)
---
### PATCH /api/v1/conferences/{conference_id}
**Изменить конференцию — только владелец или администратор.**
**Требует auth:** JWT Bearer token
**Path parameters:**
- `conference_id` (UUID)
**Request body:** все поля опциональны (как ConferenceCreateIn, но PATCH)
```json
{
"title": "Новое название",
"scheduled_at": "2026-07-25T15:00:00Z",
"duration_minutes": 90,
"is_pinned": true,
"recurrence": null,
"is_closed": false,
"password": null
}
```
**Response (200 OK):** обновленная ConferenceOut
**Коды ошибок:**
- `404` — conference_not_found
- `403` — not_owner
- `422` — invalid_conference_state (например, попытка добавить recurrence к неживой конференции)
---
### DELETE /api/v1/conferences/{conference_id}
**Удалить конференцию — только владелец или администратор.**
**Требует auth:** JWT Bearer token
**Path parameters:**
- `conference_id` (UUID)
**Response (204 No Content)** — без тела
**Коды ошибок:**
- `404` — conference_not_found
- `403` — not_owner
- `409` — conference_active (активную конференцию нельзя удалить, дождитесь завершения)
---
### GET /api/v1/conferences/{conference_id}
**Получить детальную информацию о конференции (с полным составом участников) — владелец, администратор ИЛИ приглашённый.**
Приглашённый определяется так же, как в `GET /my`/`GET /calendar`: строка
`conference_invitees` с `user_id == user.id` либо с `lower(email) == lower(email
пользователя)`. Это расширение касается ТОЛЬКО чтения — `PATCH`/`DELETE`
по-прежнему разрешены только владельцу или администратору (403 приглашённому).
**Требует auth:** JWT Bearer token
**Path parameters:**
- `conference_id` (UUID)
**Response (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"number": "123456789",
"slug": "abc123456",
"title": "Встреча Q3",
"status": "scheduled",
"is_pinned": true,
"is_closed": false,
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"recurrence": { "type": "weekly", "weekdays": [0, 2, 4] },
"summary_recipients": "all",
"owner_id": "550e8400-e29b-41d4-a716-446655440000",
"is_owner": true,
"organizer_name": "Иван Иванов",
"participants": [
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "ivan@example.com",
"name_user": "Иван Иванов",
"avatar_url": "/media/avatars/550e8400-e29b-41d4-a716-446655440000.jpg"
},
{
"user_id": null,
"email": "external@example.com",
"name_user": null,
"avatar_url": null
}
],
"created_at": "2026-07-16T12:00:00Z"
}
```
**Поля:**
- `owner_id` — UUID владельца конференции
- `is_owner` — true если текущий пользователь владелец
- `organizer_name` — имя организатора (берётся из `users.name_user`)
- `participants` — массив приглашённых (зарегистрированные и внешние). **Заполняется только в этом эндпоинте**; в GET /my и /calendar пустой массив.
**Коды ошибок:**
- `404` — conference_not_found
- `403` — not_owner (пользователь не владелец, не администратор и не приглашён)
---
## Статусы конференции
| Статус | Описание |
|--------|---------|
| `scheduled` | Плановая, время ещё не наступило или никто не вошёл |
| `active` | В настоящий момент идёт (кто-то вошёл или прошло scheduled_at) |
| `ended` | Завершена; истории, фразы, саммари остаются; вход возвращает 410 |
Переходы:
- Мгновенная создание → `active` сразу
- Плановая создание → `scheduled`
- Webhook `room_started``active`
- Webhook `room_finished``ended` (если не pinned) или `scheduled` (если pinned)
- Beat-задача → `ended` если истекло `scheduled_at + duration_minutes` и никто не входил
---
## LiveKit identity
Интеграция с LiveKit на уровне webhook'ов и токенов:
- **Зарегистрированный:** `identity = str(user_id)`, `name = user.name_user`
- **Гость:** `identity = "guest:{guest_access.id}"`, `name = guest_access.display_name`
Email гостя в LiveKit не передаётся (PII не утекает участникам).
---
## Ошибки и коды HTTP
| Код | Описание |
|-----|---------|
| `200` | OK |
| `201` | Created (POST конференции) |
| `204` | No Content (DELETE) |
| `400` | Bad Request (некорректный JSON) |
| `401` | Unauthorized (JWT missing или invalid) |
| `403` | Forbidden (password_required, invalid_password, not_owner) |
| `404` | Not Found (конференция не существует или не найдена) |
| `409` | Conflict (conference_active — нельзя удалить активную) |
| `410` | Gone (conference_ended — вход в завершённую) |
| `422` | Unprocessable Entity (валидация, invalid range на /calendar) |
| `429` | Too Many Requests (rate limit на /resolve, /guest-join) |
---
## Примеры запросов
### Создать мгновенную конференцию
```bash
curl -X POST http://localhost:8000/api/v1/conferences \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Quick call"}'
```
### Создать плановую с повторением (еженедельно по пн/ср/пт)
```bash
curl -X POST http://localhost:8000/api/v1/conferences \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Q3 standup",
"scheduled_at": "2026-07-21T09:00:00Z",
"duration_minutes": 30,
"is_pinned": true,
"is_closed": true,
"password": "secret123",
"recurrence": {
"type": "weekly",
"weekdays": [0, 2, 4],
"time_local": "09:00",
"timezone": "Europe/Moscow",
"anchor_date": "2026-07-21",
"duration_minutes": 30
}
}'
```
### Вход по номеру (публичный, без auth)
```bash
curl http://localhost:8000/api/v1/conferences/resolve?q=123456789
```
### Вход гостем
```bash
curl -X POST http://localhost:8000/api/v1/conferences/550e8400-e29b-41d4-a716-446655440000/guest-join \
-H "Content-Type: application/json" \
-d '{
"display_name": "Иван",
"email": "ivan@example.com",
"password": "secret123"
}'
```
---
## Рассылка приглашений
При создании (POST) или изменении (PATCH) конференции отправляются .ics-приглашения:
**Получатели:**
- Организатор (владелец конференции, автоматически)
- Все приглашённые в `participants` (зарегистрированные пользователи и внешние email'ы)
**Содержимое письма:**
- Тема: `"Приглашение: <название конференции>"`
- Вложение `.ics` (iCalendar формат)
- `METHOD:REQUEST` — приглашение
- `VTIMEZONE` — часовой пояс инстанса (для корректного отображения)
- `RRULE` — правило повтора (для закреплённых конференций с `recurrence`)
- `SEQUENCE` — номер версии события
**Логика рассылки:**
- **Создание (POST):** Рассылка всем в `participants` и организатору. `SEQUENCE=0`.
- **Изменение расписания (PATCH `scheduled_at`, `duration_minutes`, `recurrence`, `title`):** Инкрементируется `SEQUENCE`, рассылка повторяется.
- **Изменение только состава участников (PATCH `participants`):** Рассылка отправляется, но `SEQUENCE` **не меняется** (календарные клиенты игнорируют обновления с неизменённым SEQUENCE).
- **Прочие изменения (пароль, `is_closed`, `summary_recipients`):** Рассылка **не отправляется**.
**Внешние приглашённые:**
- Письма отправляются на email'ы без требования аутентификации.
- При входе по ссылке гост вводит имя и опционально email.
- Если email гостя совпадает с приглашённым — он может получить саммари (зависит от `summary_recipients`).
---
## Примечания
- Все `datetime` сохраняются и возвращаются в UTC (ISO 8601, суффикс `Z`)
- Преобразование в локальное время — на клиенте по таймзоне браузера
- `.ics` экспорт включает `VTIMEZONE` для правильной конвертации (`services/ics.py`)
- Номер и slug неизменны всю жизнь конференции и не переиспользуются
- `recurrence` хранится как JSONB (можно запросить всё, развёртка только в памяти)
---
## Ссылки
- [Architecture Overview](../architecture/README.md) — система компонентов
- [Database Schema](../db/schema.md) — таблицы `conferences`, `conference_sessions`, `guest_access`
- [ADR-001: Динамические конференции вместо бронирований](../architecture/adr/001-dynamic-conferences-pivot.md) — обоснование решения
- [Recurrence Service](../../backend/services/recurrence.py) — реализация развёртки

111
docs/api/teams.md Normal file
View File

@@ -0,0 +1,111 @@
# Справочник команд
Публичный API для получения списка команд. Для CRUD операций над командами см. [Администраторский API](./admin.md#команды).
## Быстрый старт
```bash
# Получить список команд
curl http://localhost:8000/api/v1/teams \
-H "Authorization: Bearer $TOKEN"
```
## Эндпоинты
### GET /api/v1/teams
**Получить полный справочник команд (отсортирован по названию).**
**Требует auth:** JWT Bearer token
**Response (200 OK):**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Backend"
},
{
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"name": "Frontend"
},
{
"id": "6ba7b811-9dad-11d1-80b4-00c04fd430c9",
"name": "DevOps"
}
]
```
**Поля:**
- `id` — UUID команды
- `name` — название (уникально)
**Примечание:** Список возвращается для **всех аутентифицированных пользователей** (не зависит от роли). Этот эндпоинт используется:
- На странице профиля пользователя для выбора своей команды (PATCH /api/v1/users/me)
- При создании конференции с приглашением участников (информационно, для фронтенда)
- На администраторской странице управления пользователями
---
## Использование
### На странице профиля
Пользователь может выбрать команду из этого справочника и назначить себе через:
```bash
curl -X PATCH http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"team_id":"550e8400-e29b-41d4-a716-446655440000"}'
```
### Администратор
Администратор может управлять командами (создание, редактирование, удаление) через [Администраторский API](./admin.md#команды):
```bash
# Создать команду
curl -X POST http://localhost:8000/api/v1/admin/teams \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"QA"}'
# Переименовать команду
curl -X PATCH http://localhost:8000/api/v1/admin/teams/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"QA & Testing"}'
# Удалить команду
curl -X DELETE http://localhost:8000/api/v1/admin/teams/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $ADMIN_TOKEN"
```
---
## Типы данных
### TeamOut
```json
{
"id": "uuid",
"name": "string"
}
```
---
## Примечания
- **Привязка опциональна:** пользователь может работать без команды (`team_id` = null в профиле)
- **Каскадное удаление:** при удалении команды пользователи, состоявшие в ней, остаются (ON DELETE SET NULL), их `team_id` становится null
- **Выбор при регистрации:** настройка `registration_team_choice` (в администраторских настройках) определяет, виден ли выбор команды на публичной форме регистрации
---
## Ссылки
- [Профиль пользователя](./users.md) — использует этот справочник для выбора команды
- [Администраторский API / Команды](./admin.md#команды) — CRUD операции (только админ)
- [Аутентификация / Опции регистрации](./auth.md#get-apiv1authregistration-options) — включает список команд если выбор включён

308
docs/api/users.md Normal file
View File

@@ -0,0 +1,308 @@
# Профиль и аватар пользователя
Эндпоинты управления профилем, загрузкой аватаров и поиска пользователей.
## Быстрый старт
```bash
# 1. Получить профиль
curl http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer $TOKEN"
# 2. Обновить ФИО и команду
curl -X PATCH http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name_user":"Новое имя","team_id":"uuid-team"}'
# 3. Загрузить аватар
curl -X POST http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/photo.jpg"
# 4. Удалить аватар
curl -X DELETE http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN"
# 5. Сменить пароль
curl -X POST http://localhost:8000/api/v1/users/me/password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password":"oldPassword123","new_password":"newSecurePassword456"}'
# 6. Поиск пользователей (для пикера участников)
curl 'http://localhost:8000/api/v1/users?q=john' \
-H "Authorization: Bearer $TOKEN"
# 7. Получить справочник команд
curl http://localhost:8000/api/v1/teams \
-H "Authorization: Bearer $TOKEN"
```
## Эндпоинты
### GET /api/v1/users/me
**Получить профиль текущего аутентифицированного пользователя.**
**Требует auth:** JWT Bearer token
**Response (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name_user": "Иван Петров",
"team_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"team_name": "Backend",
"avatar_path": "avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
"avatar_url": "/media/avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
"created_at": "2026-07-16T12:00:00Z"
}
```
**Поля:**
- `email` — адрес электронной почты (read-only)
- `name_user` — отображаемое имя (редактируемое)
- `team_id` — UUID команды (опционально, редактируемое)
- `team_name` — имя команды (null если не привязан)
- `avatar_path` — путь к файлу аватара (null если нет)
- `avatar_url` — готовый URL для отображения; при отсутствии аватара фронтенд показывает заглушку с инициалами
---
### PATCH /api/v1/users/me
**Изменить ФИО и/или команду текущего пользователя; email — read-only.**
**Требует auth:** JWT Bearer token
**Request body:** (все поля опциональны)
```json
{
"name_user": "Новое имя",
"team_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
}
```
**Поля:**
- `name_user` (str, 1..255) — новое ФИО
- `team_id` (UUID или null) — переназначить команду; явное `null` снимает привязку; отсутствие поля команду не трогает
**Response (200 OK):** обновленный профиль (как GET /me)
**Коды ошибок:**
- `404` — team_not_found (если передан несуществующий `team_id`)
- `422` — Unprocessable Entity (валидация: name_user пуст и т.д.)
---
### POST /api/v1/users/me/avatar
**Загрузить аватар текущего пользователя.**
**Требует auth:** JWT Bearer token
**Request body:** `multipart/form-data`
- `file` — файл изображения (обязателен)
**Требования:**
- **Формат:** JPEG, PNG или WebP (проверяется по magic bytes, не по расширению)
- **Максимальный размер:** 2 МБ
- Старый аватар автоматически удаляется при загрузке нового
**Response (200 OK):** обновленный профиль (как GET /me, с новым `avatar_url`)
**Коды ошибок:**
- `413` — avatar_too_large (файл больше 2 МБ)
- `415` — avatar_invalid_type (не JPEG/PNG/WebP; проверяется по magic bytes)
- `422` — Unprocessable Entity (невалидный multipart и т.д.)
**Примеры:**
```bash
# JPEG
curl -X POST http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "file=@photo.jpg"
# PNG
curl -X POST http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "file=@photo.png"
# WebP
curl -X POST http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "file=@photo.webp"
```
---
### DELETE /api/v1/users/me/avatar
**Удалить аватар текущего пользователя.**
**Требует auth:** JWT Bearer token
**Response (204 No Content)** — без тела
**Примечание:** После удаления `avatar_path` становится `null`, фронтенд отображает заглушку с инициалами.
---
### POST /api/v1/users/me/password
**Сменить пароль текущего пользователя.**
**Требует auth:** JWT Bearer token
**Request body:**
```json
{
"current_password": "oldPassword123",
"new_password": "newSecurePassword456"
}
```
**Поля:**
- `current_password` (str) — текущий пароль (обязательно, для верификации)
- `new_password` (str, ≥8) — новый пароль (политика как при регистрации: минимум 8 символов)
**Response (204 No Content)** — без тела
**Коды ошибок:**
- `400` — invalid_current_password (текущий пароль не совпадает)
- `422` — Unprocessable Entity (новый пароль короче 8 символов)
**Примечание:** Refresh-сессии сознательно **не отзываются** (см. ADR-005 `docs/architecture/adr/005-password-reset-deferred.md`) — пользователь останется залогинен на других устройствах. Массовый отзыв всех сессий появится вместе со сбросом пароля по email (v0.1.0).
---
### GET /api/v1/users
**Поиск пользователей для пикера участников конференции.**
**Требует auth:** JWT Bearer token
**Query параметры:**
- `q` (str, опционально) — текст для поиска (по имени и email)
**Поведение:**
- Без `q` — возвращает полный список (с ограничением ~20 результатов)
- С `q` — полнотекстовый поиск (также ~20 результатов)
- Поиск регистронезависим
**Response (200 OK):**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "ivan@example.com",
"name_user": "Иван Петров",
"avatar_url": "/media/avatars/550e8400-e29b-41d4-a716-446655440000.jpg"
},
{
"id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8",
"email": "maria@example.com",
"name_user": "Мария Сидорова",
"avatar_url": null
}
]
```
**Примеры:**
```bash
# Полный список
curl http://localhost:8000/api/v1/users \
-H "Authorization: Bearer $TOKEN"
# Поиск по имени
curl 'http://localhost:8000/api/v1/users?q=ivan' \
-H "Authorization: Bearer $TOKEN"
# Поиск по email
curl 'http://localhost:8000/api/v1/users?q=example.com' \
-H "Authorization: Bearer $TOKEN"
```
---
## Аватары
### Хранение
Аватары хранятся в каталоге `MEDIA_ROOT/avatars/`:
```
MEDIA_ROOT/
└── avatars/
├── 550e8400-e29b-41d4-a716-446655440000.jpg
├── 6ba7b811-9dad-11d1-80b4-00c04fd430c8.png
└── ...
```
**Адресация:**
- Backend сохраняет: `user.avatar_path = "avatars/{user_id}.{ext}"`
- Frontend отображает: `avatar_url = "/media/avatars/{user_id}.{ext}"`
- Nginx раздаёт напрямую (location `/media/` → alias `MEDIA_ROOT`)
### Валидация
**Magic bytes (не расширение):**
- JPEG: `FF D8 FF`
- PNG: `89 50 4E 47`
- WebP: `52 49 46 46 ... 57 45 42 50`
**Размер:** max 2 МБ (413 Payload Too Large)
### Заглушка (дефолт)
Если `avatar_path` = `null`:
- Фронтенд отображает цветную заглушку с инициалами (2 буквы ФИО)
- Цвет выбирается детерминированно по хэшу `user_id` (всегда одинаковый для пользователя)
---
## Типы данных
### UserProfileOut
```json
{
"id": "uuid",
"email": "string",
"name_user": "string",
"team_id": "uuid or null",
"team_name": "string or null",
"avatar_path": "string or null",
"avatar_url": "string or null",
"created_at": "datetime"
}
```
### UserListItemOut
```json
{
"id": "uuid",
"email": "string",
"name_user": "string",
"avatar_url": "string or null"
}
```
---
## Примечания
- **Email read-only:** адрес электронной почты менять нельзя (требует переверификации, которая не реализована в v0.0.1)
- **Таймзона:** все `created_at` сохраняются в UTC (ISO 8601)
- **Команда опциональна:** пользователь может работать без привязки к команде (`team_id` = null)
- **Безопасность:** пароли никогда не передаются в API; для смены пароля используйте `POST /api/v1/users/me/password`. Сброс пароля по email появится в v0.1.0 (см. ADR-005)
---
## Ссылки
- [Справочник команд](./teams.md) — GET /api/v1/teams
- [Администраторский API](./admin.md) — GET/PATCH /api/v1/admin/users/{id}, POST /api/v1/admin/users/{id}/avatar
- [Аутентификация](./auth.md) — регистрация, логин
- [Конференции](./conferences.md) — параметр `participants` при создании/редактировании

535
docs/architecture/README.md Normal file
View File

@@ -0,0 +1,535 @@
# Архитектура
Высокоуровневый обзор дизайна системы VidConf.
## Быстрые ссылки
- **[frontend-themes.md](./frontend-themes.md)** — архитектура темы оболочки: механизм `data-theme`, localStorage, React-hook, анти-FOUC, инвариант комнаты.
## Архитектура системы
```
┌─────────────────────────────────────────────────────────────┐
│ Web Browsers │
│ (React SPA + LiveKit JS SDK) │
└──────────────────────────┬──────────────────────────────────┘
│ HTTPS
┌──────▼───────┐
│ Nginx │ (TLS termination)
│ Reverse │
│ Proxy │
└──────┬───────┘
┌──────────────────┼──────────────────┐
│ │ │
┌────▼────┐ ┌──────▼──────┐ ┌─────▼─────┐
│ FastAPI │ │ LiveKit │ │ Coturn │
│ Backend │ │ SFU │ │ TURN │
└────┬────┘ └──────┬──────┘ └───────────┘
│ │
│ (TCP/Postgres) │ (RTP/SRTP WebRTC)
┌────▼──────────────────┘
├──► PostgreSQL 16
│ - 14 таблиц: users, teams, conferences, conference_sessions и т.д.
├──► Redis
│ - Celery broker
│ - Rate limiting, refresh-токены, pub/sub чата
└──► Celery воркеры (раздельные очереди)
├─► transcription (faster-whisper small/medium/large-v3 → фразы)
├─► summarize (Qwen3.5 4B/9B/35B-A3B → резюме, 3 уровня)
└─► notify (email + .ics → SMTP)
```
## Основные компоненты
### 1. Frontend (React SPA)
**Location:** `frontend/`
**Tech:** React 18 + TypeScript + Vite, Tailwind CSS 4 + @tailwindcss/vite, shadcn/ui, `@livekit/components-react`, FullCalendar
**Функциональность:**
- Аутентификация (вход, регистрация, верификация email), JWT (access — заголовок Authorization, refresh — httpOnly cookie)
- Лобби-хаб, календарь (создание/редактирование/отмена конференций, повторение), «мои конференции»
- Вход в конференцию по номеру/ссылке, гостевой вход
- Комната конференции: видео/аудио через LiveKit JS SDK, чат в реальном времени, настройки устройств, fullscreen, Picture-in-Picture, демонстрация экрана
- Профиль пользователя (ФИО, команда, аватар, смена пароля)
- Админ-панель (конференции, пользователи, команды, настройки инстанса)
- Светлая/тёмная тема оболочки (см. [frontend-themes.md](./frontend-themes.md))
**Коммуникация:**
- HTTP + WebSocket → FastAPI backend (`/api/v1/*`)
- WebRTC → LiveKit SFU
Подробнее: [frontend/README.md](../../frontend/README.md).
---
### 2. Backend (FastAPI)
**Location:** `backend/`
**Tech:** Python 3.12, FastAPI, SQLAlchemy 2.0 async, Alembic
**Реализовано:**
- Схема БД + миграции (14 таблиц + расширение `btree_gist`, установлено, но не используется текущими constraint'ами)
- Контракты плагинов (Transcriber, Summarizer) + factory с реестром (`@register_transcriber`, `@register_summarizer`)
- `NullTranscriber`, `NullSummarizer` (no-op), `FasterWhisperCPU`/`FasterWhisperGPU`, `QwenLocal`
- JWT аутентификация (вход, регистрация, верификация email, refresh, logout), OAuth2 password flow
- Генерация LiveKit токенов, приём и обработка webhook-событий LiveKit (room_started, participant_joined/left, room_finished)
- Динамические конференции: создание (мгновенное/плановое), календарь, вход по номеру/ссылке/паролю, гостевой вход, повторение
- Приглашения на конференцию (пользователь или внешний email), рассылка .ics
- Чат конференции (WebSocket, история, broadcast через Redis pub/sub)
- Администрирование: конференции, пользователи, команды, настройки инстанса
- Endpoint `GET /api/health` (проверка БД/Redis), `GET /metrics` (Prometheus)
**API структура:**
```
/api/v1/
├── auth/ register, verify-email, token, refresh, logout, registration-options
├── users/ me (GET/PATCH), me/avatar (POST/DELETE), me/password, поиск (GET ?q=)
├── teams/ справочник команд (GET)
├── conferences/ POST create, GET /my, GET /calendar, GET /resolve,
│ POST /{id}/join, POST /{id}/guest-join, GET/PATCH/DELETE /{id}
├── conferences/{id}/chat WebSocket-чат
├── admin/ conferences, users, teams, settings
└── livekit/ webhook приёмник
/api/health статус системы (БД, Redis)
/metrics метрики Prometheus
```
Полная спецификация: [docs/api/README.md](../api/README.md).
---
### 3. Database (PostgreSQL 16)
**Location:** миграции в `backend/alembic/versions/` (9 миграций)
**Основные таблицы:**
| Таблица | Назначение |
|---------|---------|
| `users` | Зарегистрированные пользователи (email, password_hash, role, is_blocked, team_id, avatar_path) |
| `teams` | Справочник команд |
| `email_verification_tokens` | Одноразовые токены верификации email |
| `conferences` | Постоянные сущности конференций (номер, slug, владелец, статус, recurrence, summary_recipients, ics_sequence) |
| `conference_invitees` | Приглашённые на конференцию (user_id ИЛИ email) |
| `guest_access` | Гости, представившиеся при входе (display_name, email) |
| `conference_sessions` | Один запуск конференции — единица AI-пайплайна (t_start, t_end, pipeline_status, summary_data) |
| `conference_participants` | Отслеживание участия в сеансе (user_id ИЛИ guest_id, session_id, joined_at, left_at) |
| `session_audio_tracks` | Аудиотреки сеанса (per-track запись, статус транскрибации, segments JSONB) |
| `phrases` | Текстовые сегменты транскрибации (participant_id, session_id, t_start, t_end) |
| `chat_messages` | Сообщения в сеансе (session_id, автор — user_id или guest_access_id, author_name) |
| `email_deliveries` | Журнал отправленных писем (саммари, приглашения) — идемпотентность рассылки |
| `instance_settings` | Настройки инстанса (key-value JSONB): AI-уровень, чат, таймзона, режим рассылки, опции регистрации |
| `livekit_webhook_events` | Журнал webhook-событий для идемпотентности (event_id, event_type, received_at) |
**Ключевые особенности:**
- **UUID первичные ключи** (кроме `phrases`/`chat_messages` → BIGINT Identity)
- **Все времена в UTC** (`timestamptz`)
- **Номер (9 цифр) и slug (base64url)** конференции — публичные идентификаторы доступа
- **Recurrence как JSONB** (не RRULE; 4 типа: weekly/biweekly/monthly/every_n_days)
- **Гости как полноценные участники** (`guest_id` в `conference_participants`)
См. [docs/db/schema.md](../db/schema.md) — полная ER-диаграмма и обоснования дизайна.
---
### 4. LiveKit SFU
**Profile compose:** `media`
**Tech:** LiveKit (Go-based SFU)
**Роль:**
- SFU (Selective Forwarding Unit) для WebRTC
- Per-track аудио (один трек = один спикер, исключает необходимость диаризации)
- Запись треков через Egress (`.ogg`, opus) для последующей транскрибации
- FastAPI генерирует LiveKit-токены (JWT, TTL 6 часов, per-room), браузер подключается через LiveKit JS SDK
---
### 5. Celery Workers
**Location:** `workers/`
**Tech:** Celery + Redis (message broker), раздельные очереди (`transcription`/`summarize`/`notify`)
**Задачи:**
| Задача | Вход | Процесс | Выход |
|--------|-------|---------|--------|
| `run_pipeline()` | session_id | Диспетчер + транскрибация (faster-whisper) + реконструкция фраз | `session_audio_tracks.segments`, `phrases` |
| `summarize_session()` | session_id | Map-reduce Qwen3.5 (уровень из instance_settings) | `conference_sessions.summary_data` |
| `notify_session()` | session_id | Генерация email + .ics, рассылка по режиму (all/owner) | `email_deliveries` |
| `send_invitations()` | conference_id, emails | Генерация .ics, рассылка приглашений | `email_deliveries` |
| `cleanup_conferences()` (beat) | — | Закрытие зависших сеансов, завершение просроченных конференций | — |
| `recover_stuck_summaries()` / `recover_stuck_notifications()` (beat) | — | Переставить зависшую `summarize_session`/`notify_session` в очередь | завершение пайплайна |
**State machine pipeline_status:**
```
recording (создана при t_start сеанса, LiveKit Egress записывает)
↓ (webhook room_finished → enqueue run_pipeline в очередь transcription)
transcribing (транскрибация faster-whisper + реконструкция фраз)
summarizing (map-reduce Qwen3.5 → очередь summarize; summary_data записан, статус не меняется)
↓ (notify_session отправляет уведомления → очередь notify)
notified
↓ Успех: история сохранена в БД
↘ (при ошибке на любом шаге)
↘ failed
↘ Лог; beat-задачи recover_stuck_* могут переставить зависший шаг
```
**Переход в notified:**
- Собрать получателей по эффективному режиму рассылки (`conference.summary_recipients or cfg.summary_recipients`)
- Режим `'all'` → участники сеанса (users) + гости с email
- Режим `'owner'` → email владельца конференции
- Отправить письмо для каждого нового адреса (таблица `email_deliveries` обеспечивает идемпотентность)
- Переход в `notified` независимо от того, были ли получатели
**Идемпотентность:** каждый шаг проверяет `pipeline_status` перед началом и пропускает уже пройденные шаги.
Подробнее: [workers/README.md](../../workers/README.md).
---
### 6. AI Plugins (Strategy Pattern)
**Location:** `backend/core/plugins/`
**Interfaces:**
- `Transcriber.transcribe(audio_path, language) → list[Segment]`
- `Summarizer.summarize(transcript) → str`
**Factory:**
- Registry: `@register_transcriber`, `@register_summarizer`
- Configuration: `config/plugins.yaml`
- No-op defaults: `NullTranscriber`, `NullSummarizer`
**Расширяемость:**
- Добавить нового провайдера = новый класс + строка в конфиге
- Никаких изменений в основном коде
См. [docs/plugins/contracts.md](../plugins/contracts.md).
---
### 7. Nginx Reverse Proxy
**Конфиг:** `deploy/nginx.conf`
**Задачи:**
- Завершение TLS
- Обслуживание статических файлов (frontend SPA)
- Маршрутизация запросов API → FastAPI
- Проксирование WebSocket (чат конференции)
- Сжатие Gzip
---
## Поток данных
### 1. Пользователь входит в конференцию
```
Browser
│ 1. POST /api/v1/conferences/{id}/join (пароль опционально)
├─────────────────────────────────► FastAPI
│ │
│ 2. Проверка доступа, генерация LiveKit-токена
│ │
│ 3. {"token": "...", "url": "...", "chat_enabled": true}
◄─────────────────────────────────────┤
4. Подключение через LiveKit JS SDK
├────────────────► LiveKit SFU
│ │
│ 5. Запись аудио per-track через Egress (при завершении сеанса)
└ (video/audio stream) ──────────►
```
БД: INSERT `conference_participants` (user_id ИЛИ guest_id, session_id, joined_at).
---
### 2. Конференция заканчивается → пост-обработка
```
room_finished webhook
1. Backend помечает завершение записи, ожидает завершения egress
2. enqueue_pipeline(session_id) → Redis, очередь `transcription`
3. run_pipeline(session_id) — диспетчер пайплайна
├─► Ожидание завершения egress (retry с backoff)
├─► Для каждого трека:
│ ├─► Загрузить плагин transcriber (FasterWhisperCPU/GPU или null)
│ ├─► transcriber.transcribe(file_path, language='ru')
│ └─► Сохранить segments в session_audio_tracks.segments (JSONB)
├─► Собрать сегменты по участникам (с учётом смещений)
├─► build_phrases(segments_by_participant, track_offsets)
├─► DELETE phrases + INSERT новые → таблица phrases
├─► UPDATE pipeline_status = 'summarizing'
4. send_task_with_retry ставит summarize_session → очередь `summarize`
5. summarize_session
├─► Получить фразы сеанса, собрать транскрипт
├─► Разбить на чанки (20 мин по умолчанию, зависит от уровня AI)
├─► Qwen3.5 map-reduce (промпты в workers/summarizer/prompts/)
├─► UPDATE conference_sessions.summary_data = '{...}' (pipeline_status остаётся 'summarizing')
6. send_task_with_retry ставит notify_session → очередь `notify`
7. notify_session
├─► Собрать получателей (режим: all=участники+гости / owner=владелец)
├─► Для каждого получателя: проверить email_deliveries, отправить письмо
├─► Сгенерировать HTML + plaintext письмо (backend/services/email_templates.py)
├─► Отправить через email-бэкенд (console|smtp, backend/services/email.py)
├─► UPDATE email_deliveries (идемпотентность повторной отправки)
├─► UPDATE pipeline_status = 'notified' (когда все получатели обработаны)
8. Beat-задачи recover_stuck_summaries/recover_stuck_notifications (интервал 300 сек)
├─► Найти сеансы, зависшие в 'summarizing' (без summary_data или без уведомления)
├─► Переставить соответствующую задачу в очередь для восстановления после сбоя брокера
```
**Ключевые особенности:**
- **Диспетчер единый:** `run_pipeline()` управляет всеми шагами транскрибации
- **Per-track идемпотентность:** каждый трек коммитится отдельно (точка возобновления)
- **Реконструкция фраз:** группировка по спикеру, короткие вставки, перекрытия речи (`workers/transcription/phrases.py`)
- **Атрибуция гостям:** фразы и треки связаны с `conference_participants`, не `users` (ADR-002)
- **Проверка pipeline_status:** guard идемпотентности (повторный запуск пропустит готовые шаги)
---
## Обработка времени
**Инвариант:** все времена в БД в UTC (`timestamptz`).
### Server-side (PostgreSQL)
```sql
INSERT INTO conferences (scheduled_at, duration_minutes)
VALUES ('2026-07-15T14:30:00+00:00', 60);
```
### Client-side (JavaScript)
```typescript
const startUtc = new Date('2026-07-15T14:30:00Z');
// Браузер форматирует в локальный часовой пояс пользователя
```
### .ics export
```ics
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//VidConf//EN
BEGIN:VTIMEZONE
TZID:Europe/Moscow
...
END:VTIMEZONE
BEGIN:VEVENT
UID:...
DTSTART;TZID=Europe/Moscow:20260715T143000
DTEND;TZID=Europe/Moscow:20260715T153000
...
END:VEVENT
END:VCALENDAR
```
---
## Ключевые архитектурные решения
1. **Динамические конференции вместо бронирований** (ADR-001) — конференция как пользовательская сущность
- Номер (9 цифр) + slug (base64url) — постоянные идентификаторы доступа
- Статусы: scheduled/active/ended
- Повторение: RecurrenceRule (JSON, 4 типа)
- Гости — полноценные участники (`guest_access`)
2. **Номер и slug как идентификаторы доступа** (ADR-001, п.4) — публичные эндпоинты без auth
- **Номер:** 9 цифр, первая 1..9 (энтропия ≈2^29.75); перебор при rate limit ≈60+ дней
- **Slug:** base64url 8 байт → 11 символов (64 бита энтропии); имя LiveKit-комнаты
- **Резолв:** единообразный 404 (живая/мёртвая неразличимы для безопасности)
- **Rate limit:** 10 запросов в минуту на IP для `/resolve` и `/guest-join`
3. **Гости как полноценные участники** (ADR-001, п.6) — представление при входе
- `guest_access`: display_name (обязателен), email (факультативен)
- LiveKit identity: `guest:{guest_id}` (email не передаётся)
- Участвуют в пайплайне саммари (email в рассылке, если заполнен)
4. **Recurrence как собственная модель, не RRULE** (ADR-001, п.3) — 4 типа, привязаны к форме UI
- `type`: weekly | biweekly | monthly | every_n_days
- Хранение в JSONB (`conferences.recurrence`), развёртка occurrences в памяти
- Правило содержит локальное время + IANA-таймзону; развёртка — в UTC
5. **Паттерн Strategy для плагинов** — подключаемые AI-реализации без изменений ядра
- Интерфейсы Transcriber/Summarizer в `backend/core/plugins/`
- Паттерн Factory с реестром
- Конфиг через YAML
6. **Идемпотентный pipeline пост-обработки** — безопасно повторять любой шаг
- Каждый шаг проверяет `pipeline_status` перед продолжением
- Per-track коммит сегментов (точка возобновления в БД)
- Неудачные задачи можно повторить без дублирования (DELETE+INSERT фраз в одной транзакции)
7. **Атрибуция фраз и треков к участнику сеанса** (ADR-002) — поддержка гостей без user_id
- `phrases.participant_id` → FK `conference_participants.id` (вместо user_id)
- `session_audio_tracks.participant_id` → FK `conference_participants.id`
- Гость без `user_id` проходит пайплайн наравне с пользователем
8. **Per-track аудио в SFU** — исключает необходимость диаризации спикеров
- LiveKit предоставляет per-track recording (один трек = один микрофон = один спикер)
- Track SID прямо соответствует identity участника
- Запись через Track Egress в `.ogg` (opus)
9. **UUID первичные ключи** — поддержка распределённых систем и репликации
- Сгенерировано через `gen_random_uuid()`
- Исключения: таблицы высокой частоты (`phrases`, `chat_messages`) используют BIGINT Identity
10. **Настройки инстанса в БД** — бутстрап из `plugins.yaml`
- Таблица `instance_settings` (key-value JSONB) импортирует дефолты `config/plugins.yaml` при старте backend
- Однократно и идемпотентно (`INSERT ... ON CONFLICT DO NOTHING`)
- Воркеры читают эффективную конфигурацию на старте каждой задачи
- Административный интерфейс может менять настройки без рестарта
11. **Рассылка саммари с переопределением на уровне конференции** — гибкая конфигурация
- `instance_settings.summary_recipients` — дефолт инстанса (`'all'` или `'owner'`)
- `conferences.summary_recipients` — переопределение для конкретной конференции (nullable)
- Эффективный режим = `conference.summary_recipients or cfg.summary_recipients`
12. **Идемпотентная рассылка саммари** — без дублирования писем
- Таблица `email_deliveries` с уникальным частичным индексом `(session_id, recipient_email)` WHERE `kind='summary'`
- Повторный запуск уведомления отправляет письмо только адресатам, которых ещё нет в таблице
- Семантика at-least-once: редкий дубль письма возможен, потеря — нет
- Per-получательный commit обеспечивает точку возобновления при обрыве
13. **Email-транспорт только через `.env`** — секреты никогда не попадают в БД или API
- Переключатель бэкенда (`EMAIL_BACKEND=console|smtp`) — переменная окружения
- Все SMTP-реквизиты (хост, порт, пароль, from) — только в `.env`
- Секреты не логируются и не попадают в `SettingsOut` API
- `aiosmtplib` с обработкой ошибок (retryable vs. skip)
14. **Управление командами и опциями регистрации** — справочник команд и гибкий вход
- Таблица `teams` — справочник команд (id, name UNIQUE, created_at); админ-API CRUD
- `users.team_id` (nullable) → FK `teams.id` с `ON DELETE SET NULL`
- Настройка инстанса `registration_team_choice` (bool) — показ выбора команды при регистрации; `GET /auth/registration-options` возвращает список доступных команд
- Настройка инстанса `registration_email_domain` (bool, domain: str|null) — обязательное совпадение домена email при регистрации (иначе 400 `invalid_email_domain`)
---
## Безопасность
- **Пароли:** хэширование Argon2 (никогда не логируется и не раскрывается)
- **JWT access-токен:** HS256, TTL по умолчанию 15 минут (`ACCESS_TOKEN_TTL_MINUTES`), передаётся в заголовке `Authorization: Bearer`
- **Refresh-токен:** httpOnly SameSite=Strict cookie, TTL по умолчанию 14 дней; отзывается при logout
- **LiveKit токены:** ограничены по времени (TTL 6 часов), выдаются на конкретную комнату
- **HTTPS/TLS:** терминируется на Nginx; dev-окружение использует HTTP
- **CSRF:** митигируется `SameSite=Strict` на cookie с refresh-токеном (не отправляется с cross-site запросов); access-токен передаётся в заголовке, а не в cookie, и потому не подвержен CSRF
- **SQL injection:** SQLAlchemy ORM с параметризованными запросами
- **Rate limiting:** Redis-based, публичные эндпоинты (`/resolve`, `/guest-join`) — 10 запросов/мин на IP
---
## ADR (Записи архитектурных решений)
Подробные обоснования см. в папке [adr/](./adr/):
- [001-dynamic-conferences-pivot.md](./adr/001-dynamic-conferences-pivot.md) — динамические конференции вместо бронирований
- [002-phrase-attribution-session-participant.md](./adr/002-phrase-attribution-session-participant.md) — атрибуция фраз и треков к участнику сеанса
- [003-conference-invitees.md](./adr/003-conference-invitees.md) — приглашённые на конференцию
- [004-ai-tier-matrix.md](./adr/004-ai-tier-matrix.md) — матрица уровней AI
- [005-password-reset-deferred.md](./adr/005-password-reset-deferred.md) — сброс пароля по email отложен
- [000-template.md](./adr/000-template.md) — шаблон ADR
---
## Производительность и масштабируемость
### Уровни AI качества (ADR-004)
**Пресет 12 (без AI):**
- 1020 одновременных конференций
- 4 vCPU, 8 GB RAM, 40 GB диск
**Пресет 3: AI min** (faster-whisper small + Qwen3.5-4B)
- 1020 одновременных конференций
- 8 vCPU, 16 GB RAM, 100 GB диск
- Транскрибация: ~2x realtime (10мин → 5мин на 8-ядерном CPU)
**Пресет 4: AI medium** (faster-whisper medium + Qwen3.5-9B; GPU опционально)
- 1530 одновременных конференций (CPU) или 3050 (GPU)
- 1216 vCPU, 32 GB RAM, 150 GB диск
- CPU: ~3x realtime; GPU (≥8 GB): ~1.5x realtime
**Пресет 5: AI max** (faster-whisper large-v3 + Qwen3.5-35B-A3B; GPU обязателен)
- 50100+ одновременных конференций
- 16+ vCPU, 64 GB RAM, 250 GB диск, GPU ≥16 GB VRAM
- Транскрибация: ~0.5x realtime (10мин → 20сек на V100)
См. [docs/deploy/hardware-profiles.md](../deploy/hardware-profiles.md) и [ADR-004](./adr/004-ai-tier-matrix.md).
---
## Развёртывание
### Установка
```bash
./install.sh # интерактивный опросник (автодетект железа)
./install.sh --preset 3 # неинтерактивно (пресет 3 = AI min)
```
Инсталлятор автоматически:
1. Детектирует CPU/RAM/GPU
2. Рекомендует пресет
3. Генерирует `.env` с секретами
4. Запускает `docker compose` с нужными профилями
5. Выполняет миграции и seed
### Локальная разработка (вручную)
```bash
docker compose -f deploy/docker-compose.yml up -d # пресет 1 (без AI)
docker compose -f deploy/docker-compose.yml \
--profile media --profile transcribe --profile llm up -d # пресет 3 (с AI min)
```
### Продакшен
- Docker Compose на одном хосте (текущий целевой сценарий)
- Nginx для TLS + статические файлы
- Переменные окружения для секретов (`.env`)
- Резервные копии БД (PostgreSQL dump)
- Мониторинг (Prometheus + Grafana, `--profile monitoring`)
См. [docs/deploy/dev-setup.md](../deploy/dev-setup.md).
---
## Мониторинг и наблюдаемость
- **Логи:** Docker-логи + логирование приложений
- **Метрики:** `GET /metrics``vidconf_http_request_duration_seconds`, `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth` (см. [workers/README.md](../../workers/README.md#метрики))
- **Визуализация:** Grafana (compose-профиль `monitoring`)
- **БД:** журнал медленных запросов PostgreSQL
См. [docs/deploy/monitoring.md](../deploy/monitoring.md).
---
## Стратегия тестирования
- **Backend:** pytest (unit + интеграционные тесты)
- **Frontend:** ESLint + TypeScript (`tsc -b`); автоматических unit/E2E тестов пока нет
- **БД:** проверка версии Alembic (`alembic current`), автогенерация миграций
---
## Ссылки
- [Корневой README](../../README.md)
- [Схема БД](../db/schema.md)
- [Справка API](../api/README.md)
- [Архитектура плагинов](../plugins/contracts.md)
- [Профили развёртывания](../deploy/hardware-profiles.md)
- [Backend README](../../backend/README.md)
- [Frontend README](../../frontend/README.md)
- [Workers README](../../workers/README.md)

View File

View File

@@ -0,0 +1,54 @@
# Шаблон ADR
## Заголовок
[Краткое название архитектурного решения]
## Статус
[PROPOSED | ACCEPTED | DEPRECATED | SUPERSEDED]
## Контекст
Опишите проблему, которая мотивирует это решение. Укажите значимые факты:
- Почему это решение нужно?
- Какие ограничения или требования применимы?
- Какие альтернативы рассматривались?
## Решение
Сформулируйте принятое решение чётко и кратко.
## Последствия
Опишите результаты и следствия этого решения:
- **Плюсы:** выгоды, улучшения
- **Минусы:** компромиссы, риски
- **Нейтрально:** изменения, которые не хороши и не плохи
## Ссылки
- Связанные ADR (если есть)
- Внешняя документация или стандарты
- Файлы кода, реализующие это решение
---
## Пример: ADR-001 Использование UUID как первичного ключа
### Статус
ACCEPTED
### Контекст
VidConf требует глобально уникальных идентификаторов для распределённых операций и будущего шардирования.
- Генерация UUID в PostgreSQL быстрая (через `gen_random_uuid()`)
- Не требует центральной нумерации
- Поддерживает репликацию без координации
### Решение
Все таблицы используют `UUID` (версия 4) как первичный ключ, генерируемый на сервере через `gen_random_uuid()`.
Исключения: `phrases` и `chat_messages` используют `BIGINT IDENTITY` для высокочастотных вставок.
### Последствия
- **Плюс:** уникальность на всех инстансах; не требует глобальной координации
- **Плюс:** поддерживает будущие распределённые архитектуры
- **Минус:** больший размер индекса (16 байт против 8 у BIGINT)
- **Нейтрально:** требует явной поддержки типа UUID в ORM
### Ссылки
- `backend/models/*.py` — все модели используют `Mapped[uuid.UUID]`
- `backend/alembic/versions/1e2e34a0cb06_initial_schema.py` — миграция

View File

@@ -0,0 +1,161 @@
# ADR-001: Динамические конференции вместо бронирований комнат
## Статус
ACCEPTED
## Контекст
Продукту не подходит модель предустановленных переговорных комнат с
бронированием: конференция должна создаваться динамически (мгновенно из
лобби или планово из календаря). Незакреплённая умирает по завершении
(история/саммари остаются), закреплённая — постоянная, с повторениями. Вход
— по ссылке или номеру, гости допускаются после «представиться». Прежняя
схема (`rooms` + `room_bookings` с EXCLUDE-constraint, `conferences` как
сеанс, привязанный к `room_id`) этой концепции не соответствует.
Продакшен-данных на момент миграции не было — допустима структурная миграция
с переименованием таблиц.
## Решения
### 1. Модель данных
Двухуровневая модель: **конференция** (пользовательская сущность) и **сеанс**
(один запуск конференции, единица AI-пайплайна).
- Таблица `conferences` **переименовывается** в `conference_sessions`
(данные сохраняются): `id`, `conference_id` FK→conferences (NOT NULL, CASCADE),
`title` (снапшот), `t_start`, `t_end`, `pipeline_status`, `summary_data`,
`created_at`. Колонки `room_id`, `booking_id` удаляются.
Статус-машина пост-обработки живёт в `conference_sessions.pipeline_status`
(семантика не меняется).
- Создаётся **новая** таблица `conferences` — сущность конференции:
`id UUID PK`, `number VARCHAR(9) UNIQUE NOT NULL`, `slug VARCHAR(22) UNIQUE NOT NULL`,
`title VARCHAR(255) NULL`, `owner_id UUID NULL FK users ON DELETE SET NULL`,
`status conference_status NOT NULL DEFAULT 'scheduled'`,
`is_pinned BOOL NOT NULL DEFAULT false`, `is_closed BOOL NOT NULL DEFAULT false`,
`password_hash TEXT NULL`, `scheduled_at TIMESTAMPTZ NULL`,
`duration_minutes INT NULL`, `recurrence JSONB NULL`,
`ended_at TIMESTAMPTZ NULL`, `created_at`.
CHECK: `is_closed = false OR password_hash IS NOT NULL`;
`recurrence IS NULL OR is_pinned = true`.
- В `phrases`, `chat_messages`, `conference_participants` колонка
`conference_id` переименовывается в `session_id` (FK → conference_sessions).
- `rooms`, `room_bookings`, `booking_participants` **удаляются**. Backfill в
миграции: для каждой room, на которую ссылаются сеансы, создаётся запись
conferences (status='ended', slug=permanent_link, номер генерируется,
owner_id=NULL), сеансы перевязываются, затем таблицы комнат/броней дропаются.
Список допущенных участников закрытой брони (`booking_participants`) уходит
без замены: доступ к закрытой конференции — только по паролю (утверждённая
концепция, п. 7).
### 2. Жизненный цикл
`conference_status` = ENUM(`scheduled`, `active`, `ended`).
- Статус `draft` отклонён: создание атомарно из формы, черновики не нужны.
- «pinned» — не статус, а ортогональный флаг `is_pinned` (закреплённость не
исключает ни scheduled, ни active).
- Переходы: мгновенное создание → `active` (вход сразу); плановое → `scheduled`;
webhook `room_started``active`; `room_finished``ended` (если не
закреплена; ставится `ended_at`) или обратно `scheduled` (закреплена).
Beat-задача переводит в `ended` незакреплённые scheduled, чьё время истекло
без единого сеанса. `ended` — терминальный: join отвечает 410, строка и
история не удаляются.
### 3. Recurrence — собственная модель, не RRULE
Хранится в `conferences.recurrence` (JSONB), Pydantic-схема `RecurrenceRule`:
```
type: 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
weekdays: list[int] # 0=пн…6=вс — для weekly/biweekly
day_of_month: int (1..31) # для monthly; 31 в коротком месяце → последний день
interval_days: int >= 1 # для every_n_days
anchor_date: date # точка отсчёта чётности biweekly / шага every_n_days
time_local: 'HH:MM'
timezone: str # IANA
duration_minutes: int
```
Обоснование: UI фиксирует ровно 4 типа повторения — структурированная модель
отображается на форму 1:1, валидируется Pydantic и разворачивается чистой
функцией `expand_occurrences(rule, t_from, t_to) -> list[datetime UTC]` (TDD);
RRULE дал бы избыточную выразительность, парсинг и зависимость без выгоды.
Инвариант №1 не нарушен: правило — не timestamp (локальное время + IANA-зона
нужны для корректности при смене смещения), все timestamp-колонки — UTC.
### 4. Номер, постоянная ссылка и резолв
- **Номер**: 9 десятичных цифр, первая 19 (`secrets.randbelow`), уникален,
генерация с retry при коллизии. Энтропия: 9·10^8 вариантов ≈ 2^29.75.
Оценка перебора: при ≤1000 живых конференций вероятность угадать с одной
попытки ≤ 1.2·10^-6; при rate limit 10 запросов/мин на IP матожидание
подбора с одного IP ≈ 60+ суток непрерывного перебора. Отображение —
группами 3-3-3 («884 210 466»); в макетах номера-плейсхолдеры 7-значные —
это контент, не layout, отклонение фиксируется здесь.
- **Резолв** (`GET /conferences/resolve`, публичный, rate limit):
- несуществующий номер/slug → **404** (единообразный, без деталей);
- существующая завершённая (`ended`) → **200 с минимальным ответом
`{id, title, status='ended'}`** — пользователь по старой ссылке/номеру
видит «конференция завершена», а не «не найдено»;
- для `ended` НЕ раскрывается ничего сверх минимума: `is_closed` /
`requires_password` не возвращаются (войти всё равно нельзя).
Trade-off принят осознанно: утечка факта существования/названия завершённой
конференции допустима, т.к. держатель slug (64 бита) или номера практически
всегда — бывший участник, перебор закрыт энтропией и rate limit'ом, а
реальный барьер повторного входа — **410 на join/guest-join** (протестировано).
- **Ссылка**: `slug = secrets.token_urlsafe(8)` — 11 символов base64url,
64 бита энтропии; URL вида `/j/{slug}`. Slug также служит именем
LiveKit-комнаты (замена room.permanent_link). Номер и slug неизменны всё
время жизни конференции и не переиспользуются.
### 5. Судьба инварианта №2 (EXCLUDE USING gist)
Constraint **снимается** — исчезает вместе с таблицей `room_bookings`.
Конференции не конкурируют за общий ресурс: пересечения по времени у одного
владельца допустимы by design, защита БД не нужна. Расширение `btree_gist`
из БД не удаляем (безвредно, миграция проще и обратима).
### 6. Гости
- Новая таблица `guest_access`: `id UUID PK`, `conference_id` FK→conferences
(CASCADE), `display_name VARCHAR(255) NOT NULL`, `email VARCHAR(320) NULL`,
`created_at`. Создаётся эндпоинтом гостевого join (без auth, rate limit).
- LiveKit identity: зарегистрированный — `str(user_id)` (как сейчас, обратная
совместимость webhook-парсера); гость — `guest:{guest_access.id}`,
`name=display_name`. Email в LiveKit (metadata) не передаётся — PII не
утекает другим участникам.
- `conference_participants`: `user_id` становится NULLABLE, добавляется
`guest_id UUID NULL FK guest_access`; CHECK — заполнено ровно одно из двух.
Webhook `participant_joined` по префиксу identity создаёт строку участника
с user_id либо guest_id.
- Рассылка саммари: получатели сеанса = email пользователей
`guest_access.email IS NOT NULL` участников сеанса.
- Закрытая конференция требует пароль и от гостя.
### 7. Переиспользование кода бронирований / удаление
Переиспользуется: инфраструктура FullCalendar и диалогов календаря
(Booking* → Conference*), механика пароля (argon2, `JoinPasswordDialog`,
`ClosedJoinPage` → единый join-flow), UTC-валидаторы из `schemas/bookings.py`,
генерация slug (`token_urlsafe`), webhook-пайплайн с идемпотентностью,
beat-каркас `release_idle_rooms` (адаптируется в очистку конференций),
booking-модель концептуально → плановая конференция (`scheduled_at`,
`is_closed`, `password_hash` переезжают в conferences).
Удаляется: seed 100 комнат (`backend/scripts/seed.py`), модели
`room.py`/`booking.py`/`booking_participant.py`, сервисы
`booking_rules.py`/`bookings.py`/`room_access.py` (включая «правило часа» —
не имеет смысла без конкуренции за комнаты), репозитории `rooms.py`/`bookings.py`,
роутеры `api/rooms.py`/`api/bookings.py`, схемы `rooms.py`/`bookings.py`,
frontend: `RoomCard`, `lib/roomColors.ts`, `api/rooms.ts`, `api/bookings.ts`,
Booking*-диалоги, тесты бронирования/комнат.
## Последствия
- **Плюсы:** модель 1:1 соответствует продукту; исчезает класс конфликтов
бронирования и его код; гости — полноценные участники пайплайна саммари;
единый join-flow (ссылка/номер/пароль/гость); внятный UX по старым
ссылкам («конференция завершена» вместо «не найдено»).
- **Минусы:** разрушительная миграция (переименование таблиц/колонок) —
допустимо до продакшена, но затрагивает пайплайн пост-обработки (пишет в
`conference_sessions`); публичные эндпоинты resolve/guest-join требуют
rate limiting (Redis) и единообразного 404 для несуществующих; резолв
раскрывает существование и название завершённой конференции держателю её
номера/ссылки (принятый trade-off, см. п. 4).
- **Нейтрально:** `btree_gist` остаётся установленным без использования.
## Ссылки
- `design/mockups/{lobby,join,calendar,my-conferences}.html` — утверждённый UI
- `backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py` — миграция реализует раздел «Модель данных»
- `backend/api/conferences.py` — резолв/join/guest-join по п. 4 и п. 6

View File

@@ -0,0 +1,42 @@
# ADR-002. Атрибуция аудиотреков и фраз к участнику сеанса (participant_id вместо user_id)
## Статус
ПРИНЯТО
## Контекст
FR-4.2 ТЗ фиксирует схему `phrases (id, user_id, conferences_id, data, t_start,
t_end)` — атрибуция фразы к зарегистрированному пользователю. После перехода
на динамические конференции (ADR-001) среди участников сеанса есть ГОСТИ без `user_id`
(`conference_participants` допускает ровно одну identity: `user_id` ИЛИ
`guest_id`). Кроме того, для записи per-track аудио (LiveKit Track Egress)
нужен персистентный маппинг «файл записи ↔ участник сеанса», которого в схеме
нет. Альтернативы:
- пара nullable-колонок `user_id`/`guest_id` в `phrases` — дублирует
CHECK-логику `conference_participants` в каждой таблице пайплайна;
- заводить фиктивного user для гостя — нарушает модель auth и FR-1.
## Решение
1. В `phrases` колонка `user_id` заменяется на `participant_id`
NOT NULL FK на `conference_participants.id` (ON DELETE CASCADE). Спикер
фразы — всегда строка участника сеанса; имя/email для отображения и
рассылки берутся join'ом через `user_id`/`guest_id` участника.
2. Вводится таблица `session_audio_tracks`: одна строка на audio-трек сеанса
(track SID, egress ID, путь к файлу, статус, `started_at`,
`segments` JSONB) с тем же FK `participant_id`. Она — источник маппинга
«файл ↔ спикер» и точка идемпотентного возобновления транскрибации.
## Последствия
- **Плюсы:** гости атрибутируются без костылей; единая точка истины об
identity (`conference_participants`); повторное подключение того же
пользователя даёт разные строки участника — тайм-окна присутствия точны.
- **Минусы:** выборка фраз «по пользователю» требует join через
`conference_participants`; отступление от буквы FR-4.2 (фиксируется этим ADR).
- **Нейтрально:** `segments` JSONB — промежуточный артефакт пайплайна,
очищается не обязательно (объём мал: текст+тайминги).
## Ссылки
- ADR-001 (динамические конференции, гостевой доступ).
- `backend/models/phrase.py`, `backend/models/participant.py`,
`backend/models/audio_track.py`.
- Сеанс (`conference_sessions`) — единица пайплайна пост-обработки.

View File

@@ -0,0 +1,70 @@
# ADR-003. Модель приглашённых участников конференции (conference_invitees)
Статус: принято.
## Контекст
Продукту нужен состав приглашённых участников конференции: зарегистрированные
пользователи (user_id) и внешние по произвольному email. Организатор обязан
всегда быть в составе и быть неудаляемым. Уже существует таблица
`conference_participants` — это ФАКТИЧЕСКИЕ участники сеанса (кто реально был,
окна присутствия, единица атрибуции фраз, ADR-002); смешивать сущности нельзя.
## Решение
1. Новая таблица `conference_invitees` — приглашённые НА КОНФЕРЕНЦИЮ
(не на сеанс):
- `id UUID PK`, `conference_id FK conferences ON DELETE CASCADE NOT NULL`;
- `user_id FK users ON DELETE CASCADE NULL` — зарегистрированный;
- `email VARCHAR(255) NULL` — внешний (хранится в lower-case);
- `CHECK ((user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1)`
ровно одна identity (тот же приём, что в `conference_participants`);
- частичные UNIQUE: `(conference_id, user_id) WHERE user_id IS NOT NULL`
и `(conference_id, email) WHERE email IS NOT NULL` — без дублей;
- `created_at`.
2. Организатор в таблице НЕ хранится: он выводится из `conferences.owner_id`
и всегда добавляется в состав на уровне API/рассылки. Инвариант
«организатор всегда в составе и неудаляем» обеспечен конструктивно —
удалить его из состава невозможно в принципе, рассинхронизация при смене
владельца исключена. Попытка добавить владельца в invitees (по user_id или
его email) молча дедуплицируется на записи.
3. Состав задаётся списком целиком (PUT-семантика поля `participants` в
create/update конференции): backend вычисляет diff, отсутствие поля —
«не менять». Права на изменение состава = права на изменение конференции.
4. Связь с фактическими участниками сеанса — аналитическая, по join без FK:
зарегистрированный — `conference_participants.user_id = invitees.user_id`;
внешний — `lower(guest_access.email) = invitees.email` (если приглашённый
вошёл гостем и указал тот же email). FK не вводим: гость может войти
с другим email или не войти вовсе — жёсткая связь ложна по природе данных.
5. Рассылка приглашений (.ics METHOD:REQUEST): получатели =
организатор + invitees (email пользователя или внешний email); для
закреплённых по-прежнему добавляются участники прошлых сеансов
(`workers/tasks/invitations.py`, дедуп по lower(email)).
6. **Видимость приглашённого в списках.** Приглашённый видит конференцию в
`GET /conferences/my` и `GET /conferences/calendar` наравне с владельцем —
строка попадает в выборку, если `owner_id == user.id` ИЛИ существует
`conference_invitees` этой конференции с `user_id == user.id` ИЛИ с
`lower(email) == lower(email пользователя)` (внешнее приглашение на адрес,
под которым человек впоследствии зарегистрировался). Критерии показа
(закреплённая — безусловно; разовая — `status=scheduled` и `scheduled_at`
в будущем) не меняются, только круг «чей» конференция. `GET /conferences/{id}`
аналогично открыт приглашённому (иначе ховер-карточка/детальная страница
получали бы 403); `is_owner` в ответе для приглашённого — `false`,
`organizer_name` — имя фактического владельца. Права на PATCH/DELETE это
расширение НЕ затрагивает — по-прежнему только владелец/администратор.
## Последствия
- (+) Чистое разделение «приглашён» / «фактически был»; пайплайн атрибуции
фраз (ADR-002) не затронут.
- (+) Инвариант организатора не требует триггеров и проверок целостности.
- () Внешний приглашённый не связывается с гостевым входом надёжно (только
эвристика по email) — принято как ограничение модели.
- () Списки состава в ответах API требуют дозагрузки (`selectinload`) —
следить за N+1 в `/my` и `/calendar`; показ приглашённому (п. 6) добавляет
туда же `EXISTS`-подзапрос по `conference_invitees` и точечный запрос имени
реального владельца на каждую НЕ свою строку списка — список короткий
(закреплённые + предстоящие), нагрузка признана приемлемой.
- Календарь и «Мои конференции» — выборка «владелец ИЛИ приглашённый» (п. 6);
до 2026-07-20 показывались только конференции владельца — приглашённый
видел состав лишь через уведомление/.ics, не через списки приложения.

View File

@@ -0,0 +1,114 @@
# ADR-004. Матрица уровней AI (min/medium/max): модели, кванты, железо, параметры генерации
## Статус
ACCEPTED
## Контекст
Продукту нужны три уровня качества AI-обработки (`min`/`medium`/`max`,
`AiLevel` в `backend/core/plugins/config.py`) для пресетов инсталлятора 35.
Ограничения: только локальные модели на всех уровнях (без внешних API);
промпты `workers/summarizer/prompts/` едины и не меняются между уровнями —
качество наращивается размером модели, а не правкой промптов. Ранний опыт с
Qwen ~3B показал, что модель на пределе инструктивной сложности: reduce
упирался в `max_tokens=1024`, отсюда per-tier лимиты (reduce ≥1536); часовой
транскрипт на CPU ≈ 6,5 мин — ориентир для уровня «min».
Актуальное на момент решения поколение моделей — **Qwen3.5**: dense
0.8B/2B/4B/9B («Small», thinking ВЫКЛЮЧЕН по умолчанию), dense 27B и MoE
35B-A3B (мультимодальные, thinking ВКЛЮЧЁН по умолчанию, отключается
`chat_template_kwargs: {"enable_thinking": false}`), крупнее — 122B-A10B,
397B-A17B. Инференс поддержан llama.cpp (llama-server,
`--chat-template-kwargs`), GGUF-кванты публикуются Qwen и Unsloth.
Кандидаты Qwen3-4B/8B/14B/32B (предыдущее поколение) отклонены в пользу
более нового поколения при том же рантайме.
faster-whisper: GPU через CTranslate2 — `WhisperModel(..., device="cuda",
compute_type="float16")` (вариант `int8_float16` для экономии VRAM); нужны
cuBLAS/cuDNN 9 для CUDA 12 (`pip install nvidia-cublas-cu12
nvidia-cudnn-cu12==9.*` + `LD_LIBRARY_PATH`) и nvidia-container-toolkit.
llama.cpp: официальные CUDA-образы `ghcr.io/ggml-org/llama.cpp:server-cuda`
(CUDA 12) / `server-cuda13`; offload — `--n-gpu-layers` /
`LLAMA_ARG_N_GPU_LAYERS`.
## Решение
### Матрица уровней
| Уровень | Транскрибация | Суммаризация (LLM) | Режим |
|---|---|---|---|
| **min** | faster-whisper `small`, CPU, `int8` (~0,5 ГБ весов) | **Qwen3.5-4B**, GGUF Q4_K_M ≈ 2,52,8 ГБ, llama.cpp CPU | thinking выключен по умолчанию (семейство Small) |
| **medium** | faster-whisper `medium` (~1,5 ГБ): CPU `int8`; при GPU — `cuda`/`float16` (VRAM ~23 ГБ) | **Qwen3.5-9B**, GGUF Q4_K_M ≈ 6,2 ГиБ, llama.cpp CPU или GPU (полный offload от ~8 ГБ VRAM) | thinking выключен по умолчанию |
| **max** | faster-whisper `large-v3` (~3 ГБ), только GPU, `cuda`/`float16` (VRAM ~4,55 ГБ) | **Qwen3.5-35B-A3B** (MoE, ~3B активных), GGUF Q4_K_M ≈ 2022 ГБ, llama.cpp GPU (полный offload от ~24 ГБ VRAM; допустим гибрид GPU+RAM за счёт скорости) | thinking ПРИНУДИТЕЛЬНО отключается: `LLAMA_ARG_CHAT_TEMPLATE_KWARGS='{"enable_thinking":false}'` на llama-server |
Замена более раннего варианта (Qwen2.5-3B → Qwen3.5-4B на min) — сопоставимый
размер/скорость, новее поколение, лучшее следование инструкциям; промпты
не трогаем — они едины для всех уровней. Точные имена GGUF-файлов фиксируются в
`deploy/llm/download-model.sh` при реализации (репозитории `Qwen/…-GGUF` /
`unsloth/…-GGUF`); размеры выше — ориентиры для инсталлятора.
### Per-tier параметры генерации (промпты неизменны)
| Параметр | min | medium | max |
|---|---|---|---|
| temperature | 0.2 | 0.2 | 0.2 |
| max_tokens (map) | 1024 | 1024 | 1536 |
| max_tokens (reduce) | 1536 | 2048 | 2560 |
| CTX llama-server | 16384 | 16384 | 16384 |
temperature 0.2 — осознанное отступление от рекомендаций карточки модели
(0.71.0 для чата): суммаризация экстрактивная, нужна детерминированность.
Раздельные лимиты map/reduce требуют параметров
`max_tokens_map`/`max_tokens_reduce` в плагине `QwenLocal` (options, контракт
`Summarizer` не меняется).
### Требования железа (таблица инсталлятора и детекта админки)
| Пресет | CPU | RAM | GPU (VRAM) | Диск | Модели на диске |
|---|---|---|---|---|---|
| 1 MVP / 2 +чат | 4 vCPU | 8 ГБ | — | 40 ГБ | — |
| 3 +AI min | 8 vCPU | 16 ГБ | — | 100 ГБ | ~3,5 ГБ |
| 4 +AI medium | 1216 vCPU | 32 ГБ | опционально ≥8 ГБ (ускорение) | 150 ГБ | ~8 ГБ |
| 5 +AI max | 16+ vCPU | 64 ГБ | ОБЯЗАТЕЛЬНО NVIDIA ≥16 ГБ (рекоменд. 24 ГБ) | 250 ГБ | ~25 ГБ |
Детект: железо определяет `install.sh` (nproc, free, nvidia-smi) и пишет в
`.env` (`HW_CPUS`, `HW_RAM_MB`, `HW_GPU_NAME`, `HW_VRAM_MB`); backend-детект
доступности уровней (`services/ai_levels.py`) читает эти переменные плюс
факт наличия скачанных моделей на томах — без зависимости от torch/nvidia-smi
внутри контейнера.
## Последствия
- **Плюс:** переключение уровней — только конфиг/админка; ядро и промпты
неизменны; min остаётся CPU-only на всех уровнях.
- **Плюс:** thinking-режим гарантированно выключен на всех уровнях
(Small — по умолчанию, MoE — флагом сервера), формат вывода промптов
сохраняется.
- **Минус:** Qwen3.5 требует свежий llama.cpp — тег образа
`ghcr.io/ggml-org/llama.cpp:server[-cuda]` фиксируется по digest в compose;
риск несовместимости старых GGUF (арх. `qwen35`) закрывается скачиванием
только официальных квантов.
- **Минус:** GPU-стек (nvidia-container-toolkit, cuDNN 9) — новая
эксплуатационная зависимость пресетов 4 (опция) и 5 (обязательно).
- **Нейтрально:** 27B dense отклонён для max в пользу MoE 35B-A3B: при
сравнимом качестве ~3B активных параметров дают кратно большую скорость
на том же VRAM-бюджете.
## Аддендум
Флаг `LLAMA_ARG_CHAT_TEMPLATE_KWARGS='{"enable_thinking":false}'`, названный
выше для принудительного отключения thinking на уровне `max`, в актуальной
llama.cpp имеет более простой равнозначный эквивалент: `LLAMA_ARG_REASONING=off`
(`--reasoning off`) — по `common/arg.cpp` проекта llama.cpp флаг выставляет
`enable_thinking=false` в шаблоне чата сервера тем же эффектом, без
необходимости передавать сырой JSON `chat_template_kwargs` через переменную
окружения. Реализация (`deploy/docker-compose.yml`) использует
`LLAMA_ARG_REASONING=off`; сама матрица уровней и решение (thinking отключён на
`max`) не меняются.
## Ссылки
- ADR-001 (динамические конференции).
- `backend/core/plugins/{faster_whisper,qwen_local}.py`,
`backend/services/ai_levels.py`, `config/plugins.yaml` — реализация.
- unsloth.ai/docs/models/qwen3.5 (линейка, режимы, требования памяти),
huggingface.co/Qwen/Qwen3.5-35B-A3B (enable_thinking, Q4_K_M 9B = 6,22 ГиБ),
github.com/SYSTRAN/faster-whisper (CUDA/CTranslate2),
github.com/ggml-org/llama.cpp docs/docker.md (server-cuda).

View File

@@ -0,0 +1,37 @@
# ADR-005: Сброс пароля по email отложен до v0.1.0
Статус: принято.
## Контекст
Ссылка «Забыли пароль?» на странице входа (сброс пароля по email-токену,
аналогично подтверждению регистрации, со страницей задания нового пароля)
рассматривалась для релиза v0.0.1. Инфраструктура писем есть
(`services/email.py`, верификация регистрации), но полный флоу сброса
требует: новый тип одноразового токена и его хранение/инвалидацию, публичный
эндпоинт запроса сброса с rate limit и единообразным ответом (защита от
перебора email), эндпоинт применения токена, новую страницу фронтенда, отзыв
активных refresh-сессий, тесты на всё перечисленное. Это заметный
security-чувствительный объём непосредственно перед тегом v0.0.1.
## Решение
1. В релиз v0.0.1 входит только смена пароля в профиле с проверкой текущего пароля.
2. Сброс пароля по email откладывается до v0.1.0 (вместе с релизом записи
конференций либо ранее отдельным патчем).
3. Вариант «смена пароля на странице login без проверки старого пароля»
отвергнут как небезопасный.
4. Операционный обходной путь для забытого пароля в v0.0.1: пользователь
обращается к администратору; администратор создаёт пользователей сам и
знает выданный пароль. Возможность админа задать новый пароль
существующему пользователю в скоуп не добавляется — при необходимости
решается отдельно.
5. Страница login не меняется: ссылку «Забыли пароль?» не добавляем, чтобы
не обещать отсутствующую функцию.
## Последствия
- Плюс: минимальный дифф перед тегом, нет спешной реализации
security-чувствительного публичного флоу.
- Минус: пользователь, забывший пароль, в v0.0.1 зависит от администратора.
- Требования к будущей реализации (v0.1.0): одноразовый токен с TTL,
хэш токена в хранилище (не сам токен), rate limit и uniform-ответ
«письмо отправлено, если адрес зарегистрирован», отзыв refresh-токенов
после смены пароля.

View File

@@ -0,0 +1,312 @@
# Интерфейс комнаты конференции
**Ссылки:** `frontend/src/pages/RoomPage.tsx`, `frontend/src/components/room/*`, `@livekit/components-react`, LiveKit JS SDK
## Обзор
Комната конференции — это отдельный замкнутый UI с собственной тёмной темой (§ frontend-themes.md):
- Диалог настроек устройств (микрофон, камера) с персист выбора
- Аватары участников (или инициалы при отсутствии)
- Fullscreen API (кнопка)
- Мини-плеер: Document Picture-in-Picture для активной плитки (Chrome/Edge 116+) с фолбэком на Video Picture-in-Picture (Safari/Firefox)
- Демонстрация экрана/окна/вкладки (любой участник, focus-раскладка, last-wins, звук где браузер отдаёт)
## 1. Настройки устройств (Device Settings)
### Архитектура
**UI (`DeviceSettingsDialog.tsx`):**
- Кнопка-шестерёнка в тулбаре комнаты → диалог настроек
- Селекты «Микрофон» и «Камера»
Список устройств, переключение активного и персист выбора между заходами в
комнату целиком делегированы хукам `@livekit/components-react`:
- `useMediaDeviceSelect({ kind })` — список устройств (подписан на
`RoomEvent.MediaDevicesChanged`), `activeDeviceId`, `setActiveMediaDevice()`
- `usePersistentUserChoices()` — сохраняет выбранные `deviceId` (localStorage,
ключи и формат — внутренняя реализация библиотеки); `RoomPage.tsx` читает
сохранённый выбор через `options`-проп `LiveKitRoom`, чтобы применить его
при следующем входе
Диалог должен рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без
явно переданного `room` берёт активную комнату из `RoomContext`.
**Ключевой момент:** ошибка переключения устройства (занято/отключено)
показывается тостом; `activeDeviceId` хука остаётся источником истины —
состояние селекта само не «откатывается».
## 2. Аватары участников
### Механизм отображения
Общий компонент `frontend/src/components/ui/Avatar.tsx` используется и в
топбаре/админке/пикере участников, и в комнате (`RoomParticipantTile.tsx`):
- Если передан `avatarUrl` — рендерится `<img>`
- Иначе — инициалы имени (первые буквы первых двух слов), на фоне базового
класса `.avatar`; отдельного визуального различия между зарегистрированным
пользователем без аватара и гостем нет — оба показывают инициалы одинаково
### Передача аватара в LiveKit
LiveKit-токен (выдаётся `POST /api/v1/conferences/{id}/join` для
зарегистрированных участников) содержит метаданные:
```json
{
"metadata": "{\"avatar_url\": \"https://vidconf.example.com/media/avatars/550e8400....jpg\"}"
}
```
**Парсинг** — инлайн-функция `parseAvatarUrl()` в `RoomParticipantTile.tsx`:
разбирает `participant.metadata` (реактивно, через `useParticipantInfo`),
возвращает `null` при пустых/невалидных метаданных. Для гостей `avatar_url` в
токен не кладётся — `parseAvatarUrl` вернёт `null`, показываются инициалы.
## 3. Fullscreen API
### Реализация
Хук `frontend/src/hooks/useFullscreen.ts` — единственный источник истины о
состоянии — событие `fullscreenchange` документа (не промис
`requestFullscreen()`: выход по Esc браузер выполняет сам, без обратного
вызова). Цель — корневой контейнер комнаты (`div[data-theme="room"]` в
`RoomPage.tsx`), чтобы тулбар и чат оставались видны внутри полноэкранного
режима. `supported` = `document.fullscreenEnabled` — кнопка в
`RoomToolbar.tsx` скрывается, если `false`.
**Поддержка:** все современные браузеры (Chrome, Firefox, Safari, Edge).
## 4. Мини-плеер (Picture-in-Picture)
### Матрица поддержки
| Браузер | Document PiP | Video PiP | Что использует |
|---|---|---|---|
| **Chrome 116+ / Edge 116+** | ✓ Да | ✓ Да | Document PiP (активная плитка в отдельном окне) |
| **Safari 17+** | ✗ Нет | ✓ Да | Video PiP (только видео активной плитки) |
| **Firefox** | ✗ Нет | ✓ Да | Video PiP (только видео активной плитки) |
Оба флага детектируются в рантайме (`'documentPictureInPicture' in window`,
`document.pictureInPictureEnabled`) — если ни один браузер API не
поддерживает, `supported: false` и кнопка скрывается (очень старые браузеры).
### Единый хук `useRoomPiP`
`frontend/src/hooks/useRoomPiP.ts` инкапсулирует оба режима за одним API
(`supported`, `active`, `mode`, `pipWindow`, `toggle`):
1. **Document Picture-in-Picture** (Chrome/Edge) — `toggle()` синхронно (в
рамках user gesture) вызывает `window.documentPictureInPicture.requestWindow()`,
копирует таблицы стилей текущего документа в PiP-окно (`copyStyleSheets`;
внешние cross-origin стили — ссылкой `<link>`, не инлайном) и проставляет
`data-theme="room"` на `<html>` PiP-окна. Содержимое — `RoomPage.tsx`
рендерит `<RoomStage variant="pip" />` порталом (`createPortal`) прямо в
`pipWindow.document.body`; React-контекст `LiveKitRoom` остаётся в основном
дереве, поэтому хуки треков продолжают работать. В `variant="pip"` сцена
показывает только одну активную плитку (без карусели/грида), фокус живо
следует за активным спикером.
2. **Video Picture-in-Picture** (Safari) — фолбэк, классический
`videoEl.requestPictureInPicture()` на видео из фокус-плитки основного окна.
3. Ни то, ни другое не поддерживается (Firefox) — `supported: false`, кнопка
в тулбаре скрывается.
Ошибки открытия (например, `requestWindow()` отклонён) показываются тостом,
не проваливаются молча. Закрытие PiP-окна пользователем через системный
крестик отслеживается через событие `pagehide` окна; при размонтировании
хука (уход со страницы) осиротевшее PiP-окно закрывается явно.
## 5. Вёрстка и CSS
### Токены цвета комнаты
Комната всегда использует `[data-theme="room"]` и токены:
```css
[data-theme="room"] {
--color-room-bg: #1E1E1E; /* Чёрный фон */
--color-room-text-primary: #E8E8E8; /* Светлый текст */
--color-room-mic-on: #7FDDA8; /* Мята (mic включен) */
--color-room-mic-off: #EB93A1; /* Роза (mic выключен) */
--color-room-camera-on: #7FDDA8; /* Мята (camera включена) */
--color-room-camera-off: #EB93A1; /* Роза (camera выключена) */
--color-room-speaker-ring: #D6A83D; /* Янтарь (спикер) */
}
```
### Аватар (CSS)
Базовый класс `.avatar` — общий для всей оболочки (`frontend/src/styles/shell.css`); в комнате плитка добавляет модификатор `.room-tile-avatar` (`frontend/src/styles/room.css`) для адаптивного размера внутри плитки участника:
```css
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-ink-700);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
/* Модификатор для плитки участника комнаты — адаптивный размер */
.room-tile-avatar {
width: 40%;
height: 40%;
min-width: 32px;
min-height: 32px;
max-width: 96px;
max-height: 96px;
font-size: clamp(12px, 3vw, 28px);
font-weight: 700;
}
```
Отдельного визуального варианта для гостей нет — инициалы гостя рендерятся тем же `.avatar`.
### Диалог настроек устройств
Диалог использует общие модальные классы комнаты (`frontend/src/styles/room.css`):
```css
.room-modal-overlay { /* полноэкранная подложка с затемнением */ }
.room-modal-panel { /* сама карточка диалога, --color-room-bg фон */ }
.room-modal-head { display: flex; align-items: flex-start; justify-content: space-between; }
.room-modal-close { /* кнопка закрытия */ }
```
## 6. Демонстрация экрана
### Обзор
Участники конференции (включая гостей) могут поделиться своим экраном или отдельным окном. Демонстрация — это перманентный источник видео (как и камера), публикуется через `Track.Source.ScreenShare`, отображается крупно в фокус-плитке при наличии, а другие участники — в карусели сбоку. Новый демонстратор автоматически перехватывает фокус (политика last-wins); предыдущий остаётся виден как обычная плитка в карусели.
### Управление (UI)
**Кнопка в тулбаре комнаты** (RoomToolbar.tsx, строки 128142):
- Иконка: `ScreenShare` / `ScreenShareOff` (из lucide-react)
- Текст: «Демонстрация»
- Состояние: отражает, активна ли локальная демонстрация текущего участника
- Клик: вызывает `useTrackToggle({ source: Track.Source.ScreenShare, captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS })`
**Жизненный цикл:**
1. **Начало демонстрации:** пользователь нажимает кнопку → браузер показывает диалог выбора экрана/окна/вкладки → пользователь выбирает источник или отменяет → состояние кнопки и сцена обновляются
2. **Во время демонстрации:**
- Трек ScreenShare остаётся активным (публикуется)
- Сцена переходит на focus-раскладку (см. ниже)
- Пользователь может закончить в любой момент: нажать кнопку ещё раз ИЛИ нажать системную кнопку браузера «Прекратить доступ» (в браузере, обычно справа в адресной строке) → состояние синхронизируется автоматически
3. **Конец демонстрации:** трек удаляется, фокус переходит на активного спикера или первого участника
### Опции захвата (ScreenShareCaptureOptions)
Константа `SCREEN_SHARE_CAPTURE_OPTIONS` (RoomToolbar.tsx, строки 3237):
```typescript
{
audio: true, // Захватывать звук (вкладка/экран, где доступно)
selfBrowserSurface: 'exclude', // Не предлагать саму вкладку конференции
surfaceSwitching: 'include', // Разрешить переключать источник во время демо
systemAudio: 'include' // Не запрещать системный звук (если браузер отдаёт)
}
```
**Обработка ошибок:**
- `NotAllowedError` (пользователь нажал «Отмена» в браузерном диалоге) — игнорируется молча
- Прочие ошибки (например, `NotReadableError` при занятом источнике) — показываются в тосте: «Не удалось начать демонстрацию экрана»
### Отображение на сцене (RoomStage.tsx)
#### Focus-раскладка при активной демонстрации
При наличии хотя бы одного активного трека `Track.Source.ScreenShare` (`RoomStage.tsx`, переменная `hasScreenShare`):
- `FocusLayoutContainer` включается БЕЗУСЛОВНО (независимо от количества участников)
- **Фокус-плитка:** первая активная демонстрация (по приоритету выбора)
- **Карусель слева:** все camera-треки + прочие screenshare-треки (проигравшие фокус)
- Инициалы/аватары заменены плитками видео, но если камера выключена — показывается аватар
#### Политика last-wins для нескольких демонстраторов
Логика в функции `pickStageFocus` (stageFocus.ts):
- Если новый участник запустил демонстрацию → её трек становится фокусом
- Предыдущая демонстрация остаётся в карусели как обычная плитка (не удаляется)
- Каждый демонстратор может остановить свою демонстрацию независимо
#### Остановка собственной демонстрации
В фокус-плитке, когда текущий участник демонстрирует экран:
- Отображается чип/кнопка: «Вы демонстрируете экран» ← click → вызов `room.localParticipant.setScreenShareEnabled(false)`
- Трек прекращается, фокус переходит на спикера
- Логика в `RoomStage.tsx` (функция `handleStopSharing`, передаётся в `RoomParticipantTile`)
### Поведение в fullscreen/PiP
**Fullscreen:**
- Демонстрация экрана работает в fullscreen-режиме как обычно
- Focus-раскладка сохраняется: демонстрация крупно, участники узкой колонкой
- Выход из fullscreen (кнопка или ESC) возвращает стандартный вид
**Document Picture-in-Picture (Chrome/Edge 116+):**
- При открытии PiP-окна показывается только активная плитка (без карусели/грида)
- Если активна демонстрация → в PiP она и отображается (фокус)
- Фокус в PiP живо следует за активным спикером (`followSpeaker`), в отличие от основного окна
- Закрытие PiP-окна возвращает вид на основное окно
**Video Picture-in-Picture (Safari/Firefox фолбэк):**
- Показывает только активное видео из фокус-плитки основного окна
- При активной демонстрации → в PiP видно именно её
### Права доступа
- **Любой участник** может начать демонстрацию экрана, включая гостей
- Нет специальных прав или ролей для демонстрации
- Ограничение: браузер может запросить разрешение на доступ к экрану у ОС (обычно да/нет в диалоге браузера)
### Звук демонстрации: матрица браузеров
Функция `getDisplayMedia` (WebRTC API) отдаёт аудио-дорожку демонстрации там, где браузер и ОС позволяют. Ниже матрица по браузерам и ОС.
| Браузер / ОС | Вкладка (tab) | Весь экран | Отдельное окно | Примечания |
|---|:---:|:---:|:---:|---|
| **Chrome/Edge — Windows** | ✓ Да | ✓ Да (системный звук) | ⚠ Обычно нет | Вкладка: звук вкладки; весь экран: системный звук; окно — редко отдаёт звук (зависит от окна) |
| **Chrome/Edge — Linux** | ✓ Да | ✓ Да (PulseAudio) | ⚠ Редко | PulseAudio выбирает источник; окно ≈ как на Windows |
| **Chrome/Edge — macOS** | ✓ Да | ✗ Нет (ОС не отдаёт) | ✗ Нет (ОС не отдаёт) | Вкладка работает; весь экран и окно — ОС macOS не предоставляет системный звук браузеру для безопасности |
| **Safari 16+ — macOS** | ✓ Да | ✗ Нет | ✗ Нет | Поддержка `getDisplayMedia` есть, звук не отдаётся; некоторые поля ScreenShareCaptureOptions (systemAudio) игнорируются |
| **Firefox — Windows/Linux/macOS** | ✓ Да | ✓ Да | ⚠ Редко | Firefox поддерживает getDisplayMedia, поля systemAudio/selfBrowserSurface могут игнорироваться — безопасная деградация |
**Ключевые моменты:**
1. **SDK не фейлит старт без аудио:** если браузер не может захватить звук, `getDisplayMedia()` всё равно вернёт видео-дорожку (без аудио) — демонстрация работает, просто без звука
2. **RoomAudioRenderer:** компонент (`RoomStage.tsx`) проигрывает аудио-дорожки удалённых демонстраций, если они присутствуют в треках
3. **Почему macOS без системного звука?** Согласно WebRTC спецификации и политике безопасности Apple, браузеры на macOS не получают системный звук через `getDisplayMedia()` — только звук текущей вкладки. Пользователь должен явно выбрать вкладку (браузер, плеер, Zoom и т. д.) в диалоге браузера, чтобы захватить её звук.
## Примечания
1. **Аватары гостей:** гости не получают `avatar_url` в метаданных LiveKit-токена, поэтому всегда видят инициалы.
2. **Document PiP требует взаимодействия:** запрос можно сделать только в ответ на `click` или похожий пользовательский жест (security policy браузера) — `toggle()` хука `useRoomPiP` поэтому вызывается синхронно из обработчика клика.
3. **Video PiP показывает только одну плитку:** если нужна сетка целиком, используйте Document PiP (Chrome/Edge).
4. **Fullscreen работает везде:** но некоторые браузеры могут показать UI-запрос перед вводом.
5. **Персист выбора устройств** — через `usePersistentUserChoices` из `@livekit/components-react`; наличие сохранённого устройства не гарантирует, что оно всё ещё подключено — библиотека сама обрабатывает этот случай при следующем входе.
6. **Демонстрация экрана требует пользовательского жеста:** браузер требует клика/касания перед открытием диалога выбора экрана (Permissions Policy, безопасность).
7. **Звук демонстрации теряется в macOS:** если требуется захват системного звука, пользователю на Mac нужно выбрать конкретную вкладку браузера/плеера (не «весь экран»).
## Ссылки
- `frontend/src/pages/RoomPage.tsx` — главный компонент комнаты, подключение LiveKit, порталы PiP
- `frontend/src/components/room/RoomToolbar.tsx` — тулбар (микрофон, камера, демонстрация, настройки, fullscreen, PiP, чат, выход)
- `frontend/src/components/room/RoomStage.tsx` — сцена с focus-раскладкой и логикой демонстрации экрана
- `frontend/src/components/room/stageFocus.ts` — чистая функция выбора фокуса (`pickStageFocus`, last-wins)
- `frontend/src/components/room/RoomParticipantTile.tsx` — плитка участника, аватар, парсинг метаданных
- `frontend/src/components/room/DeviceSettingsDialog.tsx` — диалог настроек микрофона/камеры
- `frontend/src/hooks/useFullscreen.ts` — полноэкранный режим
- `frontend/src/hooks/useRoomPiP.ts` — мини-плеер (Document PiP + Video PiP фолбэк)
- [Document Picture-in-Picture Spec](https://w3c.github.io/document-picture-in-picture/) — W3C
- [Picture-in-Picture Spec](https://www.w3.org/TR/picture-in-picture/) — W3C (video PiP)
- [Fullscreen API](https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API) — MDN
- [Screen Capture API (getDisplayMedia)](https://w3c.github.io/mediacapture-screen-share/) — W3C
- `docs/architecture/frontend-themes.md` — тёмная тема комнаты

View File

@@ -0,0 +1,224 @@
# Архитектура тем оболочки VidConf
**Ссылки:** `design/DESIGN_SYSTEM.md` §0§0.1, `design/mockups/dark/README.md`, `frontend/README.md` раздел «Темы оболочки»
## Суть
Оболочка VidConf (auth, лобби, календарь, join, админка) поддерживает две темы:
- **Светлая** — дефолт, индиго-на-белом
- **Тёмная** — графит-мята-роза-янтарь
Переключатель (☀️/🌙) в UI; автодетект системной темы (`prefers-color-scheme`); выбор сохраняется в `localStorage`. Комната конференции — всегда своя тёмная тема, не затрагивается оболочкой.
## Механизм переключения
### Атрибут `data-theme` на `<html>`
```
:root → светлая (индиго-на-белом)
:root[data-theme="dark"] → тёмная (мята-на-графите)
:root[data-theme="light"] → светлая (явный выбор)
[data-theme="room"] → комната (отдельный набор токенов, не переключается)
```
**Приоритет:**
1. Явный выбор (`data-theme="light"` или `data-theme="dark"`), если есть
2. Системная тема (`@media (prefers-color-scheme: dark)`), если явного выбора нет
3. Светлая по дефолту, если ОС не поддерживает `prefers-color-scheme`
### Токены CSS (design/tokens.css)
```css
/* Светлая — базовая */
:root {
--color-bg: #F1F1F1;
--color-ink-900: #2E3454;
--color-ink-700: #3B4D95; /* индиго-бренд */
--color-accent: #D4F2E3; /* пастель-мята CTA */
/* … ещё 20+ токенов */
}
/* Тёмная — явный выбор пользователя */
:root[data-theme="dark"] {
--color-bg: #1E1E1E;
--color-ink-900: #E8E8E8;
--color-ink-700: #7FDDA8; /* мята вместо индиго */
--color-accent: #7FDDA8; /* мята CTA */
/* … новые значения, тот же набор переменных */
}
/* Тёмная — автодетект ОС (если выбора нет) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
/* тот же набор, что и [data-theme="dark"] */
}
}
/* Комната — независимый третий скоуп */
[data-theme="room"] {
--color-room-bg: #1E1E1E;
--color-room-mic-on: #7FDDA8; /* пастель-мята */
--color-room-mic-off: #EB93A1; /* пастель-роза */
/* … отдельный неймспейс, не трогается переключателем */
}
```
**Ключевой момент:** весь CSS оболочки ссылается на переменные через `var()`, поэтому его не нужно менять под разные темы. Переиспользование `var()` обеспечивает переключение автоматически.
### Сохранение выбора (localStorage)
Ключ: `vidconf-theme` (строго совпадает между `frontend/index.html` и `src/hooks/useTheme.ts`).
Значения:
- `'light'``data-theme="light"` на `<html>`
- `'dark'``data-theme="dark"` на `<html>`
- `null` / отсутствует → `data-theme` **не проставляется**, действует системная тема
## Реализация (React)
### Hook: `src/hooks/useTheme.ts`
```typescript
export function useTheme() {
// Читает явный выбор из localStorage при монтировании
const [explicit, setExplicit] = useState<'light' | 'dark' | null>(() => readStoredTheme())
// Живое отслеживание системной темы (matchMedia listener)
const [systemPrefersDark, setSystemPrefersDark] = useState(...)
// Вычисляет текущую активную тему: явный выбор ИЛИ системная
const resolved: 'light' | 'dark' = explicit ?? (systemPrefersDark ? 'dark' : 'light')
// Проставляет data-theme на <html> и сохраняет в localStorage
const setTheme = (theme: 'light' | 'dark') => { /* … */ }
return { resolved, setTheme }
}
```
**Особенность:** читает из localStorage ДО первого рендера (через инициализатор `useState`), чтобы синхронизировать с инлайн-скриптом в index.html.
### Компонент: `src/components/ui/ThemeToggle.tsx`
```tsx
export function ThemeToggle({ className = '' }: { className?: string }) {
const { resolved, setTheme } = useTheme()
return (
<div className="theme-toggle" role="group" aria-label="Переключить тему">
<button
className={resolved === 'light' ? 'is-active' : ''}
onClick={() => setTheme('light')}
aria-label="Светлая тема"
>
<Sun className="icon" />
</button>
<button
className={resolved === 'dark' ? 'is-active' : ''}
onClick={() => setTheme('dark')}
aria-label="Тёмная тема"
>
<Moon className="icon" />
</button>
</div>
)
}
```
**Монтирование:**
- `ShellTopbar.tsx` (лобби, календарь, админка, мои конференции)
- `AuthLayout.tsx` (login, register, verify-email)
- `JoinPage.tsx` (вход по номеру/ссылке)
### Анти-FOUC: инлайн-скрипт (frontend/index.html)
```html
<script>
try {
var vidconfTheme = localStorage.getItem('vidconf-theme')
if (vidconfTheme === 'light' || vidconfTheme === 'dark') {
document.documentElement.setAttribute('data-theme', vidconfTheme)
}
} catch (e) {
// localStorage недоступен (приватный режим) — работает системная тема
}
</script>
```
Выполняется **до** бандла React, до `<div id="root">`. Исключает вспышку светлой темы при загрузке тёмного интерфейса.
## Палитра тёмной оболочки
Не новые цвета, а переиспользование уже утверждённых токенов комнаты:
| Роль | Светлая | Тёмная | Hex |
|---|---|---|---|
| Основной текст | `--color-ink-900` (индиго) | `--color-room-text-primary` | `#E8E8E8` |
| Бренд/заголовки | `--color-ink-700` (индиго) | `--color-room-mic-on` (мята) | `#7FDDA8` |
| CTA заливка | `--color-accent` (пастель-мята) | `--color-room-mic-on` (мята) | `#7FDDA8` |
| Статус ошибки | `--color-danger` (красный) | `--color-room-mic-off` (роза) | `#EB93A1` |
| Danger-кнопка (заливка) | `--color-danger` | `--color-danger-solid` | `#B85468` |
| Warning/focus | `--color-warning` (янтарь) | `--color-room-speaker-ring` (янтарь) | `#D6A83D` |
**Новые токены для оболочки** (не в комнате, но получены смешиванием утверждённых цветов):
- `--color-accent-text` (`#10331F`) — текст на мятных кнопках
- `--color-accent-hover` / `--color-accent-active` — состояния CTA
- `--color-ink-400` — подписи в скобках
- `--color-success-bg` / `--color-danger-bg` / `--color-warning-bg` — подложки бейджей
Подробный расчёт (WCAG 2.1, контраст ≥ 4.5:1) — см. `design/mockups/dark/README.md`.
## Комната конференции (инвариант)
`[data-theme="room"]` — скоуп контейнера экрана конференции (обычно на `<div class="room-container">`):
- Использует отдельный набор токенов `--color-room-*` (§1.2a `DESIGN_SYSTEM.md`)
- **Никогда** не переключается на светлую тему
- **Не слушает** `prefers-color-scheme` и `data-theme` на `<html>`
- Всегда тёмная, независимо от выбора пользователя в лобби
Пример в `RoomPage.tsx`:
```tsx
return <div data-theme="room" className="room-container">
{/* весь контент комнаты — видео, участники, чат, тулбар */}
</div>
```
## CSS страниц оболочки
Каждая страница оболочки (auth, lobby, calendar, join, my-conferences, admin) имеет свой файл стилей:
- `frontend/src/styles/auth.css`
- `frontend/src/styles/lobby.css`
- `frontend/src/styles/calendar.css`
- `frontend/src/styles/join.css`
- `frontend/src/styles/my-conferences.css`
- `frontend/src/styles/admin.css`
Они содержат точечные правки сверх токенов (декоративные градиенты, тени, layout-контроль), но **не задают цвета** — цвета задаются через `var()` из `tokens.css`. При переключении темы CSS-переменные меняются автоматически. Отдельные патчи под тёмную тему нужны там, где значение задано литеральным hex, а не через `var()` — см. комментарии в файлах.
## Тестирование
```bash
# Проверить, что localStorage-ключ совпадает
grep -n 'vidconf-theme' frontend/index.html
grep -n 'STORAGE_KEY' frontend/src/hooks/useTheme.ts
# Запустить dev сервер
cd frontend && npm run dev
# Выключить в DevTools: localStorage → удалить vidconf-theme
# Проверить, что тема следует prefers-color-scheme ОС
# Кликнуть на переключатель (☀️/🌙)
# Проверить, что localStorage получил видconf-theme='dark' или 'light'
# Перезагрузить (F5) — тема должна восстановиться без вспышки
# Открыть RoomPage — комната должна остаться тёмной, несмотря на светлую оболочку
```
## Ссылки
- `design/DESIGN_SYSTEM.md` — полная дизайн-система (§0§1.2b)
- `design/tokens.css` — авторитетный набор переменных (синхронизировано с frontend/src/styles/tokens.css)
- `design/mockups/dark/` — макеты тёмной оболочки (утверждены)
- `frontend/README.md` — раздел «Темы оболочки»
- `design/tools/contrast.py` — скрипт проверки контраста WCAG 2.1 (покрывает и тёмные пары)

0
docs/db/.gitkeep Normal file
View File

664
docs/db/schema.md Normal file
View File

@@ -0,0 +1,664 @@
# Схема БД
## ER-диаграмма
```mermaid
erDiagram
USERS ||--o{ CONFERENCES : owns
USERS ||--o{ CONFERENCE_PARTICIPANTS : joins
USERS ||--o{ CONFERENCE_INVITEES : invites
USERS ||--o{ PHRASES : speaks
USERS ||--o{ CHAT_MESSAGES : "sends (author)"
USERS ||--o{ EMAIL_VERIFICATION_TOKENS : receives
CONFERENCES ||--o{ CONFERENCE_INVITEES : "has invitees"
CONFERENCES ||--o{ CONFERENCE_SESSIONS : "has sessions"
CONFERENCES ||--o{ GUEST_ACCESS : "may allow"
CONFERENCES ||--o{ EMAIL_DELIVERIES : "invitations"
CONFERENCE_SESSIONS ||--o{ CONFERENCE_PARTICIPANTS : tracks
CONFERENCE_SESSIONS ||--o{ PHRASES : contains
CONFERENCE_SESSIONS ||--o{ CHAT_MESSAGES : contains
CONFERENCE_SESSIONS ||--o{ EMAIL_DELIVERIES : "summaries"
GUEST_ACCESS ||--o{ CONFERENCE_PARTICIPANTS : "may join"
GUEST_ACCESS ||--o{ CHAT_MESSAGES : "sends (author)"
LIVEKIT_WEBHOOK_EVENTS ||--o{ CONFERENCE_SESSIONS : "triggers"
INSTANCE_SETTINGS ||--|| CONFERENCES : "configure"
TEAMS ||--o{ USERS : "groups"
USERS {
uuid id PK
string email UK
string name_user
text password_hash
string role "admin|user"
boolean email_verified
boolean is_blocked
uuid team_id FK "nullable"
string avatar_path "nullable"
datetime created_at
}
EMAIL_VERIFICATION_TOKENS {
uuid id PK
uuid user_id FK
string token_hash UK
datetime expires_at
datetime used_at "nullable"
datetime created_at
}
CONFERENCES {
uuid id PK
string number UK "9 digits"
string slug UK "base64url"
string title "nullable"
uuid owner_id FK "nullable"
string status "enum: scheduled|active|ended"
boolean is_pinned
boolean is_closed
text password_hash "nullable"
datetime scheduled_at "nullable"
integer duration_minutes "nullable"
jsonb recurrence "nullable"
datetime ended_at "nullable"
datetime created_at
}
CONFERENCE_INVITEES {
uuid id PK
uuid conference_id FK
uuid user_id FK "nullable, для зарег. пользователей"
string email "nullable, для внешних"
datetime created_at
"CHECK: ровно один из user_id/email"
"UNIQUE: (conference_id, user_id)"
"UNIQUE: (conference_id, lower(email))"
}
CONFERENCE_SESSIONS {
uuid id PK
uuid conference_id FK
string title "nullable"
datetime t_start
datetime t_end "nullable"
text summary_data "nullable"
string pipeline_status "enum"
datetime created_at
}
GUEST_ACCESS {
uuid id PK
uuid conference_id FK
string display_name
string email "nullable"
datetime created_at
}
CONFERENCE_PARTICIPANTS {
uuid id PK
uuid session_id FK
uuid user_id FK "nullable"
uuid guest_id FK "nullable"
datetime joined_at
datetime left_at "nullable"
}
SESSION_AUDIO_TRACKS {
uuid id PK
uuid session_id FK
uuid participant_id FK
string track_sid
string egress_id "nullable"
text file_path "nullable"
string status "enum"
datetime started_at
datetime ended_at "nullable"
jsonb segments "nullable"
}
PHRASES {
bigint id PK "Identity"
uuid participant_id FK
uuid session_id FK
text data
datetime t_start
datetime t_end
}
CHAT_MESSAGES {
bigint id PK "Identity"
uuid session_id FK
uuid user_id FK "nullable"
uuid guest_access_id FK "nullable"
string author_name "255 chars"
text text
datetime created_at
}
LIVEKIT_WEBHOOK_EVENTS {
string event_id PK
string event_type
datetime received_at
}
INSTANCE_SETTINGS {
string key PK
jsonb value
datetime updated_at
}
EMAIL_DELIVERIES {
uuid id PK
uuid session_id FK "nullable"
uuid conference_id FK "nullable"
string recipient_email
string kind "summary|invitation"
datetime sent_at
}
```
## Таблицы
### users
Зарегистрированные пользователи приложения.
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `email` | VARCHAR(255) | Уникальный адрес электронной почты |
| `name_user` | VARCHAR(255) | Имя пользователя |
| `password_hash` | TEXT | Argon2-хэш пароля |
| `role` | VARCHAR(16) | 'admin' или 'user'; проверка CHECK |
| `email_verified` | BOOLEAN | Флаг подтверждения почты |
| `is_blocked` | BOOLEAN | Флаг блокировки администратором; заблокированный пользователь получает 401 немедленно, без ожидания истечения access-токена |
| `team_id` | UUID | FK → teams, nullable; `ON DELETE SET NULL` — удаление команды не удаляет пользователей |
| `avatar_path` | VARCHAR(512) | Путь к загруженному аватару (относительно `MEDIA_ROOT`); `NULL` — заглушка с инициалами на фронте; пример: `avatars/550e8400-e29b-41d4-a716-446655440000.jpg` |
| `created_at` | TIMESTAMPTZ | UTC-время создания |
### teams
Справочник команд для группировки пользователей.
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `name` | VARCHAR(255) | Название команды; уникально |
| `created_at` | TIMESTAMPTZ | UTC-время создания |
**Логика:** удаление команды не каскадирует на пользователей — `users.team_id` просто обнуляется (`ON DELETE SET NULL`); привязка необязательна.
### email_verification_tokens
Одноразовые токены подтверждения email при регистрации.
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `user_id` | UUID | FK → users; CASCADE удаление |
| `token_hash` | TEXT | SHA256-хэш одноразового токена (hex); уникален |
| `expires_at` | TIMESTAMPTZ | Срок действия (UTC); по умолчанию 24 часа от создания |
| `used_at` | TIMESTAMPTZ | Время использования; NULL если не подтверждён |
| `created_at` | TIMESTAMPTZ | UTC-время создания |
**Ограничения:**
- `UNIQUE(token_hash)` — уникальный индекс на хэш токена
- `INDEX(user_id)` — для быстрого поиска токенов пользователя
**Логика:**
- При регистрации создаётся новый токен; клиент получает открытый токен в письме
- Сервер хранит только SHA256-хэш (токен не может быть восстановлен из БД)
- Первое использование (POST /verify-email) заполняет `used_at`, устанавливает `user.email_verified=true`
- Повторное использование того же токена → 400 (used_at IS NOT NULL)
### conferences
Конференция — постоянная сущность с номером и постоянной ссылкой (slug). Может быть мгновенной или плановой с повторением (ADR-001).
**Миграция:** Таблица `conferences` переименована в `conference_sessions` (см. ниже); новая таблица `conferences` создана миграцией `f418dd65e7b1`.
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `number` | VARCHAR(9) | 9 десятичных цифр, первая 1..9, уникален; генерируется с retry при коллизии |
| `slug` | VARCHAR(22) | `secrets.token_urlsafe(8)` — 11 base64url-символов; используется как имя комнаты в LiveKit и часть URL `/j/{slug}` |
| `title` | VARCHAR(255) | Опциональное название конференции |
| `owner_id` | UUID | FK → users; SET NULL при удалении пользователя; может быть NULL для исторических данных |
| `status` | ENUM | `scheduled` (плановая, время ещё не наступило), `active` (идёт сейчас), `ended` (завершена; история остаётся) |
| `is_pinned` | BOOLEAN | Закреплена ли (видна в «Моих конференциях», может повторяться) |
| `is_closed` | BOOLEAN | Требуется ли пароль для входа |
| `password_hash` | TEXT | Argon2-хэш пароля (только если is_closed=true); CHECK: `is_closed=false OR password_hash IS NOT NULL` |
| `scheduled_at` | TIMESTAMPTZ | Время запуска (UTC); NULL для мгновенных |
| `duration_minutes` | INT | Ожидаемая длительность в минутах; опционально, для информации |
| `recurrence` | JSONB | RecurrenceRule (JSON с type/weekdays/day_of_month/interval_days/anchor_date/time_local/timezone/duration_minutes); только если is_pinned=true; CHECK: `recurrence IS NULL OR is_pinned = true` |
| `ended_at` | TIMESTAMPTZ | Время завершения (UTC); заполняется при статусе → ended |
| `summary_recipients` | VARCHAR(16) | Переопределение рассылки саммари для конференции: `'all'` (всем участникам и гостям с email), `'owner'` (только организатору), NULL (используется дефолт инстанса) |
| `ics_sequence` | INT | Счётчик изменений расписания: растёт при правке `scheduled_at`, `duration_minutes`, `recurrence`, `title`; пишется в VEVENT SEQUENCE для обновления приглашений календарными клиентами |
| `created_at` | TIMESTAMPTZ | UTC-время создания |
**Ограничения:**
- `UNIQUE(number)` — уникальность номера
- `UNIQUE(slug)` — уникальность постоянной ссылки
- CHECK: `is_closed = false OR password_hash IS NOT NULL`
- CHECK: `recurrence IS NULL OR is_pinned = true`
### conference_sessions
Один запуск конференции, единица AI-пайплайна пост-обработки.
**Миграция:** До миграции `f418dd65e7b1` это была таблица `conferences`; переименована в `conference_sessions`, связь `conference_id` добавлена.
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key |
| `conference_id` | UUID | FK → conferences; CASCADE удаление |
| `title` | VARCHAR(255) | Снапшот названия конференции на момент запуска (опционально) |
| `t_start` | TIMESTAMPTZ | Время начала сеанса (UTC) |
| `t_end` | TIMESTAMPTZ | Время окончания сеанса (UTC); опционально, заполняется при завершении |
| `summary_data` | TEXT | JSON со сводкой (заполняется после summarize-шага); опционально |
| `pipeline_status` | ENUM | Статус пайплайна: `recording``transcribing``summarizing``notified` / `failed` |
| `created_at` | TIMESTAMPTZ | UTC-время создания |
**Индексы:**
- `(conference_id, t_start)` для поиска сеансов конференции
- `(pipeline_status)` для поиска активных обработок
Статус-машина пост-обработки живёт в этой таблице (см. ADR-001).
### guest_access
Гость, представившийся при входе в конференцию без аутентификации (ADR-001, п.6).
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key |
| `conference_id` | UUID | FK → conferences; CASCADE удаление |
| `display_name` | VARCHAR(255) | Отображаемое имя гостя (обязательно) |
| `email` | VARCHAR(320) | Адрес электронной почты (опционально; для рассылки саммари) |
| `created_at` | TIMESTAMPTZ | UTC-время создания записи (при первом входе гостем) |
**Назначение:**
- Гость входит в конференцию без аккаунта (публичный эндпоинт `/guest-join`)
- LiveKit identity: `guest:{guest_access.id}`
- Email не передаётся в LiveKit (PII — только в БД)
### conference_participants
Отслеживает присутствие участника (пользователя ИЛИ гостя) в сеансе конференции (ADR-001, п.6).
**Миграция:** Колонка `conference_id` переименована в `session_id` (`f418dd65e7b1`); `user_id` стал nullable; добавлены `guest_id` и CHECK для ровно одного identity.
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key |
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
| `user_id` | UUID | FK → users; NULL для гостей; nullable |
| `guest_id` | UUID | FK → guest_access; NULL для зарегистрированных; nullable |
| `joined_at` | TIMESTAMPTZ | Время входа (UTC) |
| `left_at` | TIMESTAMPTZ | Время выхода (UTC); опционально |
**Ограничения:**
- CHECK: `(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1` — ровно одно из user_id/guest_id заполнено
**Индексы:**
- `(session_id)` для поиска участников сеанса
### conference_invitees
Приглашённые на конференцию участники (зарегистрированные пользователи или внешние email'ы) — ADR-003.
**Миграция:** `d87681e12784`
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `conference_id` | UUID | FK → conferences; CASCADE удаление |
| `user_id` | UUID | FK → users; nullable; CASCADE удаление; заполнено для зарегистрированных пользователей |
| `email` | VARCHAR(255) | Email для внешних приглашённых; nullable; нормализуется в lower-case |
| `created_at` | TIMESTAMPTZ | UTC-время создания приглашения |
**Ограничения:**
- CHECK: `(user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1` — ровно один из user_id/email должен быть заполнен (единственная identity приглашённого)
- **UNIQUE INDEX `uq_conference_invitees_user`** на `(conference_id, user_id)` WHERE `user_id IS NOT NULL` — один приглашённый пользователь на конференцию
- **UNIQUE INDEX `uq_conference_invitees_email`** на `(conference_id, lower(email))` WHERE `email IS NOT NULL` — один внешний email на конференцию (регистронезависимо)
**Назначение:**
- Хранение состава приглашённых участников, заданного организатором при создании/редактировании конференции
- **Организатор НЕ хранится в этой таблице** — он выводится из `conferences.owner_id` и всегда в составе (инвариант обеспечен конструктивно, без триггеров)
- **Не путать с `conference_participants`** — та таблица фиксирует ФАКТИЧЕСКОЕ присутствие в сеансе; эта — планируемый состав
**Семантика:**
- Дедупликация при POST/PATCH конференции: если организатор передан в массиве participants, он автоматически вычищается (не хранится дважды)
- Внешние приглашённые (email) при входе по ссылке создают запись в `guest_access`, которая затем связывается с `conference_participants` сеанса
### session_audio_tracks
Аудиодорожка сеанса, записанная LiveKit Track Egress. Каждая строка соответствует одному аудиотреку (микрофону) одного участника.
**Таблица используется для:**
- Маппинга файлов записи к участникам сеанса (включая гостей без user_id)
- Отслеживания статуса транскрибации каждого трека (recording → recorded → transcribed / failed)
- Хранения сегментов Whisper в JSONB (`segments`) как промежуточного артефакта пайплайна
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
| `participant_id` | UUID | FK → conference_participants; CASCADE удаление; NOT NULL (атрибуция к участнику сеанса, см. ADR-002) |
| `track_sid` | VARCHAR(64) | ID трека в LiveKit (уникален в пределах сеанса с session_id) |
| `egress_id` | VARCHAR(64) | ID egress-задачи в LiveKit; заполняется при старте egress; может быть NULL |
| `file_path` | TEXT | Абсолютный путь к файлу на диске (например, `/recordings/{session_id}/{participant_id}_{track_sid}.ogg`); заполняется при завершении egress |
| `status` | ENUM | Статус трека: `recording` (egress активен) → `recorded` (файл готов) → `transcribed` (Whisper завершил) / `failed` (ошибка на любом шаге) |
| `started_at` | TIMESTAMPTZ | UTC-время старта egress (из EgressInfo) |
| `ended_at` | TIMESTAMPTZ | UTC-время завершения egress; NULL пока трек в статусе `recording` |
| `segments` | JSONB | Список объектов `{start: float, end: float, text: string}` — результат faster-whisper для этого трека; NULL пока не транскрибирован |
**Ограничения:**
- `UNIQUE(session_id, track_sid)` — один трек на сеанс; дедупликация при повторных `track_published`
- Индексы: `(session_id)`, `(session_id, status)` для поиска активных и готовых треков
**Инвариант:**
- Независимый статус трека (отдельно от `conference_sessions.pipeline_status`)
- Тайминги сегментов в `segments` — относительно начала файла (не UTC); абсолютный расчёт: `session.t_start + timedelta(seconds=segment.start)`
### phrases
Восстановленные транскрипт-фразы (результат faster-whisper + реконструкция по алгоритму ТЗ §1.3).
**Изменение:** Колонка `user_id` заменена на `participant_id` (FK → conference_participants), чтобы атрибутировать фразы гостям без user_id (ADR-002).
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | BIGINT | Primary key, Identity(always=True) |
| `participant_id` | UUID | FK → conference_participants; CASCADE удаление; NOT NULL; атрибуция к участнику сеанса |
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
| `data` | TEXT | Текст фразы (результат `build_phrases()` — может быть несколько сегментов Whisper одного спикера подряд) |
| `t_start` | TIMESTAMPTZ | Начало фразы (UTC); рассчитывается как `session.t_start + (segment.start + track_offset)` |
| `t_end` | TIMESTAMPTZ | Конец фразы (UTC) |
**Индексы:**
- `(session_id, t_start)` для хронологического поиска фраз в сеансе
**Как получить пользователя/гостя фразы:**
```sql
SELECT p.data, u.name_user, g.display_name
FROM phrases p
JOIN conference_participants cp ON p.participant_id = cp.id
LEFT JOIN users u ON cp.user_id = u.id
LEFT JOIN guest_access g ON cp.guest_id = g.id
WHERE p.session_id = $1
ORDER BY p.t_start;
```
### chat_messages
Текстовые сообщения, отправленные во время сеанса конференции (WS чат).
**Миграции:**
- `f418dd65e7b1`: колонка `conference_id` переименована в `session_id`
- `504791847d4f`: добавлены `guest_access_id` и `author_name` для поддержки гостевых авторов
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | BIGINT | Primary key, Identity(always=True), уникален в пределах инстанса |
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
| `user_id` | UUID | FK → users; nullable; заполнено для авторов-пользователей |
| `guest_access_id` | UUID | FK → guest_access; nullable; CASCADE удаление; заполнено для авторов-гостей |
| `author_name` | VARCHAR(255) | Снапшот отображаемого имени автора из LiveKit access-токена на момент отправки; NOT NULL (переживает переименование пользователя или удаление гостевой записи) |
| `text` | TEXT | Текст сообщения (1..2000 символов) |
| `created_at` | TIMESTAMPTZ | UTC-время создания |
**Ограничения:**
- CHECK: `user_id IS NOT NULL OR guest_access_id IS NOT NULL` — ровно один из user_id/guest_access_id должен быть заполнен (аналогично `conference_participants`, ADR-001 п.6)
**Индексы:**
- `(session_id, created_at)` для хронологического поиска сообщений в сеансе
**Назначение:** Хранение истории чата конференции для отдачи последних N сообщений при подключении нового клиента; публикация в Redis pub/sub для broadcast остальным клиентам
### instance_settings
Настройки инстанса как key-value хранилище (JSONB). Бутстрап из `config/plugins.yaml` при старте backend'а (однократный, идемпотентный импорт).
| Поле | Тип | Описание |
|------|-----|---------|
| `key` | TEXT | Primary key; имя настройки (`transcriber`, `summarizer`, `chat`, `ai_level`, `summary_recipients`, `display_timezone`, `registration_team_choice`, `registration_email_domain`) |
| `value` | JSONB | Значение настройки (структура зависит от ключа) |
| `updated_at` | TIMESTAMPTZ | UTC-время последнего обновления |
**Назначение:**
- Централизованное хранилище конфигурации инстанса (переносит дефолты из `config/plugins.yaml` в БД)
- Административный интерфейс может менять настройки без рестарта приложения
- Воркеры Celery читают конфигурацию на старте каждой задачи (fallback на YAML при пустой таблице)
**Ключи и типы значений (примеры):**
```json
{
"transcriber": {"enabled": true, "provider": "faster-whisper-cpu", "language": "ru", ...},
"summarizer": {"enabled": true, "provider": "qwen-local", "chunk_minutes": 20, ...},
"chat": {"enabled": false},
"ai_level": {"level": "min"},
"summary_recipients": {"mode": "all"},
"display_timezone": {"tz": "Europe/Moscow"},
"registration_team_choice": {"enabled": true},
"registration_email_domain": {"enabled": true, "domain": "example.com"}
}
```
### email_deliveries
Журнал отправленных писем (саммари и приглашения). Идемпотентность рассылки саммари и отслеживание доставки.
| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `session_id` | UUID | FK → conference_sessions; NULL для приглашений; заполняется для саммари |
| `conference_id` | UUID | FK → conferences; NULL для саммари; заполняется для приглашений |
| `recipient_email` | VARCHAR(320) | Email получателя (хранится в `lower()` — нормализованный) |
| `kind` | VARCHAR(16) | Тип письма: `'summary'` (саммари сеанса), `'invitation'` (.ics приглашение) |
| `sent_at` | TIMESTAMPTZ | UTC-время отправки (server_default=now()) |
**Ограничения:**
- CHECK: `kind IN ('summary', 'invitation')`
- CHECK: `(kind = 'summary') = (session_id IS NOT NULL)` — саммари обязана иметь session_id
- CHECK: `(kind = 'invitation') = (conference_id IS NOT NULL)` — приглашение обязано иметь conference_id
- **UNIQUE INDEX `uq_email_deliveries_summary`** на `(session_id, recipient_email)` WHERE `kind = 'summary'` — гарантирует, что один адресат получит саммри сеанса один раз (идемпотентность повторной отправки)
- INDEX `ix_email_deliveries_conference` на `conference_id` для быстрого поиска истории приглашений
**Назначение:**
- **Для саммари:** гарантирует идемпотентность — повторный запуск `notify_session` отправляет письмо только тем адресатам, которые ещё не в таблице
- **Для приглашений:** журнал, без unique (изменение расписания конференции переслёт приглашение, допуская дублирование в истории)
### livekit_webhook_events
Журнал идемпотентной доставки webhook-событий от LiveKit.
| Поле | Тип | Описание |
|------|-----|---------|
| `event_id` | VARCHAR(255) | Primary key (уникальный ID события от LiveKit) |
| `event_type` | VARCHAR(64) | Тип события: `room_started`, `participant_joined`, `participant_left`, `room_finished` и др. |
| `received_at` | TIMESTAMPTZ | UTC-время получения события |
**Назначение:**
- Дедупликация: каждое webhook-событие от LiveKit имеет уникальный `event_id`
- При получении события выполняется `INSERT ... ON CONFLICT DO NOTHING` по primary key `event_id`
- Повторная доставка того же события отклоняется на уровне БД; ответ 200 всё равно отправляется (идемпотентность)
## Ключевые решения архитектуры
### UUID как primary key
Все таблицы, кроме `phrases` и `chat_messages`, используют `UUID` генерируемые функцией `gen_random_uuid()`. Это обеспечивает:
- Глобальную уникальность без синхронизации
- Предсказуемую нагрузку на индексы
- Возможность распределённых вставок
### BIGINT Identity для phrases и chat_messages
Высокочастотные таблицы используют `BIGINT Identity(always=True)` — быстрее и проще для очередей обработки фраз/сообщений.
### Номер и slug конференции (9 цифр + base64url)
- **Номер:** 9 десятичных цифр, первая 1..9 (энтропия ≈2^29.75); генерируется с retry при коллизии; задача перебора при rate limit ≈60+ дней; резолв отвечает единообразным 404 (см. ADR-001, п.4)
- **Slug:** `secrets.token_urlsafe(8)` — 11 base64url-символов (64 бита энтропии); используется как имя комнаты в LiveKit и часть URL `/j/{slug}`
### Расширение btree_gist (не используется)
**Миграция `f418dd65e7b1`:** расширение `btree_gist` больше не используется (EXCLUDE constraint на `room_bookings` снят с удалением таблицы, см. ADR-001). Расширение остаётся установленным в БД (безвредно, миграция обратима).
### Все временные метки в UTC
Все столбцы `DateTime(timezone=True)` хранят время в UTC на сервере. Конвертация в локальное время происходит на клиенте на основе часового пояса браузера. iCalendar-экспорт (`.ics`) включает `VTIMEZONE`.
### Enum pipeline_status
Статус пост-обработки сеанса (единица пайплайна) отслеживает жизненный цикл от записи через транскрибацию и суммаризацию до уведомления или ошибки.
**Состояния машины:**
```
recording → transcribing → summarizing → notified / failed
```
| Статус | Описание | Переход | Ответственный |
|--------|---------|---------|---------------|
| `recording` | Сеанс идёт, LiveKit Egress записывает треки | Автоматический при `t_start` сеанса | Backend (создание сеанса) |
| `transcribing` | Транскрибация в процессе | При `run_pipeline()`, после завершения egress | Воркер `run_pipeline` |
| `summarizing` | Суммаризация в процессе | После успешной транскрибации и реконструкции фраз | Воркер `run_pipeline` |
| `notified` | Уведомление отправлено | После успешной суммаризации и отправки email | Воркер уведомлений |
| `failed` | Ошибка на одном из шагов (пайплайн остановлен) | При сбое воркера или если все треки провалены | Воркер `run_pipeline` |
**Идемпотентность:** каждый шаг пайплайна проверяет текущий `pipeline_status` перед началом и пропускает уже завершённые шаги (guard от повторных запусков).
**Пример восстановления после сбоя:**
- Воркер транскрибации упал посеридине обработки треков
- При повторном запуске `run_pipeline(session_id)` проверяет `pipeline_status` = `transcribing`
- Пропускает уже транскрибированные треки (у которых `segments IS NOT NULL`)
- Завершает оставшиеся треки и переходит в `summarizing`
### Идемпотентность через статусы
Каждый шаг пайплайна (транскрибация, суммаризация, уведомление) должен быть идемпотентным: повторное выполнение не приведёт к дублированию или ошибкам. Это достигается через проверку текущего `pipeline_status` перед началом шага.
---
## Миграции
### Миграция d87681e12784
**Участники конференций и аватары пользователей:**
1. **Новая таблица `conference_invitees`** (ADR-003) — приглашённые участники конференции
- `id` (UUID PK), `conference_id` (FK), `user_id` (nullable FK), `email` (nullable VARCHAR), `created_at`
- CHECK: ровно одна identity (`user_id` ИЛИ `email`)
- Частичные UNIQUE индексы по (conference_id, user_id) и (conference_id, lower(email))
- Организатор не хранится в таблице (выводится из `conferences.owner_id`)
2. **Колонка в `users`:** `avatar_path` (VARCHAR(512), nullable) — путь к загруженному аватару
- Пример: `avatars/550e8400-e29b-41d4-a716-446655440000.jpg`
- NULL → заглушка с инициалами на фронте
- Удаление аватара вычищает файл с диска и обнуляет поле
**SQL:**
```sql
CREATE TABLE conference_invitees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conference_id UUID NOT NULL REFERENCES conferences(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK ((user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1)
);
CREATE UNIQUE INDEX uq_conference_invitees_user
ON conference_invitees(conference_id, user_id) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX uq_conference_invitees_email
ON conference_invitees(conference_id, lower(email)) WHERE email IS NOT NULL;
ALTER TABLE users ADD COLUMN avatar_path VARCHAR(512);
```
### Миграция 504791847d4f
**Поддержка гостевых авторов в чате:**
1. **Колонки в `chat_messages`:**
- `guest_access_id` (UUID, nullable) — FK → `guest_access.id`, `ON DELETE CASCADE`; заполнено для авторов-гостей
- `author_name` (VARCHAR(255), NOT NULL) — снапшот имени автора из LiveKit access-токена на момент отправки; добавляется nullable, backfill из `users.name_user` для уже существующих строк (все они с `user_id`), затем ужесточается до `NOT NULL`
2. **Изменение `chat_messages.user_id`:**
- Переходит в nullable (вместо NOT NULL), т.к. автором может быть гость
3. **Check-констрейнт:**
- `ck_chat_messages_author``user_id IS NOT NULL OR guest_access_id IS NOT NULL` (ровно один из двух должен быть заполнен)
**Назначение:** WS-чат поддерживает как пользователей, так и гостей как авторов сообщений, с сохранением снапшота имени (для истории и чата).
### Миграция 9d37822e4513
**Справочник команд:**
1. **Новая таблица `teams`**`id` (UUID PK), `name` (уникально), `created_at`
2. **Колонка в `users`:** `team_id` (UUID, nullable) — FK → `teams.id`, `ON DELETE SET NULL`
**SQL:**
```sql
CREATE TABLE teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE users ADD COLUMN team_id UUID;
ALTER TABLE users ADD CONSTRAINT fk_users_team_id_teams
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE SET NULL;
```
### Миграция 5970bf64fc43
**Настройки инстанса и уведомления:**
1. **Новая таблица `instance_settings`** — key-value хранилище настроек (JSONB) с бутстрапом из `config/plugins.yaml`
2. **Новая таблица `email_deliveries`** — идемпотентность саммари (уникальный частичный индекс по `(session_id, recipient_email)`) и журнал приглашений
3. **Колонки в `conferences`:**
- `summary_recipients` (nullable TEXT, CHECK) — переопределение рассылки ('all', 'owner', или NULL=дефолт)
- `ics_sequence` (INT, default 0) — счётчик для VEVENT SEQUENCE
4. **Колонка в `users`:**
- `is_blocked` (BOOLEAN, default false) — блокировка администратором
**SQL:**
```sql
CREATE TABLE instance_settings (key TEXT PRIMARY KEY, value JSONB, updated_at TIMESTAMPTZ DEFAULT now());
CREATE TABLE email_deliveries (...);
ALTER TABLE conferences ADD COLUMN summary_recipients VARCHAR(16) CHECK (...);
ALTER TABLE conferences ADD COLUMN ics_sequence INT DEFAULT 0;
ALTER TABLE users ADD COLUMN is_blocked BOOLEAN DEFAULT false;
```
### Миграция 88aa676ac140
**Таблица `session_audio_tracks` и атрибуция фраз (ADR-002):**
1. **Новая таблица `session_audio_tracks`** — аудиотреки сеанса с независимым статусом и сегментами Whisper
2. **Изменение `phrases`: `user_id` → `participant_id`** — атрибуция к участнику сеанса (FK `conference_participants.id`), чтобы работать с гостями без user_id
**Данные (в продакшене):** сохраняются через данные участников (`conference_participants`) и сеансов.
### Миграция f418dd65e7b1
**Переход на динамические конференции (ADR-001):**
1. **Таблица `conferences` → `conference_sessions`** (переименование + новые колонки)
- Сохранены все данные
- Добавлена `conference_id` (FK → новая таблица conferences)
- `pipeline_status` остаётся (семантика не меняется)
2. **Новая таблица `conferences`**
- Постоянные сущности (номер, slug, владелец, статус, recurrence)
- Связь с `conference_sessions` (один ко многим, CASCADE удаление)
3. **Новая таблица `guest_access`**
- Гости, представившиеся при входе
- display_name обязателен, email опционален
4. **Колонки фраз и сообщений: `conference_id` → `session_id`**
- `phrases.conference_id``phrases.session_id`
- `chat_messages.conference_id``chat_messages.session_id`
- `conference_participants.conference_id``conference_participants.session_id`
5. **Таблицы удалены**
- `rooms` (комнаты больше не предустановленные)
- `room_bookings` (бронирования заменены динамическими конференциями)
- `booking_participants` (доступ теперь по паролю, не по списку)
6. **CHECK-ограничения в новой таблице conferences**
- `is_closed = false OR password_hash IS NOT NULL`
- `recurrence IS NULL OR is_pinned = true`
7. **CHECK-ограничение в conference_participants**
- `(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1` — ровно один из user_id/guest_id
8. **Индексы**
- `(conference_id, t_start)` на conference_sessions
- `(pipeline_status)` на conference_sessions
- `(session_id)` на conference_participants

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) — реплики полезны в первую очередь
при большой аудитории рассылок (закреплённые конференции с длинным списком
участников/приглашённых).

0
docs/plugins/.gitkeep Normal file
View File

321
docs/plugins/contracts.md Normal file
View File

@@ -0,0 +1,321 @@
# Архитектура плагинов
## Обзор
VidConf использует **Strategy pattern + Factory pattern** для подключения реализаций AI-моделей. Это позволяет:
- Добавлять новые провайдеры без изменения ядра приложения
- Менять реализации через конфигурацию без изменения кода
- Тестировать с no-op реализациями (NullTranscriber, NullSummarizer)
## Контракты (ABC)
### Transcriber
```python
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar
@dataclass(frozen=True, slots=True)
class Segment:
"""Один транскрибированный сегмент трека."""
start: float # Начало в секундах от старта файла
end: float # Конец в секундах от старта файла
text: str # Распознанный текст
class Transcriber(ABC):
"""Интерфейс Strategy для реализаций преобразования речи в текст."""
provider: ClassVar[str]
@abstractmethod
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
"""Транскрибировать аудиофайл в список сегментов."""
...
```
**Сигнатура:**
- `transcribe(audio_path: str, language: str = "ru") -> list[Segment]`
- Читает аудиофайл с диска по абсолютному пути
- Возвращает упорядоченный список сегментов, отсортированный по `start`
- Дефолтный язык — русский (`language="ru"`)
### Summarizer
```python
from abc import ABC, abstractmethod
from typing import ClassVar
class Summarizer(ABC):
"""Интерфейс Strategy для реализаций суммаризации текста."""
provider: ClassVar[str]
@abstractmethod
def summarize(self, transcript: str) -> str:
"""Создать резюме полного текста транскрипта."""
...
```
**Сигнатура:**
- `summarize(transcript: str) -> str`
- Принимает полный текст транскрипта (или чанк)
- Возвращает суммаризованный текст на русском языке
## Конфигурационные модели (Pydantic)
### TranscriberConfig
```python
class TranscriberConfig(BaseModel):
enabled: bool = True
provider: str = Field(default="null", min_length=1)
model: str | None = None
language: str = "ru"
options: dict[str, Any] = Field(default_factory=dict)
```
- `enabled`: включена ли транскрибация (при `false` пайплайн остаётся в статусе `recording`)
- `provider`: имя зарегистрированного провайдера (строка в кавычках в YAML)
- `model`: опциональное имя модели (передаётся в конструктор провайдера)
- `language`: язык распознавания (ISO 639-1, дефолт `"ru"`)
- `options`: дополнительные параметры, специфичные для конкретного провайдера (прокидываются как `**kwargs`)
### SummarizerConfig
```python
class SummarizerConfig(BaseModel):
enabled: bool = True
provider: str = Field(default="null", min_length=1)
model: str | None = None
chunk_minutes: int = 20
options: dict[str, Any] = Field(default_factory=dict)
```
- `enabled`: включена ли суммаризация
- `provider`: имя зарегистрированного провайдера
- `model`: опциональное имя модели
- `chunk_minutes`: размер чанка для map-reduce (минуты, дефолт 20)
- `options`: дополнительные параметры провайдера
## Реестр и фабрика
Модуль `backend/core/plugins/factory.py` реализует:
```python
_TRANSCRIBERS: dict[str, type[Transcriber]] = {}
_SUMMARIZERS: dict[str, type[Summarizer]] = {}
def register_transcriber[T: type[Transcriber]](cls: T) -> T:
"""Зарегистрировать подкласс Transcriber под его ключом provider."""
_TRANSCRIBERS[cls.provider] = cls
return cls
def register_summarizer[S: type[Summarizer]](cls: S) -> S:
"""Зарегистрировать подкласс Summarizer под его ключом provider."""
_SUMMARIZERS[cls.provider] = cls
return cls
def create_transcriber(cfg: TranscriberConfig) -> Transcriber:
"""Инстанцировать Transcriber, зарегистрированный для cfg.provider.
Raises:
UnknownProviderError: если провайдер не найден в реестре.
"""
cls = _TRANSCRIBERS[cfg.provider]
return cls(model=cfg.model, language=cfg.language, **cfg.options)
def create_summarizer(cfg: SummarizerConfig) -> Summarizer:
"""Инстанцировать Summarizer, зарегистрированный для cfg.provider.
Raises:
UnknownProviderError: если провайдер не найден в реестре.
"""
cls = _SUMMARIZERS[cfg.provider]
return cls(model=cfg.model, chunk_minutes=cfg.chunk_minutes, **cfg.options)
```
**Исключения:**
- `PluginError`: базовое исключение при сбое реестра/фабрики плагинов
- `UnknownProviderError`: запрошенный `provider` не зарегистрирован
## Null-реализации (no-op)
Модуль `backend/core/plugins/null.py` содержит заглушки для тестирования и отключения:
```python
@register_transcriber
class NullTranscriber(Transcriber):
"""Заглушка транскрибера — всегда возвращает пустой список сегментов."""
provider: ClassVar[str] = "null"
def __init__(self, model: str | None = None, language: str = "ru", **options):
self.model = model
self.language = language
self.options = options
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
"""Возвращает пустой список (no-op)."""
return []
@register_summarizer
class NullSummarizer(Summarizer):
"""Заглушка суммаризатора — всегда возвращает пустую строку."""
provider: ClassVar[str] = "null"
def __init__(self, model: str | None = None, **options):
self.model = model
self.options = options
def summarize(self, transcript: str) -> str:
"""Возвращает пустую строку (no-op)."""
return ""
```
**Использование:**
- Тестирование пайплайна без реальной обработки
- Отключение транскрибации/суммаризации через `enabled: false` в `config/plugins.yaml`
- Локальная разработка когда AI-модели не установлены
## Конфигурационный файл
**Путь:** `config/plugins.yaml`
```yaml
transcriber:
enabled: true
provider: "faster_whisper_cpu" # Обязательно в кавычках
model: "small"
language: "ru"
options:
download_root: /models/whisper
summarizer:
enabled: true
provider: "null"
model: null
chunk_minutes: 20
options: {}
chat:
enabled: true
```
**Правила YAML:**
- `provider` — всегда строка в кавычках, совпадает с `Provider.provider` класса
- `model` — может быть `null` (передаётся как `None` в конструктор)
- `options` — YAML-словарь (рекурсивно прокидывается как `**kwargs`)
- Переменные окружения поддерживаются как `"${VARIABLE_NAME}"` в значениях строк
## Пошаговая инструкция: добавление нового Transcriber
Подробная пошаговая инструкция находится в **`docs/plugins/transcriber.md`**.
Краткое резюме:
**Шаг 1:** Создать файл `backend/core/plugins/my_transcriber.py`:
```python
@register_transcriber
class MyTranscriber(Transcriber):
provider: ClassVar[str] = "my_provider"
def __init__(self, model: str | None = None, language: str = "ru", **options):
...
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
# Реализация
...
```
**Шаг 2:** Импортировать в `backend/core/plugins/__init__.py`:
```python
from core.plugins.my_transcriber import my_transcriber # noqa: F401
```
**Шаг 3:** Обновить `config/plugins.yaml`:
```yaml
transcriber:
provider: "my_provider"
model: "my-model"
options:
key: "value"
```
**Шаг 4:** Добавить переменные окружения в `.env` (если нужны)
**Шаг 5:** Написать тесты и запустить:
```bash
cd backend
uv run pytest tests/test_my_transcriber.py -v
```
## Реализованные плагины
### FasterWhisperCPU (Transcriber)
- **Провайдер:** `"faster_whisper_cpu"`
- **Параметры:** `model="small"` (дефолт), `language="ru"`, `options.download_root="/models/whisper"`
- **VAD:** Silero (встроен, `vad_filter=True`)
- **Отфильтровка:** сегменты < 0.3 сек отбрасываются
- **Ленивый импорт:** `faster_whisper` грузится только при первом вызове
- **Файл:** `backend/core/plugins/faster_whisper.py`
### QwenLocal (Summarizer)
- **Провайдер:** `"qwen_local"`
- **Модели:** Qwen3.5-4B/9B/35B-A3B (GGUF, Q4_K_M; размеры ~2,8 ГБ / ~6,2 ГБ / ~2022 ГБ)
- **min:** Qwen3.5-4B (4B параметров, CPU llama.cpp)
- **medium:** Qwen3.5-9B (9B параметров, CPU/GPU llama.cpp опционально)
- **max:** Qwen3.5-35B-A3B (MoE ~3B активных, GPU llama.cpp обязателен)
- **Сервер:** llama.cpp (OpenAI-совместимый API на `/v1/chat/completions`)
- **Параметры:** выбираются эффективной конфигурацией по `ai_level` (backend/services/instance_settings.py::load_effective_config), перекрывают config/plugins.yaml; per-tier max_tokens_map/max_tokens_reduce, temperature=0.2
- **Map-reduce:** чанки по целевому времени (20 минут) с per-tier лимитом токенов, иерархический reduce при переполнении бюджета
- **Надёжность:** retry + circuit breaker в HTTP-клиенте (3 попытки, breaker после 5 ошибок), Celery-retry задачи с нарастающей паузой, идемпотентные guard'ы
- **Язык:** русский (промпты в `workers/summarizer/prompts/summary_map_ru.txt` и `summary_reduce_ru.txt`, утверждены и едины для всех уровней)
- **Токенизатор:** per-модель (загружается лениво), фолбэк-эвристика `len(text)//3` если отсутствует
- **Установка моделей:** [docs/deploy/llm-setup.md](../deploy/llm-setup.md) (скачивание автоматизировано инсталлятором `install.sh`)
- **Документация:** [docs/plugins/summarizer.md](./summarizer.md), [docs/architecture/adr/004-ai-tier-matrix.md](../architecture/adr/004-ai-tier-matrix.md)
- **Файл:** `backend/core/plugins/qwen_local.py`
## Тестирование и отладка
### Локальное тестирование с null-провайдерами
Отредактируйте `config/plugins.yaml`:
```yaml
transcriber:
enabled: true
provider: "null"
summarizer:
enabled: true
provider: "null"
```
Приложение будет работать без AI-моделей, возвращая пустые результаты.
### Проверка регистрации плагинов
```bash
cd backend
uv run python -c "
from core.plugins.factory import _TRANSCRIBERS, _SUMMARIZERS
print('Транскрибаторы:', list(_TRANSCRIBERS.keys()))
print('Суммаризаторы:', list(_SUMMARIZERS.keys()))
"
```
### Интеграционный тест пайплайна
```bash
cd backend
CELERY_ALWAYS_EAGER=True uv run pytest tests/test_build_phrases.py -v
```
## Ссылки
- **Детальное руководство:** `docs/plugins/transcriber.md`
- **Пайплайн:** `workers/tasks/pipeline.py`
- **Конфиг фабрики:** `backend/core/plugins/factory.py`
- **Модели БД:** `backend/models/audio_track.py`, `backend/models/phrase.py`

491
docs/plugins/summarizer.md Normal file
View File

@@ -0,0 +1,491 @@
# Плагин Summarizer: QwenLocal
## Обзор
Плагин `QwenLocal` реализует суммаризацию сеансов конференций с использованием локальной модели **Qwen3.5** (3 уровня качества: 4B/9B/35B-A3B) через OpenAI-совместимый сервер **llama.cpp** (матрица уровней — ADR-004).
**Провайдер:** `qwen_local`
**Назначение:** создание структурированного резюме текстового транскрипта сеанса, разбитого на логические части (что решено, ответственные, сроки и т.д.) согласно утверждённым промптам `workers/summarizer/prompts/summary_map_ru.txt` и `summary_reduce_ru.txt`.
**Архитектурные решения:**
- [docs/architecture/adr/004-ai-tier-matrix.md](../../docs/architecture/adr/004-ai-tier-matrix.md) — матрица уровней min/medium/max с моделями Qwen3.5
## Архитектура
### Конвейер обработки
```
Сеанс (phrases в БД)
build_transcript(phrases) — собрать строки [Имя MM:SS] текст
chunk_transcript() — разбить на чанки (20 мин, ≤8000 токенов)
MAP (параллельно по чанкам)
• summary_map_ru.txt.format(transcript_chunk=чанк)
• OpenAI API → LLM-сервер (llama.cpp)
• Результат: одно резюме-чанка
REDUCE (иерархически при переполнении)
• summary_reduce_ru.txt.format(partial_summaries=...)
• Группировка по бюджету 6000 токенов
• Итерация до одного финального резюме
ConferenceSession.summary_data = JSON
```
### Компоненты
#### 1. Сборка транскрипта (`workers/summarizer/transcript.py`)
**Функция:** `build_transcript(lines: Sequence[TranscriptLine]) -> str`
Входные данные:
- Список фраз из `phrases` таблицы, преобразованные в `TranscriptLine`:
- `speaker: str` — имя говорящего (из `users.name_user` ИЛИ `guest_access.display_name`)
- `offset_s: float` — смещение в секундах от `t_start` сеанса
- `text: str` — распознанный и реконструированный текст
Выходные данные:
- Строка с фразами, отсортированными по времени:
```
[Иван 00:15] Здравствуйте, начнём встречу
[Мария 01:30] Спасибо, вот моя презентация
[Иван 15:42] Подводим итоги
```
Формат времени: `MM:SS` (до часа), `ЧЧ:MM:SS` (от часа).
#### 2. Чанкинг (`backend/core/summarization/chunking.py`)
**Функция:** `chunk_transcript(transcript: str, count_tokens: Callable[[str], int], *, max_chunk_tokens: int = 8000, target_chunk_minutes: int = 20) -> list[str]`
Правила закрытия чанка:
- Каждая строка (фраза) атомарна — не режется пополам
- Чанк закрывается, когда:
1. Охваченное время ≥ `target_chunk_minutes` (20 минут дефолт), **ИЛИ**
2. Добавление следующей фразы превысит `max_chunk_tokens`
**Аварийный случай — монолог:**
- Единственная фраза сама по себе > `max_chunk_tokens` (например, 40-минутный монолог)
- Фраза делится по границам предложений (`.`, `!`, `?`)
- Каждая часть получает повторённую исходную метку спикера
- Части добавляются как отдельные чанки
Пример с монологом:
```
Входная строка (40 мин): [Директор 00:00] Первое предложение. Второе предложение. ...
Выход (2 чанка):
[Директор 00:00] Первое предложение.
[Директор 00:00] Второе предложение.
...
```
#### 3. Подсчёт токенов (`backend/core/summarization/tokens.py`)
**Класс:** `QwenTokenCounter`
```python
counter = QwenTokenCounter(tokenizer_path="/models/qwen/tokenizer.json")
token_count = counter("Какой-нибудь текст") # int
```
Поведение:
- **Штатный режим:** загрузить `tokenizers.Tokenizer` из файла `tokenizer.json` (Qwen2.5-3B)
- **Ленивая загрузка:** первый вызов — попытка загрузить, результат кэшируется
- **Фолбэк-эвристика:** если файл отсутствует или повреждён → `len(text) // 3` с предупреждением в логе
- Пайплайн не падает даже без файла токенизатора
#### 4. HTTP-клиент LLM (`backend/core/summarization/llm_client.py`)
**Класс:** `OpenAICompatClient`
```python
client = OpenAICompatClient(
base_url="http://llm:8080/v1",
model="qwen2.5-3b-instruct-q4_k_m",
temperature=0.2,
max_tokens=1024,
)
result = client.complete("Ваш промпт здесь") # str
```
**Надёжность:**
- **Retry с экспоненциальным backoff:** базовая пауза 0.5 сек, растёт как `2^attempt`
- **Retryable статусы:** 5xx (включая 503 — модель ещё грузится)
- **Circuit breaker:** после 5 подряд неудач окно отказа 60 сек (все запросы падают сразу без HTTP)
- **Исключение:** `LlmUnavailableError` при открытом breaker'е или исчерпании retry
Пример обработки ошибки:
```python
try:
summary = client.complete(prompt)
except LlmUnavailableError:
# Задача пауза и повтор через Celery (countdown зависит от номера попытки)
task.retry(countdown=60 * (attempt + 1))
```
#### 5. Плагин QwenLocal (`backend/core/plugins/qwen_local.py`)
**Класс:** `QwenLocal(Summarizer)`
Инкапсулирует весь цикл map-reduce:
```python
@register_summarizer
class QwenLocal(Summarizer):
provider: ClassVar[str] = "qwen_local"
def __init__(
self,
model: str | None = None, # GGUF-модель
chunk_minutes: int = 20, # Размер чанка
base_url: str = "http://llm:8080/v1", # LLM-сервер
tokenizer_path: str = "/models/qwen/tokenizer.json", # Токенизатор
prompts_dir: str = "workers/summarizer/prompts", # Промпты
temperature: float = 0.2, # Творчество LLM
max_tokens: int = 1024, # Макс вывод
**options: Any, # Доп. параметры
) -> None: ...
def summarize(self, transcript: str) -> str:
"""Полный цикл: chunk → map → reduce → результат."""
...
```
**Логика `summarize()`:**
1. **Чанкирование:** `chunk_transcript(transcript, self._count_tokens, target_chunk_minutes=...)`
- Пустой список чанков → вернуть пустую строку (no-op)
2. **Map-фаза:** для каждого чанка:
```python
prompt = summary_map_ru.txt.replace("{transcript_chunk}", chunk)
summary = client.complete(prompt)
```
3. **Один чанк?** Вернуть его map-результат напрямую (формат совпадает с reduce)
4. **Reduce-фаза:** объединить частичные резюме:
```python
if len(partial_summaries) == 1:
return partial_summaries[0]
return self._reduce(partial_summaries) # иерархический reduce
```
**Иерархический reduce:**
```python
def _reduce(self, summaries: list[str]) -> str:
"""Рекурсивное сведение: группировка по бюджету → reduce каждой группы."""
while len(summaries) > 1:
# Все резюме влезают в бюджет 6000 токенов?
if count_tokens("\n\n".join(summaries)) <= 6000:
return self._reduce_once(summaries)
# Нет → группируем жадно по бюджету
groups = self._group_by_token_budget(summaries, 6000)
# Все ещё одна группа (крупные резюме)? Сводим как есть
if len(groups) == 1:
return self._reduce_once(summaries)
# Reduce каждую группу отдельно → новый уровень
summaries = [self._reduce_once(group) for group in groups]
return summaries[0]
```
Пример:
- 10 чанков → 10 map-результатов
- 10 резюме не влезают в 6000 токенов → группируем на 3 группы
- 3 reduce-вызова → 3 результата
- 3 результата влезают → финальный reduce → 1 резюме
## Конфигурация
### Пример (профиль B — с LLM)
**`config/plugins.yaml`:**
```yaml
summarizer:
enabled: true
provider: qwen_local
model: qwen2.5-3b-instruct-q4_k_m
chunk_minutes: 20
options:
base_url: http://llm:8080/v1
tokenizer_path: /models/qwen/tokenizer.json
temperature: 0.2
max_tokens: 1024
```
### Параметры
| Параметр | Тип | Дефолт | Описание |
|---|---|---|---|
| `enabled` | bool | `true` | Включена ли суммаризация |
| `provider` | str | `"null"` | Провайдер (в репо дефолт `"null"`, для профиля B = `"qwen_local"`) |
| `model` | str | `"qwen2.5-3b-instruct-q4_k_m"` | GGUF-файл модели (без расширения) |
| `chunk_minutes` | int | `20` | Целевой размер чанка (минуты) |
| **options:** | | | |
| `base_url` | str | `"http://llm:8080/v1"` | OpenAI-совместимый URL сервера llama.cpp |
| `tokenizer_path` | str | `"/models/qwen/tokenizer.json"` | Путь внутри контейнера worker к файлу токенизатора |
| `temperature` | float | `0.2` | Творчество генерации (0 = точно, 1 = вариативно) |
| `max_tokens` | int | `1024` | Максимальный размер одного чанка-резюме |
### Дефолт репозитория
```yaml
summarizer:
enabled: true
provider: "null" # ← ДЕФОЛТ без LLM-сервера
model: null
chunk_minutes: 20
```
Это безопасно для dev-окружений без Docker Compose профиля `llm`. `NullSummarizer` просто возвращает пустую строку.
## Надёжность
### Retry и circuit breaker
**HTTP-клиент** (`OpenAICompatClient`):
- 3 попытки (max_attempts) на каждый запрос
- Экспоненциальный backoff: 0.5 сек → 1 сек → 2 сек
- Circuit breaker открывается после 5 подряд неудач (окно 60 сек)
**Celery-задача** (`workers.tasks.summarize.summarize_session`):
- `max_retries=5` на уровне задачи
- `acks_late=True` — подтверждение доставки после успеха
- `countdown=60 * (attempt + 1)` — нарастающая пауза между retry
Пример: если на 2-м attempt LLM упадёт → пауза 180 сек (3 минуты) перед 3-й попыткой.
### Двухуровневая защита от потери постановки задачи
**Уровень 1 — retry при сбое брокера** (`workers.tasks.pipeline._send_summarize_task`):
- Если `app.send_task` бросит `kombu.exceptions.OperationalError`/`ConnectionError` (Redis недоступен), фразы уже сохранены
- Делается 3 попытки с линейно растущим backoff (2 сек, 4 сек, 6 сек)
- Если всё исчерпано — `pipeline_status` остаётся `summarizing` (не откатывается), ошибка логируется
**Уровень 2 — периодическое восстановление** (`workers.tasks.maintenance.recover_stuck_summaries`):
- Beat-задача запускается каждые 5 минут
- Находит сеансы, зависшие в `pipeline_status='summarizing'` без `summary_data` дольше 30 минут
- Переставляет `summarize_session` в очередь повторно
- Безопасна благодаря guard'ам задачи суммаризации (если `summary_data` уже есть — no-op)
Таким образом, временная недоступность Redis при постановке задачи не ведёт к зависанию сеанса.
### Идемпотентность
**Guard'ы в `summarize_session_async()`** (в порядке исполнения):
1. **Сеанс не найден ИЛИ не завершён** (`t_end IS NULL`) → логировать warning, выход
2. **Статус уже не `summarizing`** → no-op (готов к notify или failed)
3. **`summary_data IS NOT NULL`** → no-op (уже суммаризировано)
4. **`summarizer.enabled=false`** → логировать info, выход
5. **Нет фраз** → логировать warning, выход (`summary_data` остаётся NULL)
**Пример повторной доставки задачи:**
```
Попытка 1: Celery отправляет summarize_session(session_id=abc)
↓ LLM-сервер упадёт посреди map → LlmUnavailableError
↓ task.retry(countdown=60)
↓ Очередь переотправляет через 60 сек
Попытка 2: summarize_session(session_id=abc) вызовется снова
↓ Guard №3 проверит: summary_data IS NOT NULL?
↓ Если да → no-op (уже готово)
↓ Если нет → повторить map-reduce
```
## Поведение при `summarizer.enabled=false`
**Конфиг:**
```yaml
summarizer:
enabled: false
```
**Поведение:**
1. **Пайплайн транскрибации** завершается нормально, сеанс переходит в статус `summarizing`
2. **Guard №4 в `summarize_session_async()`** проверяет `enabled` → логирует info-уровень и выходит
3. **`summary_data` остаётся `NULL`** — это нормально (документированное состояние)
4. **Пайплайн НЕ переходит в `notified`** — остаётся в `summarizing` (уведомление может пропустить сеансы без резюме)
Полезно для:
- dev-окружений без LLM-модели
- тестирования пайплайна без AI-обработки
- отключения суммаризации на боевом сервере (дорого по ресурсам)
## Фолбэк при отсутствии токенизатора
**Сценарий:** dev-окружение без смонтированного volume `llm-models` (файла `tokenizer.json` нет).
**QwenTokenCounter** реагирует:
1. При первом вызове пытается загрузить `tokenizers.Tokenizer` из `tokenizer_path`
2. Если файл не найден ИЛИ повреждён → логирует warning-уровень
3. Переходит на эвристику: `count_tokens(text) = len(text) // 3`
4. **Пайплайн продолжает работать**, но подсчёт токенов менее точен
**Последствия:**
- Чанки могут быть немного больше/меньше целевого размера
- Reduce может потребовать дополнительную итерацию
- Общее время обработки увеличится, но не критично
**Рекомендация:** в продакшене всегда монтировать volume с токенизатором.
## Установка модели
Модель и токенизатор устанавливаются в Docker Compose профилем `llm`.
**Подробно:** [docs/deploy/llm-setup.md](../deploy/llm-setup.md)
**Краткие шаги:**
1. Поднять профиль `llm`:
```bash
docker compose -f deploy/docker-compose.yml --profile llm up -d llm-model-init llm
```
2. Дождаться готовности:
```bash
curl http://localhost:8080/health
# {"status":"ok"}
```
3. Включить `qwen_local` в конфиге:
```yaml
summarizer:
provider: qwen_local
```
4. Перезапустить worker:
```bash
docker compose -f deploy/docker-compose.yml up -d --force-recreate worker
```
## Примеры использования
### Прямое использование плагина
```python
from core.plugins.config import load_plugins_config
from core.plugins.factory import create_summarizer
# Загрузить конфиг
cfg = load_plugins_config("config/plugins.yaml")
# Инстанцировать плагин QwenLocal
summarizer = create_summarizer(cfg.summarizer)
# Использовать
transcript = "[Иван 00:15] Привет...\n[Мария 02:30] Привет..."
result = summarizer.summarize(transcript)
print(result)
# Результат: структурированное резюме
```
### В пайплайне (workers)
```python
# workers/tasks/summarize.py
async def summarize_session_async(
task: RetryableTask,
session_id: uuid.UUID,
*,
plugins_config: PluginsConfig | None = None,
) -> None:
"""Суммаризировать сеанс и сохранить в БД."""
# ... guard'ы пропущены для краткости ...
# Собрать транскрипт
lines = [
TranscriptLine(
speaker=speaker_name,
offset_s=phrase.offset_s,
text=phrase.text,
)
for phrase in phrases
]
transcript = build_transcript(lines)
# Инстанцировать плагин
cfg = plugins_config or load_plugins_config()
summarizer = create_summarizer(cfg.summarizer)
# Получить резюме (может вызвать LlmUnavailableError)
try:
summary_text = summarizer.summarize(transcript)
except LlmUnavailableError:
# Retry с нарастающей паузой
countdown = RETRY_COUNTDOWN_BASE_S * (task.request.retries + 1)
raise task.retry(countdown=countdown)
# Сохранить в БД
session.summary_data = summary_text
await db_session.commit()
```
## Тестирование
### Модульные тесты компонентов
```bash
cd backend
# Чанкинг (без LLM)
uv run pytest tests/test_chunking.py -v
# LLM-клиент (на мок-транспорте)
uv run pytest tests/test_llm_client.py -v
# Плагин QwenLocal (на мок-LLM)
uv run pytest tests/test_qwen_local.py -v
```
### Интеграционный тест пайплайна
```bash
# С мок-LLM (реальной БД и Celery в eager-режиме)
CELERY_ALWAYS_EAGER=True uv run pytest tests/test_summarize_task.py -v
```
### Ручная проверка с реальной моделью
1. Убедиться, что `llm` здоров:
```bash
curl http://localhost:8080/health
```
2. Включить `qwen_local` в конфиге, перезапустить worker
3. Запустить пайплайн на тестовой конференции:
```bash
# В тестовой конференции завершить запись
# Webhook room_finished → очередь run_pipeline
# → обработка пайплайна → видеть логи worker
docker compose -f deploy/docker-compose.yml logs -f worker
# Смотреть: transcribing → summarizing → ready to notify
```
4. Проверить БД:
```sql
SELECT summary_data FROM conference_sessions WHERE id = 'test-session-id';
-- Должно содержать структурированное резюме (JSON или plain text)
```
## Ссылки
- **Матрица уровней AI:** [ADR-004](../architecture/adr/004-ai-tier-matrix.md)
- **Установка LLM:** [docs/deploy/llm-setup.md](../deploy/llm-setup.md)
- **Контракты плагинов:** [docs/plugins/contracts.md](./contracts.md)
- **Пайплайн:** `workers/tasks/pipeline.py`, `workers/tasks/summarize.py`
- **Модель Qwen:** https://huggingface.co/Qwen/Qwen2.5-3B-Instruct

389
docs/plugins/transcriber.md Normal file
View File

@@ -0,0 +1,389 @@
# Плагин Transcriber — транскрибация аудио в текст
## Обзор
VidConf использует **Strategy pattern** для подключаемых реализаций транскрибации речи. Это позволяет менять провайдеров (faster-whisper на CPU, OpenAI Whisper, Vosk и т.д.) через конфигурацию без изменения ядра.
## Контракт Transcriber
### Интерфейс
Все реализации наследуют абстрактный класс `Transcriber` из `backend/core/plugins/transcriber.py`:
```python
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar
@dataclass(frozen=True, slots=True)
class Segment:
"""Один транскрибированный сегмент трека."""
start: float # Начало в секундах от старта файла
end: float # Конец в секундах от старта файла
text: str # Распознанный текст
class Transcriber(ABC):
"""Интерфейс Strategy для реализаций преобразования речи в текст."""
provider: ClassVar[str] # Уникальный идентификатор провайдера
@abstractmethod
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
"""Транскрибировать аудиофайл в список сегментов."""
...
```
### Сигнатура метода transcribe
| Параметр | Тип | Описание |
|----------|-----|---------|
| `audio_path` | `str` | Абсолютный путь к аудиофайлу на диске |
| `language` | `str` | Код языка (ISO 639-1), дефолт `"ru"` |
| **Возврат** | `list[Segment]` | Упорядоченный список сегментов, отсортированный по `start` |
**Требования:**
- Если файл не найден → исключение `FileNotFoundError`
- Сегменты должны быть отсортированы по `start` в возрастающем порядке
- Дублирующиеся/перекрывающиеся сегменты допустимы (алгоритм фраз их обработает)
## Реализация по умолчанию: FasterWhisperCPU
### Описание
`FasterWhisperCPU` — оптимизированная реализация faster-whisper (CTranslate2-бэкэнд) для транскрибации на CPU с int8-квантизацией. Она встроена в проект как реализация по умолчанию.
**Файл:** `backend/core/plugins/faster_whisper.py`
### Параметры конструктора
```python
@register_transcriber
class FasterWhisperCPU(Transcriber):
provider: ClassVar[str] = "faster_whisper_cpu"
def __init__(
self,
model: str | None = None,
language: str = "ru",
download_root: str | None = None,
) -> None:
"""Инициализировать транскрибер.
Args:
model: Имя модели Whisper (small/base/medium/etc);
дефолт 'small' (оптимум для CPU).
language: Код языка (ISO 639-1), дефолт 'ru'.
download_root: Папка для кэша загруженных моделей;
дефолт ~/.cache/huggingface/hub.
"""
```
| Параметр | Тип | Дефолт | Описание |
|----------|-----|--------|---------|
| `model` | `str` | `"small"` | Модель Whisper: `tiny`, `base`, `small`, `medium`, `large` |
| `language` | `str` | `"ru"` | Язык распознавания (ISO 639-1) |
| `download_root` | `str` | `~/.cache/` | Папка для кэша моделей |
### Особенности
#### VAD (Voice Activity Detection)
FasterWhisperCPU включает встроенный **Silero VAD** для автоматического разбиения аудио на речевые сегменты и подавления молчания. Это улучшает качество транскрибации и снижает галлюцинации Whisper на тишине/шуме.
- **Параметр:** `vad_filter=True`
- **Порог молчания:** `vad_min_silence_duration_ms=500` (0.5 сек)
#### Отбрасывание коротких сегментов
Whisper часто халлюцинирует на очень коротких сегментах (шум, клики, тишина). FasterWhisperCPU автоматически отбрасывает сегменты, короче **0.3 секунды**:
```python
MIN_SEGMENT_DURATION_S = 0.3
# Фильтрация в transcribe()
return [
Segment(start=segment.start, end=segment.end, text=segment.text)
for segment in raw_segments
if (segment.end - segment.start) >= MIN_SEGMENT_DURATION_S
]
```
#### Ленивый импорт
Зависимость `faster-whisper` импортируется лениво — только при первом вызове `transcribe()`. Это позволяет API-процессу, который не использует транскрибацию, избежать загрузки тяжёлой библиотеки в память.
```python
def _get_model(self) -> "WhisperModel":
if FasterWhisperCPU._model is None:
from faster_whisper import WhisperModel # Импорт здесь, не в начале файла
FasterWhisperCPU._model = WhisperModel(...)
return FasterWhisperCPU._model
```
#### Синглтон модели на процесс
Модель Whisper создаётся один раз при первом вызове `transcribe()` и переиспользуется всеми последующими вызовами в пределах одного процесса воркера. Это снижает нагрузку на память и CPU (загрузка модели — дорогая операция).
> **Примечание:** Celery-воркер транскрибации запускается с флагом `--pool=solo --concurrency=1`, поэтому гонок за синглтоном не бывает.
### Конфигурация
В `config/plugins.yaml`:
```yaml
transcriber:
enabled: true
provider: "faster_whisper_cpu"
model: "small"
language: "ru"
options:
download_root: "/models/whisper"
```
Переменные окружения (если нужны):
```bash
# Опционально: переопределить папку кэша моделей
export HF_HOME=/models
```
### Производительность
- **Модель small:** ~8 минут аудио за 1 минуту на современном CPU (зависит от CPU)
- **Память:** ~1.5 ГБ на процесс (с моделью и бэкэндом)
- **Формат входа:** WAV, MP3, OGG, FLAC, M4A и др. (поддерживает ffmpeg-compatible форматы)
## Добавление нового Transcriber
### Пошаговая инструкция
#### Шаг 1: Создать файл реализации
Создайте новый файл в `backend/core/plugins/`, например `my_transcriber.py`:
```python
"""Плагин Transcriber на основе MyService."""
from typing import ClassVar
from core.plugins.factory import register_transcriber
from core.plugins.transcriber import Segment, Transcriber
@register_transcriber
class MyTranscriber(Transcriber):
"""Транскрибер, использующий MyService API."""
provider: ClassVar[str] = "my_service"
def __init__(
self,
model: str | None = None,
language: str = "ru",
api_key: str | None = None,
**options,
) -> None:
"""Инициализировать транскрибер.
Args:
model: Имя модели (опционально).
language: Язык распознавания.
api_key: API-ключ для сервиса.
**options: Дополнительные параметры из config/plugins.yaml.
"""
self.model = model or "default-model"
self.language = language
self.api_key = api_key or os.getenv("MY_SERVICE_API_KEY")
self.options = options
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
"""Транскрибировать аудиофайл через MyService API.
Args:
audio_path: Путь к аудиофайлу на диске.
language: Язык распознавания (может переопределить конструктор).
Returns:
Упорядоченный список сегментов, отсортированный по start.
"""
# 1. Прочитать аудиофайл
with open(audio_path, "rb") as f:
audio_data = f.read()
# 2. Отправить на API (пример)
response = requests.post(
"https://api.myservice.com/transcribe",
files={"audio": audio_data},
json={"language": language, "model": self.model},
headers={"Authorization": f"Bearer {self.api_key}"},
)
response.raise_for_status()
# 3. Распарсить ответ в list[Segment]
result = response.json()
segments = [
Segment(
start=float(item["start"]),
end=float(item["end"]),
text=item["text"],
)
for item in result.get("segments", [])
]
# 4. Отсортировать по start (важно!)
segments.sort(key=lambda s: s.start)
return segments
```
**Важные правила:**
- Класс **должен** наследовать `Transcriber`
- Класс **должен** иметь декоратор `@register_transcriber` (регистрирует в реестре)
- Класс-переменная `provider: ClassVar[str]` **должна быть** уникальным идентификатором
- Конструктор **должен** принимать `model`, `language` и `**options`
- Метод `transcribe()` **должен** возвращать сегменты, отсортированные по `start`
#### Шаг 2: Импортировать в `__init__.py`
Добавьте импорт в `backend/core/plugins/__init__.py`:
```python
"""Пакет плагинов Transcriber/Summarizer."""
from core.plugins import faster_whisper as faster_whisper # noqa: F401
from core.plugins import my_transcriber as my_transcriber # noqa: F401 # ДОБАВИТЬ
from core.plugins import null as null # noqa: F401
```
> **Примечание:** `noqa: F401` подавляет предупреждение о неиспользуемом импорте — он нужен для побочного эффекта (регистрация). Используйте `as` для явности.
#### Шаг 3: Обновить конфигурацию
Отредактируйте `config/plugins.yaml`:
```yaml
transcriber:
enabled: true
provider: "my_service" # Совпадает с MyTranscriber.provider
model: "default-model"
language: "ru"
options:
api_key: "${MY_SERVICE_API_KEY}" # Переменная окружения
# Дополнительные параметры, специфичные для вашего сервиса
timeout_seconds: 300
retry_count: 3
```
#### Шаг 4: Добавить переменные окружения
Отредактируйте `.env` (или `.env.example`):
```bash
# MyService API
MY_SERVICE_API_KEY=sk-...
```
#### Шаг 5: Написать тесты
Добавьте тесты в `backend/tests/test_plugins_factory.py` или новый `test_my_transcriber.py`:
```python
import pytest
from core.plugins.factory import create_transcriber
from core.plugins.config import TranscriberConfig
def test_my_transcriber_registered():
"""Проверить регистрацию плагина."""
cfg = TranscriberConfig(provider="my_service", model="test")
transcriber = create_transcriber(cfg)
assert transcriber.provider == "my_service"
assert transcriber.model == "test"
def test_my_transcriber_transcribe(tmp_path):
"""Тест транскрибации с моком API."""
# Создать тестовый WAV-файл
audio_file = tmp_path / "test.wav"
# ... создать корректный WAV-файл ...
cfg = TranscriberConfig(provider="my_service", model="test")
transcriber = create_transcriber(cfg)
# Вызвать transcribe
segments = transcriber.transcribe(str(audio_file), language="ru")
# Проверить результат
assert len(segments) > 0
assert all(s.start < s.end for s in segments)
assert segments == sorted(segments, key=lambda s: s.start)
```
#### Шаг 6: Протестировать локально
```bash
cd backend
# Убедиться, что конфиг загружается
uv run python -c "
from core.plugins.factory import _TRANSCRIBERS
print('Зарегистрированные транскрибаторы:', list(_TRANSCRIBERS.keys()))
"
# Запустить тесты
uv run pytest tests/test_my_transcriber.py -v
# Запустить пайплайн с новым провайдером (интеграционный тест)
CELERY_ALWAYS_EAGER=True uv run pytest tests/test_build_phrases.py -v
```
### Отключение транскрибации
Если нужно полностью отключить модуль транскрибации, установите в `config/plugins.yaml`:
```yaml
transcriber:
enabled: false
provider: "null"
model: null
```
Поведение:
- Запись аудиотреков продолжает работать (egress в активном состоянии)
- При `room_finished` `run_pipeline()` завершится до инстанцирования плагина
- `pipeline_status` сеанса останется `"recording"` (не перейдёт в `"transcribing"`)
- Лог-сообщение: "transcriber отключён (enabled=false) — сеанс ... пропущен"
## Архитектурные решения
### Идемпотентный пайплайн пост-обработки
Пайплайн пост-обработки — идемпотентная машина состояний (см. `backend/models/session.py`):
```
recording → transcribing → summarizing → notified / failed
```
**run_pipeline()** диспетчер:
1. Проверяет текущий `pipeline_status` сеанса
2. Продолжает работу с последнего успешного шага
3. Отслеживает статус каждого трека (`session_audio_tracks.status`)
4. Коммитит результаты транскрибации в `session_audio_tracks.segments` (JSONB) после каждого трека
Это позволяет восстановиться после сбоя воркера без потери данных или дублирования.
### Атрибуция к участнику сеанса (ADR-002)
Фразы и аудиотреки атрибутированы не к `users`, а к `conference_participants` (участник сеанса). Это позволяет работать с гостями без `user_id`:
```python
# Получить спикера фразы
participant = session.query(ConferenceParticipant).get(phrase.participant_id)
user = participant.user # Может быть None для гостей
guest = participant.guest # Может быть None для пользователей
```
## Ссылки
- **ADR-002:** `docs/architecture/adr/002-phrase-attribution-session-participant.md`
- **Контракты плагинов:** `docs/plugins/contracts.md`
- **Модели:** `backend/models/audio_track.py`, `backend/models/phrase.py`
- **Пайплайн:** `workers/tasks/pipeline.py`
- **Реконструкция фраз:** `workers/transcription/phrases.py`
- **Тесты:** `backend/tests/test_plugins_factory.py`, `backend/tests/test_build_phrases.py`