Refactor project structure and add new features

This commit is contained in:
Serge Zaigraeff
2025-10-23 00:42:09 +03:00
parent 144414fcf8
commit 852f7975b9
63 changed files with 8101 additions and 1341 deletions
+90 -54
View File
@@ -1,15 +1,33 @@
import json
"""
Главный модуль приложения, содержащий класс WhisperServiceAPI для инициализации
и запуска сервиса распознавания речи.
"""
import os
import logging
from flask import Flask
from typing import Dict
from flask import Flask
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 .transcriber import WhisperTranscriber
from .routes import Routes
from .utils import logger
class WhisperServiceAPI:
"""Класс для API сервиса распознавания речи."""
"""
Класс для API сервиса распознавания речи.
Attributes:
config (Dict): Словарь с параметрами конфигурации.
port (int): Порт для сервиса.
transcriber (WhisperTranscriber): Экземпляр транскрайбера.
app (Flask): Flask-приложение.
file_validator (FileValidator): Валидатор файлов.
"""
def __init__(self, config_path: str):
"""
@@ -19,50 +37,68 @@ class WhisperServiceAPI:
config_path: Путь к конфигурационному файлу.
"""
# Загрузка конфигурации
self.config = self._load_config(config_path)
# Порт для сервиса
self.port = self.config["service_port"]
# Создание экземпляра транскрайбера
self.transcriber = WhisperTranscriber(self.config)
# Создание Flask-приложения
self.app = Flask("whisper-service")
# Регистрация маршрутов
Routes(self.app, self.transcriber, self.config)
logger.info(f"API сервис инициализирован, порт: {self.port}")
def _load_config(self, config_path: str) -> Dict:
"""
Загрузка конфигурации из JSON-файла.
Args:
config_path: Путь к файлу конфигурации.
Returns:
Словарь с параметрами конфигурации.
Raises:
FileNotFoundError: Если файл конфигурации не найден.
json.JSONDecodeError: Если файл конфигурации содержит некорректный JSON.
"""
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
return config
except FileNotFoundError as e:
logger.error(f"Файл конфигурации не найден: {e}")
raise
except json.JSONDecodeError as e:
logger.error(f"Ошибка при загрузке конфигурации: {e}")
raise
def run(self):
"""Запуск сервиса."""
logger.info(f"Запуск сервиса на порту {self.port}")
self.config = load_config(config_path)
# Использовать waitress для production-ready сервера
waitress.serve(self.app, host='0.0.0.0', port=self.port)
# Установка уровня логирования
log_level = getattr(logging, self.config.get('log_level', 'INFO').upper())
log_file = self.config.get('log_file')
setup_logging(log_level=log_level, log_file=log_file)
# Получаем логгер для этого модуля
self.logger = logging.getLogger('app')
self.logger.info("Инициализация WhisperServiceAPI")
# Инициализация Flask приложения
self.app = Flask(__name__)
self.port = self.config.get('port', 5000)
# Инициализация компонентов
self.transcriber = WhisperTranscriber(self.config)
self.file_validator = FileValidator(self.config)
# Настройка логирования запросов
request_logger_config = self.config.get('request_logger', {})
request_logger = RequestLogger(self.app, request_logger_config)
# Регистрация маршрутов
routes = Routes(self.app, self.transcriber, self.config, self.file_validator)
# Регистрация обработчиков очистки
self._register_cleanup_handlers()
self.logger.info("WhisperServiceAPI успешно инициализирован")
def _register_cleanup_handlers(self) -> None:
"""
Регистрация обработчиков для очистки ресурсов при завершении приложения.
"""
@self.app.teardown_appcontext
def cleanup(error):
"""
Очистка временных файлов при завершении контекста запроса.
Args:
error: Ошибка, если она произошла.
"""
# Выполнение очистки временных файлов, если необходимо
pass
def run(self, host: str = '0.0.0.0', debug: bool = False) -> None:
"""
Запуск Flask приложения.
Args:
host: Хост для запуска приложения.
debug: Флаг отладочного режима.
"""
self.logger.info(f"Запуск сервиса на {host}:{self.port}")
self.app.run(host=host, port=self.port, debug=debug)
def create_app(self) -> Flask:
"""
Создание и настройка Flask приложения (для использования с WSGI серверами).
Returns:
Настроенное Flask приложение.
"""
return self.app
View File
+6
View File
@@ -0,0 +1,6 @@
"""
Модуль middleware.py содержит различные middleware для Flask приложения.
"""
# Request logging middleware is already implemented in infrastructure/logging/request_logger.py
# This file can be used for additional middleware if needed in the future
+113 -19
View File
@@ -6,20 +6,35 @@
import os
from flask import request, jsonify
from typing import Dict
import logging
from .transcriber_service import TranscriptionService
from .audio_sources import (
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
logger = logging.getLogger('app.routes')
class Routes:
"""Класс для регистрации всех эндпоинтов API."""
"""
Класс для регистрации всех эндпоинтов API.
Attributes:
app (Flask): Flask-приложение.
config (Dict): Словарь с конфигурацией.
transcription_service (TranscriptionService): Сервис транскрибации.
file_validator (FileValidator): Валидатор файлов.
"""
def __init__(self, app, transcriber, config: Dict):
def __init__(self, app, transcriber, config: Dict, file_validator):
"""
Инициализация маршрутов.
@@ -27,16 +42,24 @@ class Routes:
app: Flask-приложение.
transcriber: Экземпляр транскрайбера.
config: Словарь с конфигурацией.
file_validator: Валидатор файлов.
"""
self.app = app
self.config = config
self.transcription_service = TranscriptionService(transcriber, config)
self.file_validator = file_validator
# Регистрация маршрутов
self._register_routes()
def _register_routes(self):
"""Регистрация всех эндпоинтов."""
def _register_routes(self) -> None:
"""
Регистрация всех эндпоинтов.
"""
@self.app.route('/', methods=['GET'])
def index():
"""Корень. Отдаёт HTML клиент."""
return self.app.send_static_file('index.html')
@self.app.route('/health', methods=['GET'])
def health_check():
@@ -60,7 +83,21 @@ class Routes:
return jsonify({"error": "No file_path provided"}), 400
file_path = data["file_path"]
source = LocalFileSource(file_path, self.config.get("max_file_size", 100))
# Валидация пути к файлу
try:
validated_path = self.file_validator.validate_local_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)}")
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)
return jsonify(response), status_code
@@ -95,14 +132,20 @@ class Routes:
"details": f"Model '{model_id}' does not exist"
}), 404
@self.app.route('/v1/audio/transcriptions', methods=['POST'])
def openai_transcribe_endpoint():
"""Эндпоинт для транскрибации аудиофайла (multipart-форма)."""
source = UploadedFileSource(request.files, self.config.get("max_file_size", 100))
response, status_code = self.transcription_service.transcribe_from_source(source, request.form)
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'])
@log_invalid_file_request
def openai_transcribe_endpoint():
"""Эндпоинт для транскрибации аудиофайла (multipart-форма)."""
return _handle_transcription_request()
@self.app.route('/v1/audio/transcriptions/url', methods=['POST'])
@log_invalid_file_request
def transcribe_from_url():
"""Эндпоинт для транскрибации аудиофайла по URL."""
data = request.json
@@ -117,11 +160,12 @@ class Routes:
# Извлекаем параметры транскрибации, если они есть
params = {k: v for k, v in data.items() if k != "url"}
source = URLSource(url, self.config.get("max_file_size", 100))
response, status_code = self.transcription_service.transcribe_from_source(source, params)
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)
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
@@ -136,13 +180,63 @@ class Routes:
# Извлекаем параметры транскрибации, если они есть
params = {k: v for k, v in data.items() if k != "file"}
source = Base64Source(base64_data, self.config.get("max_file_size", 100))
response, status_code = self.transcription_service.transcribe_from_source(source, params)
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)
return jsonify(response), status_code
@self.app.route('/v1/audio/transcriptions/multipart', methods=['POST'])
@log_invalid_file_request
def transcribe_multipart():
"""Эндпоинт для транскрибации аудиофайла, загруженного через форму."""
source = UploadedFileSource(request.files, self.config.get("max_file_size", 100))
response, status_code = self.transcription_service.transcribe_from_source(source, request.form)
return jsonify(response), status_code
return _handle_transcription_request()
@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:
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)
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
@self.app.route('/v1/tasks/<task_id>', methods=['GET'])
def get_task_status(task_id):
"""Эндпоинт для получения статуса асинхронной задачи."""
task_info = task_manager.get_task_status(task_id)
if not task_info:
return jsonify({"error": "Task not found"}), 404
response = {
"task_id": task_id,
"status": task_info["status"]
}
if task_info["status"] == "completed":
response["result"] = task_info["result"]
elif task_info["status"] == "failed":
response["error"] = task_info["error"]
return jsonify(response)
View File
@@ -1,5 +1,5 @@
"""
Модуль audio_processor.py содержит класс AudioProcessor, предназначенный для предобработки аудиофайлов
Модуль processor.py содержит класс AudioProcessor, предназначенный для предобработки аудиофайлов
перед их использованием в системах распознавания речи. Класс предоставляет методы для конвертации
аудио в формат WAV с частотой дискретизации 16 кГц, нормализации уровня громкости,
добавления тишины в начало записи, а также для удаления временных файлов, созданных в процессе обработки.
@@ -7,15 +7,25 @@
import os
import subprocess
import tempfile
import uuid
from typing import Dict, Tuple
import logging
from ..infrastructure.storage.file_manager import temp_file_manager
from ..shared.context_managers import open_file
logger = logging.getLogger('app.audio_processor')
# Импорт классов и функций из других модулей
from .utils import logger
class AudioProcessor:
"""Класс для предобработки аудиофайлов перед распознаванием."""
"""
Класс для предобработки аудиофайлов перед распознаванием.
Attributes:
config (Dict): Словарь с параметрами конфигурации.
norm_level (str): Уровень нормализации аудио.
compand_params (str): Параметры компрессора аудио.
"""
def __init__(self, config: Dict):
"""
@@ -27,6 +37,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:
"""
@@ -37,8 +48,10 @@ class AudioProcessor:
Returns:
Путь к сконвертированному WAV-файлу.
Raises:
subprocess.CalledProcessError: Если произошла ошибка при конвертации.
"""
audio_rate = self.config["audio_rate"]
# Проверка расширения файла
@@ -54,8 +67,7 @@ class AudioProcessor:
# Продолжаем конвертацию, чтобы быть уверенными в формате
# Создаем временный файл для WAV
temp_dir = tempfile.mkdtemp()
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.wav")
output_path, _ = temp_file_manager.create_temp_file(".wav")
# Команда для конвертации
cmd = [
@@ -87,10 +99,12 @@ class AudioProcessor:
Returns:
Путь к нормализованному WAV-файлу.
Raises:
subprocess.CalledProcessError: Если произошла ошибка при нормализации.
"""
# Создаем временный файл для нормализованного аудио
temp_dir = tempfile.mkdtemp()
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}_normalized.wav")
output_path, _ = temp_file_manager.create_temp_file("_normalized.wav")
# Команда для нормализации аудио с помощью sox
cmd = [
@@ -111,6 +125,47 @@ 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:
"""
Добавляет тишину в начало аудиофайла.
@@ -120,10 +175,12 @@ class AudioProcessor:
Returns:
Путь к аудиофайлу с добавленной тишиной.
Raises:
subprocess.CalledProcessError: Если произошла ошибка при добавлении тишины.
"""
# Создаем временный файл
temp_dir = tempfile.mkdtemp()
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}_silence.wav")
output_path, _ = temp_file_manager.create_temp_file("_silence.wav")
# Команда для добавления тишины в начало файла
cmd = [
@@ -143,27 +200,6 @@ class AudioProcessor:
logger.error(f"Ошибка при добавлении тишины: {e.stderr.decode()}")
raise
def cleanup_temp_files(self, file_paths: list):
"""
Удаление временных файлов и директорий.
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)
logger.debug(f"Удалена временная директория: {temp_dir}")
except Exception as e:
logger.warning(f"Не удалось очистить временный файл {path}: {e}")
def process_audio(self, input_path: str) -> Tuple[str, list]:
"""
Полная обработка аудиофайла: конвертация, нормализация и добавление тишины.
@@ -173,6 +209,9 @@ class AudioProcessor:
Returns:
Кортеж: (путь к обработанному файлу, список временных файлов для удаления)
Raises:
Exception: Если произошла ошибка при обработке аудио.
"""
temp_files = []
@@ -186,13 +225,18 @@ 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(normalized_path)
silence_path = self.add_silence(speedup_path)
temp_files.append(silence_path)
return silence_path, temp_files
except Exception as e:
logger.error(f"Ошибка при обработке аудио {input_path}: {e}")
self.cleanup_temp_files(temp_files)
raise
temp_file_manager.cleanup_temp_files(temp_files)
raise
@@ -1,5 +1,5 @@
"""
Модуль audio_sources.py содержит абстрактный класс AudioSource и его конкретные реализации
Модуль sources.py содержит абстрактный класс AudioSource и его конкретные реализации
для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы).
"""
@@ -10,8 +10,9 @@ import base64
import requests
import abc
from typing import Dict, Tuple, Optional, BinaryIO
import logging
from .utils import logger
logger = logging.getLogger('app.audio_sources')
class AudioSource(abc.ABC):
"""Абстрактный класс для различных источников аудиофайлов.
@@ -308,4 +309,4 @@ class LocalFileSource(AudioSource):
except Exception as e:
logger.error(f"Ошибка при открытии локального файла {self.file_path}: {e}")
return None, None, f"Error opening local file: {str(e)}"
return None, None, f"Error opening local file: {str(e)}"
+109
View File
@@ -0,0 +1,109 @@
"""
Модуль utils.py содержит утилитарные функции для работы с аудио.
"""
import os
import subprocess
import wave
import numpy as np
import logging
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.
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}")
View File
+37
View File
@@ -0,0 +1,37 @@
"""
Модуль config.py содержит функции для управления конфигурацией приложения.
"""
import json
import os
import logging
from typing import Dict
logger = logging.getLogger('app.config')
def load_config(config_path: str) -> Dict:
"""
Загружает конфигурацию из JSON-файла.
Args:
config_path: Путь к файлу конфигурации.
Returns:
Словарь с параметрами конфигурации.
Raises:
FileNotFoundError: Если файл конфигурации не найден.
json.JSONDecodeError: Если файл конфигурации содержит некорректный JSON.
"""
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
logger.info(f"Конфигурация успешно загружена из {config_path}")
return config
except FileNotFoundError as e:
logger.error(f"Файл конфигурации не найден: {e}")
raise
except json.JSONDecodeError as e:
logger.error(f"Ошибка при загрузке конфигурации: {e}")
raise
+121 -87
View File
@@ -1,5 +1,5 @@
"""
Модуль transcribe.py содержит класс WhisperTranscriber, который использует модель Whisper от
Модуль transcriber.py содержит класс WhisperTranscriber, который использует модель Whisper от
OpenAI для транскрибации аудиофайлов в текст. Класс включает в себя методы для загрузки модели,
обработки аудио (с использованием класса AudioProcessor), и выполнения транскрибации.
Обрабатывает выбор устройства (CPU, CUDA, MPS) для выполнения вычислений и обеспечивает
@@ -7,9 +7,10 @@ OpenAI для транскрибации аудиофайлов в текст.
"""
import time
import traceback
from typing import Dict, Tuple, Union
import logging
import librosa
import numpy as np
import torch
from transformers import (
@@ -18,12 +19,34 @@ from transformers import (
pipeline,
)
from .audio_processor import AudioProcessor
from .utils import logger
from ..audio.processor import AudioProcessor
from ..audio.utils import AudioUtils
from ..infrastructure.storage.file_manager import temp_file_manager
logger = logging.getLogger('app.transcriber')
class WhisperTranscriber:
"""Класс для распознавания речи с помощью модели Whisper."""
"""
Класс для распознавания речи с помощью модели Whisper.
Attributes:
config (Dict): Словарь с параметрами конфигурации.
model_path (str): Путь к модели Whisper.
language (str): Язык распознавания.
chunk_length_s (int): Длина аудиочанка в секундах.
batch_size (int): Размер пакета для обработки.
max_new_tokens (int): Максимальное количество новых токенов для генерации.
return_timestamps (bool): Флаг возврата временных меток.
temperature (float): Параметр температуры для генерации.
torch_dtype (torch.dtype): Оптимальный тип данных для тензоров.
audio_processor (AudioProcessor): Объект для обработки аудио.
device (torch.device): Устройство для вычислений.
model (WhisperForConditionalGeneration): Загруженная модель Whisper.
processor (WhisperProcessor): Процессор для модели Whisper.
asr_pipeline (pipeline): Пайплайн для автоматического распознавания речи.
"""
def __init__(self, config: Dict):
"""
Инициализация транскрайбера.
@@ -53,7 +76,12 @@ class WhisperTranscriber:
self._load_model()
def _get_device(self) -> torch.device:
"""Определение доступного устройства для вычислений."""
"""
Определение доступного устройства для вычислений.
Returns:
Объект устройства PyTorch.
"""
if torch.cuda.is_available():
# Проверяем, доступна ли GPU с индексом 1
if torch.cuda.device_count() > 1:
@@ -71,8 +99,13 @@ class WhisperTranscriber:
logger.info("Используется CPU для вычислений")
return torch.device("cpu")
def _load_model(self):
"""Загрузка модели и процессора."""
def _load_model(self) -> None:
"""
Загрузка модели и процессора.
Raises:
Exception: Если не удалось загрузить модель.
"""
logger.info(f"Загрузка модели из {self.model_path}")
try:
@@ -119,23 +152,6 @@ class WhisperTranscriber:
logger.info("Модель успешно загружена и готова к использованию")
def _load_audio(self, file_path: str) -> Tuple[np.ndarray, int]:
"""
Загрузка аудиофайла с использованием librosa.
Args:
file_path: Путь к аудиофайлу.
Returns:
Tuple с массивом numpy и частотой дискретизации.
"""
try:
audio_array, sampling_rate = librosa.load(file_path, sr=16000)
return audio_array, sampling_rate
except Exception as e:
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}")
raise
def transcribe(self, audio_path: str) -> Union[str, Dict]:
"""
Транскрибация аудиофайла.
@@ -149,61 +165,72 @@ class WhisperTranscriber:
- Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст)
"""
logger.info(f"Начало транскрибации файла: {audio_path}")
# Загрузка аудио в формате numpy array
audio_array, sampling_rate = self._load_audio(audio_path)
# Транскрибация с корректным форматом данных
result = self.asr_pipeline(
{"raw": audio_array, "sampling_rate": sampling_rate},
generate_kwargs={"language": self.language, "max_new_tokens": self.max_new_tokens, "temperature": self.temperature},
return_timestamps=self.return_timestamps
)
# Если временные метки не запрошены, возвращаем только текст
if not self.return_timestamps:
transcribed_text = result.get("text", "")
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
return transcribed_text
# Если временные метки запрошены, обрабатываем и форматируем результат
segments = []
full_text = result.get("text", "")
if "chunks" in result:
# Для новых версий модели Whisper
for chunk in result["chunks"]:
start_time = chunk.get("timestamp", [0, 0])[0]
end_time = chunk.get("timestamp", [0, 0])[1]
text = chunk.get("text", "").strip()
segments.append({
"start_time_ms": int(start_time * 1000),
"end_time_ms": int(end_time * 1000),
"text": text
})
elif hasattr(result, "get") and "segments" in result:
# Для старых версий модели Whisper
for segment in result["segments"]:
start_time = segment.get("start", 0)
end_time = segment.get("end", 0)
text = segment.get("text", "").strip()
segments.append({
"start_time_ms": int(start_time * 1000),
"end_time_ms": int(end_time * 1000),
"text": text
})
else:
logger.warning("Временные метки запрошены, но не найдены в результате транскрибации")
logger.info(f"Транскрибация с временными метками завершена: получено {len(segments)} сегментов")
# Возвращаем словарь с сегментами и полным текстом
return {
"segments": segments,
"text": full_text
}
try:
# Загрузка аудио в формате numpy array
audio_array, sampling_rate = AudioUtils.load_audio(audio_path, sr=16000)
# Транскрибация с корректным форматом данных
result = self.asr_pipeline(
{"raw": audio_array, "sampling_rate": sampling_rate},
generate_kwargs={
"language": self.language,
"max_new_tokens": self.max_new_tokens,
"temperature": self.temperature
},
return_timestamps=self.return_timestamps
)
# Если временные метки не запрошены, возвращаем только текст
if not self.return_timestamps:
transcribed_text = result.get("text", "")
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
return transcribed_text
# Если временные метки запрошены, обрабатываем и форматируем результат
segments = []
full_text = result.get("text", "")
if "chunks" in result:
# Для новых версий модели Whisper
for chunk in result["chunks"]:
start_time = chunk.get("timestamp", [0, 0])[0]
end_time = chunk.get("timestamp", [0, 0])[1]
text = chunk.get("text", "").strip()
segments.append({
"start_time_ms": int(start_time * 1000),
"end_time_ms": int(end_time * 1000),
"text": text
})
elif hasattr(result, "get") and "segments" in result:
# Для старых версий модели Whisper
for segment in result["segments"]:
start_time = segment.get("start", 0)
end_time = segment.get("end", 0)
text = segment.get("text", "").strip()
segments.append({
"start_time_ms": int(start_time * 1000),
"end_time_ms": int(end_time * 1000),
"text": text
})
else:
logger.warning("Временные метки запрошены, но не найдены в результате транскрибации")
logger.info(f"Транскрибация с временными метками завершена: получено {len(segments)} сегментов")
# Возвращаем словарь с сегментами и полным текстом
return {
"segments": segments,
"text": full_text
}
except Exception as e:
logger.error(f"Ошибка в процессе транскрибации аудиофайла '{audio_path}': {str(e)}")
logger.error(f"Тип исключения: {type(e).__name__}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise
def process_file(self, input_path: str) -> Union[str, Dict]:
"""
@@ -219,21 +246,28 @@ class WhisperTranscriber:
"""
start_time = time.time()
logger.info(f"Начало обработки файла: {input_path}")
temp_files = []
try:
# Обработка аудио (конвертация, нормализация, добавление тишины)
processed_path, temp_files = self.audio_processor.process_audio(input_path)
# Транскрибация
result = self.transcribe(processed_path)
elapsed_time = time.time() - start_time
logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд")
return result
except Exception as e:
elapsed_time = time.time() - start_time
logger.error(f"Ошибка при обработке файла '{input_path}' через {elapsed_time:.2f} секунд: {str(e)}")
logger.error(f"Тип исключения: {type(e).__name__}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise
finally:
# Очистка временных файлов
self.audio_processor.cleanup_temp_files(temp_files)
temp_file_manager.cleanup_temp_files(temp_files)
+152
View File
@@ -0,0 +1,152 @@
"""
Модуль transcription_service.py содержит класс TranscriptionService,
который отвечает за обработку и транскрибацию аудиофайлов.
"""
import os
import uuid
import tempfile
import time
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
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]:
"""
Транскрибирует аудиофайл из указанного источника.
Args:
source: Источник аудиофайла.
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))
prompt = params.get('prompt', '')
# Проверяем, запрошены ли временные метки
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')
# Временно изменяем настройку return_timestamps в транскрайбере
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:
duration = AudioUtils.get_audio_duration(temp_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() # Очищаем временные файлы источника
try:
start_time = time.time()
result = self.transcriber.process_file(temp_file_path)
processing_time = time.time() - start_time
# Формируем ответ в зависимости от 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"])
}
# Журналирование результата
self.history.save(response, filename)
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
View File
+139
View File
@@ -0,0 +1,139 @@
"""
Модуль manager.py содержит функции для асинхронной обработки задач.
"""
import uuid
import time
from typing import Dict, Any, Callable, Optional
from threading import Thread
import logging
logger = logging.getLogger('app.async_tasks')
class AsyncTaskManager:
"""
Менеджер асинхронных задач на основе потоков.
Attributes:
tasks (Dict): Словарь для хранения информации о задачах.
"""
def __init__(self):
"""
Инициализация менеджера асинхронных задач.
"""
self.tasks: Dict[str, Dict[str, Any]] = {}
def run_task(self, func: Callable, *args, **kwargs) -> str:
"""
Запуск задачи в отдельном потоке.
Args:
func: Функция для выполнения.
*args: Позиционные аргументы для функции.
**kwargs: Именованные аргументы для функции.
Returns:
ID задачи.
"""
task_id = str(uuid.uuid4())
# Создание информации о задаче
self.tasks[task_id] = {
"status": "pending",
"result": None,
"error": None,
"created_at": time.time(),
"started_at": None,
"completed_at": None
}
# Создание и запуск потока
thread = Thread(target=self._run_task_thread, args=(task_id, func, args, kwargs))
thread.daemon = True
thread.start()
return task_id
def _run_task_thread(self, task_id: str, func: Callable, args: tuple, kwargs: dict) -> None:
"""
Функция для выполнения задачи в потоке.
Args:
task_id: ID задачи.
func: Функция для выполнения.
args: Позиционные аргументы для функции.
kwargs: Именованные аргументы для функции.
"""
try:
# Обновление статуса задачи
self.tasks[task_id]["status"] = "running"
self.tasks[task_id]["started_at"] = time.time()
# Выполнение функции
result = func(*args, **kwargs)
# Сохранение результата
self.tasks[task_id]["status"] = "completed"
self.tasks[task_id]["result"] = result
self.tasks[task_id]["completed_at"] = time.time()
logger.info(f"Задача {task_id} завершена успешно")
except Exception as e:
# Обработка ошибки
self.tasks[task_id]["status"] = "failed"
self.tasks[task_id]["error"] = str(e)
self.tasks[task_id]["completed_at"] = time.time()
logger.error(f"Задача {task_id} завершилась с ошибкой: {e}")
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
"""
Получение статуса задачи.
Args:
task_id: ID задачи.
Returns:
Информация о задаче или None, если задача не найдена.
"""
return self.tasks.get(task_id)
def cleanup_completed_tasks(self, max_age_seconds: int = 3600) -> None:
"""
Очистка завершенных задач старше указанного возраста.
Args:
max_age_seconds: Максимальный возраст задачи в секундах.
"""
current_time = time.time()
tasks_to_remove = []
for task_id, task_info in self.tasks.items():
if (task_info["status"] in ["completed", "failed"] and
"completed_at" in task_info and
current_time - task_info["completed_at"] > max_age_seconds):
tasks_to_remove.append(task_id)
for task_id in tasks_to_remove:
del self.tasks[task_id]
logger.debug(f"Задача {task_id} удалена из-за устаревания")
# Глобальный экземпляр менеджера асинхронных задач
task_manager = AsyncTaskManager()
def transcribe_audio_async(file_path: str, transcriber) -> str:
"""
Асинхронная транскрибация аудиофайла.
Args:
file_path: Путь к аудиофайлу.
transcriber: Экземпляр транскрайбера.
Returns:
ID задачи.
"""
return task_manager.run_task(transcriber.process_file, file_path)
+64
View File
@@ -0,0 +1,64 @@
"""
Модуль 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
@@ -0,0 +1,234 @@
"""
Модуль 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'
+128
View File
@@ -0,0 +1,128 @@
"""
Модуль cache.py содержит функции для кэширования данных.
"""
import time
from typing import Dict, Any, Optional, Callable
from functools import wraps
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 час)
config_cache = SimpleCache(ttl=300) # Кэш для конфигурации (5 минут)
def cache_result(cache_instance: SimpleCache, key_prefix: str = ""):
"""
Декоратор для кэширования результатов функции.
Args:
cache_instance: Экземпляр кэша.
key_prefix: Префикс для ключа кэша.
Returns:
Декорированная функция.
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
# Генерация ключа кэша на основе имени функции и аргументов
cache_key = f"{key_prefix}{func.__name__}_{str(args)}_{str(kwargs)}"
# Попытка получить результат из кэша
cached_result = cache_instance.get(cache_key)
if cached_result is not None:
return cached_result
# Если результат не в кэше, вызываем функцию
result = func(*args, **kwargs)
# Сохраняем результат в кэш
cache_instance.set(cache_key, result)
return result
return wrapper
return decorator
+118
View File
@@ -0,0 +1,118 @@
"""
Модуль 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])
def cleanup_all(self) -> None:
"""
Очищает все отслеживаемые временные файлы и директории.
"""
self.cleanup_temp_files()
for temp_dir in self.temp_dirs:
try:
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
os.rmdir(temp_dir)
logger.debug(f"Удалена временная директория: {temp_dir}")
except Exception as e:
logger.warning(f"Не удалось очистить временную директорию {temp_dir}: {e}")
self.temp_dirs.clear()
# Глобальный экземпляр менеджера временных файлов
temp_file_manager = TempFileManager()
+183
View File
@@ -0,0 +1,183 @@
"""
Модуль validators.py содержит классы и функции для валидации входных данных.
"""
import os
import magic
from typing import Dict, List, BinaryIO, Optional
import logging
# Получаем логгер из централизованной настройки
logger = logging.getLogger('app.validators')
class ValidationError(Exception):
"""Исключение для ошибок валидации."""
pass
class FileValidator:
"""
Класс для валидации файлов.
Проверяет тип файла, размер и другие параметры на основе конфигурации.
"""
def __init__(self, config: Dict):
"""
Инициализация валидатора файлов.
Args:
config: Словарь с параметрами конфигурации.
"""
self.validation_config = config.get("file_validation", {})
self.max_file_size_mb = self.validation_config.get("max_file_size_mb", 100)
self.allowed_extensions = self.validation_config.get("allowed_extensions",
[".wav", ".mp3", ".ogg", ".flac", ".m4a"])
self.allowed_mime_types = self.validation_config.get("allowed_mime_types",
["audio/wav", "audio/mpeg", "audio/ogg",
"audio/flac", "audio/mp4"])
def validate_file(self, file: BinaryIO, filename: str) -> bool:
"""
Валидирует файл на основе конфигурации.
Args:
file: Файловый объект.
filename: Имя файла.
Returns:
True, если файл прошел валидацию.
Raises:
ValidationError: Если файл не прошел валидацию.
"""
try:
# Проверка размера файла
self._validate_file_size(file)
# Проверка расширения файла
self._validate_file_extension(filename)
# Проверка MIME-типа файла
self._validate_file_mime_type(file)
return True
except ValidationError as e:
# Логирование общей ошибки валидации
logger.warning(f"Ошибка валидации файла '{filename}': {str(e)}")
raise
def _validate_file_size(self, file: BinaryIO) -> None:
"""
Валидирует размер файла.
Args:
file: Файловый объект.
Raises:
ValidationError: Если размер файла превышает максимально допустимый.
"""
# Сохранение текущей позиции
current_position = file.tell()
# Переход в конец файла для определения размера
file.seek(0, os.SEEK_END)
file_length = file.tell()
# Возврат к исходной позиции
file.seek(current_position)
max_size_bytes = self.max_file_size_mb * 1024 * 1024
if file_length > max_size_bytes:
logger.warning(f"Попытка загрузки файла размером {file_length / (1024*1024):.2f} МБ, "
f"что превышает максимально допустимый размер {self.max_file_size_mb} МБ")
raise ValidationError(f"Размер файла ({file_length / (1024*1024):.2f} МБ) "
f"превышает максимально допустимый ({self.max_file_size_mb} МБ)")
def _validate_file_extension(self, filename: str) -> None:
"""
Валидирует расширение файла.
Args:
filename: Имя файла.
Raises:
ValidationError: Если расширение файла не входит в список разрешенных.
"""
if not any(filename.lower().endswith(ext.lower()) for ext in self.allowed_extensions):
# Логирование попытки загрузки файла с неразрешенным расширением
file_extension = os.path.splitext(filename)[1]
logger.warning(f"Попытка загрузки файла с неразрешенным расширением '{file_extension}'. "
f"Имя файла: {filename}. Разрешенные расширения: {', '.join(self.allowed_extensions)}")
raise ValidationError(f"Расширение файла не разрешено. "
f"Разрешенные расширения: {', '.join(self.allowed_extensions)}")
def _validate_file_mime_type(self, file: BinaryIO) -> None:
"""
Валидирует MIME-тип файла.
Args:
file: Файловый объект.
Raises:
ValidationError: Если MIME-тип файла не входит в список разрешенных.
"""
# Сохранение текущей позиции
current_position = file.tell()
try:
# Чтение первых байтов для определения MIME-типа
header = file.read(1024)
mime_type = magic.from_buffer(header, mime=True)
# Возврат к исходной позиции
file.seek(current_position)
if mime_type not in self.allowed_mime_types:
# Логирование попытки загрузки файла с неразрешенным MIME-типом
logger.warning(f"Попытка загрузки файла с неразрешенным MIME-типом '{mime_type}'. "
f"Разрешенные MIME-типы: {', '.join(self.allowed_mime_types)}")
raise ValidationError(f"MIME-тип файла ({mime_type}) не разрешен. "
f"Разрешенные MIME-типы: {', '.join(self.allowed_mime_types)}")
except Exception as e:
# Возврат к исходной позиции в случае ошибки
file.seek(current_position)
logger.warning(f"Не удалось определить MIME-тип файла: {e}")
# Не прерываем валидацию, если не удалось определить MIME-тип
@staticmethod
def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str:
"""
Валидирует путь к локальному файлу для предотвращения атак обхода пути.
Args:
file_path: Путь к файлу.
allowed_directories: Список разрешенных директорий.
Returns:
Нормализованный и проверенный путь к файлу.
Raises:
ValidationError: Если путь к файлу небезопасен.
"""
# Нормализация пути
normalized_path = os.path.normpath(file_path)
# Если указаны разрешенные директории, проверяем, что путь находится в одной из них
if allowed_directories:
for allowed_dir in allowed_directories:
full_allowed_path = os.path.abspath(allowed_dir)
full_file_path = os.path.abspath(os.path.join(full_allowed_path, normalized_path))
if full_file_path.startswith(full_allowed_path):
return full_file_path
logger.warning(f"Попытка доступа к файлу вне разрешенных директорий: {file_path}")
raise ValidationError("Путь к файлу не находится в разрешенных директориях")
# Если разрешенные директории не указаны, просто возвращаем нормализованный путь
return normalized_path
View File
+49
View File
@@ -0,0 +1,49 @@
"""
Модуль 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()
@contextlib.contextmanager
def audio_file(file_path: str) -> Generator[BinaryIO, None, None]:
"""
Контекстный менеджер для работы с аудиофайлами.
Args:
file_path: Путь к аудиофайлу.
Yields:
Файловый объект в бинарном режиме.
"""
with open_file(file_path, 'rb') as f:
yield f
+48
View File
@@ -0,0 +1,48 @@
"""
Модуль 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
@@ -9,8 +9,11 @@ import datetime
import random
import string
from typing import Dict, Any, Optional
import logging
from .utils import logger
import logging
logger = logging.getLogger('app.history')
class HistoryLogger:
"""Класс для сохранения истории транскрибации."""
@@ -82,4 +85,4 @@ class HistoryLogger:
except Exception as e:
logger.error(f"Ошибка при сохранении истории транскрибации: {e}")
return None
return None
File diff suppressed because it is too large Load Diff
-146
View File
@@ -1,146 +0,0 @@
"""
Модуль transcriber_service.py содержит класс TranscriptionService,
который отвечает за обработку и транскрибацию аудиофайлов.
"""
import os
import uuid
import tempfile
import time
import librosa
from typing import Dict, Tuple
from .utils import logger
from .history_logger import HistoryLogger
from .audio_sources import AudioSource
class TranscriptionService:
"""Сервис для обработки и транскрибации аудиофайлов."""
def __init__(self, transcriber, config: Dict):
"""
Инициализация сервиса транскрибации.
Args:
transcriber: Экземпляр транскрайбера.
config: Словарь с конфигурацией.
"""
self.transcriber = transcriber
self.config = config
self.max_file_size_mb = self.config.get("max_file_size", 100) # Default 100MB
# Объект журналирования
self.history = HistoryLogger(config)
def get_audio_duration(self, file_path: str) -> float:
"""
Определяет длительность аудиофайла в секундах.
Args:
file_path: Путь к аудиофайлу.
Returns:
Длительность в секундах.
"""
try:
y, sr = librosa.load(file_path, sr=None)
duration = librosa.get_duration(y=y, sr=sr)
return duration
except Exception as e:
logger.error(f"Ошибка при определении длительности файла: {e}")
return 0.0
def transcribe_from_source(self, source: AudioSource, params: Dict = None) -> Tuple[Dict, int]:
"""
Транскрибирует аудиофайл из указанного источника.
Args:
source: Источник аудиофайла.
params: Дополнительные параметры для транскрибации.
Returns:
Кортеж (JSON-ответ, HTTP-код).
"""
# Получаем файл из источника
file, filename, error = source.get_audio_file()
# Обрабатываем ошибки получения файла
if error:
return {"error": error}, 400
if not file:
return {"error": "Failed to get audio file"}, 400
# Извлекаем параметры из запроса, если они есть
params = params or {}
language = params.get('language', self.config.get('language', 'en'))
temperature = float(params.get('temperature', 0.0))
prompt = params.get('prompt', '')
# Проверяем, запрошены ли временные метки
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')
# Временно изменяем настройку return_timestamps в транскрайбере
original_return_timestamps = self.transcriber.return_timestamps
self.transcriber.return_timestamps = return_timestamps
# Сохраняем файл во временный файл
temp_dir = tempfile.mkdtemp()
temp_file_path = os.path.join(temp_dir, str(uuid.uuid4()) + "_" + os.path.basename(filename))
file.save(temp_file_path)
# Определяем длительность аудиофайла
duration = self.get_audio_duration(temp_file_path)
# Для файлов из внешних источников (URL, base64), закрываем их и выполняем очистку
if hasattr(source, 'cleanup'):
file.file.close() # Закрываем файловый объект
source.cleanup() # Очищаем временные файлы источника
try:
start_time = time.time()
result = self.transcriber.process_file(temp_file_path)
processing_time = time.time() - start_time
# Формируем ответ в зависимости от 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"])
}
# Журналирование результата
self.history.save(response, filename)
return response, 200
except Exception as e:
logger.error(f"Ошибка при транскрибации: {e}")
return {"error": str(e)}, 500
finally:
# Восстанавливаем оригинальное значение return_timestamps
self.transcriber.return_timestamps = original_return_timestamps
# Очистка временных файлов
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
if os.path.exists(temp_dir):
os.rmdir(temp_dir)
-12
View File
@@ -1,12 +0,0 @@
import logging
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler("logs/transcribe.log")
]
)
logger = logging.getLogger("whisper-service")