# Архитектура плагинов

## Обзор

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`
