Первоначальная версия VidConf
This commit is contained in:
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