Files
local-transcriber/src/local_transcriber/formatter.py
T
Dmitriy DementievandClaude Fable 5 b1dfd9dcca feat(quality): предупреждения о возможной потере содержания транскрипта
- Зачем:
  - транскрипция может тихо терять содержание: блоки галлюцинированных повторов и обрыв распознавания до конца файла выглядят как законченный транскрипт (класс ошибок из ADR-006), и читатель об этом не узнаёт.
- Что:
  - новый модуль quality.py: детектор разрыва в хвосте (строго >120 с) и детектор блоков повторяющихся сегментов (серия >=4 для длинного текста, >=10 для короткого вроде «ага»).
  - предупреждения жёлтым в консоли (single и batch) и строками «Внимание» в шапке markdown-транскрипта; _format_duration переименована в публичную format_duration.
  - 27 новых тестов: границы порогов, нормализация текста, шапка formatter, точные строки CLI-сообщений с обрезкой «(+ ещё N)».
- Проверка:
  - uv run pytest — 218 passed, 1 skipped.
  - ручной прогон записи 23:01 (gigaam-v3/onnx) — транскрипт полный, предупреждений нет.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:21:07 +03:00

132 lines
4.9 KiB
Python
Raw 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.
"""Формирование markdown-транскрипта из результатов распознавания."""
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap
from .types import Segment, TranscribeResult
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
_MAX_PARAGRAPH_S = 60.0 # максимальная длительность абзаца
@dataclass
class _Paragraph:
start: float
end: float
text: str
def _group_segments(segments: list[Segment]) -> list[_Paragraph]:
"""Объединяет мелкие сегменты в абзацы по паузам и макс. длительности."""
if not segments:
return []
paragraphs: list[_Paragraph] = []
cur_start = segments[0].start
cur_end = segments[0].end
cur_texts: list[str] = [segments[0].text.strip()]
for seg in segments[1:]:
gap = seg.start - cur_end
duration = seg.end - cur_start
if gap >= _PAUSE_THRESHOLD_S or duration > _MAX_PARAGRAPH_S:
paragraphs.append(_Paragraph(cur_start, cur_end, " ".join(cur_texts)))
cur_start = seg.start
cur_end = seg.end
cur_texts = [seg.text.strip()]
else:
cur_end = seg.end
cur_texts.append(seg.text.strip())
paragraphs.append(_Paragraph(cur_start, cur_end, " ".join(cur_texts)))
return paragraphs
def format_timestamp(seconds: float, use_hours: bool = False) -> str:
"""Форматирует время в ``MM:SS.cc`` или ``HH:MM:SS.cc``.
Сотые доли (centiseconds) — максимальная точность, которую даёт Whisper.
"""
total_cs = round(seconds * 100)
centiseconds = total_cs % 100
total_seconds = total_cs // 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(
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:
"""Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами."""
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)}")
if tail_gap(result) > TAIL_GAP_WARN_S:
last_end = result.segments[-1].end
lines.append(
f"- **Внимание**: транскрипт покрывает {format_duration(last_end)} "
f"из {format_duration(result.duration)} — возможна потеря хвоста записи"
)
for block in find_repetition_blocks(result.segments):
start = format_timestamp(block.start, use_hours=use_hours)
end = format_timestamp(block.end, use_hours=use_hours)
lines.append(
f"- **Внимание**: повторы в [{start} - {end}] ({block.count}×) "
"— возможны галлюцинации модели"
)
lines.append(f"- **Устройство**: {device_info}")
lines.append("")
lines.append("---")
if not result.segments:
lines.append("")
lines.append("*Речь не обнаружена.*")
else:
for para in _group_segments(result.segments):
start = format_timestamp(para.start, use_hours=use_hours)
end = format_timestamp(para.end, use_hours=use_hours)
lines.append("")
lines.append(f"[{start} - {end}] {para.text}")
lines.append("")
return "\n".join(lines)
def write_transcript(content: str, output_path: Path) -> None:
"""Записывает готовый транскрипт в файл."""
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)