Первоначальная версия 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

0
deploy/.gitkeep Normal file
View File

View File

@@ -0,0 +1,38 @@
# Dev config for coturn. Verified against the official documentation
# (github.com/coturn/coturn/wiki/turnserver, docker/coturn/README.md).
# Runs with network_mode: host in docker-compose.yml (recommended by coturn
# docs for large UDP relay port ranges).
#
# Secrets (realm / static-auth-secret) come from .env via TURN_REALM /
# TURN_STATIC_AUTH_SECRET; coturn substitutes $(VAR) at startup when invoked
# through the image's docker-entrypoint.sh, which evaluates each CLI arg.
# Since we pass a config file instead of CLI flags, keep real secrets in the
# .env and inject them here through docker-compose "environment" + a
# lightweight envsubst step if/when TLS certs are added; for the dev/plain
# profile the plaintext defaults below are fine (`change-me` values only).
listening-port=3478
# Set to 443 in production (TURN over TLS) once real TLS certs are mounted.
tls-listening-port=5349
# Relay port range for TURN allocations.
min-port=49160
max-port=49200
# --- Long-term credential mechanism via shared secret (TURN REST API) ---
use-auth-secret
static-auth-secret=change-me-turn-secret
realm=vidconf.local
# Required by WebRTC clients (adds STUN FINGERPRINT attribute).
fingerprint
# No CLI/telnet admin interface in this dev deployment.
no-cli
# Uncomment and mount real certs to enable TURN over TLS on 443:
# cert=/etc/coturn/certs/cert.pem
# pkey=/etc/coturn/certs/key.pem
log-file=stdout
simple-log

696
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,696 @@
name: vidconf
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-vidconf}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vidconf}
POSTGRES_DB: ${POSTGRES_DB:-vidconf}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vidconf} -d ${POSTGRES_DB:-vidconf}"]
interval: 5s
timeout: 5s
retries: 10
logging: *default-logging
# NOTE: btree_gist extension is created by an Alembic migration, not here.
redis:
image: redis:7
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
logging: *default-logging
backend:
build:
context: ../backend
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- ../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-devkey}
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-change-me-livekit-secret}
LIVEKIT_PUBLIC_URL: ${LIVEKIT_PUBLIC_URL:-ws://localhost:7880}
# Внутренний server-to-server адрес LiveKit (RoomService/Egress API).
# Жёстко перекрывает значение из .env: там LIVEKIT_URL=ws://localhost:7880
# для запуска backend на хосте, а изнутри контейнера localhost — это сам
# backend, и start_track_egress молча деградирует (warning, записи нет).
LIVEKIT_URL: ws://livekit:7880
# Общий с egress/worker-transcriber путь на volume `recordings` —
# backend формирует по нему filepath для start_track_egress, сам
# том backend'у не примонтирован (файлы пишет контейнер egress).
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
# Каталог загруженных аватаров — общий volume с nginx, который
# раздаёт его напрямую по `location /media/` (alias), в обход backend.
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.1}
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
# только backend/), поэтому plugins.yaml монтируется отдельно.
volumes:
- ../config:/app/config:ro
- media:/app/media
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
logging: *default-logging
# --- Celery worker + beat: та же backend-сборка, но с примонтированным
# пакетом workers/ (workers/ импортирует модели backend). ---
# `-Q celery,summarize,notify`: на малых пресетах
# поставки (13) один контейнер обслуживает суммаризацию, уведомления и
# обслуживающие задачи (`celery` — дефолтная очередь); `transcription`
# сюда не входит — её слушает только `worker-transcriber`(-gpu), см. их
# комментарий ниже. Вынос очередей в отдельные реплики при масштабировании
# — `docs/deploy/scaling.md`.
worker:
build:
context: ../backend
dockerfile: Dockerfile
restart: unless-stopped
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", "-B",
"-Q", "celery,summarize,notify", "--loglevel=info"]
env_file:
- ../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
PYTHONPATH: /app
volumes:
- ../workers:/app/workers:ro
# plugins.yaml из корня репозитория (см. комментарий у сервиса backend)
- ../config:/app/config:ro
# tokenizer.json модели Qwen (профиль `llm`, скачан llm-model-init) —
# нужен QwenTokenCounter для подсчёта токенов при чанкинге транскрипта
# (backend/core/summarization/tokens.py). Без профиля `llm` том пуст —
# QwenTokenCounter уходит в фолбэк-эвристику len(text)//3.
- llm-models:/models/qwen:ro
depends_on:
redis:
condition: service_healthy
# Сервис worker использует тот же backend-образ (см. build выше), поэтому
# без переопределения он наследует HEALTHCHECK из backend/Dockerfile
# (curl /api/health), который здесь бессмысленен — у celery-контейнера
# нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect
# ping`, как рекомендует документация Celery.
healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
interval: 15s
timeout: 10s
retries: 5
start_period: 20s
logging: *default-logging
# --- Профили transcribe/transcribe-gpu: том whisper-cache создаётся
# Docker root:root при первом использовании — случайно совпадает с uid
# backend-образа (в backend/Dockerfile НЕТ директивы USER, процессы внутри
# выполняются от root, uid 0), но фиксируем владельца явно, по образцу
# `recordings-init` — страховка от смены дефолтного uid образа/докера
# в будущем.
whisper-cache-init:
image: busybox:1.36
command: ["chown", "-R", "0:0", "/models/whisper"]
volumes:
- whisper-cache:/models/whisper
restart: "no"
profiles: ["transcribe", "transcribe-gpu"]
logging: *default-logging
# Разовая предзагрузка модели faster-whisper уровня AI (иначе первая
# транскрибация после старта воркера сама тянет веса из HuggingFace Hub —
# тот же дефект, что и с правами тома выше). `WHISPER_MODEL`
# пишет install.sh по выбранному пресету (3 → small, 4 → medium,
# 5 → large-v3, см. ADR-004); путь кэша — `/models/whisper/<модель>`,
# КОНТРАКТ с `backend/services/ai_tiers.py::WHISPER_MODELS_ROOT` (см.
# докстринг `deploy/whisper/download-model.py`) — используется и профилем
# `transcribe` (CPU, пресеты 3/4), и `transcribe-gpu` (пресет 5, max).
whisper-model-init:
build:
context: ../backend
dockerfile: Dockerfile
entrypoint: ["uv", "run", "python", "/download-model.py"]
environment:
WHISPER_MODEL: ${WHISPER_MODEL:-small}
WHISPER_MODELS_ROOT: /models/whisper
volumes:
- ./whisper/download-model.py:/download-model.py:ro
- whisper-cache:/models/whisper
restart: "no"
depends_on:
whisper-cache-init:
condition: service_completed_successfully
profiles: ["transcribe", "transcribe-gpu"]
logging: *default-logging
# --- Профиль transcribe: отдельный celery-воркер очереди `transcription`
# (faster-whisper, CPU — уровни AI `min`/`medium`, ADR-004: `medium`
# остаётся CPU-only в матрице `backend/services/ai_tiers.py`, GPU для него —
# ручная настройка администратора вне детекта). Изолирован от базового
# `worker`, потому что ctranslate2/faster-whisper несовместимы с
# prefork-пулом Celery (fork процесса после инициализации нативных
# библиотек небезопасен) — здесь `--pool=solo --concurrency=1`, модель —
# синглтон на процесс. Базовый `worker` очередь `transcription` не
# слушает (см. его command выше — явный `-Q` без `transcription`).
# Start with: docker compose --profile transcribe up -d
# (livekit из профиля `media` должен быть поднят отдельно/вместе).
worker-transcriber:
build:
context: ../backend
dockerfile: Dockerfile
restart: unless-stopped
# Фиксированное имя узла (`--hostname`) нужно, чтобы healthcheck ниже
# мог обратиться именно к этому воркеру: broker/redis общий с базовым
# `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ
# кластер — упавший worker-transcriber остался бы "healthy", потому что
# ответил бы базовый worker.
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
"-Q", "transcription", "--pool=solo", "--concurrency=1",
"--hostname=worker-transcriber@localhost", "--loglevel=info"]
env_file:
- ../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
PYTHONPATH: /app
volumes:
- ../workers:/app/workers:ro
# plugins.yaml из корня репозитория (см. комментарий у сервиса backend)
- ../config:/app/config:ro
- recordings:/recordings:ro
- whisper-cache:/models/whisper
# Лимиты ресурсов CPU-bound транскрибации (faster-whisper на CPU).
# `cpus`/`mem_limit` — прямые атрибуты Compose (применяются и без
# swarm), в отличие от `deploy.resources`, который вне `docker stack
# deploy` игнорируется.
cpus: "4"
mem_limit: 6g
depends_on:
redis:
condition: service_healthy
whisper-model-init:
condition: service_completed_successfully
# HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname
# в command выше), а не первого ответившего в общем кластере.
healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
interval: 15s
timeout: 10s
retries: 5
start_period: 20s
profiles: ["transcribe"]
logging: *default-logging
# --- Профиль transcribe-gpu: GPU-вариант транскрайбера, ТОЛЬКО уровень AI
# `max` (ADR-004, `backend/services/ai_tiers.py`: `medium` в матрице
# остаётся CPU-only, GPU для него — ручная настройка вне детекта, поэтому
# отдельного GPU-профиля для пресета 4 нет — см. комментарий у
# worker-transcriber выше). Собран из того же Dockerfile backend с
# extras-группой `gpu` (`nvidia-cublas-cu12`, `nvidia-cudnn-cu12==9.*` —
# объявлена в `backend/pyproject.toml`); действующий провайдер
# `faster_whisper_gpu` выбирается конфигом (TIERS["max"], не этим файлом).
# Взаимоисключаем c `worker-transcriber` через профили: одновременно оба
# профиля не поднимаются (install.sh выбирает ровно один по пресету).
worker-transcriber-gpu:
build:
context: ../backend
dockerfile: Dockerfile
args:
WITH_GPU_EXTRA: "true"
restart: unless-stopped
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
"-Q", "transcription", "--pool=solo", "--concurrency=1",
"--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"]
env_file:
- ../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
PYTHONPATH: /app
# cuBLAS/cuDNN9, поставленные extras-группой `gpu` в venv образа (не
# системные библиотеки CUDA) — CTranslate2 находит их только через
# LD_LIBRARY_PATH, см. ADR-004 (faster-whisper README).
LD_LIBRARY_PATH: /app/.venv/lib/python3.12/site-packages/nvidia/cublas/lib:/app/.venv/lib/python3.12/site-packages/nvidia/cudnn/lib
volumes:
- ../workers:/app/workers:ro
- ../config:/app/config:ro
- recordings:/recordings:ro
- whisper-cache:/models/whisper
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
depends_on:
redis:
condition: service_healthy
whisper-model-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
profiles: ["transcribe-gpu"]
logging: *default-logging
nginx:
# Собственный образ: nginx со вкомпилированной статикой фронтенд-SPA
# (frontend/Dockerfile, multi-stage: Vite-сборка → nginx). Раздаётся во
# ВСЕХ пресетах (сервис без profiles), поэтому http://localhost/ отдаёт
# рабочий фронт, а не заглушку. Конфиг реверс-прокси по-прежнему
# монтируется томом ниже — правки прокси не требуют пересборки фронта.
build:
context: ../frontend
dockerfile: Dockerfile
image: vidconf-nginx:latest
restart: unless-stopped
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
# Аватары: тот же volume, что у backend — nginx раздаёт файлы
# напрямую (`location /media/`, alias), в обход backend-процесса.
- media:/media:ro
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:80/"]
interval: 10s
timeout: 5s
retries: 5
logging: *default-logging
# --- Media profile: LiveKit SFU + TURN. Disabled by default. ---
# Start with: docker compose --profile media up -d
livekit:
image: livekit/livekit-server:latest
restart: unless-stopped
command: --config /etc/livekit.yaml
volumes:
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
environment:
# Формат LIVEKIT_KEYS СТРОГО "key: secret" (двоеточие + пробел),
# иначе LiveKit падает в crash-loop с "Could not parse keys". Значение
# обязательно в кавычках: YAML запрещает ": " внутри неквотированного
# plain-скаляра.
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-change-me-livekit-secret}"
ports:
- "7880:7880" # HTTP/WebSocket signaling
- "7881:7881" # RTC TCP fallback
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop.
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+)
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения.
- "54000-54100:54000-54100/udp" # WebRTC media (ICE)
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:7880/"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["media"]
logging: *default-logging
coturn:
image: coturn/coturn:latest
restart: unless-stopped
command: -c /etc/coturn/turnserver.conf
volumes:
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
environment:
TURN_REALM: ${TURN_REALM:-vidconf.local}
TURN_STATIC_AUTH_SECRET: ${TURN_STATIC_AUTH_SECRET:-change-me-turn-secret}
network_mode: host
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
# curl/wget, поэтому проверка процесса по имени не работает
# ("pgrep: not found" → healthcheck всегда unhealthy). Вместо этого
# опрашиваем сам TURN-сервер STUN-запросом через штатную утилиту
# turnutils_stunclient, которая есть в образе.
healthcheck:
test: ["CMD-SHELL", "turnutils_stunclient -p 3478 127.0.0.1 || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["media"]
logging: *default-logging
# --- Профиль transcribe: LiveKit Egress — запись per-track аудио для
# последующей транскрибации (см. ADR-002). Egress запускает обработчик каждой записи как отдельный
# процесс внутри своего же контейнера (не Docker-in-Docker — проверено
# по официальной документации livekit/egress), поэтому доступ к
# /var/run/docker.sock не требуется. Записи и без транскрибации никому
# не нужны — profiles совпадает с worker-transcriber; livekit (профиль
# `media`) при этом должен быть поднят отдельно/вместе.
# Start with: docker compose --profile media --profile transcribe up -d
# Именованный том recordings при первом создании принадлежит root (0:0,
# drwxr-xr-x), а процесс egress работает от uid 1001 (gid 0) и не может
# создавать в нём каталоги сеансов. Однократный init-контейнер отдаёт том
# владельцу-egress до старта записи (иначе Track Egress падает с
# "Local upload failed: mkdir ... permission denied").
recordings-init:
image: busybox:1.36
command: ["chown", "-R", "1001:0", "/recordings"]
volumes:
- recordings:/recordings
restart: "no"
profiles: ["transcribe"]
logging: *default-logging
egress:
image: livekit/egress:latest
restart: unless-stopped
volumes:
- ./egress/egress.yaml:/etc/egress.yaml:ro
- recordings:/recordings
environment:
EGRESS_CONFIG_FILE: /etc/egress.yaml
# api_key/api_secret/ws_url — обязательные переменные окружения
# (см. deploy/egress/egress.yaml); ws_url — внутренний адрес сервиса
# livekit в docker-сети, НЕ LIVEKIT_PUBLIC_URL для браузера.
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-devkey}
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-change-me-livekit-secret}
LIVEKIT_WS_URL: ${LIVEKIT_WS_URL:-ws://livekit:7880}
depends_on:
# required: false — livekit живёт в профиле `media`, а не
# `transcribe`; без этого `docker compose --profile transcribe up`
# (без media) падает с ошибкой "service livekit ... is disabled".
# На практике egress без livekit бесполезен — профили запускают
# вместе (см. комментарий выше), но это не должно ломать валидацию
# конфигурации, если кто-то поднимает профили по отдельности.
livekit:
condition: service_healthy
required: false
redis:
condition: service_healthy
recordings-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:8081/healthz"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["transcribe"]
logging: *default-logging
# --- Профили llm/llm-gpu: том llm-models создаётся Docker root:root при
# первом использовании — по тем же соображениям, что и whisper-cache-init
# выше (backend-образ и curlimages/curl оба в итоге пишут
# от root — см. `user: "0:0"` у llm-model-init ниже), явный init-контейнер
# фиксирует владельца, по образцу `recordings-init`.
llm-models-init:
image: busybox:1.36
command: ["chown", "-R", "0:0", "/models/qwen"]
volumes:
- llm-models:/models/qwen
restart: "no"
profiles: ["llm", "llm-gpu"]
logging: *default-logging
# --- Профили llm/llm-gpu: локальный LLM-сервер для плагина суммаризации
# QwenLocal (см. ADR-004). Один и тот же
# init-контейнер обслуживает оба профиля — уровень AI (`LLM_MODEL_*`)
# определяет install.sh при выборе пресета 3/4/5, а не профиль CPU/GPU.
# Start with: docker compose --profile llm up -d
#
# Разовое скачивание GGUF-модели уровня AI и tokenizer.json в общий volume
# `llm-models`. Идемпотентен — при повторном запуске (файлы уже в volume)
# ничего не перекачивает, см. deploy/llm/download-model.sh.
llm-model-init:
image: curlimages/curl:8.10.1
entrypoint: ["/bin/sh", "/download-model.sh"]
environment:
LLM_MODEL_FILE: ${LLM_MODEL_FILE:-qwen3.5-4b-instruct-q4_k_m.gguf}
LLM_MODEL_URL: ${LLM_MODEL_URL:-https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf}
LLM_MODEL_MIN_SIZE: ${LLM_MODEL_MIN_SIZE:-2000000000}
LLM_TOKENIZER_FILE: ${LLM_TOKENIZER_FILE:-qwen3.5-4b-instruct.tokenizer.json}
LLM_TOKENIZER_URL: ${LLM_TOKENIZER_URL:-https://huggingface.co/Qwen/Qwen3.5-4B/resolve/main/tokenizer.json}
# Образ curlimages/curl по умолчанию работает от непривилегированного
# curl_user (uid 100) — записать .part-файл в /models/qwen не получится
# без явного root (том фиксирован под 0:0 в llm-models-init выше).
user: "0:0"
volumes:
- ./llm/download-model.sh:/download-model.sh:ro
- llm-models:/models/qwen
restart: "no"
depends_on:
llm-models-init:
condition: service_completed_successfully
profiles: ["llm", "llm-gpu"]
logging: *default-logging
# LLM-сервер (CPU) на базе официального образа llama.cpp (OpenAI-
# совместимый /v1/chat/completions, используется QwenLocal через
# OpenAICompatClient). Конфигурация — исключительно через переменные
# окружения LLAMA_ARG_* (штатный механизм образа, см.
# tools/server/README.md проекта llama.cpp). Тег закреплён по номеру
# сборки (не плавающий `:server`) — см. ADR-004, «Сводка рисков»,
# п.1; актуальный тег проверен по реестру ghcr.io.
llm:
image: ghcr.io/ggml-org/llama.cpp:server-b10068
restart: unless-stopped
environment:
LLAMA_ARG_MODEL: /models/qwen/${LLM_MODEL_FILE:-qwen3.5-4b-instruct-q4_k_m.gguf}
# 8k токенов на чанк (верхняя граница чанкера) + промпт + до 1536
# токенов вывода (max-уровень reduce), с запасом на служебные токены.
LLAMA_ARG_CTX_SIZE: 16384
LLAMA_ARG_HOST: 0.0.0.0
LLAMA_ARG_PORT: 8080
# Отключение thinking-режима (ADR-004: семейство Qwen3.5 Small — по
# умолчанию выключен, но фиксируем явно). `--chat-template-kwargs
# '{"enable_thinking":false}'`, упомянутый в ADR-004, на актуальной
# версии llama.cpp помечен деприкейтед в пользу `--reasoning off`
# (проверено по common/arg.cpp: одинаковый эффект —
# `default_template_kwargs["enable_thinking"]="false"`, но без
# предупреждения в логе на каждый запуск).
LLAMA_ARG_REASONING: "off"
# Экспозиция /metrics для Prometheus (job `llm`, deploy/monitoring/prometheus.yml).
LLAMA_ARG_ENDPOINT_METRICS: 1
volumes:
- llm-models:/models/qwen:ro
ports:
- "8080:8080"
depends_on:
llm-model-init:
condition: service_completed_successfully
# /health отдаёт 503 ("Loading model"), пока модель грузится в память,
# и 200 ("status": "ok"), когда сервер готов принимать запросы —
# штатный эндпоинт образа llama.cpp (curl есть в базовом слое образа,
# см. .devops/cpu.Dockerfile проекта llama.cpp — используется как в
# фирменном HEALTHCHECK образа).
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
profiles: ["llm"]
logging: *default-logging
# --- Профиль llm-gpu: GPU-вариант LLM-сервера, ТОЛЬКО уровень AI `max`
# (ADR-004: единственный уровень с `base_url: http://llm-gpu:8080/v1` в
# `backend/services/ai_tiers.py`). Официальный CUDA-образ llama.cpp
# (CUDA 12); тег закреплён по номеру сборки, синхронно с `llm` выше.
llm-gpu:
image: ghcr.io/ggml-org/llama.cpp:server-cuda-b10068
restart: unless-stopped
environment:
LLAMA_ARG_MODEL: /models/qwen/${LLM_MODEL_FILE:-qwen3.5-35b-a3b-instruct-q4_k_m.gguf}
LLAMA_ARG_CTX_SIZE: 16384
LLAMA_ARG_HOST: 0.0.0.0
LLAMA_ARG_PORT: 8080
# Полный offload в VRAM (ADR-004: max — обязательный GPU ≥16 ГБ,
# рекомендовано 24 ГБ; 999 — общепринятое в доках llama.cpp значение
# «офлоадить все слои», сервер сам ограничивает реальным числом слоёв
# модели, если их меньше).
LLAMA_ARG_N_GPU_LAYERS: 999
LLAMA_ARG_REASONING: "off"
LLAMA_ARG_ENDPOINT_METRICS: 1
volumes:
- llm-models:/models/qwen:ro
ports:
- "8081:8080"
# Алиас `llm` в сети compose (в дополнение к штатному `llm-gpu`) —
# `llm` и `llm-gpu` взаимоисключающи по профилю (install.sh включает
# ровно один), поэтому Prometheus (deploy/monitoring/prometheus.yml,
# job `llm`) всегда скрейпит один и тот же адрес `llm:8080` независимо
# от того, какой из двух реально поднят — без дублирования job'ов и
# вечно "красного" targets для неиспользуемого профиля.
networks:
default:
aliases:
- llm
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
depends_on:
llm-model-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
profiles: ["llm-gpu"]
logging: *default-logging
# --- Профиль monitoring: Prometheus + Grafana + exporter'ы.
# Дашборд «Пайплайны пост-обработки» — deploy/monitoring/grafana/.
# Start with: docker compose --profile monitoring up -d
# (можно вместе с любыми другими профилями — независимый набор сервисов).
prometheus:
# Тег закреплён по версии (не `:latest`) — та же причина, что и у llm-образов.
image: prom/prometheus:v3.13.1
restart: unless-stopped
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus_data:/prometheus
ports:
- "9090:9090"
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9090/-/healthy || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["monitoring"]
logging: *default-logging
postgres-exporter:
image: quay.io/prometheuscommunity/postgres-exporter:v0.20.1
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}?sslmode=disable"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9187/metrics >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["monitoring"]
logging: *default-logging
redis-exporter:
image: oliver006/redis_exporter:v1.87.0-alpine
restart: unless-stopped
environment:
REDIS_ADDR: "redis://redis:6379"
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9121/metrics >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["monitoring"]
logging: *default-logging
grafana:
image: grafana/grafana:13.1.0
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
# Дефолт только для dev — install.sh генерирует секрет при первой
# установке (секреты только в .env).
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-change-me-grafana}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana_data:/var/lib/grafana
ports:
- "3001:3000"
depends_on:
prometheus:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
profiles: ["monitoring"]
logging: *default-logging
volumes:
postgres_data:
redis_data:
# Общий том между egress (пишет) и worker-transcriber (читает :ro):
# per-track .ogg-записи (profile transcribe).
recordings:
# Кэш весов faster-whisper — переживает пересоздание контейнера. Каждый
# уровень AI хранит модель в подкаталоге `<volume>/<модель>` (`small`/
# `medium`/`large-v3`) — КОНТРАКТ с `backend/services/ai_tiers.py`
# (WHISPER_MODELS_ROOT) и детектом доступности уровня, см.
# `deploy/whisper/download-model.py`.
whisper-cache:
# GGUF-модель Qwen3.5 уровня AI + tokenizer.json (профили `llm`/`llm-gpu`,
# скачивает llm-model-init) — общий том между `llm`/`llm-gpu` (инференс) и
# `worker` (подсчёт токенов чанкером через QwenTokenCounter). Имена файлов
# — КОНТРАКТ с `backend/services/ai_tiers.py` (QWEN_MODELS_ROOT).
llm-models:
# Загруженные пользователями файлы (аватары) — общий том между
# backend (запись при загрузке) и nginx (раздача статики, `location /media/`).
media:
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
prometheus_data:
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
grafana_data:

34
deploy/egress/egress.yaml Normal file
View File

@@ -0,0 +1,34 @@
# Конфиг LiveKit Egress — запись per-track аудио для последующей
# транскрибации (см. ADR-002).
# Проверено по официальной документации livekit/egress
# (github.com/livekit/egress: README.md, _autodocs/api-reference-config.md,
# _autodocs/deployment-and-operations.md) — 2026-07-17.
#
# api_key/api_secret/ws_url НЕ хранятся в этом файле: egress поддерживает
# их как обязательные переменные окружения LIVEKIT_API_KEY/
# LIVEKIT_API_SECRET/LIVEKIT_WS_URL (см. сервис egress в docker-compose.yml)
# — тот же подход, что и в deploy/livekit/livekit.yaml (LIVEKIT_KEYS),
# секреты только через .env.
log_level: info
# Обязателен для egress (координация запущенных записей, см. комментарий
# в deploy/livekit/livekit.yaml).
redis:
address: redis:6379
# HTTP-эндпоинт /healthz для healthcheck контейнера.
health_port: 8081
# Метрики Prometheus (Ф7).
prometheus_port: 8888
# ws_url внутри docker-сети (ws://livekit:7880) — не TLS, поэтому строгая
# проверка сертификата не нужна; insecure=true допустим только для
# внутреннего dev/self-hosted контура, не для публичного wss://.
insecure: true
# Предохранитель от зависшей записи (обрыв room_finished/egress_ended):
# файловый egress принудительно завершается через 6 часов.
session_limits:
file_output_max_duration: 6h

View File

@@ -0,0 +1,60 @@
# Dev config for LiveKit SFU. Verified against the official documentation
# (github.com/livekit/livekit config-sample.yaml + configuration.md).
# Real API key/secret must come from .env (LIVEKIT_API_KEY / LIVEKIT_API_SECRET);
# this file intentionally omits `keys:` so it can be supplied via the
# LIVEKIT_KEYS env var (format "key:secret") set in docker-compose.yml.
port: 7880
rtc:
tcp_port: 7881
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде
# расширить и синхронизировать с пробросом портов.
port_range_start: 54000
port_range_end: 54100
# use_external_ip: false + node_ip — dev-режим по докам LiveKit
# (rtc.node_ip / use_external_ip в config-sample.yaml): use_external_ip
# определяет публичный IP через STUN, что в контейнере Docker Desktop на
# macOS даёт недостижимый изнутри хоста внутренний IP (172.18.x.x) — из-за
# этого DTLS-хендшейк по reliable/lossy data-каналам не проходит (см.
# "dtls timeout" в логах). node_ip: 127.0.0.1 работает, потому что порты
# 7881/tcp и 54000-54100/udp проброшены Docker Desktop на loopback хоста,
# а браузер-клиент запускается на том же хосте. В проде (клиенты снаружи
# хоста) node_ip заменить на реальный внешний IP/домен либо вернуть
# use_external_ip: true, если сервер не за NAT с пробросом портов 1:1.
use_external_ip: false
node_ip: 127.0.0.1
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
# его как pub/sub и key-value хранилище состояния запущенных записей;
# без него egress не может получать room/track-события от LiveKit
# (проверено по официальной документации livekit/egress, раздел "Running
# locally"). LiveKit сам по себе тоже использует redis для координации
# между узлами кластера (здесь один узел, но сервис оставлен включённым).
redis:
address: redis:6379
# TURN is handled by the standalone coturn service (deploy/coturn) in the
# `media` profile. When exposing LiveKit publicly, put coturn/TURN-TLS on 443
# and keep this section disabled to avoid double TURN servers.
turn:
enabled: false
# Webhook-приёмник backend'а: события room_started/participant_joined/
# participant_left/room_finished подписываются ключом api_key, который
# должен совпадать с одним из ключей в LIVEKIT_KEYS (см. выше).
webhook:
api_key: devkey
urls:
- http://backend:8000/api/v1/livekit/webhook
# Если backend запускается на хосте (uvicorn вне docker-compose, а
# LiveKit — внутри), используйте вместо этого:
# - http://host.docker.internal:8000/api/v1/livekit/webhook
logging:
level: info
json: false
# Prometheus metrics (scraped by the monitoring profile).
prometheus:
port: 6789

99
deploy/llm/download-model.sh Executable file
View File

@@ -0,0 +1,99 @@
#!/bin/sh
# Идемпотентное скачивание GGUF-модели для локального LLM-сервера
# (llama.cpp), см. ADR-004 (`docs/architecture/adr/004-ai-tier-matrix.md`).
#
# Генерализовано под 3 уровня AI (min/medium/max, разные модели семейства
# Qwen3.5) — конкретную модель задают переменные окружения, которые пишет
# `install.sh` при выборе пресета 3/4/5 (см. `docs/deploy/llm-setup.md`):
# LLM_MODEL_FILE — имя файла модели внутри $MODEL_DIR (ПОД ЭТИМ именем
# сохраняется локально — не обязано совпадать с
# именем файла в источнике, см. GGUF_URL/GGUF_FILE_NAME)
# LLM_MODEL_URL — прямая ссылка на GGUF (resolve/main/...)
# LLM_MODEL_MIN_SIZE — ожидаемый минимальный размер файла в байтах
# (страховка от оборванной/битой докачки)
# LLM_TOKENIZER_FILE — имя файла токенизатора внутри $MODEL_DIR
# LLM_TOKENIZER_URL — ссылка на tokenizer.json базовой модели (нужен
# QwenTokenCounter, backend/core/summarization/tokens.py)
#
# Имена файлов (LLM_MODEL_FILE/LLM_TOKENIZER_FILE) — КОНТРАКТ с
# `backend/services/ai_tiers.py` (см. докстринг модуля): детект доступности
# уровня AI и сам плагин `qwen_local` (`tokenizer_path`/`model` в
# `config/plugins.yaml`) ищут файлы по путям `{QWEN_MODELS_ROOT}/{model}.gguf`
# и `{QWEN_MODELS_ROOT}/{model}.tokenizer.json` из этого модуля — install.sh
# обязан передавать сюда ИМЕННО эти имена, не имена файлов в источнике.
#
# Дефолты ниже соответствуют уровню `min` (Qwen3.5-4B-Instruct, ADR-004) —
# скрипт можно запускать вручную без install.sh (см. `docs/deploy/llm-setup.md`).
#
# Запускается init-контейнером `llm-model-init` (образ curlimages/curl,
# профили compose `llm`/`llm-gpu`) перед стартом сервиса `llm`/`llm-gpu`.
#
# Идемпотентность: перед скачиванием каждого файла проверяется его
# наличие и размер (не меньше ожидаемого минимума) — повторный запуск
# на уже заполненном томе (в т.ч. после смены пресета на модель ДРУГОГО
# уровня, ранее уже скачанную в этот же volume) ничего не перекачивает.
set -eu
MODEL_DIR="${MODEL_DIR:-/models/qwen}"
GGUF_URL="${LLM_MODEL_URL:-https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf}"
GGUF_FILE_NAME="${LLM_MODEL_FILE:-qwen3.5-4b-instruct-q4_k_m.gguf}"
GGUF_FILE="${MODEL_DIR}/${GGUF_FILE_NAME}"
# Порог занижен относительно реального размера файла — не ловить ложные
# срабатывания на разных ревизиях модели, но отсекать битые/оборванные
# докачки (пустой или сильно урезанный файл). install.sh передаёт более
# точный порог для выбранного уровня.
GGUF_MIN_SIZE="${LLM_MODEL_MIN_SIZE:-2000000000}"
TOKENIZER_URL="${LLM_TOKENIZER_URL:-https://huggingface.co/Qwen/Qwen3.5-4B/resolve/main/tokenizer.json}"
TOKENIZER_FILE_NAME="${LLM_TOKENIZER_FILE:-qwen3.5-4b-instruct.tokenizer.json}"
TOKENIZER_FILE="${MODEL_DIR}/${TOKENIZER_FILE_NAME}"
# tokenizer.json семейства Qwen3.5 занимает ~12 МБ (словарь + merges) —
# порог просто отсекает пустой/HTML-ответ вместо файла.
TOKENIZER_MIN_SIZE=$((1 * 1000 * 1000))
# Размер существующего файла в байтах (0, если файла нет).
file_size() {
if [ -f "$1" ]; then
wc -c < "$1" | tr -d ' '
else
echo 0
fi
}
# Скачать файл по URL, если он отсутствует или меньше минимального размера.
download_if_missing() {
url="$1"
dest="$2"
min_size="$3"
name="$4"
size=$(file_size "$dest")
if [ "$size" -ge "$min_size" ]; then
echo "[download-model] ${name}: уже скачан (${dest}, ${size} байт) — пропуск"
return 0
fi
echo "[download-model] ${name}: скачивание из ${url} в ${dest}"
tmp="${dest}.part"
# -f — считать HTTP-ошибки (4xx/5xx) провалом; -L — следовать
# редиректам HuggingFace на CDN; --retry — устойчивость к обрывам сети.
curl -fL --retry 5 --retry-delay 5 -o "$tmp" "$url"
new_size=$(file_size "$tmp")
if [ "$new_size" -lt "$min_size" ]; then
echo "[download-model] ${name}: скачанный файл подозрительно мал (${new_size} байт) — прерывание" >&2
rm -f "$tmp"
exit 1
fi
mv "$tmp" "$dest"
echo "[download-model] ${name}: готово (${dest}, ${new_size} байт)"
}
mkdir -p "$MODEL_DIR"
download_if_missing "$GGUF_URL" "$GGUF_FILE" "$GGUF_MIN_SIZE" "GGUF-модель (${GGUF_FILE_NAME})"
download_if_missing "$TOKENIZER_URL" "$TOKENIZER_FILE" "$TOKENIZER_MIN_SIZE" "${TOKENIZER_FILE_NAME}"
echo "[download-model] всё готово: ${MODEL_DIR}"

View File

@@ -0,0 +1,59 @@
# Правила алертинга Prometheus. Подключены через
# `rule_files` в prometheus.yml. Метрики — из `backend/api/metrics.py`
# (см. комментарий в prometheus.yml про контракт имён).
#
# Проверка: алерт на искусственно заваленном пайплайне — остановить `llm`
# при активном `summarize_session` (см. `docs/deploy/monitoring.md`).
groups:
- name: vidconf-pipeline
rules:
# Растёт число сеансов, застрявших в статусе `failed`
# (`conference_sessions.pipeline_status`).
# `delta()` — корректная функция PromQL именно для gauge (не
# `increase()`, которая рассчитана на монотонные counter'ы).
- alert: PipelineFailed
expr: delta(vidconf_pipeline_sessions{status="failed"}[15m]) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Растёт число упавших сеансов пайплайна пост-обработки"
description: >-
За последние 15 минут число сеансов в статусе failed выросло
на {{ $value }}. Смотреть логи worker/worker-transcriber и
таблицу conference_sessions (pipeline_status).
# Глубина хотя бы одной из очередей Celery устойчиво растёт 15 минут
# подряд — воркеры не успевают за потоком задач (см.
# docs/deploy/scaling.md — вынос очереди в отдельную реплику).
- alert: QueueGrowing
expr: delta(vidconf_celery_queue_depth[15m]) > 0 and vidconf_celery_queue_depth > 10
for: 15m
labels:
severity: warning
annotations:
summary: "Очередь Celery «{{ $labels.queue }}» растёт 15 минут подряд"
description: >-
Текущая глубина очереди {{ $labels.queue }}: {{ $value }} задач,
рост не прекращается 15 минут — см. docs/deploy/scaling.md
(вынос очереди в отдельную реплику/масштабирование).
# LLM-сервер (llama.cpp, job `llm` — см. комментарий в prometheus.yml
# про сетевой алиас `llm`/`llm-gpu`) недоступен. Актуально ТОЛЬКО на
# инсталляциях с включённым профилем `llm`/`llm-gpu` (пресеты 35,
# `install.sh`) — на пресетах 1/2 (без AI) таргет `llm:8080` в принципе
# не резолвится, и этот алерт будет постоянно активен, если профиль
# `monitoring` включён без AI-профиля; для таких инсталляций правило
# можно отключить (закомментировать) в локальной копии alerts.yml.
- alert: LlmDown
expr: up{job="llm"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "LLM-сервер (llama.cpp) недоступен"
description: >-
Prometheus не может достучаться до llm:8080 дольше 2 минут —
суммаризация встанет (задачи будут копиться в очереди summarize,
см. также алерт QueueGrowing). Проверить
`docker compose ps llm` / `llm-gpu` и `docker compose logs llm`.

View File

@@ -0,0 +1,150 @@
{
"title": "Пайплайны пост-обработки",
"uid": "vidconf-pipelines",
"editable": false,
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"time": { "from": "now-6h", "to": "now" },
"refresh": "30s",
"tags": ["vidconf", "pipeline"],
"panels": [
{
"id": 1,
"title": "Сеансы по статусу пайплайна",
"description": "conference_sessions.pipeline_status (recording→transcribing→summarizing→notified|failed), метрика vidconf_pipeline_sessions.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "vidconf_pipeline_sessions",
"legendFormat": "{{status}}",
"refId": "A"
}
]
},
{
"id": 2,
"title": "Глубина очередей Celery",
"description": "vidconf_celery_queue_depth{queue=...} — transcription/summarize/notify/celery (redis LLEN). Порог алерта QueueGrowing см. deploy/monitoring/alerts.yml.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "vidconf_celery_queue_depth",
"legendFormat": "{{queue}}",
"refId": "A"
}
]
},
{
"id": 3,
"title": "Латентность API (p50/p95/p99 по маршрутам)",
"description": "vidconf_http_request_duration_seconds — histogram латентности HTTP по шаблону маршрута.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 5 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(vidconf_http_request_duration_seconds_bucket[5m])) by (le, path))",
"legendFormat": "p50 {{path}}",
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(vidconf_http_request_duration_seconds_bucket[5m])) by (le, path))",
"legendFormat": "p95 {{path}}",
"refId": "B"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.99, sum(rate(vidconf_http_request_duration_seconds_bucket[5m])) by (le, path))",
"legendFormat": "p99 {{path}}",
"refId": "C"
}
]
},
{
"id": 4,
"title": "Длительность шагов пайплайна (p95)",
"description": "Ожидает метрику vidconf_pipeline_step_duration_seconds (histogram, label step) — пока не реализована (backend/api/metrics.py содержит только латентность HTTP и gauge'и статусов/очередей). Панель — заготовка под будущую инструментацию шагов transcribing/summarizing/notified; до её появления показывает «No data».",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 5 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(vidconf_pipeline_step_duration_seconds_bucket[15m])) by (le, step))",
"legendFormat": "p95 {{step}}",
"refId": "A"
}
]
},
{
"id": 5,
"title": "LLM-сервер доступен (job=llm)",
"description": "up{job=\"llm\"} — 1, если Prometheus успешно скрейпит llama.cpp (алерт LlmDown, deploy/monitoring/alerts.yml). Актуально только на инсталляциях с профилем llm/llm-gpu (пресеты 35).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }
],
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "up{job=\"llm\"}",
"refId": "A"
}
]
},
{
"id": 6,
"title": "Сеансы failed (текущее число)",
"description": "vidconf_pipeline_sessions{status=\"failed\"} — алерт PipelineFailed срабатывает на росте за 15 минут, здесь — снимок текущего значения.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "vidconf_pipeline_sessions{status=\"failed\"}",
"refId": "A"
}
]
}
]
}

View File

@@ -0,0 +1,15 @@
# Провижининг дашбордов Grafana (devops) — файлы из
# deploy/monitoring/grafana/dashboards/ (том :ro в контейнере grafana).
apiVersion: 1
providers:
- name: vidconf
orgId: 1
folder: VidConf
type: file
disableDeletion: false
allowUiUpdates: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false

View File

@@ -0,0 +1,12 @@
# Провижининг источника данных Grafana (devops) — Prometheus
# внутри той же docker-сети compose (профиль `monitoring`).
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

View File

@@ -0,0 +1,45 @@
# Конфигурация Prometheus (devops) — сбор метрик backend,
# PostgreSQL, Redis и локального LLM-сервера. Поднимается compose-профилем
# `monitoring` (deploy/docker-compose.yml, сервис `prometheus`).
#
# Имена метрик backend (`vidconf_http_request_duration_seconds`,
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с
# `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально —
# при переименовании метрик в backend поправить оба файла одновременно.
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- /etc/prometheus/alerts.yml
scrape_configs:
# Backend FastAPI: латентность HTTP по маршрутам + gauge'и пайплайна и
# очередей Celery (см. GET /metrics, `backend/api/metrics.py`).
- job_name: backend
metrics_path: /metrics
static_configs:
- targets: ["backend:8000"]
# PostgreSQL (профиль monitoring — сервис postgres-exporter).
- job_name: postgres
static_configs:
- targets: ["postgres-exporter:9187"]
# Redis (профиль monitoring — сервис redis-exporter): также источник для
# алерта LlmDown нет, но по нему видно состояние брокера Celery отдельно
# от глубины очередей (та берётся из backend, не отсюда).
- job_name: redis
static_configs:
- targets: ["redis-exporter:9121"]
# Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес
# `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от
# выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни
# min/medium) или `llm-gpu` (GPU, профиль `llm-gpu`, уровень max, у
# которого в сети compose есть сетевой алиас `llm`, см. его определение в
# deploy/docker-compose.yml) — эти профили взаимоисключающи, поэтому один
# job без дублирования и без вечно недоступного второго таргета.
- job_name: llm
static_configs:
- targets: ["llm:8080"]

120
deploy/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,120 @@
# Dev reverse proxy: backend API + раздача фронтенд-SPA (статика в образе).
# Завершение TLS для production документировано в docs/deploy/dev-setup.md
# и должно добавляться отдельным server-блоком (443) с реальными сертификатами.
#
# ВАЖНО (stale DNS): статический `upstream { server backend:8000; }` nginx
# резолвит один раз при старте/reload и держит IP в памяти. После пересоздания
# контейнера backend (`docker compose up -d --force-recreate backend`) Docker
# выдаёт ему новый IP, а nginx продолжает стучаться по старому → 502, пока
# nginx не перезапустят. Чтобы резолвить имя заново на каждый запрос,
# используем embedded DNS Docker (127.0.0.11) через directive `resolver` и
# ПЕРЕМЕННУЮ в proxy_pass — переменные nginx не кэшируются на старте и
# резолвятся заново по истечении `valid=`.
resolver 127.0.0.11 valid=10s ipv6=off;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
# --- Backend API ---
# Проброс через переменную: proxy_pass с переменной НЕ выполняет
# автоматическую подстановку URI (в отличие от статического
# `proxy_pass http://upstream/prefix/;`), поэтому URI-часть в proxy_pass
# не указываем — nginx передаёт исходный URI запроса как есть (path +
# query), что и сохраняет прежний маппинг /api/ -> backend:8000/api/
# (префикс совпадает 1-в-1, подмены не требовалось и раньше).
# Upgrade-заголовки нужны и обычным HTTP-запросам (без `Upgrade` в запросе
# `map $http_upgrade $connection_upgrade` выше подставляет `close`, что не
# ломает keep-alive обычных ответов), и WS-эндпоинту чата конференции
# (`WS /api/v1/conferences/{id}/chat`) — он живёт под тем же
# префиксом /api/, а не под отдельным /ws/.
location /api/ {
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
# --- WebSocket upgrade для чата и LiveKit signaling через nginx ---
location /ws/ {
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
# --- LiveKit SFU signaling (WebSocket) ---
# Доступен только когда стек запущен с профилем `media`
# (см. docker-compose.yml) — без него resolver вернёт NXDOMAIN на первый
# запрос к этому location, что ожидаемо.
# Здесь префикс /livekit/ нужно СНЯТЬ (как раньше делал
# `proxy_pass http://livekit_upstream/;`). С переменной в proxy_pass
# автоматическая подмена недоступна, поэтому переписываем URI явно через
# rewrite ... break — nginx передаст на upstream уже переписанный $uri.
# ВАЖНО: `set` должен идти ДО `rewrite ... break` — break прерывает
# выполнение всех последующих директив модуля rewrite (включая set) в
# этом location, иначе переменная останется неинициализированной.
location /livekit/ {
set $livekit_upstream http://livekit:7880;
rewrite ^/livekit/(.*)$ /$1 break;
proxy_pass $livekit_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
}
# --- Метрики backend: `GET /metrics` без авторизации
# внутри приложения (backend/api/metrics.py) — снаружи периметра нарочно
# закрываем явным запретом. Это ОБЯЗАТЕЛЬНО именно exact-match
# (`location = /metrics`): ниже есть catch-all `location /` (раздача
# фронтенд-SPA), который иначе отдал бы на /metrics файл фронта (или
# index.html через try_files). Exact-match в nginx приоритетнее любого
# prefix-location, поэтому 403 срабатывает раньше SPA-раздачи.
# Prometheus (профиль compose `monitoring`) ходит в backend НАПРЯМУЮ по
# внутренней docker-сети (`backend:8000/metrics`), минуя nginx.
location = /metrics {
return 403;
}
# --- Медиа (аватары): раздача напрямую из volume, в обход backend. ---
location /media/ {
alias /media/;
autoindex off;
}
# --- Frontend SPA (React + Vite): статика вкомпилирована в образ nginx
# (frontend/Dockerfile) в /usr/share/nginx/html. `try_files` с
# history-fallback на /index.html нужен для клиентского роутинга
# react-router (deep-ссылки вида /conferences/<id>, /admin/settings —
# при перезагрузке страницы отдаётся index.html, дальше роутит SPA).
# Более специфичные location выше (/api/, /media/, = /metrics, /livekit/,
# /ws/) матчатся раньше этого catch-all, поэтому API и метрики не
# перехватываются фронтом.
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /healthz {
return 200 "ok\n";
add_header Content-Type text/plain;
}
}

View File

@@ -0,0 +1,54 @@
"""Идемпотентная предзагрузка модели faster-whisper.
Без предзагрузки первая транскрибация после старта воркера сама тянет веса
модели из HuggingFace Hub в рантайме (минуты простоя пайплайна на первом
сеансе) — раньше предзагрузки не было вовсе.
Использует ТУ ЖЕ функцию, что и сам faster-whisper при ленивой загрузке
модели (`faster_whisper.utils.download_model`, см.
`backend/core/plugins/faster_whisper.py::_get_model` — `WhisperModel(...,
download_root=...)` внутри вызывает её с `cache_dir=download_root`), поэтому
создаёт кэш `huggingface_hub`, полностью совместимый с тем, что ожидает
плагин в `WHISPER_CACHE_DIR` (`config/plugins.yaml`,
`transcriber.options.download_root`) — повторный запуск (в т.ч. сам запуск
воркера) ничего не перекачивает: `snapshot_download` идемпотентен по факту
наличия файлов в кэше.
Запускается init-контейнером `whisper-model-init` (сборка backend-образа,
профили compose `transcribe`/`transcribe-gpu`, см. `deploy/docker-compose.yml`)
— faster-whisper уже есть в зависимостях backend/worker (`pyproject.toml`).
Путь кэша — КОНТРАКТ с `backend/services/ai_tiers.py` (см. докстринг модуля):
детект доступности уровня AI (`services/ai_levels.py`) и сам плагин
транскрибера (`transcriber.options.download_root` в `config/plugins.yaml`)
ожидают модель именно по пути `{WHISPER_MODELS_ROOT}/{модель}` (например,
`/models/whisper/small`) — НЕ по плоскому корню тома. `WHISPER_MODELS_ROOT`
здесь должен буквально совпадать со значением одноимённой константы в
`ai_tiers.py`.
"""
import os
import sys
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "small")
WHISPER_MODELS_ROOT = os.environ.get("WHISPER_MODELS_ROOT", "/models/whisper")
def main() -> None:
from faster_whisper.utils import download_model
cache_dir = f"{WHISPER_MODELS_ROOT}/{WHISPER_MODEL}"
print(
f"[download-whisper-model] модель '{WHISPER_MODEL}' -> {cache_dir}",
flush=True,
)
path = download_model(WHISPER_MODEL, cache_dir=cache_dir)
print(f"[download-whisper-model] готово: {path}", flush=True)
if __name__ == "__main__":
try:
main()
except Exception as exc: # noqa: BLE001
print(f"[download-whisper-model] ошибка: {exc}", file=sys.stderr)
sys.exit(1)