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:
Serge Zaigraeff
2026-03-31 00:43:06 +03:00
co-authored by Claude Sonnet 4.6
parent 2138651474
commit 1e778cae86
7 changed files with 171 additions and 87 deletions
+11 -23
View File
@@ -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
View File
@@ -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)}"
+71 -28
View File
@@ -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)
# Транскрибация с корректным форматом данных # Транскрибация с корректным форматом данных
generate_kwargs = {
"language": language,
"max_new_tokens": self.max_new_tokens,
"temperature": temperature,
}
if prompt:
generate_kwargs["prompt_ids"] = self.processor.get_prompt_ids(
prompt, return_tensors="pt"
).to(self.device)
with self._inference_lock:
result = self.asr_pipeline( result = self.asr_pipeline(
{"raw": audio_array, "sampling_rate": sampling_rate}, {"raw": audio_array, "sampling_rate": sampling_rate},
generate_kwargs={ generate_kwargs=generate_kwargs,
"language": self.language, return_timestamps=return_timestamps,
"max_new_tokens": self.max_new_tokens,
"temperature": self.temperature
},
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:
+12 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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'])
+1
View File
@@ -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,