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:
Serge Zaigraeff
2026-03-22 05:28:34 +03:00
co-authored by Claude Opus 4.6
parent 38785398b3
commit 2e2bad8255
30 changed files with 556 additions and 1407 deletions
+5 -80
View File
@@ -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.
+1 -3
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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)
# Регистрация маршрутов # Регистрация маршрутов
View File
+6 -54
View File
@@ -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
+62 -238
View File
@@ -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 __init__(self, max_file_size_mb: int = 100):
"""
Инициализация источника аудио.
Args: def _make_temp_path(suffix: str = ".wav") -> str:
max_file_size_mb: Максимальный размер файла в МБ. """Создаёт путь для временного файла."""
""" temp_dir = tempfile.mkdtemp()
self.max_file_size_mb = max_file_size_mb return os.path.join(temp_dir, f"{uuid.uuid4()}{suffix}")
@abc.abstractmethod
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]: def get_uploaded_file(request_files, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
""" """
Получает аудиофайл из источника. Получает аудиофайл из загруженных файлов Flask.
Returns: Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке). Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
В случае ошибки, возвращает (None, None, сообщение об ошибке).
""" """
pass if 'file' not in request_files:
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, обеспечивая совместимость с существующей
логикой обработки файлов.
"""
def __init__(self, file: BinaryIO, filename: str):
"""
Инициализация объекта FakeFile.
Args:
file: Исходный файловый объект или поток.
filename: Имя файла для метаданных.
"""
self.file = file
self.filename = filename
def read(self):
"""Чтение содержимого файла."""
return self.file.read()
def seek(self, offset: int, whence: int = 0):
"""Перемещение позиции чтения."""
self.file.seek(offset, whence)
def tell(self):
"""Получение текущей позиции чтения."""
return self.file.tell()
def save(self, destination: str):
"""
Сохраняет содержимое файла в указанное место назначения.
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):
"""Источник аудио для файлов, загруженных через HTTP-запрос."""
def __init__(self, request_files, max_file_size_mb: int = 100):
"""
Инициализация источника для загруженных файлов.
Args:
request_files: Объект request.files из Flask.
max_file_size_mb: Максимальный размер файла в МБ.
"""
super().__init__(max_file_size_mb)
self.request_files = request_files
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
"""
Получает аудиофайл из загруженных файлов.
Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке).
"""
if 'file' not in self.request_files:
return None, None, "No file part" return None, None, "No file part"
file = self.request_files['file'] file = request_files['file']
if file.filename == '': if file.filename == '':
return None, None, "No selected file" return None, None, "No selected file"
# Проверка размера файла # Проверка размера
is_valid, error_message = self.check_file_size(file) file.seek(0, os.SEEK_END)
if not is_valid: size = file.tell()
return None, None, error_message file.seek(0)
return file, file.filename, None error = _check_size(size, max_file_size_mb)
if error:
return None, None, error
# Сохраняем во временный файл
temp_path = _make_temp_path()
file.save(temp_path)
return temp_path, file.filename, None
class URLSource(AudioSource): def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""Источник аудио для файлов, доступных по URL."""
def __init__(self, url: str, max_file_size_mb: int = 100):
"""
Инициализация источника для файлов по URL.
Args:
url: URL аудиофайла.
max_file_size_mb: Максимальный размер файла в МБ.
"""
super().__init__(max_file_size_mb)
self.url = url
self.temp_file_path = None
self.temp_dir = None
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
""" """
Получает аудиофайл по URL. Получает аудиофайл по URL.
Returns: Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке). Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
""" """
try: try:
# Скачиваем файл по URL response = requests.get(url, stream=True)
response = requests.get(self.url, stream=True)
response.raise_for_status() response.raise_for_status()
# Проверка размера файла (если сервер предоставил информацию о размере) # Проверка размера по Content-Length
content_length = response.headers.get('Content-Length') content_length = response.headers.get('Content-Length')
if content_length and int(content_length) > self.max_file_size_mb * 1024 * 1024: if content_length:
return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB" error = _check_size(int(content_length), max_file_size_mb)
if error:
return None, None, error
# Сохраняем файл во временный файл temp_path = _make_temp_path()
self.temp_dir = tempfile.mkdtemp() with open(temp_path, 'wb') as f:
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): for chunk in response.iter_content(chunk_size=8192):
f.write(chunk) f.write(chunk)
# Открываем файл для обработки return temp_path, os.path.basename(temp_path), None
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: except Exception as e:
logger.error(f"Ошибка при получении файла по URL {self.url}: {e}") logger.error(f"Ошибка при получении файла по URL {url}: {e}")
self.cleanup()
return None, None, f"Error retrieving file from URL: {str(e)}" return None, None, f"Error retrieving file from URL: {str(e)}"
def cleanup(self):
"""Очищает временные файлы и директории."""
if self.temp_file_path and os.path.exists(self.temp_file_path):
os.remove(self.temp_file_path)
if self.temp_dir and os.path.exists(self.temp_dir):
os.rmdir(self.temp_dir)
def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
class Base64Source(AudioSource):
"""Источник аудио для файлов, закодированных в base64."""
def __init__(self, base64_data: str, max_file_size_mb: int = 100):
"""
Инициализация источника для base64 файлов.
Args:
base64_data: Данные аудиофайла в формате base64.
max_file_size_mb: Максимальный размер файла в МБ.
"""
super().__init__(max_file_size_mb)
self.base64_data = base64_data
self.temp_file_path = None
self.temp_dir = None
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
""" """
Получает аудиофайл из base64 данных. Получает аудиофайл из base64 данных.
Returns: Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке). Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
""" """
try: try:
# Декодируем base64 audio_data = base64.b64decode(base64_data)
audio_data = base64.b64decode(self.base64_data)
# Проверка размера файла error = _check_size(len(audio_data), max_file_size_mb)
if len(audio_data) > self.max_file_size_mb * 1024 * 1024: if error:
return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB" return None, None, error
# Сохраняем файл во временный файл temp_path = _make_temp_path()
self.temp_dir = tempfile.mkdtemp() with open(temp_path, 'wb') as f:
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) f.write(audio_data)
# Открываем файл для обработки return temp_path, os.path.basename(temp_path), None
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: except Exception as e:
logger.error(f"Ошибка при декодировании base64 данных: {e}") logger.error(f"Ошибка при декодировании base64 данных: {e}")
self.cleanup()
return None, None, f"Error decoding base64 data: {str(e)}" return None, None, f"Error decoding base64 data: {str(e)}"
def cleanup(self):
"""Очищает временные файлы и директории."""
if self.temp_file_path and os.path.exists(self.temp_file_path):
os.remove(self.temp_file_path)
if self.temp_dir and os.path.exists(self.temp_dir):
os.rmdir(self.temp_dir)
def get_local_file(file_path: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
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: Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке). Кортеж (путь к файлу, имя файла, сообщение об ошибке).
Примечание: возвращает оригинальный путь, не копирует файл.
""" """
if not os.path.exists(self.file_path): if not os.path.exists(file_path):
return None, None, f"File not found: {self.file_path}" return None, None, f"File not found: {file_path}"
try: try:
# Проверка размера файла error = _check_size(os.path.getsize(file_path), max_file_size_mb)
file_size = os.path.getsize(self.file_path) if error:
if file_size > self.max_file_size_mb * 1024 * 1024: return None, None, error
return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB"
# Открываем файл для обработки return file_path, os.path.basename(file_path), None
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: except Exception as e:
logger.error(f"Ошибка при открытии локального файла {self.file_path}: {e}") logger.error(f"Ошибка при открытии локального файла {file_path}: {e}")
return None, None, f"Error opening local file: {str(e)}" return None, None, f"Error opening local file: {str(e)}"
+7 -39
View File
@@ -1,5 +1,5 @@
""" """
Модуль utils.py содержит утилитарные функции для работы с аудио. Утилитарные функции для работы с аудио.
""" """
import os import os
@@ -12,10 +12,6 @@ from typing import Tuple
logger = logging.getLogger('app.audio_utils') logger = logging.getLogger('app.audio_utils')
class AudioUtils:
"""Утилитарный класс для работы с аудио."""
@staticmethod
def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]: def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
""" """
Загрузка аудиофайла с использованием встроенной библиотеки wave. Загрузка аудиофайла с использованием встроенной библиотеки wave.
@@ -26,27 +22,16 @@ class AudioUtils:
Returns: Returns:
Кортеж (массив numpy, частота дискретизации). Кортеж (массив numpy, частота дискретизации).
Raises:
Exception: Если не удалось загрузить аудиофайл.
""" """
try: try:
# Открываем WAV файл
with wave.open(file_path, 'rb') as wav_file: with wave.open(file_path, 'rb') as wav_file:
# Проверяем, что это моно-аудио
if wav_file.getnchannels() != 1: if wav_file.getnchannels() != 1:
logger.warning(f"Файл {file_path} не моно-аудио, конвертируем в моно") logger.warning(f"Файл {file_path} не моно-аудио")
# Читаем аудиоданные
frames = wav_file.readframes(-1) 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 audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
# Получаем частоту дискретизации
sampling_rate = wav_file.getframerate() sampling_rate = wav_file.getframerate()
# Если частота дискретизации не совпадает с целевой, выполняем ресемплинг
if sampling_rate != sr: if sampling_rate != sr:
from scipy.signal import resample from scipy.signal import resample
num_samples = int(len(audio_array) * sr / sampling_rate) num_samples = int(len(audio_array) * sr / sampling_rate)
@@ -59,7 +44,7 @@ class AudioUtils:
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}") logger.error(f"Ошибка при загрузке аудио {file_path}: {e}")
raise raise
@staticmethod
def get_audio_duration(file_path: str) -> float: def get_audio_duration(file_path: str) -> float:
""" """
Определяет длительность аудиофайла с использованием ffprobe. Определяет длительность аудиофайла с использованием ffprobe.
@@ -70,10 +55,7 @@ class AudioUtils:
Returns: Returns:
Длительность в секундах. Длительность в секундах.
""" """
try:
# Проверяем, что файл существует
if not os.path.exists(file_path): if not os.path.exists(file_path):
logger.error(f"Файл не существует: {file_path}")
raise Exception(f"Файл не существует: {file_path}") raise Exception(f"Файл не существует: {file_path}")
cmd = [ cmd = [
@@ -84,26 +66,12 @@ class AudioUtils:
file_path file_path
] ]
result = subprocess.run( try:
cmd, result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=10)
capture_output=True, return float(result.stdout.strip())
text=True,
check=True,
timeout=10 # Ограничение по времени выполнения
)
duration = float(result.stdout.strip())
return duration
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.error(f"Таймаут при определении длительности файла {file_path}")
raise Exception(f"Таймаут при определении длительности файла {file_path}") raise Exception(f"Таймаут при определении длительности файла {file_path}")
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при выполнении ffprobe для файла {file_path}: {e.stderr}") raise Exception(f"Ошибка ffprobe для файла {file_path}: {e.stderr}")
raise Exception(f"Ошибка при выполнении ffprobe для файла {file_path}: {e.stderr}")
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
logger.error(f"Ошибка при преобразовании длительности для файла {file_path}: {e}")
raise Exception(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}")
+4 -4
View File
@@ -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)
+13 -72
View File
@@ -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,29 +48,19 @@ 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), закрываем их и выполняем очистку
if hasattr(source, 'cleanup'):
file.file.close() # Закрываем файловый объект
source.cleanup() # Очищаем временные файлы источника
try:
start_time = time.time() start_time = time.time()
result = self.transcriber.process_file(temp_file_path) result = self.transcriber.process_file(file_path)
processing_time = time.time() - start_time processing_time = time.time() - start_time
# Формируем ответ в зависимости от return_timestamps # Формируем ответ
if return_timestamps: if return_timestamps:
response = { response = {
"segments": result.get("segments", []), "segments": result.get("segments", []),
@@ -125,7 +71,6 @@ class TranscriptionService:
"model": os.path.basename(self.config["model_path"]) "model": os.path.basename(self.config["model_path"])
} }
else: else:
# Если не запрашивались временные метки, result - это строка
response = { response = {
"text": result, "text": result,
"processing_time": processing_time, "processing_time": processing_time,
@@ -134,17 +79,13 @@ class TranscriptionService:
"model": os.path.basename(self.config["model_path"]) "model": os.path.basename(self.config["model_path"])
} }
# Журналирование результата save_history(response, filename, self.config)
self.history.save(response, filename)
return response, 200 return response, 200
except Exception as e: except Exception as e:
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}") logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
logger.error(f"Тип исключения: {type(e).__name__}")
logger.error(f"Traceback: {traceback.format_exc()}") logger.error(f"Traceback: {traceback.format_exc()}")
return {"error": str(e)}, 500 return {"error": str(e)}, 500
finally: finally:
# Восстанавливаем оригинальное значение return_timestamps
self.transcriber.return_timestamps = original_return_timestamps self.transcriber.return_timestamps = original_return_timestamps
+56
View File
@@ -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
+106
View File
@@ -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
-64
View File
@@ -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'
+46
View File
@@ -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}")
-93
View File
@@ -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 час)
-104
View File
@@ -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:
""" """
+58 -92
View File
@@ -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,42 +154,34 @@ 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
# Сохраняем файл во временный файл
from ..infrastructure.storage.file_manager import temp_file_manager
with temp_file_manager.temp_file() as temp_path:
file.save(temp_path)
# Запускаем асинхронную транскрибацию
task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber) task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber)
return jsonify({"task_id": task_id}), 202 return jsonify({"task_id": task_id}), 202
@self.app.route('/v1/tasks/<task_id>', methods=['GET']) @self.app.route('/v1/tasks/<task_id>', methods=['GET'])
@@ -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"]
View File
-34
View File
@@ -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()
-48
View File
@@ -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
-86
View File
@@ -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
View File
@@ -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
} }
} }
-1
View File
@@ -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