322 lines
13 KiB
Markdown
322 lines
13 KiB
Markdown
# Архитектура плагинов
|
||
|
||
## Обзор
|
||
|
||
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`
|