Первоначальная версия VidConf
This commit is contained in:
0
backend/core/__init__.py
Normal file
0
backend/core/__init__.py
Normal file
138
backend/core/config.py
Normal file
138
backend/core/config.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Конфигурация приложения, загруженная из переменных окружения / файла .env."""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from core.plugins.config import AiLevel
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Центральные параметры приложения.
|
||||
|
||||
Значения читаются из переменных окружения (или файла `.env`).
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
database_url: str = "postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
plugins_config_path: str = "../config/plugins.yaml"
|
||||
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из файла `VERSION` (корень репозитория) в
|
||||
# `.env` при каждой установке/обновлении — здесь только чтение готового
|
||||
# значения. Отдаётся в `GET /api/health` (футер админки, Блок 4).
|
||||
vidconf_version: str = "0.0.0"
|
||||
|
||||
# Домен не должен попадать в список special-use/reserved (RFC 6761,
|
||||
# напр. `.local`/`.test`): email-validator (`EmailStr`) их отклоняет, а
|
||||
# раньше это ловилось и на выходе — старый дефолт `admin@vidconf.local`
|
||||
# ронял `GET /users/me` 500 `ResponseValidationError`, пока `UserOut.email`
|
||||
# был `EmailStr`. `.example` (RFC 2606) email-validator пропускает.
|
||||
seed_admin_email: str = "admin@vidconf.example"
|
||||
seed_admin_password: str = "change-me"
|
||||
|
||||
# --- Auth (JWT + email-подтверждение) ---
|
||||
jwt_secret: str = "dev-only-insecure-secret-change-me"
|
||||
access_token_ttl_minutes: int = 15
|
||||
refresh_token_ttl_days: int = 14
|
||||
email_verification_ttl_hours: int = 24
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
# Флаг Secure для refresh-cookie. false нужен только для dev по
|
||||
# http://localhost (Safari, в отличие от Chrome, не сохраняет
|
||||
# Secure-cookie без HTTPS); в проде обязательно true.
|
||||
auth_cookie_secure: bool = True
|
||||
|
||||
# --- LiveKit ---
|
||||
livekit_api_key: str = "devkey"
|
||||
livekit_api_secret: str = "change-me-livekit-secret"
|
||||
livekit_public_url: str = "ws://localhost:7880"
|
||||
# Внутренний server-to-server URL для вызовов LiveKit RoomService (Celery
|
||||
# maintenance-задача); в отличие от `livekit_public_url` не проксируется
|
||||
# через nginx/TLS для браузера. LiveKit SDK сам нормализует ws:// в http://.
|
||||
livekit_url: str = "ws://localhost:7880"
|
||||
|
||||
# --- Пайплайн транскрибации ---
|
||||
# Общий volume между LiveKit Egress и celery-воркером `transcription`
|
||||
# (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`.
|
||||
recordings_dir: str = "/recordings"
|
||||
|
||||
# --- Email (SMTP-бэкенд) ---
|
||||
# `console` — дефолт для dev (письмо только логируется); `smtp` — реальная
|
||||
# отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6),
|
||||
# переключатель бэкенда — тоже переменная окружения, а не настройка в БД
|
||||
# (`instance_settings`).
|
||||
email_backend: str = "console"
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 587
|
||||
smtp_username: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_start_tls: bool = True
|
||||
smtp_use_tls: bool = False
|
||||
smtp_from: str = "VidConf <no-reply@vidconf.example>"
|
||||
smtp_timeout_s: int = 30
|
||||
|
||||
# --- Медиа (аватары пользователей) ---
|
||||
# Каталог, куда сохраняются загруженные файлы (аватары — `avatars/{user_id}.{ext}`);
|
||||
# раздаётся статикой по `/media` (`main.py`, dev) либо через nginx `location /media/`
|
||||
# в проде (`deploy/nginx/nginx.conf`, volume `media`). Относительный путь по
|
||||
# умолчанию — рабочая директория backend (аналог `recordings_dir`, но без
|
||||
# требования root для локального запуска вне Docker).
|
||||
media_root: str = "media"
|
||||
|
||||
# --- Автодетект железа: install.sh определяет `nproc`/`free -m`/
|
||||
# `nvidia-smi` и пишет в `.env`; читает `services/ai_levels.py` для детекта
|
||||
# доступности уровней AI (ADR-004) без torch/nvidia-smi внутри процесса
|
||||
# backend/воркеров. `None` — install.sh не запускался (dev-окружение) либо
|
||||
# GPU не обнаружен (`hw_gpu_name`/`hw_vram_mb`).
|
||||
hw_cpus: int | None = None
|
||||
hw_ram_mb: int | None = None
|
||||
hw_gpu_name: str | None = None
|
||||
hw_vram_mb: int | None = None
|
||||
|
||||
# --- Матрица «пресет → настройки» инсталлятора: install.sh пишет эти три
|
||||
# переменные в `.env` по выбранному пресету (1–5), lifespan backend
|
||||
# передаёт их бутстрапу `instance_settings` (`services/instance_settings.py`,
|
||||
# `bootstrap_overrides_from_settings`) как overrides дефолтов
|
||||
# `plugins.yaml` — БЕЗ этого механизма бутстрап всегда включал чат и
|
||||
# AI-модули независимо от пресета. `None` — install.sh не запускался
|
||||
# (dev-окружение) либо переменная не установлена для этого пресета:
|
||||
# бутстрап тогда использует дефолты `plugins.yaml` как раньше.
|
||||
bootstrap_chat_enabled: bool | None = None
|
||||
bootstrap_transcription_enabled: bool | None = None
|
||||
bootstrap_ai_level: AiLevel | None = None
|
||||
|
||||
@field_validator(
|
||||
"hw_cpus",
|
||||
"hw_ram_mb",
|
||||
"hw_gpu_name",
|
||||
"hw_vram_mb",
|
||||
"bootstrap_chat_enabled",
|
||||
"bootstrap_transcription_enabled",
|
||||
"bootstrap_ai_level",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _empty_hw_string_to_none(cls, value: object) -> object:
|
||||
"""Пустая строка env (`KEY=`, а не отсутствие переменной) → `None`.
|
||||
|
||||
`docker-compose` подставляет `env_file` дословно: `HW_VRAM_MB=` в `.env`
|
||||
(пишет `install.sh` на любой машине без NVIDIA GPU, пресеты 1–4;
|
||||
`.env.example` — все четыре `HW_*` пустыми по умолчанию) превращается в
|
||||
переменную окружения со значением `""`, а не в отсутствующую переменную —
|
||||
без этой нормализации pydantic не парсит `""` как `int` и роняет
|
||||
`Settings()` уже на импорте модуля (`main.py`, `workers/celery_app.py`),
|
||||
не давая контейнеру стартовать. Та же проблема для `BOOTSTRAP_*`
|
||||
(`.env.example` — пустыми по умолчанию, install.sh заполняет по пресету).
|
||||
"""
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Вернуть кэшированный экземпляр `Settings`."""
|
||||
return Settings()
|
||||
24
backend/core/db.py
Normal file
24
backend/core/db.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Настройка асинхронного движка SQLAlchemy и сеанса."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
|
||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Зависимость FastAPI, возвращающая `AsyncSession`."""
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
12
backend/core/plugins/__init__.py
Normal file
12
backend/core/plugins/__init__.py
Normal 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
|
||||
84
backend/core/plugins/config.py
Normal file
84
backend/core/plugins/config.py
Normal 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
|
||||
47
backend/core/plugins/factory.py
Normal file
47
backend/core/plugins/factory.py
Normal 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]
|
||||
141
backend/core/plugins/faster_whisper.py
Normal file
141
backend/core/plugins/faster_whisper.py
Normal 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
|
||||
36
backend/core/plugins/null.py
Normal file
36
backend/core/plugins/null.py
Normal 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 ""
|
||||
203
backend/core/plugins/qwen_local.py
Normal file
203
backend/core/plugins/qwen_local.py
Normal 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()
|
||||
15
backend/core/plugins/summarizer.py
Normal file
15
backend/core/plugins/summarizer.py
Normal 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:
|
||||
"""Создать резюме переданной трансцрибции."""
|
||||
...
|
||||
25
backend/core/plugins/transcriber.py
Normal file
25
backend/core/plugins/transcriber.py
Normal 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` в список сегментов."""
|
||||
...
|
||||
36
backend/core/rate_limit.py
Normal file
36
backend/core/rate_limit.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Rate limit на основе Redis `INCR`+`EXPIRE` для публичных (без auth) эндпоинтов.
|
||||
|
||||
Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
|
||||
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
|
||||
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from core.redis import redis_client
|
||||
|
||||
RATE_LIMIT_MAX_REQUESTS = 10
|
||||
RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
async def enforce_rate_limit(
|
||||
key: str,
|
||||
*,
|
||||
max_requests: int = RATE_LIMIT_MAX_REQUESTS,
|
||||
window_seconds: int = RATE_LIMIT_WINDOW_SECONDS,
|
||||
) -> None:
|
||||
"""Увеличить счётчик запросов по ключу; бросить 429, если лимит превышен.
|
||||
|
||||
`INCR` атомарно создаёт ключ со значением 1, если его ещё не было; TTL
|
||||
выставляется только при первом инкременте в окне (когда счётчик стал
|
||||
равен 1) — иначе окно продлевалось бы при каждом запросе и лимит
|
||||
никогда бы не истекал.
|
||||
"""
|
||||
redis_key = f"rate_limit:{key}"
|
||||
current = await redis_client.incr(redis_key)
|
||||
if current == 1:
|
||||
await redis_client.expire(redis_key, window_seconds)
|
||||
if current > max_requests:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="rate_limit_exceeded"
|
||||
)
|
||||
9
backend/core/redis.py
Normal file
9
backend/core/redis.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Настройка асинхронного Redis клиента."""
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
redis_client: Redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
71
backend/core/security.py
Normal file
71
backend/core/security.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
from core.config import get_settings
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Захэшировать пароль алгоритмом argon2 для хранения в БД."""
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются."""
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(user_id: uuid.UUID, role: str) -> str:
|
||||
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
|
||||
settings = get_settings()
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"type": "access",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=settings.access_token_ttl_minutes),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: uuid.UUID) -> tuple[str, str]:
|
||||
"""Выпустить refresh-токен с уникальным `jti`.
|
||||
|
||||
Возвращает пару (token, jti); сохранение jti в Redis — ответственность
|
||||
вызывающего кода (`services.auth.AuthService`).
|
||||
"""
|
||||
settings = get_settings()
|
||||
jti = str(uuid.uuid4())
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"jti": jti,
|
||||
"type": "refresh",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(days=settings.refresh_token_ttl_days),
|
||||
}
|
||||
token = jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM)
|
||||
return token, jti
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""Декодировать и верифицировать JWT (сигнатура + срок действия).
|
||||
|
||||
Бросает `jwt.PyJWTError` (или подкласс) при невалидном/просроченном токене.
|
||||
"""
|
||||
settings = get_settings()
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
|
||||
5
backend/core/summarization/__init__.py
Normal file
5
backend/core/summarization/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Пакет чистых функций суммаризации: чанкинг транскрипта и подсчёт токенов.
|
||||
|
||||
Плагин `QwenLocal` использует эти функции как строительные
|
||||
блоки map-reduce; сам пакет не знает про LLM и HTTP.
|
||||
"""
|
||||
156
backend/core/summarization/chunking.py
Normal file
156
backend/core/summarization/chunking.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Чанкинг транскрипта для map-стадии суммаризации (ТЗ §1.3).
|
||||
|
||||
Транскрипт — строка с одной фразой на строку в формате `[Имя MM:SS] текст`
|
||||
(или `[Имя ЧЧ:MM:SS] текст` от часа, см. `workers/summarizer/transcript.py`).
|
||||
Каждая строка уже атомарна и соответствует одной реконструированной фразе
|
||||
(`build_phrases`) — граница фразы там уже равна смене спикера,
|
||||
поэтому `chunk_transcript` закрывает чанк исключительно на границах строк и
|
||||
никогда не режет фразу пополам (кроме аварийного случая монолога, см. ниже).
|
||||
|
||||
Правила закрытия чанка (проверяются перед добавлением очередной фразы):
|
||||
- добавление фразы сделало бы охваченный чанком промежуток времени
|
||||
(от начала чанка до начала этой фразы) >= `target_chunk_minutes`;
|
||||
- ЛИБО добавление фразы превысило бы `max_chunk_tokens` для чанка.
|
||||
В любом из этих случаев уже накопленный чанк закрывается, а фраза уходит в
|
||||
новый чанк.
|
||||
|
||||
Аварийный случай — монолог: единственная фраза сама по себе превышает
|
||||
`max_chunk_tokens` (например, 40-минутный монолог одного участника). Такую
|
||||
фразу нельзя оставить целой и нельзя просто выбросить — она делится по
|
||||
границам предложений на несколько частей меньше лимита, каждая из которых
|
||||
получает повторённую исходную метку `[Имя MM:SS]`, и добавляется в список
|
||||
чанков как самостоятельный чанк.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
|
||||
_LABEL_RE = re.compile(r"^\[(?P<speaker>.+) (?P<time>\d{1,3}:\d{2}(?::\d{2})?)\] (?P<text>.*)$")
|
||||
"""Метка фразы: `[Имя MM:SS]` или `[Имя ЧЧ:MM:SS]`; имя может содержать пробелы."""
|
||||
|
||||
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
|
||||
"""Граница предложения — пробел после `.`, `!` или `?`."""
|
||||
|
||||
|
||||
def _parse_offset_seconds(time_str: str) -> float:
|
||||
"""Разобрать метку времени `MM:SS` или `ЧЧ:MM:SS` в секунды от начала сеанса."""
|
||||
parts = [int(part) for part in time_str.split(":")]
|
||||
if len(parts) == 2:
|
||||
minutes, seconds = parts
|
||||
return float(minutes * 60 + seconds)
|
||||
hours, minutes, seconds = parts
|
||||
return float(hours * 3600 + minutes * 60 + seconds)
|
||||
|
||||
|
||||
def _line_offset(line: str) -> float:
|
||||
"""Извлечь смещение начала фразы (в секундах) из метки строки.
|
||||
|
||||
Строка без распознаваемой метки считается стоящей в начале сеанса
|
||||
(0.0) — чанкер не должен падать на нестандартном входе, только терять
|
||||
точность деления по времени.
|
||||
"""
|
||||
match = _LABEL_RE.match(line)
|
||||
if match is None:
|
||||
return 0.0
|
||||
return _parse_offset_seconds(match.group("time"))
|
||||
|
||||
|
||||
def _split_sentences(text: str) -> list[str]:
|
||||
"""Разбить текст фразы на предложения; пустой текст даёт список из одного элемента."""
|
||||
sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()]
|
||||
return sentences or [text]
|
||||
|
||||
|
||||
def _split_monologue(
|
||||
line: str,
|
||||
count_tokens: Callable[[str], int],
|
||||
max_chunk_tokens: int,
|
||||
) -> list[str]:
|
||||
"""Аварийно разделить фразу-монолог, превышающую лимит, по предложениям.
|
||||
|
||||
Метка спикера повторяется в каждой части. Если строка не распознана как
|
||||
фраза с меткой (не должно случаться при штатном входе из
|
||||
`build_transcript`), делить не на чем — возвращаем строку одним чанком,
|
||||
даже с превышением лимита.
|
||||
"""
|
||||
match = _LABEL_RE.match(line)
|
||||
if match is None:
|
||||
return [line]
|
||||
|
||||
label = f"[{match.group('speaker')} {match.group('time')}]"
|
||||
sentences = _split_sentences(match.group("text"))
|
||||
|
||||
pieces: list[str] = []
|
||||
current: list[str] = []
|
||||
for sentence in sentences:
|
||||
candidate = f"{label} {' '.join([*current, sentence])}"
|
||||
if current and count_tokens(candidate) > max_chunk_tokens:
|
||||
pieces.append(f"{label} {' '.join(current)}")
|
||||
current = [sentence]
|
||||
else:
|
||||
current.append(sentence)
|
||||
if current:
|
||||
pieces.append(f"{label} {' '.join(current)}")
|
||||
return pieces
|
||||
|
||||
|
||||
def chunk_transcript(
|
||||
transcript: str,
|
||||
count_tokens: Callable[[str], int],
|
||||
*,
|
||||
max_chunk_tokens: int = 8000,
|
||||
target_chunk_minutes: int = 20,
|
||||
) -> list[str]:
|
||||
"""Разбить транскрипт на чанки для map-стадии суммаризации.
|
||||
|
||||
Аргументы:
|
||||
transcript: строки-фразы `[Имя MM:SS] текст`, по одной на строку.
|
||||
count_tokens: подсчёт токенов для строки/чанка (в проде —
|
||||
`QwenTokenCounter`, в тестах — фейковый callable).
|
||||
max_chunk_tokens: верхняя граница токенов на чанк (ТЗ: 3–8k).
|
||||
target_chunk_minutes: целевая длительность чанка в минутах записи.
|
||||
|
||||
Возвращает список строк-чанков (без изменения содержимого фраз внутри),
|
||||
пустой список для пустого транскрипта.
|
||||
"""
|
||||
lines = [line for line in transcript.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
target_seconds = target_chunk_minutes * 60
|
||||
|
||||
chunks: list[str] = []
|
||||
current_lines: list[str] = []
|
||||
current_tokens = 0
|
||||
chunk_start_offset = 0.0
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current_lines, current_tokens
|
||||
if current_lines:
|
||||
chunks.append("\n".join(current_lines))
|
||||
current_lines = []
|
||||
current_tokens = 0
|
||||
|
||||
for line in lines:
|
||||
line_tokens = count_tokens(line)
|
||||
|
||||
if line_tokens > max_chunk_tokens:
|
||||
# монолог одной фразой больше лимита — аварийное деление по предложениям
|
||||
flush()
|
||||
chunks.extend(_split_monologue(line, count_tokens, max_chunk_tokens))
|
||||
continue
|
||||
|
||||
if current_lines:
|
||||
offset = _line_offset(line)
|
||||
prospective_span = offset - chunk_start_offset
|
||||
prospective_tokens = current_tokens + line_tokens
|
||||
if prospective_tokens > max_chunk_tokens or prospective_span >= target_seconds:
|
||||
flush()
|
||||
|
||||
if not current_lines:
|
||||
chunk_start_offset = _line_offset(line)
|
||||
current_lines.append(line)
|
||||
current_tokens += line_tokens
|
||||
|
||||
flush()
|
||||
return chunks
|
||||
155
backend/core/summarization/llm_client.py
Normal file
155
backend/core/summarization/llm_client.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""HTTP-клиент к OpenAI-совместимому LLM-серверу (llama.cpp server, ТЗ §3.3).
|
||||
|
||||
`llama.cpp server` (образ `ghcr.io/ggml-org/llama.cpp:server`) отдаёт
|
||||
`/v1/chat/completions` в формате OpenAI Chat Completions API: тело запроса
|
||||
`{"model": ..., "messages": [{"role": "user", "content": ...}]}`, ответ —
|
||||
`choices[0].message.content` (см. `tools/server/README.md` проекта llama.cpp).
|
||||
|
||||
Надёжность: retry с экспоненциальным backoff на
|
||||
сетевых ошибках и retryable HTTP-статусах (5xx, включая 503 — модель ещё
|
||||
грузится), плюс circuit breaker поверх retry — после `breaker_threshold`
|
||||
подряд неудачных вызовов `complete()` окно `breaker_cooldown_s` секунд все
|
||||
вызовы падают немедленно с `LlmUnavailableError`, не делая HTTP-запросов.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INITIAL_BACKOFF_S = 0.5
|
||||
"""Базовая пауза перед повтором; растёт экспоненциально: `_INITIAL_BACKOFF_S * 2**attempt`."""
|
||||
|
||||
_RETRYABLE_STATUS_CODES = frozenset({503})
|
||||
"""Дополнительные ретраибл-статусы помимо диапазона 5xx (503 — модель llama.cpp ещё грузится)."""
|
||||
|
||||
|
||||
class LlmUnavailableError(Exception):
|
||||
"""LLM-сервер недоступен: исчерпаны попытки retry либо открыт circuit breaker."""
|
||||
|
||||
|
||||
class OpenAICompatClient:
|
||||
"""Клиент чат-комплишенов OpenAI-совместимого сервера (llama.cpp, Ollama и т.п.).
|
||||
|
||||
`transport` — точка внедрения `httpx.MockTransport` в тестах; в проде не
|
||||
передаётся (используется реальный сетевой транспорт `httpx`).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
model: str,
|
||||
*,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 1024,
|
||||
timeout_s: float = 600.0,
|
||||
max_attempts: int = 3,
|
||||
breaker_threshold: int = 5,
|
||||
breaker_cooldown_s: float = 60.0,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._model = model
|
||||
self._temperature = temperature
|
||||
self._max_tokens = max_tokens
|
||||
self._max_attempts = max_attempts
|
||||
self._breaker_threshold = breaker_threshold
|
||||
self._breaker_cooldown_s = breaker_cooldown_s
|
||||
self._client = httpx.Client(timeout=timeout_s, transport=transport)
|
||||
|
||||
self._consecutive_failures = 0
|
||||
self._breaker_open_until = 0.0
|
||||
|
||||
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
|
||||
"""Выполнить чат-комплишн по одному пользовательскому сообщению `prompt`.
|
||||
|
||||
`max_tokens` — переопределение лимита на конкретный вызов (ADR-004:
|
||||
раздельные лимиты map/reduce у `QwenLocal`); по умолчанию берётся
|
||||
`max_tokens`, заданный в конструкторе клиента.
|
||||
|
||||
Бросает `LlmUnavailableError`, если circuit breaker открыт (окно
|
||||
отказа ещё не истекло) либо все попытки retry исчерпаны.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if now < self._breaker_open_until:
|
||||
raise LlmUnavailableError(
|
||||
"LLM-сервер недоступен: circuit breaker открыт после серии сбоев"
|
||||
)
|
||||
|
||||
url = f"{self._base_url}/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": self._temperature,
|
||||
"max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
|
||||
}
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(self._max_attempts):
|
||||
try:
|
||||
response = self._client.post(url, json=payload)
|
||||
except httpx.TransportError as exc:
|
||||
last_error = exc
|
||||
logger.warning(
|
||||
"Сетевая ошибка при обращении к LLM (попытка %d): %s", attempt + 1, exc
|
||||
)
|
||||
else:
|
||||
if response.status_code == 200:
|
||||
self._consecutive_failures = 0
|
||||
try:
|
||||
data = response.json()
|
||||
content: str = data["choices"][0]["message"]["content"]
|
||||
except (ValueError, KeyError, IndexError, TypeError) as exc:
|
||||
last_error = exc
|
||||
logger.warning("Некорректный ответ LLM-сервера: %s", exc)
|
||||
else:
|
||||
return content
|
||||
elif response.status_code >= 500 or response.status_code in _RETRYABLE_STATUS_CODES:
|
||||
last_error = RuntimeError(
|
||||
f"LLM-сервер вернул retryable статус {response.status_code}"
|
||||
)
|
||||
logger.warning(
|
||||
"Retryable статус %d от LLM (попытка %d)", response.status_code, attempt + 1
|
||||
)
|
||||
else:
|
||||
# Не retryable статус (например, 4xx) — не тратим оставшиеся
|
||||
# попытки, но фиксируем сбой для circuit breaker.
|
||||
self._register_failure()
|
||||
raise LlmUnavailableError(
|
||||
f"LLM-сервер вернул статус {response.status_code}: {response.text}"
|
||||
) from None
|
||||
|
||||
if attempt < self._max_attempts - 1:
|
||||
time.sleep(_INITIAL_BACKOFF_S * (2**attempt))
|
||||
|
||||
self._register_failure()
|
||||
raise LlmUnavailableError(
|
||||
f"LLM-сервер недоступен после {self._max_attempts} попыток"
|
||||
) from last_error
|
||||
|
||||
def _register_failure(self) -> None:
|
||||
"""Учесть неудачный вызов `complete()`; открыть breaker при достижении порога."""
|
||||
self._consecutive_failures += 1
|
||||
if self._consecutive_failures >= self._breaker_threshold:
|
||||
self._breaker_open_until = time.monotonic() + self._breaker_cooldown_s
|
||||
logger.warning(
|
||||
"Circuit breaker открыт на %.0fс после %d подряд неудач",
|
||||
self._breaker_cooldown_s,
|
||||
self._consecutive_failures,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Закрыть базовый HTTP-клиент (освободить соединения и пул `httpx`).
|
||||
|
||||
Безопасно вызывать более одного раза — `httpx.Client.close()` идемпотентен.
|
||||
"""
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> "OpenAICompatClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
51
backend/core/summarization/tokens.py
Normal file
51
backend/core/summarization/tokens.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Подсчёт токенов для чанкера и плагина `QwenLocal`."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QwenTokenCounter:
|
||||
"""Подсчёт токенов токенизатором модели Qwen2.5-3B-Instruct.
|
||||
|
||||
Загрузка `tokenizers.Tokenizer` — ленивая (при первом вызове), чтобы
|
||||
инстанцирование плагина в API-процессе не тянуло за собой файл
|
||||
токенизатора. Если файл отсутствует или повреждён — используется
|
||||
эвристический фолбэк `len(text) // 3`, чтобы пайплайн не падал из-за
|
||||
отсутствия токенизатора (например, в dev-окружении без volume модели).
|
||||
"""
|
||||
|
||||
def __init__(self, tokenizer_path: str | None = None) -> None:
|
||||
self._tokenizer_path = tokenizer_path
|
||||
self._tokenizer: Any | None = None
|
||||
self._load_attempted = False
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Загрузить токенизатор один раз (синглтон на инстанс counter'а)."""
|
||||
if self._load_attempted:
|
||||
return
|
||||
self._load_attempted = True
|
||||
|
||||
if not self._tokenizer_path:
|
||||
logger.warning("Путь к токенизатору не задан, использую эвристику len(text) // 3")
|
||||
return
|
||||
|
||||
try:
|
||||
from tokenizers import Tokenizer
|
||||
|
||||
self._tokenizer = Tokenizer.from_file(self._tokenizer_path)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Не удалось загрузить токенизатор из %s, использую эвристику len(text) // 3",
|
||||
self._tokenizer_path,
|
||||
)
|
||||
self._tokenizer = None
|
||||
|
||||
def __call__(self, text: str) -> int:
|
||||
"""Вернуть число токенов в тексте (или эвристическую оценку)."""
|
||||
self._ensure_loaded()
|
||||
if self._tokenizer is not None:
|
||||
encoded: int = len(self._tokenizer.encode(text).ids)
|
||||
return encoded
|
||||
return len(text) // 3
|
||||
Reference in New Issue
Block a user