Fix code review issues: security, race conditions, resource leaks
- Fix race condition: pass return_timestamps as parameter instead of mutating shared state - Remove /local/transcriptions endpoint (path traversal vulnerability) - Add timeout=30 and URL scheme validation to prevent SSRF - Add temp file cleanup via try/finally in all route handlers - AsyncTaskManager: add threading.Lock and TTL cleanup for completed tasks - Change audio_rate to 16000 (matches Whisper's expected sample rate) - Extract filename from URL via Content-Disposition/path - Detect base64 audio format via python-magic - Fix response_size_bytes to use json.dumps instead of str() - Move scipy import to module level - Clamp temperature to [0.0, 1.0] - Deduplicate _load_model() code - Fix docstrings, README structure, typo, log level Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ff4bd2ba3d
commit
2138651474
@@ -224,13 +224,20 @@ The project consists of the following components:
|
|||||||
- `config.json`: Service configuration file
|
- `config.json`: Service configuration file
|
||||||
- `app/`: Main application module
|
- `app/`: Main application module
|
||||||
- `__init__.py`: Contains the `WhisperServiceAPI` class for service initialization
|
- `__init__.py`: Contains the `WhisperServiceAPI` class for service initialization
|
||||||
- `utils.py`: Logging configuration
|
- `routes.py`: API route definitions
|
||||||
- `transcriber.py`: Contains the `WhisperTranscriber` class for speech recognition
|
- `history.py`: Saving transcription history
|
||||||
- `audio_processor.py`: Contains the `AudioProcessor` class for audio preprocessing
|
- `core/`: Core logic
|
||||||
- `audio_sources.py`: Contains different audio source handlers (upload, URL, base64, local)
|
- `transcriber.py`: `WhisperTranscriber` class for speech recognition
|
||||||
- `transcriber_service.py`: Manages the transcription workflow
|
- `transcription_service.py`: Manages the transcription workflow
|
||||||
- `history_logger.py`: Handles saving transcription history
|
- `audio/`: Audio processing
|
||||||
- `routes.py`: Contains the API route definitions
|
- `processor.py`: `AudioProcessor` class for audio preprocessing
|
||||||
|
- `sources.py`: Audio source handlers (upload, URL, base64)
|
||||||
|
- `utils.py`: Audio utilities (loading, duration)
|
||||||
|
- `infrastructure/`: Supporting modules
|
||||||
|
- `log.py`: Logging configuration
|
||||||
|
- `validation.py`: File validation
|
||||||
|
- `storage.py`: Temp file management
|
||||||
|
- `async_tasks.py`: Async task manager
|
||||||
- `static/`: Web interface files
|
- `static/`: Web interface files
|
||||||
|
|
||||||
## Advanced usage
|
## Advanced usage
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class AudioProcessor:
|
|||||||
output_path
|
output_path
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info(f"Конвертация в WAV: {' '.join(cmd)}")
|
logger.debug(f"Конвертация в WAV: {' '.join(cmd)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
|||||||
+33
-27
@@ -1,12 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Модуль sources.py содержит функции для получения аудиофайлов
|
Модуль sources.py содержит функции для получения аудиофайлов
|
||||||
из различных источников (загруженные файлы, URL, base64, локальные файлы).
|
из различных источников (загруженные файлы, URL, base64).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import tempfile
|
import tempfile
|
||||||
import base64
|
import base64
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import magic
|
||||||
import requests
|
import requests
|
||||||
from typing import Tuple, Optional
|
from typing import Tuple, Optional
|
||||||
import logging
|
import logging
|
||||||
@@ -66,7 +68,11 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str],
|
|||||||
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, stream=True)
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ('http', 'https'):
|
||||||
|
return None, None, f"Unsupported URL scheme: {parsed.scheme}. Only http/https allowed"
|
||||||
|
|
||||||
|
response = requests.get(url, stream=True, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Проверка размера по Content-Length
|
# Проверка размера по Content-Length
|
||||||
@@ -76,12 +82,22 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str],
|
|||||||
if error:
|
if error:
|
||||||
return None, None, error
|
return None, None, error
|
||||||
|
|
||||||
|
# Извлекаем имя файла из Content-Disposition или URL
|
||||||
|
original_name = None
|
||||||
|
cd = response.headers.get('Content-Disposition', '')
|
||||||
|
if 'filename=' in cd:
|
||||||
|
original_name = cd.split('filename=')[-1].strip('" ')
|
||||||
|
if not original_name:
|
||||||
|
url_path = parsed.path.rstrip('/')
|
||||||
|
if url_path:
|
||||||
|
original_name = os.path.basename(url_path)
|
||||||
|
|
||||||
temp_path = _make_temp_path()
|
temp_path = _make_temp_path()
|
||||||
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):
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
return temp_path, 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(f"Ошибка при получении файла по URL {url}: {e}")
|
||||||
@@ -102,7 +118,20 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti
|
|||||||
if error:
|
if error:
|
||||||
return None, None, error
|
return None, None, error
|
||||||
|
|
||||||
temp_path = _make_temp_path()
|
# Определяем формат по содержимому
|
||||||
|
mime_to_ext = {
|
||||||
|
"audio/mpeg": ".mp3",
|
||||||
|
"audio/ogg": ".ogg",
|
||||||
|
"audio/flac": ".flac",
|
||||||
|
"audio/mp4": ".m4a",
|
||||||
|
"audio/x-m4a": ".m4a",
|
||||||
|
"audio/aac": ".aac",
|
||||||
|
"audio/webm": ".webm",
|
||||||
|
}
|
||||||
|
detected_mime = magic.from_buffer(audio_data[:1024], mime=True)
|
||||||
|
suffix = mime_to_ext.get(detected_mime, ".wav")
|
||||||
|
|
||||||
|
temp_path = _make_temp_path(suffix)
|
||||||
with open(temp_path, 'wb') as f:
|
with open(temp_path, 'wb') as f:
|
||||||
f.write(audio_data)
|
f.write(audio_data)
|
||||||
|
|
||||||
@@ -111,26 +140,3 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при декодировании base64 данных: {e}")
|
logger.error(f"Ошибка при декодировании base64 данных: {e}")
|
||||||
return None, None, f"Error decoding base64 data: {str(e)}"
|
return None, None, f"Error decoding base64 data: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
def get_local_file(file_path: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
||||||
"""
|
|
||||||
Получает локальный аудиофайл.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Кортеж (путь к файлу, имя файла, сообщение об ошибке).
|
|
||||||
Примечание: возвращает оригинальный путь, не копирует файл.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
return None, None, f"File not found: {file_path}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
error = _check_size(os.path.getsize(file_path), max_file_size_mb)
|
|
||||||
if error:
|
|
||||||
return None, None, error
|
|
||||||
|
|
||||||
return file_path, os.path.basename(file_path), None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при открытии локального файла {file_path}: {e}")
|
|
||||||
return None, None, f"Error opening local file: {str(e)}"
|
|
||||||
|
|||||||
+2
-2
@@ -6,6 +6,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import wave
|
import wave
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from scipy.signal import resample as scipy_resample
|
||||||
import logging
|
import logging
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
@@ -33,9 +34,8 @@ def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
|
|||||||
sampling_rate = wav_file.getframerate()
|
sampling_rate = wav_file.getframerate()
|
||||||
|
|
||||||
if sampling_rate != sr:
|
if sampling_rate != sr:
|
||||||
from scipy.signal import resample
|
|
||||||
num_samples = int(len(audio_array) * sr / sampling_rate)
|
num_samples = int(len(audio_array) * sr / sampling_rate)
|
||||||
audio_array = resample(audio_array, num_samples)
|
audio_array = scipy_resample(audio_array, num_samples)
|
||||||
sampling_rate = sr
|
sampling_rate = sr
|
||||||
|
|
||||||
return audio_array, sampling_rate
|
return audio_array, sampling_rate
|
||||||
|
|||||||
+23
-25
@@ -117,32 +117,25 @@ class WhisperTranscriber:
|
|||||||
"""
|
"""
|
||||||
logger.info(f"Загрузка модели из {self.model_path}")
|
logger.info(f"Загрузка модели из {self.model_path}")
|
||||||
|
|
||||||
|
model_kwargs = dict(
|
||||||
|
torch_dtype=self.torch_dtype,
|
||||||
|
low_cpu_mem_usage=True,
|
||||||
|
use_safetensors=True,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Проверка возможности использования Flash Attention 2
|
|
||||||
if self.device.type == "cuda":
|
if self.device.type == "cuda":
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(
|
model_kwargs["attn_implementation"] = "flash_attention_2"
|
||||||
self.model_path,
|
self.model = WhisperForConditionalGeneration.from_pretrained(
|
||||||
torch_dtype=self.torch_dtype,
|
self.model_path, **model_kwargs
|
||||||
low_cpu_mem_usage=True,
|
).to(self.device)
|
||||||
use_safetensors=True,
|
if self.device.type == "cuda":
|
||||||
attn_implementation="flash_attention_2"
|
|
||||||
).to(self.device)
|
|
||||||
logger.info("Используется Flash Attention 2")
|
logger.info("Используется Flash Attention 2")
|
||||||
else:
|
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(
|
|
||||||
self.model_path,
|
|
||||||
torch_dtype=self.torch_dtype,
|
|
||||||
low_cpu_mem_usage=True,
|
|
||||||
use_safetensors=True
|
|
||||||
).to(self.device)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Не удалось загрузить модель с Flash Attention: {e}")
|
logger.warning(f"Не удалось загрузить модель с Flash Attention: {e}")
|
||||||
# Fallback к обычной версии
|
model_kwargs.pop("attn_implementation", None)
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(
|
self.model = WhisperForConditionalGeneration.from_pretrained(
|
||||||
self.model_path,
|
self.model_path, **model_kwargs
|
||||||
torch_dtype=self.torch_dtype,
|
|
||||||
low_cpu_mem_usage=True,
|
|
||||||
use_safetensors=True
|
|
||||||
).to(self.device)
|
).to(self.device)
|
||||||
|
|
||||||
self.processor = WhisperProcessor.from_pretrained(self.model_path)
|
self.processor = WhisperProcessor.from_pretrained(self.model_path)
|
||||||
@@ -161,18 +154,22 @@ class WhisperTranscriber:
|
|||||||
|
|
||||||
logger.info("Модель успешно загружена и готова к использованию")
|
logger.info("Модель успешно загружена и готова к использованию")
|
||||||
|
|
||||||
def transcribe(self, audio_path: str) -> Union[str, Dict]:
|
def transcribe(self, audio_path: str, return_timestamps: bool = None) -> Union[str, Dict]:
|
||||||
"""
|
"""
|
||||||
Транскрибация аудиофайла.
|
Транскрибация аудиофайла.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_path: Путь к обработанному аудиофайлу.
|
audio_path: Путь к обработанному аудиофайлу.
|
||||||
|
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
В зависимости от параметра return_timestamps:
|
В зависимости от параметра return_timestamps:
|
||||||
- Если return_timestamps=False: строка с распознанным текстом
|
- Если return_timestamps=False: строка с распознанным текстом
|
||||||
- Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст)
|
- Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст)
|
||||||
"""
|
"""
|
||||||
|
if return_timestamps is None:
|
||||||
|
return_timestamps = self.return_timestamps
|
||||||
|
|
||||||
logger.info(f"Начало транскрибации файла: {audio_path}")
|
logger.info(f"Начало транскрибации файла: {audio_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -187,11 +184,11 @@ class WhisperTranscriber:
|
|||||||
"max_new_tokens": self.max_new_tokens,
|
"max_new_tokens": self.max_new_tokens,
|
||||||
"temperature": self.temperature
|
"temperature": self.temperature
|
||||||
},
|
},
|
||||||
return_timestamps=self.return_timestamps
|
return_timestamps=return_timestamps
|
||||||
)
|
)
|
||||||
|
|
||||||
# Если временные метки не запрошены, возвращаем только текст
|
# Если временные метки не запрошены, возвращаем только текст
|
||||||
if not self.return_timestamps:
|
if not return_timestamps:
|
||||||
transcribed_text = result.get("text", "")
|
transcribed_text = result.get("text", "")
|
||||||
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
|
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
|
||||||
return transcribed_text
|
return transcribed_text
|
||||||
@@ -241,12 +238,13 @@ class WhisperTranscriber:
|
|||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def process_file(self, input_path: str) -> Union[str, Dict]:
|
def process_file(self, input_path: str, return_timestamps: bool = None) -> Union[str, Dict]:
|
||||||
"""
|
"""
|
||||||
Полный процесс обработки и транскрибации аудиофайла.
|
Полный процесс обработки и транскрибации аудиофайла.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_path: Путь к исходному аудиофайлу.
|
input_path: Путь к исходному аудиофайлу.
|
||||||
|
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
В зависимости от параметра return_timestamps:
|
В зависимости от параметра return_timestamps:
|
||||||
@@ -263,7 +261,7 @@ 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)
|
result = self.transcribe(processed_path, return_timestamps=return_timestamps)
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
elapsed_time = time.time() - start_time
|
||||||
logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд")
|
logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
который отвечает за транскрибацию аудиофайлов.
|
который отвечает за транскрибацию аудиофайлов.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -36,7 +37,7 @@ class TranscriptionService:
|
|||||||
"""
|
"""
|
||||||
params = params or {}
|
params = params or {}
|
||||||
language = params.get('language', self.config.get('language', 'en'))
|
language = params.get('language', self.config.get('language', 'en'))
|
||||||
temperature = float(params.get('temperature', 0.0))
|
temperature = max(0.0, min(1.0, float(params.get('temperature', 0.0))))
|
||||||
prompt = params.get('prompt', '')
|
prompt = params.get('prompt', '')
|
||||||
|
|
||||||
# Проверяем, запрошены ли временные метки
|
# Проверяем, запрошены ли временные метки
|
||||||
@@ -44,10 +45,6 @@ class TranscriptionService:
|
|||||||
if isinstance(return_timestamps, str):
|
if isinstance(return_timestamps, str):
|
||||||
return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1')
|
return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1')
|
||||||
|
|
||||||
# Временно изменяем настройку return_timestamps в транскрайбере
|
|
||||||
original_return_timestamps = self.transcriber.return_timestamps
|
|
||||||
self.transcriber.return_timestamps = return_timestamps
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Определяем длительность аудиофайла
|
# Определяем длительность аудиофайла
|
||||||
try:
|
try:
|
||||||
@@ -57,7 +54,7 @@ class TranscriptionService:
|
|||||||
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)
|
result = self.transcriber.process_file(file_path, return_timestamps=return_timestamps)
|
||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
|
|
||||||
# Формируем ответ
|
# Формируем ответ
|
||||||
@@ -66,7 +63,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(str(result).encode('utf-8')),
|
"response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')),
|
||||||
"duration_seconds": duration,
|
"duration_seconds": duration,
|
||||||
"model": os.path.basename(self.config["model_path"])
|
"model": os.path.basename(self.config["model_path"])
|
||||||
}
|
}
|
||||||
@@ -74,7 +71,7 @@ class TranscriptionService:
|
|||||||
response = {
|
response = {
|
||||||
"text": result,
|
"text": result,
|
||||||
"processing_time": processing_time,
|
"processing_time": processing_time,
|
||||||
"response_size_bytes": len(str(result).encode('utf-8')),
|
"response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')),
|
||||||
"duration_seconds": duration,
|
"duration_seconds": duration,
|
||||||
"model": os.path.basename(self.config["model_path"])
|
"model": os.path.basename(self.config["model_path"])
|
||||||
}
|
}
|
||||||
@@ -86,6 +83,3 @@ class TranscriptionService:
|
|||||||
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
|
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
return {"error": str(e)}, 500
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
finally:
|
|
||||||
self.transcriber.return_timestamps = original_return_timestamps
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Модуль history_logger.py — сохранение истории транскрибации в JSON-файлы.
|
Модуль history.py — сохранение истории транскрибации в JSON-файлы.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
logger = logging.getLogger('app.history')
|
logger = logging.getLogger('app.history')
|
||||||
|
|
||||||
# Корневая директория истории (относительно корня проекта)
|
# Корневая директория истории (относительно корня проекта)
|
||||||
_history_root = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "history")
|
_history_root = os.path.join(os.path.dirname(os.path.dirname(__file__)), "history")
|
||||||
|
|
||||||
|
|
||||||
def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -> Optional[str]:
|
def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -> Optional[str]:
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Модуль manager.py содержит функции для асинхронной обработки задач.
|
Модуль async_tasks.py содержит функции для асинхронной обработки задач.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, Callable, Optional
|
from typing import Dict, Any, Callable, Optional
|
||||||
from threading import Thread
|
from threading import Thread, Lock
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger('app.async_tasks')
|
logger = logging.getLogger('app.async_tasks')
|
||||||
@@ -19,10 +19,13 @@ class AsyncTaskManager:
|
|||||||
tasks (Dict): Словарь для хранения информации о задачах.
|
tasks (Dict): Словарь для хранения информации о задачах.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
TASK_TTL = 3600 # 1 час
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""
|
"""
|
||||||
Инициализация менеджера асинхронных задач.
|
Инициализация менеджера асинхронных задач.
|
||||||
"""
|
"""
|
||||||
|
self._lock = Lock()
|
||||||
self.tasks: Dict[str, Dict[str, Any]] = {}
|
self.tasks: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
def run_task(self, func: Callable, *args, **kwargs) -> str:
|
def run_task(self, func: Callable, *args, **kwargs) -> str:
|
||||||
@@ -39,23 +42,33 @@ class AsyncTaskManager:
|
|||||||
"""
|
"""
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Создание информации о задаче
|
with self._lock:
|
||||||
self.tasks[task_id] = {
|
self._cleanup_old_tasks()
|
||||||
"status": "pending",
|
self.tasks[task_id] = {
|
||||||
"result": None,
|
"status": "pending",
|
||||||
"error": None,
|
"result": None,
|
||||||
"created_at": time.time(),
|
"error": None,
|
||||||
"started_at": None,
|
"created_at": time.time(),
|
||||||
"completed_at": None
|
"started_at": None,
|
||||||
}
|
"completed_at": None
|
||||||
|
}
|
||||||
|
|
||||||
# Создание и запуск потока
|
|
||||||
thread = Thread(target=self._run_task_thread, args=(task_id, func, args, kwargs))
|
thread = Thread(target=self._run_task_thread, args=(task_id, func, args, kwargs))
|
||||||
thread.daemon = True
|
thread.daemon = True
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
return task_id
|
return task_id
|
||||||
|
|
||||||
|
def _cleanup_old_tasks(self) -> None:
|
||||||
|
"""Удаляет завершённые задачи старше TASK_TTL. Вызывать под self._lock."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
tid for tid, info in self.tasks.items()
|
||||||
|
if info["completed_at"] and now - info["completed_at"] > self.TASK_TTL
|
||||||
|
]
|
||||||
|
for tid in expired:
|
||||||
|
del self.tasks[tid]
|
||||||
|
|
||||||
def _run_task_thread(self, task_id: str, func: Callable, args: tuple, kwargs: dict) -> None:
|
def _run_task_thread(self, task_id: str, func: Callable, args: tuple, kwargs: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Функция для выполнения задачи в потоке.
|
Функция для выполнения задачи в потоке.
|
||||||
@@ -66,25 +79,24 @@ class AsyncTaskManager:
|
|||||||
args: Позиционные аргументы для функции.
|
args: Позиционные аргументы для функции.
|
||||||
kwargs: Именованные аргументы для функции.
|
kwargs: Именованные аргументы для функции.
|
||||||
"""
|
"""
|
||||||
try:
|
with self._lock:
|
||||||
# Обновление статуса задачи
|
|
||||||
self.tasks[task_id]["status"] = "running"
|
self.tasks[task_id]["status"] = "running"
|
||||||
self.tasks[task_id]["started_at"] = time.time()
|
self.tasks[task_id]["started_at"] = time.time()
|
||||||
|
|
||||||
# Выполнение функции
|
try:
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
|
|
||||||
# Сохранение результата
|
with self._lock:
|
||||||
self.tasks[task_id]["status"] = "completed"
|
self.tasks[task_id]["status"] = "completed"
|
||||||
self.tasks[task_id]["result"] = result
|
self.tasks[task_id]["result"] = result
|
||||||
self.tasks[task_id]["completed_at"] = time.time()
|
self.tasks[task_id]["completed_at"] = time.time()
|
||||||
|
|
||||||
logger.info(f"Задача {task_id} завершена успешно")
|
logger.info(f"Задача {task_id} завершена успешно")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Обработка ошибки
|
with self._lock:
|
||||||
self.tasks[task_id]["status"] = "failed"
|
self.tasks[task_id]["status"] = "failed"
|
||||||
self.tasks[task_id]["error"] = str(e)
|
self.tasks[task_id]["error"] = str(e)
|
||||||
self.tasks[task_id]["completed_at"] = time.time()
|
self.tasks[task_id]["completed_at"] = time.time()
|
||||||
|
|
||||||
logger.error(f"Задача {task_id} завершилась с ошибкой: {e}")
|
logger.error(f"Задача {task_id} завершилась с ошибкой: {e}")
|
||||||
|
|
||||||
@@ -98,7 +110,9 @@ class AsyncTaskManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Информация о задаче или None, если задача не найдена.
|
Информация о задаче или None, если задача не найдена.
|
||||||
"""
|
"""
|
||||||
return self.tasks.get(task_id)
|
with self._lock:
|
||||||
|
task = self.tasks.get(task_id)
|
||||||
|
return dict(task) if task else None
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр менеджера асинхронных задач
|
# Глобальный экземпляр менеджера асинхронных задач
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Модуль validators.py содержит классы и функции для валидации входных данных.
|
Модуль validation.py содержит классы и функции для валидации входных данных.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import magic
|
import magic
|
||||||
from typing import Dict, List, BinaryIO, Optional
|
from typing import Dict, List, BinaryIO
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Получаем логгер из централизованной настройки
|
# Получаем логгер из централизованной настройки
|
||||||
@@ -186,35 +186,3 @@ class FileValidator:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str:
|
|
||||||
"""
|
|
||||||
Валидирует путь к локальному файлу для предотвращения атак обхода пути.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Путь к файлу.
|
|
||||||
allowed_directories: Список разрешенных директорий.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Нормализованный и проверенный путь к файлу.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValidationError: Если путь к файлу небезопасен.
|
|
||||||
"""
|
|
||||||
# Нормализация пути
|
|
||||||
normalized_path = os.path.normpath(file_path)
|
|
||||||
|
|
||||||
# Если указаны разрешенные директории, проверяем, что путь находится в одной из них
|
|
||||||
if allowed_directories:
|
|
||||||
for allowed_dir in allowed_directories:
|
|
||||||
full_allowed_path = os.path.abspath(allowed_dir)
|
|
||||||
full_file_path = os.path.abspath(os.path.join(full_allowed_path, normalized_path))
|
|
||||||
|
|
||||||
if full_file_path.startswith(full_allowed_path):
|
|
||||||
return full_file_path
|
|
||||||
|
|
||||||
logger.warning(f"Попытка доступа к файлу вне разрешенных директорий: {file_path}")
|
|
||||||
raise ValidationError("Путь к файлу не находится в разрешенных директориях")
|
|
||||||
|
|
||||||
# Если разрешенные директории не указаны, просто возвращаем нормализованный путь
|
|
||||||
return normalized_path
|
|
||||||
+16
-38
@@ -9,8 +9,9 @@ from typing import Dict
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
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, get_local_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.async_tasks import transcribe_audio_async, task_manager
|
from .infrastructure.async_tasks import transcribe_audio_async, task_manager
|
||||||
|
|
||||||
logger = logging.getLogger('app.routes')
|
logger = logging.getLogger('app.routes')
|
||||||
@@ -43,33 +44,6 @@ class Routes:
|
|||||||
"""Эндпоинт для получения конфигурации сервиса."""
|
"""Эндпоинт для получения конфигурации сервиса."""
|
||||||
return jsonify(self.config), 200
|
return jsonify(self.config), 200
|
||||||
|
|
||||||
@self.app.route('/local/transcriptions', methods=['POST'])
|
|
||||||
def local_transcribe():
|
|
||||||
"""Эндпоинт для локальной транскрибации файла по пути на сервере."""
|
|
||||||
data = request.json
|
|
||||||
|
|
||||||
if not data or "file_path" not in data:
|
|
||||||
return jsonify({"error": "No file_path provided"}), 400
|
|
||||||
|
|
||||||
file_path = data["file_path"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
validated_path = self.file_validator.validate_local_file_path(
|
|
||||||
file_path,
|
|
||||||
allowed_directories=self.config.get("allowed_directories", [])
|
|
||||||
)
|
|
||||||
except ValidationError as e:
|
|
||||||
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown'))
|
|
||||||
logger.warning(f"Невалидный путь '{file_path}' от {client_ip}: {e}")
|
|
||||||
return jsonify({"error": str(e)}), 400
|
|
||||||
|
|
||||||
temp_path, filename, error = get_local_file(validated_path, self._max_size)
|
|
||||||
if error:
|
|
||||||
return jsonify({"error": error}), 400
|
|
||||||
|
|
||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, data)
|
|
||||||
return jsonify(response), status_code
|
|
||||||
|
|
||||||
@self.app.route('/v1/models', methods=['GET'])
|
@self.app.route('/v1/models', methods=['GET'])
|
||||||
def list_models():
|
def list_models():
|
||||||
"""Эндпоинт для получения списка доступных моделей."""
|
"""Эндпоинт для получения списка доступных моделей."""
|
||||||
@@ -105,15 +79,15 @@ class Routes:
|
|||||||
if error:
|
if error:
|
||||||
return jsonify({"error": error}), 400
|
return jsonify({"error": error}), 400
|
||||||
|
|
||||||
# Валидация файла
|
|
||||||
try:
|
try:
|
||||||
self.file_validator.validate_file_by_path(temp_path, filename)
|
self.file_validator.validate_file_by_path(temp_path, filename)
|
||||||
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
|
||||||
|
return jsonify(response), status_code
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
finally:
|
||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
|
cleanup_temp_files([temp_path])
|
||||||
return jsonify(response), status_code
|
|
||||||
|
|
||||||
@self.app.route('/v1/audio/transcriptions/url', methods=['POST'])
|
@self.app.route('/v1/audio/transcriptions/url', methods=['POST'])
|
||||||
def transcribe_from_url():
|
def transcribe_from_url():
|
||||||
@@ -135,12 +109,13 @@ class Routes:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.file_validator.validate_file_by_path(temp_path, filename)
|
self.file_validator.validate_file_by_path(temp_path, filename)
|
||||||
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
||||||
|
return jsonify(response), status_code
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
finally:
|
||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
cleanup_temp_files([temp_path])
|
||||||
return jsonify(response), status_code
|
|
||||||
|
|
||||||
@self.app.route('/v1/audio/transcriptions/base64', methods=['POST'])
|
@self.app.route('/v1/audio/transcriptions/base64', methods=['POST'])
|
||||||
def transcribe_from_base64():
|
def transcribe_from_base64():
|
||||||
@@ -162,12 +137,13 @@ class Routes:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.file_validator.validate_file_by_path(temp_path, filename)
|
self.file_validator.validate_file_by_path(temp_path, filename)
|
||||||
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
||||||
|
return jsonify(response), status_code
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
finally:
|
||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
cleanup_temp_files([temp_path])
|
||||||
return jsonify(response), status_code
|
|
||||||
|
|
||||||
@self.app.route('/v1/audio/transcriptions/async', methods=['POST'])
|
@self.app.route('/v1/audio/transcriptions/async', methods=['POST'])
|
||||||
def transcribe_async():
|
def transcribe_async():
|
||||||
@@ -179,8 +155,10 @@ class Routes:
|
|||||||
try:
|
try:
|
||||||
self.file_validator.validate_file_by_path(temp_path, filename)
|
self.file_validator.validate_file_by_path(temp_path, filename)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
|
cleanup_temp_files([temp_path])
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
# Не чистим 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.transcriber)
|
||||||
return jsonify({"task_id": task_id}), 202
|
return jsonify({"task_id": task_id}), 202
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -8,7 +8,7 @@
|
|||||||
"max_new_tokens": 384,
|
"max_new_tokens": 384,
|
||||||
"temperature": 0.01,
|
"temperature": 0.01,
|
||||||
"return_timestamps": false,
|
"return_timestamps": false,
|
||||||
"audio_rate": 8000,
|
"audio_rate": 16000,
|
||||||
"norm_level": "-0.55",
|
"norm_level": "-0.55",
|
||||||
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15",
|
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15",
|
||||||
"device_id": 0,
|
"device_id": 0,
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
"allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga", ".aac", ".webm"],
|
"allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga", ".aac", ".webm"],
|
||||||
"allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"]
|
"allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"]
|
||||||
},
|
},
|
||||||
"allowed_directories": [],
|
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"log_file": "logs/whisper_api.log",
|
"log_file": "logs/whisper_api.log",
|
||||||
"request_logging": {
|
"request_logging": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Conda environment dependencies
|
# Conda environment dependencies
|
||||||
|
|
||||||
# Tourch
|
# Torch
|
||||||
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.13'
|
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.13'
|
||||||
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.12'
|
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.12'
|
||||||
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.11'
|
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.11'
|
||||||
|
|||||||
Reference in New Issue
Block a user