Files
ddadminandClaude Opus 4.6 e28232ef50 feat(cli): добавлен батч-режим и конфигурационный файл (шаги 8–12)
- Зачем:
  - обработка нескольких файлов за один вызов с загрузкой модели один раз.
  - хранение дефолтов (модель, язык, устройство) в .transcriber.toml.
- Что:
  - добавлен config.py: поиск .transcriber.toml (CWD → ~/.config), парсинг, валидация, приоритет CLI > конфиг > хардкод.
  - рефакторинг transcriber.py: выделены load_model() и _transcribe_file() с TranscribeFileResult для переиспользования модели в батче.
  - добавлены expand_globs() с дедупликацией и has_existing_transcript() в utils.py.
  - CLI: files: list[Path], --force/-f, prescan-first батч с итоговой статистикой и временем, Status-спиннер для прогресса.
  - README: секции батч-режим, конфигурационный файл, --force в таблице опций.
  - ADR-002: зафиксированы архитектурные решения (prescan-first, TranscribeFileResult, конфиг без мержа).
- Проверка:
  - uv run pytest -q — 94 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 20:57:19 +03:00

364 lines
20 KiB
Markdown
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.
# plan.md — План реализации local-transcriber
> Источник требований: docs/PRD.md
> Каждый шаг — атомарный коммит. После выполнения шага — отметить `[x]`.
> Агент: реализуй шаги последовательно. Читай ТОЛЬКО свой текущий шаг + секции PRD, на которые он ссылается. Не реализуй функциональность из других шагов. Не рефактори код предыдущих шагов без явной необходимости.
---
## Шаг 1: Scaffold проекта (без ML-зависимостей)
- [x] Создать структуру:
```
local-transcriber/
├── pyproject.toml
├── .gitignore
├── src/
│ └── local_transcriber/
│ ├── __init__.py
│ ├── cli.py
│ ├── transcriber.py
│ ├── formatter.py
│ └── utils.py
└── tests/
├── __init__.py
├── test_formatter.py # заглушка
├── test_transcriber.py # заглушка
└── test_utils.py # заглушка
```
- [x] `pyproject.toml`:
- `name = "local-transcriber"`, `python = ">=3.10"`
- `[project.scripts]`: `transcribe = "local_transcriber.cli:app"`
- dependencies: `typer`, `rich` (НЕ faster-whisper — он добавляется в шаге 3)
- dev-dependencies: `pytest`
- [x] `.gitignore`: `__pycache__/`, `.venv/`, `*.egg-info/`, `.mypy_cache/`, `dist/`, `*.pyc`
- [x] В каждом модуле — заглушки с сигнатурами и `raise NotImplementedError`:
**utils.py**:
```python
from pathlib import Path
def check_ffmpeg() -> None: ...
def detect_device(requested: str = "auto") -> str: ... # возвращает только device
def get_gpu_name() -> str | None: ... # nvidia-smi → "NVIDIA GeForce RTX 3060" или None
def validate_input_file(path: Path) -> Path: ...
def build_output_path(input_path: Path, output: Path | None = None) -> Path: ...
```
**transcriber.py**:
```python
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Segment:
start: float # секунды
end: float # секунды
text: str
@dataclass
class TranscribeResult:
segments: list[Segment]
language: str
language_probability: float
duration: float # секунды
device_used: str # фактическое устройство ("cpu" / "cuda") — может отличаться от запрошенного после fallback
def transcribe(
file_path: Path,
model_name: str = "large-v3",
device: str = "auto",
compute_type: str = "int8",
language: str | None = None,
on_segment: Callable[[Segment], None] | None = None, # callback для --verbose (вызывается на каждый сегмент)
) -> TranscribeResult: ...
```
**formatter.py**:
```python
from datetime import datetime
from pathlib import Path
from .transcriber import TranscribeResult
def format_timestamp(seconds: float, use_hours: bool = False) -> str: ...
def format_transcript(
result: TranscribeResult,
source_filename: str,
model_name: str,
device_info: str,
language_mode: str, # "detected" | "forced"
transcription_date: datetime | None = None, # None → datetime.now()
) -> str: ...
def write_transcript(content: str, output_path: Path) -> None: ...
```
**cli.py**:
```python
from pathlib import Path
import typer
app = typer.Typer()
@app.command()
def main(file: Path) -> None:
typer.echo("TODO: not implemented")
if __name__ == "__main__":
app()
```
- [x] `uv sync` → `uv run transcribe --help` работает
**Критерий готовности**: `uv run transcribe --help` показывает аргументы. `uv run pytest` проходит (тесты пустые, но pytest находит test_formatter.py, test_transcriber.py и test_utils.py). Все модули импортируются без ошибок.
---
## Шаг 2: utils.py — проверки окружения
> PRD-ссылки: 3.1 (flow), 3.2 (опции --device), 3.4 (форматы), 4.2 (ffmpeg)
- [x] `check_ffmpeg()`:
- `subprocess.run(["ffmpeg", "-version"], capture_output=True)`
- При `FileNotFoundError` → `SystemExit` с сообщением и инструкцией: `apt install ffmpeg` / `winget install ffmpeg` / `brew install ffmpeg`
- [x] `detect_device(requested: str = "auto") -> str`:
- Если `requested != "auto"` → вернуть `requested`
- Иначе: проверить CUDA через `shutil.which("nvidia-smi")` как быстрый хинт
- Если nvidia-smi найден → `"cuda"`
- Иначе → `"cpu"`
- **Не импортировать** ctranslate2 или torch здесь — faster-whisper ещё не в зависимостях
- Точная проверка CUDA будет при загрузке модели (шаг 3), здесь — best effort
- `--compute-type` остаётся независимым параметром CLI, не связан с detect_device
- [x] `get_gpu_name() -> str | None`:
- `subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True)`
- Вернуть первую строку stdout (strip) или `None` если nvidia-smi недоступен / ошибка
- Используется для формирования `device_info` в шапке markdown: `"CUDA (NVIDIA GeForce RTX 3060)"` или `"CPU"`
- [x] `validate_input_file(path: Path) -> Path`:
- Проверить: существует, является файлом (не директорией), размер > 0
- Расширение из допустимых (PRD 3.4) → если нет, **warning** (не ошибка), продолжить
- Вернуть `path.resolve()`
- [x] `build_output_path(input_path: Path, output: Path | None = None) -> Path`:
- Если `output` задан → вернуть его
- Иначе → `input_path.with_stem(input_path.stem + "-transcript").with_suffix(".md")`
- [x] Тесты в `tests/test_utils.py`:
- `test_validate_input_file_not_found` — несуществующий файл → ошибка
- `test_validate_input_file_empty` — пустой файл → ошибка
- `test_validate_input_file_unknown_ext` — `.txt` → warning, но не ошибка
- `test_validate_input_file_ok` — валидный файл → возвращает resolved path
- `test_build_output_path_default` — без `--output` → `*-transcript.md`
- `test_build_output_path_custom` — с `--output` → возвращает его
- `test_detect_device_explicit` — `requested="cpu"` → `"cpu"`
- `test_get_gpu_name_no_nvidia_smi` — nvidia-smi недоступен → `None`
- `test_get_gpu_name_success` — mock nvidia-smi → возвращает строку с именем GPU
**Критерий готовности**: `uv run pytest tests/test_utils.py -v` — все тесты зелёные. `uv run python -c "from local_transcriber.utils import check_ffmpeg, detect_device; check_ffmpeg(); print(detect_device())"` — работает (CPU fallback).
---
## Шаг 3: transcriber.py — обёртка над faster-whisper
> PRD-ссылки: 5.1 (faster-whisper), 4.1.1 (GPU совместимость), 6 (риски OOM)
- [x] `uv add faster-whisper` — добавить в зависимости
- [x] Реализовать `transcribe()`:
- Создать `WhisperModel(model_name, device=device, compute_type=compute_type)`
- При ошибке загрузки на CUDA (OOM, CUDA error) → **поймать**, вывести warning, **повторить с device="cpu"**
- Запомнить фактический device → записать в `TranscribeResult.device_used`
- `model.transcribe(str(file_path), language=language if language != "auto" else None)`
- faster-whisper возвращает `(segment_generator, info)` — итерировать generator, для каждого сегмента вызвать `on_segment(segment)` если callback передан, затем собрать в `list[Segment]`
- Заполнить `TranscribeResult` из info (language, duration и т.д.)
- [x] Обработка ошибок:
- `RuntimeError` с "CUDA" / "out of memory" → fallback на CPU + warning
- Ошибка ffmpeg (невалидный медиафайл) → пробросить с понятным текстом
- [x] Тесты в `tests/test_transcriber.py` (mock WhisperModel, без реальной модели):
- `test_transcribe_collects_segments` — mock возвращает 3 сегмента → результат содержит 3 Segment
- `test_transcribe_calls_on_segment` — callback вызывается для каждого сегмента
- `test_transcribe_cuda_fallback` — mock бросает RuntimeError("CUDA") при device="cuda" → fallback, `device_used == "cpu"`
- `test_transcribe_device_used` — без fallback → `device_used` совпадает с запрошенным
**Критерий готовности**: `uv run pytest tests/test_transcriber.py -v` — зелёное. Дополнительно на машине агента — `transcribe()` работает с `device="cpu"`, `model="tiny"`. Полноценная проверка с large-v3 и GPU — на локальной машине.
---
## Шаг 4: formatter.py + тесты
> PRD-ссылки: 3.3 (формат выходного файла)
- [x] `format_timestamp(seconds: float, use_hours: bool = False) -> str`:
- `False` → `"01:23.45"` (MM:SS.ss)
- `True` → `"01:23:45.67"` (HH:MM:SS.ss)
- Сотые — всегда 2 знака после точки
- [x] `format_transcript(...)`:
- Шапка по шаблону PRD 3.3 (заголовок, метаданные, разделитель)
- `language_mode`: `"detected"` если CLI получил `--language auto`, `"forced"` если язык задан явно
- В шапке: `**Язык**: {language} ({language_mode})` → например `ru (detected)` или `en (forced)`
- `transcription_date`: если `None` → `datetime.now()`. Формат в шапке: `YYYY-MM-DD HH:MM:SS`
- Автоматически `use_hours=True` если `result.duration > 3600`
- Сегменты: `[MM:SS.ss - MM:SS.ss] текст\n\n`
- Если `len(result.segments) == 0` → после разделителя: `\n*Речь не обнаружена.*\n` (файл создаётся с полной шапкой метаданных; warning в stderr выводит CLI в шаге 5)
- [x] `write_transcript(content: str, output_path: Path)`:
- `open(output_path, "w", encoding="utf-8")`
- [x] Тесты в `tests/test_formatter.py`:
- `test_format_timestamp_minutes` — обычный таймкод
- `test_format_timestamp_hours` — формат с часами
- `test_format_transcript_basic` — проверить шапку + пару сегментов
- `test_format_transcript_empty` — 0 сегментов → содержит "Речь не обнаружена"
- `test_format_transcript_long` — duration > 3600 → таймкоды с часами
**Критерий готовности**: `uv run pytest tests/test_formatter.py -v` — все тесты зелёные.
---
## Шаг 5: cli.py — связка всех модулей, happy path
> PRD-ссылки: 3.1 (flow), 3.2 (CLI-интерфейс)
- [x] Typer command с аргументами и опциями по PRD 3.2:
- `file: Path` — позиционный аргумент
- `--model` / `-m` → default `"large-v3"`
- `--language` / `-l` → default `"auto"`
- `--output` / `-o` → optional Path
- `--device` / `-d` → default `"auto"`
- `--compute-type` → default `"int8"`
- `--verbose` / `-v` → flag, default False
- [x] Happy path flow:
1. `check_ffmpeg()`
2. `validate_input_file(file)`
3. `detect_device(device)` → получить device; `--compute-type` используется как есть (независим от device)
4. rich Console → stderr: информация о запуске (модель, устройство, файл)
5. rich Spinner/Status во время транскрипции
6. `transcribe(...)` — передать `on_segment=<callback>` если `--verbose`
7. Если 0 сегментов → `console.print("⚠ Речь не обнаружена в файле ...", style="yellow")`
8. `format_transcript(...)` — `device_info`: если `result.device_used == "cuda"` → `"CUDA ({get_gpu_name() or 'Unknown GPU'})"`, иначе `"CPU"`
9. `write_transcript(...)`
10. `console.print("✓ Транскрипт сохранён: <путь>", style="green")`
11. Статистика: кол-во сегментов, время работы (замерить через `time.monotonic()`)
- [x] Exit codes: 0 — успех (включая пустую речь), 1 — ошибка
**Критерий готовности**: `uv run transcribe test.mp3` — создаёт корректный .md файл (проверить на локальной машине с реальным файлом).
---
## Шаг 5.1: GPU runtime — прозрачная работа CUDA на Linux/WSL2
> Детали решения и обоснование: [ADR-001](adr/001-cuda-bootstrap.md)
- [x] `nvidia-cublas-cu12>=12.4` в dependencies (Linux x86_64)
- [x] `_cuda_bootstrap.py` — preload libcublas через `ctypes.CDLL(RTLD_GLOBAL)` до импорта ctranslate2
- [x] `strict_device` в transcriber — `--device cuda/cpu` без silent fallback
- [x] CLI: диагностика requested vs resolved device, Windows CUDA-подсказка
- [x] Тесты: bootstrap (unit + интеграционный), strict_device, Windows-диагностика
**Критерий готовности**: `uv run pytest -v` — 52 passed, 1 skipped. На GPU-машине без системного CUDA toolkit: `uv run transcribe test.mp3 --device cuda` работает.
---
## Шаг 6: Error handling и UX polish
> PRD-ссылки: 6 (риски)
- [x] Graceful Ctrl+C: перехват `KeyboardInterrupt` в cli.py → `console.print("Прервано пользователем", style="yellow")` + `raise SystemExit(130)`
- [x] Красивые ошибки: обернуть main в try/except, для пользовательских ошибок (файл не найден, ffmpeg, OOM) — вывод через rich без traceback; для неожиданных — traceback только с `--verbose`
- [x] `--verbose` режим: реализуется через `on_segment` callback в `transcribe()` (уже заложен в шаге 3) — печатать каждый сегмент в stderr по мере поступления
- [x] Проверка: неподдерживаемое расширение → warning, но попытка продолжить
**Критерий готовности**: ручной прогон edge cases — несуществующий файл, .txt файл, Ctrl+C во время работы.
---
## Шаг 7: README.md
> PRD-ссылки: 4.2 (требования), 4.1.1 (GPU таблица), 4.3 (кроссплатформенность)
- [x] Описание: что делает, зачем
- [x] Требования: Python ≥ 3.10, uv, ffmpeg, (опционально) NVIDIA GPU + драйвер
- [x] Установка: `uv tool install .` (CLI глобально в PATH), troubleshooting
- [x] Использование: 4 примера команд, таблица опций CLI
- [x] Таблица моделей + таблица типов квантизации (`--compute-type`)
- [x] Установка ffmpeg: Linux/WSL2 (`apt`), Windows (`winget`), macOS (`brew`)
- [x] GPU и CUDA: платформы, режимы `--device`, совместимость GPU, ожидаемая скорость
- [x] Пример выходного файла (с корректными HH:MM:SS.ss и группировкой абзацев)
- [x] Поддерживаемые форматы аудио/видео
**Критерий готовности**: коллега может по README установить и запустить на Windows/WSL2 без вопросов.
---
## Шаг 8: Конфиг `.transcriber.toml`
- [x] Зависимость `tomli` в `pyproject.toml` (для Python < 3.11)
- [x] Новый файл `src/local_transcriber/config.py`:
- `HARDCODED_DEFAULTS` — дефолтные значения
- `find_config_file()` — ищет `.transcriber.toml` в CWD, потом `~/.config/transcriber/config.toml`
- `load_config()` — парсит TOML, валидирует ключи/значения
- `resolve_defaults()` — приоритет CLI > конфиг > хардкод
- [x] Тесты `tests/test_config.py` — 11 тестов
**Критерий готовности**: `uv run pytest tests/test_config.py -v` — все тесты зелёные.
---
## Шаг 9: Рефакторинг transcriber.py — выделить загрузку модели
- [x] `load_model()` — загрузка модели с CUDA-фолбеком
- [x] `_transcribe_file()` — транскрипция одного файла, возвращает `TranscribeFileResult`
- [x] `TranscribeFileResult` — dataclass с result, model, actual_device
- [x] `transcribe()` — тонкая обёртка для обратной совместимости
- [x] Новые тесты: `test_load_model_cuda_fallback`, `test_load_model_strict_raises`, `test__transcribe_file_basic`
**Критерий готовности**: все существующие тесты `test_transcriber.py` проходят без изменений.
---
## Шаг 10: Утилиты для батч-режима в utils.py
- [x] `expand_globs()` — раскрытие glob-паттернов
- [x] `has_existing_transcript()` — проверка существования транскрипта
- [x] Тесты в `test_utils.py` — 5 новых тестов
**Критерий готовности**: `uv run pytest tests/test_utils.py -v` — все тесты зелёные.
---
## Шаг 11: Батч-режим + конфиг в CLI
- [x] Изменение сигнатуры CLI: `files: list[Path]`, дефолты `None`, `--force`
- [x] Интеграция `load_config()` / `resolve_defaults()` в `main()`
- [x] `_run_single()` — текущий flow для одного файла
- [x] `_run_batch()` — prescan, загрузка модели, транскрипция с итогами
- [x] Обновлённые тесты `test_cli.py` — 11 новых тестов
**Критерий готовности**: `uv run pytest tests/test_cli.py -v` — все тесты зелёные.
---
## Шаг 12: Документация + ADR
- [x] ADR-002: Batch mode и config (`docs/adr/002-batch-and-config.md`)
- [x] README.md: секции «Батч-режим», «Конфигурационный файл», обновлена таблица опций CLI
- [x] `docs/plan.md`: отмечены шаги 8–12
**Критерий готовности**: README содержит документацию по батч-режиму и конфигу.
---
## Инструкция для агента
Команда на каждый шаг:
```
Прочитай docs/PRD.md (только секции, указанные в текущем шаге) и plan.md (только текущий шаг).
Реализуй шаг N.
После реализации — отметь все чекбоксы шага как [x] в plan.md.
Не трогай код и чекбоксы из других шагов.
```