Files
local-transcriber/tests/test_formatter.py
T
Dmitriy Dementiev 726718197f feat(diarization): добавлено разделение транскрипта по говорящим
Зачем:
- локальным транскриптам нужна структура реплик для конспектов и протоколов.

Что:
- добавлены пословные таймкоды для всех ASR-бэкендов и сведение с Sherpa-ONNX.
- реализованы CLI-флаги, деградация без потери ASR и speaker Markdown.
- добавлены проверяемый кеш моделей, тесты и документация.

Проверка:
- `pytest` — 283 passed, 1 skipped.
- `pyright` — 0 errors.
- Ruff и `git diff --check` — без ошибок.
- выполнены три контрольных прогона на реальных записях.
2026-08-14 18:55:42 +03:00

454 lines
15 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.
from datetime import datetime
from local_transcriber.formatter import (
LANGUAGE_DETECTED,
LANGUAGE_FORCED,
LANGUAGE_UNKNOWN,
_group_segments,
format_timestamp,
format_transcript,
write_transcript,
)
from local_transcriber.transcriber import Segment, TranscribeResult
from local_transcriber.types import (
UNKNOWN_LANGUAGE,
SmallSpeakerCluster,
SpeakerTranscript,
SpeakerTurn,
)
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"
assert format_timestamp(0.995) == "00:01.00" # carry-over: не даёт .100
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=LANGUAGE_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 (определён автоматически)" in content
assert "**Длительность**: 02:00" in content
assert "**Устройство**: CUDA (NVIDIA GeForce RTX 3060)" in content
assert "---" in content
# Соседние сегменты без паузы объединяются в один абзац
assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." in content
def test_format_transcript_speaker_turns_use_truncated_start_timestamps():
result = TranscribeResult(
segments=[Segment(start=547.96, end=560.0, text=" Обычный текст")],
language="ru",
language_probability=0.97,
duration=700.0,
device_used="cpu",
)
speakers = SpeakerTranscript(
turns=[
SpeakerTurn(547.96, 550.0, "Первая реплика.", 1),
SpeakerTurn(558.4, 560.0, "Ответ.", 2),
],
cluster_count=2,
unassigned_word_count=0,
small_clusters=[],
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_DETECTED,
speaker_transcript=speakers,
)
assert "- **Голосовых кластеров**: 2" in content
assert "[09:07] Speaker 1: Первая реплика." in content
assert "[09:18] Speaker 2: Ответ." in content
assert "[09:07.96 -" not in content
def test_format_transcript_speaker_turns_use_hours_after_one_hour():
result = TranscribeResult(
segments=[Segment(start=3661.9, end=3663.0, text=" Длинная встреча")],
language="ru",
language_probability=1.0,
duration=3700.0,
device_used="cpu",
)
speakers = SpeakerTranscript(
turns=[SpeakerTurn(3661.9, 3663.0, "Длинная встреча", 1)],
cluster_count=2,
unassigned_word_count=0,
small_clusters=[],
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
speaker_transcript=speakers,
)
assert "[01:01:01] Speaker 1: Длинная встреча" in content
def test_format_transcript_reports_unknown_words_and_small_clusters():
result = TranscribeResult(
segments=[Segment(start=0.0, end=8.0, text=" Текст")],
language="ru",
language_probability=1.0,
duration=20.0,
device_used="cpu",
)
speakers = SpeakerTranscript(
turns=[SpeakerTurn(1.2, 2.0, "Неясная реплика.", None)],
cluster_count=2,
unassigned_word_count=3,
small_clusters=[SmallSpeakerCluster(speaker=2, duration=4.2)],
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
speaker_transcript=speakers,
)
assert "[00:01] Speaker ?: Неясная реплика." in content
assert "3 слов без назначенного говорящего" in content
assert "малый кластер Speaker 2: 4.2 с" in content
def test_format_transcript_keeps_plain_body_with_diarization_warning():
result = TranscribeResult(
segments=[Segment(start=0.0, end=2.0, text=" Полезный текст.")],
language="ru",
language_probability=1.0,
duration=5.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
diarization_warning="Диаризация завершилась с ошибкой: boom",
)
assert "**Внимание**: Диаризация завершилась с ошибкой: boom" in content
assert "[00:00.00 - 00:02.00] Полезный текст." in content
assert "Speaker" not in content
def test_format_transcript_unknown_language_without_placeholder():
"""Неизвестный язык печатается одной строкой, без служебного значения."""
result = TranscribeResult(
segments=[Segment(start=0.0, end=4.0, text=" Добрый день.")],
language=UNKNOWN_LANGUAGE,
language_probability=0.0,
duration=120.0,
device_used="openvino-cpu",
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="OpenVINO (CPU)",
language_mode=LANGUAGE_UNKNOWN,
)
assert "**Язык**: не определён" in content
assert UNKNOWN_LANGUAGE not in content
def test_format_transcript_segment_no_leading_space():
"""Сегменты без ведущего пробела должны форматироваться корректно."""
result = TranscribeResult(
segments=[Segment(start=0.0, end=2.0, text="Hello")],
language="en",
language_probability=0.99,
duration=5.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="f.mp3",
model_name="tiny",
device_info="CPU",
language_mode=LANGUAGE_DETECTED,
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "[00:00.00 - 00:02.00] Hello" 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=LANGUAGE_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=LANGUAGE_FORCED,
transcription_date=datetime(2026, 3, 17, 10, 0, 0),
)
assert "**Длительность**: 01:03:20" in content
assert "**Язык**: en (задан явно)" 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_group_segments_merges_adjacent():
"""Соседние сегменты без паузы объединяются."""
segments = [
Segment(start=0.0, end=3.0, text=" Первый."),
Segment(start=3.0, end=6.0, text=" Второй."),
Segment(start=6.0, end=9.0, text=" Третий."),
]
groups = _group_segments(segments)
assert len(groups) == 1
assert groups[0].start == 0.0
assert groups[0].end == 9.0
assert groups[0].text == "Первый. Второй. Третий."
def test_group_segments_splits_on_pause():
"""Пауза > 2с разбивает на отдельные абзацы."""
segments = [
Segment(start=0.0, end=3.0, text=" Первый."),
Segment(start=3.0, end=6.0, text=" Второй."),
Segment(start=9.0, end=12.0, text=" После паузы."),
]
groups = _group_segments(segments)
assert len(groups) == 2
assert groups[0].text == "Первый. Второй."
assert groups[1].text == "После паузы."
def test_group_segments_splits_on_max_duration():
"""Абзац разбивается при превышении макс. длительности."""
segments = [
Segment(start=0.0, end=30.0, text=" Длинный."),
Segment(start=30.0, end=55.0, text=" Ещё."),
Segment(start=55.0, end=80.0, text=" Перелив."),
]
groups = _group_segments(segments)
assert len(groups) == 2
assert groups[0].text == "Длинный. Ещё."
assert groups[1].text == "Перелив."
def test_group_segments_empty():
assert _group_segments([]) == []
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"
def test_format_transcript_tail_gap_warning():
result = TranscribeResult(
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
language="ru",
language_probability=0.95,
duration=600.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="tail.mp3",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "возможна потеря хвоста" in content
assert "транскрипт покрывает 01:00 из 10:00" in content
def test_format_transcript_no_tail_gap_warning_for_small_gap():
result = TranscribeResult(
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
language="ru",
language_probability=0.95,
duration=179.99,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="ok.mp3",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "потеря хвоста" not in content
def test_format_transcript_no_tail_gap_warning_for_exact_threshold():
result = TranscribeResult(
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
language="ru",
language_probability=0.95,
duration=180.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="ok.mp3",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "потеря хвоста" not in content
def test_format_transcript_repetition_warning():
result = TranscribeResult(
segments=[
Segment(start=10.0, end=11.0, text=" Повторяемая фраза."),
Segment(start=11.0, end=12.0, text=" повторяемая фраза"),
Segment(start=12.0, end=13.0, text=" «Повторяемая фраза»"),
Segment(start=13.0, end=14.0, text=" повторяемая фраза…"),
],
language="ru",
language_probability=0.95,
duration=60.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="repeat.mp3",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "повторы в [00:10.00 - 00:14.00] (4×)" in content
assert "возможны галлюцинации" in content
def test_format_transcript_repetition_warning_uses_hours():
result = TranscribeResult(
segments=[
Segment(start=3600.0, end=3601.0, text=" Повтор."),
Segment(start=3601.0, end=3602.0, text=" повтор"),
Segment(start=3602.0, end=3603.0, text=" повтор"),
Segment(start=3603.0, end=3604.0, text=" повтор"),
],
language="ru",
language_probability=0.95,
duration=3700.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="long-repeat.mp3",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "повторы в [01:00:00.00 - 01:00:04.00] (4×)" in content
def test_format_transcript_without_anomalies_has_no_warning_lines():
result = TranscribeResult(
segments=[Segment(start=0.0, end=60.0, text=" Обычная запись.")],
language="ru",
language_probability=0.95,
duration=120.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="ok.mp3",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "Внимание" not in content