Fix thread safety, implement prompt, fix resource leaks, add history rotation
- Add threading.Lock around asr_pipeline calls (thread-safe inference on shared GPU) - Implement prompt parameter: propagate through process_file → transcribe → generate_kwargs - Fix prompt_ids device mismatch (.to(self.device) for CUDA) - Replace mkdtemp() with create_temp_file() in sources.py (no more leaked /tmp dirs) - Add max_history_days (default 30) with automatic cleanup of old history directories - Add intentional comments for SSRF and /config decisions (internal service) - Lower normalize_audio log level from INFO to DEBUG Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2138651474
commit
1e778cae86
+11
-23
@@ -38,7 +38,7 @@ class AudioProcessor:
|
|||||||
|
|
||||||
def convert_to_wav(self, input_path: str) -> str:
|
def convert_to_wav(self, input_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
Конвертация входного аудиофайла в WAV формат с частотой дискретизации 16 кГц.
|
Конвертация входного аудиофайла в WAV формат с частотой дискретизации 16 кГц (моно).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_path: Путь к исходному аудиофайлу.
|
input_path: Путь к исходному аудиофайлу.
|
||||||
@@ -51,18 +51,6 @@ class AudioProcessor:
|
|||||||
"""
|
"""
|
||||||
audio_rate = self.config["audio_rate"]
|
audio_rate = self.config["audio_rate"]
|
||||||
|
|
||||||
# Проверка расширения файла
|
|
||||||
if input_path.lower().endswith('.wav'):
|
|
||||||
# Проверяем, нужно ли преобразовывать WAV-файл (например, если частота не 16 кГц)
|
|
||||||
try:
|
|
||||||
info = subprocess.check_output(['soxi', input_path]).decode()
|
|
||||||
if f'{audio_rate} Hz' in info:
|
|
||||||
logger.info(f"Файл {input_path} уже в формате WAV с частотой {audio_rate} Гц")
|
|
||||||
return input_path
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
logger.warning(f"Не удалось получить информацию о WAV-файле {input_path}")
|
|
||||||
# Продолжаем конвертацию, чтобы быть уверенными в формате
|
|
||||||
|
|
||||||
# Создаем временный файл для WAV
|
# Создаем временный файл для WAV
|
||||||
output_path = create_temp_file(".wav")
|
output_path = create_temp_file(".wav")
|
||||||
|
|
||||||
@@ -77,14 +65,14 @@ class AudioProcessor:
|
|||||||
output_path
|
output_path
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.debug(f"Конвертация в WAV: {' '.join(cmd)}")
|
logger.debug("Конвертация в WAV: %s", " ".join(cmd))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
logger.info(f"Файл конвертирован в WAV: {output_path}")
|
logger.info("Файл конвертирован в WAV: %s", output_path)
|
||||||
return output_path
|
return output_path
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error(f"Ошибка при конвертации в WAV: {e.stderr.decode()}")
|
logger.error("Ошибка при конвертации в WAV: %s", e.stderr.decode())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def normalize_audio(self, input_path: str) -> str:
|
def normalize_audio(self, input_path: str) -> str:
|
||||||
@@ -112,14 +100,14 @@ class AudioProcessor:
|
|||||||
"compand"
|
"compand"
|
||||||
] + self.compand_params.split()
|
] + self.compand_params.split()
|
||||||
|
|
||||||
logger.info(f"Нормализация аудио: {' '.join(cmd)}")
|
logger.debug("Нормализация аудио: %s", " ".join(cmd))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
logger.info(f"Аудио нормализовано: {output_path}")
|
logger.info("Аудио нормализовано: %s", output_path)
|
||||||
return output_path
|
return output_path
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error(f"Ошибка при нормализации аудио: {e.stderr.decode()}")
|
logger.error("Ошибка при нормализации аудио: %s", e.stderr.decode())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def add_silence(self, input_path: str) -> str:
|
def add_silence(self, input_path: str) -> str:
|
||||||
@@ -146,14 +134,14 @@ class AudioProcessor:
|
|||||||
"pad", "2.0", "1.0" # Добавление тишины в начале и в конце (секунды)
|
"pad", "2.0", "1.0" # Добавление тишины в начале и в конце (секунды)
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info(f"Добавление тишины: {' '.join(cmd)}")
|
logger.info("Добавление тишины: %s", " ".join(cmd))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
logger.info(f"Тишина добавлена: {output_path}")
|
logger.info("Тишина добавлена: %s", output_path)
|
||||||
return output_path
|
return output_path
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error(f"Ошибка при добавлении тишины: {e.stderr.decode()}")
|
logger.error("Ошибка при добавлении тишины: %s", e.stderr.decode())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def process_audio(self, input_path: str) -> Tuple[str, list]:
|
def process_audio(self, input_path: str) -> Tuple[str, list]:
|
||||||
@@ -188,6 +176,6 @@ class AudioProcessor:
|
|||||||
return silence_path, temp_files
|
return silence_path, temp_files
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при обработке аудио {input_path}: {e}")
|
logger.error("Ошибка при обработке аудио %s: %s", input_path, e)
|
||||||
cleanup_temp_files(temp_files)
|
cleanup_temp_files(temp_files)
|
||||||
raise
|
raise
|
||||||
+22
-16
@@ -4,8 +4,6 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
import tempfile
|
|
||||||
import base64
|
import base64
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
import magic
|
import magic
|
||||||
@@ -13,6 +11,8 @@ import requests
|
|||||||
from typing import Tuple, Optional
|
from typing import Tuple, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from ..infrastructure.storage import create_temp_file
|
||||||
|
|
||||||
logger = logging.getLogger('app.audio_sources')
|
logger = logging.getLogger('app.audio_sources')
|
||||||
|
|
||||||
|
|
||||||
@@ -23,12 +23,6 @@ def _check_size(size_bytes: int, max_size_mb: int) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _make_temp_path(suffix: str = ".wav") -> str:
|
|
||||||
"""Создаёт путь для временного файла."""
|
|
||||||
temp_dir = tempfile.mkdtemp()
|
|
||||||
return os.path.join(temp_dir, f"{uuid.uuid4()}{suffix}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_uploaded_file(request_files, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
def get_uploaded_file(request_files, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Получает аудиофайл из загруженных файлов Flask.
|
Получает аудиофайл из загруженных файлов Flask.
|
||||||
@@ -54,7 +48,7 @@ def get_uploaded_file(request_files, max_file_size_mb: int = 100) -> Tuple[Optio
|
|||||||
return None, None, error
|
return None, None, error
|
||||||
|
|
||||||
# Сохраняем во временный файл
|
# Сохраняем во временный файл
|
||||||
temp_path = _make_temp_path()
|
temp_path = create_temp_file()
|
||||||
file.save(temp_path)
|
file.save(temp_path)
|
||||||
|
|
||||||
return temp_path, file.filename, None
|
return temp_path, file.filename, None
|
||||||
@@ -72,6 +66,8 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str],
|
|||||||
if parsed.scheme not in ('http', 'https'):
|
if parsed.scheme not in ('http', 'https'):
|
||||||
return None, None, f"Unsupported URL scheme: {parsed.scheme}. Only http/https allowed"
|
return None, None, f"Unsupported URL scheme: {parsed.scheme}. Only http/https allowed"
|
||||||
|
|
||||||
|
# SSRF-защита (валидация private IP) не добавлена сознательно:
|
||||||
|
# сервис работает во внутренней сети и недоступен извне
|
||||||
response = requests.get(url, stream=True, timeout=30)
|
response = requests.get(url, stream=True, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -92,15 +88,23 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str],
|
|||||||
if url_path:
|
if url_path:
|
||||||
original_name = os.path.basename(url_path)
|
original_name = os.path.basename(url_path)
|
||||||
|
|
||||||
temp_path = _make_temp_path()
|
temp_path = create_temp_file()
|
||||||
|
max_bytes = max_file_size_mb * 1024 * 1024
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
with open(temp_path, 'wb') as f:
|
with open(temp_path, 'wb') as f:
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
total_size += len(chunk)
|
||||||
|
if total_size > max_bytes:
|
||||||
|
f.close()
|
||||||
|
os.remove(temp_path)
|
||||||
|
return None, None, f"File exceeds maximum size of {max_file_size_mb}MB"
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
return temp_path, original_name or os.path.basename(temp_path), None
|
return temp_path, original_name or os.path.basename(temp_path), None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении файла по URL {url}: {e}")
|
logger.error("Ошибка при получении файла по URL %s: %s", url, e)
|
||||||
return None, None, f"Error retrieving file from URL: {str(e)}"
|
return None, None, f"Error retrieving file from URL: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
@@ -112,12 +116,14 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti
|
|||||||
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
audio_data = base64.b64decode(base64_data)
|
# Оценка размера до декодирования: base64 кодирует 3 байта в 4 символа
|
||||||
|
estimated_size = len(base64_data) * 3 // 4
|
||||||
error = _check_size(len(audio_data), max_file_size_mb)
|
error = _check_size(estimated_size, max_file_size_mb)
|
||||||
if error:
|
if error:
|
||||||
return None, None, error
|
return None, None, error
|
||||||
|
|
||||||
|
audio_data = base64.b64decode(base64_data)
|
||||||
|
|
||||||
# Определяем формат по содержимому
|
# Определяем формат по содержимому
|
||||||
mime_to_ext = {
|
mime_to_ext = {
|
||||||
"audio/mpeg": ".mp3",
|
"audio/mpeg": ".mp3",
|
||||||
@@ -131,12 +137,12 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti
|
|||||||
detected_mime = magic.from_buffer(audio_data[:1024], mime=True)
|
detected_mime = magic.from_buffer(audio_data[:1024], mime=True)
|
||||||
suffix = mime_to_ext.get(detected_mime, ".wav")
|
suffix = mime_to_ext.get(detected_mime, ".wav")
|
||||||
|
|
||||||
temp_path = _make_temp_path(suffix)
|
temp_path = create_temp_file(suffix)
|
||||||
with open(temp_path, 'wb') as f:
|
with open(temp_path, 'wb') as f:
|
||||||
f.write(audio_data)
|
f.write(audio_data)
|
||||||
|
|
||||||
return temp_path, os.path.basename(temp_path), None
|
return temp_path, os.path.basename(temp_path), None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при декодировании base64 данных: {e}")
|
logger.error("Ошибка при декодировании base64 данных: %s", e)
|
||||||
return None, None, f"Error decoding base64 data: {str(e)}"
|
return None, None, f"Error decoding base64 data: {str(e)}"
|
||||||
|
|||||||
+74
-31
@@ -7,6 +7,7 @@ OpenAI для транскрибации аудиофайлов в текст.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Dict, Tuple, Union
|
from typing import Dict, Tuple, Union
|
||||||
import logging
|
import logging
|
||||||
@@ -63,8 +64,10 @@ class WhisperTranscriber:
|
|||||||
self.return_timestamps = config["return_timestamps"]
|
self.return_timestamps = config["return_timestamps"]
|
||||||
self.temperature = config["temperature"]
|
self.temperature = config["temperature"]
|
||||||
|
|
||||||
# Оптимальный тип для тензоров
|
# Lock для потокобезопасного доступа к модели —
|
||||||
self.torch_dtype = torch.bfloat16
|
# Waitress обслуживает запросы в нескольких потоках,
|
||||||
|
# а HuggingFace pipeline не является thread-safe
|
||||||
|
self._inference_lock = threading.Lock()
|
||||||
|
|
||||||
# Создаем объект для обработки аудио
|
# Создаем объект для обработки аудио
|
||||||
self.audio_processor = AudioProcessor(config)
|
self.audio_processor = AudioProcessor(config)
|
||||||
@@ -72,6 +75,9 @@ class WhisperTranscriber:
|
|||||||
# Определяем устройство для вычислений
|
# Определяем устройство для вычислений
|
||||||
self.device = self._get_device()
|
self.device = self._get_device()
|
||||||
|
|
||||||
|
# Оптимальный тип для тензоров (зависит от устройства)
|
||||||
|
self.torch_dtype = self._get_torch_dtype()
|
||||||
|
|
||||||
# Загружаем модель при инициализации
|
# Загружаем модель при инициализации
|
||||||
self._load_model()
|
self._load_model()
|
||||||
|
|
||||||
@@ -88,26 +94,40 @@ class WhisperTranscriber:
|
|||||||
|
|
||||||
# Проверяем, что device_id является целым числом
|
# Проверяем, что device_id является целым числом
|
||||||
if not isinstance(device_id, int):
|
if not isinstance(device_id, int):
|
||||||
logger.warning(f"device_id должен быть целым числом, получено: {device_id}. Используем значение по умолчанию 0")
|
logger.warning("device_id должен быть целым числом, получено: %s. Используем значение по умолчанию 0", device_id)
|
||||||
device_id = 0
|
device_id = 0
|
||||||
|
|
||||||
# Проверяем, доступен ли запрошенный GPU
|
# Проверяем, доступен ли запрошенный GPU
|
||||||
device_count = torch.cuda.device_count()
|
device_count = torch.cuda.device_count()
|
||||||
if device_id >= device_count:
|
if device_id >= device_count:
|
||||||
logger.warning(f"Запрошенный GPU с индексом {device_id} недоступен. Доступно GPU: {device_count}. Используем GPU с индексом 0")
|
logger.warning("Запрошенный GPU с индексом %s недоступен. Доступно GPU: %s. Используем GPU с индексом 0", device_id, device_count)
|
||||||
device_id = 0
|
device_id = 0
|
||||||
|
|
||||||
logger.info(f"Используется CUDA GPU с индексом {device_id} для вычислений")
|
logger.info("Используется CUDA GPU с индексом %s для вычислений", device_id)
|
||||||
return torch.device(f"cuda:{device_id}")
|
return torch.device(f"cuda:{device_id}")
|
||||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
logger.info("Используется MPS (Apple Silicon) для вычислений")
|
logger.info("Используется MPS (Apple Silicon) для вычислений")
|
||||||
# Обходное решение для MPS
|
# Обходное решение для MPS: PyTorch проверяет is_initialized()
|
||||||
|
# при создании тензоров на MPS-устройстве, что вызывает ошибку
|
||||||
|
# в однопроцессном режиме.
|
||||||
|
# TODO: Удалить после обновления до PyTorch >= 2.5
|
||||||
setattr(torch.distributed, "is_initialized", lambda: False)
|
setattr(torch.distributed, "is_initialized", lambda: False)
|
||||||
return torch.device("mps")
|
return torch.device("mps")
|
||||||
else:
|
else:
|
||||||
logger.info("Используется CPU для вычислений")
|
logger.info("Используется CPU для вычислений")
|
||||||
return torch.device("cpu")
|
return torch.device("cpu")
|
||||||
|
|
||||||
|
def _get_torch_dtype(self) -> torch.dtype:
|
||||||
|
"""
|
||||||
|
Определяет оптимальный тип данных для тензоров в зависимости от устройства.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
torch.float32 для CPU, torch.bfloat16 для GPU/MPS.
|
||||||
|
"""
|
||||||
|
if self.device.type == "cpu":
|
||||||
|
return torch.float32
|
||||||
|
return torch.bfloat16
|
||||||
|
|
||||||
def _load_model(self) -> None:
|
def _load_model(self) -> None:
|
||||||
"""
|
"""
|
||||||
Загрузка модели и процессора.
|
Загрузка модели и процессора.
|
||||||
@@ -115,7 +135,7 @@ class WhisperTranscriber:
|
|||||||
Raises:
|
Raises:
|
||||||
Exception: Если не удалось загрузить модель.
|
Exception: Если не удалось загрузить модель.
|
||||||
"""
|
"""
|
||||||
logger.info(f"Загрузка модели из {self.model_path}")
|
logger.info("Загрузка модели из %s", self.model_path)
|
||||||
|
|
||||||
model_kwargs = dict(
|
model_kwargs = dict(
|
||||||
torch_dtype=self.torch_dtype,
|
torch_dtype=self.torch_dtype,
|
||||||
@@ -132,7 +152,7 @@ class WhisperTranscriber:
|
|||||||
if self.device.type == "cuda":
|
if self.device.type == "cuda":
|
||||||
logger.info("Используется Flash Attention 2")
|
logger.info("Используется Flash Attention 2")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Не удалось загрузить модель с Flash Attention: {e}")
|
logger.warning("Не удалось загрузить модель с Flash Attention: %s", e)
|
||||||
model_kwargs.pop("attn_implementation", None)
|
model_kwargs.pop("attn_implementation", None)
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(
|
self.model = WhisperForConditionalGeneration.from_pretrained(
|
||||||
self.model_path, **model_kwargs
|
self.model_path, **model_kwargs
|
||||||
@@ -154,13 +174,18 @@ class WhisperTranscriber:
|
|||||||
|
|
||||||
logger.info("Модель успешно загружена и готова к использованию")
|
logger.info("Модель успешно загружена и готова к использованию")
|
||||||
|
|
||||||
def transcribe(self, audio_path: str, return_timestamps: bool = None) -> Union[str, Dict]:
|
def transcribe(self, audio_path: str, return_timestamps: bool = None,
|
||||||
|
language: str = None, temperature: float = None,
|
||||||
|
prompt: str = None) -> Union[str, Dict]:
|
||||||
"""
|
"""
|
||||||
Транскрибация аудиофайла.
|
Транскрибация аудиофайла.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_path: Путь к обработанному аудиофайлу.
|
audio_path: Путь к обработанному аудиофайлу.
|
||||||
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
|
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
|
||||||
|
language: Язык распознавания. Если None — берётся из конфига.
|
||||||
|
temperature: Параметр температуры для генерации. Если None — берётся из конфига.
|
||||||
|
prompt: Текстовая подсказка для модели (имена, термины, контекст).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
В зависимости от параметра return_timestamps:
|
В зависимости от параметра return_timestamps:
|
||||||
@@ -169,28 +194,39 @@ class WhisperTranscriber:
|
|||||||
"""
|
"""
|
||||||
if return_timestamps is None:
|
if return_timestamps is None:
|
||||||
return_timestamps = self.return_timestamps
|
return_timestamps = self.return_timestamps
|
||||||
|
if language is None:
|
||||||
|
language = self.language
|
||||||
|
if temperature is None:
|
||||||
|
temperature = self.temperature
|
||||||
|
|
||||||
logger.info(f"Начало транскрибации файла: {audio_path}")
|
logger.info("Начало транскрибации файла: %s", audio_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Загрузка аудио в формате numpy array
|
# Загрузка аудио в формате numpy array
|
||||||
audio_array, sampling_rate = load_audio(audio_path, sr=16000)
|
audio_array, sampling_rate = load_audio(audio_path, sr=16000)
|
||||||
|
|
||||||
# Транскрибация с корректным форматом данных
|
# Транскрибация с корректным форматом данных
|
||||||
result = self.asr_pipeline(
|
generate_kwargs = {
|
||||||
{"raw": audio_array, "sampling_rate": sampling_rate},
|
"language": language,
|
||||||
generate_kwargs={
|
"max_new_tokens": self.max_new_tokens,
|
||||||
"language": self.language,
|
"temperature": temperature,
|
||||||
"max_new_tokens": self.max_new_tokens,
|
}
|
||||||
"temperature": self.temperature
|
if prompt:
|
||||||
},
|
generate_kwargs["prompt_ids"] = self.processor.get_prompt_ids(
|
||||||
return_timestamps=return_timestamps
|
prompt, return_tensors="pt"
|
||||||
)
|
).to(self.device)
|
||||||
|
|
||||||
|
with self._inference_lock:
|
||||||
|
result = self.asr_pipeline(
|
||||||
|
{"raw": audio_array, "sampling_rate": sampling_rate},
|
||||||
|
generate_kwargs=generate_kwargs,
|
||||||
|
return_timestamps=return_timestamps,
|
||||||
|
)
|
||||||
|
|
||||||
# Если временные метки не запрошены, возвращаем только текст
|
# Если временные метки не запрошены, возвращаем только текст
|
||||||
if not return_timestamps:
|
if not return_timestamps:
|
||||||
transcribed_text = result.get("text", "")
|
transcribed_text = result.get("text", "")
|
||||||
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
|
logger.info("Транскрибация завершена: получено %s символов текста", len(transcribed_text))
|
||||||
return transcribed_text
|
return transcribed_text
|
||||||
|
|
||||||
# Если временные метки запрошены, обрабатываем и форматируем результат
|
# Если временные метки запрошены, обрабатываем и форматируем результат
|
||||||
@@ -224,7 +260,7 @@ class WhisperTranscriber:
|
|||||||
else:
|
else:
|
||||||
logger.warning("Временные метки запрошены, но не найдены в результате транскрибации")
|
logger.warning("Временные метки запрошены, но не найдены в результате транскрибации")
|
||||||
|
|
||||||
logger.info(f"Транскрибация с временными метками завершена: получено {len(segments)} сегментов")
|
logger.info("Транскрибация с временными метками завершена: получено %s сегментов", len(segments))
|
||||||
|
|
||||||
# Возвращаем словарь с сегментами и полным текстом
|
# Возвращаем словарь с сегментами и полным текстом
|
||||||
return {
|
return {
|
||||||
@@ -233,18 +269,23 @@ class WhisperTranscriber:
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка в процессе транскрибации аудиофайла '{audio_path}': {str(e)}")
|
logger.error("Ошибка в процессе транскрибации аудиофайла '%s': %s", audio_path, e)
|
||||||
logger.error(f"Тип исключения: {type(e).__name__}")
|
logger.error("Тип исключения: %s", type(e).__name__)
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error("Traceback: %s", traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def process_file(self, input_path: str, return_timestamps: bool = None) -> Union[str, Dict]:
|
def process_file(self, input_path: str, return_timestamps: bool = None,
|
||||||
|
language: str = None, temperature: float = None,
|
||||||
|
prompt: str = None) -> Union[str, Dict]:
|
||||||
"""
|
"""
|
||||||
Полный процесс обработки и транскрибации аудиофайла.
|
Полный процесс обработки и транскрибации аудиофайла.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_path: Путь к исходному аудиофайлу.
|
input_path: Путь к исходному аудиофайлу.
|
||||||
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
|
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
|
||||||
|
language: Язык распознавания. Если None — берётся из конфига.
|
||||||
|
temperature: Параметр температуры для генерации. Если None — берётся из конфига.
|
||||||
|
prompt: Текстовая подсказка для модели (имена, термины, контекст).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
В зависимости от параметра return_timestamps:
|
В зависимости от параметра return_timestamps:
|
||||||
@@ -252,7 +293,7 @@ class WhisperTranscriber:
|
|||||||
- Если return_timestamps=True: словарь с ключами "segments" и "text"
|
- Если return_timestamps=True: словарь с ключами "segments" и "text"
|
||||||
"""
|
"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
logger.info(f"Начало обработки файла: {input_path}")
|
logger.info("Начало обработки файла: %s", input_path)
|
||||||
|
|
||||||
temp_files = []
|
temp_files = []
|
||||||
|
|
||||||
@@ -261,18 +302,20 @@ class WhisperTranscriber:
|
|||||||
processed_path, temp_files = self.audio_processor.process_audio(input_path)
|
processed_path, temp_files = self.audio_processor.process_audio(input_path)
|
||||||
|
|
||||||
# Транскрибация
|
# Транскрибация
|
||||||
result = self.transcribe(processed_path, return_timestamps=return_timestamps)
|
result = self.transcribe(processed_path, return_timestamps=return_timestamps,
|
||||||
|
language=language, temperature=temperature,
|
||||||
|
prompt=prompt)
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
elapsed_time = time.time() - start_time
|
||||||
logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд")
|
logger.info("Обработка и транскрибация завершены за %.2f секунд", elapsed_time)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed_time = time.time() - start_time
|
elapsed_time = time.time() - start_time
|
||||||
logger.error(f"Ошибка при обработке файла '{input_path}' через {elapsed_time:.2f} секунд: {str(e)}")
|
logger.error("Ошибка при обработке файла '%s' через %.2f секунд: %s", input_path, elapsed_time, e)
|
||||||
logger.error(f"Тип исключения: {type(e).__name__}")
|
logger.error("Тип исключения: %s", type(e).__name__)
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error("Traceback: %s", traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -50,11 +50,15 @@ class TranscriptionService:
|
|||||||
try:
|
try:
|
||||||
duration = get_audio_duration(file_path)
|
duration = get_audio_duration(file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при определении длительности файла: {e}")
|
logger.error("Ошибка при определении длительности файла: %s", e)
|
||||||
return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500
|
return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
result = self.transcriber.process_file(file_path, return_timestamps=return_timestamps)
|
result = self.transcriber.process_file(
|
||||||
|
file_path, return_timestamps=return_timestamps,
|
||||||
|
language=language, temperature=temperature,
|
||||||
|
prompt=prompt
|
||||||
|
)
|
||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
|
|
||||||
# Формируем ответ
|
# Формируем ответ
|
||||||
@@ -63,7 +67,7 @@ class TranscriptionService:
|
|||||||
"segments": result.get("segments", []),
|
"segments": result.get("segments", []),
|
||||||
"text": result.get("text", ""),
|
"text": result.get("text", ""),
|
||||||
"processing_time": processing_time,
|
"processing_time": processing_time,
|
||||||
"response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')),
|
"response_size_bytes": 0,
|
||||||
"duration_seconds": duration,
|
"duration_seconds": duration,
|
||||||
"model": os.path.basename(self.config["model_path"])
|
"model": os.path.basename(self.config["model_path"])
|
||||||
}
|
}
|
||||||
@@ -71,15 +75,17 @@ class TranscriptionService:
|
|||||||
response = {
|
response = {
|
||||||
"text": result,
|
"text": result,
|
||||||
"processing_time": processing_time,
|
"processing_time": processing_time,
|
||||||
"response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')),
|
"response_size_bytes": 0,
|
||||||
"duration_seconds": duration,
|
"duration_seconds": duration,
|
||||||
"model": os.path.basename(self.config["model_path"])
|
"model": os.path.basename(self.config["model_path"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response["response_size_bytes"] = len(json.dumps(response, ensure_ascii=False).encode('utf-8'))
|
||||||
|
|
||||||
save_history(response, filename, self.config)
|
save_history(response, filename, self.config)
|
||||||
return response, 200
|
return response, 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
|
logger.error("Ошибка при транскрибации файла '%s': %s", filename, e)
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error("Traceback: %s", traceback.format_exc())
|
||||||
return {"error": str(e)}, 500
|
return {"error": str(e)}, 500
|
||||||
|
|||||||
+32
-3
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
import datetime
|
import datetime
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
@@ -34,7 +35,7 @@ def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -
|
|||||||
try:
|
try:
|
||||||
os.makedirs(_history_root, exist_ok=True)
|
os.makedirs(_history_root, exist_ok=True)
|
||||||
|
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||||
date_str = now.strftime("%Y-%m-%d")
|
date_str = now.strftime("%Y-%m-%d")
|
||||||
timestamp_ms = int(now.timestamp() * 1000)
|
timestamp_ms = int(now.timestamp() * 1000)
|
||||||
random_tag = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4))
|
random_tag = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4))
|
||||||
@@ -48,9 +49,37 @@ def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -
|
|||||||
with open(history_path, 'w', encoding='utf-8') as f:
|
with open(history_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
logger.info(f"Результат сохранён в историю: {history_path}")
|
logger.info("Результат сохранён в историю: %s", history_path)
|
||||||
|
_cleanup_old_history(config)
|
||||||
return history_path
|
return history_path
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при сохранении истории: {e}")
|
logger.error("Ошибка при сохранении истории: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_old_history(config: Dict) -> None:
|
||||||
|
"""
|
||||||
|
Удаляет директории истории старше max_history_days дней.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Конфигурация (проверяется max_history_days).
|
||||||
|
"""
|
||||||
|
max_days = config.get("max_history_days", 30)
|
||||||
|
if max_days <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
cutoff = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_days)
|
||||||
|
cutoff_str = cutoff.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
try:
|
||||||
|
for entry in os.listdir(_history_root):
|
||||||
|
entry_path = os.path.join(_history_root, entry)
|
||||||
|
if not os.path.isdir(entry_path):
|
||||||
|
continue
|
||||||
|
# Директории имеют формат YYYY-MM-DD
|
||||||
|
if len(entry) == 10 and entry < cutoff_str:
|
||||||
|
shutil.rmtree(entry_path, ignore_errors=True)
|
||||||
|
logger.info("Удалена старая директория истории: %s", entry)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Ошибка при очистке старой истории: %s", e)
|
||||||
|
|||||||
+19
-8
@@ -3,24 +3,32 @@
|
|||||||
для сервиса распознавания речи.
|
для сервиса распознавания речи.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from flask import request, jsonify
|
from typing import Dict, TYPE_CHECKING
|
||||||
from typing import Dict
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
|
||||||
from .core.transcription_service import TranscriptionService
|
from .core.transcription_service import TranscriptionService
|
||||||
from .audio.sources import get_uploaded_file, get_url_file, get_base64_file
|
from .audio.sources import get_uploaded_file, get_url_file, get_base64_file
|
||||||
from .infrastructure.validation import ValidationError
|
from .infrastructure.validation import ValidationError
|
||||||
from .infrastructure.storage import cleanup_temp_files
|
from .infrastructure.storage import cleanup_temp_files
|
||||||
from .infrastructure.async_tasks import transcribe_audio_async, task_manager
|
from .infrastructure.async_tasks import transcribe_audio_async, task_manager
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .core.transcriber import WhisperTranscriber
|
||||||
|
from .infrastructure.validation import FileValidator
|
||||||
|
|
||||||
logger = logging.getLogger('app.routes')
|
logger = logging.getLogger('app.routes')
|
||||||
|
|
||||||
|
|
||||||
class Routes:
|
class Routes:
|
||||||
"""Класс для регистрации всех эндпоинтов API."""
|
"""Класс для регистрации всех эндпоинтов API."""
|
||||||
|
|
||||||
def __init__(self, app, transcriber, config: Dict, file_validator):
|
def __init__(self, app: Flask, transcriber: WhisperTranscriber,
|
||||||
|
config: Dict, file_validator: FileValidator):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.config = config
|
self.config = config
|
||||||
self.transcription_service = TranscriptionService(transcriber, config)
|
self.transcription_service = TranscriptionService(transcriber, config)
|
||||||
@@ -41,7 +49,9 @@ class Routes:
|
|||||||
|
|
||||||
@self.app.route('/config', methods=['GET'])
|
@self.app.route('/config', methods=['GET'])
|
||||||
def get_config():
|
def get_config():
|
||||||
"""Эндпоинт для получения конфигурации сервиса."""
|
"""Эндпоинт для получения конфигурации сервиса.
|
||||||
|
Отдаёт полную конфигурацию включая model_path — это сознательное
|
||||||
|
решение, сервис работает во внутренней сети."""
|
||||||
return jsonify(self.config), 200
|
return jsonify(self.config), 200
|
||||||
|
|
||||||
@self.app.route('/v1/models', methods=['GET'])
|
@self.app.route('/v1/models', methods=['GET'])
|
||||||
@@ -84,7 +94,7 @@ class Routes:
|
|||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
|
||||||
return jsonify(response), status_code
|
return jsonify(response), status_code
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
logger.warning("Ошибка валидации файла '%s': %s", filename, e)
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
finally:
|
finally:
|
||||||
cleanup_temp_files([temp_path])
|
cleanup_temp_files([temp_path])
|
||||||
@@ -112,7 +122,7 @@ class Routes:
|
|||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
||||||
return jsonify(response), status_code
|
return jsonify(response), status_code
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
logger.warning("Ошибка валидации файла '%s': %s", filename, e)
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
finally:
|
finally:
|
||||||
cleanup_temp_files([temp_path])
|
cleanup_temp_files([temp_path])
|
||||||
@@ -140,7 +150,7 @@ class Routes:
|
|||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
||||||
return jsonify(response), status_code
|
return jsonify(response), status_code
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
logger.warning("Ошибка валидации файла '%s': %s", filename, e)
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
finally:
|
finally:
|
||||||
cleanup_temp_files([temp_path])
|
cleanup_temp_files([temp_path])
|
||||||
@@ -158,8 +168,9 @@ class Routes:
|
|||||||
cleanup_temp_files([temp_path])
|
cleanup_temp_files([temp_path])
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
params = dict(request.form)
|
||||||
# Не чистим temp_path здесь — async task отвечает за cleanup
|
# Не чистим temp_path здесь — async task отвечает за cleanup
|
||||||
task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber)
|
task_id = transcribe_audio_async(temp_path, self.transcription_service, params)
|
||||||
return jsonify({"task_id": task_id}), 202
|
return jsonify({"task_id": task_id}), 202
|
||||||
|
|
||||||
@self.app.route('/v1/tasks/<task_id>', methods=['GET'])
|
@self.app.route('/v1/tasks/<task_id>', methods=['GET'])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"model_path": "/home/text-generation/models/whisper/podlodka-turbo",
|
"model_path": "/home/text-generation/models/whisper/podlodka-turbo",
|
||||||
"language": "ru",
|
"language": "ru",
|
||||||
"enable_history": true,
|
"enable_history": true,
|
||||||
|
"max_history_days": 30,
|
||||||
"chunk_length_s": 28,
|
"chunk_length_s": 28,
|
||||||
"batch_size": 6,
|
"batch_size": 6,
|
||||||
"max_new_tokens": 384,
|
"max_new_tokens": 384,
|
||||||
|
|||||||
Reference in New Issue
Block a user