feat(transcriber): добавлен module выполнения с владением моделью и исполнением

Transcriber принимает ExecutionRequest, разрешает auto и device-aware
умолчания, загружает модель один раз на запуск и держит fallback внутри.
Caller получает TranscribeResult и ExecutionInfo вместо model/backend/path.
Публичный transcribe() идёт через тот же module. Adapters отдают
runtime_info() для диагностики; legacy load_model/_transcribe_file пока
остаются для CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dmitry DementievandClaude Opus 5 committed 2026-09-21 23:46:54 +03:00
1 parent 343a31b35b
commit 4dea7f4bc6
10 files changed
+620 -29

No files matched your search

+4
View File
@@ -53,3 +53,7 @@ class Backend(Protocol):
) -> TranscribeResult:
"""Транскрибирует файл, возвращает результат."""
...
def runtime_info(self) -> dict[str, str]:
"""Версии runtime и фактическая конфигурация для удалённой диагностики."""
...
@@ -16,6 +16,7 @@ from local_transcriber.types import (
Word,
WordTimestampsUnavailableError,
)
from local_transcriber.utils import package_version
MODEL_REPOS = {
"tiny": "Systran/faster-whisper-tiny",
@@ -157,6 +158,22 @@ class FasterWhisperBackend:
words=words,
)
def runtime_info(self) -> dict[str, str]:
"""Версии CTranslate2/faster-whisper и число видимых CUDA-устройств."""
import ctranslate2
try:
cuda_devices = str(ctranslate2.get_cuda_device_count())
except Exception as exc: # noqa: BLE001 — диагностика не должна ронять запуск
cuda_devices = f"недоступно ({exc})"
return {
"engine": "faster-whisper",
"faster_whisper": package_version("faster-whisper"),
"ctranslate2": package_version("ctranslate2"),
"cuda_devices": cuda_devices,
"compute_type": self.actual_compute_type or "",
}
def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
if on_status is not None:
+27 -2
View File
@@ -15,6 +15,7 @@ from local_transcriber.types import (
Word,
WordTimestampsUnavailableError,
)
from local_transcriber.utils import package_version
@dataclass(frozen=True)
@@ -138,6 +139,8 @@ class OnnxAsrBackend:
self._model_name: str | None = None
self._model_spec: OnnxModelSpec | None = None
self._vad: Any = None
self._providers: list[str] = []
self._quantization: str | None = None
@property
def word_timestamps_available(self) -> bool:
@@ -197,15 +200,37 @@ class OnnxAsrBackend:
actual_compute_type = self.actual_compute_type or compute_type
quantization = _normalize_quantization(actual_compute_type)
providers = ["CPUExecutionProvider"]
model = onnx_asr.load_model(
model=model_path,
quantization=quantization,
providers=["CPUExecutionProvider"],
providers=providers,
)
vad = onnx_asr.load_vad("silero", providers=["CPUExecutionProvider"])
vad = onnx_asr.load_vad("silero", providers=providers)
self._vad = vad
self._providers = providers
self._quantization = quantization
return model.with_vad(vad).with_timestamps()
def runtime_info(self) -> dict[str, str]:
"""Версии ONNX Runtime/onnx-asr и providers, заданные сессиям ASR и VAD.
Доступность provider в wheel не означает, что граф выполняется на нём:
сессии получают только явно заданный список.
"""
import onnxruntime
configured = ", ".join(self._providers)
return {
"engine": "onnx-asr",
"onnxruntime": package_version("onnxruntime"),
"onnx_asr": package_version("onnx-asr"),
"available_providers": ", ".join(onnxruntime.get_available_providers()),
"asr_providers": configured,
"vad_providers": configured,
"quantization": self._quantization or "float32",
}
def transcribe(
self,
model: Any,
@@ -19,6 +19,7 @@ from local_transcriber.types import (
Word,
WordTimestampsUnavailableError,
)
from local_transcriber.utils import package_version
# (model_alias, compute_type) → HF repo
MODEL_REPOS: dict[tuple[str, str], str] = {
@@ -203,6 +204,18 @@ class OpenVINOBackend:
words=words,
)
def runtime_info(self) -> dict[str, str]:
"""Версия OpenVINO, доступные устройства и выбранное фактически."""
from openvino import Core
return {
"engine": "openvino",
"openvino": package_version("openvino"),
"available_devices": ", ".join(Core().available_devices),
"device": self.actual_ov_device or "",
"compute_type": self.actual_compute_type or "",
}
def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
"""Находит HF repo для пары (model, compute_type) с fallback.
+229 -27
View File
@@ -2,18 +2,230 @@
import warnings
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from local_transcriber.backends import get_backend
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
# Re-export из types.py для обратной совместимости
from local_transcriber.types import ( # noqa: F401
from local_transcriber.types import (
Segment,
TranscribeFileResult,
TranscribeResult,
WordTimestampsUnavailableError,
)
from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name
# Движок распознавания для каждого значения device; остальное, как и в
# get_backend, уходит в faster-whisper. Способ исполнения (CPU/GPU) остаётся
# в самом значении device и трактуется adapter'ом.
_ENGINES: dict[str, str] = {
"cpu": "faster-whisper",
"cuda": "faster-whisper",
"openvino-cpu": "openvino",
"openvino-gpu": "openvino",
"onnx": "onnx-asr",
}
@dataclass(frozen=True)
class ExecutionRequest:
"""Запрошенные настройки запуска до разрешения умолчаний.
``None`` в model/compute_type означает device-aware умолчание;
``strict_device=None`` — strict для любого явного device, кроме ``auto``.
"""
device: str = "auto"
model: str | None = None
compute_type: str | None = None
language: str | None = None
cpu_threads: int = 0
strict_device: bool | None = None
require_word_timestamps: bool = False
@dataclass(frozen=True)
class ExecutionInfo:
"""Сведения о выполнении: что запрошено, что выбрано и чем выполняется."""
requested_device: str
resolved_device: str # выбор до загрузки; отличается от device после fallback
device: str
engine: str
model: str
compute_type: str
cpu_threads: int
word_timestamps_available: bool
description: str # строка исполнения для шапки транскрипта
runtime: dict[str, str] = field(default_factory=dict)
class Transcriber:
"""Module выполнения распознавания на один запуск.
Владеет загруженной моделью, adapter'ом, фактическими настройками
и разрешёнными переходами исполнения. Caller передаёт файлы по одному
и получает результат; состояние между файлами не переносит.
"""
def __init__(self, request: ExecutionRequest) -> None:
self._request = request
self._strict = (
request.strict_device
if request.strict_device is not None
else request.device != "auto"
)
self._compute_type_explicit = request.compute_type is not None
resolved = detect_device(request.device)
defaults = DEVICE_DEFAULTS.get(resolved, {})
self._model_name = request.model or defaults.get(
"model", HARDCODED_DEFAULTS["model"]
)
self._compute_type = request.compute_type or defaults.get(
"compute_type", HARDCODED_DEFAULTS["compute_type"]
)
self._resolved_device = resolved
self._device = resolved
self._backend: Any = None
self._model: Any = None
self._info: ExecutionInfo | None = None
@property
def execution(self) -> ExecutionInfo:
"""Текущие сведения о выполнении; до prepare() — без загрузки."""
if self._info is None:
raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()")
return self._info
def prepare(self, on_status: Callable[[str], None] | None = None) -> ExecutionInfo:
"""Загружает модель и проверяет возможности до первого файла. Идемпотентно."""
if self._info is not None:
return self._info
try:
self._load(self._device, on_status)
except (RuntimeError, ValueError) as exc:
if not self._may_fall_back(exc):
raise
warnings.warn(
f"Не удалось загрузить модель на {self._device}: {exc}. "
"Переключение на CPU.",
stacklevel=2,
)
self._load("cpu", on_status)
if (
self._request.require_word_timestamps
and not self._info.word_timestamps_available
):
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
return self._info
def transcribe(
self,
file_path: Path,
on_segment: Callable[[Segment], None] | None = None,
on_status: Callable[[str], None] | None = None,
) -> TranscribeResult:
"""Транскрибирует один файл; при разрешённом fallback перезагружает модель."""
self.prepare(on_status)
lang_arg = self._request.language
if lang_arg == "auto":
lang_arg = None
try:
result = self._recognize(file_path, lang_arg, on_segment, on_status)
except (RuntimeError, ValueError) as exc:
if not self._may_fall_back(exc):
raise
warnings.warn(
f"Ошибка при транскрипции на {self._device}: {exc}. "
"Переключение на CPU и повтор.",
stacklevel=2,
)
self._load("cpu", on_status)
result = self._recognize(file_path, lang_arg, on_segment, on_status)
return result
def _recognize(
self,
file_path: Path,
language: str | None,
on_segment: Callable[[Segment], None] | None,
on_status: Callable[[str], None] | None,
) -> TranscribeResult:
_notify_status(on_status, "Транскрибирую...")
result = self._backend.transcribe(
self._model, file_path, language, on_segment, on_status
)
result.device_used = self._device
return result
def _may_fall_back(self, exc: BaseException) -> bool:
"""Разрешённый переход: не strict, не CPU и ошибка самого движка."""
if self._strict or self._device == "cpu":
return False
return _is_backend_error(exc, self._device)
def _load(self, device: str, on_status: Callable[[str], None] | None) -> None:
backend = get_backend(device, compute_type_explicit=self._compute_type_explicit)
model_path = backend.ensure_model_available(
self._model_name, self._compute_type, on_status
)
_notify_status(on_status, f"Инициализирую модель на {device}...")
model = backend.create_model(
model_path,
device,
self._compute_type,
cpu_threads=self._request.cpu_threads,
)
self._backend = backend
self._model = model
self._device = _refine_openvino_device(device, backend)
self._info = self._describe()
def _describe(self) -> ExecutionInfo:
backend = self._backend
compute_type = (
getattr(backend, "actual_compute_type", None) or self._compute_type
)
return ExecutionInfo(
requested_device=self._request.device,
resolved_device=self._resolved_device,
device=self._device,
engine=_ENGINES.get(self._device, "faster-whisper"),
model=self._model_name,
compute_type=compute_type,
cpu_threads=self._request.cpu_threads,
word_timestamps_available=bool(backend.word_timestamps_available),
description=_describe_device(self._device),
runtime=dict(backend.runtime_info()),
)
def _refine_openvino_device(device: str, backend: Any) -> str:
"""Заменяет запрошенный openvino-* на устройство, которое реально выбрал OpenVINO."""
ov_dev = getattr(backend, "actual_ov_device", None)
if ov_dev == "GPU" and device != "openvino-gpu":
return "openvino-gpu"
if ov_dev == "CPU" and device.startswith("openvino") and device != "openvino-cpu":
return "openvino-cpu"
return device
def _describe_device(device: str) -> str:
"""Строка исполнения для шапки транскрипта; GPU называется только по данным драйвера."""
if device == "cuda":
return f"CUDA ({get_gpu_name() or 'Unknown GPU'})"
if device == "openvino-gpu":
return f"OpenVINO ({get_intel_gpu_name() or 'Intel GPU'})"
if device == "openvino-cpu":
return "OpenVINO (CPU)"
if device == "onnx":
return "ONNX (CPU)"
return "CPU"
def load_model(
@@ -132,40 +344,30 @@ def _transcribe_file(
def transcribe(
file_path: Path,
model_name: str = "large-v3",
model_name: str | None = None,
device: str = "auto",
compute_type: str = "int8",
compute_type: str | None = None,
language: str | None = None,
on_segment: Callable[[Segment], None] | None = None,
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
cpu_threads: int = 0,
) -> TranscribeResult:
"""High-level API: загрузка модели + транскрипция за один вызов."""
model, actual_device, backend, model_path = load_model(
model_name,
device,
compute_type,
on_status,
strict_device,
compute_type_explicit=True, # Python API — caller explicitly chose compute_type
cpu_threads=cpu_threads,
"""Публичный путь одного файла через тот же module выполнения, что и CLI.
``None`` в model_name/compute_type берёт умолчания выбранного исполнения.
"""
run = Transcriber(
ExecutionRequest(
device=device,
model=model_name,
compute_type=compute_type,
language=language,
cpu_threads=cpu_threads,
strict_device=strict_device,
)
)
tfr = _transcribe_file(
model,
actual_device,
backend,
model_path,
file_path,
model_name,
compute_type,
language,
on_segment,
on_status,
strict_device,
cpu_threads=cpu_threads,
)
return tfr.result
return run.transcribe(file_path, on_segment, on_status)
def ensure_model_available(
+9
View File
@@ -4,6 +4,7 @@ import glob
import platform
import subprocess
import warnings
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
SUPPORTED_EXTENSIONS = {
@@ -141,3 +142,11 @@ def expand_globs(paths: list[Path]) -> list[Path]:
def has_existing_transcript(input_path: Path) -> bool:
"""Проверяет наличие транскрипта для skip-логики батч-режима."""
return build_output_path(input_path).exists()
def package_version(name: str) -> str:
"""Версия установленного пакета для диагностики; отсутствие не считается ошибкой."""
try:
return version(name)
except PackageNotFoundError:
return "не установлен"