From ef61ced2e5fad4a11390f5bf5380f364acf0f028 Mon Sep 17 00:00:00 2001 From: Dmitriy Dementiev Date: Wed, 2 Sep 2026 12:45:57 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(config):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B0=20=D0=B4=D0=B8=D0=B0=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B2=20TOML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - пользователю нужна постоянная настройка диаризации с явным CLI-переопределением. - Что: - добавлен булев ключ diarize и каскад CLI > TOML > false. - добавлены --no-diarize и проверка конфликта с --speakers. - обновлены документация и поведенческие тесты single/batch. - Проверка: - uv run pytest tests/test_config.py tests/test_cli.py -v. - uv run pytest: 452 passed, 1 skipped, 1 несвязанный Windows-сбой. --- README.md | 7 +- docs/specs/2026-08-14-speaker-diarization.md | 10 +-- src/local_transcriber/cli.py | 25 ++++--- src/local_transcriber/config.py | 31 +++++--- tests/test_cli.py | 75 +++++++++++++++++++- tests/test_config.py | 34 +++++++++ 6 files changed, 155 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index abc1d37..011261f 100644 --- a/README.md +++ b/README.md @@ -223,8 +223,8 @@ transcribe --uninstall-menu | `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) | | `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений | | `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) | -| `--diarize` | — | — | Разделить текст на реплики говорящих | -| `--speakers` | — | авто | Ожидаемое число говорящих; включает `--diarize` | +| `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих | +| `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` | | `--force` | `-f` | — | Перезаписать существующие транскрипты | | `--verbose` | `-v` | — | Подробный вывод | @@ -282,6 +282,7 @@ device = "openvino-cpu" model = "large-v3-turbo" compute_type = "int8" language = "ru" +diarize = true ``` Порядок поиска: @@ -289,6 +290,8 @@ language = "ru" 2. `~/.config/transcriber/config.toml` Приоритет: **CLI-аргумент > конфиг > device-aware дефолт > встроенный дефолт**. +`--diarize` и `--no-diarize` позволяют переопределить `diarize` из конфига для +отдельного запуска. При `device = "auto"` выбирается CUDA, если доступен `nvidia-smi`, иначе ONNX. Явный `device` из CLI или конфига отключает этот автоматический выбор. diff --git a/docs/specs/2026-08-14-speaker-diarization.md b/docs/specs/2026-08-14-speaker-diarization.md index 832b349..ee4f485 100644 --- a/docs/specs/2026-08-14-speaker-diarization.md +++ b/docs/specs/2026-08-14-speaker-diarization.md @@ -23,7 +23,7 @@ ## Не входит -- Автоматическое включение диаризации без флага и `--no-diarize`. +- Аппаратно-зависимое автоматическое включение диаризации. - Параллельный запуск ASR и диаризации. - Сопоставление `Speaker N` с именами участников. - Постоянный кеш разметки говорящих, голосовые эмбеддинги и диагностические @@ -33,10 +33,12 @@ ## Пользовательский интерфейс -- `--diarize` включает диаризацию. По умолчанию она выключена; ключ в - `.transcriber.toml` в первой версии не добавляется. +- `--diarize` включает диаризацию, `--no-diarize` явно выключает. Булев ключ + `diarize` в `.transcriber.toml` задаёт значение по умолчанию; без него + диаризация выключена. CLI-флаги имеют приоритет над конфигом. - `--speakers N`, где `N >= 1`, задаёт известное число участников и сам включает - диаризацию. Без него число кластеров определяется автоматически. + диаризацию. Без него число кластеров определяется автоматически. Сочетание + `--speakers N` с `--no-diarize` считается ошибкой использования. - `--threads N` остаётся единым бюджетом активного CPU-прохода. Значение целиком получает сначала ASR, затем диаризация; `0` оставляет настройки библиотек. - `--verbose` показывает в консоли прогресс, число кластеров и интервалов, diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index bc9c849..7e23466 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -11,7 +11,7 @@ import typer from rich.console import Console from rich.status import Status -from .config import apply_device_defaults, load_config, resolve_defaults +from .config import ConfigValue, 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 .diarization import build_speaker_transcript @@ -261,9 +261,9 @@ def main( min=0, help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)", ), - diarize: bool = typer.Option( - False, - "--diarize", + diarize: bool | None = typer.Option( + None, + "--diarize/--no-diarize", help="Разделить транскрипт на реплики говорящих", ), speakers: int | None = typer.Option( @@ -328,6 +328,13 @@ def main( ) raise SystemExit(2) + if diarize is False and speakers is not None: + console.print( + "--no-diarize и --speakers несовместимы.", + style="red bold", + ) + raise SystemExit(2) + try: config = load_config() cli_values = { @@ -335,8 +342,10 @@ def main( "language": language, "device": device, "compute_type": compute_type, + "diarize": diarize, } defaults = resolve_defaults(cli_values, config) + diarize_enabled = defaults["diarize"] is True or speakers is not None resolved_device = detect_device(defaults["device"]) defaults = apply_device_defaults(defaults, resolved_device, cli_values, config) @@ -363,7 +372,7 @@ def main( force, ct_explicit, cpu_threads=threads, - diarize=diarize or speakers is not None, + diarize=diarize_enabled, speakers=speakers, ) else: @@ -374,7 +383,7 @@ def main( verbose, ct_explicit, cpu_threads=threads, - diarize=diarize or speakers is not None, + diarize=diarize_enabled, speakers=speakers, ) except KeyboardInterrupt: @@ -408,7 +417,7 @@ def main( def _run_single( file: Path, - defaults: dict[str, str], + defaults: dict[str, ConfigValue], output: Path | None, verbose: bool, compute_type_explicit: bool = False, @@ -552,7 +561,7 @@ def _run_single( def _run_batch( files: list[Path], - defaults: dict[str, str], + defaults: dict[str, ConfigValue], verbose: bool, force: bool, compute_type_explicit: bool = False, diff --git a/src/local_transcriber/config.py b/src/local_transcriber/config.py index 2b80b1b..e3dce72 100644 --- a/src/local_transcriber/config.py +++ b/src/local_transcriber/config.py @@ -4,11 +4,14 @@ import tomllib import warnings from pathlib import Path -HARDCODED_DEFAULTS: dict[str, str] = { +type ConfigValue = str | bool + +HARDCODED_DEFAULTS: dict[str, ConfigValue] = { "model": "medium", "language": "ru", "device": "auto", "compute_type": "float32", + "diarize": False, } DEVICE_DEFAULTS: dict[str, dict[str, str]] = { @@ -46,7 +49,7 @@ def find_config_file() -> Path | None: return None -def load_config(path: Path | None = None) -> dict[str, str]: +def load_config(path: Path | None = None) -> dict[str, ConfigValue]: """Загружает и валидирует TOML-конфиг. Неизвестные ключи вызывают предупреждение (а не ошибку) для forward @@ -70,11 +73,19 @@ def load_config(path: Path | None = None) -> dict[str, str]: stacklevel=2, ) - result: dict[str, str] = {} + result: dict[str, ConfigValue] = {} for key in _VALID_KEYS: if key not in data: continue value = data[key] + if key == "diarize": + if not isinstance(value, bool): + raise ValueError( + f"Значение 'diarize' в {path} должно быть логическим, " + f"получено {type(value).__name__}" + ) + result[key] = value + continue if not isinstance(value, str): raise ValueError( f"Значение '{key}' в {path} должно быть строкой, получено {type(value).__name__}" @@ -92,10 +103,10 @@ def load_config(path: Path | None = None) -> dict[str, str]: def resolve_defaults( - cli_values: dict[str, str | None], config: dict[str, str] -) -> dict[str, str]: + cli_values: dict[str, ConfigValue | None], config: dict[str, ConfigValue] +) -> dict[str, ConfigValue]: """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.""" - result: dict[str, str] = {} + result: dict[str, ConfigValue] = {} for key in HARDCODED_DEFAULTS: cli_val = cli_values.get(key) if cli_val is not None: @@ -108,11 +119,11 @@ def resolve_defaults( def apply_device_defaults( - defaults: dict[str, str], + defaults: dict[str, ConfigValue], resolved_device: str, - cli_values: dict[str, str | None], - config: dict[str, str], -) -> dict[str, str]: + cli_values: dict[str, ConfigValue | None], + config: dict[str, ConfigValue], +) -> dict[str, ConfigValue]: """Применяет device-aware дефолты для model и compute_type, если они не были явно заданы через CLI или конфиг.""" device_defs = DEVICE_DEFAULTS.get(resolved_device, {}) diff --git a/tests/test_cli.py b/tests/test_cli.py index e56cd51..add639e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -519,6 +519,27 @@ def test_cli_rejects_nonpositive_speaker_count(tmp_path): assert out.exit_code == 2 +def test_cli_rejects_no_diarize_with_speakers_before_model_load(tmp_path): + audio = tmp_path / "meeting.mp3" + audio.write_bytes(b"fake") + load_model = MagicMock() + + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch("local_transcriber.cli.load_model", load_model), + ): + out = runner.invoke( + app, + [str(audio), "--no-diarize", "--speakers", "2"], + ) + + assert out.exit_code == 2 + assert "--no-diarize и --speakers несовместимы" in out.output + load_model.assert_not_called() + + def test_cli_verbose_passes_on_segment_callback(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") @@ -917,7 +938,13 @@ def test_cli_batch_two_files(tmp_path): assert "2 обработано" in out.output -def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path): +@pytest.mark.parametrize( + ("config", "cli_args"), + [({}, ["--diarize"]), ({"diarize": True}, [])], +) +def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files( + tmp_path, config, cli_args +): first = tmp_path / "first.mp3" second = tmp_path / "second.mp3" first.write_bytes(b"fake") @@ -941,7 +968,7 @@ def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path): ) with ( - patch("local_transcriber.cli.load_config", return_value={}), + patch("local_transcriber.cli.load_config", return_value=config), patch( "local_transcriber.cli.validate_input_file", side_effect=lambda path: path, @@ -958,7 +985,7 @@ def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path): ) as load_diarizer, patch("local_transcriber.cli.write_transcript") as write, ): - out = runner.invoke(app, [str(first), str(second), "--diarize"]) + out = runner.invoke(app, [str(first), str(second), *cli_args]) assert out.exit_code == 0 load_diarizer.assert_called_once() @@ -1232,6 +1259,48 @@ def test_cli_config_applied(tmp_path): assert mock_load_model.call_args[0][0] == "tiny" +@pytest.mark.parametrize( + ("config_value", "cli_args", "expected_calls"), + [ + (True, [], 1), + (False, [], 0), + (False, ["--diarize"], 1), + (True, ["--no-diarize"], 0), + ], +) +def test_cli_resolves_diarization_priority( + tmp_path, config_value, cli_args, expected_calls +): + audio = tmp_path / "meeting.mp3" + audio.write_bytes(b"fake") + result = _make_result(segments=[]) + model = _make_model() + backend = _make_backend() + backend.word_timestamps_available = True + tfr = _make_tfr(result=result, model=model, backend=backend) + load_diarizer = MagicMock() + + with ( + patch( + "local_transcriber.cli.load_config", + return_value={"diarize": config_value}, + ), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + 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", return_value=tfr), + patch("local_transcriber.cli.load_speaker_diarizer", load_diarizer), + patch("local_transcriber.cli.write_transcript"), + ): + out = runner.invoke(app, [str(audio), *cli_args]) + + assert out.exit_code == 0 + assert load_diarizer.call_count == expected_calls + + def test_cli_config_overrides_auto_device(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") diff --git a/tests/test_config.py b/tests/test_config.py index 6e2162b..69fe8a2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -41,6 +41,23 @@ def test_load_config_valid(tmp_path): assert result == {"model": "small", "language": "ru"} +@pytest.mark.parametrize("value", [True, False]) +def test_load_config_accepts_diarize_boolean(tmp_path, value): + config = tmp_path / "config.toml" + config.write_text(f"diarize = {str(value).lower()}\n") + + assert load_config(config) == {"diarize": value} + + +@pytest.mark.parametrize("toml_value", ['"true"', "1"]) +def test_load_config_rejects_non_boolean_diarize(tmp_path, toml_value): + config = tmp_path / "config.toml" + config.write_text(f"diarize = {toml_value}\n") + + with pytest.raises(ValueError, match="должно быть логическим"): + load_config(config) + + def test_load_config_malformed(tmp_path): config = tmp_path / "config.toml" config.write_text("this is not valid toml [[[") @@ -100,9 +117,26 @@ def test_resolve_defaults_hardcoded_fallback(): "language": "ru", "device": "auto", "compute_type": "float32", + "diarize": False, } +@pytest.mark.parametrize( + ("cli_value", "config_value", "expected"), + [ + (None, True, True), + (None, False, False), + (True, False, True), + (False, True, False), + ], +) +def test_resolve_defaults_diarize_priority(cli_value, config_value, expected): + cli = {"diarize": cli_value} + config = {"diarize": config_value} + + assert resolve_defaults(cli, config)["diarize"] is expected + + def test_apply_device_defaults_cuda(): defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"} cli = {"model": None, "language": None, "device": None, "compute_type": None} -- 2.54.0 From 2e30d668ce53e40d50fed678deb239ab52729852 Mon Sep 17 00:00:00 2001 From: Dmitriy Dementiev Date: Wed, 2 Sep 2026 12:52:33 +0300 Subject: [PATCH 2/2] =?UTF-8?q?refactor(config):=20=D1=83=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B8=D0=BF=D1=8B=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=D0=BA=D0=B0=D0=B4=D0=B0=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - общий union допускал несоответствие между ключом настройки и его типом. - Что: - разделены типы TOML, CLI и полностью разрешённой конфигурации. - каскад значений сохранён в типобезопасной generic-функции. - Проверка: - mypy src/local_transcriber/config.py. - pytest tests/test_config.py tests/test_cli.py: 109 passed. --- src/local_transcriber/cli.py | 14 +++-- src/local_transcriber/config.py | 97 ++++++++++++++++++++++++++------- tests/test_config.py | 1 - 3 files changed, 86 insertions(+), 26 deletions(-) diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 7e23466..5a6a04a 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -11,7 +11,13 @@ import typer from rich.console import Console from rich.status import Status -from .config import ConfigValue, apply_device_defaults, load_config, resolve_defaults +from .config import ( + CliValues, + ResolvedConfig, + 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 .diarization import build_speaker_transcript @@ -337,7 +343,7 @@ def main( try: config = load_config() - cli_values = { + cli_values: CliValues = { "model": model, "language": language, "device": device, @@ -417,7 +423,7 @@ def main( def _run_single( file: Path, - defaults: dict[str, ConfigValue], + defaults: ResolvedConfig, output: Path | None, verbose: bool, compute_type_explicit: bool = False, @@ -561,7 +567,7 @@ def _run_single( def _run_batch( files: list[Path], - defaults: dict[str, ConfigValue], + defaults: ResolvedConfig, verbose: bool, force: bool, compute_type_explicit: bool = False, diff --git a/src/local_transcriber/config.py b/src/local_transcriber/config.py index e3dce72..0f36361 100644 --- a/src/local_transcriber/config.py +++ b/src/local_transcriber/config.py @@ -3,10 +3,43 @@ import tomllib import warnings from pathlib import Path +from typing import TypedDict, TypeVar, cast type ConfigValue = str | bool +_T = TypeVar("_T") -HARDCODED_DEFAULTS: dict[str, ConfigValue] = { + +class ConfigValues(TypedDict, total=False): + """Проверенные значения из TOML-конфига.""" + + model: str + language: str + device: str + compute_type: str + diarize: bool + + +class CliValues(TypedDict, total=False): + """Явные значения CLI до разрешения каскада.""" + + model: str | None + language: str | None + device: str | None + compute_type: str | None + diarize: bool | None + + +class ResolvedConfig(TypedDict): + """Полная конфигурация после разрешения каскада.""" + + model: str + language: str + device: str + compute_type: str + diarize: bool + + +HARDCODED_DEFAULTS: ResolvedConfig = { "model": "medium", "language": "ru", "device": "auto", @@ -49,7 +82,7 @@ def find_config_file() -> Path | None: return None -def load_config(path: Path | None = None) -> dict[str, ConfigValue]: +def load_config(path: Path | None = None) -> ConfigValues: """Загружает и валидирует TOML-конфиг. Неизвестные ключи вызывают предупреждение (а не ошибку) для forward @@ -99,31 +132,53 @@ def load_config(path: Path | None = None) -> dict[str, ConfigValue]: raise ValueError(f"Значение 'language' в {path} не может быть пустым") result[key] = value - return result + return cast(ConfigValues, result) -def resolve_defaults( - cli_values: dict[str, ConfigValue | None], config: dict[str, ConfigValue] -) -> dict[str, ConfigValue]: +def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig: """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.""" - result: dict[str, ConfigValue] = {} - for key in HARDCODED_DEFAULTS: - cli_val = cli_values.get(key) - if cli_val is not None: - result[key] = cli_val - elif key in config: - result[key] = config[key] - else: - result[key] = HARDCODED_DEFAULTS[key] - return result + return { + "model": _resolve_value( + cli_values.get("model"), config.get("model"), HARDCODED_DEFAULTS["model"] + ), + "language": _resolve_value( + cli_values.get("language"), + config.get("language"), + HARDCODED_DEFAULTS["language"], + ), + "device": _resolve_value( + cli_values.get("device"), + config.get("device"), + HARDCODED_DEFAULTS["device"], + ), + "compute_type": _resolve_value( + cli_values.get("compute_type"), + config.get("compute_type"), + HARDCODED_DEFAULTS["compute_type"], + ), + "diarize": _resolve_value( + cli_values.get("diarize"), + config.get("diarize"), + HARDCODED_DEFAULTS["diarize"], + ), + } + + +def _resolve_value(cli_value: _T | None, config_value: _T | None, default: _T) -> _T: + """Выбирает первое явно заданное значение каскада.""" + if cli_value is not None: + return cli_value + if config_value is not None: + return config_value + return default def apply_device_defaults( - defaults: dict[str, ConfigValue], + defaults: ResolvedConfig, resolved_device: str, - cli_values: dict[str, ConfigValue | None], - config: dict[str, ConfigValue], -) -> dict[str, ConfigValue]: + cli_values: CliValues, + config: ConfigValues, +) -> ResolvedConfig: """Применяет device-aware дефолты для model и compute_type, если они не были явно заданы через CLI или конфиг.""" device_defs = DEVICE_DEFAULTS.get(resolved_device, {}) @@ -135,4 +190,4 @@ def apply_device_defaults( if cli_values.get(key) is None and key not in config: if key in device_defs: result[key] = device_defs[key] - return result + return cast(ResolvedConfig, result) diff --git a/tests/test_config.py b/tests/test_config.py index 69fe8a2..2408a01 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,3 @@ -from pathlib import Path from unittest.mock import patch import pytest -- 2.54.0