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:
co-authored by
Claude Fable 5
parent
650e7580e5
commit
b1dfd9dcca
@@ -11,9 +11,21 @@ from rich.status import Status
|
||||
from .config import apply_device_defaults, load_config, resolve_defaults
|
||||
from .context_menu import install_menu as install_context_menu
|
||||
from .context_menu import uninstall_menu as uninstall_context_menu
|
||||
from .formatter import format_transcript, write_transcript
|
||||
from .formatter import (
|
||||
format_duration,
|
||||
format_timestamp,
|
||||
format_transcript,
|
||||
write_transcript,
|
||||
)
|
||||
from .quality import (
|
||||
TAIL_GAP_WARN_S,
|
||||
RepetitionBlock,
|
||||
find_repetition_blocks,
|
||||
tail_gap,
|
||||
)
|
||||
from .transcriber import (
|
||||
Segment,
|
||||
TranscribeResult,
|
||||
_is_cuda_error,
|
||||
_transcribe_file,
|
||||
load_model,
|
||||
@@ -45,6 +57,59 @@ def _format_device_info(device_used: str) -> str:
|
||||
return "CPU"
|
||||
|
||||
|
||||
def _format_repetition_blocks(
|
||||
blocks: list[RepetitionBlock],
|
||||
use_hours: bool,
|
||||
) -> str:
|
||||
"""Формирует краткое описание блоков повторов для консоли."""
|
||||
rendered = [
|
||||
f"[{format_timestamp(block.start, use_hours=use_hours)} - "
|
||||
f"{format_timestamp(block.end, use_hours=use_hours)}] ({block.count}×)"
|
||||
for block in blocks[:3]
|
||||
]
|
||||
summary = "; ".join(rendered)
|
||||
remaining = len(blocks) - 3
|
||||
if remaining > 0:
|
||||
summary = f"{summary} (+ ещё {remaining})"
|
||||
return summary
|
||||
|
||||
|
||||
def _print_quality_warnings(result: TranscribeResult, file_name: str | None = None) -> None:
|
||||
"""Печатает предупреждения о возможной потере содержания."""
|
||||
is_batch = file_name is not None
|
||||
use_hours = result.duration > 3600
|
||||
|
||||
gap = tail_gap(result)
|
||||
if gap > TAIL_GAP_WARN_S:
|
||||
covered = format_duration(result.segments[-1].end)
|
||||
total = format_duration(result.duration)
|
||||
message = (
|
||||
f"транскрипт покрывает {covered} из {total} — "
|
||||
"возможна потеря хвоста записи"
|
||||
)
|
||||
if is_batch:
|
||||
console.print(f" {file_name}: {message}", style="yellow")
|
||||
else:
|
||||
console.print(
|
||||
f"Внимание: {message}. Попробуйте другой --device.",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
blocks = find_repetition_blocks(result.segments)
|
||||
if blocks:
|
||||
message = (
|
||||
f"блоки повторов: {_format_repetition_blocks(blocks, use_hours)} "
|
||||
"— возможны галлюцинации модели"
|
||||
)
|
||||
if is_batch:
|
||||
console.print(f" {file_name}: {message}", style="yellow")
|
||||
else:
|
||||
console.print(
|
||||
f"Внимание: {message}. Попробуйте другой --device.",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
|
||||
@@ -257,6 +322,7 @@ def _run_single(
|
||||
elapsed = time.monotonic() - start
|
||||
console.print(f"Транскрипт сохранён: \"{output_path}\"", style="green")
|
||||
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
|
||||
_print_quality_warnings(result)
|
||||
|
||||
|
||||
def _run_batch(
|
||||
@@ -392,6 +458,7 @@ def _run_batch(
|
||||
style="green",
|
||||
)
|
||||
processed += 1
|
||||
_print_quality_warnings(result, file.name)
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 # пауза между сегментами для разбиения на абзацы
|
||||
@@ -63,7 +64,7 @@ def format_timestamp(seconds: float, use_hours: bool = False) -> str:
|
||||
return f"{minutes:02d}:{secs:02d}.{centiseconds:02d}"
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""Человекочитаемая длительность для метаданных в шапке транскрипта."""
|
||||
total = int(seconds)
|
||||
h = total // 3600
|
||||
@@ -92,7 +93,20 @@ def format_transcript(
|
||||
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"- **Длительность**: {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("---")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Эвристики качества транскрипта."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .types import Segment, TranscribeResult
|
||||
|
||||
TAIL_GAP_WARN_S = 120.0
|
||||
REPETITION_MIN_RUN = 4
|
||||
REPETITION_MIN_RUN_SHORT = 10
|
||||
REPETITION_MIN_LEN = 6
|
||||
|
||||
_PUNCTUATION_TO_REMOVE = ".,!?…:;—–-\"'«»()[]<>"
|
||||
_REMOVE_PUNCTUATION = str.maketrans("", "", _PUNCTUATION_TO_REMOVE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepetitionBlock:
|
||||
start: float
|
||||
end: float
|
||||
count: int
|
||||
text: str
|
||||
|
||||
|
||||
def tail_gap(result: TranscribeResult) -> float:
|
||||
"""Возвращает непокрытый хвост записи в секундах."""
|
||||
if not result.segments:
|
||||
return 0.0
|
||||
return max(0.0, result.duration - result.segments[-1].end)
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Нормализует текст сегмента для поиска межсегментных повторов."""
|
||||
text = text.casefold()
|
||||
text = text.translate(_REMOVE_PUNCTUATION)
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def find_repetition_blocks(segments: list[Segment]) -> list[RepetitionBlock]:
|
||||
"""Находит серии подряд идущих одинаковых сегментов."""
|
||||
blocks: list[RepetitionBlock] = []
|
||||
run_start = 0
|
||||
run_norm = ""
|
||||
|
||||
def append_run(run_end: int) -> None:
|
||||
count = run_end - run_start
|
||||
if not run_norm:
|
||||
return
|
||||
min_run = (
|
||||
REPETITION_MIN_RUN
|
||||
if len(run_norm) >= REPETITION_MIN_LEN
|
||||
else REPETITION_MIN_RUN_SHORT
|
||||
)
|
||||
if count >= min_run:
|
||||
blocks.append(
|
||||
RepetitionBlock(
|
||||
start=segments[run_start].start,
|
||||
end=segments[run_end - 1].end,
|
||||
count=count,
|
||||
text=segments[run_start].text,
|
||||
)
|
||||
)
|
||||
|
||||
for index, segment in enumerate(segments):
|
||||
norm = _normalize(segment.text)
|
||||
if index == 0:
|
||||
run_start = 0
|
||||
run_norm = norm
|
||||
continue
|
||||
if norm == run_norm:
|
||||
continue
|
||||
append_run(index)
|
||||
run_start = index
|
||||
run_norm = norm
|
||||
|
||||
if segments:
|
||||
append_run(len(segments))
|
||||
|
||||
return blocks
|
||||
Reference in New Issue
Block a user