Первоначальная версия VidConf
This commit is contained in:
152
docs/README.md
Normal file
152
docs/README.md
Normal 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
0
docs/api/.gitkeep
Normal file
184
docs/api/README.md
Normal file
184
docs/api/README.md
Normal 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
578
docs/api/admin.md
Normal 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
513
docs/api/auth.md
Normal 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
272
docs/api/chat.md
Normal 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
540
docs/api/conferences.md
Normal 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
111
docs/api/teams.md
Normal 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
308
docs/api/users.md
Normal 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
535
docs/architecture/README.md
Normal 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)
|
||||
|
||||
**Пресет 1–2 (без AI):**
|
||||
- 10–20 одновременных конференций
|
||||
- 4 vCPU, 8 GB RAM, 40 GB диск
|
||||
|
||||
**Пресет 3: AI min** (faster-whisper small + Qwen3.5-4B)
|
||||
- 10–20 одновременных конференций
|
||||
- 8 vCPU, 16 GB RAM, 100 GB диск
|
||||
- Транскрибация: ~2x realtime (10мин → 5мин на 8-ядерном CPU)
|
||||
|
||||
**Пресет 4: AI medium** (faster-whisper medium + Qwen3.5-9B; GPU опционально)
|
||||
- 15–30 одновременных конференций (CPU) или 30–50 (GPU)
|
||||
- 12–16 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 обязателен)
|
||||
- 50–100+ одновременных конференций
|
||||
- 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)
|
||||
0
docs/architecture/adr/.gitkeep
Normal file
0
docs/architecture/adr/.gitkeep
Normal file
54
docs/architecture/adr/000-template.md
Normal file
54
docs/architecture/adr/000-template.md
Normal 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` — миграция
|
||||
161
docs/architecture/adr/001-dynamic-conferences-pivot.md
Normal file
161
docs/architecture/adr/001-dynamic-conferences-pivot.md
Normal 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 десятичных цифр, первая 1–9 (`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
|
||||
@@ -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`) — единица пайплайна пост-обработки.
|
||||
70
docs/architecture/adr/003-conference-invitees.md
Normal file
70
docs/architecture/adr/003-conference-invitees.md
Normal 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, не через списки приложения.
|
||||
114
docs/architecture/adr/004-ai-tier-matrix.md
Normal file
114
docs/architecture/adr/004-ai-tier-matrix.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# ADR-004. Матрица уровней AI (min/medium/max): модели, кванты, железо, параметры генерации
|
||||
|
||||
## Статус
|
||||
ACCEPTED
|
||||
|
||||
## Контекст
|
||||
Продукту нужны три уровня качества AI-обработки (`min`/`medium`/`max`,
|
||||
`AiLevel` в `backend/core/plugins/config.py`) для пресетов инсталлятора 3–5.
|
||||
Ограничения: только локальные модели на всех уровнях (без внешних 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,5–2,8 ГБ, llama.cpp CPU | thinking выключен по умолчанию (семейство Small) |
|
||||
| **medium** | faster-whisper `medium` (~1,5 ГБ): CPU `int8`; при GPU — `cuda`/`float16` (VRAM ~2–3 ГБ) | **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,5–5 ГБ) | **Qwen3.5-35B-A3B** (MoE, ~3B активных), GGUF Q4_K_M ≈ 20–22 ГБ, 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.7–1.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 | 12–16 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).
|
||||
37
docs/architecture/adr/005-password-reset-deferred.md
Normal file
37
docs/architecture/adr/005-password-reset-deferred.md
Normal 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-токенов
|
||||
после смены пароля.
|
||||
312
docs/architecture/conference-room-ui.md
Normal file
312
docs/architecture/conference-room-ui.md
Normal 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, строки 128–142):
|
||||
- Иконка: `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, строки 32–37):
|
||||
|
||||
```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` — тёмная тема комнаты
|
||||
224
docs/architecture/frontend-themes.md
Normal file
224
docs/architecture/frontend-themes.md
Normal 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
0
docs/db/.gitkeep
Normal file
664
docs/db/schema.md
Normal file
664
docs/db/schema.md
Normal 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
0
docs/deploy/.gitkeep
Normal file
268
docs/deploy/capacity.md
Normal file
268
docs/deploy/capacity.md
Normal 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`) — при их одновременной активности они отъедали
|
||||
до 4–9 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-соединения за 5–7 с), а у
|
||||
`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`, длительность 45–55 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): стабильно **~19–21 кбит/с** на трек — использовать
|
||||
20 кбит/с как плановую цифру на одного говорящего участника.
|
||||
- Видео, «сеточный» (не приоритетный) слой simulcast, который SFU
|
||||
форвардит подписчикам при 10+ видимых плитках: **~200–350 кбит/с** на
|
||||
трек — это нижний/средний слой (`q`/`h` в терминах rid). Годится как
|
||||
плановая цифра для комнат с сеткой ≥3×3.
|
||||
- Видео, верхний слой simulcast (форвардится, когда подписчик один/
|
||||
мало плиток, либо трек — «в фокусе»/спикер): **~1.3–2.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.6−0.35)/0.006 ≈ 240 | ≈ 120 |
|
||||
| 3 (+AI min) | 8 | ~4.0 vCPU (доля с транскрибацией/суммаризацией) | ≈ (4.0×0.6−0.35)/0.006 ≈ 342 | ≈ 170 |
|
||||
| 4 (+AI medium) | 12–16 | ~5.0 vCPU | ≈ (5.0×0.6−0.35)/0.006 ≈ 458 | ≈ 230 |
|
||||
| 5 (+AI max) | 16+ | ~6.0 vCPU (AI забирает GPU, CPU на SFU свободнее) | ≈ (6.0×0.6−0.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
194
docs/deploy/dev-setup.md
Normal 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
526
docs/deploy/env.md
Normal 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` для синхронизации
|
||||
пресетов поставки (пресеты 1–5 → матрица `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 # пресеты 1–3
|
||||
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 в коде
|
||||
64
docs/deploy/hardware-profiles.md
Normal file
64
docs/deploy/hardware-profiles.md
Normal 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 (матрица уровней)
|
||||
|
||||
Три уровня качества обработки, применяемые к пресетам 3–5:
|
||||
|
||||
| Уровень | Транскрибация | Суммаризация | Требования (мин) | 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 ГБ) | 12–16 vCPU, 32 ГБ RAM | опционально ≥8 ГБ |
|
||||
| **max** | faster-whisper `large-v3` | Qwen3.5-35B-A3B (GGUF Q4_K_M, ~20–22 ГБ) | 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
134
docs/deploy/install.md
Normal 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,6–20,5 ГБ, GPU-хост для пресета 5) — вне
|
||||
рамок автоматической проверки.
|
||||
121
docs/deploy/llm-setup.md
Normal file
121
docs/deploy/llm-setup.md
Normal 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
90
docs/deploy/monitoring.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Мониторинг (Prometheus + Grafana, профиль compose `monitoring`)
|
||||
|
||||
Независимый compose-профиль — можно поднимать вместе
|
||||
с любым пресетом инсталлятора (1–5) или отдельно.
|
||||
|
||||
## 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`
|
||||
(пресеты 3–5) — на пресетах 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 дефолт, донастраивается отдельно при необходимости).
|
||||
294
docs/deploy/quality-tiers.md
Normal file
294
docs/deploy/quality-tiers.md
Normal 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` — пока
|
||||
только методика, реальных данных нет, см. разделы 4–5):
|
||||
|
||||
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-хост, разделы 4–5): более крупная модель должна снять
|
||||
именно эти 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
146
docs/deploy/scaling.md
Normal 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` — для малых
|
||||
пресетов поставки (1–3) это один контейнер, обрабатывающий и суммаризацию, и
|
||||
уведомления, и обслуживание. `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
0
docs/plugins/.gitkeep
Normal file
321
docs/plugins/contracts.md
Normal file
321
docs/plugins/contracts.md
Normal 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 ГБ / ~20–22 ГБ)
|
||||
- **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
491
docs/plugins/summarizer.md
Normal 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
389
docs/plugins/transcriber.md
Normal 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`
|
||||
Reference in New Issue
Block a user