Files
vidconf/workers/summarizer/eval/run_tiers.py

329 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Прогон корпуса качества суммаризации по всем уровням AI.
Для каждого уровня (`min`/`medium`/`max`, матрица `backend/services/ai_tiers.TIERS`,
ADR-004 `docs/architecture/adr/004-ai-tier-matrix.md`) скрипт:
1. проверяет ДОСТУПНОСТЬ уровня на текущей машине — GPU (для `max`) и живой
LLM-сервер с ИМЕННО той моделью, что предписана `TierSpec` (сверка через
`/v1/models` официального llama.cpp сервера); недоступный
уровень корректно пропускается с человекочитаемой причиной, скрипт не падает;
2. для доступного уровня создаёт плагин `QwenLocal` штатной Factory
(`backend/core/plugins/factory.py::create_summarizer`) из конфигурации
`TierSpec.summarizer` — так же, как это делает прод-код воркера
суммаризации, никакой отдельной логики вызова LLM в этом скрипте нет;
3. прогоняет через `Summarizer.summarize()` весь корпус
(`workers/summarizer/eval/corpus/*.txt`, формат — как штатный вход
`summarize`: строки `[Имя MM:SS] текст`) и пишет результаты в
`workers/summarizer/eval/results/<уровень>/<имя-транскрипта>.md` плюс
метаданные прогона `_run_meta.json` в тот же каталог.
Промпты (`workers/summarizer/prompts/summary_map_ru.txt`,
`summary_reduce_ru.txt`) — ШТАТНЫЕ, скрипт их не читает и не редактирует
напрямую: только передаёт `QwenLocal` абсолютный путь к каталогу с ними
(`git diff --stat workers/summarizer/prompts/` остаётся пуст).
Запуск (из корня репозитория, с поднятым `docker compose --profile llm up -d`):
cd backend && uv run python ../workers/summarizer/eval/run_tiers.py \\
--base-url http://localhost:8080/v1
`--base-url` переопределяет адрес LLM-сервера для ВСЕХ уровней — на dev-машине
вне docker-сети штатные `http://llm:8080/v1`/`http://llm-gpu:8080/v1`
(`backend/services/ai_tiers.py`) не резолвятся; без флага скрипт предполагает
запуск изнутри docker-сети (например, `docker compose exec worker ...`), где
эти хосты резолвятся штатно.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
# --- Настройка sys.path: скрипт живёт вне пакета `backend`, но использует
# его модули (`core.plugins.factory`, `services.ai_tiers`) напрямую, как это
# делает прод-код `workers/` (см. докстринг `workers/celery_app.py`: пакеты
# backend и workers делят один Python-процесс/venv). Вставляем `backend/` в
# sys.path здесь же, а не через PYTHONPATH — чтобы скрипт можно было
# запускать одной командой без дополнительной настройки окружения.
_EVAL_DIR = Path(__file__).resolve().parent
_REPO_ROOT = _EVAL_DIR.parents[2]
_BACKEND_DIR = _REPO_ROOT / "backend"
_PROMPTS_DIR = _REPO_ROOT / "workers" / "summarizer" / "prompts"
if str(_BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(_BACKEND_DIR))
from core.plugins.factory import create_summarizer # noqa: E402
from services.ai_tiers import TIERS, TierSpec # noqa: E402
@dataclass
class TierAvailability:
"""Результат проверки доступности уровня AI на текущей машине."""
available: bool
reason: str | None
base_url: str
def _has_nvidia_gpu() -> bool:
"""Простой детект NVIDIA GPU для eval-скрипта: наличие `nvidia-smi` в PATH.
Не использует `backend.core.config.Settings.hw_*` (детект инсталлятора по
`.env`) — скрипт запускается вне контекста инстанса, ему достаточно
факта присутствия GPU-инструментария на машине, где он выполняется.
"""
return shutil.which("nvidia-smi") is not None
def _expected_gguf_name(spec: TierSpec) -> str:
"""Имя файла GGUF-модели, ожидаемой для уровня (из `TierSpec.model_files`)."""
for path in spec.model_files:
if path.endswith(".gguf"):
return Path(path).name
raise ValueError(f"В model_files уровня нет .gguf файла: {spec.model_files!r}")
def _check_tier_availability(
level: str,
spec: TierSpec,
base_url_override: str | None,
timeout_s: float,
) -> TierAvailability:
"""Проверить доступность уровня: GPU (если требуется) + живой сервер с нужной моделью.
Проверка модели — через `GET /v1/models` (OpenAI-совместимый эндпоинт
llama.cpp server, официальная документация `tools/server/README.md`):
без неё сервер мог бы ответить на `/health`, но с ДРУГОЙ загруженной
моделью (`min` и `medium` в проде используют один и тот же compose-сервис
`llm` на разных пресетах — на этой машине единовременно поднят только
один из них), и результат прогона был бы приписан не тому уровню.
"""
if spec.requires_gpu and not _has_nvidia_gpu():
return TierAvailability(
available=False,
reason=(
"требуется NVIDIA GPU (nvidia-smi не найден в PATH) — недоступно на "
"этой машине; прогон уровня — ручной шаг на GPU-хосте"
),
base_url="",
)
base_url = base_url_override or spec.summarizer.options.get("base_url", "")
root = base_url.removesuffix("/v1").rstrip("/")
health_url = f"{root}/health"
models_url = f"{root}/v1/models"
try:
health_resp = httpx.get(health_url, timeout=timeout_s)
except httpx.HTTPError as exc:
return TierAvailability(
available=False,
reason=f"LLM-сервер недоступен по {health_url}: {exc}",
base_url=base_url,
)
if health_resp.status_code != 200:
return TierAvailability(
available=False,
reason=(
f"LLM-сервер не готов ({health_url} -> {health_resp.status_code}: "
f"{health_resp.text.strip()})"
),
base_url=base_url,
)
expected = _expected_gguf_name(spec)
try:
models_resp = httpx.get(models_url, timeout=timeout_s)
models_resp.raise_for_status()
data = models_resp.json()["data"]
loaded = Path(data[0]["id"]).name if data else "<пусто>"
except (httpx.HTTPError, KeyError, IndexError, ValueError) as exc:
return TierAvailability(
available=False,
reason=f"не удалось получить {models_url}: {exc}",
base_url=base_url,
)
if loaded != expected:
return TierAvailability(
available=False,
reason=(
f"на {base_url} загружена другая модель ({loaded}), для уровня "
f"{level!r} ожидалась {expected} — поднимите отдельный сервер "
f"с нужной моделью (LLM_MODEL_FILE в .env)"
),
base_url=base_url,
)
return TierAvailability(available=True, reason=None, base_url=base_url)
def _run_corpus(
level: str,
spec: TierSpec,
base_url: str,
out_dir: Path,
corpus_files: list[Path],
tokenizer_override: str | None,
) -> dict[str, Any]:
"""Прогнать весь корпус через `QwenLocal` уровня `level`, вернуть метаданные прогона."""
options = dict(spec.summarizer.options)
options["base_url"] = base_url
options["prompts_dir"] = str(_PROMPTS_DIR)
if tokenizer_override:
options["tokenizer_path"] = tokenizer_override
cfg = spec.summarizer.model_copy(update={"options": options})
level_dir = out_dir / level
level_dir.mkdir(parents=True, exist_ok=True)
run_meta: dict[str, Any] = {
"level": level,
"model": cfg.model,
"base_url": base_url,
"chunk_minutes": cfg.chunk_minutes,
"temperature": options.get("temperature"),
"max_tokens_map": options.get("max_tokens_map"),
"max_tokens_reduce": options.get("max_tokens_reduce"),
"tokenizer_path": options.get("tokenizer_path"),
"started_at": datetime.now(UTC).isoformat(),
"files": [],
}
for corpus_file in corpus_files:
# Новый инстанс плагина на файл — так же, как `create_summarizer`
# используется в проде: один плагин на одну задачу суммаризации
# (см. докстринг `QwenLocal.summarize`).
summarizer = create_summarizer(cfg)
transcript = corpus_file.read_text(encoding="utf-8")
started = time.monotonic()
status = "ok"
error_text: str | None = None
summary = ""
try:
summary = summarizer.summarize(transcript)
except Exception as exc: # noqa: BLE001 — сбой LLM (см. LlmUnavailableError) не должен прервать прогон корпуса
status = "error"
error_text = f"{type(exc).__name__}: {exc}"
elapsed_s = time.monotonic() - started
out_path = level_dir / f"{corpus_file.stem}.md"
header = (
f"<!-- уровень: {level} | модель: {cfg.model} | источник: {corpus_file.name} "
f"| статус: {status} | время: {elapsed_s:.1f}с -->\n\n"
)
body = summary if status == "ok" else f"ОШИБКА: {error_text}\n"
out_path.write_text(header + body, encoding="utf-8")
run_meta["files"].append(
{
"source": corpus_file.name,
"status": status,
"elapsed_s": round(elapsed_s, 1),
"error": error_text,
"result_file": str(out_path.relative_to(_REPO_ROOT)),
}
)
print(f" [{level}] {corpus_file.name}: {status} за {elapsed_s:.1f}с -> {out_path}")
run_meta["finished_at"] = datetime.now(UTC).isoformat()
(level_dir / "_run_meta.json").write_text(
json.dumps(run_meta, ensure_ascii=False, indent=2), encoding="utf-8"
)
return run_meta
def main() -> int:
"""Точка входа CLI: разобрать аргументы, прогнать доступные уровни, напечатать сводку."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else "")
parser.add_argument(
"--levels",
default="min,medium,max",
help="Уровни через запятую (по умолчанию все три из ADR-004)",
)
parser.add_argument(
"--corpus-dir",
default=str(_EVAL_DIR / "corpus"),
help="Каталог с *.txt транскриптами (формат входа summarize)",
)
parser.add_argument(
"--out-dir",
default=str(_EVAL_DIR / "results"),
help="Каталог для результатов (подкаталог на уровень)",
)
parser.add_argument(
"--base-url",
default=None,
help=(
"Переопределить base_url LLM-сервера для всех уровней "
"(например, http://localhost:8080/v1 при запуске с хоста вне docker-сети)"
),
)
parser.add_argument(
"--tokenizer-path",
default=None,
help="Переопределить путь к tokenizer.json (по умолчанию — путь из TierSpec)",
)
parser.add_argument(
"--health-timeout",
type=float,
default=5.0,
help="Таймаут проверки доступности сервера (секунды)",
)
args = parser.parse_args()
levels = [lvl.strip() for lvl in args.levels.split(",") if lvl.strip()]
corpus_dir = Path(args.corpus_dir)
out_dir = Path(args.out_dir)
corpus_files = sorted(corpus_dir.glob("*.txt"))
if not corpus_files:
print(f"В {corpus_dir} не найдено ни одного .txt транскрипта", file=sys.stderr)
return 1
summary_lines: list[str] = []
for level in levels:
spec = TIERS.get(level)
if spec is None:
print(
f"Неизвестный уровень {level!r} (ожидались min/medium/max), пропуск",
file=sys.stderr,
)
continue
availability = _check_tier_availability(level, spec, args.base_url, args.health_timeout)
if not availability.available:
print(f"[{level}] ПРОПУСК: {availability.reason}")
summary_lines.append(f"{level}: пропущен — {availability.reason}")
continue
print(
f"[{level}] доступен: base_url={availability.base_url}, модель={spec.summarizer.model}"
)
run_meta = _run_corpus(
level, spec, availability.base_url, out_dir, corpus_files, args.tokenizer_path
)
ok_count = sum(1 for f in run_meta["files"] if f["status"] == "ok")
summary_lines.append(
f"{level}: {ok_count}/{len(run_meta['files'])} успешно, результаты в {out_dir / level}"
)
print("\nИтог прогона:")
for line in summary_lines:
print(f" - {line}")
return 0
if __name__ == "__main__":
raise SystemExit(main())