From 9f11c329797ad300ac5dfe1d85760935ce3252bd Mon Sep 17 00:00:00 2001 From: Dmitriy Dementiev Date: Wed, 12 Aug 2026 11:52:50 +0300 Subject: [PATCH] =?UTF-8?q?fix(cli)!:=20=D0=B8=D1=81=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=20=D1=8F=D0=B7=D1=8B=D0=BA=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D1=88=D0=B0=D0=BF=D0=BA=D0=B5=20=D1=82=D1=80=D0=B0=D0=BD=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=B0=20=D0=BD=D0=B0=20=D1=80?= =?UTF-8?q?=D1=83=D1=81=D1=81=D0=BA=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - в одной строке шапки смешивались английские и русские значения, а при неизвестном языке в неё попадало служебное «unknown (не определён)». - Что: - формулировки источника языка вынесены константами в formatter и переведены на русский. - оба бэкенда используют общий признак UNKNOWN_LANGUAGE вместо «auto» и «unknown». - неизвестный язык печатается одной строкой, без служебного значения. - Проверка: - uv run pytest -q: 242 passed, 1 skipped. - ruff check .: 48 замечаний, столько же, сколько до правки. BREAKING CHANGE: в шапке транскрипта значения detected и forced заменены на «определён автоматически» и «задан явно»; при неизвестном языке строка выглядит как «- **Язык**: не определён». --- src/local_transcriber/backends/onnx_asr.py | 6 ++- src/local_transcriber/backends/openvino.py | 4 +- src/local_transcriber/cli.py | 15 ++++--- src/local_transcriber/formatter.py | 20 ++++++++- src/local_transcriber/types.py | 5 ++- tests/test_backend_openvino.py | 4 +- tests/test_cli.py | 15 +++++-- tests/test_formatter.py | 49 ++++++++++++++++------ tests/test_onnx_asr.py | 4 +- 9 files changed, 90 insertions(+), 32 deletions(-) diff --git a/src/local_transcriber/backends/onnx_asr.py b/src/local_transcriber/backends/onnx_asr.py index ad9e0ee..5946ea1 100644 --- a/src/local_transcriber/backends/onnx_asr.py +++ b/src/local_transcriber/backends/onnx_asr.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from local_transcriber.types import Segment, TranscribeResult +from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult @dataclass(frozen=True) @@ -216,7 +216,9 @@ class OnnxAsrBackend: _notify(on_status, "Транскрибирую (onnx-asr)...") segments: list[Segment] = [] - result_language = language or _model_language(self._model_spec) or "unknown" + result_language = ( + language or _model_language(self._model_spec) or UNKNOWN_LANGUAGE + ) for vad_seg in model.recognize( audio_array, sample_rate=16000, language=language diff --git a/src/local_transcriber/backends/openvino.py b/src/local_transcriber/backends/openvino.py index 85b80bd..2fc618a 100644 --- a/src/local_transcriber/backends/openvino.py +++ b/src/local_transcriber/backends/openvino.py @@ -12,7 +12,7 @@ from typing import Any from huggingface_hub import snapshot_download from huggingface_hub.errors import LocalEntryNotFoundError -from local_transcriber.types import Segment, TranscribeResult +from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult # (model_alias, compute_type) → HF repo MODEL_REPOS: dict[tuple[str, str], str] = { @@ -159,7 +159,7 @@ class OpenVINOBackend: f"Транскрибирую (OpenVINO)... [{len(segments)} сегм.]", ) - detected_language = language or "auto" + detected_language = language or UNKNOWN_LANGUAGE language_probability = 1.0 if language else 0.0 return TranscribeResult( diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 3adebf1..7698908 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -12,6 +12,10 @@ 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 ( + LANGUAGE_DETECTED, + LANGUAGE_FORCED, + LANGUAGE_FROM_MODEL, + LANGUAGE_UNKNOWN, format_duration, format_timestamp, format_transcript, @@ -30,6 +34,7 @@ from .transcriber import ( _transcribe_file, load_model, ) +from .types import UNKNOWN_LANGUAGE from .utils import ( build_output_path, detect_device, @@ -62,12 +67,12 @@ def _format_language_mode( ) -> str: """Описывает источник языка, не выдавая профиль модели за детектор.""" if requested_language != "auto": - return "forced" + return LANGUAGE_FORCED if result.language_probability > 0: - return "detected" - if result.language not in {"", "auto", "unknown"}: - return "из профиля модели" - return "не определён" + return LANGUAGE_DETECTED + if result.language not in {"", UNKNOWN_LANGUAGE}: + return LANGUAGE_FROM_MODEL + return LANGUAGE_UNKNOWN def _format_repetition_blocks( diff --git a/src/local_transcriber/formatter.py b/src/local_transcriber/formatter.py index 55b0295..dc4a2e3 100644 --- a/src/local_transcriber/formatter.py +++ b/src/local_transcriber/formatter.py @@ -10,6 +10,19 @@ from .types import Segment, TranscribeResult _PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы _MAX_PARAGRAPH_S = 60.0 # максимальная длительность абзаца +# Источник языка в шапке транскрипта +LANGUAGE_FORCED = "задан явно" +LANGUAGE_DETECTED = "определён автоматически" +LANGUAGE_FROM_MODEL = "из профиля модели" +LANGUAGE_UNKNOWN = "не определён" + +LANGUAGE_MODES = ( + LANGUAGE_FORCED, + LANGUAGE_DETECTED, + LANGUAGE_FROM_MODEL, + LANGUAGE_UNKNOWN, +) + @dataclass class _Paragraph: @@ -80,7 +93,7 @@ def format_transcript( source_filename: str, model_name: str, device_info: str, - language_mode: str, # detected | forced | из профиля модели | не определён + language_mode: str, # см. LANGUAGE_MODES transcription_date: datetime | None = None, # None -> datetime.now() ) -> str: """Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами.""" @@ -92,7 +105,10 @@ def format_transcript( 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})") + if language_mode == LANGUAGE_UNKNOWN: + lines.append(f"- **Язык**: {LANGUAGE_UNKNOWN}") + else: + 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 diff --git a/src/local_transcriber/types.py b/src/local_transcriber/types.py index f625dfb..1dc84cb 100644 --- a/src/local_transcriber/types.py +++ b/src/local_transcriber/types.py @@ -4,6 +4,9 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any +# Единый признак «язык неизвестен» для всех бэкендов +UNKNOWN_LANGUAGE = "unknown" + @dataclass class Segment: @@ -15,7 +18,7 @@ class Segment: @dataclass class TranscribeResult: segments: list[Segment] - language: str + language: str # код языка или UNKNOWN_LANGUAGE, если он неизвестен language_probability: float duration: float # seconds device_used: str # "cpu" / "cuda" / "onnx" / "openvino-gpu" / "openvino-cpu" diff --git a/tests/test_backend_openvino.py b/tests/test_backend_openvino.py index 5d7ad4a..361bb6d 100644 --- a/tests/test_backend_openvino.py +++ b/tests/test_backend_openvino.py @@ -11,7 +11,7 @@ from local_transcriber.backends.openvino import ( OpenVINOBackend, _validate_model_dir, ) -from local_transcriber.types import Segment +from local_transcriber.types import UNKNOWN_LANGUAGE, Segment # === _resolve_repo === @@ -301,7 +301,7 @@ def test_transcribe_no_language_auto(): call_kwargs = mock_model.generate.call_args.kwargs assert "language" not in call_kwargs - assert result.language == "auto" + assert result.language == UNKNOWN_LANGUAGE assert result.language_probability == 0.0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 1e5f3cd..f40e2e0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,14 @@ from rich.console import Console from typer.testing import CliRunner from local_transcriber.cli import _format_device_info, _format_language_mode, app +from local_transcriber.formatter import ( + LANGUAGE_DETECTED, + LANGUAGE_FORCED, + LANGUAGE_FROM_MODEL, + LANGUAGE_UNKNOWN, +) from local_transcriber.transcriber import Segment, TranscribeFileResult, TranscribeResult +from local_transcriber.types import UNKNOWN_LANGUAGE runner = CliRunner() @@ -45,10 +52,10 @@ def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_ @pytest.mark.parametrize( ("requested_language", "language", "probability", "expected"), [ - ("ru", "ru", 1.0, "forced"), - ("auto", "ru", 0.95, "detected"), - ("auto", "ru", 0.0, "из профиля модели"), - ("auto", "unknown", 0.0, "не определён"), + ("ru", "ru", 1.0, LANGUAGE_FORCED), + ("auto", "ru", 0.95, LANGUAGE_DETECTED), + ("auto", "ru", 0.0, LANGUAGE_FROM_MODEL), + ("auto", UNKNOWN_LANGUAGE, 0.0, LANGUAGE_UNKNOWN), ], ) def test_format_language_mode( diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 28bf201..ac1e3d5 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -2,12 +2,16 @@ from datetime import datetime from pathlib import Path 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 def test_format_timestamp_minutes(): @@ -40,14 +44,14 @@ def test_format_transcript_basic(): source_filename="meeting.mp4", model_name="large-v3", device_info="CUDA (NVIDIA GeForce RTX 3060)", - language_mode="detected", + 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 (detected)" in content + assert "**Язык**: ru (определён автоматически)" in content assert "**Длительность**: 02:00" in content assert "**Устройство**: CUDA (NVIDIA GeForce RTX 3060)" in content assert "---" in content @@ -55,6 +59,27 @@ def test_format_transcript_basic(): assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." 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( @@ -69,7 +94,7 @@ def test_format_transcript_segment_no_leading_space(): source_filename="f.mp3", model_name="tiny", device_info="CPU", - language_mode="detected", + language_mode=LANGUAGE_DETECTED, transcription_date=datetime(2026, 1, 1, 0, 0, 0), ) assert "[00:00.00 - 00:02.00] Hello" in content @@ -88,7 +113,7 @@ def test_format_transcript_empty(): source_filename="silence.wav", model_name="tiny", device_info="CPU", - language_mode="detected", + language_mode=LANGUAGE_DETECTED, transcription_date=datetime(2026, 1, 1, 0, 0, 0), ) @@ -113,12 +138,12 @@ def test_format_transcript_long(): source_filename="long.mp4", model_name="large-v3", device_info="CUDA", - language_mode="forced", + language_mode=LANGUAGE_FORCED, transcription_date=datetime(2026, 3, 17, 10, 0, 0), ) assert "**Длительность**: 01:03:20" in content - assert "**Язык**: en (forced)" 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 @@ -188,7 +213,7 @@ def test_format_transcript_tail_gap_warning(): source_filename="tail.mp3", model_name="medium", device_info="CPU", - language_mode="forced", + language_mode=LANGUAGE_FORCED, transcription_date=datetime(2026, 1, 1, 0, 0, 0), ) @@ -210,7 +235,7 @@ def test_format_transcript_no_tail_gap_warning_for_small_gap(): source_filename="ok.mp3", model_name="medium", device_info="CPU", - language_mode="forced", + language_mode=LANGUAGE_FORCED, transcription_date=datetime(2026, 1, 1, 0, 0, 0), ) @@ -231,7 +256,7 @@ def test_format_transcript_no_tail_gap_warning_for_exact_threshold(): source_filename="ok.mp3", model_name="medium", device_info="CPU", - language_mode="forced", + language_mode=LANGUAGE_FORCED, transcription_date=datetime(2026, 1, 1, 0, 0, 0), ) @@ -257,7 +282,7 @@ def test_format_transcript_repetition_warning(): source_filename="repeat.mp3", model_name="medium", device_info="CPU", - language_mode="forced", + language_mode=LANGUAGE_FORCED, transcription_date=datetime(2026, 1, 1, 0, 0, 0), ) @@ -284,7 +309,7 @@ def test_format_transcript_repetition_warning_uses_hours(): source_filename="long-repeat.mp3", model_name="medium", device_info="CPU", - language_mode="forced", + language_mode=LANGUAGE_FORCED, transcription_date=datetime(2026, 1, 1, 0, 0, 0), ) @@ -305,7 +330,7 @@ def test_format_transcript_without_anomalies_has_no_warning_lines(): source_filename="ok.mp3", model_name="medium", device_info="CPU", - language_mode="forced", + language_mode=LANGUAGE_FORCED, transcription_date=datetime(2026, 1, 1, 0, 0, 0), ) diff --git a/tests/test_onnx_asr.py b/tests/test_onnx_asr.py index 2d1e611..b8783f6 100644 --- a/tests/test_onnx_asr.py +++ b/tests/test_onnx_asr.py @@ -5,7 +5,7 @@ import warnings import pytest from local_transcriber.backends.onnx_asr import OnnxAsrBackend -from local_transcriber.types import Segment, TranscribeResult +from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult class FakeVadSegment: @@ -390,7 +390,7 @@ class TestTranscribe: result = backend.transcribe(FakeModel(), wav_file, language=None) assert len(result.segments) == 0 - assert result.language == "unknown" + assert result.language == UNKNOWN_LANGUAGE assert result.duration == 1.0 def test_transcribe_skips_zero_length_vad_segments(self, monkeypatch, tmp_path):