Первоначальная версия VidConf
This commit is contained in:
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-токенов
|
||||
после смены пароля.
|
||||
Reference in New Issue
Block a user