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>
This commit is contained in:
Dmitriy Dementiev
2026-07-09 22:21:07 +03:00
co-authored by Claude Fable 5
parent 650e7580e5
commit b1dfd9dcca
6 changed files with 575 additions and 3 deletions
+150
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from rich.console import Console
from typer.testing import CliRunner
from local_transcriber.cli import _format_device_info, app
@@ -1018,3 +1019,152 @@ def test_cli_menu_runtime_error_has_no_verbose_hint():
assert out.exit_code == 1
assert "нет APPDATA" in out.output
assert "--verbose" not in out.output
def test_cli_tail_gap_quality_warning_single(tmp_path):
audio = tmp_path / "tail.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(start=0.0, end=60.0, text="Фраза")],
duration=600.0,
)
patches = _single_patches(result=result, tmp_file=audio)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
):
out = runner.invoke(app, [str(audio)])
assert out.exit_code == 0
assert (
"Внимание: транскрипт покрывает 01:00 из 10:00 — "
"возможна потеря хвоста записи. Попробуйте другой --device."
) in out.output
def test_cli_repetition_quality_warning_single(tmp_path):
audio = tmp_path / "repeat.mp3"
audio.write_bytes(b"fake")
result = _make_result(
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="повторяемая фраза"),
],
duration=60.0,
)
patches = _single_patches(result=result, tmp_file=audio)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
):
out = runner.invoke(app, [str(audio)])
assert out.exit_code == 0
assert (
"Внимание: блоки повторов: [00:10.00 - 00:14.00] (4×) — "
"возможны галлюцинации модели. Попробуйте другой --device."
) in out.output
def test_cli_quality_warning_batch_includes_file_name(tmp_path):
a = tmp_path / "a.mp3"
b = tmp_path / "b.mp3"
a.write_bytes(b"fake")
b.write_bytes(b"fake")
result_warn = _make_result(
segments=[Segment(start=0.0, end=60.0, text="Фраза")],
duration=600.0,
)
result_ok = _make_result()
model = _make_model()
backend = _make_backend()
tfr_warn = _make_tfr(result=result_warn, model=model, backend=backend)
tfr_ok = _make_tfr(result=result_ok, model=model, backend=backend)
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_warn, tfr_ok]),
patch("local_transcriber.cli.write_transcript"),
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
):
out = runner.invoke(app, [str(a), str(b)])
assert out.exit_code == 0
assert (
" a.mp3: транскрипт покрывает 01:00 из 10:00 — "
"возможна потеря хвоста записи"
) in out.output
def test_cli_repetition_quality_warning_truncates_after_three_blocks(tmp_path):
audio = tmp_path / "repeat-many.mp3"
audio.write_bytes(b"fake")
def run(start, count, text):
return [
Segment(start=start + index, end=start + index + 1.0, text=text)
for index in range(count)
]
result = _make_result(
segments=[
*run(10.0, 6, "Первый повтор"),
Segment(start=18.0, end=19.0, text="Разрыв один"),
*run(20.0, 5, "Второй повтор"),
Segment(start=28.0, end=29.0, text="Разрыв два"),
*run(30.0, 4, "Третий повтор"),
Segment(start=38.0, end=39.0, text="Разрыв три"),
*run(40.0, 4, "Четвёртый повтор"),
],
duration=90.0,
)
patches = _single_patches(result=result, tmp_file=audio)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
):
out = runner.invoke(app, [str(audio)])
assert out.exit_code == 0
assert (
"Внимание: блоки повторов: [00:10.00 - 00:16.00] (6×); "
"[00:20.00 - 00:25.00] (5×); [00:30.00 - 00:34.00] (4×) "
"(+ ещё 1) — возможны галлюцинации модели. Попробуйте другой --device."
) in out.output
def test_cli_default_result_has_no_quality_warnings(tmp_path):
audio = tmp_path / "normal.mp3"
audio.write_bytes(b"fake")
patches = _single_patches(tmp_file=audio)
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
out = runner.invoke(app, [str(audio)])
assert out.exit_code == 0
assert "потеря хвоста" not in out.output
assert "галлюцинации" not in out.output
+138
View File
@@ -172,3 +172,141 @@ 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="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="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="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="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="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="forced",
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "Внимание" not in content
+125
View File
@@ -0,0 +1,125 @@
import pytest
from local_transcriber.quality import (
REPETITION_MIN_LEN,
TAIL_GAP_WARN_S,
_normalize,
find_repetition_blocks,
tail_gap,
)
from local_transcriber.types import Segment, TranscribeResult
def _result(segments, duration):
return TranscribeResult(
segments=segments,
language="ru",
language_probability=0.95,
duration=duration,
device_used="cpu",
)
def _segments(texts, start=0.0):
return [
Segment(start=start + index, end=start + index + 1.0, text=text)
for index, text in enumerate(texts)
]
def test_tail_gap_returns_positive_gap():
result = _result([Segment(0.0, 10.0, "Текст")], duration=42.0)
assert tail_gap(result) == 32.0
def test_tail_gap_empty_segments_returns_zero():
assert tail_gap(_result([], duration=42.0)) == 0.0
def test_tail_gap_negative_gap_returns_zero():
result = _result([Segment(0.0, 43.0, "Текст")], duration=42.0)
assert tail_gap(result) == 0.0
@pytest.mark.parametrize("gap", [119.99, 120.0])
def test_tail_gap_boundary_does_not_warn(gap):
result = _result([Segment(0.0, 10.0, "Текст")], duration=10.0 + gap)
assert tail_gap(result) <= TAIL_GAP_WARN_S
def test_tail_gap_boundary_warns_above_threshold():
result = _result([Segment(0.0, 10.0, "Текст")], duration=130.01)
assert tail_gap(result) > TAIL_GAP_WARN_S
def test_normalize_removes_case_punctuation_and_collapses_spaces():
assert _normalize(' «ПРИВЕТ…» — (мир) [тест] <да> ') == "привет мир тест да"
assert _normalize("раз–два-три: да; нет!") == "раздватри да нет"
def test_normalize_punctuation_only_returns_empty():
assert _normalize('.,!?…:;—–-"\'«»()[]<> ') == ""
def test_find_repetition_blocks_four_long_segments_with_normalization():
segments = _segments(["Повтор!", "повтор", "«ПОВТОР»", "повтор…"])
blocks = find_repetition_blocks(segments)
assert len(blocks) == 1
assert blocks[0].start == 0.0
assert blocks[0].end == 4.0
assert blocks[0].count == 4
assert blocks[0].text == "Повтор!"
def test_find_repetition_blocks_three_long_segments_is_empty():
assert find_repetition_blocks(_segments(["Повтор", "повтор", "повтор"])) == []
def test_find_repetition_blocks_length_boundary():
assert find_repetition_blocks(_segments(["пять5"] * 4)) == []
blocks = find_repetition_blocks(_segments(["шесть6"] * 4))
assert len("шесть6") == REPETITION_MIN_LEN
assert len(blocks) == 1
def test_find_repetition_blocks_short_text_boundary():
assert find_repetition_blocks(_segments(["Ага."] * 9)) == []
blocks = find_repetition_blocks(_segments(["Ага."] * 10))
assert len(blocks) == 1
assert blocks[0].count == 10
def test_find_repetition_blocks_empty_normalized_text_is_ignored():
assert find_repetition_blocks(_segments([":", "", " "] * 10)) == []
def test_find_repetition_blocks_two_separate_runs():
segments = [
*_segments(["Первый повтор"] * 4),
Segment(10.0, 11.0, "Разрыв"),
*_segments(["Второй повтор"] * 4, start=20.0),
]
blocks = find_repetition_blocks(segments)
assert len(blocks) == 2
assert blocks[0].text == "Первый повтор"
assert blocks[1].text == "Второй повтор"
def test_find_repetition_blocks_empty_list_is_empty():
assert find_repetition_blocks([]) == []
def test_find_repetition_blocks_single_segment_is_empty():
assert find_repetition_blocks([Segment(0.0, 1.0, "Повтор")]) == []