Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
"""Пакет плагинов Transcriber/Summarizer (Strategy + Factory).
Импорт конкретных реализаций здесь регистрирует их в `core.plugins.factory`
через декораторы `@register_transcriber`/`@register_summarizer` (побочный
эффект импорта модуля). Новая реализация = новый класс + импорт в этом
файле + строка в `config/plugins.yaml` — ядро (`factory.py`, контракты) не
трогаем.
"""
from core.plugins import faster_whisper as faster_whisper # noqa: F401
from core.plugins import null as null # noqa: F401
from core.plugins import qwen_local as qwen_local # noqa: F401

View File

@@ -0,0 +1,84 @@
"""Модели Pydantic для описания `config/plugins.yaml` и его загрузчика."""
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import BaseModel, Field
class TranscriberConfig(BaseModel):
"""Конфигурация активного плагина transcriber."""
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)
class SummarizerConfig(BaseModel):
"""Конфигурация активного плагина summarizer."""
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)
class ChatConfig(BaseModel):
"""Конфигурация переключателя функции чата."""
enabled: bool = True
class PluginsConfig(BaseModel):
"""Корневая модель конфигурации для `config/plugins.yaml`."""
transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig)
summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig)
chat: ChatConfig = Field(default_factory=ChatConfig)
def load_plugins_config(path: str | Path) -> PluginsConfig:
"""Загрузить и валидировать `PluginsConfig` из YAML файла."""
raw = yaml.safe_load(Path(path).read_text()) or {}
return PluginsConfig.model_validate(raw)
# --- Настройки инстанса: БД поверх дефолтов `plugins.yaml`. ---
# Ключи `instance_settings` зеркалят секции ниже (`transcriber`, `summarizer`,
# `chat`, `ai_level`, `summary_recipients`, `display_timezone`) — см.
# `services/instance_settings.py`.
AiLevel = Literal["min", "medium", "max"]
"""Уровень AI-модуля инстанса (модели/требования — ADR-004,
`docs/architecture/adr/004-ai-tier-matrix.md`, `services/ai_tiers.TIERS`).
Доступность каждого уровня на конкретном инстансе зависит от обнаруженного
железа и скачанных моделей — см. `services/ai_levels.py::detect_ai_levels`."""
SummaryRecipientsMode = Literal["all", "owner"]
"""Режим рассылки саммари по умолчанию: всем участникам либо только
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`)."""
class InstanceConfig(BaseModel):
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
`plugins.yaml`, см. `services/instance_settings.py::load_effective_config`)."""
transcriber: TranscriberConfig
summarizer: SummarizerConfig
chat: ChatConfig
ai_level: AiLevel = "min"
summary_recipients: SummaryRecipientsMode = "all"
display_timezone: str = "Europe/Moscow"
# Разрешить выбор команды на форме регистрации (справочник `teams`)
# — см. `services/instance_settings.py`.
registration_team_choice: bool = False
# Верификация регистрирующихся по домену email: при включении
# `POST /auth/register` принимает только
# email с доменом `registration_email_domain` — см.
# `services/instance_settings.py`.
registration_email_domain_enabled: bool = False
registration_email_domain: str | None = None

View File

@@ -0,0 +1,47 @@
"""Factory + реестр для реализаций плагинов Transcriber/Summarizer."""
from core.plugins.config import SummarizerConfig, TranscriberConfig
from core.plugins.summarizer import Summarizer
from core.plugins.transcriber import Transcriber
class PluginError(Exception):
"""Базовая ошибка при сбое реестра/factory плагинов."""
class UnknownProviderError(PluginError):
"""Вызывается, когда запрошенный `provider` плагина не зарегистрирован."""
_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`."""
try:
cls = _TRANSCRIBERS[cfg.provider]
except KeyError as exc:
raise UnknownProviderError(f"Неизвестный провайдер transcriber: {cfg.provider!r}") from exc
return cls(model=cfg.model, language=cfg.language, **cfg.options) # type: ignore[call-arg]
def create_summarizer(cfg: SummarizerConfig) -> Summarizer:
"""Инстанцировать `Summarizer`, зарегистрированный для `cfg.provider`."""
try:
cls = _SUMMARIZERS[cfg.provider]
except KeyError as exc:
raise UnknownProviderError(f"Неизвестный провайдер summarizer: {cfg.provider!r}") from exc
return cls(model=cfg.model, chunk_minutes=cfg.chunk_minutes, **cfg.options) # type: ignore[call-arg]

View File

@@ -0,0 +1,141 @@
"""Плагины `Transcriber` на основе faster-whisper: CPU (`min`) и GPU (`medium`/`max`).
Оба плагина используют встроенный в faster-whisper Silero VAD (`vad_filter=True`)
и дополнительно отбрасывают сегменты короче `MIN_SEGMENT_DURATION_S` —
типичные галлюцинации Whisper на тишине/шуме (ТЗ §1.4). Общая логика
(ленивая загрузка модели-синглтона процесса, вызов `transcribe` с VAD,
фильтрация коротких сегментов) вынесена в `_FasterWhisperBase`; CPU/GPU-варианты
отличаются только параметрами устройства/квантизации (ADR-004,
`docs/architecture/adr/004-ai-tier-matrix.md`).
"""
from typing import TYPE_CHECKING, ClassVar
from core.plugins.factory import register_transcriber
from core.plugins.transcriber import Segment, Transcriber
if TYPE_CHECKING:
# Импорт только для проверки типов: рантайм-импорт — ленивый, см. `_get_model`,
# чтобы API-процесс, где транскрибация не используется, не тянул тяжёлую
# зависимость (ctranslate2 и т.п.) в память.
from faster_whisper import WhisperModel
MIN_SEGMENT_DURATION_S = 0.3
"""Минимальная длительность сегмента (сек); короче — отбрасывается как
вероятная галлюцинация Whisper на тишине/шуме."""
VAD_MIN_SILENCE_DURATION_MS = 500
"""Порог Silero VAD (мс) для разбиения на речевые куски внутри трека."""
class _FasterWhisperBase(Transcriber):
"""Общая логика плагинов faster-whisper: синглтон модели процесса + VAD-транскрибация.
Модель-синглтон принадлежит конкретному подклассу (`FasterWhisperCPU`,
`FasterWhisperGPU`), а не общему базовому классу: присваивание
`cls._model = ...` в `_get_model` всегда происходит через `type(self)`,
поэтому у каждого подкласса — свой атрибут класса, и CPU/GPU-плагины не
делят один кэшированный инстанс модели, даже если оба сконфигурированы в
одном процессе.
"""
MIN_SEGMENT_S: ClassVar[float] = MIN_SEGMENT_DURATION_S
_model: "ClassVar[WhisperModel | None]" = None
# Задаются наследниками в `__init__` (device — фиксированно классом,
# compute_type — либо фиксированно, либо конструкторская опция).
device: str
compute_type: str
def __init__(
self,
model: str,
language: str = "ru",
download_root: str | None = None,
) -> None:
self.model_name = model
self.language = language
self.download_root = download_root
def _get_model(self) -> "WhisperModel":
"""Лениво создать (или переиспользовать) синглтон `WhisperModel` конкретного подкласса."""
cls = type(self)
if cls._model is None:
from faster_whisper import WhisperModel # ленивый импорт тяжёлой зависимости
cls._model = WhisperModel(
self.model_name,
device=self.device,
compute_type=self.compute_type,
download_root=self.download_root,
)
return cls._model
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
"""Транскрибировать аудиофайл трека, отбросив короткие сегменты-галлюцинации.
VAD (Silero, встроен в faster-whisper) включён с порогом тишины
`VAD_MIN_SILENCE_DURATION_MS`; дополнительно отбрасываются сегменты
короче `MIN_SEGMENT_DURATION_S`.
"""
model = self._get_model()
raw_segments, _info = model.transcribe(
audio_path,
language=language,
vad_filter=True,
vad_parameters={"min_silence_duration_ms": VAD_MIN_SILENCE_DURATION_MS},
)
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
]
@register_transcriber
class FasterWhisperCPU(_FasterWhisperBase):
"""Транскрибер faster-whisper (CTranslate2) на CPU с int8-квантизацией (уровень `min`).
Модель — синглтон на процесс: создаётся лениво при первом вызове
`transcribe` и переиспользуется всеми последующими вызовами в рамках
одного процесса воркера (процесс запускается
в Celery-очереди `transcription` с `--pool=solo --concurrency=1`, поэтому
гонок за атрибут класса не возникает).
"""
provider: ClassVar[str] = "faster_whisper_cpu"
_model: "ClassVar[WhisperModel | None]" = None
def __init__(
self,
model: str | None = None,
language: str = "ru",
download_root: str | None = None,
) -> None:
super().__init__(model=model or "small", language=language, download_root=download_root)
self.device = "cpu"
self.compute_type = "int8"
@register_transcriber
class FasterWhisperGPU(_FasterWhisperBase):
"""Транскрибер faster-whisper на GPU (CUDA, уровни `medium`/`max`, ADR-004).
`compute_type` — конструкторская опция (дефолт `float16`, как в матрице
ADR-004); для экономии VRAM конфиг уровня может задать `int8_float16`
(options плагина в `TIERS`/`plugins.yaml`).
"""
provider: ClassVar[str] = "faster_whisper_gpu"
_model: "ClassVar[WhisperModel | None]" = None
def __init__(
self,
model: str | None = None,
language: str = "ru",
download_root: str | None = None,
compute_type: str = "float16",
) -> None:
super().__init__(model=model or "medium", language=language, download_root=download_root)
self.device = "cuda"
self.compute_type = compute_type

View File

@@ -0,0 +1,36 @@
"""No-op реализации Transcriber/Summarizer, используемые как безопасный default."""
from typing import Any, ClassVar
from core.plugins.factory import register_summarizer, register_transcriber
from core.plugins.summarizer import Summarizer
from core.plugins.transcriber import Segment, Transcriber
@register_transcriber
class NullTranscriber(Transcriber):
"""Transcriber, который не выдаёт сегменты; используется когда транскрибация отключена."""
provider: ClassVar[str] = "null"
def __init__(self, model: str | None = None, language: str = "ru", **options: Any) -> None:
self.model = model
self.language = language
self.options = options
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
return []
@register_summarizer
class NullSummarizer(Summarizer):
"""Summarizer, который выдаёт пустое резюме; используется когда суммаризация отключена."""
provider: ClassVar[str] = "null"
def __init__(self, model: str | None = None, **options: Any) -> None:
self.model = model
self.options = options
def summarize(self, transcript: str) -> str:
return ""

View File

@@ -0,0 +1,203 @@
"""Плагин `Summarizer` на локальной модели семейства Qwen через сервер llama.cpp.
Конкретная модель/квант не зашиты в плагине — их задаёт конфигурация
(`config/plugins.yaml` либо `TierSpec` в `services/ai_tiers.py`, ADR-004);
дефолт конструктора (`qwen2.5-3b-instruct-q4_k_m`) — только фолбэк на случай
прямого создания плагина без конфига.
Map-reduce целиком инкапсулирован в плагине (контракт `Summarizer.summarize`
не меняется — ТЗ §1.3): транскрипт делится на чанки
чистой функцией `chunk_transcript`, каждый чанк резюмируется отдельным
вызовом LLM (map), частичные резюме объединяются одним reduce-вызовом; если
частичные резюме суммарно не влезают в бюджет токенов запроса — reduce
выполняется иерархически, группами, пока не останется одно резюме.
`max_tokens_map`/`max_tokens_reduce` — раздельные per-tier лимиты генерации
(ADR-004: reduce всегда ≥ map — 1024 токенов на reduce не хватает).
Тексты промптов (`workers/summarizer/prompts/summary_map_ru.txt`,
`summary_reduce_ru.txt`) утверждены и не меняются в коде — загружаются
лениво из файлов. Подстановка плейсхолдера — через `str.replace`, а не
`str.format`: промпты содержат разметку формата вывода (`[что решено] —
ответственный: [имя]` и т.п.) с квадратными, но потенциально и фигурными
скобками в будущих правках текста — `str.format` на них падает с
`KeyError`/`IndexError`, тогда как `str.replace` нечувствителен к остальному
содержимому файла.
"""
from pathlib import Path
from typing import Any, ClassVar
from core.plugins.factory import register_summarizer
from core.plugins.summarizer import Summarizer
from core.summarization.chunking import chunk_transcript
from core.summarization.llm_client import OpenAICompatClient
from core.summarization.tokens import QwenTokenCounter
_DEFAULT_PROMPTS_DIR = "workers/summarizer/prompts"
_MAP_PROMPT_FILE = "summary_map_ru.txt"
_REDUCE_PROMPT_FILE = "summary_reduce_ru.txt"
_MAP_PLACEHOLDER = "{transcript_chunk}"
_REDUCE_PLACEHOLDER = "{partial_summaries}"
_REDUCE_BUDGET_TOKENS = 6000
"""Бюджет токенов на один reduce-вызов (частичные резюме + шаблон промпта);
меньше `max_chunk_tokens` чанкера — запас под текст самого reduce-промпта и
вывод модели в общем контексте (CTX_SIZE=16384)."""
@register_summarizer
class QwenLocal(Summarizer):
"""Summarizer на Qwen2.5-3B-Instruct через OpenAI-совместимый сервер llama.cpp."""
provider: ClassVar[str] = "qwen_local"
def __init__(
self,
model: str | None = None,
chunk_minutes: int = 20,
base_url: str = "http://llm:8080/v1",
tokenizer_path: str = "/models/qwen/tokenizer.json",
prompts_dir: str = _DEFAULT_PROMPTS_DIR,
temperature: float = 0.2,
max_tokens: int = 1024,
max_tokens_map: int | None = None,
max_tokens_reduce: int | None = None,
**options: Any,
) -> None:
self.model = model or "qwen2.5-3b-instruct-q4_k_m"
self.chunk_minutes = chunk_minutes
self.base_url = base_url
self.tokenizer_path = tokenizer_path
self.prompts_dir = prompts_dir
self.temperature = temperature
self.max_tokens = max_tokens
# Раздельные лимиты map/reduce (ADR-004, per-tier параметры генерации);
# без явного значения оба используют общий `max_tokens` — обратная
# совместимость со старым форматом конфигурации.
self.max_tokens_map = max_tokens_map if max_tokens_map is not None else max_tokens
self.max_tokens_reduce = max_tokens_reduce if max_tokens_reduce is not None else max_tokens
self.options = options
self._count_tokens = QwenTokenCounter(tokenizer_path)
self._client: OpenAICompatClient | None = None
self._map_prompt: str | None = None
self._reduce_prompt: str | None = None
def _get_client(self) -> OpenAICompatClient:
"""Лениво создать HTTP-клиент LLM (переиспользуется в рамках инстанса плагина)."""
if self._client is None:
self._client = OpenAICompatClient(
base_url=self.base_url,
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
**self.options,
)
return self._client
def close(self) -> None:
"""Закрыть HTTP-клиент LLM, если он был лениво создан (освободить пул соединений).
Безопасно вызывать многократно и до первого использования — если
клиент ни разу не создавался, ничего не делает.
"""
if self._client is not None:
self._client.close()
self._client = None
def _load_prompt(self, filename: str) -> str:
"""Прочитать текст промпта из `prompts_dir` (без изменений, как есть на диске)."""
return (Path(self.prompts_dir) / filename).read_text(encoding="utf-8")
def _map_prompt_template(self) -> str:
if self._map_prompt is None:
self._map_prompt = self._load_prompt(_MAP_PROMPT_FILE)
return self._map_prompt
def _reduce_prompt_template(self) -> str:
if self._reduce_prompt is None:
self._reduce_prompt = self._load_prompt(_REDUCE_PROMPT_FILE)
return self._reduce_prompt
def _map_chunk(self, chunk: str) -> str:
"""Выполнить map-вызов LLM для одного чанка транскрипта."""
prompt = self._map_prompt_template().replace(_MAP_PLACEHOLDER, chunk)
return self._get_client().complete(prompt, max_tokens=self.max_tokens_map)
def _reduce_once(self, summaries: list[str]) -> str:
"""Выполнить один reduce-вызов LLM над группой частичных резюме."""
joined = "\n\n".join(summaries)
prompt = self._reduce_prompt_template().replace(_REDUCE_PLACEHOLDER, joined)
return self._get_client().complete(prompt, max_tokens=self.max_tokens_reduce)
def _group_by_token_budget(self, summaries: list[str], budget: int) -> list[list[str]]:
"""Жадно сгруппировать резюме так, чтобы каждая группа влезала в `budget` токенов."""
groups: list[list[str]] = []
current: list[str] = []
current_tokens = 0
for summary in summaries:
tokens = self._count_tokens(summary)
if current and current_tokens + tokens > budget:
groups.append(current)
current = []
current_tokens = 0
current.append(summary)
current_tokens += tokens
if current:
groups.append(current)
return groups
def _reduce(self, partial_summaries: list[str]) -> str:
"""Свести частичные резюме к одному, иерархически группами при переполнении бюджета.
Группировка по токенам (`_group_by_token_budget`) не гарантирует
прогресс, если отдельные частичные резюме сами не помещаются в
`_REDUCE_BUDGET_TOKENS` (например, при неудачно большом `max_tokens`
в конфиге плагина) — тогда она вырождается в список синглтон-групп,
и список резюме не сокращается. В этом случае принудительно сводим
резюме попарно: длина списка минимум делится пополам на каждой
итерации, что гарантирует завершение цикла за конечное число шагов.
"""
summaries = partial_summaries
while len(summaries) > 1:
joined_tokens = self._count_tokens("\n\n".join(summaries))
if joined_tokens <= _REDUCE_BUDGET_TOKENS:
return self._reduce_once(summaries)
groups = self._group_by_token_budget(summaries, _REDUCE_BUDGET_TOKENS)
if len(groups) >= len(summaries):
# Группировка по бюджету не уменьшила число групп (каждое
# резюме — уже отдельная группа) — гарантируем прогресс
# принудительным объединением попарно.
groups = [summaries[i : i + 2] for i in range(0, len(summaries), 2)]
summaries = [self._reduce_once(group) for group in groups]
return summaries[0]
def summarize(self, transcript: str) -> str:
"""Построить резюме транскрипта: map по чанкам, затем reduce до одного текста.
Пустой транскрипт (пустой список чанков) — пустая строка без вызовов
LLM. Единственный чанк — map-результат уже соответствует формату
reduce-вывода, дополнительный reduce-вызов не требуется.
HTTP-клиент LLM (если он был создан) закрывается по завершении вызова
независимо от исхода — плагин инстанцируется на одну задачу
суммаризации (см. `create_summarizer` в фабрике), поэтому держать
пул соединений открытым дольше одного вызова `summarize` не нужно.
"""
try:
chunks = chunk_transcript(
transcript,
self._count_tokens,
target_chunk_minutes=self.chunk_minutes,
)
if not chunks:
return ""
partial_summaries = [self._map_chunk(chunk) for chunk in chunks]
if len(partial_summaries) == 1:
return partial_summaries[0]
return self._reduce(partial_summaries)
finally:
self.close()

View File

@@ -0,0 +1,15 @@
"""Контракт плагина Summarizer."""
from abc import ABC, abstractmethod
from typing import ClassVar
class Summarizer(ABC):
"""Интерфейс Strategy для реализаций суммаризации текста."""
provider: ClassVar[str]
@abstractmethod
def summarize(self, transcript: str) -> str:
"""Создать резюме переданной трансцрибции."""
...

View File

@@ -0,0 +1,25 @@
"""Контракт плагина Transcriber."""
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]:
"""Транскрибировать аудиофайл по пути `audio_path` в список сегментов."""
...