docs(plan): уточнён план по итогам ревью

- Зачем:
  - устранены пробелы в плане: недостающие тесты, неописанные параметры, архитектурные расхождения.
- Что:
  - detect_device() возвращает только str (device), compute-type независим от него.
  - добавлен get_gpu_name() для человекочитаемой строки устройства в шапке markdown.
  - добавлен on_segment callback в transcribe() для --verbose без переделки API.
  - добавлено поле device_used в TranscribeResult (фактическое устройство после fallback).
  - добавлены тесты: test_utils.py (9 тестов), test_transcriber.py (4 mock-теста).
  - выровнено поведение пустой речи: файл с шапкой + *Речь не обнаружена.* в теле (PRD и plan).
  - добавлены импорты Callable, datetime, Path в заглушки шага 1.
  - убраны строки с git add -A / git commit из всех шагов.
- Проверка:
  - открыть docs/plan.md и docs/PRD.md и убедиться в согласованности.
This commit is contained in:
2026-03-17 21:40:41 +03:00
parent 557190e8a9
commit e0fcf43533
2 changed files with 56 additions and 33 deletions
+2 -2
View File
@@ -33,7 +33,7 @@ transcribe meeting-2026-03-17.mp4
- Явный вызов по файлу → транскрипт перезаписывается, даже если уже существует - Явный вызов по файлу → транскрипт перезаписывается, даже если уже существует
- Батч-режим (glob-маска, post-MVP) → пропускать файлы, для которых транскрипт уже существует; `--force` для принудительной перезаписи - Батч-режим (glob-маска, post-MVP) → пропускать файлы, для которых транскрипт уже существует; `--force` для принудительной перезаписи
**Пустая речь**: если модель не распознала ни одного сегмента — файл транскрипта всё равно создаётся (с метаданными в шапке), выводится предупреждение: `⚠ Речь не обнаружена в файле <имя>` **Пустая речь**: если модель не распознала ни одного сегмента — файл транскрипта всё равно создаётся (шапка с метаданными + `*Речь не обнаружена.*` в теле), выводится предупреждение в stderr: `⚠ Речь не обнаружена в файле <имя>`
### 3.2. CLI-интерфейс ### 3.2. CLI-интерфейс
@@ -178,7 +178,7 @@ local-transcriber/
| CUDA несовместимость на Windows | Среднее | Автоматический fallback на CPU + предупреждение; в README — инструкция по CUDA | | CUDA несовместимость на Windows | Среднее | Автоматический fallback на CPU + предупреждение; в README — инструкция по CUDA |
| Большие файлы (>2 часов) | Низкое | faster-whisper работает потоково, не грузит всё в память | | Большие файлы (>2 часов) | Низкое | faster-whisper работает потоково, не грузит всё в память |
| OOM на GPU с 4 GB VRAM | Среднее | Дефолт int8 (~2.5 GB); при OOM — fallback на CPU с предупреждением | | OOM на GPU с 4 GB VRAM | Среднее | Дефолт int8 (~2.5 GB); при OOM — fallback на CPU с предупреждением |
| Файл без речи (тишина, музыка, шум) | Низкое | Создаётся транскрипт с пустым телом + предупреждение в stderr | | Файл без речи (тишина, музыка, шум) | Низкое | Создаётся транскрипт с шапкой метаданных и `*Речь не обнаружена.*` в теле + предупреждение в stderr |
## 7. Вне скоупа MVP ## 7. Вне скоупа MVP
+52 -29
View File
@@ -22,7 +22,9 @@
│ └── utils.py │ └── utils.py
└── tests/ └── tests/
├── __init__.py ├── __init__.py
── test_formatter.py # заглушка ── test_formatter.py # заглушка
├── test_transcriber.py # заглушка
└── test_utils.py # заглушка
``` ```
- [ ] `pyproject.toml`: - [ ] `pyproject.toml`:
- `name = "local-transcriber"`, `python = ">=3.10"` - `name = "local-transcriber"`, `python = ">=3.10"`
@@ -37,13 +39,15 @@
from pathlib import Path from pathlib import Path
def check_ffmpeg() -> None: ... def check_ffmpeg() -> None: ...
def detect_device(requested: str = "auto") -> tuple[str, str]: ... 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 validate_input_file(path: Path) -> Path: ...
def build_output_path(input_path: Path, output: Path | None = None) -> Path: ... def build_output_path(input_path: Path, output: Path | None = None) -> Path: ...
``` ```
**transcriber.py**: **transcriber.py**:
```python ```python
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -59,6 +63,7 @@
language: str language: str
language_probability: float language_probability: float
duration: float # секунды duration: float # секунды
device_used: str # фактическое устройство ("cpu" / "cuda") — может отличаться от запрошенного после fallback
def transcribe( def transcribe(
file_path: Path, file_path: Path,
@@ -66,11 +71,13 @@
device: str = "auto", device: str = "auto",
compute_type: str = "int8", compute_type: str = "int8",
language: str | None = None, language: str | None = None,
on_segment: Callable[[Segment], None] | None = None, # callback для --verbose (вызывается на каждый сегмент)
) -> TranscribeResult: ... ) -> TranscribeResult: ...
``` ```
**formatter.py**: **formatter.py**:
```python ```python
from datetime import datetime
from pathlib import Path from pathlib import Path
from .transcriber import TranscribeResult from .transcriber import TranscribeResult
@@ -81,7 +88,8 @@
source_filename: str, source_filename: str,
model_name: str, model_name: str,
device_info: str, device_info: str,
language_mode: str, language_mode: str, # "detected" | "forced"
transcription_date: datetime | None = None, # None → datetime.now()
) -> str: ... ) -> str: ...
def write_transcript(content: str, output_path: Path) -> None: ... def write_transcript(content: str, output_path: Path) -> None: ...
@@ -89,6 +97,7 @@
**cli.py**: **cli.py**:
```python ```python
from pathlib import Path
import typer import typer
app = typer.Typer() app = typer.Typer()
@@ -102,9 +111,7 @@
- [ ] `uv sync` → `uv run transcribe --help` работает - [ ] `uv sync` → `uv run transcribe --help` работает
**Критерий готовности**: `uv run transcribe --help` показывает аргументы. `uv run pytest` проходит (тесты пустые, но pytest находит test_formatter.py). Все модули импортируются без ошибок. **Критерий готовности**: `uv run transcribe --help` показывает аргументы. `uv run pytest` проходит (тесты пустые, но pytest находит test_formatter.py, test_transcriber.py и test_utils.py). Все модули импортируются без ошибок.
**Коммит**: `git add -A && git commit -m "step 1: scaffold with stubs and interfaces"`
--- ---
@@ -115,13 +122,18 @@
- [ ] `check_ffmpeg()`: - [ ] `check_ffmpeg()`:
- `subprocess.run(["ffmpeg", "-version"], capture_output=True)` - `subprocess.run(["ffmpeg", "-version"], capture_output=True)`
- При `FileNotFoundError` → `SystemExit` с сообщением и инструкцией: `apt install ffmpeg` / `winget install ffmpeg` / `brew install ffmpeg` - При `FileNotFoundError` → `SystemExit` с сообщением и инструкцией: `apt install ffmpeg` / `winget install ffmpeg` / `brew install ffmpeg`
- [ ] `detect_device(requested: str = "auto") -> tuple[str, str]`: - [ ] `detect_device(requested: str = "auto") -> str`:
- Если `requested != "auto"` → вернуть `(requested, "int8")` - Если `requested != "auto"` → вернуть `requested`
- Иначе: проверить CUDA через `shutil.which("nvidia-smi")` как быстрый хинт - Иначе: проверить CUDA через `shutil.which("nvidia-smi")` как быстрый хинт
- Если nvidia-smi найден → `("cuda", "int8")` - Если nvidia-smi найден → `"cuda"`
- Иначе → `("cpu", "int8")` - Иначе → `"cpu"`
- **Не импортировать** ctranslate2 или torch здесь — faster-whisper ещё не в зависимостях - **Не импортировать** ctranslate2 или torch здесь — faster-whisper ещё не в зависимостях
- Точная проверка CUDA будет при загрузке модели (шаг 3), здесь — best effort - Точная проверка 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`: - [ ] `validate_input_file(path: Path) -> Path`:
- Проверить: существует, является файлом (не директорией), размер > 0 - Проверить: существует, является файлом (не директорией), размер > 0
- Расширение из допустимых (PRD 3.4) → если нет, **warning** (не ошибка), продолжить - Расширение из допустимых (PRD 3.4) → если нет, **warning** (не ошибка), продолжить
@@ -130,9 +142,18 @@
- Если `output` задан → вернуть его - Если `output` задан → вернуть его
- Иначе → `input_path.with_stem(input_path.stem + "-transcript").with_suffix(".md")` - Иначе → `input_path.with_stem(input_path.stem + "-transcript").with_suffix(".md")`
**Критерий готовности**: `uv run python -c "from local_transcriber.utils import check_ffmpeg, detect_device; check_ffmpeg(); print(detect_device())"` — работает на машине агента (CPU fallback). - [ ] Тесты в `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
**Коммит**: `git add -A && git commit -m "step 2: utils — ffmpeg check, device detection, path helpers"` **Критерий готовности**: `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).
--- ---
@@ -144,16 +165,21 @@
- [ ] Реализовать `transcribe()`: - [ ] Реализовать `transcribe()`:
- Создать `WhisperModel(model_name, device=device, compute_type=compute_type)` - Создать `WhisperModel(model_name, device=device, compute_type=compute_type)`
- При ошибке загрузки на CUDA (OOM, CUDA error) → **поймать**, вывести warning, **повторить с device="cpu"** - При ошибке загрузки на CUDA (OOM, CUDA error) → **поймать**, вывести warning, **повторить с device="cpu"**
- Запомнить фактический device → записать в `TranscribeResult.device_used`
- `model.transcribe(str(file_path), language=language if language != "auto" else None)` - `model.transcribe(str(file_path), language=language if language != "auto" else None)`
- faster-whisper возвращает `(segment_generator, info)` — итерировать generator, собрать в `list[Segment]` - faster-whisper возвращает `(segment_generator, info)` — итерировать generator, для каждого сегмента вызвать `on_segment(segment)` если callback передан, затем собрать в `list[Segment]`
- Заполнить `TranscribeResult` из info (language, duration и т.д.) - Заполнить `TranscribeResult` из info (language, duration и т.д.)
- [ ] Обработка ошибок: - [ ] Обработка ошибок:
- `RuntimeError` с "CUDA" / "out of memory" → fallback на CPU + warning - `RuntimeError` с "CUDA" / "out of memory" → fallback на CPU + warning
- Ошибка ffmpeg (невалидный медиафайл) → пробросить с понятным текстом - Ошибка 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` совпадает с запрошенным
**Критерий готовности**: на машине агента — `transcribe()` работает с `device="cpu"`, `model="tiny"` (быстро скачивается). Полноценная проверка с large-v3 и GPU — на локальной машине разработчика. **Критерий готовности**: `uv run pytest tests/test_transcriber.py -v` — зелёное. Дополнительно на машине агента — `transcribe()` работает с `device="cpu"`, `model="tiny"`. Полноценная проверка с large-v3 и GPU — на локальной машине.
**Коммит**: `git add -A && git commit -m "step 3: transcriber — faster-whisper wrapper with CUDA fallback"`
--- ---
@@ -167,9 +193,12 @@
- Сотые — всегда 2 знака после точки - Сотые — всегда 2 знака после точки
- [ ] `format_transcript(...)`: - [ ] `format_transcript(...)`:
- Шапка по шаблону PRD 3.3 (заголовок, метаданные, разделитель) - Шапка по шаблону 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` - Автоматически `use_hours=True` если `result.duration > 3600`
- Сегменты: `[MM:SS.ss - MM:SS.ss] текст\n\n` - Сегменты: `[MM:SS.ss - MM:SS.ss] текст\n\n`
- Если `len(result.segments) == 0` → после разделителя: `\n*Речь не обнаружена.*\n` - Если `len(result.segments) == 0` → после разделителя: `\n*Речь не обнаружена.*\n` (файл создаётся с полной шапкой метаданных; warning в stderr выводит CLI в шаге 5)
- [ ] `write_transcript(content: str, output_path: Path)`: - [ ] `write_transcript(content: str, output_path: Path)`:
- `open(output_path, "w", encoding="utf-8")` - `open(output_path, "w", encoding="utf-8")`
- [ ] Тесты в `tests/test_formatter.py`: - [ ] Тесты в `tests/test_formatter.py`:
@@ -181,8 +210,6 @@
**Критерий готовности**: `uv run pytest tests/test_formatter.py -v` — все тесты зелёные. **Критерий готовности**: `uv run pytest tests/test_formatter.py -v` — все тесты зелёные.
**Коммит**: `git add -A && git commit -m "step 4: formatter with markdown output and tests"`
--- ---
## Шаг 5: cli.py — связка всех модулей, happy path ## Шаг 5: cli.py — связка всех модулей, happy path
@@ -200,19 +227,19 @@
- [ ] Happy path flow: - [ ] Happy path flow:
1. `check_ffmpeg()` 1. `check_ffmpeg()`
2. `validate_input_file(file)` 2. `validate_input_file(file)`
3. `detect_device()` если `--device auto`, иначе использовать переданные device + compute_type 3. `detect_device(device)` → получить device; `--compute-type` используется как есть (независим от device)
4. rich Console → stderr: информация о запуске (модель, устройство, файл) 4. rich Console → stderr: информация о запуске (модель, устройство, файл)
5. rich Spinner/Status во время транскрипции 5. rich Spinner/Status во время транскрипции
6. `transcribe(...)` 6. `transcribe(...)` — передать `on_segment=<callback>` если `--verbose`
7. Если 0 сегментов → `console.print("⚠ Речь не обнаружена в файле ...", style="yellow")` 7. Если 0 сегментов → `console.print("⚠ Речь не обнаружена в файле ...", style="yellow")`
8. `format_transcript(...)` → `write_transcript(...)` 8. `format_transcript(...)` — `device_info`: если `result.device_used == "cuda"` → `"CUDA ({get_gpu_name() or 'Unknown GPU'})"`, иначе `"CPU"`
9. `console.print("✓ Транскрипт сохранён: <путь>", style="green")` 9. `write_transcript(...)`
10. Статистика: кол-во сегментов, время работы (замерить через `time.monotonic()`) 10. `console.print("✓ Транскрипт сохранён: <путь>", style="green")`
11. Статистика: кол-во сегментов, время работы (замерить через `time.monotonic()`)
- [ ] Exit codes: 0 — успех (включая пустую речь), 1 — ошибка - [ ] Exit codes: 0 — успех (включая пустую речь), 1 — ошибка
**Критерий готовности**: `uv run transcribe test.mp3` — создаёт корректный .md файл (проверить на локальной машине с реальным файлом). **Критерий готовности**: `uv run transcribe test.mp3` — создаёт корректный .md файл (проверить на локальной машине с реальным файлом).
**Коммит**: `git add -A && git commit -m "step 5: CLI happy path — end-to-end transcription"`
--- ---
@@ -222,12 +249,11 @@
- [ ] Graceful Ctrl+C: перехват `KeyboardInterrupt` в cli.py → `console.print("Прервано пользователем", style="yellow")` + `raise SystemExit(130)` - [ ] Graceful Ctrl+C: перехват `KeyboardInterrupt` в cli.py → `console.print("Прервано пользователем", style="yellow")` + `raise SystemExit(130)`
- [ ] Красивые ошибки: обернуть main в try/except, для пользовательских ошибок (файл не найден, ffmpeg, OOM) — вывод через rich без traceback; для неожиданных — traceback только с `--verbose` - [ ] Красивые ошибки: обернуть main в try/except, для пользовательских ошибок (файл не найден, ffmpeg, OOM) — вывод через rich без traceback; для неожиданных — traceback только с `--verbose`
- [ ] `--verbose` режим: при транскрипции выводить каждый сегмент в stderr по мере получения из generator (до сборки в список) - [ ] `--verbose` режим: реализуется через `on_segment` callback в `transcribe()` (уже заложен в шаге 3) — печатать каждый сегмент в stderr по мере поступления
- [ ] Проверка: неподдерживаемое расширение → warning, но попытка продолжить - [ ] Проверка: неподдерживаемое расширение → warning, но попытка продолжить
**Критерий готовности**: ручной прогон edge cases — несуществующий файл, .txt файл, Ctrl+C во время работы. **Критерий готовности**: ручной прогон edge cases — несуществующий файл, .txt файл, Ctrl+C во время работы.
**Коммит**: `git add -A && git commit -m "step 6: error handling, verbose mode, graceful shutdown"`
--- ---
@@ -251,7 +277,6 @@
**Критерий готовности**: коллега может по README установить и запустить на Windows/WSL2 без вопросов. **Критерий готовности**: коллега может по README установить и запустить на Windows/WSL2 без вопросов.
**Коммит**: `git add -A && git commit -m "step 7: README with install, usage, GPU guide"`
--- ---
@@ -264,5 +289,3 @@
После реализации — отметь все чекбоксы шага как [x] в plan.md. После реализации — отметь все чекбоксы шага как [x] в plan.md.
Не трогай код и чекбоксы из других шагов. Не трогай код и чекбоксы из других шагов.
``` ```
После каждого шага — `git add -A && git commit -m "<сообщение из шага>"`.