feat(formatter): реализовано форматирование транскрипта (шаг 4)
- Зачем: - необходим модуль формирования markdown-файла с таймкодами и метаданными. - Что: - реализованы format_timestamp (MM:SS.ss / HH:MM:SS.ss), format_transcript (шапка по PRD 3.3, авто-часы при >1ч, пустая речь), write_transcript (UTF-8). - добавлено 6 тестов в test_formatter.py (22 теста зелёные). - Проверка: - uv run pytest tests/test_formatter.py -v (6 passed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -187,11 +187,11 @@
|
|||||||
|
|
||||||
> PRD-ссылки: 3.3 (формат выходного файла)
|
> PRD-ссылки: 3.3 (формат выходного файла)
|
||||||
|
|
||||||
- [ ] `format_timestamp(seconds: float, use_hours: bool = False) -> str`:
|
- [x] `format_timestamp(seconds: float, use_hours: bool = False) -> str`:
|
||||||
- `False` → `"01:23.45"` (MM:SS.ss)
|
- `False` → `"01:23.45"` (MM:SS.ss)
|
||||||
- `True` → `"01:23:45.67"` (HH:MM:SS.ss)
|
- `True` → `"01:23:45.67"` (HH:MM:SS.ss)
|
||||||
- Сотые — всегда 2 знака после точки
|
- Сотые — всегда 2 знака после точки
|
||||||
- [ ] `format_transcript(...)`:
|
- [x] `format_transcript(...)`:
|
||||||
- Шапка по шаблону PRD 3.3 (заголовок, метаданные, разделитель)
|
- Шапка по шаблону PRD 3.3 (заголовок, метаданные, разделитель)
|
||||||
- `language_mode`: `"detected"` если CLI получил `--language auto`, `"forced"` если язык задан явно
|
- `language_mode`: `"detected"` если CLI получил `--language auto`, `"forced"` если язык задан явно
|
||||||
- В шапке: `**Язык**: {language} ({language_mode})` → например `ru (detected)` или `en (forced)`
|
- В шапке: `**Язык**: {language} ({language_mode})` → например `ru (detected)` или `en (forced)`
|
||||||
@@ -199,9 +199,9 @@
|
|||||||
- Автоматически `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` (файл создаётся с полной шапкой метаданных; warning в stderr выводит CLI в шаге 5)
|
- Если `len(result.segments) == 0` → после разделителя: `\n*Речь не обнаружена.*\n` (файл создаётся с полной шапкой метаданных; warning в stderr выводит CLI в шаге 5)
|
||||||
- [ ] `write_transcript(content: str, output_path: Path)`:
|
- [x] `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`:
|
- [x] Тесты в `tests/test_formatter.py`:
|
||||||
- `test_format_timestamp_minutes` — обычный таймкод
|
- `test_format_timestamp_minutes` — обычный таймкод
|
||||||
- `test_format_timestamp_hours` — формат с часами
|
- `test_format_timestamp_hours` — формат с часами
|
||||||
- `test_format_transcript_basic` — проверить шапку + пару сегментов
|
- `test_format_transcript_basic` — проверить шапку + пару сегментов
|
||||||
|
|||||||
@@ -5,7 +5,28 @@ from .transcriber import TranscribeResult
|
|||||||
|
|
||||||
|
|
||||||
def format_timestamp(seconds: float, use_hours: bool = False) -> str:
|
def format_timestamp(seconds: float, use_hours: bool = False) -> str:
|
||||||
raise NotImplementedError
|
total_seconds = int(seconds)
|
||||||
|
centiseconds = int(round((seconds - total_seconds) * 100))
|
||||||
|
|
||||||
|
if use_hours:
|
||||||
|
hours = total_seconds // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
secs = total_seconds % 60
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{centiseconds:02d}"
|
||||||
|
|
||||||
|
minutes = total_seconds // 60
|
||||||
|
secs = total_seconds % 60
|
||||||
|
return f"{minutes:02d}:{secs:02d}.{centiseconds:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration(seconds: float) -> str:
|
||||||
|
total = int(seconds)
|
||||||
|
h = total // 3600
|
||||||
|
m = (total % 3600) // 60
|
||||||
|
s = total % 60
|
||||||
|
if h > 0:
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
|
return f"{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
def format_transcript(
|
def format_transcript(
|
||||||
@@ -16,8 +37,34 @@ def format_transcript(
|
|||||||
language_mode: str, # "detected" | "forced"
|
language_mode: str, # "detected" | "forced"
|
||||||
transcription_date: datetime | None = None, # None -> datetime.now()
|
transcription_date: datetime | None = None, # None -> datetime.now()
|
||||||
) -> str:
|
) -> str:
|
||||||
raise NotImplementedError
|
date = transcription_date or datetime.now()
|
||||||
|
use_hours = result.duration > 3600
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(f"# Транскрипт: {source_filename}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
lines.append(f"- **Модель**: {model_name}")
|
||||||
|
lines.append(f"- **Язык**: {result.language} ({language_mode})")
|
||||||
|
lines.append(f"- **Длительность**: {_format_duration(result.duration)}")
|
||||||
|
lines.append(f"- **Устройство**: {device_info}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("---")
|
||||||
|
|
||||||
|
if not result.segments:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("*Речь не обнаружена.*")
|
||||||
|
else:
|
||||||
|
for seg in result.segments:
|
||||||
|
start = format_timestamp(seg.start, use_hours=use_hours)
|
||||||
|
end = format_timestamp(seg.end, use_hours=use_hours)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"[{start} - {end}]{seg.text}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def write_transcript(content: str, output_path: Path) -> None:
|
def write_transcript(content: str, output_path: Path) -> None:
|
||||||
raise NotImplementedError
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from local_transcriber.formatter import (
|
||||||
|
format_timestamp,
|
||||||
|
format_transcript,
|
||||||
|
write_transcript,
|
||||||
|
)
|
||||||
|
from local_transcriber.transcriber import Segment, TranscribeResult
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_timestamp_minutes():
|
||||||
|
assert format_timestamp(0.0) == "00:00.00"
|
||||||
|
assert format_timestamp(83.45) == "01:23.45"
|
||||||
|
assert format_timestamp(9.1) == "00:09.10"
|
||||||
|
assert format_timestamp(599.99) == "09:59.99"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_timestamp_hours():
|
||||||
|
assert format_timestamp(3723.45, use_hours=True) == "01:02:03.45"
|
||||||
|
assert format_timestamp(0.0, use_hours=True) == "00:00:00.00"
|
||||||
|
assert format_timestamp(7261.0, use_hours=True) == "02:01:01.00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_transcript_basic():
|
||||||
|
result = TranscribeResult(
|
||||||
|
segments=[
|
||||||
|
Segment(start=0.0, end=4.82, text=" Добрый день, коллеги."),
|
||||||
|
Segment(start=4.82, end=9.15, text=" Первый вопрос."),
|
||||||
|
],
|
||||||
|
language="ru",
|
||||||
|
language_probability=0.97,
|
||||||
|
duration=120.0,
|
||||||
|
device_used="cuda",
|
||||||
|
)
|
||||||
|
content = format_transcript(
|
||||||
|
result,
|
||||||
|
source_filename="meeting.mp4",
|
||||||
|
model_name="large-v3",
|
||||||
|
device_info="CUDA (NVIDIA GeForce RTX 3060)",
|
||||||
|
language_mode="detected",
|
||||||
|
transcription_date=datetime(2026, 3, 17, 14, 30, 5),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "# Транскрипт: meeting.mp4" in content
|
||||||
|
assert "**Дата транскрипции**: 2026-03-17 14:30:05" in content
|
||||||
|
assert "**Модель**: large-v3" in content
|
||||||
|
assert "**Язык**: ru (detected)" in content
|
||||||
|
assert "**Длительность**: 02:00" in content
|
||||||
|
assert "**Устройство**: CUDA (NVIDIA GeForce RTX 3060)" in content
|
||||||
|
assert "---" in content
|
||||||
|
assert "[00:00.00 - 00:04.82] Добрый день, коллеги." in content
|
||||||
|
assert "[00:04.82 - 00:09.15] Первый вопрос." in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_transcript_empty():
|
||||||
|
result = TranscribeResult(
|
||||||
|
segments=[],
|
||||||
|
language="ru",
|
||||||
|
language_probability=0.5,
|
||||||
|
duration=30.0,
|
||||||
|
device_used="cpu",
|
||||||
|
)
|
||||||
|
content = format_transcript(
|
||||||
|
result,
|
||||||
|
source_filename="silence.wav",
|
||||||
|
model_name="tiny",
|
||||||
|
device_info="CPU",
|
||||||
|
language_mode="detected",
|
||||||
|
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "# Транскрипт: silence.wav" in content
|
||||||
|
assert "*Речь не обнаружена.*" in content
|
||||||
|
assert "**Модель**: tiny" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_transcript_long():
|
||||||
|
result = TranscribeResult(
|
||||||
|
segments=[
|
||||||
|
Segment(start=0.0, end=10.5, text=" Начало."),
|
||||||
|
Segment(start=3700.0, end=3710.25, text=" Конец."),
|
||||||
|
],
|
||||||
|
language="en",
|
||||||
|
language_probability=0.99,
|
||||||
|
duration=3800.0,
|
||||||
|
device_used="cuda",
|
||||||
|
)
|
||||||
|
content = format_transcript(
|
||||||
|
result,
|
||||||
|
source_filename="long.mp4",
|
||||||
|
model_name="large-v3",
|
||||||
|
device_info="CUDA",
|
||||||
|
language_mode="forced",
|
||||||
|
transcription_date=datetime(2026, 3, 17, 10, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "**Длительность**: 01:03:20" in content
|
||||||
|
assert "**Язык**: en (forced)" in content
|
||||||
|
# Timestamps should use hours format
|
||||||
|
assert "[00:00:00.00 - 00:00:10.50] Начало." in content
|
||||||
|
assert "[01:01:40.00 - 01:01:50.25] Конец." in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_transcript(tmp_path):
|
||||||
|
out = tmp_path / "output.md"
|
||||||
|
write_transcript("# Test content\n", out)
|
||||||
|
assert out.read_text(encoding="utf-8") == "# Test content\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user