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
|
## Architecture
|
||||||
|
|
||||||
```
|
* Entry: `server.py` -> `app/__init__.py` (WhisperServiceAPI)
|
||||||
server.py # Entry point, argparse, launches WhisperServiceAPI
|
* Modules: `app/core/` (transcriber, config), `app/audio/` (processor, sources, utils), `app/infrastructure/` (logging, validation, storage, async tasks)
|
||||||
app/__init__.py # WhisperServiceAPI: Flask init, wires all components
|
* Request flow: source function -> validate -> transcribe (AudioProcessor -> Whisper inference) -> save history -> JSON response
|
||||||
app/core/
|
* OpenAI-compatible API: `/v1/audio/transcriptions` matches OpenAI contract for drop-in replacement
|
||||||
config.py # load_config() from JSON
|
* All settings in `config.json`. Device fallback: CUDA -> MPS -> CPU
|
||||||
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.
|
|
||||||
|
|||||||
@@ -80,8 +80,7 @@ The service is configured through the `config.json` file:
|
|||||||
"return_timestamps": false,
|
"return_timestamps": false,
|
||||||
"audio_rate": 8000,
|
"audio_rate": 8000,
|
||||||
"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"
|
||||||
"audio_speed_factor": 1.25
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -101,7 +100,6 @@ The service is configured through the `config.json` file:
|
|||||||
| `audio_rate` | Audio sampling rate in Hz |
|
| `audio_rate` | Audio sampling rate in Hz |
|
||||||
| `norm_level` | Normalization level for audio preprocessing |
|
| `norm_level` | Normalization level for audio preprocessing |
|
||||||
| `compand_params` | Parameters for audio compression/expansion |
|
| `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
|
## Web interface
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ Non-negotiable. Violation = stop and fix before continuing.
|
|||||||
## Audio Processing Rules
|
## Audio Processing Rules
|
||||||
|
|
||||||
* FFmpeg and SoX are external dependencies. Always check subprocess return codes.
|
* 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`.
|
* 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 -> speedup -> add silence.
|
* Audio pipeline order matters: convert to WAV 16kHz -> normalize -> compress/expand -> add silence.
|
||||||
|
|
||||||
## Flask / API Rules
|
## Flask / API Rules
|
||||||
|
|
||||||
* New endpoints go in `app/api/routes.py` inside `_register_routes()`.
|
* New endpoints go in `app/routes.py` inside `_register_routes()`.
|
||||||
* All transcription endpoints delegate to `TranscriptionService.transcribe_from_source()`.
|
* All transcription endpoints delegate to `TranscriptionService.transcribe()`.
|
||||||
* New audio input methods: subclass `AudioSource`, implement `get_audio_file()`.
|
* 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.
|
* 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.
|
* 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.transcriber import WhisperTranscriber
|
||||||
from .core.config import load_config
|
from .core.config import load_config
|
||||||
from .api.routes import Routes
|
from .routes import Routes
|
||||||
from .infrastructure.validation.validators import FileValidator
|
from .infrastructure.validation import FileValidator
|
||||||
from .infrastructure.storage.file_manager import temp_file_manager
|
from .infrastructure.log import setup_logging, RequestLogger
|
||||||
from .infrastructure.logging.config import setup_logging
|
|
||||||
from .infrastructure.logging.request_logger import RequestLogger
|
|
||||||
|
|
||||||
|
|
||||||
class WhisperServiceAPI:
|
class WhisperServiceAPI:
|
||||||
@@ -60,7 +58,7 @@ class WhisperServiceAPI:
|
|||||||
self.file_validator = FileValidator(self.config)
|
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)
|
request_logger = RequestLogger(self.app, request_logger_config)
|
||||||
|
|
||||||
# Регистрация маршрутов
|
# Регистрация маршрутов
|
||||||
|
|||||||
+6
-54
@@ -7,11 +7,10 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import uuid
|
|
||||||
from typing import Dict, Tuple
|
from typing import Dict, Tuple
|
||||||
import logging
|
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')
|
logger = logging.getLogger('app.audio_processor')
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ class AudioProcessor:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.norm_level = config.get("norm_level", "-0.5")
|
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.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:
|
def convert_to_wav(self, input_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -66,7 +64,7 @@ class AudioProcessor:
|
|||||||
# Продолжаем конвертацию, чтобы быть уверенными в формате
|
# Продолжаем конвертацию, чтобы быть уверенными в формате
|
||||||
|
|
||||||
# Создаем временный файл для WAV
|
# Создаем временный файл для WAV
|
||||||
output_path, _ = temp_file_manager.create_temp_file(".wav")
|
output_path = create_temp_file(".wav")
|
||||||
|
|
||||||
# Команда для конвертации
|
# Команда для конвертации
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -103,7 +101,7 @@ class AudioProcessor:
|
|||||||
subprocess.CalledProcessError: Если произошла ошибка при нормализации.
|
subprocess.CalledProcessError: Если произошла ошибка при нормализации.
|
||||||
"""
|
"""
|
||||||
# Создаем временный файл для нормализованного аудио
|
# Создаем временный файл для нормализованного аудио
|
||||||
output_path, _ = temp_file_manager.create_temp_file("_normalized.wav")
|
output_path = create_temp_file("_normalized.wav")
|
||||||
|
|
||||||
# Команда для нормализации аудио с помощью sox
|
# Команда для нормализации аудио с помощью sox
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -124,47 +122,6 @@ class AudioProcessor:
|
|||||||
logger.error(f"Ошибка при нормализации аудио: {e.stderr.decode()}")
|
logger.error(f"Ошибка при нормализации аудио: {e.stderr.decode()}")
|
||||||
raise
|
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:
|
def add_silence(self, input_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
Добавляет тишину в начало аудиофайла.
|
Добавляет тишину в начало аудиофайла.
|
||||||
@@ -179,7 +136,7 @@ class AudioProcessor:
|
|||||||
subprocess.CalledProcessError: Если произошла ошибка при добавлении тишины.
|
subprocess.CalledProcessError: Если произошла ошибка при добавлении тишины.
|
||||||
"""
|
"""
|
||||||
# Создаем временный файл
|
# Создаем временный файл
|
||||||
output_path, _ = temp_file_manager.create_temp_file("_silence.wav")
|
output_path = create_temp_file("_silence.wav")
|
||||||
|
|
||||||
# Команда для добавления тишины в начало файла
|
# Команда для добавления тишины в начало файла
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -224,18 +181,13 @@ class AudioProcessor:
|
|||||||
normalized_path = self.normalize_audio(wav_path)
|
normalized_path = self.normalize_audio(wav_path)
|
||||||
temp_files.append(normalized_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)
|
temp_files.append(silence_path)
|
||||||
|
|
||||||
return silence_path, temp_files
|
return silence_path, temp_files
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при обработке аудио {input_path}: {e}")
|
logger.error(f"Ошибка при обработке аудио {input_path}: {e}")
|
||||||
temp_file_manager.cleanup_temp_files(temp_files)
|
cleanup_temp_files(temp_files)
|
||||||
raise
|
raise
|
||||||
+94
-270
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Модуль sources.py содержит абстрактный класс AudioSource и его конкретные реализации
|
Модуль sources.py содержит функции для получения аудиофайлов
|
||||||
для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы).
|
из различных источников (загруженные файлы, URL, base64, локальные файлы).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -8,305 +8,129 @@ import uuid
|
|||||||
import tempfile
|
import tempfile
|
||||||
import base64
|
import base64
|
||||||
import requests
|
import requests
|
||||||
import abc
|
from typing import Tuple, Optional
|
||||||
from typing import Dict, Tuple, Optional, BinaryIO
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger('app.audio_sources')
|
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]]:
|
||||||
"""
|
"""
|
||||||
|
Получает аудиофайл из загруженных файлов Flask.
|
||||||
|
|
||||||
def __init__(self, max_file_size_mb: int = 100):
|
Returns:
|
||||||
"""
|
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
||||||
Инициализация источника аудио.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class FakeFile:
|
|
||||||
"""Имитирует файловый объект для унификации обработки из разных источников.
|
|
||||||
|
|
||||||
Позволяет обрабатывать файлы из различных источников (локальный путь, URL, base64)
|
|
||||||
как стандартные файловые объекты Flask, обеспечивая совместимость с существующей
|
|
||||||
логикой обработки файлов.
|
|
||||||
"""
|
"""
|
||||||
|
if 'file' not in request_files:
|
||||||
|
return None, None, "No file part"
|
||||||
|
|
||||||
def __init__(self, file: BinaryIO, filename: str):
|
file = request_files['file']
|
||||||
"""
|
|
||||||
Инициализация объекта FakeFile.
|
|
||||||
|
|
||||||
Args:
|
if file.filename == '':
|
||||||
file: Исходный файловый объект или поток.
|
return None, None, "No selected file"
|
||||||
filename: Имя файла для метаданных.
|
|
||||||
"""
|
|
||||||
self.file = file
|
|
||||||
self.filename = filename
|
|
||||||
|
|
||||||
def read(self):
|
# Проверка размера
|
||||||
"""Чтение содержимого файла."""
|
file.seek(0, os.SEEK_END)
|
||||||
return self.file.read()
|
size = file.tell()
|
||||||
|
file.seek(0)
|
||||||
|
|
||||||
def seek(self, offset: int, whence: int = 0):
|
error = _check_size(size, max_file_size_mb)
|
||||||
"""Перемещение позиции чтения."""
|
if error:
|
||||||
self.file.seek(offset, whence)
|
return None, None, error
|
||||||
|
|
||||||
def tell(self):
|
# Сохраняем во временный файл
|
||||||
"""Получение текущей позиции чтения."""
|
temp_path = _make_temp_path()
|
||||||
return self.file.tell()
|
file.save(temp_path)
|
||||||
|
|
||||||
def save(self, destination: str):
|
return temp_path, file.filename, None
|
||||||
"""
|
|
||||||
Сохраняет содержимое файла в указанное место назначения.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
destination: Путь для сохранения файла.
|
|
||||||
"""
|
|
||||||
with open(destination, 'wb') as f:
|
|
||||||
content = self.file.read()
|
|
||||||
f.write(content)
|
|
||||||
self.file.seek(0) # Сброс указателя после чтения
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
"""Возвращает имя файла."""
|
|
||||||
return self.filename
|
|
||||||
|
|
||||||
|
|
||||||
class UploadedFileSource(AudioSource):
|
def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
"""Источник аудио для файлов, загруженных через HTTP-запрос."""
|
"""
|
||||||
|
Получает аудиофайл по URL.
|
||||||
|
|
||||||
def __init__(self, request_files, max_file_size_mb: int = 100):
|
Returns:
|
||||||
"""
|
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
||||||
Инициализация источника для загруженных файлов.
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.get(url, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
Args:
|
# Проверка размера по Content-Length
|
||||||
request_files: Объект request.files из Flask.
|
content_length = response.headers.get('Content-Length')
|
||||||
max_file_size_mb: Максимальный размер файла в МБ.
|
if content_length:
|
||||||
"""
|
error = _check_size(int(content_length), max_file_size_mb)
|
||||||
super().__init__(max_file_size_mb)
|
if error:
|
||||||
self.request_files = request_files
|
return None, None, error
|
||||||
|
|
||||||
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
|
temp_path = _make_temp_path()
|
||||||
"""
|
with open(temp_path, 'wb') as f:
|
||||||
Получает аудиофайл из загруженных файлов.
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
Returns:
|
return temp_path, os.path.basename(temp_path), None
|
||||||
Кортеж (файловый объект, имя файла, сообщение об ошибке).
|
|
||||||
"""
|
|
||||||
if 'file' not in self.request_files:
|
|
||||||
return None, None, "No file part"
|
|
||||||
|
|
||||||
file = self.request_files['file']
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении файла по URL {url}: {e}")
|
||||||
if file.filename == '':
|
return None, None, f"Error retrieving file from URL: {str(e)}"
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class URLSource(AudioSource):
|
def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
"""Источник аудио для файлов, доступных по URL."""
|
"""
|
||||||
|
Получает аудиофайл из base64 данных.
|
||||||
|
|
||||||
def __init__(self, url: str, max_file_size_mb: int = 100):
|
Returns:
|
||||||
"""
|
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
|
||||||
Инициализация источника для файлов по URL.
|
"""
|
||||||
|
try:
|
||||||
|
audio_data = base64.b64decode(base64_data)
|
||||||
|
|
||||||
Args:
|
error = _check_size(len(audio_data), max_file_size_mb)
|
||||||
url: URL аудиофайла.
|
if error:
|
||||||
max_file_size_mb: Максимальный размер файла в МБ.
|
return None, None, error
|
||||||
"""
|
|
||||||
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]]:
|
temp_path = _make_temp_path()
|
||||||
"""
|
with open(temp_path, 'wb') as f:
|
||||||
Получает аудиофайл по URL.
|
f.write(audio_data)
|
||||||
|
|
||||||
Returns:
|
return temp_path, os.path.basename(temp_path), None
|
||||||
Кортеж (файловый объект, имя файла, сообщение об ошибке).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Скачиваем файл по URL
|
|
||||||
response = requests.get(self.url, stream=True)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# Проверка размера файла (если сервер предоставил информацию о размере)
|
except Exception as e:
|
||||||
content_length = response.headers.get('Content-Length')
|
logger.error(f"Ошибка при декодировании base64 данных: {e}")
|
||||||
if content_length and int(content_length) > self.max_file_size_mb * 1024 * 1024:
|
return None, None, f"Error decoding base64 data: {str(e)}"
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class Base64Source(AudioSource):
|
def get_local_file(file_path: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
"""Источник аудио для файлов, закодированных в base64."""
|
"""
|
||||||
|
Получает локальный аудиофайл.
|
||||||
|
|
||||||
def __init__(self, base64_data: str, max_file_size_mb: int = 100):
|
Returns:
|
||||||
"""
|
Кортеж (путь к файлу, имя файла, сообщение об ошибке).
|
||||||
Инициализация источника для base64 файлов.
|
Примечание: возвращает оригинальный путь, не копирует файл.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return None, None, f"File not found: {file_path}"
|
||||||
|
|
||||||
Args:
|
try:
|
||||||
base64_data: Данные аудиофайла в формате base64.
|
error = _check_size(os.path.getsize(file_path), max_file_size_mb)
|
||||||
max_file_size_mb: Максимальный размер файла в МБ.
|
if error:
|
||||||
"""
|
return None, None, error
|
||||||
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]]:
|
return file_path, os.path.basename(file_path), None
|
||||||
"""
|
|
||||||
Получает аудиофайл из base64 данных.
|
|
||||||
|
|
||||||
Returns:
|
except Exception as e:
|
||||||
Кортеж (файловый объект, имя файла, сообщение об ошибке).
|
logger.error(f"Ошибка при открытии локального файла {file_path}: {e}")
|
||||||
"""
|
return None, None, f"Error opening local file: {str(e)}"
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
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)}"
|
|
||||||
|
|||||||
+52
-84
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Модуль utils.py содержит утилитарные функции для работы с аудио.
|
Утилитарные функции для работы с аудио.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -12,98 +12,66 @@ from typing import Tuple
|
|||||||
logger = logging.getLogger('app.audio_utils')
|
logger = logging.getLogger('app.audio_utils')
|
||||||
|
|
||||||
|
|
||||||
class AudioUtils:
|
def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
|
||||||
"""Утилитарный класс для работы с аудио."""
|
"""
|
||||||
|
Загрузка аудиофайла с использованием встроенной библиотеки wave.
|
||||||
|
|
||||||
@staticmethod
|
Args:
|
||||||
def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
|
file_path: Путь к аудиофайлу.
|
||||||
"""
|
sr: Целевая частота дискретизации.
|
||||||
Загрузка аудиофайла с использованием встроенной библиотеки wave.
|
|
||||||
|
|
||||||
Args:
|
Returns:
|
||||||
file_path: Путь к аудиофайлу.
|
Кортеж (массив numpy, частота дискретизации).
|
||||||
sr: Целевая частота дискретизации.
|
"""
|
||||||
|
try:
|
||||||
|
with wave.open(file_path, 'rb') as wav_file:
|
||||||
|
if wav_file.getnchannels() != 1:
|
||||||
|
logger.warning(f"Файл {file_path} не моно-аудио")
|
||||||
|
|
||||||
Returns:
|
frames = wav_file.readframes(-1)
|
||||||
Кортеж (массив numpy, частота дискретизации).
|
audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
sampling_rate = wav_file.getframerate()
|
||||||
|
|
||||||
Raises:
|
if sampling_rate != sr:
|
||||||
Exception: Если не удалось загрузить аудиофайл.
|
from scipy.signal import resample
|
||||||
"""
|
num_samples = int(len(audio_array) * sr / sampling_rate)
|
||||||
try:
|
audio_array = resample(audio_array, num_samples)
|
||||||
# Открываем WAV файл
|
sampling_rate = sr
|
||||||
with wave.open(file_path, 'rb') as wav_file:
|
|
||||||
# Проверяем, что это моно-аудио
|
|
||||||
if wav_file.getnchannels() != 1:
|
|
||||||
logger.warning(f"Файл {file_path} не моно-аудио, конвертируем в моно")
|
|
||||||
|
|
||||||
# Читаем аудиоданные
|
return audio_array, sampling_rate
|
||||||
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
|
|
||||||
|
|
||||||
# Получаем частоту дискретизации
|
except Exception as e:
|
||||||
sampling_rate = wav_file.getframerate()
|
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# Если частота дискретизации не совпадает с целевой, выполняем ресемплинг
|
|
||||||
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
|
def get_audio_duration(file_path: str) -> float:
|
||||||
|
"""
|
||||||
|
Определяет длительность аудиофайла с использованием ffprobe.
|
||||||
|
|
||||||
except Exception as e:
|
Args:
|
||||||
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}")
|
file_path: Путь к аудиофайлу.
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
Returns:
|
||||||
def get_audio_duration(file_path: str) -> float:
|
Длительность в секундах.
|
||||||
"""
|
"""
|
||||||
Определяет длительность аудиофайла с использованием ffprobe.
|
if not os.path.exists(file_path):
|
||||||
|
raise Exception(f"Файл не существует: {file_path}")
|
||||||
|
|
||||||
Args:
|
cmd = [
|
||||||
file_path: Путь к аудиофайлу.
|
"ffprobe",
|
||||||
|
"-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
|
file_path
|
||||||
|
]
|
||||||
|
|
||||||
Returns:
|
try:
|
||||||
Длительность в секундах.
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=10)
|
||||||
"""
|
return float(result.stdout.strip())
|
||||||
try:
|
except subprocess.TimeoutExpired:
|
||||||
# Проверяем, что файл существует
|
raise Exception(f"Таймаут при определении длительности файла {file_path}")
|
||||||
if not os.path.exists(file_path):
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error(f"Файл не существует: {file_path}")
|
raise Exception(f"Ошибка ffprobe для файла {file_path}: {e.stderr}")
|
||||||
raise Exception(f"Файл не существует: {file_path}")
|
except (ValueError, TypeError) as e:
|
||||||
|
raise Exception(f"Ошибка при преобразовании длительности для файла {file_path}: {e}")
|
||||||
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}")
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ from transformers import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..audio.processor import AudioProcessor
|
from ..audio.processor import AudioProcessor
|
||||||
from ..audio.utils import AudioUtils
|
from ..audio.utils import load_audio
|
||||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
from ..infrastructure.storage import cleanup_temp_files
|
||||||
|
|
||||||
logger = logging.getLogger('app.transcriber')
|
logger = logging.getLogger('app.transcriber')
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ class WhisperTranscriber:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Загрузка аудио в формате numpy array
|
# Загрузка аудио в формате 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(
|
result = self.asr_pipeline(
|
||||||
@@ -279,4 +279,4 @@ class WhisperTranscriber:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Очистка временных файлов
|
# Очистка временных файлов
|
||||||
temp_file_manager.cleanup_temp_files(temp_files)
|
cleanup_temp_files(temp_files)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Модуль transcription_service.py содержит класс TranscriptionService,
|
Модуль transcription_service.py содержит класс TranscriptionService,
|
||||||
который отвечает за обработку и транскрибацию аудиофайлов.
|
который отвечает за транскрибацию аудиофайлов.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -9,74 +9,31 @@ import traceback
|
|||||||
from typing import Dict, Tuple
|
from typing import Dict, Tuple
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ..audio.utils import AudioUtils
|
from ..audio.utils import get_audio_duration
|
||||||
from ..shared.history_logger import HistoryLogger
|
from ..history import save_history
|
||||||
from ..audio.sources import AudioSource
|
|
||||||
from ..infrastructure.validation.validators import FileValidator, ValidationError
|
|
||||||
|
|
||||||
logger = logging.getLogger('app.transcription_service')
|
logger = logging.getLogger('app.transcription_service')
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionService:
|
class TranscriptionService:
|
||||||
"""
|
"""Сервис для транскрибации аудиофайлов."""
|
||||||
Сервис для обработки и транскрибации аудиофайлов.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
transcriber: Экземпляр транскрайбера.
|
|
||||||
config (Dict): Словарь с конфигурацией.
|
|
||||||
max_file_size_mb (int): Максимальный размер файла в МБ.
|
|
||||||
history (HistoryLogger): Объект журналирования.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, transcriber, config: Dict):
|
def __init__(self, transcriber, config: Dict):
|
||||||
"""
|
|
||||||
Инициализация сервиса транскрибации.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
transcriber: Экземпляр транскрайбера.
|
|
||||||
config: Словарь с конфигурацией.
|
|
||||||
"""
|
|
||||||
self.transcriber = transcriber
|
self.transcriber = transcriber
|
||||||
self.config = config
|
self.config = config
|
||||||
self.max_file_size_mb = self.config.get("file_validation", {}).get("max_file_size_mb", 100)
|
|
||||||
|
|
||||||
# Объект журналирования
|
def transcribe(self, file_path: str, filename: str, params: Dict = None) -> Tuple[Dict, int]:
|
||||||
self.history = HistoryLogger(config)
|
|
||||||
|
|
||||||
def transcribe_from_source(self, source: AudioSource, params: Dict = None, file_validator: FileValidator = None) -> Tuple[Dict, int]:
|
|
||||||
"""
|
"""
|
||||||
Транскрибирует аудиофайл из указанного источника.
|
Транскрибирует аудиофайл по пути.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source: Источник аудиофайла.
|
file_path: Путь к аудиофайлу.
|
||||||
|
filename: Имя файла (для логов и истории).
|
||||||
params: Дополнительные параметры для транскрибации.
|
params: Дополнительные параметры для транскрибации.
|
||||||
file_validator: Валидатор файлов.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Кортеж (JSON-ответ, HTTP-код).
|
Кортеж (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 {}
|
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 = float(params.get('temperature', 0.0))
|
||||||
@@ -84,7 +41,6 @@ class TranscriptionService:
|
|||||||
|
|
||||||
# Проверяем, запрошены ли временные метки
|
# Проверяем, запрошены ли временные метки
|
||||||
return_timestamps = params.get('return_timestamps', self.config.get('return_timestamps', False))
|
return_timestamps = params.get('return_timestamps', self.config.get('return_timestamps', False))
|
||||||
# Преобразуем строковое значение в булево, если необходимо
|
|
||||||
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')
|
||||||
|
|
||||||
@@ -92,59 +48,44 @@ class TranscriptionService:
|
|||||||
original_return_timestamps = self.transcriber.return_timestamps
|
original_return_timestamps = self.transcriber.return_timestamps
|
||||||
self.transcriber.return_timestamps = return_timestamps
|
self.transcriber.return_timestamps = return_timestamps
|
||||||
|
|
||||||
# Сохраняем файл во временный файл
|
try:
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при определении длительности файла: {e}")
|
logger.error(f"Ошибка при определении длительности файла: {e}")
|
||||||
return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500
|
return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500
|
||||||
|
|
||||||
# Для файлов из внешних источников (URL, base64), закрываем их и выполняем очистку
|
start_time = time.time()
|
||||||
if hasattr(source, 'cleanup'):
|
result = self.transcriber.process_file(file_path)
|
||||||
file.file.close() # Закрываем файловый объект
|
processing_time = time.time() - start_time
|
||||||
source.cleanup() # Очищаем временные файлы источника
|
|
||||||
|
|
||||||
try:
|
# Формируем ответ
|
||||||
start_time = time.time()
|
if return_timestamps:
|
||||||
result = self.transcriber.process_file(temp_file_path)
|
response = {
|
||||||
processing_time = time.time() - start_time
|
"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
|
save_history(response, filename, self.config)
|
||||||
if return_timestamps:
|
return response, 200
|
||||||
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"])
|
|
||||||
}
|
|
||||||
|
|
||||||
# Журналирование результата
|
except Exception as e:
|
||||||
self.history.save(response, filename)
|
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
|
||||||
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
return response, 200
|
finally:
|
||||||
|
self.transcriber.return_timestamps = original_return_timestamps
|
||||||
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
|
|
||||||
|
|||||||
@@ -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}")
|
logger.warning(f"Не удалось определить MIME-тип файла: {e}")
|
||||||
# Не прерываем валидацию, если не удалось определить MIME-тип
|
# Не прерываем валидацию, если не удалось определить 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
|
@staticmethod
|
||||||
def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str:
|
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
|
from typing import Dict
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ..core.transcription_service import TranscriptionService
|
from .core.transcription_service import TranscriptionService
|
||||||
from ..audio.sources import (
|
from .audio.sources import get_uploaded_file, get_url_file, get_base64_file, get_local_file
|
||||||
UploadedFileSource,
|
from .infrastructure.validation import ValidationError
|
||||||
URLSource,
|
from .infrastructure.async_tasks import transcribe_audio_async, task_manager
|
||||||
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
|
|
||||||
|
|
||||||
logger = logging.getLogger('app.routes')
|
logger = logging.getLogger('app.routes')
|
||||||
|
|
||||||
|
|
||||||
class Routes:
|
class Routes:
|
||||||
"""
|
"""Класс для регистрации всех эндпоинтов API."""
|
||||||
Класс для регистрации всех эндпоинтов API.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
app (Flask): Flask-приложение.
|
|
||||||
config (Dict): Словарь с конфигурацией.
|
|
||||||
transcription_service (TranscriptionService): Сервис транскрибации.
|
|
||||||
file_validator (FileValidator): Валидатор файлов.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, app, transcriber, config: Dict, file_validator):
|
def __init__(self, app, transcriber, config: Dict, file_validator):
|
||||||
"""
|
|
||||||
Инициализация маршрутов.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app: Flask-приложение.
|
|
||||||
transcriber: Экземпляр транскрайбера.
|
|
||||||
config: Словарь с конфигурацией.
|
|
||||||
file_validator: Валидатор файлов.
|
|
||||||
"""
|
|
||||||
self.app = app
|
self.app = app
|
||||||
self.config = config
|
self.config = config
|
||||||
self.transcription_service = TranscriptionService(transcriber, config)
|
self.transcription_service = TranscriptionService(transcriber, config)
|
||||||
self.file_validator = file_validator
|
self.file_validator = file_validator
|
||||||
|
self._max_size = self.config.get("file_validation", {}).get("max_file_size_mb", 100)
|
||||||
# Регистрация маршрутов
|
|
||||||
self._register_routes()
|
self._register_routes()
|
||||||
|
|
||||||
def _register_routes(self) -> None:
|
def _register_routes(self) -> None:
|
||||||
"""
|
|
||||||
Регистрация всех эндпоинтов.
|
|
||||||
"""
|
|
||||||
@self.app.route('/', methods=['GET'])
|
@self.app.route('/', methods=['GET'])
|
||||||
def index():
|
def index():
|
||||||
"""Корень. Отдаёт HTML клиент."""
|
"""Корень. Отдаёт HTML клиент."""
|
||||||
@@ -64,10 +36,7 @@ class Routes:
|
|||||||
@self.app.route('/health', methods=['GET'])
|
@self.app.route('/health', methods=['GET'])
|
||||||
def health_check():
|
def health_check():
|
||||||
"""Эндпоинт для проверки статуса сервиса."""
|
"""Эндпоинт для проверки статуса сервиса."""
|
||||||
return jsonify({
|
return jsonify({"status": "ok", "version": "1.0.0"}), 200
|
||||||
"status": "ok",
|
|
||||||
"version": self.config.get("version", "1.0.0")
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
@self.app.route('/config', methods=['GET'])
|
@self.app.route('/config', methods=['GET'])
|
||||||
def get_config():
|
def get_config():
|
||||||
@@ -84,35 +53,33 @@ class Routes:
|
|||||||
|
|
||||||
file_path = data["file_path"]
|
file_path = data["file_path"]
|
||||||
|
|
||||||
# Валидация пути к файлу
|
|
||||||
try:
|
try:
|
||||||
validated_path = self.file_validator.validate_local_file_path(
|
validated_path = self.file_validator.validate_local_file_path(
|
||||||
file_path,
|
file_path,
|
||||||
allowed_directories=self.config.get("allowed_directories", [])
|
allowed_directories=self.config.get("allowed_directories", [])
|
||||||
)
|
)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
# Логирование обращения к API с невалидным путем к файлу
|
|
||||||
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown'))
|
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown'))
|
||||||
logger.warning(f"Обращение к эндпоинту /local/transcriptions с невалидным путем к файлу '{file_path}' "
|
logger.warning(f"Невалидный путь '{file_path}' от {client_ip}: {e}")
|
||||||
f"от клиента {client_ip}. Ошибка: {str(e)}")
|
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
source = LocalFileSource(validated_path, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
temp_path, filename, error = get_local_file(validated_path, self._max_size)
|
||||||
response, status_code = self.transcription_service.transcribe_from_source(source, data)
|
if error:
|
||||||
|
return jsonify({"error": error}), 400
|
||||||
|
|
||||||
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, data)
|
||||||
return jsonify(response), status_code
|
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():
|
||||||
"""Эндпоинт для получения списка доступных моделей."""
|
"""Эндпоинт для получения списка доступных моделей."""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"data": [
|
"data": [{
|
||||||
{
|
"id": os.path.basename(self.config["model_path"]),
|
||||||
"id": os.path.basename(self.config["model_path"]),
|
"object": "model",
|
||||||
"object": "model",
|
"owned_by": "openai",
|
||||||
"owned_by": "openai",
|
"permissions": []
|
||||||
"permissions": []
|
}],
|
||||||
}
|
|
||||||
],
|
|
||||||
"object": "list"
|
"object": "list"
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
@@ -126,26 +93,29 @@ class Routes:
|
|||||||
"owned_by": "openai",
|
"owned_by": "openai",
|
||||||
"permissions": []
|
"permissions": []
|
||||||
}), 200
|
}), 200
|
||||||
else:
|
return jsonify({
|
||||||
return jsonify({
|
"error": "Model not found",
|
||||||
"error": "Model not found",
|
"details": f"Model '{model_id}' does not exist"
|
||||||
"details": f"Model '{model_id}' does not exist"
|
}), 404
|
||||||
}), 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
|
|
||||||
|
|
||||||
@self.app.route('/v1/audio/transcriptions', methods=['POST'])
|
@self.app.route('/v1/audio/transcriptions', methods=['POST'])
|
||||||
@log_invalid_file_request
|
|
||||||
def openai_transcribe_endpoint():
|
def openai_transcribe_endpoint():
|
||||||
"""Эндпоинт для транскрибации аудиофайла (multipart-форма)."""
|
"""Эндпоинт для транскрибации аудиофайла (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'])
|
@self.app.route('/v1/audio/transcriptions/url', methods=['POST'])
|
||||||
@log_invalid_file_request
|
|
||||||
def transcribe_from_url():
|
def transcribe_from_url():
|
||||||
"""Эндпоинт для транскрибации аудиофайла по URL."""
|
"""Эндпоинт для транскрибации аудиофайла по URL."""
|
||||||
data = request.json
|
data = request.json
|
||||||
@@ -157,15 +127,22 @@ class Routes:
|
|||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
url = data["url"]
|
url = data["url"]
|
||||||
# Извлекаем параметры транскрибации, если они есть
|
|
||||||
params = {k: v for k, v in data.items() if k != "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))
|
temp_path, filename, error = get_url_file(url, self._max_size)
|
||||||
response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator)
|
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
|
return jsonify(response), status_code
|
||||||
|
|
||||||
@self.app.route('/v1/audio/transcriptions/base64', methods=['POST'])
|
@self.app.route('/v1/audio/transcriptions/base64', methods=['POST'])
|
||||||
@log_invalid_file_request
|
|
||||||
def transcribe_from_base64():
|
def transcribe_from_base64():
|
||||||
"""Эндпоинт для транскрибации аудио, закодированного в base64."""
|
"""Эндпоинт для транскрибации аудио, закодированного в base64."""
|
||||||
data = request.json
|
data = request.json
|
||||||
@@ -177,43 +154,35 @@ class Routes:
|
|||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
base64_data = data["file"]
|
base64_data = data["file"]
|
||||||
# Извлекаем параметры транскрибации, если они есть
|
|
||||||
params = {k: v for k, v in data.items() if k != "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))
|
temp_path, filename, error = get_base64_file(base64_data, self._max_size)
|
||||||
response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator)
|
|
||||||
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()
|
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
return jsonify({"error": error}), 400
|
return jsonify({"error": error}), 400
|
||||||
|
|
||||||
if not file:
|
|
||||||
return jsonify({"error": "Failed to get audio file"}), 400
|
|
||||||
|
|
||||||
# Валидация файла
|
|
||||||
try:
|
try:
|
||||||
self.file_validator.validate_file(file, filename)
|
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'])
|
||||||
|
def transcribe_async():
|
||||||
|
"""Эндпоинт для асинхронной транскрибации аудиофайла."""
|
||||||
|
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:
|
except ValidationError as e:
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
# Сохраняем файл во временный файл
|
task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber)
|
||||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
return jsonify({"task_id": task_id}), 202
|
||||||
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
|
|
||||||
|
|
||||||
@self.app.route('/v1/tasks/<task_id>', methods=['GET'])
|
@self.app.route('/v1/tasks/<task_id>', methods=['GET'])
|
||||||
def get_task_status(task_id):
|
def get_task_status(task_id):
|
||||||
@@ -223,10 +192,7 @@ class Routes:
|
|||||||
if not task_info:
|
if not task_info:
|
||||||
return jsonify({"error": "Task not found"}), 404
|
return jsonify({"error": "Task not found"}), 404
|
||||||
|
|
||||||
response = {
|
response = {"task_id": task_id, "status": task_info["status"]}
|
||||||
"task_id": task_id,
|
|
||||||
"status": task_info["status"]
|
|
||||||
}
|
|
||||||
|
|
||||||
if task_info["status"] == "completed":
|
if task_info["status"] == "completed":
|
||||||
response["result"] = task_info["result"]
|
response["result"] = task_info["result"]
|
||||||
@@ -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,
|
"audio_rate": 8000,
|
||||||
"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",
|
||||||
"audio_speed_factor": 1.0,
|
|
||||||
"device_id": 0,
|
"device_id": 0,
|
||||||
"file_validation": {
|
"file_validation": {
|
||||||
"max_file_size_mb": 500,
|
"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_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"]
|
||||||
},
|
},
|
||||||
"allowed_directories": [],
|
"allowed_directories": [],
|
||||||
"version": "1.0.0",
|
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"log_file": "logs/whisper_api.log",
|
"log_file": "logs/whisper_api.log",
|
||||||
"request_logging": {
|
"request_logging": {
|
||||||
"exclude_endpoints": ["/health", "/static"],
|
"exclude_endpoints": ["/health", "/static"]
|
||||||
"sensitive_headers": ["authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"],
|
|
||||||
"log_debug": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,4 +17,3 @@ scipy==1.13.1
|
|||||||
transformers==4.49.0
|
transformers==4.49.0
|
||||||
accelerate==1.4.0
|
accelerate==1.4.0
|
||||||
python-magic==0.4.27
|
python-magic==0.4.27
|
||||||
flask-limiter==3.5.0
|
|
||||||
|
|||||||
Reference in New Issue
Block a user