Files
local-transcriber/docs/plan.md
T
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

20 KiB
Raw Blame History

plan.md — План реализации local-transcriber

Источник требований: docs/PRD.md Каждый шаг — атомарный коммит. После выполнения шага — отметить [x]. Агент: реализуй шаги последовательно. Читай ТОЛЬКО свой текущий шаг + секции PRD, на которые он ссылается. Не реализуй функциональность из других шагов. Не рефактори код предыдущих шагов без явной необходимости.


Шаг 1: Scaffold проекта (без ML-зависимостей)

  • Создать структуру:

    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       # заглушка
    
  • pyproject.toml:

    • name = "local-transcriber", python = ">=3.10"
    • [project.scripts]: transcribe = "local_transcriber.cli:app"
    • dependencies: typer, rich (НЕ faster-whisper — он добавляется в шаге 3)
    • dev-dependencies: pytest
  • .gitignore: __pycache__/, .venv/, *.egg-info/, .mypy_cache/, dist/, *.pyc

  • В каждом модуле — заглушки с сигнатурами и raise NotImplementedError:

    utils.py:

    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:

    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:

    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:

    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()
    
  • uv syncuv 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)

  • check_ffmpeg():

    • subprocess.run(["ffmpeg", "-version"], capture_output=True)
    • При FileNotFoundErrorSystemExit с сообщением и инструкцией: apt install ffmpeg / winget install ffmpeg / brew install ffmpeg
  • 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
  • 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"
  • validate_input_file(path: Path) -> Path:

    • Проверить: существует, является файлом (не директорией), размер > 0
    • Расширение из допустимых (PRD 3.4) → если нет, warning (не ошибка), продолжить
    • Вернуть path.resolve()
  • build_output_path(input_path: Path, output: Path | None = None) -> Path:

    • Если output задан → вернуть его
    • Иначе → input_path.with_stem(input_path.stem + "-transcript").with_suffix(".md")
  • Тесты в 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_explicitrequested="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)

  • uv add faster-whisper — добавить в зависимости
  • Реализовать 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 и т.д.)
  • Обработка ошибок:
    • RuntimeError с "CUDA" / "out of memory" → fallback на CPU + warning
    • Ошибка ffmpeg (невалидный медиафайл) → пробросить с понятным текстом
  • Тесты в 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 (формат выходного файла)

  • 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 знака после точки
  • format_transcript(...):
    • Шапка по шаблону PRD 3.3 (заголовок, метаданные, разделитель)
    • language_mode: "detected" если CLI получил --language auto, "forced" если язык задан явно
    • В шапке: **Язык**: {language} ({language_mode}) → например ru (detected) или en (forced)
    • transcription_date: если Nonedatetime.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)
  • write_transcript(content: str, output_path: Path):
    • open(output_path, "w", encoding="utf-8")
  • Тесты в 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-интерфейс)

  • 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
  • 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())
  • Exit codes: 0 — успех (включая пустую речь), 1 — ошибка

Критерий готовности: uv run transcribe test.mp3 — создаёт корректный .md файл (проверить на локальной машине с реальным файлом).


Шаг 5.1: GPU runtime — прозрачная работа CUDA на Linux/WSL2

Детали решения и обоснование: ADR-001

  • nvidia-cublas-cu12>=12.4 в dependencies (Linux x86_64)
  • _cuda_bootstrap.py — preload libcublas через ctypes.CDLL(RTLD_GLOBAL) до импорта ctranslate2
  • strict_device в transcriber — --device cuda/cpu без silent fallback
  • CLI: диагностика requested vs resolved device, Windows CUDA-подсказка
  • Тесты: 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 (риски)

  • Graceful Ctrl+C: перехват KeyboardInterrupt в cli.py → console.print("Прервано пользователем", style="yellow") + raise SystemExit(130)
  • Красивые ошибки: обернуть main в try/except, для пользовательских ошибок (файл не найден, ffmpeg, OOM) — вывод через rich без traceback; для неожиданных — traceback только с --verbose
  • --verbose режим: реализуется через on_segment callback в transcribe() (уже заложен в шаге 3) — печатать каждый сегмент в stderr по мере поступления
  • Проверка: неподдерживаемое расширение → warning, но попытка продолжить

Критерий готовности: ручной прогон edge cases — несуществующий файл, .txt файл, Ctrl+C во время работы.


Шаг 7: README.md

PRD-ссылки: 4.2 (требования), 4.1.1 (GPU таблица), 4.3 (кроссплатформенность)

  • Описание: что делает, зачем
  • Требования: Python ≥ 3.10, uv, ffmpeg, (опционально) NVIDIA GPU + драйвер
  • Установка: uv tool install . (CLI глобально в PATH), troubleshooting
  • Использование: 4 примера команд, таблица опций CLI
  • Таблица моделей + таблица типов квантизации (--compute-type)
  • Установка ffmpeg: Linux/WSL2 (apt), Windows (winget), macOS (brew)
  • GPU и CUDA: платформы, режимы --device, совместимость GPU, ожидаемая скорость
  • Пример выходного файла (с корректными HH:MM:SS.ss и группировкой абзацев)
  • Поддерживаемые форматы аудио/видео

Критерий готовности: коллега может по README установить и запустить на Windows/WSL2 без вопросов.


Шаг 8: Конфиг .transcriber.toml

  • Зависимость tomli в pyproject.toml (для Python < 3.11)
  • Новый файл src/local_transcriber/config.py:
    • HARDCODED_DEFAULTS — дефолтные значения
    • find_config_file() — ищет .transcriber.toml в CWD, потом ~/.config/transcriber/config.toml
    • load_config() — парсит TOML, валидирует ключи/значения
    • resolve_defaults() — приоритет CLI > конфиг > хардкод
  • Тесты tests/test_config.py — 11 тестов

Критерий готовности: uv run pytest tests/test_config.py -v — все тесты зелёные.


Шаг 9: Рефакторинг transcriber.py — выделить загрузку модели

  • load_model() — загрузка модели с CUDA-фолбеком
  • _transcribe_file() — транскрипция одного файла, возвращает TranscribeFileResult
  • TranscribeFileResult — dataclass с result, model, actual_device
  • transcribe() — тонкая обёртка для обратной совместимости
  • Новые тесты: test_load_model_cuda_fallback, test_load_model_strict_raises, test__transcribe_file_basic

Критерий готовности: все существующие тесты test_transcriber.py проходят без изменений.


Шаг 10: Утилиты для батч-режима в utils.py

  • expand_globs() — раскрытие glob-паттернов
  • has_existing_transcript() — проверка существования транскрипта
  • Тесты в test_utils.py — 5 новых тестов

Критерий готовности: uv run pytest tests/test_utils.py -v — все тесты зелёные.


Шаг 11: Батч-режим + конфиг в CLI

  • Изменение сигнатуры CLI: files: list[Path], дефолты None, --force
  • Интеграция load_config() / resolve_defaults() в main()
  • _run_single() — текущий flow для одного файла
  • _run_batch() — prescan, загрузка модели, транскрипция с итогами
  • Обновлённые тесты test_cli.py — 11 новых тестов

Критерий готовности: uv run pytest tests/test_cli.py -v — все тесты зелёные.


Шаг 12: Документация + ADR

  • ADR-002: Batch mode и config (docs/adr/002-batch-and-config.md)
  • README.md: секции «Батч-режим», «Конфигурационный файл», обновлена таблица опций CLI
  • docs/plan.md: отмечены шаги 8–12

Критерий готовности: README содержит документацию по батч-режиму и конфигу.


Инструкция для агента

Команда на каждый шаг:

Прочитай docs/PRD.md (только секции, указанные в текущем шаге) и plan.md (только текущий шаг).
Реализуй шаг N.
После реализации — отметь все чекбоксы шага как [x] в plan.md.
Не трогай код и чекбоксы из других шагов.