From 0d1a734479ee36c52cf08b8220359632f8b4337f Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 17 Mar 2026 22:35:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(formatter):=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=82=D1=80=D0=B0=D0=BD=D1=81=D0=BA=D1=80=D0=B8=D0=BF?= =?UTF-8?q?=D1=82=D0=B0=20(=D1=88=D0=B0=D0=B3=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - необходим модуль формирования 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 --- docs/plan.md | 8 +-- src/local_transcriber/formatter.py | 53 +++++++++++++- tests/test_formatter.py | 108 +++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 7 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index 4e22d17..451d091 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -187,11 +187,11 @@ > 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) - `True` → `"01:23:45.67"` (HH:MM:SS.ss) - Сотые — всегда 2 знака после точки -- [ ] `format_transcript(...)`: +- [x] `format_transcript(...)`: - Шапка по шаблону PRD 3.3 (заголовок, метаданные, разделитель) - `language_mode`: `"detected"` если CLI получил `--language auto`, `"forced"` если язык задан явно - В шапке: `**Язык**: {language} ({language_mode})` → например `ru (detected)` или `en (forced)` @@ -199,9 +199,9 @@ - Автоматически `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)`: +- [x] `write_transcript(content: str, output_path: Path)`: - `open(output_path, "w", encoding="utf-8")` -- [ ] Тесты в `tests/test_formatter.py`: +- [x] Тесты в `tests/test_formatter.py`: - `test_format_timestamp_minutes` — обычный таймкод - `test_format_timestamp_hours` — формат с часами - `test_format_transcript_basic` — проверить шапку + пару сегментов diff --git a/src/local_transcriber/formatter.py b/src/local_transcriber/formatter.py index acf0621..09bad1d 100644 --- a/src/local_transcriber/formatter.py +++ b/src/local_transcriber/formatter.py @@ -5,7 +5,28 @@ from .transcriber import TranscribeResult 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( @@ -16,8 +37,34 @@ def format_transcript( language_mode: str, # "detected" | "forced" transcription_date: datetime | None = None, # None -> datetime.now() ) -> 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: - raise NotImplementedError + with open(output_path, "w", encoding="utf-8") as f: + f.write(content) diff --git a/tests/test_formatter.py b/tests/test_formatter.py index e69de29..a483ef4 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -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"