From ee9e017083a817607a0f4837140558cc037775dc Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 21 Mar 2026 23:30:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(transcriber):=20cross-backend=20fallback?= =?UTF-8?q?=20openvino=20=E2=86=92=20cpu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - если OpenVINO не справляется (ошибка init или mid-stream), нужен автоматический переход на CPU через faster-whisper. - Что: - _is_openvino_error() распознаёт ошибки OpenVINO runtime по паттернам в сообщении. - _is_backend_error() теперь маршрутизирует на правильную проверку по device. - при fallback: скачивается модель для CPU бэкенда, создаётся новая модель, обновляются backend/model_path. - 3 интеграционных теста: openvino→cpu init fallback, midstream fallback, strict_device без fallback. - Проверка: - uv run pytest -v — 122 passed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/local_transcriber/transcriber.py | 10 +++- tests/test_transcriber.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/local_transcriber/transcriber.py b/src/local_transcriber/transcriber.py index 8e51de5..1561707 100644 --- a/src/local_transcriber/transcriber.py +++ b/src/local_transcriber/transcriber.py @@ -148,11 +148,17 @@ def _is_backend_error(exc: BaseException, device: str) -> bool: """Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными).""" if device in ("cuda", "cpu"): return _is_cuda_error(exc) - # openvino и другие бэкенды: конкретные паттерны ошибок добавим - # при реализации бэкенда; пока — не маскируем ошибки + if device == "openvino": + return _is_openvino_error(exc) return False +def _is_openvino_error(exc: BaseException) -> bool: + """Проверка ошибок OpenVINO runtime.""" + msg = str(exc).lower() + return any(k in msg for k in ("openvino", "ov_", "inference_engine")) + + def _notify_status(on_status: Callable[[str], None] | None, message: str) -> None: if on_status is not None: on_status(message) diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index edb569f..ecfb96d 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -440,3 +440,75 @@ def test_ensure_model_available_rejects_incomplete_local_directory(tmp_path): with pytest.raises(ValueError, match="Неполная локальная модель"): ensure_model_available(str(model_dir)) + + +# === Cross-backend fallback (openvino → cpu) === + + +@patch("local_transcriber.transcriber.get_backend") +def test_load_model_openvino_fallback_to_cpu(mock_get_backend): + """OpenVINO ошибка при init → fallback на CPU (FasterWhisper).""" + ov_backend = _make_backend( + create_model_error=RuntimeError("OpenVINO model load failed"), + ) + cpu_model = MagicMock() + cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model") + + def backend_for_device(device, **kwargs): + return ov_backend if device == "openvino" else cpu_backend + + mock_get_backend.side_effect = backend_for_device + + with pytest.warns(UserWarning, match="Переключение на CPU"): + model, actual_device, backend, model_path = load_model( + "medium", "openvino", "int8", + ) + + assert actual_device == "cpu" + assert model is cpu_model + assert backend is cpu_backend + assert model_path == "/mock/cpu/model" + + +@patch("local_transcriber.transcriber.get_backend") +def test_transcribe_file_openvino_midstream_fallback(mock_get_backend): + """OpenVINO ошибка при транскрипции → fallback на CPU.""" + ov_backend = _make_backend( + transcribe_error=RuntimeError("OpenVINO inference error"), + ) + cpu_backend = _make_backend( + transcribe_result=_make_result(count=2, device_used="cpu"), + model_path="/mock/cpu/model", + ) + + def backend_for_device(device, **kwargs): + return ov_backend if device == "openvino" else cpu_backend + + mock_get_backend.side_effect = backend_for_device + + with pytest.warns(UserWarning, match="Переключение на CPU"): + tfr = _transcribe_file( + model=MagicMock(), + actual_device="openvino", + backend=ov_backend, + model_path="/mock/ov/model", + file_path=Path("test.mp3"), + model_name="medium", + compute_type="int8", + ) + + assert tfr.actual_device == "cpu" + assert tfr.backend is cpu_backend + assert tfr.model_path == "/mock/cpu/model" + + +@patch("local_transcriber.transcriber.get_backend") +def test_openvino_strict_device_no_fallback(mock_get_backend): + """strict_device=True + OpenVINO ошибка → raise.""" + backend = _make_backend( + create_model_error=RuntimeError("OpenVINO model load failed"), + ) + mock_get_backend.return_value = backend + + with pytest.raises(RuntimeError, match="OpenVINO"): + load_model("medium", "openvino", "int8", strict_device=True)