diff --git a/CLAUDE.md b/CLAUDE.md index 050f111..c76e453 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,83 +15,8 @@ Local, OpenAI-compatible speech recognition API service using the Whisper model. ## Architecture -``` -server.py # Entry point, argparse, launches WhisperServiceAPI -app/__init__.py # WhisperServiceAPI: Flask init, wires all components -app/core/ - config.py # load_config() from JSON - transcriber.py # WhisperTranscriber: model load, device select, inference - transcription_service.py # TranscriptionService: orchestrates source -> validate -> transcribe -> log -app/audio/ - processor.py # AudioProcessor: WAV convert, normalize, compress, speedup, silence - sources.py # AudioSource (abstract) + UploadedFile/URL/Base64/LocalFile sources - utils.py # AudioUtils: load audio as numpy, get duration via ffprobe -app/api/ - routes.py # All Flask endpoints (OpenAI-compatible + local + async) -app/infrastructure/ - storage/cache.py # SimpleCache with TTL - storage/file_manager.py # TempFileManager: temp file lifecycle with context managers - logging/config.py # setup_logging(): console + rotating file handler - logging/request_logger.py # RequestLogger: HTTP request/response middleware - validation/validators.py # FileValidator: size, extension, MIME checks - async_tasks/manager.py # AsyncTaskManager: thread-based async with status tracking -app/shared/ - history_logger.py # HistoryLogger: saves transcription results as JSON by date - decorators.py # log_invalid_file_request decorator - context_managers.py # open_file context manager -app/static/ - index.html # Built-in web UI client -``` - -## Request Flow - -``` -Flask Request - -> RequestLogger middleware (logs request) - -> Routes (endpoint handler) - -> TranscriptionService.transcribe_from_source() - -> AudioSource.get_audio_file() # fetch from upload/URL/base64/local - -> FileValidator.validate_file() # size/extension/MIME - -> WhisperTranscriber.process_file() - -> AudioProcessor.process_audio() # WAV 16kHz, normalize, compress, speedup, silence - -> WhisperTranscriber.transcribe() # model inference - -> HistoryLogger.save() # persist result JSON - -> JSON Response (text, processing_time, duration, model) -``` - -## API Endpoints - -| Method | Path | Purpose | -|--------|------|---------| -| GET | `/` | Web UI | -| GET | `/health` | Service status | -| GET | `/config` | Current configuration | -| GET | `/v1/models` | List models (OpenAI-compatible) | -| GET | `/v1/models/` | 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/` | Async task status | -| POST | `/local/transcriptions` | Transcribe local server file | - -## Configuration - -All settings in `config.json`. Key parameters: - -* `service_port`: server port (default 5042) -* `model_path`: path to Whisper model directory -* `language`: recognition language -* `device_id`: GPU index for CUDA -* Audio processing: `norm_level`, `compand_params`, `audio_speed_factor`, `audio_rate` -* Inference: `chunk_length_s`, `batch_size`, `max_new_tokens`, `temperature` -* `file_validation`: max size, allowed extensions/MIME types -* `request_logging`: excluded endpoints, sensitive headers - -## Key Design Decisions - -* **Config-driven**: All behavior controlled via `config.json`. No hardcoded model paths or thresholds. -* **Source abstraction**: `AudioSource` ABC unifies all input methods. New sources implement `get_audio_file()`. -* **Temp file lifecycle**: `TempFileManager` with context managers ensures cleanup even on errors. -* **OpenAI compatibility**: `/v1/audio/transcriptions` matches OpenAI API contract for drop-in replacement. -* **Device fallback**: CUDA -> MPS -> CPU, with Flash Attention 2 attempted first on CUDA. +* Entry: `server.py` -> `app/__init__.py` (WhisperServiceAPI) +* Modules: `app/core/` (transcriber, config), `app/audio/` (processor, sources, utils), `app/infrastructure/` (logging, validation, storage, async tasks) +* Request flow: source function -> validate -> transcribe (AudioProcessor -> Whisper inference) -> save history -> JSON response +* OpenAI-compatible API: `/v1/audio/transcriptions` matches OpenAI contract for drop-in replacement +* All settings in `config.json`. Device fallback: CUDA -> MPS -> CPU diff --git a/README.md b/README.md index 92e41d5..d3ce3ee 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,7 @@ The service is configured through the `config.json` file: "return_timestamps": false, "audio_rate": 8000, "norm_level": "-0.55", - "compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15", - "audio_speed_factor": 1.25 + "compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15" } ``` @@ -101,7 +100,6 @@ The service is configured through the `config.json` file: | `audio_rate` | Audio sampling rate in Hz | | `norm_level` | Normalization level for audio preprocessing | | `compand_params` | Parameters for audio compression/expansion | -| `audio_speed_factor` | Audio speed factor for faster recognition (1.0 = no speedup, 1.25 = 25% faster) | ## Web interface diff --git a/RULES.md b/RULES.md index 15026bd..bc0dcc4 100644 --- a/RULES.md +++ b/RULES.md @@ -46,13 +46,13 @@ Non-negotiable. Violation = stop and fix before continuing. ## Audio Processing Rules * FFmpeg and SoX are external dependencies. Always check subprocess return codes. -* Temp files must be created via `TempFileManager.temp_file()` context manager -- never raw `tempfile.mktemp`. -* Audio pipeline order matters: convert to WAV 16kHz -> normalize -> compress/expand -> speedup -> add silence. +* Temp files must be created via `create_temp_file()` from `app/infrastructure/storage.py` -- never raw `tempfile.mktemp`. +* Audio pipeline order matters: convert to WAV 16kHz -> normalize -> compress/expand -> add silence. ## Flask / API Rules -* New endpoints go in `app/api/routes.py` inside `_register_routes()`. -* All transcription endpoints delegate to `TranscriptionService.transcribe_from_source()`. -* New audio input methods: subclass `AudioSource`, implement `get_audio_file()`. +* New endpoints go in `app/routes.py` inside `_register_routes()`. +* All transcription endpoints delegate to `TranscriptionService.transcribe()`. +* New audio input methods: add a `get_*_file()` function in `sources.py` returning `(temp_path, filename, error)`. * File validation runs through `FileValidator` -- never validate inline in routes. * Configuration values accessed via `self.config.get()` with sensible defaults, except critical params which must crash if missing. diff --git a/app/__init__.py b/app/__init__.py index d2a171e..6d81669 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -12,11 +12,9 @@ import waitress from .core.transcriber import WhisperTranscriber from .core.config import load_config -from .api.routes import Routes -from .infrastructure.validation.validators import FileValidator -from .infrastructure.storage.file_manager import temp_file_manager -from .infrastructure.logging.config import setup_logging -from .infrastructure.logging.request_logger import RequestLogger +from .routes import Routes +from .infrastructure.validation import FileValidator +from .infrastructure.log import setup_logging, RequestLogger class WhisperServiceAPI: @@ -60,7 +58,7 @@ class WhisperServiceAPI: self.file_validator = FileValidator(self.config) # Настройка логирования запросов - request_logger_config = self.config.get('request_logger', {}) + request_logger_config = self.config.get('request_logging', {}) request_logger = RequestLogger(self.app, request_logger_config) # Регистрация маршрутов diff --git a/app/api/__init__.py b/app/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/audio/processor.py b/app/audio/processor.py index 695c128..6da6ef8 100644 --- a/app/audio/processor.py +++ b/app/audio/processor.py @@ -7,11 +7,10 @@ import os import subprocess -import uuid from typing import Dict, Tuple import logging -from ..infrastructure.storage.file_manager import temp_file_manager +from ..infrastructure.storage import create_temp_file, cleanup_temp_files logger = logging.getLogger('app.audio_processor') @@ -36,8 +35,7 @@ class AudioProcessor: self.config = config self.norm_level = config.get("norm_level", "-0.5") self.compand_params = config.get("compand_params", "0.3,1 -90,-90,-70,-70,-60,-20,0,0 -5 0 0.2") - self.audio_speed_factor = config.get("audio_speed_factor", 1.25) - + def convert_to_wav(self, input_path: str) -> str: """ Конвертация входного аудиофайла в WAV формат с частотой дискретизации 16 кГц. @@ -66,7 +64,7 @@ class AudioProcessor: # Продолжаем конвертацию, чтобы быть уверенными в формате # Создаем временный файл для WAV - output_path, _ = temp_file_manager.create_temp_file(".wav") + output_path = create_temp_file(".wav") # Команда для конвертации cmd = [ @@ -103,7 +101,7 @@ class AudioProcessor: subprocess.CalledProcessError: Если произошла ошибка при нормализации. """ # Создаем временный файл для нормализованного аудио - output_path, _ = temp_file_manager.create_temp_file("_normalized.wav") + output_path = create_temp_file("_normalized.wav") # Команда для нормализации аудио с помощью sox cmd = [ @@ -124,47 +122,6 @@ class AudioProcessor: logger.error(f"Ошибка при нормализации аудио: {e.stderr.decode()}") raise - def speed_up_audio(self, input_path: str) -> str: - """ - Ускоряет воспроизведение аудиофайла с использованием FFmpeg. - - Args: - input_path: Путь к WAV-файлу. - - Returns: - Путь к ускоренному WAV-файлу. - - Raises: - subprocess.CalledProcessError: Если произошла ошибка при ускорении. - """ - # Если ускорение не требуется (коэффициент = 1.0), возвращаем исходный файл - if float(self.audio_speed_factor) == 1.0: - logger.info(f"Ускорение не требуется (коэффициент = {self.audio_speed_factor})") - return input_path - - # Создаем временный файл для ускоренного аудио - output_path, _ = temp_file_manager.create_temp_file("_speedup.wav") - - # Команда для ускорения аудио с помощью FFmpeg - cmd = [ - "ffmpeg", - "-hide_banner", - "-loglevel", "warning", - "-i", input_path, - "-filter:a", f"atempo={self.audio_speed_factor}", - output_path - ] - - logger.info(f"Ускорение аудио в {self.audio_speed_factor}x: {' '.join(cmd)}") - - try: - subprocess.run(cmd, check=True, capture_output=True) - logger.info(f"Аудио ускорено: {output_path}") - return output_path - except subprocess.CalledProcessError as e: - logger.error(f"Ошибка при ускорении аудио: {e.stderr.decode()}") - raise - def add_silence(self, input_path: str) -> str: """ Добавляет тишину в начало аудиофайла. @@ -179,7 +136,7 @@ class AudioProcessor: subprocess.CalledProcessError: Если произошла ошибка при добавлении тишины. """ # Создаем временный файл - output_path, _ = temp_file_manager.create_temp_file("_silence.wav") + output_path = create_temp_file("_silence.wav") # Команда для добавления тишины в начало файла cmd = [ @@ -223,19 +180,14 @@ class AudioProcessor: # Нормализация normalized_path = self.normalize_audio(wav_path) temp_files.append(normalized_path) - - # УСКОРЕНИЕ ЗВУКА (НОВЫЙ ШАГ) - speedup_path = self.speed_up_audio(normalized_path) - if speedup_path != normalized_path: # Если был создан временный файл - temp_files.append(speedup_path) - + # Добавление тишины - silence_path = self.add_silence(speedup_path) + silence_path = self.add_silence(normalized_path) temp_files.append(silence_path) return silence_path, temp_files except Exception as e: logger.error(f"Ошибка при обработке аудио {input_path}: {e}") - temp_file_manager.cleanup_temp_files(temp_files) + cleanup_temp_files(temp_files) raise \ No newline at end of file diff --git a/app/audio/sources.py b/app/audio/sources.py index 72145d8..2941965 100644 --- a/app/audio/sources.py +++ b/app/audio/sources.py @@ -1,6 +1,6 @@ """ -Модуль sources.py содержит абстрактный класс AudioSource и его конкретные реализации -для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы). +Модуль sources.py содержит функции для получения аудиофайлов +из различных источников (загруженные файлы, URL, base64, локальные файлы). """ import os @@ -8,305 +8,129 @@ import uuid import tempfile import base64 import requests -import abc -from typing import Dict, Tuple, Optional, BinaryIO +from typing import Tuple, Optional import logging logger = logging.getLogger('app.audio_sources') -class AudioSource(abc.ABC): - """Абстрактный класс для различных источников аудиофайлов. - - Определяет интерфейс для различных источников аудио и предоставляет общие - методы для работы с аудиофайлами, такие как проверка размера файла. + +def _check_size(size_bytes: int, max_size_mb: int) -> Optional[str]: + """Проверяет размер файла. Возвращает сообщение об ошибке или None.""" + if size_bytes > max_size_mb * 1024 * 1024: + return f"File exceeds maximum size of {max_size_mb}MB" + return None + + +def _make_temp_path(suffix: str = ".wav") -> str: + """Создаёт путь для временного файла.""" + temp_dir = tempfile.mkdtemp() + return os.path.join(temp_dir, f"{uuid.uuid4()}{suffix}") + + +def get_uploaded_file(request_files, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]: """ - - def __init__(self, max_file_size_mb: int = 100): - """ - Инициализация источника аудио. - - Args: - max_file_size_mb: Максимальный размер файла в МБ. - """ - self.max_file_size_mb = max_file_size_mb - - @abc.abstractmethod - def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]: - """ - Получает аудиофайл из источника. - - Returns: - Кортеж (файловый объект, имя файла, сообщение об ошибке). - В случае ошибки, возвращает (None, None, сообщение об ошибке). - """ - pass - - def check_file_size(self, file: BinaryIO) -> Tuple[bool, Optional[str]]: - """ - Проверяет размер файла. - - Args: - file: Файловый объект для проверки. - - Returns: - Кортеж (результат проверки, сообщение об ошибке). - Если проверка пройдена, сообщение об ошибке будет None. - """ - file.seek(0, os.SEEK_END) - file_length = file.tell() - file.seek(0) # Сброс указателя файла после проверки размера - - if file_length > self.max_file_size_mb * 1024 * 1024: - return False, f"File exceeds maximum size of {self.max_file_size_mb}MB" - - return True, None + Получает аудиофайл из загруженных файлов Flask. - -class FakeFile: - """Имитирует файловый объект для унификации обработки из разных источников. - - Позволяет обрабатывать файлы из различных источников (локальный путь, URL, base64) - как стандартные файловые объекты Flask, обеспечивая совместимость с существующей - логикой обработки файлов. + Returns: + Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке). """ - - def __init__(self, file: BinaryIO, filename: str): - """ - Инициализация объекта FakeFile. - - Args: - file: Исходный файловый объект или поток. - filename: Имя файла для метаданных. - """ - self.file = file - self.filename = filename + if 'file' not in request_files: + return None, None, "No file part" - def read(self): - """Чтение содержимого файла.""" - return self.file.read() + file = request_files['file'] - def seek(self, offset: int, whence: int = 0): - """Перемещение позиции чтения.""" - self.file.seek(offset, whence) + if file.filename == '': + return None, None, "No selected file" - def tell(self): - """Получение текущей позиции чтения.""" - return self.file.tell() + # Проверка размера + file.seek(0, os.SEEK_END) + size = file.tell() + file.seek(0) - def save(self, destination: str): - """ - Сохраняет содержимое файла в указанное место назначения. - - Args: - destination: Путь для сохранения файла. - """ - with open(destination, 'wb') as f: - content = self.file.read() - f.write(content) - self.file.seek(0) # Сброс указателя после чтения + error = _check_size(size, max_file_size_mb) + if error: + return None, None, error - @property - def name(self): - """Возвращает имя файла.""" - return self.filename + # Сохраняем во временный файл + temp_path = _make_temp_path() + file.save(temp_path) + + return temp_path, file.filename, None -class UploadedFileSource(AudioSource): - """Источник аудио для файлов, загруженных через HTTP-запрос.""" - - def __init__(self, request_files, max_file_size_mb: int = 100): - """ - Инициализация источника для загруженных файлов. - - Args: - request_files: Объект request.files из Flask. - max_file_size_mb: Максимальный размер файла в МБ. - """ - super().__init__(max_file_size_mb) - self.request_files = request_files - - def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]: - """ - Получает аудиофайл из загруженных файлов. - - Returns: - Кортеж (файловый объект, имя файла, сообщение об ошибке). - """ - if 'file' not in self.request_files: - return None, None, "No file part" - - file = self.request_files['file'] - - if file.filename == '': - return None, None, "No selected file" - - # Проверка размера файла - is_valid, error_message = self.check_file_size(file) - if not is_valid: - return None, None, error_message - - return file, file.filename, None +def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Получает аудиофайл по URL. + + Returns: + Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке). + """ + try: + response = requests.get(url, stream=True) + response.raise_for_status() + + # Проверка размера по Content-Length + content_length = response.headers.get('Content-Length') + if content_length: + error = _check_size(int(content_length), max_file_size_mb) + if error: + return None, None, error + + temp_path = _make_temp_path() + with open(temp_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + return temp_path, os.path.basename(temp_path), None + + except Exception as e: + logger.error(f"Ошибка при получении файла по URL {url}: {e}") + return None, None, f"Error retrieving file from URL: {str(e)}" -class URLSource(AudioSource): - """Источник аудио для файлов, доступных по URL.""" - - def __init__(self, url: str, max_file_size_mb: int = 100): - """ - Инициализация источника для файлов по URL. - - Args: - url: URL аудиофайла. - max_file_size_mb: Максимальный размер файла в МБ. - """ - super().__init__(max_file_size_mb) - self.url = url - self.temp_file_path = None - self.temp_dir = None - - def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]: - """ - Получает аудиофайл по URL. - - Returns: - Кортеж (файловый объект, имя файла, сообщение об ошибке). - """ - try: - # Скачиваем файл по URL - response = requests.get(self.url, stream=True) - response.raise_for_status() - - # Проверка размера файла (если сервер предоставил информацию о размере) - content_length = response.headers.get('Content-Length') - if content_length and int(content_length) > self.max_file_size_mb * 1024 * 1024: - return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB" - - # Сохраняем файл во временный файл - self.temp_dir = tempfile.mkdtemp() - self.temp_file_path = os.path.join(self.temp_dir, str(uuid.uuid4()) + ".wav") - - with open(self.temp_file_path, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - - # Открываем файл для обработки - file = open(self.temp_file_path, 'rb') - - # Создаем объект файла, как будто он пришел из request.files - fake_file = FakeFile(file, os.path.basename(self.temp_file_path)) - - return fake_file, fake_file.filename, None - - except Exception as e: - logger.error(f"Ошибка при получении файла по URL {self.url}: {e}") - self.cleanup() - return None, None, f"Error retrieving file from URL: {str(e)}" - - def cleanup(self): - """Очищает временные файлы и директории.""" - if self.temp_file_path and os.path.exists(self.temp_file_path): - os.remove(self.temp_file_path) - if self.temp_dir and os.path.exists(self.temp_dir): - os.rmdir(self.temp_dir) +def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Получает аудиофайл из base64 данных. + + Returns: + Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке). + """ + try: + audio_data = base64.b64decode(base64_data) + + error = _check_size(len(audio_data), max_file_size_mb) + if error: + return None, None, error + + temp_path = _make_temp_path() + with open(temp_path, 'wb') as f: + f.write(audio_data) + + return temp_path, os.path.basename(temp_path), None + + except Exception as e: + logger.error(f"Ошибка при декодировании base64 данных: {e}") + return None, None, f"Error decoding base64 data: {str(e)}" -class Base64Source(AudioSource): - """Источник аудио для файлов, закодированных в base64.""" - - def __init__(self, base64_data: str, max_file_size_mb: int = 100): - """ - Инициализация источника для base64 файлов. - - Args: - base64_data: Данные аудиофайла в формате base64. - max_file_size_mb: Максимальный размер файла в МБ. - """ - super().__init__(max_file_size_mb) - self.base64_data = base64_data - self.temp_file_path = None - self.temp_dir = None - - def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]: - """ - Получает аудиофайл из base64 данных. - - Returns: - Кортеж (файловый объект, имя файла, сообщение об ошибке). - """ - try: - # Декодируем base64 - audio_data = base64.b64decode(self.base64_data) - - # Проверка размера файла - if len(audio_data) > self.max_file_size_mb * 1024 * 1024: - return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB" - - # Сохраняем файл во временный файл - self.temp_dir = tempfile.mkdtemp() - self.temp_file_path = os.path.join(self.temp_dir, str(uuid.uuid4()) + ".wav") - - with open(self.temp_file_path, 'wb') as f: - f.write(audio_data) - - # Открываем файл для обработки - file = open(self.temp_file_path, 'rb') - - # Создаем объект файла, как будто он пришел из request.files - fake_file = FakeFile(file, os.path.basename(self.temp_file_path)) - - return fake_file, fake_file.filename, None - - except Exception as e: - logger.error(f"Ошибка при декодировании base64 данных: {e}") - self.cleanup() - return None, None, f"Error decoding base64 data: {str(e)}" - - def cleanup(self): - """Очищает временные файлы и директории.""" - if self.temp_file_path and os.path.exists(self.temp_file_path): - os.remove(self.temp_file_path) - if self.temp_dir and os.path.exists(self.temp_dir): - os.rmdir(self.temp_dir) +def get_local_file(file_path: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Получает локальный аудиофайл. + Returns: + Кортеж (путь к файлу, имя файла, сообщение об ошибке). + Примечание: возвращает оригинальный путь, не копирует файл. + """ + if not os.path.exists(file_path): + return None, None, f"File not found: {file_path}" -class LocalFileSource(AudioSource): - """Источник аудио для локальных файлов на сервере.""" - - def __init__(self, file_path: str, max_file_size_mb: int = 100): - """ - Инициализация источника для локальных файлов. - - Args: - file_path: Путь к локальному файлу. - max_file_size_mb: Максимальный размер файла в МБ. - """ - super().__init__(max_file_size_mb) - self.file_path = file_path - - def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]: - """ - Получает локальный аудиофайл. - - Returns: - Кортеж (файловый объект, имя файла, сообщение об ошибке). - """ - if not os.path.exists(self.file_path): - return None, None, f"File not found: {self.file_path}" - - try: - # Проверка размера файла - file_size = os.path.getsize(self.file_path) - if file_size > self.max_file_size_mb * 1024 * 1024: - return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB" - - # Открываем файл для обработки - file = open(self.file_path, 'rb') - - # Создаем объект файла, как будто он пришел из request.files - fake_file = FakeFile(file, os.path.basename(self.file_path)) - - return fake_file, fake_file.filename, None - - except Exception as e: - logger.error(f"Ошибка при открытии локального файла {self.file_path}: {e}") - return None, None, f"Error opening local file: {str(e)}" \ No newline at end of file + try: + error = _check_size(os.path.getsize(file_path), max_file_size_mb) + if error: + return None, None, error + + return file_path, os.path.basename(file_path), None + + except Exception as e: + logger.error(f"Ошибка при открытии локального файла {file_path}: {e}") + return None, None, f"Error opening local file: {str(e)}" diff --git a/app/audio/utils.py b/app/audio/utils.py index 54a7e81..94f781b 100644 --- a/app/audio/utils.py +++ b/app/audio/utils.py @@ -1,5 +1,5 @@ """ -Модуль utils.py содержит утилитарные функции для работы с аудио. +Утилитарные функции для работы с аудио. """ import os @@ -12,98 +12,66 @@ from typing import Tuple logger = logging.getLogger('app.audio_utils') -class AudioUtils: - """Утилитарный класс для работы с аудио.""" - - @staticmethod - def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]: - """ - Загрузка аудиофайла с использованием встроенной библиотеки wave. +def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]: + """ + Загрузка аудиофайла с использованием встроенной библиотеки wave. - Args: - file_path: Путь к аудиофайлу. - sr: Целевая частота дискретизации. + Args: + file_path: Путь к аудиофайлу. + sr: Целевая частота дискретизации. - Returns: - Кортеж (массив numpy, частота дискретизации). - - Raises: - Exception: Если не удалось загрузить аудиофайл. - """ - try: - # Открываем WAV файл - with wave.open(file_path, 'rb') as wav_file: - # Проверяем, что это моно-аудио - if wav_file.getnchannels() != 1: - logger.warning(f"Файл {file_path} не моно-аудио, конвертируем в моно") - - # Читаем аудиоданные - frames = wav_file.readframes(-1) - # Конвертируем 16-битные целые числа в float32 в диапазоне [-1.0, 1.0] - # 32768.0 - это 2^15, максимальное значение для 16-битного знакового целого - audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0 - - # Получаем частоту дискретизации - sampling_rate = wav_file.getframerate() - - # Если частота дискретизации не совпадает с целевой, выполняем ресемплинг - if sampling_rate != sr: - from scipy.signal import resample - num_samples = int(len(audio_array) * sr / sampling_rate) - audio_array = resample(audio_array, num_samples) - sampling_rate = sr - - return audio_array, sampling_rate - - except Exception as e: - logger.error(f"Ошибка при загрузке аудио {file_path}: {e}") - raise - - @staticmethod - def get_audio_duration(file_path: str) -> float: - """ - Определяет длительность аудиофайла с использованием ffprobe. - - Args: - file_path: Путь к аудиофайлу. - - Returns: - Длительность в секундах. - """ - try: - # Проверяем, что файл существует - if not os.path.exists(file_path): - logger.error(f"Файл не существует: {file_path}") - raise Exception(f"Файл не существует: {file_path}") - - cmd = [ - "ffprobe", - "-v", "error", - "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", - file_path - ] - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True, - timeout=10 # Ограничение по времени выполнения - ) - - duration = float(result.stdout.strip()) - return duration - - except subprocess.TimeoutExpired: - logger.error(f"Таймаут при определении длительности файла {file_path}") - raise Exception(f"Таймаут при определении длительности файла {file_path}") - except subprocess.CalledProcessError as e: - logger.error(f"Ошибка при выполнении ffprobe для файла {file_path}: {e.stderr}") - raise Exception(f"Ошибка при выполнении ffprobe для файла {file_path}: {e.stderr}") - except (ValueError, TypeError) as e: - logger.error(f"Ошибка при преобразовании длительности для файла {file_path}: {e}") - raise Exception(f"Ошибка при преобразовании длительности для файла {file_path}: {e}") - except Exception as e: - logger.error(f"Неожиданная ошибка при определении длительности файла {file_path}: {e}") - raise Exception(f"Неожиданная ошибка при определении длительности файла {file_path}: {e}") \ No newline at end of file + Returns: + Кортеж (массив numpy, частота дискретизации). + """ + try: + with wave.open(file_path, 'rb') as wav_file: + if wav_file.getnchannels() != 1: + logger.warning(f"Файл {file_path} не моно-аудио") + + frames = wav_file.readframes(-1) + audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0 + sampling_rate = wav_file.getframerate() + + if sampling_rate != sr: + from scipy.signal import resample + num_samples = int(len(audio_array) * sr / sampling_rate) + audio_array = resample(audio_array, num_samples) + sampling_rate = sr + + return audio_array, sampling_rate + + except Exception as e: + logger.error(f"Ошибка при загрузке аудио {file_path}: {e}") + raise + + +def get_audio_duration(file_path: str) -> float: + """ + Определяет длительность аудиофайла с использованием ffprobe. + + Args: + file_path: Путь к аудиофайлу. + + Returns: + Длительность в секундах. + """ + if not os.path.exists(file_path): + raise Exception(f"Файл не существует: {file_path}") + + cmd = [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + file_path + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=10) + return float(result.stdout.strip()) + except subprocess.TimeoutExpired: + raise Exception(f"Таймаут при определении длительности файла {file_path}") + except subprocess.CalledProcessError as e: + raise Exception(f"Ошибка ffprobe для файла {file_path}: {e.stderr}") + except (ValueError, TypeError) as e: + raise Exception(f"Ошибка при преобразовании длительности для файла {file_path}: {e}") diff --git a/app/core/transcriber.py b/app/core/transcriber.py index 905bd6c..9713673 100644 --- a/app/core/transcriber.py +++ b/app/core/transcriber.py @@ -20,8 +20,8 @@ from transformers import ( ) from ..audio.processor import AudioProcessor -from ..audio.utils import AudioUtils -from ..infrastructure.storage.file_manager import temp_file_manager +from ..audio.utils import load_audio +from ..infrastructure.storage import cleanup_temp_files logger = logging.getLogger('app.transcriber') @@ -177,7 +177,7 @@ class WhisperTranscriber: try: # Загрузка аудио в формате numpy array - audio_array, sampling_rate = AudioUtils.load_audio(audio_path, sr=16000) + audio_array, sampling_rate = load_audio(audio_path, sr=16000) # Транскрибация с корректным форматом данных result = self.asr_pipeline( @@ -279,4 +279,4 @@ class WhisperTranscriber: finally: # Очистка временных файлов - temp_file_manager.cleanup_temp_files(temp_files) \ No newline at end of file + cleanup_temp_files(temp_files) \ No newline at end of file diff --git a/app/core/transcription_service.py b/app/core/transcription_service.py index 4eea324..34b8d76 100644 --- a/app/core/transcription_service.py +++ b/app/core/transcription_service.py @@ -1,6 +1,6 @@ """ Модуль transcription_service.py содержит класс TranscriptionService, -который отвечает за обработку и транскрибацию аудиофайлов. +который отвечает за транскрибацию аудиофайлов. """ import os @@ -9,74 +9,31 @@ import traceback from typing import Dict, Tuple import logging -from ..audio.utils import AudioUtils -from ..shared.history_logger import HistoryLogger -from ..audio.sources import AudioSource -from ..infrastructure.validation.validators import FileValidator, ValidationError +from ..audio.utils import get_audio_duration +from ..history import save_history logger = logging.getLogger('app.transcription_service') class TranscriptionService: - """ - Сервис для обработки и транскрибации аудиофайлов. - - Attributes: - transcriber: Экземпляр транскрайбера. - config (Dict): Словарь с конфигурацией. - max_file_size_mb (int): Максимальный размер файла в МБ. - history (HistoryLogger): Объект журналирования. - """ + """Сервис для транскрибации аудиофайлов.""" def __init__(self, transcriber, config: Dict): - """ - Инициализация сервиса транскрибации. - - Args: - transcriber: Экземпляр транскрайбера. - config: Словарь с конфигурацией. - """ self.transcriber = transcriber self.config = config - self.max_file_size_mb = self.config.get("file_validation", {}).get("max_file_size_mb", 100) - # Объект журналирования - self.history = HistoryLogger(config) - - def transcribe_from_source(self, source: AudioSource, params: Dict = None, file_validator: FileValidator = None) -> Tuple[Dict, int]: + def transcribe(self, file_path: str, filename: str, params: Dict = None) -> Tuple[Dict, int]: """ - Транскрибирует аудиофайл из указанного источника. + Транскрибирует аудиофайл по пути. Args: - source: Источник аудиофайла. + file_path: Путь к аудиофайлу. + filename: Имя файла (для логов и истории). params: Дополнительные параметры для транскрибации. - file_validator: Валидатор файлов. Returns: Кортеж (JSON-ответ, HTTP-код). """ - # Получаем файл из источника - file, filename, error = source.get_audio_file() - - # Обрабатываем ошибки получения файла - if error: - logger.warning(f"Ошибка получения файла из источника: {error}") - return {"error": error}, 400 - - if not file: - logger.warning("Не удалось получить аудиофайл из источника") - return {"error": "Failed to get audio file"}, 400 - - # Валидация файла, если предоставлен валидатор - if file_validator: - try: - file_validator.validate_file(file, filename) - except ValidationError as e: - # Логирование ошибки валидации - logger.warning(f"Ошибка валидации файла '{filename}': {str(e)}") - return {"error": str(e)}, 400 - - # Извлекаем параметры из запроса, если они есть params = params or {} language = params.get('language', self.config.get('language', 'en')) temperature = float(params.get('temperature', 0.0)) @@ -84,7 +41,6 @@ class TranscriptionService: # Проверяем, запрошены ли временные метки return_timestamps = params.get('return_timestamps', self.config.get('return_timestamps', False)) - # Преобразуем строковое значение в булево, если необходимо if isinstance(return_timestamps, str): return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1') @@ -92,59 +48,44 @@ class TranscriptionService: original_return_timestamps = self.transcriber.return_timestamps self.transcriber.return_timestamps = return_timestamps - # Сохраняем файл во временный файл - from ..infrastructure.storage.file_manager import temp_file_manager - with temp_file_manager.temp_file() as temp_file_path: - file.save(temp_file_path) - + try: # Определяем длительность аудиофайла try: - duration = AudioUtils.get_audio_duration(temp_file_path) + duration = get_audio_duration(file_path) except Exception as e: logger.error(f"Ошибка при определении длительности файла: {e}") return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500 - # Для файлов из внешних источников (URL, base64), закрываем их и выполняем очистку - if hasattr(source, 'cleanup'): - file.file.close() # Закрываем файловый объект - source.cleanup() # Очищаем временные файлы источника + start_time = time.time() + result = self.transcriber.process_file(file_path) + processing_time = time.time() - start_time - try: - start_time = time.time() - result = self.transcriber.process_file(temp_file_path) - processing_time = time.time() - start_time + # Формируем ответ + if return_timestamps: + response = { + "segments": result.get("segments", []), + "text": result.get("text", ""), + "processing_time": processing_time, + "response_size_bytes": len(str(result).encode('utf-8')), + "duration_seconds": duration, + "model": os.path.basename(self.config["model_path"]) + } + else: + response = { + "text": result, + "processing_time": processing_time, + "response_size_bytes": len(str(result).encode('utf-8')), + "duration_seconds": duration, + "model": os.path.basename(self.config["model_path"]) + } - # Формируем ответ в зависимости от return_timestamps - if return_timestamps: - response = { - "segments": result.get("segments", []), - "text": result.get("text", ""), - "processing_time": processing_time, - "response_size_bytes": len(str(result).encode('utf-8')), - "duration_seconds": duration, - "model": os.path.basename(self.config["model_path"]) - } - else: - # Если не запрашивались временные метки, result - это строка - response = { - "text": result, - "processing_time": processing_time, - "response_size_bytes": len(str(result).encode('utf-8')), - "duration_seconds": duration, - "model": os.path.basename(self.config["model_path"]) - } + save_history(response, filename, self.config) + return response, 200 - # Журналирование результата - self.history.save(response, filename) + except Exception as e: + logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}") + logger.error(f"Traceback: {traceback.format_exc()}") + return {"error": str(e)}, 500 - return response, 200 - - except Exception as e: - logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}") - logger.error(f"Тип исключения: {type(e).__name__}") - logger.error(f"Traceback: {traceback.format_exc()}") - return {"error": str(e)}, 500 - - finally: - # Восстанавливаем оригинальное значение return_timestamps - self.transcriber.return_timestamps = original_return_timestamps \ No newline at end of file + finally: + self.transcriber.return_timestamps = original_return_timestamps diff --git a/app/history.py b/app/history.py new file mode 100644 index 0000000..2a933c2 --- /dev/null +++ b/app/history.py @@ -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 diff --git a/app/infrastructure/async_tasks/manager.py b/app/infrastructure/async_tasks.py similarity index 100% rename from app/infrastructure/async_tasks/manager.py rename to app/infrastructure/async_tasks.py diff --git a/app/infrastructure/async_tasks/__init__.py b/app/infrastructure/async_tasks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/infrastructure/log.py b/app/infrastructure/log.py new file mode 100644 index 0000000..8f17cae --- /dev/null +++ b/app/infrastructure/log.py @@ -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 diff --git a/app/infrastructure/logging/__init__.py b/app/infrastructure/logging/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/infrastructure/logging/config.py b/app/infrastructure/logging/config.py deleted file mode 100644 index c81a968..0000000 --- a/app/infrastructure/logging/config.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/infrastructure/logging/request_logger.py b/app/infrastructure/logging/request_logger.py deleted file mode 100644 index bfef1d7..0000000 --- a/app/infrastructure/logging/request_logger.py +++ /dev/null @@ -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' \ No newline at end of file diff --git a/app/infrastructure/storage.py b/app/infrastructure/storage.py new file mode 100644 index 0000000..e0826ff --- /dev/null +++ b/app/infrastructure/storage.py @@ -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}") diff --git a/app/infrastructure/storage/__init__.py b/app/infrastructure/storage/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/infrastructure/storage/cache.py b/app/infrastructure/storage/cache.py deleted file mode 100644 index 653cd5b..0000000 --- a/app/infrastructure/storage/cache.py +++ /dev/null @@ -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 час) \ No newline at end of file diff --git a/app/infrastructure/storage/file_manager.py b/app/infrastructure/storage/file_manager.py deleted file mode 100644 index b9827e0..0000000 --- a/app/infrastructure/storage/file_manager.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/app/infrastructure/validation/validators.py b/app/infrastructure/validation.py similarity index 84% rename from app/infrastructure/validation/validators.py rename to app/infrastructure/validation.py index 8a74019..761bae8 100644 --- a/app/infrastructure/validation/validators.py +++ b/app/infrastructure/validation.py @@ -149,6 +149,43 @@ class FileValidator: logger.warning(f"Не удалось определить MIME-тип файла: {e}") # Не прерываем валидацию, если не удалось определить MIME-тип + def validate_file_by_path(self, file_path: str, filename: str) -> bool: + """ + Валидирует файл по пути на диске. + + Args: + file_path: Путь к файлу. + filename: Имя файла (для проверки расширения). + + Returns: + True, если файл прошел валидацию. + + Raises: + ValidationError: Если файл не прошел валидацию. + """ + # Проверка расширения + self._validate_file_extension(filename) + + # Проверка размера + file_size = os.path.getsize(file_path) + max_size_bytes = self.max_file_size_mb * 1024 * 1024 + if file_size > max_size_bytes: + raise ValidationError(f"Размер файла ({file_size / (1024*1024):.2f} МБ) " + f"превышает максимально допустимый ({self.max_file_size_mb} МБ)") + + # Проверка MIME-типа + try: + mime_type = magic.from_file(file_path, mime=True) + if mime_type not in self.allowed_mime_types: + raise ValidationError(f"MIME-тип файла ({mime_type}) не разрешен. " + f"Разрешенные MIME-типы: {', '.join(self.allowed_mime_types)}") + except ValidationError: + raise + except Exception as e: + logger.warning(f"Не удалось определить MIME-тип файла: {e}") + + return True + @staticmethod def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str: """ diff --git a/app/infrastructure/validation/__init__.py b/app/infrastructure/validation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/routes.py b/app/routes.py similarity index 54% rename from app/api/routes.py rename to app/routes.py index 9b7f0b1..38d098e 100644 --- a/app/api/routes.py +++ b/app/routes.py @@ -8,54 +8,26 @@ from flask import request, jsonify from typing import Dict import logging -from ..core.transcription_service import TranscriptionService -from ..audio.sources import ( - UploadedFileSource, - URLSource, - Base64Source, - LocalFileSource -) -from ..infrastructure.validation.validators import ValidationError -from ..infrastructure.async_tasks.manager import transcribe_audio_async, task_manager -from ..infrastructure.storage.cache import model_cache -from ..shared.decorators import log_invalid_file_request +from .core.transcription_service import TranscriptionService +from .audio.sources import get_uploaded_file, get_url_file, get_base64_file, get_local_file +from .infrastructure.validation import ValidationError +from .infrastructure.async_tasks import transcribe_audio_async, task_manager logger = logging.getLogger('app.routes') class Routes: - """ - Класс для регистрации всех эндпоинтов API. - - Attributes: - app (Flask): Flask-приложение. - config (Dict): Словарь с конфигурацией. - transcription_service (TranscriptionService): Сервис транскрибации. - file_validator (FileValidator): Валидатор файлов. - """ + """Класс для регистрации всех эндпоинтов API.""" def __init__(self, app, transcriber, config: Dict, file_validator): - """ - Инициализация маршрутов. - - Args: - app: Flask-приложение. - transcriber: Экземпляр транскрайбера. - config: Словарь с конфигурацией. - file_validator: Валидатор файлов. - """ self.app = app self.config = config self.transcription_service = TranscriptionService(transcriber, config) self.file_validator = file_validator - - # Регистрация маршрутов + self._max_size = self.config.get("file_validation", {}).get("max_file_size_mb", 100) self._register_routes() def _register_routes(self) -> None: - """ - Регистрация всех эндпоинтов. - """ @self.app.route('/', methods=['GET']) def index(): """Корень. Отдаёт HTML клиент.""" @@ -64,10 +36,7 @@ class Routes: @self.app.route('/health', methods=['GET']) def health_check(): """Эндпоинт для проверки статуса сервиса.""" - return jsonify({ - "status": "ok", - "version": self.config.get("version", "1.0.0") - }), 200 + return jsonify({"status": "ok", "version": "1.0.0"}), 200 @self.app.route('/config', methods=['GET']) def get_config(): @@ -83,36 +52,34 @@ class Routes: return jsonify({"error": "No file_path provided"}), 400 file_path = data["file_path"] - - # Валидация пути к файлу + try: validated_path = self.file_validator.validate_local_file_path( - file_path, + file_path, allowed_directories=self.config.get("allowed_directories", []) ) except ValidationError as e: - # Логирование обращения к API с невалидным путем к файлу client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown')) - logger.warning(f"Обращение к эндпоинту /local/transcriptions с невалидным путем к файлу '{file_path}' " - f"от клиента {client_ip}. Ошибка: {str(e)}") + logger.warning(f"Невалидный путь '{file_path}' от {client_ip}: {e}") return jsonify({"error": str(e)}), 400 - - source = LocalFileSource(validated_path, self.config.get("file_validation", {}).get("max_file_size_mb", 100)) - response, status_code = self.transcription_service.transcribe_from_source(source, data) + + temp_path, filename, error = get_local_file(validated_path, self._max_size) + if error: + return jsonify({"error": error}), 400 + + response, status_code = self.transcription_service.transcribe(temp_path, filename, data) return jsonify(response), status_code @self.app.route('/v1/models', methods=['GET']) def list_models(): """Эндпоинт для получения списка доступных моделей.""" return jsonify({ - "data": [ - { - "id": os.path.basename(self.config["model_path"]), - "object": "model", - "owned_by": "openai", - "permissions": [] - } - ], + "data": [{ + "id": os.path.basename(self.config["model_path"]), + "object": "model", + "owned_by": "openai", + "permissions": [] + }], "object": "list" }), 200 @@ -126,26 +93,29 @@ class Routes: "owned_by": "openai", "permissions": [] }), 200 - else: - return jsonify({ - "error": "Model not found", - "details": f"Model '{model_id}' does not exist" - }), 404 - - def _handle_transcription_request(): - """Общая функция для обработки запросов транскрибации.""" - source = UploadedFileSource(request.files, self.config.get("file_validation", {}).get("max_file_size_mb", 100)) - response, status_code = self.transcription_service.transcribe_from_source(source, request.form, self.file_validator) - return jsonify(response), status_code + return jsonify({ + "error": "Model not found", + "details": f"Model '{model_id}' does not exist" + }), 404 @self.app.route('/v1/audio/transcriptions', methods=['POST']) - @log_invalid_file_request def openai_transcribe_endpoint(): """Эндпоинт для транскрибации аудиофайла (multipart-форма).""" - return _handle_transcription_request() + temp_path, filename, error = get_uploaded_file(request.files, self._max_size) + if error: + return jsonify({"error": error}), 400 + + # Валидация файла + try: + self.file_validator.validate_file_by_path(temp_path, filename) + except ValidationError as e: + logger.warning(f"Ошибка валидации файла '{filename}': {e}") + return jsonify({"error": str(e)}), 400 + + response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form)) + return jsonify(response), status_code @self.app.route('/v1/audio/transcriptions/url', methods=['POST']) - @log_invalid_file_request def transcribe_from_url(): """Эндпоинт для транскрибации аудиофайла по URL.""" data = request.json @@ -157,15 +127,22 @@ class Routes: }), 400 url = data["url"] - # Извлекаем параметры транскрибации, если они есть params = {k: v for k, v in data.items() if k != "url"} - source = URLSource(url, self.config.get("file_validation", {}).get("max_file_size_mb", 100)) - response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator) + temp_path, filename, error = get_url_file(url, self._max_size) + if error: + return jsonify({"error": error}), 400 + + try: + self.file_validator.validate_file_by_path(temp_path, filename) + except ValidationError as e: + logger.warning(f"Ошибка валидации файла '{filename}': {e}") + return jsonify({"error": str(e)}), 400 + + response, status_code = self.transcription_service.transcribe(temp_path, filename, params) return jsonify(response), status_code @self.app.route('/v1/audio/transcriptions/base64', methods=['POST']) - @log_invalid_file_request def transcribe_from_base64(): """Эндпоинт для транскрибации аудио, закодированного в base64.""" data = request.json @@ -177,60 +154,49 @@ class Routes: }), 400 base64_data = data["file"] - # Извлекаем параметры транскрибации, если они есть params = {k: v for k, v in data.items() if k != "file"} - source = Base64Source(base64_data, self.config.get("file_validation", {}).get("max_file_size_mb", 100)) - response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator) + temp_path, filename, error = get_base64_file(base64_data, self._max_size) + if error: + return jsonify({"error": error}), 400 + + try: + self.file_validator.validate_file_by_path(temp_path, filename) + except ValidationError as e: + logger.warning(f"Ошибка валидации файла '{filename}': {e}") + return jsonify({"error": str(e)}), 400 + + response, status_code = self.transcription_service.transcribe(temp_path, filename, params) return jsonify(response), status_code @self.app.route('/v1/audio/transcriptions/async', methods=['POST']) - @log_invalid_file_request def transcribe_async(): """Эндпоинт для асинхронной транскрибации аудиофайла.""" - source = UploadedFileSource(request.files, self.config.get("file_validation", {}).get("max_file_size_mb", 100)) - - # Получаем файл - file, filename, error = source.get_audio_file() - + temp_path, filename, error = get_uploaded_file(request.files, self._max_size) if error: return jsonify({"error": error}), 400 - - if not file: - return jsonify({"error": "Failed to get audio file"}), 400 - - # Валидация файла + try: - self.file_validator.validate_file(file, filename) + self.file_validator.validate_file_by_path(temp_path, filename) except ValidationError as e: return jsonify({"error": str(e)}), 400 - - # Сохраняем файл во временный файл - from ..infrastructure.storage.file_manager import temp_file_manager - with temp_file_manager.temp_file() as temp_path: - file.save(temp_path) - - # Запускаем асинхронную транскрибацию - task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber) - - return jsonify({"task_id": task_id}), 202 - + + task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber) + return jsonify({"task_id": task_id}), 202 + @self.app.route('/v1/tasks/', methods=['GET']) def get_task_status(task_id): """Эндпоинт для получения статуса асинхронной задачи.""" task_info = task_manager.get_task_status(task_id) - + if not task_info: return jsonify({"error": "Task not found"}), 404 - - response = { - "task_id": task_id, - "status": task_info["status"] - } - + + response = {"task_id": task_id, "status": task_info["status"]} + if task_info["status"] == "completed": response["result"] = task_info["result"] elif task_info["status"] == "failed": response["error"] = task_info["error"] - - return jsonify(response) \ No newline at end of file + + return jsonify(response) diff --git a/app/shared/__init__.py b/app/shared/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/shared/context_managers.py b/app/shared/context_managers.py deleted file mode 100644 index 83f6f37..0000000 --- a/app/shared/context_managers.py +++ /dev/null @@ -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() diff --git a/app/shared/decorators.py b/app/shared/decorators.py deleted file mode 100644 index 171b16e..0000000 --- a/app/shared/decorators.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/shared/history_logger.py b/app/shared/history_logger.py deleted file mode 100644 index 01aae0c..0000000 --- a/app/shared/history_logger.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/config.json b/config.json index ab7a88c..704e56b 100644 --- a/config.json +++ b/config.json @@ -11,7 +11,6 @@ "audio_rate": 8000, "norm_level": "-0.55", "compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15", - "audio_speed_factor": 1.0, "device_id": 0, "file_validation": { "max_file_size_mb": 500, @@ -19,12 +18,9 @@ "allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"] }, "allowed_directories": [], - "version": "1.0.0", "log_level": "INFO", "log_file": "logs/whisper_api.log", "request_logging": { - "exclude_endpoints": ["/health", "/static"], - "sensitive_headers": ["authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"], - "log_debug": false + "exclude_endpoints": ["/health", "/static"] } } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ac4ea0e..d62581b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,3 @@ scipy==1.13.1 transformers==4.49.0 accelerate==1.4.0 python-magic==0.4.27 -flask-limiter==3.5.0