Simplify architecture: remove overengineering, flatten structure
- Remove dead code: cache.py, context_managers.py, decorators.py, flask-limiter - Replace AudioSource ABC + FakeFile (5 classes) with 4 plain functions - Replace TempFileManager class with create_temp_file/cleanup_temp_files functions - Simplify RequestLogger from 233 to 65 lines, remove file reading side-effect - Convert HistoryLogger and AudioUtils classes to module-level functions - Remove unused speed_up_audio and audio_speed_factor config - Flatten single-file directories: shared/, api/, storage/, validation/, async_tasks/, logging/ - Merge logging config + request_logger into single infrastructure/log.py - Fix request_logging config key (was request_logger) - Trim CLAUDE.md to high-level only 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
38785398b3
commit
2e2bad8255
@@ -15,83 +15,8 @@ Local, OpenAI-compatible speech recognition API service using the Whisper model.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
server.py # Entry point, argparse, launches WhisperServiceAPI
|
||||
app/__init__.py # WhisperServiceAPI: Flask init, wires all components
|
||||
app/core/
|
||||
config.py # load_config() from JSON
|
||||
transcriber.py # WhisperTranscriber: model load, device select, inference
|
||||
transcription_service.py # TranscriptionService: orchestrates source -> validate -> transcribe -> log
|
||||
app/audio/
|
||||
processor.py # AudioProcessor: WAV convert, normalize, compress, speedup, silence
|
||||
sources.py # AudioSource (abstract) + UploadedFile/URL/Base64/LocalFile sources
|
||||
utils.py # AudioUtils: load audio as numpy, get duration via ffprobe
|
||||
app/api/
|
||||
routes.py # All Flask endpoints (OpenAI-compatible + local + async)
|
||||
app/infrastructure/
|
||||
storage/cache.py # SimpleCache with TTL
|
||||
storage/file_manager.py # TempFileManager: temp file lifecycle with context managers
|
||||
logging/config.py # setup_logging(): console + rotating file handler
|
||||
logging/request_logger.py # RequestLogger: HTTP request/response middleware
|
||||
validation/validators.py # FileValidator: size, extension, MIME checks
|
||||
async_tasks/manager.py # AsyncTaskManager: thread-based async with status tracking
|
||||
app/shared/
|
||||
history_logger.py # HistoryLogger: saves transcription results as JSON by date
|
||||
decorators.py # log_invalid_file_request decorator
|
||||
context_managers.py # open_file context manager
|
||||
app/static/
|
||||
index.html # Built-in web UI client
|
||||
```
|
||||
|
||||
## Request Flow
|
||||
|
||||
```
|
||||
Flask Request
|
||||
-> RequestLogger middleware (logs request)
|
||||
-> Routes (endpoint handler)
|
||||
-> TranscriptionService.transcribe_from_source()
|
||||
-> AudioSource.get_audio_file() # fetch from upload/URL/base64/local
|
||||
-> FileValidator.validate_file() # size/extension/MIME
|
||||
-> WhisperTranscriber.process_file()
|
||||
-> AudioProcessor.process_audio() # WAV 16kHz, normalize, compress, speedup, silence
|
||||
-> WhisperTranscriber.transcribe() # model inference
|
||||
-> HistoryLogger.save() # persist result JSON
|
||||
-> JSON Response (text, processing_time, duration, model)
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/` | Web UI |
|
||||
| GET | `/health` | Service status |
|
||||
| GET | `/config` | Current configuration |
|
||||
| GET | `/v1/models` | List models (OpenAI-compatible) |
|
||||
| GET | `/v1/models/<id>` | Model details |
|
||||
| POST | `/v1/audio/transcriptions` | Transcribe uploaded file (multipart) |
|
||||
| POST | `/v1/audio/transcriptions/url` | Transcribe from URL |
|
||||
| POST | `/v1/audio/transcriptions/base64` | Transcribe from base64 |
|
||||
| POST | `/v1/audio/transcriptions/async` | Async transcription |
|
||||
| GET | `/v1/tasks/<task_id>` | Async task status |
|
||||
| POST | `/local/transcriptions` | Transcribe local server file |
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings in `config.json`. Key parameters:
|
||||
|
||||
* `service_port`: server port (default 5042)
|
||||
* `model_path`: path to Whisper model directory
|
||||
* `language`: recognition language
|
||||
* `device_id`: GPU index for CUDA
|
||||
* Audio processing: `norm_level`, `compand_params`, `audio_speed_factor`, `audio_rate`
|
||||
* Inference: `chunk_length_s`, `batch_size`, `max_new_tokens`, `temperature`
|
||||
* `file_validation`: max size, allowed extensions/MIME types
|
||||
* `request_logging`: excluded endpoints, sensitive headers
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
* **Config-driven**: All behavior controlled via `config.json`. No hardcoded model paths or thresholds.
|
||||
* **Source abstraction**: `AudioSource` ABC unifies all input methods. New sources implement `get_audio_file()`.
|
||||
* **Temp file lifecycle**: `TempFileManager` with context managers ensures cleanup even on errors.
|
||||
* **OpenAI compatibility**: `/v1/audio/transcriptions` matches OpenAI API contract for drop-in replacement.
|
||||
* **Device fallback**: CUDA -> MPS -> CPU, with Flash Attention 2 attempted first on CUDA.
|
||||
* Entry: `server.py` -> `app/__init__.py` (WhisperServiceAPI)
|
||||
* Modules: `app/core/` (transcriber, config), `app/audio/` (processor, sources, utils), `app/infrastructure/` (logging, validation, storage, async tasks)
|
||||
* Request flow: source function -> validate -> transcribe (AudioProcessor -> Whisper inference) -> save history -> JSON response
|
||||
* OpenAI-compatible API: `/v1/audio/transcriptions` matches OpenAI contract for drop-in replacement
|
||||
* All settings in `config.json`. Device fallback: CUDA -> MPS -> CPU
|
||||
|
||||
@@ -80,8 +80,7 @@ The service is configured through the `config.json` file:
|
||||
"return_timestamps": false,
|
||||
"audio_rate": 8000,
|
||||
"norm_level": "-0.55",
|
||||
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15",
|
||||
"audio_speed_factor": 1.25
|
||||
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -101,7 +100,6 @@ The service is configured through the `config.json` file:
|
||||
| `audio_rate` | Audio sampling rate in Hz |
|
||||
| `norm_level` | Normalization level for audio preprocessing |
|
||||
| `compand_params` | Parameters for audio compression/expansion |
|
||||
| `audio_speed_factor` | Audio speed factor for faster recognition (1.0 = no speedup, 1.25 = 25% faster) |
|
||||
|
||||
## Web interface
|
||||
|
||||
|
||||
@@ -46,13 +46,13 @@ Non-negotiable. Violation = stop and fix before continuing.
|
||||
## Audio Processing Rules
|
||||
|
||||
* FFmpeg and SoX are external dependencies. Always check subprocess return codes.
|
||||
* Temp files must be created via `TempFileManager.temp_file()` context manager -- never raw `tempfile.mktemp`.
|
||||
* Audio pipeline order matters: convert to WAV 16kHz -> normalize -> compress/expand -> speedup -> add silence.
|
||||
* Temp files must be created via `create_temp_file()` from `app/infrastructure/storage.py` -- never raw `tempfile.mktemp`.
|
||||
* Audio pipeline order matters: convert to WAV 16kHz -> normalize -> compress/expand -> add silence.
|
||||
|
||||
## Flask / API Rules
|
||||
|
||||
* New endpoints go in `app/api/routes.py` inside `_register_routes()`.
|
||||
* All transcription endpoints delegate to `TranscriptionService.transcribe_from_source()`.
|
||||
* New audio input methods: subclass `AudioSource`, implement `get_audio_file()`.
|
||||
* New endpoints go in `app/routes.py` inside `_register_routes()`.
|
||||
* All transcription endpoints delegate to `TranscriptionService.transcribe()`.
|
||||
* New audio input methods: add a `get_*_file()` function in `sources.py` returning `(temp_path, filename, error)`.
|
||||
* File validation runs through `FileValidator` -- never validate inline in routes.
|
||||
* Configuration values accessed via `self.config.get()` with sensible defaults, except critical params which must crash if missing.
|
||||
|
||||
+4
-6
@@ -12,11 +12,9 @@ import waitress
|
||||
|
||||
from .core.transcriber import WhisperTranscriber
|
||||
from .core.config import load_config
|
||||
from .api.routes import Routes
|
||||
from .infrastructure.validation.validators import FileValidator
|
||||
from .infrastructure.storage.file_manager import temp_file_manager
|
||||
from .infrastructure.logging.config import setup_logging
|
||||
from .infrastructure.logging.request_logger import RequestLogger
|
||||
from .routes import Routes
|
||||
from .infrastructure.validation import FileValidator
|
||||
from .infrastructure.log import setup_logging, RequestLogger
|
||||
|
||||
|
||||
class WhisperServiceAPI:
|
||||
@@ -60,7 +58,7 @@ class WhisperServiceAPI:
|
||||
self.file_validator = FileValidator(self.config)
|
||||
|
||||
# Настройка логирования запросов
|
||||
request_logger_config = self.config.get('request_logger', {})
|
||||
request_logger_config = self.config.get('request_logging', {})
|
||||
request_logger = RequestLogger(self.app, request_logger_config)
|
||||
|
||||
# Регистрация маршрутов
|
||||
|
||||
+8
-56
@@ -7,11 +7,10 @@
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Dict, Tuple
|
||||
import logging
|
||||
|
||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
||||
from ..infrastructure.storage import create_temp_file, cleanup_temp_files
|
||||
|
||||
logger = logging.getLogger('app.audio_processor')
|
||||
|
||||
@@ -36,8 +35,7 @@ class AudioProcessor:
|
||||
self.config = config
|
||||
self.norm_level = config.get("norm_level", "-0.5")
|
||||
self.compand_params = config.get("compand_params", "0.3,1 -90,-90,-70,-70,-60,-20,0,0 -5 0 0.2")
|
||||
self.audio_speed_factor = config.get("audio_speed_factor", 1.25)
|
||||
|
||||
|
||||
def convert_to_wav(self, input_path: str) -> str:
|
||||
"""
|
||||
Конвертация входного аудиофайла в WAV формат с частотой дискретизации 16 кГц.
|
||||
@@ -66,7 +64,7 @@ class AudioProcessor:
|
||||
# Продолжаем конвертацию, чтобы быть уверенными в формате
|
||||
|
||||
# Создаем временный файл для WAV
|
||||
output_path, _ = temp_file_manager.create_temp_file(".wav")
|
||||
output_path = create_temp_file(".wav")
|
||||
|
||||
# Команда для конвертации
|
||||
cmd = [
|
||||
@@ -103,7 +101,7 @@ class AudioProcessor:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при нормализации.
|
||||
"""
|
||||
# Создаем временный файл для нормализованного аудио
|
||||
output_path, _ = temp_file_manager.create_temp_file("_normalized.wav")
|
||||
output_path = create_temp_file("_normalized.wav")
|
||||
|
||||
# Команда для нормализации аудио с помощью sox
|
||||
cmd = [
|
||||
@@ -124,47 +122,6 @@ class AudioProcessor:
|
||||
logger.error(f"Ошибка при нормализации аудио: {e.stderr.decode()}")
|
||||
raise
|
||||
|
||||
def speed_up_audio(self, input_path: str) -> str:
|
||||
"""
|
||||
Ускоряет воспроизведение аудиофайла с использованием FFmpeg.
|
||||
|
||||
Args:
|
||||
input_path: Путь к WAV-файлу.
|
||||
|
||||
Returns:
|
||||
Путь к ускоренному WAV-файлу.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при ускорении.
|
||||
"""
|
||||
# Если ускорение не требуется (коэффициент = 1.0), возвращаем исходный файл
|
||||
if float(self.audio_speed_factor) == 1.0:
|
||||
logger.info(f"Ускорение не требуется (коэффициент = {self.audio_speed_factor})")
|
||||
return input_path
|
||||
|
||||
# Создаем временный файл для ускоренного аудио
|
||||
output_path, _ = temp_file_manager.create_temp_file("_speedup.wav")
|
||||
|
||||
# Команда для ускорения аудио с помощью FFmpeg
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel", "warning",
|
||||
"-i", input_path,
|
||||
"-filter:a", f"atempo={self.audio_speed_factor}",
|
||||
output_path
|
||||
]
|
||||
|
||||
logger.info(f"Ускорение аудио в {self.audio_speed_factor}x: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
logger.info(f"Аудио ускорено: {output_path}")
|
||||
return output_path
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при ускорении аудио: {e.stderr.decode()}")
|
||||
raise
|
||||
|
||||
def add_silence(self, input_path: str) -> str:
|
||||
"""
|
||||
Добавляет тишину в начало аудиофайла.
|
||||
@@ -179,7 +136,7 @@ class AudioProcessor:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при добавлении тишины.
|
||||
"""
|
||||
# Создаем временный файл
|
||||
output_path, _ = temp_file_manager.create_temp_file("_silence.wav")
|
||||
output_path = create_temp_file("_silence.wav")
|
||||
|
||||
# Команда для добавления тишины в начало файла
|
||||
cmd = [
|
||||
@@ -223,19 +180,14 @@ class AudioProcessor:
|
||||
# Нормализация
|
||||
normalized_path = self.normalize_audio(wav_path)
|
||||
temp_files.append(normalized_path)
|
||||
|
||||
# УСКОРЕНИЕ ЗВУКА (НОВЫЙ ШАГ)
|
||||
speedup_path = self.speed_up_audio(normalized_path)
|
||||
if speedup_path != normalized_path: # Если был создан временный файл
|
||||
temp_files.append(speedup_path)
|
||||
|
||||
|
||||
# Добавление тишины
|
||||
silence_path = self.add_silence(speedup_path)
|
||||
silence_path = self.add_silence(normalized_path)
|
||||
temp_files.append(silence_path)
|
||||
|
||||
return silence_path, temp_files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке аудио {input_path}: {e}")
|
||||
temp_file_manager.cleanup_temp_files(temp_files)
|
||||
cleanup_temp_files(temp_files)
|
||||
raise
|
||||
+108
-284
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Модуль sources.py содержит абстрактный класс AudioSource и его конкретные реализации
|
||||
для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы).
|
||||
Модуль sources.py содержит функции для получения аудиофайлов
|
||||
из различных источников (загруженные файлы, URL, base64, локальные файлы).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -8,305 +8,129 @@ import uuid
|
||||
import tempfile
|
||||
import base64
|
||||
import requests
|
||||
import abc
|
||||
from typing import Dict, Tuple, Optional, BinaryIO
|
||||
from typing import Tuple, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.audio_sources')
|
||||
|
||||
class AudioSource(abc.ABC):
|
||||
"""Абстрактный класс для различных источников аудиофайлов.
|
||||
|
||||
Определяет интерфейс для различных источников аудио и предоставляет общие
|
||||
методы для работы с аудиофайлами, такие как проверка размера файла.
|
||||
|
||||
def _check_size(size_bytes: int, max_size_mb: int) -> Optional[str]:
|
||||
"""Проверяет размер файла. Возвращает сообщение об ошибке или None."""
|
||||
if size_bytes > max_size_mb * 1024 * 1024:
|
||||
return f"File exceeds maximum size of {max_size_mb}MB"
|
||||
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 __init__(self, max_file_size_mb: int = 100):
|
||||
"""
|
||||
Инициализация источника аудио.
|
||||
|
||||
Args:
|
||||
max_file_size_mb: Максимальный размер файла в МБ.
|
||||
"""
|
||||
self.max_file_size_mb = max_file_size_mb
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Получает аудиофайл из источника.
|
||||
|
||||
Returns:
|
||||
Кортеж (файловый объект, имя файла, сообщение об ошибке).
|
||||
В случае ошибки, возвращает (None, None, сообщение об ошибке).
|
||||
"""
|
||||
pass
|
||||
|
||||
def check_file_size(self, file: BinaryIO) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Проверяет размер файла.
|
||||
|
||||
Args:
|
||||
file: Файловый объект для проверки.
|
||||
|
||||
Returns:
|
||||
Кортеж (результат проверки, сообщение об ошибке).
|
||||
Если проверка пройдена, сообщение об ошибке будет None.
|
||||
"""
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_length = file.tell()
|
||||
file.seek(0) # Сброс указателя файла после проверки размера
|
||||
|
||||
if file_length > self.max_file_size_mb * 1024 * 1024:
|
||||
return False, f"File exceeds maximum size of {self.max_file_size_mb}MB"
|
||||
|
||||
return True, None
|
||||
Получает аудиофайл из загруженных файлов Flask.
|
||||
|
||||
|
||||
class FakeFile:
|
||||
"""Имитирует файловый объект для унификации обработки из разных источников.
|
||||
|
||||
Позволяет обрабатывать файлы из различных источников (локальный путь, URL, base64)
|
||||
как стандартные файловые объекты Flask, обеспечивая совместимость с существующей
|
||||
логикой обработки файлов.
|
||||
Returns:
|
||||
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
||||
"""
|
||||
|
||||
def __init__(self, file: BinaryIO, filename: str):
|
||||
"""
|
||||
Инициализация объекта FakeFile.
|
||||
|
||||
Args:
|
||||
file: Исходный файловый объект или поток.
|
||||
filename: Имя файла для метаданных.
|
||||
"""
|
||||
self.file = file
|
||||
self.filename = filename
|
||||
if 'file' not in request_files:
|
||||
return None, None, "No file part"
|
||||
|
||||
def read(self):
|
||||
"""Чтение содержимого файла."""
|
||||
return self.file.read()
|
||||
file = request_files['file']
|
||||
|
||||
def seek(self, offset: int, whence: int = 0):
|
||||
"""Перемещение позиции чтения."""
|
||||
self.file.seek(offset, whence)
|
||||
if file.filename == '':
|
||||
return None, None, "No selected file"
|
||||
|
||||
def tell(self):
|
||||
"""Получение текущей позиции чтения."""
|
||||
return self.file.tell()
|
||||
# Проверка размера
|
||||
file.seek(0, os.SEEK_END)
|
||||
size = file.tell()
|
||||
file.seek(0)
|
||||
|
||||
def save(self, destination: str):
|
||||
"""
|
||||
Сохраняет содержимое файла в указанное место назначения.
|
||||
|
||||
Args:
|
||||
destination: Путь для сохранения файла.
|
||||
"""
|
||||
with open(destination, 'wb') as f:
|
||||
content = self.file.read()
|
||||
f.write(content)
|
||||
self.file.seek(0) # Сброс указателя после чтения
|
||||
error = _check_size(size, max_file_size_mb)
|
||||
if error:
|
||||
return None, None, error
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Возвращает имя файла."""
|
||||
return self.filename
|
||||
# Сохраняем во временный файл
|
||||
temp_path = _make_temp_path()
|
||||
file.save(temp_path)
|
||||
|
||||
return temp_path, file.filename, None
|
||||
|
||||
|
||||
class UploadedFileSource(AudioSource):
|
||||
"""Источник аудио для файлов, загруженных через HTTP-запрос."""
|
||||
|
||||
def __init__(self, request_files, max_file_size_mb: int = 100):
|
||||
"""
|
||||
Инициализация источника для загруженных файлов.
|
||||
|
||||
Args:
|
||||
request_files: Объект request.files из Flask.
|
||||
max_file_size_mb: Максимальный размер файла в МБ.
|
||||
"""
|
||||
super().__init__(max_file_size_mb)
|
||||
self.request_files = request_files
|
||||
|
||||
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Получает аудиофайл из загруженных файлов.
|
||||
|
||||
Returns:
|
||||
Кортеж (файловый объект, имя файла, сообщение об ошибке).
|
||||
"""
|
||||
if 'file' not in self.request_files:
|
||||
return None, None, "No file part"
|
||||
|
||||
file = self.request_files['file']
|
||||
|
||||
if file.filename == '':
|
||||
return None, None, "No selected file"
|
||||
|
||||
# Проверка размера файла
|
||||
is_valid, error_message = self.check_file_size(file)
|
||||
if not is_valid:
|
||||
return None, None, error_message
|
||||
|
||||
return file, file.filename, None
|
||||
def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Получает аудиофайл по URL.
|
||||
|
||||
Returns:
|
||||
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
||||
"""
|
||||
try:
|
||||
response = requests.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
# Проверка размера по Content-Length
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length:
|
||||
error = _check_size(int(content_length), max_file_size_mb)
|
||||
if error:
|
||||
return None, None, error
|
||||
|
||||
temp_path = _make_temp_path()
|
||||
with open(temp_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
return temp_path, os.path.basename(temp_path), None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении файла по URL {url}: {e}")
|
||||
return None, None, f"Error retrieving file from URL: {str(e)}"
|
||||
|
||||
|
||||
class URLSource(AudioSource):
|
||||
"""Источник аудио для файлов, доступных по URL."""
|
||||
|
||||
def __init__(self, url: str, max_file_size_mb: int = 100):
|
||||
"""
|
||||
Инициализация источника для файлов по URL.
|
||||
|
||||
Args:
|
||||
url: URL аудиофайла.
|
||||
max_file_size_mb: Максимальный размер файла в МБ.
|
||||
"""
|
||||
super().__init__(max_file_size_mb)
|
||||
self.url = url
|
||||
self.temp_file_path = None
|
||||
self.temp_dir = None
|
||||
|
||||
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Получает аудиофайл по URL.
|
||||
|
||||
Returns:
|
||||
Кортеж (файловый объект, имя файла, сообщение об ошибке).
|
||||
"""
|
||||
try:
|
||||
# Скачиваем файл по URL
|
||||
response = requests.get(self.url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
# Проверка размера файла (если сервер предоставил информацию о размере)
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length and int(content_length) > self.max_file_size_mb * 1024 * 1024:
|
||||
return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB"
|
||||
|
||||
# Сохраняем файл во временный файл
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.temp_file_path = os.path.join(self.temp_dir, str(uuid.uuid4()) + ".wav")
|
||||
|
||||
with open(self.temp_file_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
# Открываем файл для обработки
|
||||
file = open(self.temp_file_path, 'rb')
|
||||
|
||||
# Создаем объект файла, как будто он пришел из request.files
|
||||
fake_file = FakeFile(file, os.path.basename(self.temp_file_path))
|
||||
|
||||
return fake_file, fake_file.filename, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении файла по URL {self.url}: {e}")
|
||||
self.cleanup()
|
||||
return None, None, f"Error retrieving file from URL: {str(e)}"
|
||||
|
||||
def cleanup(self):
|
||||
"""Очищает временные файлы и директории."""
|
||||
if self.temp_file_path and os.path.exists(self.temp_file_path):
|
||||
os.remove(self.temp_file_path)
|
||||
if self.temp_dir and os.path.exists(self.temp_dir):
|
||||
os.rmdir(self.temp_dir)
|
||||
def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Получает аудиофайл из base64 данных.
|
||||
|
||||
Returns:
|
||||
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
||||
"""
|
||||
try:
|
||||
audio_data = base64.b64decode(base64_data)
|
||||
|
||||
error = _check_size(len(audio_data), max_file_size_mb)
|
||||
if error:
|
||||
return None, None, error
|
||||
|
||||
temp_path = _make_temp_path()
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(audio_data)
|
||||
|
||||
return temp_path, os.path.basename(temp_path), None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при декодировании base64 данных: {e}")
|
||||
return None, None, f"Error decoding base64 data: {str(e)}"
|
||||
|
||||
|
||||
class Base64Source(AudioSource):
|
||||
"""Источник аудио для файлов, закодированных в base64."""
|
||||
|
||||
def __init__(self, base64_data: str, max_file_size_mb: int = 100):
|
||||
"""
|
||||
Инициализация источника для base64 файлов.
|
||||
|
||||
Args:
|
||||
base64_data: Данные аудиофайла в формате base64.
|
||||
max_file_size_mb: Максимальный размер файла в МБ.
|
||||
"""
|
||||
super().__init__(max_file_size_mb)
|
||||
self.base64_data = base64_data
|
||||
self.temp_file_path = None
|
||||
self.temp_dir = None
|
||||
|
||||
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Получает аудиофайл из base64 данных.
|
||||
|
||||
Returns:
|
||||
Кортеж (файловый объект, имя файла, сообщение об ошибке).
|
||||
"""
|
||||
try:
|
||||
# Декодируем base64
|
||||
audio_data = base64.b64decode(self.base64_data)
|
||||
|
||||
# Проверка размера файла
|
||||
if len(audio_data) > self.max_file_size_mb * 1024 * 1024:
|
||||
return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB"
|
||||
|
||||
# Сохраняем файл во временный файл
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.temp_file_path = os.path.join(self.temp_dir, str(uuid.uuid4()) + ".wav")
|
||||
|
||||
with open(self.temp_file_path, 'wb') as f:
|
||||
f.write(audio_data)
|
||||
|
||||
# Открываем файл для обработки
|
||||
file = open(self.temp_file_path, 'rb')
|
||||
|
||||
# Создаем объект файла, как будто он пришел из request.files
|
||||
fake_file = FakeFile(file, os.path.basename(self.temp_file_path))
|
||||
|
||||
return fake_file, fake_file.filename, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при декодировании base64 данных: {e}")
|
||||
self.cleanup()
|
||||
return None, None, f"Error decoding base64 data: {str(e)}"
|
||||
|
||||
def cleanup(self):
|
||||
"""Очищает временные файлы и директории."""
|
||||
if self.temp_file_path and os.path.exists(self.temp_file_path):
|
||||
os.remove(self.temp_file_path)
|
||||
if self.temp_dir and os.path.exists(self.temp_dir):
|
||||
os.rmdir(self.temp_dir)
|
||||
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}"
|
||||
|
||||
class LocalFileSource(AudioSource):
|
||||
"""Источник аудио для локальных файлов на сервере."""
|
||||
|
||||
def __init__(self, file_path: str, max_file_size_mb: int = 100):
|
||||
"""
|
||||
Инициализация источника для локальных файлов.
|
||||
|
||||
Args:
|
||||
file_path: Путь к локальному файлу.
|
||||
max_file_size_mb: Максимальный размер файла в МБ.
|
||||
"""
|
||||
super().__init__(max_file_size_mb)
|
||||
self.file_path = file_path
|
||||
|
||||
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Получает локальный аудиофайл.
|
||||
|
||||
Returns:
|
||||
Кортеж (файловый объект, имя файла, сообщение об ошибке).
|
||||
"""
|
||||
if not os.path.exists(self.file_path):
|
||||
return None, None, f"File not found: {self.file_path}"
|
||||
|
||||
try:
|
||||
# Проверка размера файла
|
||||
file_size = os.path.getsize(self.file_path)
|
||||
if file_size > self.max_file_size_mb * 1024 * 1024:
|
||||
return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB"
|
||||
|
||||
# Открываем файл для обработки
|
||||
file = open(self.file_path, 'rb')
|
||||
|
||||
# Создаем объект файла, как будто он пришел из request.files
|
||||
fake_file = FakeFile(file, os.path.basename(self.file_path))
|
||||
|
||||
return fake_file, fake_file.filename, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при открытии локального файла {self.file_path}: {e}")
|
||||
return None, None, f"Error opening local file: {str(e)}"
|
||||
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)}"
|
||||
|
||||
+62
-94
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Модуль utils.py содержит утилитарные функции для работы с аудио.
|
||||
Утилитарные функции для работы с аудио.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -12,98 +12,66 @@ from typing import Tuple
|
||||
logger = logging.getLogger('app.audio_utils')
|
||||
|
||||
|
||||
class AudioUtils:
|
||||
"""Утилитарный класс для работы с аудио."""
|
||||
|
||||
@staticmethod
|
||||
def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
|
||||
"""
|
||||
Загрузка аудиофайла с использованием встроенной библиотеки wave.
|
||||
def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
|
||||
"""
|
||||
Загрузка аудиофайла с использованием встроенной библиотеки wave.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
sr: Целевая частота дискретизации.
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
sr: Целевая частота дискретизации.
|
||||
|
||||
Returns:
|
||||
Кортеж (массив numpy, частота дискретизации).
|
||||
|
||||
Raises:
|
||||
Exception: Если не удалось загрузить аудиофайл.
|
||||
"""
|
||||
try:
|
||||
# Открываем WAV файл
|
||||
with wave.open(file_path, 'rb') as wav_file:
|
||||
# Проверяем, что это моно-аудио
|
||||
if wav_file.getnchannels() != 1:
|
||||
logger.warning(f"Файл {file_path} не моно-аудио, конвертируем в моно")
|
||||
|
||||
# Читаем аудиоданные
|
||||
frames = wav_file.readframes(-1)
|
||||
# Конвертируем 16-битные целые числа в float32 в диапазоне [-1.0, 1.0]
|
||||
# 32768.0 - это 2^15, максимальное значение для 16-битного знакового целого
|
||||
audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
# Получаем частоту дискретизации
|
||||
sampling_rate = wav_file.getframerate()
|
||||
|
||||
# Если частота дискретизации не совпадает с целевой, выполняем ресемплинг
|
||||
if sampling_rate != sr:
|
||||
from scipy.signal import resample
|
||||
num_samples = int(len(audio_array) * sr / sampling_rate)
|
||||
audio_array = resample(audio_array, num_samples)
|
||||
sampling_rate = sr
|
||||
|
||||
return audio_array, sampling_rate
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_audio_duration(file_path: str) -> float:
|
||||
"""
|
||||
Определяет длительность аудиофайла с использованием ffprobe.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
|
||||
Returns:
|
||||
Длительность в секундах.
|
||||
"""
|
||||
try:
|
||||
# Проверяем, что файл существует
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"Файл не существует: {file_path}")
|
||||
raise Exception(f"Файл не существует: {file_path}")
|
||||
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
file_path
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=10 # Ограничение по времени выполнения
|
||||
)
|
||||
|
||||
duration = float(result.stdout.strip())
|
||||
return duration
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"Таймаут при определении длительности файла {file_path}")
|
||||
raise Exception(f"Таймаут при определении длительности файла {file_path}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при выполнении ffprobe для файла {file_path}: {e.stderr}")
|
||||
raise Exception(f"Ошибка при выполнении ffprobe для файла {file_path}: {e.stderr}")
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"Ошибка при преобразовании длительности для файла {file_path}: {e}")
|
||||
raise Exception(f"Ошибка при преобразовании длительности для файла {file_path}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при определении длительности файла {file_path}: {e}")
|
||||
raise Exception(f"Неожиданная ошибка при определении длительности файла {file_path}: {e}")
|
||||
Returns:
|
||||
Кортеж (массив numpy, частота дискретизации).
|
||||
"""
|
||||
try:
|
||||
with wave.open(file_path, 'rb') as wav_file:
|
||||
if wav_file.getnchannels() != 1:
|
||||
logger.warning(f"Файл {file_path} не моно-аудио")
|
||||
|
||||
frames = wav_file.readframes(-1)
|
||||
audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
sampling_rate = wav_file.getframerate()
|
||||
|
||||
if sampling_rate != sr:
|
||||
from scipy.signal import resample
|
||||
num_samples = int(len(audio_array) * sr / sampling_rate)
|
||||
audio_array = resample(audio_array, num_samples)
|
||||
sampling_rate = sr
|
||||
|
||||
return audio_array, sampling_rate
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def get_audio_duration(file_path: str) -> float:
|
||||
"""
|
||||
Определяет длительность аудиофайла с использованием ffprobe.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
|
||||
Returns:
|
||||
Длительность в секундах.
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
raise Exception(f"Файл не существует: {file_path}")
|
||||
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
file_path
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=10)
|
||||
return float(result.stdout.strip())
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Exception(f"Таймаут при определении длительности файла {file_path}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise Exception(f"Ошибка ffprobe для файла {file_path}: {e.stderr}")
|
||||
except (ValueError, TypeError) as e:
|
||||
raise Exception(f"Ошибка при преобразовании длительности для файла {file_path}: {e}")
|
||||
|
||||
@@ -20,8 +20,8 @@ from transformers import (
|
||||
)
|
||||
|
||||
from ..audio.processor import AudioProcessor
|
||||
from ..audio.utils import AudioUtils
|
||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
||||
from ..audio.utils import load_audio
|
||||
from ..infrastructure.storage import cleanup_temp_files
|
||||
|
||||
logger = logging.getLogger('app.transcriber')
|
||||
|
||||
@@ -177,7 +177,7 @@ class WhisperTranscriber:
|
||||
|
||||
try:
|
||||
# Загрузка аудио в формате numpy array
|
||||
audio_array, sampling_rate = AudioUtils.load_audio(audio_path, sr=16000)
|
||||
audio_array, sampling_rate = load_audio(audio_path, sr=16000)
|
||||
|
||||
# Транскрибация с корректным форматом данных
|
||||
result = self.asr_pipeline(
|
||||
@@ -279,4 +279,4 @@ class WhisperTranscriber:
|
||||
|
||||
finally:
|
||||
# Очистка временных файлов
|
||||
temp_file_manager.cleanup_temp_files(temp_files)
|
||||
cleanup_temp_files(temp_files)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Модуль transcription_service.py содержит класс TranscriptionService,
|
||||
который отвечает за обработку и транскрибацию аудиофайлов.
|
||||
который отвечает за транскрибацию аудиофайлов.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -9,74 +9,31 @@ import traceback
|
||||
from typing import Dict, Tuple
|
||||
import logging
|
||||
|
||||
from ..audio.utils import AudioUtils
|
||||
from ..shared.history_logger import HistoryLogger
|
||||
from ..audio.sources import AudioSource
|
||||
from ..infrastructure.validation.validators import FileValidator, ValidationError
|
||||
from ..audio.utils import get_audio_duration
|
||||
from ..history import save_history
|
||||
|
||||
logger = logging.getLogger('app.transcription_service')
|
||||
|
||||
|
||||
class TranscriptionService:
|
||||
"""
|
||||
Сервис для обработки и транскрибации аудиофайлов.
|
||||
|
||||
Attributes:
|
||||
transcriber: Экземпляр транскрайбера.
|
||||
config (Dict): Словарь с конфигурацией.
|
||||
max_file_size_mb (int): Максимальный размер файла в МБ.
|
||||
history (HistoryLogger): Объект журналирования.
|
||||
"""
|
||||
"""Сервис для транскрибации аудиофайлов."""
|
||||
|
||||
def __init__(self, transcriber, config: Dict):
|
||||
"""
|
||||
Инициализация сервиса транскрибации.
|
||||
|
||||
Args:
|
||||
transcriber: Экземпляр транскрайбера.
|
||||
config: Словарь с конфигурацией.
|
||||
"""
|
||||
self.transcriber = transcriber
|
||||
self.config = config
|
||||
self.max_file_size_mb = self.config.get("file_validation", {}).get("max_file_size_mb", 100)
|
||||
|
||||
# Объект журналирования
|
||||
self.history = HistoryLogger(config)
|
||||
|
||||
def transcribe_from_source(self, source: AudioSource, params: Dict = None, file_validator: FileValidator = None) -> Tuple[Dict, int]:
|
||||
def transcribe(self, file_path: str, filename: str, params: Dict = None) -> Tuple[Dict, int]:
|
||||
"""
|
||||
Транскрибирует аудиофайл из указанного источника.
|
||||
Транскрибирует аудиофайл по пути.
|
||||
|
||||
Args:
|
||||
source: Источник аудиофайла.
|
||||
file_path: Путь к аудиофайлу.
|
||||
filename: Имя файла (для логов и истории).
|
||||
params: Дополнительные параметры для транскрибации.
|
||||
file_validator: Валидатор файлов.
|
||||
|
||||
Returns:
|
||||
Кортеж (JSON-ответ, HTTP-код).
|
||||
"""
|
||||
# Получаем файл из источника
|
||||
file, filename, error = source.get_audio_file()
|
||||
|
||||
# Обрабатываем ошибки получения файла
|
||||
if error:
|
||||
logger.warning(f"Ошибка получения файла из источника: {error}")
|
||||
return {"error": error}, 400
|
||||
|
||||
if not file:
|
||||
logger.warning("Не удалось получить аудиофайл из источника")
|
||||
return {"error": "Failed to get audio file"}, 400
|
||||
|
||||
# Валидация файла, если предоставлен валидатор
|
||||
if file_validator:
|
||||
try:
|
||||
file_validator.validate_file(file, filename)
|
||||
except ValidationError as e:
|
||||
# Логирование ошибки валидации
|
||||
logger.warning(f"Ошибка валидации файла '{filename}': {str(e)}")
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
# Извлекаем параметры из запроса, если они есть
|
||||
params = params or {}
|
||||
language = params.get('language', self.config.get('language', 'en'))
|
||||
temperature = float(params.get('temperature', 0.0))
|
||||
@@ -84,7 +41,6 @@ class TranscriptionService:
|
||||
|
||||
# Проверяем, запрошены ли временные метки
|
||||
return_timestamps = params.get('return_timestamps', self.config.get('return_timestamps', False))
|
||||
# Преобразуем строковое значение в булево, если необходимо
|
||||
if isinstance(return_timestamps, str):
|
||||
return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1')
|
||||
|
||||
@@ -92,59 +48,44 @@ class TranscriptionService:
|
||||
original_return_timestamps = self.transcriber.return_timestamps
|
||||
self.transcriber.return_timestamps = return_timestamps
|
||||
|
||||
# Сохраняем файл во временный файл
|
||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
||||
with temp_file_manager.temp_file() as temp_file_path:
|
||||
file.save(temp_file_path)
|
||||
|
||||
try:
|
||||
# Определяем длительность аудиофайла
|
||||
try:
|
||||
duration = AudioUtils.get_audio_duration(temp_file_path)
|
||||
duration = get_audio_duration(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при определении длительности файла: {e}")
|
||||
return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500
|
||||
|
||||
# Для файлов из внешних источников (URL, base64), закрываем их и выполняем очистку
|
||||
if hasattr(source, 'cleanup'):
|
||||
file.file.close() # Закрываем файловый объект
|
||||
source.cleanup() # Очищаем временные файлы источника
|
||||
start_time = time.time()
|
||||
result = self.transcriber.process_file(file_path)
|
||||
processing_time = time.time() - start_time
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
result = self.transcriber.process_file(temp_file_path)
|
||||
processing_time = time.time() - start_time
|
||||
# Формируем ответ
|
||||
if return_timestamps:
|
||||
response = {
|
||||
"segments": result.get("segments", []),
|
||||
"text": result.get("text", ""),
|
||||
"processing_time": processing_time,
|
||||
"response_size_bytes": len(str(result).encode('utf-8')),
|
||||
"duration_seconds": duration,
|
||||
"model": os.path.basename(self.config["model_path"])
|
||||
}
|
||||
else:
|
||||
response = {
|
||||
"text": result,
|
||||
"processing_time": processing_time,
|
||||
"response_size_bytes": len(str(result).encode('utf-8')),
|
||||
"duration_seconds": duration,
|
||||
"model": os.path.basename(self.config["model_path"])
|
||||
}
|
||||
|
||||
# Формируем ответ в зависимости от return_timestamps
|
||||
if return_timestamps:
|
||||
response = {
|
||||
"segments": result.get("segments", []),
|
||||
"text": result.get("text", ""),
|
||||
"processing_time": processing_time,
|
||||
"response_size_bytes": len(str(result).encode('utf-8')),
|
||||
"duration_seconds": duration,
|
||||
"model": os.path.basename(self.config["model_path"])
|
||||
}
|
||||
else:
|
||||
# Если не запрашивались временные метки, result - это строка
|
||||
response = {
|
||||
"text": result,
|
||||
"processing_time": processing_time,
|
||||
"response_size_bytes": len(str(result).encode('utf-8')),
|
||||
"duration_seconds": duration,
|
||||
"model": os.path.basename(self.config["model_path"])
|
||||
}
|
||||
save_history(response, filename, self.config)
|
||||
return response, 200
|
||||
|
||||
# Журналирование результата
|
||||
self.history.save(response, filename)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
return response, 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
|
||||
logger.error(f"Тип исключения: {type(e).__name__}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
finally:
|
||||
# Восстанавливаем оригинальное значение return_timestamps
|
||||
self.transcriber.return_timestamps = original_return_timestamps
|
||||
finally:
|
||||
self.transcriber.return_timestamps = original_return_timestamps
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Модуль history_logger.py — сохранение истории транскрибации в JSON-файлы.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
import random
|
||||
import string
|
||||
from typing import Dict, Any, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.history')
|
||||
|
||||
# Корневая директория истории (относительно корня проекта)
|
||||
_history_root = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "history")
|
||||
|
||||
|
||||
def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -> Optional[str]:
|
||||
"""
|
||||
Сохраняет результат транскрибации в файл истории.
|
||||
|
||||
Args:
|
||||
result: Результат транскрибации.
|
||||
original_filename: Исходное имя аудиофайла.
|
||||
config: Конфигурация (проверяется enable_history).
|
||||
|
||||
Returns:
|
||||
Путь к сохранённому файлу или None.
|
||||
"""
|
||||
if not config.get("enable_history", False):
|
||||
return None
|
||||
|
||||
try:
|
||||
os.makedirs(_history_root, exist_ok=True)
|
||||
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%Y-%m-%d")
|
||||
timestamp_ms = int(now.timestamp() * 1000)
|
||||
random_tag = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4))
|
||||
base_filename = os.path.basename(original_filename)
|
||||
|
||||
date_dir = os.path.join(_history_root, date_str)
|
||||
os.makedirs(date_dir, exist_ok=True)
|
||||
|
||||
history_path = os.path.join(date_dir, f"{timestamp_ms}_{base_filename}_{random_tag}.json")
|
||||
|
||||
with open(history_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"Результат сохранён в историю: {history_path}")
|
||||
return history_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сохранении истории: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Настройка логирования и middleware для логирования HTTP-запросов.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from flask import request, g
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
def setup_logging(log_level=logging.INFO, log_file=None):
|
||||
"""
|
||||
Настройка логирования для всего приложения.
|
||||
|
||||
Args:
|
||||
log_level: Уровень логирования (по умолчанию INFO).
|
||||
log_file: Путь к файлу для записи логов (опционально).
|
||||
"""
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# Очищаем существующие обработчики
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
class CustomFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
if not hasattr(record, 'type'):
|
||||
record.type = 'general'
|
||||
return super().format(record)
|
||||
|
||||
formatter = CustomFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - [%(type)s] %(message)s'
|
||||
)
|
||||
|
||||
# Консольный обработчик
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# Файловый обработчик
|
||||
if log_file:
|
||||
log_dir = os.path.dirname(log_file)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
file_handler = RotatingFileHandler(log_file, maxBytes=10*1024*1024, backupCount=5)
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
logging.getLogger('app').setLevel(log_level)
|
||||
logging.getLogger('app.request').setLevel(log_level)
|
||||
|
||||
return root_logger
|
||||
|
||||
|
||||
class RequestLogger:
|
||||
"""Middleware для логирования HTTP-запросов и ответов."""
|
||||
|
||||
def __init__(self, app=None, config: Optional[Dict] = None):
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger('app.request')
|
||||
self.exclude_endpoints = set(self.config.get('exclude_endpoints', ['/health', '/static']))
|
||||
|
||||
if app is not None:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Регистрация middleware в Flask-приложении."""
|
||||
app.before_request(self._before_request)
|
||||
app.after_request(self._after_request)
|
||||
|
||||
def _should_log(self) -> bool:
|
||||
for excluded in self.exclude_endpoints:
|
||||
if request.path.startswith(excluded):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_client_ip(self) -> str:
|
||||
return (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.headers.get('X-Real-IP')
|
||||
or request.remote_addr
|
||||
or 'unknown')
|
||||
|
||||
def _before_request(self):
|
||||
if not self._should_log():
|
||||
return
|
||||
g.start_time = time.time()
|
||||
self.logger.info(
|
||||
f"{request.method} {request.path} от {self._get_client_ip()}",
|
||||
extra={"type": "request"}
|
||||
)
|
||||
|
||||
def _after_request(self, response):
|
||||
if not self._should_log():
|
||||
return response
|
||||
processing_time = time.time() - getattr(g, 'start_time', time.time())
|
||||
self.logger.info(
|
||||
f"{response.status_code} за {processing_time:.3f} сек",
|
||||
extra={"type": "response"}
|
||||
)
|
||||
return response
|
||||
@@ -1,64 +0,0 @@
|
||||
"""
|
||||
Модуль config.py содержит централизованную настройку логирования.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
|
||||
def setup_logging(log_level=logging.INFO, log_file=None):
|
||||
"""
|
||||
Настройка логирования для всего приложения.
|
||||
|
||||
Args:
|
||||
log_level: Уровень логирования (по умолчанию INFO).
|
||||
log_file: Путь к файлу для записи логов (опционально).
|
||||
"""
|
||||
# Создаем корневой логгер
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# Очищаем существующие обработчики
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# Создаем улучшенный форматтер с поддержкой дополнительных полей
|
||||
class CustomFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
# Добавляем поле type если оно отсутствует
|
||||
if not hasattr(record, 'type'):
|
||||
record.type = 'general'
|
||||
return super().format(record)
|
||||
|
||||
formatter = CustomFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - [%(type)s] %(message)s'
|
||||
)
|
||||
|
||||
# Добавляем обработчик для вывода в консоль
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# Добавляем обработчик для записи в файл, если указан путь
|
||||
if log_file:
|
||||
# Создаем директорию для файла логов, если она не существует
|
||||
log_dir = os.path.dirname(log_file)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10*1024*1024, # 10 МБ
|
||||
backupCount=5
|
||||
)
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Устанавливаем уровень логирования для логгеров в других модулях
|
||||
logging.getLogger('app').setLevel(log_level)
|
||||
logging.getLogger('app.request').setLevel(log_level)
|
||||
|
||||
return root_logger
|
||||
@@ -1,234 +0,0 @@
|
||||
"""
|
||||
Модуль request_logger.py содержит middleware для логирования входящих запросов и ответов.
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from flask import request, g
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class RequestLogger:
|
||||
"""
|
||||
Middleware для логирования входящих запросов и ответов.
|
||||
"""
|
||||
|
||||
def __init__(self, app=None, config: Optional[Dict] = None):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
Args:
|
||||
app: Flask приложение.
|
||||
config: Конфигурация логирования.
|
||||
"""
|
||||
self.app = app
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger('app.request')
|
||||
|
||||
# Чувствительные заголовки для фильтрации
|
||||
self.sensitive_headers = set(self.config.get(
|
||||
'sensitive_headers',
|
||||
['authorization', 'cookie', 'set-cookie', 'proxy-authorization', 'x-api-key']
|
||||
))
|
||||
|
||||
# Эндпоинты для исключения из логирования
|
||||
self.exclude_endpoints = set(self.config.get('exclude_endpoints', ['/health', '/static']))
|
||||
|
||||
if app is not None:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Инициализация middleware с Flask приложением."""
|
||||
app.before_request(self._before_request)
|
||||
app.after_request(self._after_request)
|
||||
|
||||
def _should_log_request(self) -> bool:
|
||||
"""Проверка, нужно ли логировать текущий запрос."""
|
||||
# Проверяем, исключен ли эндпоинт
|
||||
path = request.path
|
||||
for excluded in self.exclude_endpoints:
|
||||
if path.startswith(excluded):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _before_request(self):
|
||||
"""Логирование входящего запроса."""
|
||||
if not self._should_log_request():
|
||||
return
|
||||
|
||||
g.start_time = time.time()
|
||||
|
||||
# Определяем режим логирования
|
||||
debug_mode = self.config.get('log_debug', False)
|
||||
|
||||
# Сбор информации о запросе
|
||||
request_info = self._extract_request_info(debug=debug_mode)
|
||||
|
||||
# Логирование в зависимости от режима
|
||||
if debug_mode:
|
||||
self._log_debug_request(request_info)
|
||||
else:
|
||||
message = self._format_request_message(request_info)
|
||||
self.logger.info(
|
||||
message,
|
||||
extra={"type": "request"}
|
||||
)
|
||||
|
||||
def _after_request(self, response):
|
||||
"""Логирование ответа."""
|
||||
if not self._should_log_request():
|
||||
return response
|
||||
|
||||
# Расчет времени обработки
|
||||
processing_time = time.time() - getattr(g, 'start_time', time.time())
|
||||
|
||||
# Определяем режим логирования
|
||||
debug_mode = self.config.get('log_debug', False)
|
||||
|
||||
# Логирование в зависимости от режима
|
||||
if debug_mode:
|
||||
self._log_debug_response(response, processing_time)
|
||||
else:
|
||||
message = self._format_response_message(response, processing_time)
|
||||
self.logger.info(
|
||||
message,
|
||||
extra={"type": "response"}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _extract_request_info(self, debug: bool = False) -> Dict[str, Any]:
|
||||
"""Извлечение информации о запросе."""
|
||||
# Базовая информация
|
||||
info = {
|
||||
"endpoint": request.endpoint or str(request.url_rule),
|
||||
"method": request.method,
|
||||
"path": request.path,
|
||||
"client_ip": self._get_client_ip(),
|
||||
"user_agent": request.headers.get('User-Agent', 'Unknown')
|
||||
}
|
||||
|
||||
# Параметры запроса
|
||||
if request.args:
|
||||
info["query_params"] = dict(request.args)
|
||||
|
||||
# Данные формы (исключая файлы)
|
||||
if request.form:
|
||||
info["form_data"] = dict(request.form)
|
||||
|
||||
# JSON данные
|
||||
if request.is_json:
|
||||
try:
|
||||
info["json_data"] = request.get_json()
|
||||
except Exception:
|
||||
info["json_data"] = "Invalid JSON"
|
||||
|
||||
# Информация о файлах
|
||||
if request.files:
|
||||
file_info = {}
|
||||
for key, file in request.files.items():
|
||||
file_info[key] = {
|
||||
"filename": file.filename,
|
||||
"content_type": file.content_type,
|
||||
"content_length": len(file.read()) if file else 0
|
||||
}
|
||||
file.seek(0) # Возвращаем указатель файла
|
||||
info["files"] = file_info
|
||||
|
||||
# Заголовки
|
||||
if debug:
|
||||
# В отладочном режиме логируем все заголовки
|
||||
headers = dict(request.headers)
|
||||
else:
|
||||
# В обычном режиме фильтруем чувствительные заголовки
|
||||
headers = {}
|
||||
for key, value in request.headers:
|
||||
if key.lower() not in self.sensitive_headers:
|
||||
headers[key] = value
|
||||
info["headers"] = headers
|
||||
|
||||
return info
|
||||
|
||||
def _log_debug_request(self, request_info: Dict[str, Any]):
|
||||
"""Логирование полных данных запроса в отладочном режиме."""
|
||||
debug_data = {
|
||||
"timestamp": time.time(),
|
||||
"type": "request",
|
||||
"data": request_info
|
||||
}
|
||||
self.logger.info(
|
||||
"DEBUG REQUEST: %s",
|
||||
json.dumps(debug_data, ensure_ascii=False, default=str)
|
||||
)
|
||||
|
||||
def _log_debug_response(self, response, processing_time: float):
|
||||
"""Логирование полных данных ответа в отладочном режиме."""
|
||||
response_info = {
|
||||
"status_code": response.status_code,
|
||||
"headers": dict(response.headers),
|
||||
"content_length": response.content_length,
|
||||
"processing_time": round(processing_time, 3)
|
||||
}
|
||||
debug_data = {
|
||||
"timestamp": time.time(),
|
||||
"type": "response",
|
||||
"data": response_info
|
||||
}
|
||||
self.logger.info(
|
||||
"DEBUG RESPONSE: %s",
|
||||
json.dumps(debug_data, ensure_ascii=False, default=str)
|
||||
)
|
||||
|
||||
def _format_request_message(self, request_info: Dict[str, Any]) -> str:
|
||||
"""Форматирование сообщения с деталями запроса."""
|
||||
# Базовая информация
|
||||
method = request_info.get("method", "UNKNOWN")
|
||||
path = request_info.get("path", "/")
|
||||
client_ip = request_info.get("client_ip", "unknown")
|
||||
user_agent = request_info.get("user_agent", "Unknown")
|
||||
|
||||
# Информация о файлах
|
||||
file_info = ""
|
||||
if "files" in request_info and request_info["files"]:
|
||||
file_details = []
|
||||
for file_key, file_data in request_info["files"].items():
|
||||
filename = file_data.get("filename", "unknown")
|
||||
size = file_data.get("content_length", 0)
|
||||
file_details.append(f"{filename} ({size} байт)")
|
||||
file_info = f" файлы: {', '.join(file_details)}"
|
||||
|
||||
# Информация о параметрах (только имена для безопасности)
|
||||
param_info = ""
|
||||
if "query_params" in request_info and request_info["query_params"]:
|
||||
param_names = list(request_info["query_params"].keys())
|
||||
param_info = f" параметры: {', '.join(param_names)}"
|
||||
elif "form_data" in request_info and request_info["form_data"]:
|
||||
param_names = list(request_info["form_data"].keys())
|
||||
param_info = f" параметры: {', '.join(param_names)}"
|
||||
elif "json_data" in request_info and isinstance(request_info["json_data"], dict):
|
||||
param_names = list(request_info["json_data"].keys())
|
||||
param_info = f" параметры: {', '.join(param_names)}"
|
||||
|
||||
# Формирование полного сообщения
|
||||
message = f"{method} {path} от {client_ip} ({user_agent}){file_info}{param_info}"
|
||||
|
||||
return message.strip()
|
||||
|
||||
def _format_response_message(self, response, processing_time: float) -> str:
|
||||
"""Форматирование сообщения с деталями ответа."""
|
||||
status_code = response.status_code
|
||||
content_length = response.content_length or 0
|
||||
processing_time_rounded = round(processing_time, 3)
|
||||
|
||||
return f"{status_code} за {processing_time_rounded} сек, {content_length} байт"
|
||||
|
||||
def _get_client_ip(self) -> str:
|
||||
"""Получение реального IP адреса клиента."""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0]
|
||||
elif request.headers.get('X-Real-IP'):
|
||||
return request.headers.get('X-Real-IP')
|
||||
else:
|
||||
return request.remote_addr or 'unknown'
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Утилиты для управления временными файлами.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.file_manager')
|
||||
|
||||
|
||||
def create_temp_file(suffix: str = ".wav") -> str:
|
||||
"""
|
||||
Создаёт временный файл с уникальным именем.
|
||||
|
||||
Args:
|
||||
suffix: Расширение временного файла.
|
||||
|
||||
Returns:
|
||||
Путь к временному файлу.
|
||||
"""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_path = os.path.join(temp_dir, f"{uuid.uuid4()}{suffix}")
|
||||
logger.debug(f"Создан временный файл: {temp_path}")
|
||||
return temp_path
|
||||
|
||||
|
||||
def cleanup_temp_files(file_paths: list) -> None:
|
||||
"""
|
||||
Удаляет временные файлы и их директории.
|
||||
|
||||
Args:
|
||||
file_paths: Список путей к файлам для удаления.
|
||||
"""
|
||||
for path in file_paths:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
logger.debug(f"Удалён временный файл: {path}")
|
||||
|
||||
temp_dir = os.path.dirname(path)
|
||||
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
|
||||
os.rmdir(temp_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось очистить временный файл {path}: {e}")
|
||||
@@ -1,93 +0,0 @@
|
||||
"""
|
||||
Модуль cache.py содержит функции для кэширования данных.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.cache')
|
||||
|
||||
|
||||
class SimpleCache:
|
||||
"""
|
||||
Простой кэш на основе словаря с поддержкой TTL (Time To Live).
|
||||
|
||||
Attributes:
|
||||
cache (Dict): Словарь для хранения кэшированных данных.
|
||||
ttl (int): Время жизни кэша в секундах.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: int = 300):
|
||||
"""
|
||||
Инициализация кэша.
|
||||
|
||||
Args:
|
||||
ttl: Время жизни кэша в секундах (по умолчанию 5 минут).
|
||||
"""
|
||||
self.cache: Dict[str, Dict[str, Any]] = {}
|
||||
self.ttl = ttl
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Получение значения из кэша.
|
||||
|
||||
Args:
|
||||
key: Ключ для получения значения.
|
||||
|
||||
Returns:
|
||||
Кэшированное значение или None, если ключ не найден или срок действия истек.
|
||||
"""
|
||||
if key in self.cache:
|
||||
item = self.cache[key]
|
||||
if time.time() - item["timestamp"] < self.ttl:
|
||||
logger.debug(f"Кэш hit для ключа: {key}")
|
||||
return item["value"]
|
||||
else:
|
||||
# Удаление просроченного элемента
|
||||
del self.cache[key]
|
||||
logger.debug(f"Кэш expired для ключа: {key}")
|
||||
|
||||
logger.debug(f"Кэш miss для ключа: {key}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Установка значения в кэш.
|
||||
|
||||
Args:
|
||||
key: Ключ для хранения значения.
|
||||
value: Значение для кэширования.
|
||||
"""
|
||||
self.cache[key] = {
|
||||
"value": value,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
logger.debug(f"Значение кэшировано для ключа: {key}")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
Очистка кэша.
|
||||
"""
|
||||
self.cache.clear()
|
||||
logger.debug("Кэш очищен")
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Удаление значения из кэша.
|
||||
|
||||
Args:
|
||||
key: Ключ для удаления.
|
||||
|
||||
Returns:
|
||||
True, если ключ был удален, иначе False.
|
||||
"""
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
logger.debug(f"Значение удалено из кэша для ключа: {key}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Глобальный экземпляр кэша
|
||||
model_cache = SimpleCache(ttl=3600) # Кэш для метаданных модели (1 час)
|
||||
@@ -1,104 +0,0 @@
|
||||
"""
|
||||
Модуль file_manager.py содержит классы для централизованного управления временными файлами.
|
||||
Предоставляет унифицированный интерфейс для создания, отслеживания и очистки временных файлов.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
import contextlib
|
||||
from typing import List, Tuple, Optional, Generator
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.file_manager')
|
||||
|
||||
|
||||
class TempFileManager:
|
||||
"""
|
||||
Класс для централизованного управления временными файлами.
|
||||
|
||||
Предоставляет методы для создания временных файлов и их последующей очистки.
|
||||
Использует контекстные менеджеры для автоматической очистки ресурсов.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Инициализация менеджера временных файлов.
|
||||
"""
|
||||
self.temp_files = []
|
||||
self.temp_dirs = []
|
||||
|
||||
def create_temp_file(self, suffix: str = ".wav") -> Tuple[str, str]:
|
||||
"""
|
||||
Создает временный файл с уникальным именем.
|
||||
|
||||
Args:
|
||||
suffix: Расширение временного файла.
|
||||
|
||||
Returns:
|
||||
Кортеж (путь к файлу, путь к временной директории).
|
||||
"""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_file = os.path.join(temp_dir, f"{uuid.uuid4()}{suffix}")
|
||||
|
||||
self.temp_files.append(temp_file)
|
||||
self.temp_dirs.append(temp_dir)
|
||||
|
||||
logger.debug(f"Создан временный файл: {temp_file}")
|
||||
return temp_file, temp_dir
|
||||
|
||||
def cleanup_temp_files(self, file_paths: Optional[List[str]] = None) -> None:
|
||||
"""
|
||||
Очищает временные файлы и директории.
|
||||
|
||||
Args:
|
||||
file_paths: Список путей к файлам для очистки. Если None, очищает все отслеживаемые файлы.
|
||||
"""
|
||||
paths_to_clean = file_paths if file_paths is not None else self.temp_files
|
||||
|
||||
for path in paths_to_clean:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
logger.debug(f"Удален временный файл: {path}")
|
||||
|
||||
# Попытка удалить директорию, если она пуста
|
||||
temp_dir = os.path.dirname(path)
|
||||
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
|
||||
os.rmdir(temp_dir)
|
||||
logger.debug(f"Удалена временная директория: {temp_dir}")
|
||||
|
||||
# Удаление из списка отслеживаемых директорий
|
||||
if temp_dir in self.temp_dirs:
|
||||
self.temp_dirs.remove(temp_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось очистить временный файл {path}: {e}")
|
||||
|
||||
# Удаление файлов из списка отслеживаемых
|
||||
if file_paths is None:
|
||||
self.temp_files.clear()
|
||||
else:
|
||||
for path in file_paths:
|
||||
if path in self.temp_files:
|
||||
self.temp_files.remove(path)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def temp_file(self, suffix: str = ".wav") -> Generator[str, None, None]:
|
||||
"""
|
||||
Контекстный менеджер для создания и автоматической очистки временного файла.
|
||||
|
||||
Args:
|
||||
suffix: Расширение временного файла.
|
||||
|
||||
Yields:
|
||||
Путь к временному файлу.
|
||||
"""
|
||||
temp_file, _ = self.create_temp_file(suffix)
|
||||
try:
|
||||
yield temp_file
|
||||
finally:
|
||||
self.cleanup_temp_files([temp_file])
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера временных файлов
|
||||
temp_file_manager = TempFileManager()
|
||||
@@ -149,6 +149,43 @@ class FileValidator:
|
||||
logger.warning(f"Не удалось определить MIME-тип файла: {e}")
|
||||
# Не прерываем валидацию, если не удалось определить MIME-тип
|
||||
|
||||
def validate_file_by_path(self, file_path: str, filename: str) -> bool:
|
||||
"""
|
||||
Валидирует файл по пути на диске.
|
||||
|
||||
Args:
|
||||
file_path: Путь к файлу.
|
||||
filename: Имя файла (для проверки расширения).
|
||||
|
||||
Returns:
|
||||
True, если файл прошел валидацию.
|
||||
|
||||
Raises:
|
||||
ValidationError: Если файл не прошел валидацию.
|
||||
"""
|
||||
# Проверка расширения
|
||||
self._validate_file_extension(filename)
|
||||
|
||||
# Проверка размера
|
||||
file_size = os.path.getsize(file_path)
|
||||
max_size_bytes = self.max_file_size_mb * 1024 * 1024
|
||||
if file_size > max_size_bytes:
|
||||
raise ValidationError(f"Размер файла ({file_size / (1024*1024):.2f} МБ) "
|
||||
f"превышает максимально допустимый ({self.max_file_size_mb} МБ)")
|
||||
|
||||
# Проверка MIME-типа
|
||||
try:
|
||||
mime_type = magic.from_file(file_path, mime=True)
|
||||
if mime_type not in self.allowed_mime_types:
|
||||
raise ValidationError(f"MIME-тип файла ({mime_type}) не разрешен. "
|
||||
f"Разрешенные MIME-типы: {', '.join(self.allowed_mime_types)}")
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось определить MIME-тип файла: {e}")
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str:
|
||||
"""
|
||||
@@ -8,54 +8,26 @@ from flask import request, jsonify
|
||||
from typing import Dict
|
||||
import logging
|
||||
|
||||
from ..core.transcription_service import TranscriptionService
|
||||
from ..audio.sources import (
|
||||
UploadedFileSource,
|
||||
URLSource,
|
||||
Base64Source,
|
||||
LocalFileSource
|
||||
)
|
||||
from ..infrastructure.validation.validators import ValidationError
|
||||
from ..infrastructure.async_tasks.manager import transcribe_audio_async, task_manager
|
||||
from ..infrastructure.storage.cache import model_cache
|
||||
from ..shared.decorators import log_invalid_file_request
|
||||
from .core.transcription_service import TranscriptionService
|
||||
from .audio.sources import get_uploaded_file, get_url_file, get_base64_file, get_local_file
|
||||
from .infrastructure.validation import ValidationError
|
||||
from .infrastructure.async_tasks import transcribe_audio_async, task_manager
|
||||
|
||||
logger = logging.getLogger('app.routes')
|
||||
|
||||
|
||||
class Routes:
|
||||
"""
|
||||
Класс для регистрации всех эндпоинтов API.
|
||||
|
||||
Attributes:
|
||||
app (Flask): Flask-приложение.
|
||||
config (Dict): Словарь с конфигурацией.
|
||||
transcription_service (TranscriptionService): Сервис транскрибации.
|
||||
file_validator (FileValidator): Валидатор файлов.
|
||||
"""
|
||||
"""Класс для регистрации всех эндпоинтов API."""
|
||||
|
||||
def __init__(self, app, transcriber, config: Dict, file_validator):
|
||||
"""
|
||||
Инициализация маршрутов.
|
||||
|
||||
Args:
|
||||
app: Flask-приложение.
|
||||
transcriber: Экземпляр транскрайбера.
|
||||
config: Словарь с конфигурацией.
|
||||
file_validator: Валидатор файлов.
|
||||
"""
|
||||
self.app = app
|
||||
self.config = config
|
||||
self.transcription_service = TranscriptionService(transcriber, config)
|
||||
self.file_validator = file_validator
|
||||
|
||||
# Регистрация маршрутов
|
||||
self._max_size = self.config.get("file_validation", {}).get("max_file_size_mb", 100)
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self) -> None:
|
||||
"""
|
||||
Регистрация всех эндпоинтов.
|
||||
"""
|
||||
@self.app.route('/', methods=['GET'])
|
||||
def index():
|
||||
"""Корень. Отдаёт HTML клиент."""
|
||||
@@ -64,10 +36,7 @@ class Routes:
|
||||
@self.app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Эндпоинт для проверки статуса сервиса."""
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"version": self.config.get("version", "1.0.0")
|
||||
}), 200
|
||||
return jsonify({"status": "ok", "version": "1.0.0"}), 200
|
||||
|
||||
@self.app.route('/config', methods=['GET'])
|
||||
def get_config():
|
||||
@@ -83,36 +52,34 @@ class Routes:
|
||||
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,
|
||||
file_path,
|
||||
allowed_directories=self.config.get("allowed_directories", [])
|
||||
)
|
||||
except ValidationError as e:
|
||||
# Логирование обращения к API с невалидным путем к файлу
|
||||
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown'))
|
||||
logger.warning(f"Обращение к эндпоинту /local/transcriptions с невалидным путем к файлу '{file_path}' "
|
||||
f"от клиента {client_ip}. Ошибка: {str(e)}")
|
||||
logger.warning(f"Невалидный путь '{file_path}' от {client_ip}: {e}")
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
source = LocalFileSource(validated_path, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, data)
|
||||
|
||||
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'])
|
||||
def list_models():
|
||||
"""Эндпоинт для получения списка доступных моделей."""
|
||||
return jsonify({
|
||||
"data": [
|
||||
{
|
||||
"id": os.path.basename(self.config["model_path"]),
|
||||
"object": "model",
|
||||
"owned_by": "openai",
|
||||
"permissions": []
|
||||
}
|
||||
],
|
||||
"data": [{
|
||||
"id": os.path.basename(self.config["model_path"]),
|
||||
"object": "model",
|
||||
"owned_by": "openai",
|
||||
"permissions": []
|
||||
}],
|
||||
"object": "list"
|
||||
}), 200
|
||||
|
||||
@@ -126,26 +93,29 @@ class Routes:
|
||||
"owned_by": "openai",
|
||||
"permissions": []
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
"error": "Model not found",
|
||||
"details": f"Model '{model_id}' does not exist"
|
||||
}), 404
|
||||
|
||||
def _handle_transcription_request():
|
||||
"""Общая функция для обработки запросов транскрибации."""
|
||||
source = UploadedFileSource(request.files, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, request.form, self.file_validator)
|
||||
return jsonify(response), status_code
|
||||
return jsonify({
|
||||
"error": "Model not found",
|
||||
"details": f"Model '{model_id}' does not exist"
|
||||
}), 404
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def openai_transcribe_endpoint():
|
||||
"""Эндпоинт для транскрибации аудиофайла (multipart-форма)."""
|
||||
return _handle_transcription_request()
|
||||
temp_path, filename, error = get_uploaded_file(request.files, self._max_size)
|
||||
if error:
|
||||
return jsonify({"error": error}), 400
|
||||
|
||||
# Валидация файла
|
||||
try:
|
||||
self.file_validator.validate_file_by_path(temp_path, filename)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
|
||||
return jsonify(response), status_code
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions/url', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def transcribe_from_url():
|
||||
"""Эндпоинт для транскрибации аудиофайла по URL."""
|
||||
data = request.json
|
||||
@@ -157,15 +127,22 @@ class Routes:
|
||||
}), 400
|
||||
|
||||
url = data["url"]
|
||||
# Извлекаем параметры транскрибации, если они есть
|
||||
params = {k: v for k, v in data.items() if k != "url"}
|
||||
|
||||
source = URLSource(url, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator)
|
||||
temp_path, filename, error = get_url_file(url, self._max_size)
|
||||
if error:
|
||||
return jsonify({"error": error}), 400
|
||||
|
||||
try:
|
||||
self.file_validator.validate_file_by_path(temp_path, filename)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
||||
return jsonify(response), status_code
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions/base64', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def transcribe_from_base64():
|
||||
"""Эндпоинт для транскрибации аудио, закодированного в base64."""
|
||||
data = request.json
|
||||
@@ -177,60 +154,49 @@ class Routes:
|
||||
}), 400
|
||||
|
||||
base64_data = data["file"]
|
||||
# Извлекаем параметры транскрибации, если они есть
|
||||
params = {k: v for k, v in data.items() if k != "file"}
|
||||
|
||||
source = Base64Source(base64_data, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator)
|
||||
temp_path, filename, error = get_base64_file(base64_data, self._max_size)
|
||||
if error:
|
||||
return jsonify({"error": error}), 400
|
||||
|
||||
try:
|
||||
self.file_validator.validate_file_by_path(temp_path, filename)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
||||
return jsonify(response), status_code
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions/async', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def transcribe_async():
|
||||
"""Эндпоинт для асинхронной транскрибации аудиофайла."""
|
||||
source = UploadedFileSource(request.files, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
|
||||
# Получаем файл
|
||||
file, filename, error = source.get_audio_file()
|
||||
|
||||
temp_path, filename, error = get_uploaded_file(request.files, self._max_size)
|
||||
if error:
|
||||
return jsonify({"error": error}), 400
|
||||
|
||||
if not file:
|
||||
return jsonify({"error": "Failed to get audio file"}), 400
|
||||
|
||||
# Валидация файла
|
||||
|
||||
try:
|
||||
self.file_validator.validate_file(file, filename)
|
||||
self.file_validator.validate_file_by_path(temp_path, filename)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
# Сохраняем файл во временный файл
|
||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
||||
with temp_file_manager.temp_file() as temp_path:
|
||||
file.save(temp_path)
|
||||
|
||||
# Запускаем асинхронную транскрибацию
|
||||
task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber)
|
||||
|
||||
return jsonify({"task_id": task_id}), 202
|
||||
|
||||
|
||||
task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber)
|
||||
return jsonify({"task_id": task_id}), 202
|
||||
|
||||
@self.app.route('/v1/tasks/<task_id>', methods=['GET'])
|
||||
def get_task_status(task_id):
|
||||
"""Эндпоинт для получения статуса асинхронной задачи."""
|
||||
task_info = task_manager.get_task_status(task_id)
|
||||
|
||||
|
||||
if not task_info:
|
||||
return jsonify({"error": "Task not found"}), 404
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"status": task_info["status"]
|
||||
}
|
||||
|
||||
|
||||
response = {"task_id": task_id, "status": task_info["status"]}
|
||||
|
||||
if task_info["status"] == "completed":
|
||||
response["result"] = task_info["result"]
|
||||
elif task_info["status"] == "failed":
|
||||
response["error"] = task_info["error"]
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
return jsonify(response)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""
|
||||
Модуль context_managers.py содержит контекстные менеджеры для управления ресурсами.
|
||||
"""
|
||||
|
||||
import os
|
||||
import contextlib
|
||||
from typing import Generator, BinaryIO
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.context_managers')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def open_file(file_path: str, mode: str = 'rb') -> Generator[BinaryIO, None, None]:
|
||||
"""
|
||||
Контекстный менеджер для безопасного открытия и закрытия файлов.
|
||||
|
||||
Args:
|
||||
file_path: Путь к файлу.
|
||||
mode: Режим открытия файла.
|
||||
|
||||
Yields:
|
||||
Файловый объект.
|
||||
"""
|
||||
file_obj = None
|
||||
try:
|
||||
file_obj = open(file_path, mode)
|
||||
yield file_obj
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с файлом {file_path}: {e}")
|
||||
raise
|
||||
finally:
|
||||
if file_obj:
|
||||
file_obj.close()
|
||||
@@ -1,48 +0,0 @@
|
||||
"""
|
||||
Модуль decorators.py содержит общие декораторы для использования в приложении.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import functools
|
||||
from flask import request
|
||||
|
||||
# Получаем логгер из централизованной настройки
|
||||
logger = logging.getLogger('app.utils')
|
||||
|
||||
|
||||
def log_invalid_file_request(func):
|
||||
"""
|
||||
Декоратор для логирования запросов с невалидными файлами.
|
||||
|
||||
Args:
|
||||
func: Декорируемая функция.
|
||||
|
||||
Returns:
|
||||
Обернутая функция с логированием ошибок валидации файлов.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
# Получение информации о запросе
|
||||
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown'))
|
||||
endpoint = request.endpoint or 'unknown'
|
||||
method = request.method or 'unknown'
|
||||
|
||||
# Получение имени файла из запроса
|
||||
filename = 'unknown'
|
||||
if 'file' in request.files:
|
||||
filename = request.files['file'].filename
|
||||
elif request.is_json:
|
||||
data = request.get_json()
|
||||
if data and 'file' in data:
|
||||
filename = data.get('filename', 'base64_data')
|
||||
|
||||
# Логирование обращения к API с невалидным файлом
|
||||
logger.warning(f"Обращение к эндпоинту {method} {endpoint} с невалидным файлом '{filename}' "
|
||||
f"от клиента {client_ip}. Ошибка: {str(e)}")
|
||||
|
||||
# Пробрасываем исключение дальше
|
||||
raise
|
||||
return wrapper
|
||||
@@ -1,86 +0,0 @@
|
||||
"""
|
||||
Модуль history_logger.py содержит класс HistoryLogger для журналирования результатов
|
||||
транскрибации.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
import random
|
||||
import string
|
||||
from typing import Dict, Any, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.history')
|
||||
|
||||
class HistoryLogger:
|
||||
"""Класс для сохранения истории транскрибации."""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""
|
||||
Инициализация логгера истории.
|
||||
|
||||
Args:
|
||||
config: Словарь с конфигурацией.
|
||||
"""
|
||||
self.config = config
|
||||
self.history_enabled = config.get("enable_history", False)
|
||||
self.history_root = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "history")
|
||||
|
||||
# Создаем корневую директорию истории, если она не существует
|
||||
if self.history_enabled and not os.path.exists(self.history_root):
|
||||
os.makedirs(self.history_root)
|
||||
logger.info(f"Создана директория для истории транскрибации: {self.history_root}")
|
||||
|
||||
def save(self, result: Dict[str, Any], original_filename: str) -> Optional[str]:
|
||||
"""
|
||||
Сохраняет результат транскрибации в файл истории.
|
||||
|
||||
Args:
|
||||
result: Результат транскрибации.
|
||||
original_filename: Исходное имя аудиофайла.
|
||||
|
||||
Returns:
|
||||
Путь к сохраненному файлу истории или None, если сохранение отключено.
|
||||
"""
|
||||
if not self.history_enabled:
|
||||
logger.debug("История транскрибации отключена в конфигурации")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Получаем текущую дату и время
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%Y-%m-%d")
|
||||
|
||||
# Получаем текущий таймстамп в миллисекундах
|
||||
timestamp_ms = int(datetime.datetime.now().timestamp() * 1000)
|
||||
|
||||
# Генерируем 4-символьную случайную метку
|
||||
random_tag = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4))
|
||||
|
||||
# Получаем только имя файла без пути
|
||||
base_filename = os.path.basename(original_filename)
|
||||
|
||||
# Создаем имя файла истории
|
||||
history_filename = f"{timestamp_ms}_{base_filename}_{random_tag}.json"
|
||||
|
||||
# Путь к директории для текущей даты
|
||||
date_dir = os.path.join(self.history_root, date_str)
|
||||
|
||||
# Создаем директорию для текущей даты, если она не существует
|
||||
if not os.path.exists(date_dir):
|
||||
os.makedirs(date_dir)
|
||||
|
||||
# Полный путь к файлу истории
|
||||
history_path = os.path.join(date_dir, history_filename)
|
||||
|
||||
# Сохраняем результат в JSON файл
|
||||
with open(history_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"Результат транскрибации сохранен в историю: {history_path}")
|
||||
return history_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сохранении истории транскрибации: {e}")
|
||||
return None
|
||||
+1
-5
@@ -11,7 +11,6 @@
|
||||
"audio_rate": 8000,
|
||||
"norm_level": "-0.55",
|
||||
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15",
|
||||
"audio_speed_factor": 1.0,
|
||||
"device_id": 0,
|
||||
"file_validation": {
|
||||
"max_file_size_mb": 500,
|
||||
@@ -19,12 +18,9 @@
|
||||
"allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"]
|
||||
},
|
||||
"allowed_directories": [],
|
||||
"version": "1.0.0",
|
||||
"log_level": "INFO",
|
||||
"log_file": "logs/whisper_api.log",
|
||||
"request_logging": {
|
||||
"exclude_endpoints": ["/health", "/static"],
|
||||
"sensitive_headers": ["authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"],
|
||||
"log_debug": false
|
||||
"exclude_endpoints": ["/health", "/static"]
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,3 @@ scipy==1.13.1
|
||||
transformers==4.49.0
|
||||
accelerate==1.4.0
|
||||
python-magic==0.4.27
|
||||
flask-limiter==3.5.0
|
||||
|
||||
Reference in New Issue
Block a user