Refactor project structure and add new features
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"n8n-mcp": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--init",
|
||||
"-e",
|
||||
"MCP_MODE=stdio",
|
||||
"-e",
|
||||
"LOG_LEVEL=error",
|
||||
"-e",
|
||||
"DISABLE_CONSOLE_OUTPUT=true",
|
||||
"-e",
|
||||
"N8N_API_URL=https://n8n.box.nnp.space",
|
||||
"-e",
|
||||
"N8N_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3NWRmOTUwZi05Njk3LTRjYzktOTg3Mi1kMTJiN2VlYjdiYmYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzYxMTU2NjkxLCJleHAiOjE3NjE3MTA0MDB9.nPZQ2jk88Vkm3yi4ojXNTi_hAsas_d2VuE7TvyCWjHY",
|
||||
"ghcr.io/czlonkowski/n8n-mcp@sha256:80613ac30c3fe39a7b9d343104603804edfbc34c1cdc27e95459b15b7c651dd2"
|
||||
]
|
||||
},
|
||||
"sequentialthinking": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"mcp/sequentialthinking"
|
||||
],
|
||||
"disabled": true,
|
||||
"alwaysAllow": []
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-45
@@ -1,76 +1,84 @@
|
||||
# Структура проекта Whisper API Service
|
||||
# Whisper API server project structure
|
||||
|
||||
Проект представляет собой локальный API-сервис для распознавания речи, построенный на основе модели Whisper. Сервис разработан как OpenAI-совместимый API, что позволяет использовать его в качестве локальной альтернативы облачным сервисам распознавания речи.
|
||||
The project is a local API service for speech recognition based on the Whisper model. The service is designed as an OpenAI-compatible API, allowing it to be used as a local alternative to cloud-based speech recognition services.
|
||||
|
||||
## Основные файлы
|
||||
## Main files
|
||||
|
||||
### Корневые файлы
|
||||
- **server.py** - точка входа в приложение, инициализирует и запускает сервис
|
||||
- **server.sh** - bash-скрипт для запуска сервера с опциональным обновлением conda-окружения
|
||||
- **config.json** - конфигурационный файл с настройками сервиса
|
||||
- **requirements.txt** - зависимости проекта для conda/pip
|
||||
### Root files
|
||||
- **server.py** - Application entry point, initializes and starts the service.
|
||||
- **server.sh** - Bash script to start the server with optional conda environment update.
|
||||
- **config.json** - Configuration file with service settings.
|
||||
- **requirements.txt** - Project dependencies for conda/pip.
|
||||
|
||||
### Модуль `app`
|
||||
### `app` module
|
||||
|
||||
#### app/\_\_init\_\_.py
|
||||
Содержит основной класс `WhisperServiceAPI`, который инициализирует приложение, загружает конфигурацию и запускает сервер на указанном порту.
|
||||
Contains the main class `WhisperServiceAPI`, which initializes the application, loads the configuration, and starts the server on the specified port using the production-ready Waitress server.
|
||||
|
||||
#### app/logger.py
|
||||
Настройка логирования для всех компонентов приложения.
|
||||
Configures logging for all application components.
|
||||
|
||||
#### app/transcriber.py
|
||||
Содержит класс `WhisperTranscriber`, который загружает модель Whisper и выполняет распознавание речи. Класс определяет оптимальное устройство для вычислений (CPU, CUDA, MPS) и поддерживает ускорение с помощью Flash Attention 2.
|
||||
Contains the `WhisperTranscriber` class, which loads the Whisper model and performs speech recognition. The class determines the optimal device for computations (CPU, CUDA, MPS) and supports acceleration with Flash Attention 2.
|
||||
|
||||
#### app/audio_processor.py
|
||||
Содержит класс `AudioProcessor` для предобработки аудиофайлов перед их транскрибацией. Включает методы для:
|
||||
- Конвертации в WAV с частотой 16 кГц
|
||||
- Нормализации уровня громкости
|
||||
- Добавления тишины в начало записи
|
||||
- Очистки временных файлов
|
||||
Contains the `AudioProcessor` class for preprocessing audio files before transcription. Includes methods for:
|
||||
- Converting to WAV with a 16 kHz sample rate.
|
||||
- Normalizing volume level (with configurable `norm_level` parameters).
|
||||
- Applying compression/expansion (with configurable `compand_params` parameters).
|
||||
- Speeding up audio playback for faster recognition (with configurable `audio_speed_factor` parameter).
|
||||
- Adding silence at the beginning of the recording.
|
||||
- Cleaning up temporary files.
|
||||
|
||||
#### app/audio_sources.py
|
||||
Содержит абстрактный класс `AudioSource` и его конкретные реализации для различных источников аудио:
|
||||
- `UploadedFileSource` - для файлов, загруженных через HTTP-запрос
|
||||
- `URLSource` - для файлов, доступных по URL
|
||||
- `Base64Source` - для аудио, закодированного в base64
|
||||
- `LocalFileSource` - для локальных файлов на сервере
|
||||
- `FakeFile` - вспомогательный класс для унификации обработки из разных источников
|
||||
Contains the abstract class `AudioSource` and its concrete implementations for various audio sources:
|
||||
- `UploadedFileSource` - for files uploaded via HTTP request.
|
||||
- `URLSource` - for files available via URL.
|
||||
- `Base64Source` - for audio encoded in base64.
|
||||
- `LocalFileSource` - for local files on the server.
|
||||
- `FakeFile` - a helper class for unifying processing from different sources.
|
||||
|
||||
#### app/history_logger.py
|
||||
Contains the `HistoryLogger` class for saving transcription history.
|
||||
|
||||
#### app/routes.py
|
||||
Содержит классы:
|
||||
- `TranscriptionService` - сервис для обработки и транскрибации аудиофайлов
|
||||
- `Routes` - регистрирует все эндпоинты API, включая OpenAI-совместимые маршруты
|
||||
Contains the classes:
|
||||
- `TranscriptionService` - a service for processing and transcribing audio files, including methods for getting audio duration and transcribing from various sources.
|
||||
- `Routes` - registers all API endpoints, including OpenAI-compatible routes and an endpoint for retrieving service configuration.
|
||||
|
||||
## Основные классы и их описание
|
||||
## Main classes
|
||||
|
||||
### WhisperServiceAPI
|
||||
Основной класс приложения, инициализирует сервис, загружает конфигурацию и запускает сервер с использованием Waitress.
|
||||
The main application class, initializes the service, loads the configuration, and starts the server using Waitress.
|
||||
|
||||
### WhisperTranscriber
|
||||
Класс для распознавания речи с использованием модели Whisper. Определяет оптимальное устройство для вычислений, загружает модель с учетом доступного оборудования и выполняет транскрибацию аудиофайлов.
|
||||
A class for speech recognition using the Whisper model. Determines the optimal device for computations, loads the model considering available hardware, and performs transcription of audio files.
|
||||
|
||||
### AudioProcessor
|
||||
Класс для предобработки аудиофайлов. Выполняет конвертацию, нормализацию и добавление тишины в начало записи для улучшения качества распознавания.
|
||||
A class for preprocessing audio files. Performs conversion, normalization, and adds silence at the beginning of the recording to improve recognition quality, using configurable parameters.
|
||||
|
||||
### AudioSource (и наследники)
|
||||
Абстрактный класс и его реализации для работы с различными источниками аудиофайлов. Обеспечивает унифицированный интерфейс для получения аудиофайлов из разных источников.
|
||||
### AudioSource (and subclasses)
|
||||
An abstract class and its implementations for working with various audio file sources. Provides a unified interface for obtaining audio files from different sources.
|
||||
|
||||
### HistoryLogger
|
||||
A class for saving transcription history.
|
||||
|
||||
### TranscriptionService
|
||||
Сервис, объединяющий логику обработки запросов и транскрибации аудио. Принимает источник аудио, обрабатывает его и возвращает результат транскрибации.
|
||||
A service that combines the logic for processing requests and transcribing audio. Accepts an audio source, processes it, and returns the transcription result.
|
||||
|
||||
### Routes
|
||||
Класс, регистрирующий все маршруты API сервиса, включая совместимые с OpenAI эндпоинты для интеграции с существующими клиентами.
|
||||
A class that registers all API routes of the service, including OpenAI-compatible endpoints for integration with existing systems, as well as an endpoint for retrieving the current configuration.
|
||||
|
||||
## API Endpoints
|
||||
## API endpoints
|
||||
|
||||
Сервис предоставляет несколько эндпоинтов, включая:
|
||||
- `/health` - проверка статуса сервиса
|
||||
- `/config` - получение текущей конфигурации
|
||||
- `/local/transcriptions` - транскрибация локального файла на сервере
|
||||
- `/v1/models` - получение списка доступных моделей (OpenAI-совместимый)
|
||||
- `/v1/audio/transcriptions` - транскрибация загруженного файла (OpenAI-совместимый)
|
||||
- `/v1/audio/transcriptions/url` - транскрибация по URL
|
||||
- `/v1/audio/transcriptions/base64` - транскрибация из base64
|
||||
- `/v1/audio/transcriptions/multipart` - транскрибация файла из multipart-формы
|
||||
The service provides several endpoints, including:
|
||||
- `/health` - Service status check.
|
||||
- `/config` - Get current configuration.
|
||||
- `/local/transcriptions` - Transcribe a local file on the server.
|
||||
- `/v1/models` - Get a list of available models (OpenAI-compatible).
|
||||
- `/v1/audio/transcriptions` - Transcribe an uploaded file (OpenAI-compatible).
|
||||
- `/v1/audio/transcriptions/url` - Transcribe from a URL.
|
||||
- `/v1/audio/transcriptions/base64` - Transcribe from base64.
|
||||
- `/v1/audio/transcriptions/multipart` - Transcribe a file from a multipart form.
|
||||
|
||||
Сервис разработан таким образом, чтобы обеспечить максимальную гибкость в использовании и интеграцию с существующими системами, поддерживающими API OpenAI Whisper.
|
||||
The service is designed to provide maximum flexibility in use and integration with existing systems that support the OpenAI Whisper API.
|
||||
@@ -1,31 +1,37 @@
|
||||
# Whisper API Service
|
||||
# Whisper API server
|
||||
|
||||
A local, OpenAI-compatible speech recognition API service using the Whisper model. This service provides a straightforward way to transcribe audio files in various formats with high accuracy and is designed to be compatible with the OpenAI Whisper API.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- 🔊 High-quality speech recognition using Whisper model
|
||||
- 🔊 High-quality speech recognition using Whisper models
|
||||
- 🌐 OpenAI-compatible API endpoints
|
||||
- 🚀 Hardware acceleration support (CUDA, MPS)
|
||||
- ⚡ Flash Attention 2 for faster transcription on compatible GPUs
|
||||
- 🎛️ Audio preprocessing for better transcription results
|
||||
- 🔄 Multiple input formats (file upload, URL, base64, local files)
|
||||
- 🚪 Easy deployment with Docker or conda environment
|
||||
- 🔄 Multiple input methods (file upload, URL, base64, local files)
|
||||
- 📊 Optional timestamp generation for word-level alignment
|
||||
- 🎧 Convenient built-in client with text editing and audio playback capabilities
|
||||
- 📱 Responsive web interface included
|
||||
- 📝 Transcription history logging
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+ (3.11 recommended)
|
||||
- Python 3.12+ recommended
|
||||
- CUDA-compatible GPU (optional, for faster processing)
|
||||
- FFmpeg and SoX for audio processing
|
||||
- Whisper model (download from Hugging Face)
|
||||
|
||||
## Installation
|
||||
|
||||
### Using conda (recommended)
|
||||
### Using server.sh (recommended)
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/yourusername/whisper-api-service.git
|
||||
cd whisper-api-service
|
||||
git clone https://github.com/kreolsky/whisper-api-server.git
|
||||
cd whisper-api-server
|
||||
```
|
||||
|
||||
2. Run the server script with the update flag to create and set up the conda environment:
|
||||
@@ -35,16 +41,16 @@ chmod +x server.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create a conda environment named "transcribe" with Python 3.11
|
||||
- Create a conda environment named "whisper-api" with Python 3.12
|
||||
- Install all required dependencies
|
||||
- Start the service
|
||||
|
||||
### Manual Installation
|
||||
### Manual installation
|
||||
|
||||
1. Create and activate a conda environment:
|
||||
```bash
|
||||
conda create -n transcribe python=3.11
|
||||
conda activate transcribe
|
||||
conda create -n whisper-api python=3.12
|
||||
conda activate whisper-api
|
||||
```
|
||||
|
||||
2. Install the required dependencies:
|
||||
@@ -64,47 +70,75 @@ The service is configured through the `config.json` file:
|
||||
```json
|
||||
{
|
||||
"service_port": 5042,
|
||||
"model_path": "/mnt/cloud/llm/whisper/whisper-large-v3-russian",
|
||||
"model_path": "/path/to/whisper/model",
|
||||
"language": "russian",
|
||||
"chunk_length_s": 30,
|
||||
"batch_size": 16,
|
||||
"max_new_tokens": 256,
|
||||
"enable_history": true,
|
||||
"chunk_length_s": 28,
|
||||
"batch_size": 8,
|
||||
"max_new_tokens": 384,
|
||||
"temperature": 0.01,
|
||||
"return_timestamps": false,
|
||||
"norm_level": "-0.5",
|
||||
"compand_params": "0.3,1 -90,-90,-70,-70,-60,-20,0,0 -5 0 0.2"
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Parameters
|
||||
### Configuration parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `service_port` | Port on which the service will run |
|
||||
| `model_path` | Path to the Whisper model directory |
|
||||
| `language` | Language for transcription (e.g., "russian", "english") |
|
||||
| `enable_history` | Whether to save transcription history (true/false) |
|
||||
| `chunk_length_s` | Length of audio chunks for processing (in seconds) |
|
||||
| `batch_size` | Batch size for processing |
|
||||
| `max_new_tokens` | Maximum new tokens for the model output |
|
||||
| `temperature` | Model temperature parameter (lower = more deterministic) |
|
||||
| `return_timestamps` | Whether to return timestamps in the transcription |
|
||||
| `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) |
|
||||
|
||||
## API Usage
|
||||
## Web interface
|
||||
|
||||
### Health Check
|
||||
The service includes a user-friendly web interface accessible at:
|
||||
```
|
||||
http://localhost:5042/
|
||||
```
|
||||
|
||||
The interface allows you to:
|
||||
- Upload audio files via drag-and-drop or file picker
|
||||
- Upload multiple files for sequential processing
|
||||
- Listen to the uploaded audio
|
||||
- Edit the transcription text if needed
|
||||
- Download results as TXT or JSON or copy results to clipboard
|
||||
- View API request/response details for debugging
|
||||
|
||||
## API usage
|
||||
|
||||
### Health check
|
||||
|
||||
```bash
|
||||
curl http://localhost:5042/health
|
||||
```
|
||||
|
||||
### Get Configuration
|
||||
### Get configuration
|
||||
|
||||
```bash
|
||||
curl http://localhost:5042/config
|
||||
```
|
||||
|
||||
### Transcribe an Audio File (OpenAI-compatible)
|
||||
### Get available models
|
||||
|
||||
```bash
|
||||
curl http://localhost:5042/v1/models
|
||||
```
|
||||
|
||||
### Transcribe an audio file (OpenAI-compatible)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5042/v1/audio/transcriptions \
|
||||
@@ -119,7 +153,7 @@ curl -X POST http://localhost:5042/v1/audio/transcriptions/url \
|
||||
-d '{"url":"https://example.com/audio.mp3"}'
|
||||
```
|
||||
|
||||
### Transcribe from Base64
|
||||
### Transcribe from base64
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5042/v1/audio/transcriptions/base64 \
|
||||
@@ -127,7 +161,7 @@ curl -X POST http://localhost:5042/v1/audio/transcriptions/base64 \
|
||||
-d '{"file":"base64_encoded_audio_data"}'
|
||||
```
|
||||
|
||||
### Transcribe a Local File on the Server
|
||||
### Transcribe a local file on the server
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5042/local/transcriptions \
|
||||
@@ -135,25 +169,75 @@ curl -X POST http://localhost:5042/local/transcriptions \
|
||||
-d '{"file_path":"/path/to/audio.mp3"}'
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
### Request with additional parameters
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5042/v1/audio/transcriptions \
|
||||
-F file=@audio.mp3 \
|
||||
-F language=english \
|
||||
-F return_timestamps=true \
|
||||
-F temperature=0.0
|
||||
```
|
||||
|
||||
## Response format
|
||||
|
||||
### Without timestamps
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Transcribed text content",
|
||||
"processing_time": 2.34,
|
||||
"response_size_bytes": 1234,
|
||||
"duration_seconds": 10.5,
|
||||
"model": "whisper-large-v3"
|
||||
}
|
||||
```
|
||||
|
||||
### With timestamps
|
||||
|
||||
```json
|
||||
{
|
||||
"segments": [
|
||||
{
|
||||
"start_time_ms": 0,
|
||||
"end_time_ms": 5000,
|
||||
"text": "First segment of text"
|
||||
},
|
||||
{
|
||||
"start_time_ms": 5000,
|
||||
"end_time_ms": 10000,
|
||||
"text": "Second segment of text"
|
||||
}
|
||||
],
|
||||
"text": "First segment of text Second segment of text",
|
||||
"processing_time": 3.45,
|
||||
"response_size_bytes": 2345,
|
||||
"duration_seconds": 10.5,
|
||||
"model": "whisper-large-v3"
|
||||
}
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
The project consists of the following components:
|
||||
|
||||
- `server.py`: Entry point that initializes and starts the service
|
||||
- `server.sh`: Bash script for launching the server with optional conda environment update
|
||||
- `config.json`: Service configuration file
|
||||
- `requirements.txt`: Project dependencies for conda/pip
|
||||
- `app/`: Main application module
|
||||
- `__init__.py`: Contains the `WhisperServiceAPI` class for service initialization
|
||||
- `logger.py`: Logging configuration
|
||||
- `utils.py`: Logging configuration
|
||||
- `transcriber.py`: Contains the `WhisperTranscriber` class for speech recognition
|
||||
- `audio_processor.py`: Contains the `AudioProcessor` class for audio preprocessing
|
||||
- `audio_sources.py`: Contains the `AudioSource` abstract class and implementations
|
||||
- `audio_sources.py`: Contains different audio source handlers (upload, URL, base64, local)
|
||||
- `transcriber_service.py`: Manages the transcription workflow
|
||||
- `history_logger.py`: Handles saving transcription history
|
||||
- `routes.py`: Contains the API route definitions
|
||||
- `static/`: Web interface files
|
||||
|
||||
## Advanced Usage
|
||||
## Advanced usage
|
||||
|
||||
### Using with Different Models
|
||||
### Using with different models
|
||||
|
||||
You can use any Whisper model by changing the `model_path` in the configuration:
|
||||
|
||||
@@ -161,11 +245,7 @@ You can use any Whisper model by changing the `model_path` in the configuration:
|
||||
2. Update the `model_path` in `config.json`
|
||||
3. Restart the service
|
||||
|
||||
#### Recommended Models
|
||||
|
||||
For Russian language transcription, we recommend using the [**whisper-large-v3-russian**](https://huggingface.co/antony66/whisper-large-v3-russian) model from Hugging Face. This model is fine-tuned specifically for Russian speech recognition and delivers high accuracy. For faster transcription with slightly lower accuracy, consider the [**whisper-large-v3-turbo-russian**](https://huggingface.co/dvislobokov/whisper-large-v3-turbo-russian) model, which is optimized for speed.
|
||||
|
||||
### Hardware Acceleration
|
||||
### Hardware acceleration
|
||||
|
||||
The service automatically selects the best available compute device:
|
||||
- CUDA GPU (index 1 if available, otherwise index 0)
|
||||
@@ -174,23 +254,28 @@ The service automatically selects the best available compute device:
|
||||
|
||||
For best performance on NVIDIA GPUs, Flash Attention 2 is used when available.
|
||||
|
||||
### Transcription history
|
||||
|
||||
When `enable_history` is set to `true`, transcription results are saved in a `history` folder organized by date. Each transcription is saved as a JSON file with the format:
|
||||
```
|
||||
history/
|
||||
└── YYYY-MM-DD/
|
||||
└── timestamp_filename_xxxx.json
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Audio Processing Issues
|
||||
### Audio processing issues
|
||||
|
||||
If you encounter audio processing errors:
|
||||
- Ensure that FFmpeg and SoX are installed on your system
|
||||
- Check that the audio file is not corrupted
|
||||
- Try different audio preprocessing parameters in the configuration
|
||||
|
||||
### Performance Issues
|
||||
### Performance issues
|
||||
|
||||
For slow transcription:
|
||||
- Use a GPU if available
|
||||
- Adjust `chunk_length_s` and `batch_size` parameters
|
||||
- Consider using a smaller Whisper model
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- OpenAI for the Whisper model
|
||||
- Hugging Face for model distribution and transformers library
|
||||
- Reduce `audio_rate` if full quality isn't needed
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Реорганизация проекта Whisper API Server
|
||||
|
||||
## Обзор
|
||||
|
||||
Этот документ описывает реорганизацию проекта Whisper API Server с плоской структуры на архитектуру, основанную на доменах. Цель реорганизации - улучшить поддерживаемость, читаемость и масштабируемость кода.
|
||||
|
||||
## Старая структура
|
||||
|
||||
```
|
||||
app/
|
||||
├── __init__.py
|
||||
├── async_tasks.py
|
||||
├── audio_processor.py
|
||||
├── audio_sources.py
|
||||
├── audio_utils.py
|
||||
├── cache.py
|
||||
├── context_managers.py
|
||||
├── file_manager.py
|
||||
├── history_logger.py
|
||||
├── logging_config.py
|
||||
├── request_logger.py
|
||||
├── routes.py
|
||||
├── transcriber_service.py
|
||||
├── transcriber.py
|
||||
├── utils.py
|
||||
├── validators.py
|
||||
└── static/
|
||||
└── index.html
|
||||
```
|
||||
|
||||
## Новая структура
|
||||
|
||||
```
|
||||
app/
|
||||
├── __init__.py # Главный модуль приложения
|
||||
├── api/ # Слой API
|
||||
│ ├── __init__.py
|
||||
│ ├── routes.py # Определения маршрутов API
|
||||
│ └── middleware.py # Middleware для запросов/ответов
|
||||
├── core/ # Основная бизнес-логика
|
||||
│ ├── __init__.py
|
||||
│ ├── transcriber.py # Основной сервис транскрибации
|
||||
│ ├── transcription_service.py
|
||||
│ └── config.py # Управление конфигурацией
|
||||
├── audio/ # Домен обработки аудио
|
||||
│ ├── __init__.py
|
||||
│ ├── processor.py # Предобработка аудио
|
||||
│ ├── sources.py # Обработчики источников аудио
|
||||
│ └── utils.py # Утилиты для работы с аудио
|
||||
├── infrastructure/ # Инфраструктурные сервисы
|
||||
│ ├── __init__.py
|
||||
│ ├── logging/ # Конфигурация логирования
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config.py
|
||||
│ │ └── request_logger.py
|
||||
│ ├── storage/ # Управление файлами и кэшем
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── file_manager.py
|
||||
│ │ └── cache.py
|
||||
│ ├── async_tasks/ # Управление фоновыми задачами
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── manager.py
|
||||
│ └── validation/ # Валидация входных данных
|
||||
│ ├── __init__.py
|
||||
│ └── validators.py
|
||||
├── shared/ # Общие утилиты
|
||||
│ ├── __init__.py
|
||||
│ ├── context_managers.py # Переиспользуемые контекстные менеджеры
|
||||
│ ├── decorators.py # Общие декораторы
|
||||
│ └── history_logger.py # Функциональность журналирования истории
|
||||
└── static/ # Статические веб-ресурсы
|
||||
└── index.html
|
||||
```
|
||||
|
||||
## Преимущества новой структуры
|
||||
|
||||
1. **Четкое разделение ответственности**: Каждый модуль имеет одну, хорошо определенную зону ответственности
|
||||
2. **Лучшая поддерживаемость**: Поиск и изменение кода становится более интуитивным
|
||||
3. **Улучшенная тестируемость**: Тесты могут быть организованы по доменам
|
||||
4. **Масштабируемость**: Новые функции могут быть добавлены в соответствующие домены
|
||||
5. **Снижение связанности**: Зависимости между модулями становятся более явными
|
||||
6. **Улучшенная читаемость**: Организация кода отражает ментальную модель системы
|
||||
|
||||
## Карта миграции файлов
|
||||
|
||||
| Старый файл | Новое расположение |
|
||||
|--------------|--------------|
|
||||
| `routes.py` | `api/routes.py` |
|
||||
| `request_logger.py` | `infrastructure/logging/request_logger.py` |
|
||||
| `transcriber.py` | `core/transcriber.py` |
|
||||
| `transcriber_service.py` | `core/transcription_service.py` |
|
||||
| `audio_processor.py` | `audio/processor.py` |
|
||||
| `audio_sources.py` | `audio/sources.py` |
|
||||
| `audio_utils.py` | `audio/utils.py` |
|
||||
| `file_manager.py` | `infrastructure/storage/file_manager.py` |
|
||||
| `cache.py` | `infrastructure/storage/cache.py` |
|
||||
| `async_tasks.py` | `infrastructure/async_tasks/manager.py` |
|
||||
| `validators.py` | `infrastructure/validation/validators.py` |
|
||||
| `logging_config.py` | `infrastructure/logging/config.py` |
|
||||
| `context_managers.py` | `shared/context_managers.py` |
|
||||
| `utils.py` | `shared/decorators.py` |
|
||||
| `history_logger.py` | `shared/history_logger.py` |
|
||||
|
||||
## Процесс миграции
|
||||
|
||||
1. **Создание новой структуры**: Созданы все новые директории с файлами `__init__.py`
|
||||
2. **Миграция кода**: Все файлы перемещены в новые расположения с обновленными импортами
|
||||
3. **Документация**: Весь код задокументирован на русском языке
|
||||
4. **Тестирование**: Убедитесь, что все функции работают корректно
|
||||
|
||||
## Использование скрипта миграции
|
||||
|
||||
Для помощи в процессе миграции предоставлен скрипт `migrate_to_new_structure.py`:
|
||||
|
||||
```bash
|
||||
# Запуск миграции
|
||||
python migrate_to_new_structure.py
|
||||
|
||||
# Запуск миграции с последующим удалением старых файлов
|
||||
python migrate_to_new_structure.py --cleanup
|
||||
```
|
||||
|
||||
## Обновление импортов
|
||||
|
||||
При переходе на новую структуру необходимо обновить импорты в вашем коде:
|
||||
|
||||
### Примеры обновления импортов
|
||||
|
||||
```python
|
||||
# Старые импорты
|
||||
from .transcriber import WhisperTranscriber
|
||||
from .routes import Routes
|
||||
from .validators import FileValidator
|
||||
|
||||
# Новые импорты
|
||||
from .core.transcriber import WhisperTranscriber
|
||||
from .api.routes import Routes
|
||||
from .infrastructure.validation.validators import FileValidator
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
После миграции убедитесь, что:
|
||||
|
||||
1. Приложение запускается без ошибок
|
||||
2. Все эндпоинты API работают корректно
|
||||
3. Функции транскрибации работают как ожидалось
|
||||
4. Логирование работает правильно
|
||||
|
||||
## Возврат к старой структуре
|
||||
|
||||
Если возникнут проблемы с новой структурой, вы можете вернуться к старой:
|
||||
|
||||
1. Восстановите файлы из директории `backup_old_structure/`
|
||||
2. Удалите новые директории
|
||||
3. Проверьте, что все работает корректно
|
||||
|
||||
## Заключение
|
||||
|
||||
Новая структура проекта обеспечивает лучшую организацию кода и упрощает его поддержку. Все функции сохранены, а код стал более читаемым и масштабируемым.
|
||||
+77
-41
@@ -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.config = load_config(config_path)
|
||||
|
||||
# Порт для сервиса
|
||||
self.port = self.config["service_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)
|
||||
|
||||
# Создание Flask-приложения
|
||||
self.app = Flask("whisper-service")
|
||||
# Настройка логирования запросов
|
||||
request_logger_config = self.config.get('request_logger', {})
|
||||
request_logger = RequestLogger(self.app, request_logger_config)
|
||||
|
||||
# Регистрация маршрутов
|
||||
Routes(self.app, self.transcriber, self.config)
|
||||
routes = Routes(self.app, self.transcriber, self.config, self.file_validator)
|
||||
|
||||
logger.info(f"API сервис инициализирован, порт: {self.port}")
|
||||
# Регистрация обработчиков очистки
|
||||
self._register_cleanup_handlers()
|
||||
|
||||
def _load_config(self, config_path: str) -> Dict:
|
||||
self.logger.info("WhisperServiceAPI успешно инициализирован")
|
||||
|
||||
def _register_cleanup_handlers(self) -> None:
|
||||
"""
|
||||
Загрузка конфигурации из JSON-файла.
|
||||
Регистрация обработчиков для очистки ресурсов при завершении приложения.
|
||||
"""
|
||||
@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:
|
||||
config_path: Путь к файлу конфигурации.
|
||||
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:
|
||||
Словарь с параметрами конфигурации.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если файл конфигурации не найден.
|
||||
json.JSONDecodeError: Если файл конфигурации содержит некорректный JSON.
|
||||
Настроенное Flask приложение.
|
||||
"""
|
||||
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}")
|
||||
|
||||
# Использовать waitress для production-ready сервера
|
||||
waitress.serve(self.app, host='0.0.0.0', port=self.port)
|
||||
return self.app
|
||||
@@ -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
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
Модуль routes.py содержит классы для регистрации маршрутов API
|
||||
для сервиса распознавания речи.
|
||||
"""
|
||||
|
||||
import os
|
||||
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
|
||||
|
||||
logger = logging.getLogger('app.routes')
|
||||
|
||||
|
||||
class Routes:
|
||||
"""
|
||||
Класс для регистрации всех эндпоинтов API.
|
||||
|
||||
Attributes:
|
||||
app (Flask): Flask-приложение.
|
||||
config (Dict): Словарь с конфигурацией.
|
||||
transcription_service (TranscriptionService): Сервис транскрибации.
|
||||
file_validator (FileValidator): Валидатор файлов.
|
||||
"""
|
||||
|
||||
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._register_routes()
|
||||
|
||||
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():
|
||||
"""Эндпоинт для проверки статуса сервиса."""
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"version": self.config.get("version", "1.0.0")
|
||||
}), 200
|
||||
|
||||
@self.app.route('/config', methods=['GET'])
|
||||
def get_config():
|
||||
"""Эндпоинт для получения конфигурации сервиса."""
|
||||
return jsonify(self.config), 200
|
||||
|
||||
@self.app.route('/local/transcriptions', methods=['POST'])
|
||||
def local_transcribe():
|
||||
"""Эндпоинт для локальной транскрибации файла по пути на сервере."""
|
||||
data = request.json
|
||||
|
||||
if not data or "file_path" not in data:
|
||||
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,
|
||||
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
|
||||
|
||||
@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": []
|
||||
}
|
||||
],
|
||||
"object": "list"
|
||||
}), 200
|
||||
|
||||
@self.app.route('/v1/models/<model_id>', methods=['GET'])
|
||||
def retrieve_model(model_id):
|
||||
"""Эндпоинт для получения информации о конкретной модели."""
|
||||
if model_id == os.path.basename(self.config["model_path"]):
|
||||
return jsonify({
|
||||
"id": model_id,
|
||||
"object": "model",
|
||||
"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
|
||||
|
||||
@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
|
||||
|
||||
if not data or "url" not in data:
|
||||
return jsonify({
|
||||
"error": "No URL provided",
|
||||
"details": "Please provide 'url' in the JSON request"
|
||||
}), 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)
|
||||
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
|
||||
|
||||
if not data or "file" not in data:
|
||||
return jsonify({
|
||||
"error": "No base64 file provided",
|
||||
"details": "Please provide 'file' in the JSON request"
|
||||
}), 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)
|
||||
return jsonify(response), status_code
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions/multipart', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def transcribe_multipart():
|
||||
"""Эндпоинт для транскрибации аудиофайла, загруженного через форму."""
|
||||
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)
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
Модуль processor.py содержит класс AudioProcessor, предназначенный для предобработки аудиофайлов
|
||||
перед их использованием в системах распознавания речи. Класс предоставляет методы для конвертации
|
||||
аудио в формат WAV с частотой дискретизации 16 кГц, нормализации уровня громкости,
|
||||
добавления тишины в начало записи, а также для удаления временных файлов, созданных в процессе обработки.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
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')
|
||||
|
||||
|
||||
class AudioProcessor:
|
||||
"""
|
||||
Класс для предобработки аудиофайлов перед распознаванием.
|
||||
|
||||
Attributes:
|
||||
config (Dict): Словарь с параметрами конфигурации.
|
||||
norm_level (str): Уровень нормализации аудио.
|
||||
compand_params (str): Параметры компрессора аудио.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""
|
||||
Инициализация обработчика аудио.
|
||||
|
||||
Args:
|
||||
config: Словарь с параметрами конфигурации.
|
||||
"""
|
||||
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 кГц.
|
||||
|
||||
Args:
|
||||
input_path: Путь к исходному аудиофайлу.
|
||||
|
||||
Returns:
|
||||
Путь к сконвертированному WAV-файлу.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при конвертации.
|
||||
"""
|
||||
audio_rate = self.config["audio_rate"]
|
||||
|
||||
# Проверка расширения файла
|
||||
if input_path.lower().endswith('.wav'):
|
||||
# Проверяем, нужно ли преобразовывать WAV-файл (например, если частота не 16 кГц)
|
||||
try:
|
||||
info = subprocess.check_output(['soxi', input_path]).decode()
|
||||
if f'{audio_rate} Hz' in info:
|
||||
logger.info(f"Файл {input_path} уже в формате WAV с частотой {audio_rate} Гц")
|
||||
return input_path
|
||||
except subprocess.CalledProcessError:
|
||||
logger.warning(f"Не удалось получить информацию о WAV-файле {input_path}")
|
||||
# Продолжаем конвертацию, чтобы быть уверенными в формате
|
||||
|
||||
# Создаем временный файл для WAV
|
||||
output_path, _ = temp_file_manager.create_temp_file(".wav")
|
||||
|
||||
# Команда для конвертации
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel", "warning",
|
||||
"-i", input_path,
|
||||
"-ar", f"{audio_rate}",
|
||||
"-ac", "1", # Монофонический звук
|
||||
output_path
|
||||
]
|
||||
|
||||
logger.info(f"Конвертация в WAV: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
logger.info(f"Файл конвертирован в WAV: {output_path}")
|
||||
return output_path
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при конвертации в WAV: {e.stderr.decode()}")
|
||||
raise
|
||||
|
||||
def normalize_audio(self, input_path: str) -> str:
|
||||
"""
|
||||
Нормализация аудиофайла с использованием sox.
|
||||
|
||||
Args:
|
||||
input_path: Путь к WAV-файлу.
|
||||
|
||||
Returns:
|
||||
Путь к нормализованному WAV-файлу.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при нормализации.
|
||||
"""
|
||||
# Создаем временный файл для нормализованного аудио
|
||||
output_path, _ = temp_file_manager.create_temp_file("_normalized.wav")
|
||||
|
||||
# Команда для нормализации аудио с помощью sox
|
||||
cmd = [
|
||||
"sox",
|
||||
input_path,
|
||||
output_path,
|
||||
"norm", self.norm_level,
|
||||
"compand"
|
||||
] + self.compand_params.split()
|
||||
|
||||
logger.info(f"Нормализация аудио: {' '.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 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:
|
||||
"""
|
||||
Добавляет тишину в начало аудиофайла.
|
||||
|
||||
Args:
|
||||
input_path: Путь к аудиофайлу.
|
||||
|
||||
Returns:
|
||||
Путь к аудиофайлу с добавленной тишиной.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при добавлении тишины.
|
||||
"""
|
||||
# Создаем временный файл
|
||||
output_path, _ = temp_file_manager.create_temp_file("_silence.wav")
|
||||
|
||||
# Команда для добавления тишины в начало файла
|
||||
cmd = [
|
||||
"sox",
|
||||
input_path,
|
||||
output_path,
|
||||
"pad", "2.0", "1.0" # Добавление тишины в начале и в конце (секунды)
|
||||
]
|
||||
|
||||
logger.info(f"Добавление тишины: {' '.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 process_audio(self, input_path: str) -> Tuple[str, list]:
|
||||
"""
|
||||
Полная обработка аудиофайла: конвертация, нормализация и добавление тишины.
|
||||
|
||||
Args:
|
||||
input_path: Путь к исходному аудиофайлу.
|
||||
|
||||
Returns:
|
||||
Кортеж: (путь к обработанному файлу, список временных файлов для удаления)
|
||||
|
||||
Raises:
|
||||
Exception: Если произошла ошибка при обработке аудио.
|
||||
"""
|
||||
temp_files = []
|
||||
|
||||
try:
|
||||
# Конвертация в WAV
|
||||
wav_path = self.convert_to_wav(input_path)
|
||||
if wav_path != input_path: # Если был создан временный файл
|
||||
temp_files.append(wav_path)
|
||||
|
||||
# Нормализация
|
||||
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)
|
||||
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)
|
||||
raise
|
||||
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Модуль sources.py содержит абстрактный класс AudioSource и его конкретные реализации
|
||||
для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы).
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
import base64
|
||||
import requests
|
||||
import abc
|
||||
from typing import Dict, Tuple, Optional, BinaryIO
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.audio_sources')
|
||||
|
||||
class AudioSource(abc.ABC):
|
||||
"""Абстрактный класс для различных источников аудиофайлов.
|
||||
|
||||
Определяет интерфейс для различных источников аудио и предоставляет общие
|
||||
методы для работы с аудиофайлами, такие как проверка размера файла.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class FakeFile:
|
||||
"""Имитирует файловый объект для унификации обработки из разных источников.
|
||||
|
||||
Позволяет обрабатывать файлы из различных источников (локальный путь, URL, base64)
|
||||
как стандартные файловые объекты Flask, обеспечивая совместимость с существующей
|
||||
логикой обработки файлов.
|
||||
"""
|
||||
|
||||
def __init__(self, file: BinaryIO, filename: str):
|
||||
"""
|
||||
Инициализация объекта FakeFile.
|
||||
|
||||
Args:
|
||||
file: Исходный файловый объект или поток.
|
||||
filename: Имя файла для метаданных.
|
||||
"""
|
||||
self.file = file
|
||||
self.filename = filename
|
||||
|
||||
def read(self):
|
||||
"""Чтение содержимого файла."""
|
||||
return self.file.read()
|
||||
|
||||
def seek(self, offset: int, whence: int = 0):
|
||||
"""Перемещение позиции чтения."""
|
||||
self.file.seek(offset, whence)
|
||||
|
||||
def tell(self):
|
||||
"""Получение текущей позиции чтения."""
|
||||
return self.file.tell()
|
||||
|
||||
def save(self, destination: str):
|
||||
"""
|
||||
Сохраняет содержимое файла в указанное место назначения.
|
||||
|
||||
Args:
|
||||
destination: Путь для сохранения файла.
|
||||
"""
|
||||
with open(destination, 'wb') as f:
|
||||
content = self.file.read()
|
||||
f.write(content)
|
||||
self.file.seek(0) # Сброс указателя после чтения
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Возвращает имя файла."""
|
||||
return self.filename
|
||||
|
||||
|
||||
class UploadedFileSource(AudioSource):
|
||||
"""Источник аудио для файлов, загруженных через HTTP-запрос."""
|
||||
|
||||
def __init__(self, request_files, max_file_size_mb: int = 100):
|
||||
"""
|
||||
Инициализация источника для загруженных файлов.
|
||||
|
||||
Args:
|
||||
request_files: Объект request.files из Flask.
|
||||
max_file_size_mb: Максимальный размер файла в МБ.
|
||||
"""
|
||||
super().__init__(max_file_size_mb)
|
||||
self.request_files = request_files
|
||||
|
||||
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Получает аудиофайл из загруженных файлов.
|
||||
|
||||
Returns:
|
||||
Кортеж (файловый объект, имя файла, сообщение об ошибке).
|
||||
"""
|
||||
if 'file' not in self.request_files:
|
||||
return None, None, "No file part"
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)}"
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Модуль transcriber.py содержит класс WhisperTranscriber, который использует модель Whisper от
|
||||
OpenAI для транскрибации аудиофайлов в текст. Класс включает в себя методы для загрузки модели,
|
||||
обработки аудио (с использованием класса AudioProcessor), и выполнения транскрибации.
|
||||
Обрабатывает выбор устройства (CPU, CUDA, MPS) для выполнения вычислений и обеспечивает
|
||||
возможность использования Flash Attention 2 для ускорения работы модели на поддерживаемых GPU.
|
||||
"""
|
||||
|
||||
import time
|
||||
import traceback
|
||||
from typing import Dict, Tuple, Union
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from transformers import (
|
||||
WhisperForConditionalGeneration,
|
||||
WhisperProcessor,
|
||||
pipeline,
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
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):
|
||||
"""
|
||||
Инициализация транскрайбера.
|
||||
|
||||
Args:
|
||||
config: Словарь с параметрами конфигурации.
|
||||
"""
|
||||
self.config = config
|
||||
self.model_path = config["model_path"]
|
||||
self.language = config["language"]
|
||||
self.chunk_length_s = config["chunk_length_s"]
|
||||
self.batch_size = config["batch_size"]
|
||||
self.max_new_tokens = config["max_new_tokens"]
|
||||
self.return_timestamps = config["return_timestamps"]
|
||||
self.temperature = config["temperature"]
|
||||
|
||||
# Оптимальный тип для тензоров
|
||||
self.torch_dtype = torch.bfloat16
|
||||
|
||||
# Создаем объект для обработки аудио
|
||||
self.audio_processor = AudioProcessor(config)
|
||||
|
||||
# Определяем устройство для вычислений
|
||||
self.device = self._get_device()
|
||||
|
||||
# Загружаем модель при инициализации
|
||||
self._load_model()
|
||||
|
||||
def _get_device(self) -> torch.device:
|
||||
"""
|
||||
Определение доступного устройства для вычислений.
|
||||
|
||||
Returns:
|
||||
Объект устройства PyTorch.
|
||||
"""
|
||||
if torch.cuda.is_available():
|
||||
# Проверяем, доступна ли GPU с индексом 1
|
||||
if torch.cuda.device_count() > 1:
|
||||
logger.info("Используется CUDA GPU с индексом 1 для вычислений")
|
||||
return torch.device("cuda:1")
|
||||
else:
|
||||
logger.info("Доступна только одна CUDA GPU, используется GPU с индексом 0")
|
||||
return torch.device("cuda:0")
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
logger.info("Используется MPS (Apple Silicon) для вычислений")
|
||||
# Обходное решение для MPS
|
||||
setattr(torch.distributed, "is_initialized", lambda: False)
|
||||
return torch.device("mps")
|
||||
else:
|
||||
logger.info("Используется CPU для вычислений")
|
||||
return torch.device("cpu")
|
||||
|
||||
def _load_model(self) -> None:
|
||||
"""
|
||||
Загрузка модели и процессора.
|
||||
|
||||
Raises:
|
||||
Exception: Если не удалось загрузить модель.
|
||||
"""
|
||||
logger.info(f"Загрузка модели из {self.model_path}")
|
||||
|
||||
try:
|
||||
# Проверка возможности использования Flash Attention 2
|
||||
if self.device.type == "cuda":
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(
|
||||
self.model_path,
|
||||
torch_dtype=self.torch_dtype,
|
||||
low_cpu_mem_usage=True,
|
||||
use_safetensors=True,
|
||||
attn_implementation="flash_attention_2"
|
||||
).to(self.device)
|
||||
logger.info("Используется Flash Attention 2")
|
||||
else:
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(
|
||||
self.model_path,
|
||||
torch_dtype=self.torch_dtype,
|
||||
low_cpu_mem_usage=True,
|
||||
use_safetensors=True
|
||||
).to(self.device)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось загрузить модель с Flash Attention: {e}")
|
||||
# Fallback к обычной версии
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(
|
||||
self.model_path,
|
||||
torch_dtype=self.torch_dtype,
|
||||
low_cpu_mem_usage=True,
|
||||
use_safetensors=True
|
||||
).to(self.device)
|
||||
|
||||
self.processor = WhisperProcessor.from_pretrained(self.model_path)
|
||||
|
||||
self.asr_pipeline = pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=self.model,
|
||||
tokenizer=self.processor.tokenizer,
|
||||
feature_extractor=self.processor.feature_extractor,
|
||||
chunk_length_s=self.chunk_length_s,
|
||||
batch_size=self.batch_size,
|
||||
return_timestamps=self.return_timestamps,
|
||||
torch_dtype=self.torch_dtype,
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
logger.info("Модель успешно загружена и готова к использованию")
|
||||
|
||||
def transcribe(self, audio_path: str) -> Union[str, Dict]:
|
||||
"""
|
||||
Транскрибация аудиофайла.
|
||||
|
||||
Args:
|
||||
audio_path: Путь к обработанному аудиофайлу.
|
||||
|
||||
Returns:
|
||||
В зависимости от параметра return_timestamps:
|
||||
- Если return_timestamps=False: строка с распознанным текстом
|
||||
- Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст)
|
||||
"""
|
||||
logger.info(f"Начало транскрибации файла: {audio_path}")
|
||||
|
||||
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]:
|
||||
"""
|
||||
Полный процесс обработки и транскрибации аудиофайла.
|
||||
|
||||
Args:
|
||||
input_path: Путь к исходному аудиофайлу.
|
||||
|
||||
Returns:
|
||||
В зависимости от параметра return_timestamps:
|
||||
- Если return_timestamps=False: строка с распознанным текстом
|
||||
- Если return_timestamps=True: словарь с ключами "segments" и "text"
|
||||
"""
|
||||
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:
|
||||
# Очистка временных файлов
|
||||
temp_file_manager.cleanup_temp_files(temp_files)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Модуль history_logger.py содержит класс HistoryLogger для журналирования результатов
|
||||
транскрибации.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
import random
|
||||
import string
|
||||
from typing import Dict, Any, Optional
|
||||
import logging
|
||||
|
||||
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(__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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Главный модуль приложения, содержащий класс WhisperServiceAPI для инициализации
|
||||
и запуска сервиса распознавания речи.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from flask import Flask
|
||||
from typing import Dict
|
||||
|
||||
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
|
||||
|
||||
|
||||
class WhisperServiceAPI:
|
||||
"""
|
||||
Класс для API сервиса распознавания речи.
|
||||
|
||||
Attributes:
|
||||
config (Dict): Словарь с параметрами конфигурации.
|
||||
port (int): Порт для сервиса.
|
||||
transcriber (WhisperTranscriber): Экземпляр транскрайбера.
|
||||
app (Flask): Flask-приложение.
|
||||
file_validator (FileValidator): Валидатор файлов.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: str):
|
||||
"""
|
||||
Инициализация API сервиса.
|
||||
|
||||
Args:
|
||||
config_path: Путь к конфигурационному файлу.
|
||||
"""
|
||||
# Загрузка конфигурации
|
||||
self.config = load_config(config_path)
|
||||
|
||||
# Установка уровня логирования
|
||||
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
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Модуль async_tasks.py содержит функции для асинхронной обработки задач.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import time
|
||||
from typing import Dict, Any, Callable, Optional
|
||||
from threading import Thread
|
||||
from .utils import logger
|
||||
|
||||
|
||||
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)
|
||||
@@ -7,15 +7,23 @@
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import Dict, Tuple
|
||||
|
||||
# Импорт классов и функций из других модулей
|
||||
from .file_manager import temp_file_manager
|
||||
from .context_managers import open_file
|
||||
from .utils import logger
|
||||
|
||||
|
||||
class AudioProcessor:
|
||||
"""Класс для предобработки аудиофайлов перед распознаванием."""
|
||||
"""
|
||||
Класс для предобработки аудиофайлов перед распознаванием.
|
||||
|
||||
Attributes:
|
||||
config (Dict): Словарь с параметрами конфигурации.
|
||||
norm_level (str): Уровень нормализации аудио.
|
||||
compand_params (str): Параметры компрессора аудио.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""
|
||||
@@ -27,6 +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:
|
||||
"""
|
||||
@@ -37,8 +46,10 @@ class AudioProcessor:
|
||||
|
||||
Returns:
|
||||
Путь к сконвертированному WAV-файлу.
|
||||
"""
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при конвертации.
|
||||
"""
|
||||
audio_rate = self.config["audio_rate"]
|
||||
|
||||
# Проверка расширения файла
|
||||
@@ -54,8 +65,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 +97,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 +123,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 +173,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 +198,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 +207,9 @@ class AudioProcessor:
|
||||
|
||||
Returns:
|
||||
Кортеж: (путь к обработанному файлу, список временных файлов для удаления)
|
||||
|
||||
Raises:
|
||||
Exception: Если произошла ошибка при обработке аудио.
|
||||
"""
|
||||
temp_files = []
|
||||
|
||||
@@ -186,13 +223,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)
|
||||
temp_file_manager.cleanup_temp_files(temp_files)
|
||||
raise
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Модуль audio_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}")
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Модуль cache.py содержит функции для кэширования данных.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from functools import wraps
|
||||
from .utils import logger
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Модуль context_managers.py содержит контекстные менеджеры для управления ресурсами.
|
||||
"""
|
||||
|
||||
import os
|
||||
import contextlib
|
||||
from typing import Generator, BinaryIO
|
||||
from .utils import logger
|
||||
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Модуль file_manager.py содержит классы для централизованного управления временными файлами.
|
||||
Предоставляет унифицированный интерфейс для создания, отслеживания и очистки временных файлов.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
import contextlib
|
||||
from typing import List, Tuple, Optional, Generator
|
||||
from .utils import logger
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Модуль logging_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,227 @@
|
||||
"""
|
||||
Модуль 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):
|
||||
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'
|
||||
@@ -14,12 +14,24 @@ from .audio_sources import (
|
||||
Base64Source,
|
||||
LocalFileSource
|
||||
)
|
||||
from .validators import ValidationError
|
||||
from .async_tasks import transcribe_audio_async, task_manager
|
||||
from .cache import model_cache
|
||||
from .utils import logger, log_invalid_file_request
|
||||
|
||||
|
||||
class Routes:
|
||||
"""Класс для регистрации всех эндпоинтов API."""
|
||||
"""
|
||||
Класс для регистрации всех эндпоинтов API.
|
||||
|
||||
def __init__(self, app, transcriber, config: Dict):
|
||||
Attributes:
|
||||
app (Flask): Flask-приложение.
|
||||
config (Dict): Словарь с конфигурацией.
|
||||
transcription_service (TranscriptionService): Сервис транскрибации.
|
||||
file_validator (FileValidator): Валидатор файлов.
|
||||
"""
|
||||
|
||||
def __init__(self, app, transcriber, config: Dict, file_validator):
|
||||
"""
|
||||
Инициализация маршрутов.
|
||||
|
||||
@@ -27,16 +39,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 +80,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 +129,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 +157,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 +177,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 .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)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Модуль transcribe.py содержит класс WhisperTranscriber, который использует модель Whisper от
|
||||
Модуль transcriber.py содержит класс WhisperTranscriber, который использует модель Whisper от
|
||||
OpenAI для транскрибации аудиофайлов в текст. Класс включает в себя методы для загрузки модели,
|
||||
обработки аудио (с использованием класса AudioProcessor), и выполнения транскрибации.
|
||||
Обрабатывает выбор устройства (CPU, CUDA, MPS) для выполнения вычислений и обеспечивает
|
||||
@@ -7,9 +7,9 @@ OpenAI для транскрибации аудиофайлов в текст.
|
||||
"""
|
||||
|
||||
import time
|
||||
import traceback
|
||||
from typing import Dict, Tuple, Union
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
import torch
|
||||
from transformers import (
|
||||
@@ -19,10 +19,31 @@ from transformers import (
|
||||
)
|
||||
|
||||
from .audio_processor import AudioProcessor
|
||||
from .audio_utils import AudioUtils
|
||||
from .file_manager import temp_file_manager
|
||||
from .utils import logger
|
||||
|
||||
|
||||
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 +74,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 +97,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,22 +150,7 @@ 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
|
||||
# Метод _load_audio удален, так как его функциональность перенесена в AudioUtils
|
||||
|
||||
def transcribe(self, audio_path: str) -> Union[str, Dict]:
|
||||
"""
|
||||
@@ -150,60 +166,71 @@ class WhisperTranscriber:
|
||||
"""
|
||||
logger.info(f"Начало транскрибации файла: {audio_path}")
|
||||
|
||||
# Загрузка аудио в формате numpy array
|
||||
audio_array, sampling_rate = self._load_audio(audio_path)
|
||||
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
|
||||
)
|
||||
# Транскрибация с корректным форматом данных
|
||||
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
|
||||
# Если временные метки не запрошены, возвращаем только текст
|
||||
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", "")
|
||||
# Если временные метки запрошены, обрабатываем и форматируем результат
|
||||
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()
|
||||
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
|
||||
})
|
||||
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("Временные метки запрошены, но не найдены в результате транскрибации")
|
||||
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)} сегментов")
|
||||
logger.info(f"Транскрибация с временными метками завершена: получено {len(segments)} сегментов")
|
||||
|
||||
# Возвращаем словарь с сегментами и полным текстом
|
||||
return {
|
||||
"segments": segments,
|
||||
"text": full_text
|
||||
}
|
||||
# Возвращаем словарь с сегментами и полным текстом
|
||||
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]:
|
||||
"""
|
||||
@@ -234,6 +261,13 @@ class WhisperTranscriber:
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Модуль transcriber_service.py содержит класс TranscriptionService,
|
||||
который отвечает за обработку и транскрибацию аудиофайлов.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from .utils import logger
|
||||
from .audio_utils import AudioUtils
|
||||
from .history_logger import HistoryLogger
|
||||
from .audio_sources import AudioSource
|
||||
from .validators import FileValidator, ValidationError
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Метод get_audio_duration удален, так как его функциональность перенесена в AudioUtils
|
||||
|
||||
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 .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
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Модуль utils.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
|
||||
@@ -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
|
||||
@@ -1,119 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import requests\n",
|
||||
"\n",
|
||||
"def transcribe_audio(file_path: str, server_url: str = \"\"):\n",
|
||||
" \"\"\"\n",
|
||||
" Отправляет аудиофайл на сервер для распознавания и возвращает результат.\n",
|
||||
"\n",
|
||||
" Args:\n",
|
||||
" file_path: Путь к локальному аудиофайлу.\n",
|
||||
" server_url: URL сервера (по умолчанию \"\").\n",
|
||||
"\n",
|
||||
" Returns:\n",
|
||||
" Распознанный текст.\n",
|
||||
" \"\"\"\n",
|
||||
" try:\n",
|
||||
" # Открываем файл для отправки\n",
|
||||
" with open(file_path, \"rb\") as audio_file:\n",
|
||||
" # Формируем запрос\n",
|
||||
" files = {\"file\": (file_path, audio_file)}\n",
|
||||
" response = requests.post(f\"{server_url}/v1/audio/transcriptions\", files=files)\n",
|
||||
"\n",
|
||||
" # Проверяем статус ответа\n",
|
||||
" if response.status_code == 200:\n",
|
||||
" return response.json()\n",
|
||||
" else:\n",
|
||||
" print(f\"Ошибка: {response.status_code}\")\n",
|
||||
" print(response.json())\n",
|
||||
" return None\n",
|
||||
"\n",
|
||||
" except Exception as e:\n",
|
||||
" print(f\"Ошибка при отправке файла: {e}\")\n",
|
||||
" return None\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Запуск транскрибации\n",
|
||||
"server=\"http:192.168.1.176:5042\"\n",
|
||||
"file=\"/Users/serge/Downloads/emToQc8pxaI_audio.mp3\"\n",
|
||||
"text = transcribe_audio(file, server)\n",
|
||||
"\n",
|
||||
"print(text)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import secrets\n",
|
||||
"import pyperclip # Import the pyperclip library\n",
|
||||
"\n",
|
||||
"def generate_token(num_groups: int, group_length: int) -> str:\n",
|
||||
" \"\"\"\n",
|
||||
" Generates a token with a specified number of groups and length for each group.\n",
|
||||
"\n",
|
||||
" Args:\n",
|
||||
" num_groups (int): The number of groups in the token.\n",
|
||||
" group_length (int): The length of each group.\n",
|
||||
"\n",
|
||||
" Returns:\n",
|
||||
" str: The generated token.\n",
|
||||
" \"\"\"\n",
|
||||
" groups = [secrets.token_urlsafe(group_length) for _ in range(num_groups)]\n",
|
||||
" return \"-\".join(groups)\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" num_groups = 4\n",
|
||||
" group_length = 8\n",
|
||||
" token = generate_token(num_groups, group_length)\n",
|
||||
" print(token)\n",
|
||||
"\n",
|
||||
" # Copy the token to the clipboard\n",
|
||||
" pyperclip.copy(token)\n",
|
||||
" print(\"Token copied to clipboard!\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "wpc",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Клиент для взаимодействия с сервисом распознавания речи через REST API.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
import sys
|
||||
|
||||
|
||||
class TranscribeClient:
|
||||
"""
|
||||
Класс для взаимодействия с сервисом распознавания речи через REST API.
|
||||
"""
|
||||
|
||||
def __init__(self, server_url="http://localhost:5000"):
|
||||
"""
|
||||
Инициализация клиента.
|
||||
|
||||
Args:
|
||||
server_url: URL сервера распознавания.
|
||||
"""
|
||||
self.server_url = server_url
|
||||
|
||||
def health(self):
|
||||
"""
|
||||
Проверка статуса сервиса.
|
||||
|
||||
Returns:
|
||||
Словарь с информацией о статусе сервиса или None при ошибке.
|
||||
"""
|
||||
endpoint = f"{self.server_url}/health"
|
||||
try:
|
||||
response = requests.get(endpoint, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
print(f"Ошибка health check: {response.status_code}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Ошибка при выполнении health check: {e}")
|
||||
return None
|
||||
|
||||
def transcribe(self, file_path):
|
||||
"""
|
||||
Отправка файла на транскрибацию.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
|
||||
Returns:
|
||||
Словарь с результатом транскрибации или None при ошибке.
|
||||
"""
|
||||
# Проверка существования файла
|
||||
if not os.path.exists(file_path):
|
||||
print(f"Ошибка: Файл '{file_path}' не найден")
|
||||
return None
|
||||
|
||||
# Формирование абсолютного пути к файлу
|
||||
absolute_path = os.path.abspath(file_path)
|
||||
|
||||
# Подготовка данных для запроса
|
||||
payload = {"file_path": absolute_path}
|
||||
|
||||
# Запрос на транскрибацию
|
||||
endpoint = f"{self.server_url}/local/transcriptions"
|
||||
|
||||
try:
|
||||
# Отправка запроса
|
||||
response = requests.post(endpoint, json=payload, timeout=600) # Таймаут 10 минут
|
||||
|
||||
# Обработка ответа
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result
|
||||
else:
|
||||
print(f"Ошибка запроса: {response.status_code}")
|
||||
if response.headers.get('content-type') == 'application/json':
|
||||
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(response.text)
|
||||
return None
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Ошибка при отправке запроса: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Основная функция клиента."""
|
||||
parser = argparse.ArgumentParser(description="Клиент для сервиса распознавания речи")
|
||||
parser.add_argument("file_path", help="Путь к аудиофайлу для транскрибации")
|
||||
parser.add_argument("--server", help="URL сервера распознавания", default="http://localhost:5042")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Создание экземпляра клиента
|
||||
client = TranscribeClient(args.server)
|
||||
|
||||
# Запуск транскрибации
|
||||
result = client.transcribe(args.file_path)
|
||||
|
||||
if result:
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
return result
|
||||
|
||||
else:
|
||||
print("Что-то пошло не так 🤔")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+19
-4
@@ -1,14 +1,29 @@
|
||||
{
|
||||
"service_port": 5042,
|
||||
"model_path": "/home/text-generation/models.whisper/antony-ties-podlodka-v1.2",
|
||||
"language": "russian",
|
||||
"model_path": "/home/text-generation/models/whisper/podlodka-turbo",
|
||||
"language": "ru",
|
||||
"enable_history": true,
|
||||
"chunk_length_s": 28,
|
||||
"batch_size": 8,
|
||||
"batch_size": 6,
|
||||
"max_new_tokens": 384,
|
||||
"temperature": 0.01,
|
||||
"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"
|
||||
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15",
|
||||
"audio_speed_factor": 1.0,
|
||||
"file_validation": {
|
||||
"max_file_size_mb": 500,
|
||||
"allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga"],
|
||||
"allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a"]
|
||||
},
|
||||
"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": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
# Implementation Plan: Whisper API Server Code Reorganization and Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the comprehensive plan to reorganize the Whisper API server codebase from a flat structure to a well-organized, domain-driven architecture. The reorganization will improve code maintainability, readability, and scalability while preserving all existing functionality. Additionally, all code will be documented with Russian docstrings and comments to improve code comprehension.
|
||||
|
||||
## Scope Clarification
|
||||
|
||||
**Included in this reorganization:**
|
||||
- Complete restructuring of the `app/` directory into functional domains
|
||||
- Moving all files to appropriate new locations
|
||||
- Updating all import statements throughout the codebase
|
||||
- Adding comprehensive Russian documentation to all modules
|
||||
- Creating proper docstrings for all classes and functions
|
||||
- Adding inline comments explaining complex logic
|
||||
|
||||
**Excluded from this reorganization:**
|
||||
- Changing any external APIs or interfaces
|
||||
- Modifying configuration file structure
|
||||
- Changing the static web interface
|
||||
- Altering the server entry point (`server.py`)
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Current Directory Structure
|
||||
```
|
||||
app/
|
||||
├── __init__.py
|
||||
├── async_tasks.py
|
||||
├── audio_processor.py
|
||||
├── audio_sources.py
|
||||
├── audio_utils.py
|
||||
├── cache.py
|
||||
├── context_managers.py
|
||||
├── file_manager.py
|
||||
├── history_logger.py
|
||||
├── logging_config.py
|
||||
├── request_logger.py
|
||||
├── routes.py
|
||||
├── transcriber_service.py
|
||||
├── transcriber.py
|
||||
├── utils.py
|
||||
├── validators.py
|
||||
└── static/
|
||||
└── index.html
|
||||
```
|
||||
|
||||
### Current Issues
|
||||
1. **Flat structure**: All files in a single directory without logical grouping
|
||||
2. **Mixed responsibilities**: Infrastructure, business logic, and utilities are mixed
|
||||
3. **Poor discoverability**: Difficult to locate specific functionality
|
||||
4. **Maintenance challenges**: No clear boundaries between different concerns
|
||||
5. **Documentation gaps**: Inconsistent or missing Russian documentation
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### New Directory Structure
|
||||
```
|
||||
app/
|
||||
├── __init__.py # Main application entry point
|
||||
├── api/ # API layer
|
||||
│ ├── __init__.py
|
||||
│ ├── routes.py # API route definitions
|
||||
│ └── middleware.py # Request/response middleware
|
||||
├── core/ # Core business logic
|
||||
│ ├── __init__.py
|
||||
│ ├── transcriber.py # Main transcription service
|
||||
│ ├── transcription_service.py
|
||||
│ └── config.py # Configuration management
|
||||
├── audio/ # Audio processing domain
|
||||
│ ├── __init__.py
|
||||
│ ├── processor.py # Audio preprocessing
|
||||
│ ├── sources.py # Audio source handlers
|
||||
│ └── utils.py # Audio-specific utilities
|
||||
├── infrastructure/ # Infrastructure services
|
||||
│ ├── __init__.py
|
||||
│ ├── logging/ # Logging configuration
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config.py
|
||||
│ │ └── request_logger.py
|
||||
│ ├── storage/ # File and cache management
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── file_manager.py
|
||||
│ │ └── cache.py
|
||||
│ ├── async_tasks/ # Background task management
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── manager.py
|
||||
│ └── validation/ # Input validation
|
||||
│ ├── __init__.py
|
||||
│ └── validators.py
|
||||
├── shared/ # Shared utilities
|
||||
│ ├── __init__.py
|
||||
│ ├── context_managers.py # Reusable context managers
|
||||
│ ├── decorators.py # Shared decorators
|
||||
│ └── history_logger.py # History logging functionality
|
||||
└── static/ # Static web assets
|
||||
└── index.html
|
||||
```
|
||||
|
||||
### File Mapping
|
||||
| Current File | New Location |
|
||||
|--------------|--------------|
|
||||
| `routes.py` | `api/routes.py` |
|
||||
| `request_logger.py` | `infrastructure/logging/request_logger.py` |
|
||||
| `transcriber.py` | `core/transcriber.py` |
|
||||
| `transcriber_service.py` | `core/transcription_service.py` |
|
||||
| `audio_processor.py` | `audio/processor.py` |
|
||||
| `audio_sources.py` | `audio/sources.py` |
|
||||
| `audio_utils.py` | `audio/utils.py` |
|
||||
| `file_manager.py` | `infrastructure/storage/file_manager.py` |
|
||||
| `cache.py` | `infrastructure/storage/cache.py` |
|
||||
| `async_tasks.py` | `infrastructure/async_tasks/manager.py` |
|
||||
| `validators.py` | `infrastructure/validation/validators.py` |
|
||||
| `logging_config.py` | `infrastructure/logging/config.py` |
|
||||
| `context_managers.py` | `shared/context_managers.py` |
|
||||
| `utils.py` | `shared/decorators.py` |
|
||||
| `history_logger.py` | `shared/history_logger.py` |
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Infrastructure Setup
|
||||
|
||||
#### 1.1 Create New Directory Structure
|
||||
Create all new directories with `__init__.py` files:
|
||||
|
||||
```bash
|
||||
mkdir -p app/api
|
||||
mkdir -p app/core
|
||||
mkdir -p app/audio
|
||||
mkdir -p app/infrastructure/logging
|
||||
mkdir -p app/infrastructure/storage
|
||||
mkdir -p app/infrastructure/async_tasks
|
||||
mkdir -p app/infrastructure/validation
|
||||
mkdir -p app/shared
|
||||
```
|
||||
|
||||
#### 1.2 Create __init__.py Files
|
||||
Create empty `__init__.py` files in all new directories to make them Python packages.
|
||||
|
||||
### Phase 2: Core Business Logic Migration
|
||||
|
||||
#### 2.1 Migrate Transcriber Components
|
||||
**File**: `app/core/transcriber.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль transcriber.py содержит класс WhisperTranscriber, который использует модель Whisper от
|
||||
OpenAI для транскрибации аудиофайлов в текст. Класс включает в себя методы для загрузки модели,
|
||||
обработки аудио (с использованием класса AudioProcessor), и выполнения транскрибации.
|
||||
Обрабатывает выбор устройства (CPU, CUDA, MPS) для выполнения вычислений и обеспечивает
|
||||
возможность использования Flash Attention 2 для ускорения работы модели на поддерживаемых GPU.
|
||||
"""
|
||||
|
||||
# [Full implementation with Russian documentation]
|
||||
```
|
||||
|
||||
**File**: `app/core/transcription_service.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль transcription_service.py содержит класс TranscriptionService,
|
||||
который отвечает за обработку и транскрибацию аудиофайлов.
|
||||
"""
|
||||
|
||||
# [Full implementation with Russian documentation]
|
||||
```
|
||||
|
||||
**File**: `app/core/config.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль config.py содержит функции для управления конфигурацией приложения.
|
||||
"""
|
||||
|
||||
def load_config(config_path: str) -> Dict:
|
||||
"""
|
||||
Загружает конфигурацию из JSON-файла.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации.
|
||||
|
||||
Returns:
|
||||
Словарь с параметрами конфигурации.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если файл конфигурации не найден.
|
||||
json.JSONDecodeError: Если файл конфигурации содержит некорректный JSON.
|
||||
"""
|
||||
# Implementation moved from __init__.py
|
||||
```
|
||||
|
||||
### Phase 3: Audio Domain Migration
|
||||
|
||||
#### 3.1 Migrate Audio Processing Components
|
||||
**File**: `app/audio/processor.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль processor.py содержит класс AudioProcessor, предназначенный для предобработки аудиофайлов
|
||||
перед их использованием в системах распознавания речи. Класс предоставляет методы для конвертации
|
||||
аудио в формат WAV с частотой дискретизации 16 кГц, нормализации уровня громкости,
|
||||
добавления тишины в начало записи, а также для удаления временных файлов, созданных в процессе обработки.
|
||||
"""
|
||||
|
||||
# [Full implementation with Russian documentation]
|
||||
```
|
||||
|
||||
**File**: `app/audio/sources.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль sources.py содержит абстрактный класс AudioSource и его конкретные реализации
|
||||
для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы).
|
||||
"""
|
||||
|
||||
# [Full implementation with Russian documentation]
|
||||
```
|
||||
|
||||
**File**: `app/audio/utils.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль utils.py содержит утилитарные функции для работы с аудио.
|
||||
"""
|
||||
|
||||
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: Если не удалось загрузить аудиофайл.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
### Phase 4: Infrastructure Migration
|
||||
|
||||
#### 4.1 Migrate Logging Components
|
||||
**File**: `infrastructure/logging/config.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль config.py содержит централизованную настройку логирования.
|
||||
"""
|
||||
|
||||
def setup_logging(log_level=logging.INFO, log_file=None):
|
||||
"""
|
||||
Настройка логирования для всего приложения.
|
||||
|
||||
Args:
|
||||
log_level: Уровень логирования (по умолчанию INFO).
|
||||
log_file: Путь к файлу для записи логов (опционально).
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
**File**: `infrastructure/logging/request_logger.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль request_logger.py содержит middleware для логирования входящих запросов и ответов.
|
||||
"""
|
||||
|
||||
class RequestLogger:
|
||||
"""
|
||||
Middleware для логирования входящих запросов и ответов.
|
||||
"""
|
||||
|
||||
def __init__(self, app=None, config: Optional[Dict] = None):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
Args:
|
||||
app: Flask приложение.
|
||||
config: Конфигурация логирования.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
#### 4.2 Migrate Storage Components
|
||||
**File**: `infrastructure/storage/file_manager.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль file_manager.py содержит классы для централизованного управления временными файлами.
|
||||
Предоставляет унифицированный интерфейс для создания, отслеживания и очистки временных файлов.
|
||||
"""
|
||||
|
||||
class TempFileManager:
|
||||
"""
|
||||
Класс для централизованного управления временными файлами.
|
||||
|
||||
Предоставляет методы для создания временных файлов и их последующей очистки.
|
||||
Использует контекстные менеджеры для автоматической очистки ресурсов.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Инициализация менеджера временных файлов.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
**File**: `infrastructure/storage/cache.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль cache.py содержит функции для кэширования данных.
|
||||
"""
|
||||
|
||||
class SimpleCache:
|
||||
"""
|
||||
Простой кэш на основе словаря с поддержкой TTL (Time To Live).
|
||||
|
||||
Attributes:
|
||||
cache (Dict): Словарь для хранения кэшированных данных.
|
||||
ttl (int): Время жизни кэша в секундах.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: int = 300):
|
||||
"""
|
||||
Инициализация кэша.
|
||||
|
||||
Args:
|
||||
ttl: Время жизни кэша в секундах (по умолчанию 5 минут).
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
#### 4.3 Migrate Other Infrastructure Components
|
||||
**File**: `infrastructure/async_tasks/manager.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль manager.py содержит функции для асинхронной обработки задач.
|
||||
"""
|
||||
|
||||
class AsyncTaskManager:
|
||||
"""
|
||||
Менеджер асинхронных задач на основе потоков.
|
||||
|
||||
Attributes:
|
||||
tasks (Dict): Словарь для хранения информации о задачах.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Инициализация менеджера асинхронных задач.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
**File**: `infrastructure/validation/validators.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль validators.py содержит классы и функции для валидации входных данных.
|
||||
"""
|
||||
|
||||
class FileValidator:
|
||||
"""
|
||||
Класс для валидации файлов.
|
||||
|
||||
Проверяет тип файла, размер и другие параметры на основе конфигурации.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""
|
||||
Инициализация валидатора файлов.
|
||||
|
||||
Args:
|
||||
config: Словарь с параметрами конфигурации.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
### Phase 5: API Layer Migration
|
||||
|
||||
#### 5.1 Migrate API Components
|
||||
**File**: `app/api/routes.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль routes.py содержит классы для регистрации маршрутов API
|
||||
для сервиса распознавания речи.
|
||||
"""
|
||||
|
||||
class Routes:
|
||||
"""
|
||||
Класс для регистрации всех эндпоинтов API.
|
||||
|
||||
Attributes:
|
||||
app (Flask): Flask-приложение.
|
||||
config (Dict): Словарь с конфигурацией.
|
||||
transcription_service (TranscriptionService): Сервис транскрибации.
|
||||
file_validator (FileValidator): Валидатор файлов.
|
||||
"""
|
||||
|
||||
def __init__(self, app, transcriber, config: Dict, file_validator):
|
||||
"""
|
||||
Инициализация маршрутов.
|
||||
|
||||
Args:
|
||||
app: Flask-приложение.
|
||||
transcriber: Экземпляр транскрайбера.
|
||||
config: Словарь с конфигурацией.
|
||||
file_validator: Валидатор файлов.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
**File**: `app/api/middleware.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль middleware.py содержит различные middleware для Flask приложения.
|
||||
"""
|
||||
|
||||
# Request logging middleware will be moved here
|
||||
```
|
||||
|
||||
### Phase 6: Shared Utilities Migration
|
||||
|
||||
#### 6.1 Migrate Shared Components
|
||||
**File**: `app/shared/context_managers.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль context_managers.py содержит контекстные менеджеры для управления ресурсами.
|
||||
"""
|
||||
|
||||
@contextlib.contextmanager
|
||||
def open_file(file_path: str, mode: str = 'rb') -> Generator[BinaryIO, None, None]:
|
||||
"""
|
||||
Контекстный менеджер для безопасного открытия и закрытия файлов.
|
||||
|
||||
Args:
|
||||
file_path: Путь к файлу.
|
||||
mode: Режим открытия файла.
|
||||
|
||||
Yields:
|
||||
Файловый объект.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
**File**: `app/shared/decorators.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль decorators.py содержит общие декораторы для использования в приложении.
|
||||
"""
|
||||
|
||||
def log_invalid_file_request(func):
|
||||
"""
|
||||
Декоратор для логирования запросов с невалидными файлами.
|
||||
|
||||
Args:
|
||||
func: Декорируемая функция.
|
||||
|
||||
Returns:
|
||||
Обернутая функция с логированием ошибок валидации файлов.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
**File**: `app/shared/history_logger.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Модуль history_logger.py содержит класс HistoryLogger для журналирования результатов
|
||||
транскрибации.
|
||||
"""
|
||||
|
||||
class HistoryLogger:
|
||||
"""Класс для сохранения истории транскрибации."""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""
|
||||
Инициализация логгера истории.
|
||||
|
||||
Args:
|
||||
config: Словарь с конфигурацией.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
### Phase 7: Update Main Application
|
||||
|
||||
#### 7.1 Update Main Application File
|
||||
**File**: `app/__init__.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Главный модуль приложения, содержащий класс WhisperServiceAPI для инициализации
|
||||
и запуска сервиса распознавания речи.
|
||||
"""
|
||||
|
||||
# Updated imports from new structure
|
||||
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
|
||||
|
||||
class WhisperServiceAPI:
|
||||
"""
|
||||
Класс для API сервиса распознавания речи.
|
||||
|
||||
Attributes:
|
||||
config (Dict): Словарь с параметрами конфигурации.
|
||||
port (int): Порт для сервиса.
|
||||
transcriber (WhisperTranscriber): Экземпляр транскрайбера.
|
||||
app (Flask): Flask-приложение.
|
||||
file_validator (FileValidator): Валидатор файлов.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: str):
|
||||
"""
|
||||
Инициализация API сервиса.
|
||||
|
||||
Args:
|
||||
config_path: Путь к конфигурационному файлу.
|
||||
"""
|
||||
# [Implementation with Russian comments]
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Import Update Strategy
|
||||
|
||||
All import statements throughout the codebase will be updated to reflect the new structure. For example:
|
||||
|
||||
```python
|
||||
# Before
|
||||
from .transcriber import WhisperTranscriber
|
||||
from .routes import Routes
|
||||
from .validators import FileValidator
|
||||
|
||||
# After
|
||||
from .core.transcriber import WhisperTranscriber
|
||||
from .api.routes import Routes
|
||||
from .infrastructure.validation.validators import FileValidator
|
||||
```
|
||||
|
||||
### Documentation Standards
|
||||
|
||||
All code will be documented following these standards:
|
||||
|
||||
1. **Module-level docstrings**: Explain the purpose and functionality of the module
|
||||
2. **Class docstrings**: Describe the class purpose and its attributes
|
||||
3. **Method/function docstrings**: Explain parameters, return values, and exceptions
|
||||
4. **Inline comments**: Explain complex logic and business rules
|
||||
5. **Language**: All documentation in Russian as requested
|
||||
|
||||
## Data Flow Diagram
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Current Structure"
|
||||
A[Flat app/ directory] --> B[Mixed responsibilities]
|
||||
B --> C[Poor maintainability]
|
||||
end
|
||||
|
||||
subgraph "New Structure"
|
||||
D[app/api/] --> E[API layer]
|
||||
F[app/core/] --> G[Business logic]
|
||||
H[app/audio/] --> I[Audio processing]
|
||||
J[app/infrastructure/] --> K[Cross-cutting concerns]
|
||||
L[app/shared/] --> M[Common utilities]
|
||||
end
|
||||
|
||||
subgraph "Benefits"
|
||||
N[Clear separation] --> O[Easy maintenance]
|
||||
P[Better testability] --> Q[Improved scalability]
|
||||
R[Russian documentation] --> S[Better code comprehension]
|
||||
end
|
||||
```
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
### Migration Error Handling
|
||||
1. **Backup Strategy**: Create a complete backup before starting migration
|
||||
2. **Incremental Migration**: Move files one module at a time
|
||||
3. **Validation**: Test each module after migration
|
||||
4. **Rollback Plan**: Keep the original structure until migration is complete
|
||||
|
||||
### Import Error Resolution
|
||||
1. **Systematic Update**: Update all imports in a systematic way
|
||||
2. **Validation**: Run tests after each import update
|
||||
3. **Dependency Tracking**: Track which files depend on others
|
||||
4. **Circular Dependency Prevention**: Ensure no circular dependencies are created
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Migration Testing
|
||||
1. **Unit Tests**: Run existing unit tests after each module migration
|
||||
2. **Integration Tests**: Test API endpoints after reorganization
|
||||
3. **Import Tests**: Verify all imports work correctly
|
||||
4. **Functional Tests**: Ensure all functionality remains intact
|
||||
|
||||
### Documentation Testing
|
||||
1. **Docstring Validation**: Ensure all docstrings are properly formatted
|
||||
2. **Language Validation**: Verify all documentation is in Russian
|
||||
3. **Completeness Check**: Ensure all classes and functions are documented
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create new directory structure with empty `__init__.py` files
|
||||
2. Migrate infrastructure components (logging, storage, validation)
|
||||
3. Migrate audio processing components
|
||||
4. Migrate core business logic
|
||||
5. Migrate API layer components
|
||||
6. Migrate shared utilities
|
||||
7. Update main application file with new imports
|
||||
8. Add comprehensive Russian documentation to all modules
|
||||
9. Run tests to verify functionality
|
||||
10. Remove old files after successful migration
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Migration Performance
|
||||
1. **Zero Downtime**: Migration should not affect running service
|
||||
2. **Incremental Updates**: Apply changes incrementally to reduce risk
|
||||
3. **Backward Compatibility**: Maintain compatibility during transition
|
||||
|
||||
### Documentation Performance
|
||||
1. **Search Optimization**: Well-documented code is easier to search
|
||||
2. **Maintenance Reduction**: Good documentation reduces maintenance time
|
||||
3. **Onboarding Speed**: New developers can understand code faster
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise during migration:
|
||||
1. **Stop Migration**: Halt the migration process
|
||||
2. **Assess Issues**: Identify what went wrong
|
||||
3. **Partial Rollback**: Rollback only affected modules
|
||||
4. **Complete Rollback**: If necessary, restore from backup
|
||||
5. **Fix Issues**: Address the problems before retrying
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The migration will be considered successful when:
|
||||
1. All files are moved to their new locations
|
||||
2. All imports are updated and working
|
||||
3. All tests pass without modification
|
||||
4. All code is documented in Russian
|
||||
5. The application runs without errors
|
||||
6. No functionality is lost or changed
|
||||
@@ -0,0 +1,576 @@
|
||||
{
|
||||
"expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
|
||||
"id": "494944",
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issue/494944",
|
||||
"key": "WPCMU-9082",
|
||||
"fields": {
|
||||
"customfield_19200": null,
|
||||
"customfield_18113": null,
|
||||
"customfield_46700": null,
|
||||
"customfield_44400": null,
|
||||
"customfield_15400": null,
|
||||
"customfield_17700": null,
|
||||
"customfield_38400": null,
|
||||
"customfield_18115": null,
|
||||
"resolution": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/resolution/6",
|
||||
"id": "6",
|
||||
"description": "the task is done",
|
||||
"name": "Done"
|
||||
},
|
||||
"customfield_10500": null,
|
||||
"customfield_36104": null,
|
||||
"customfield_36103": null,
|
||||
"customfield_36102": null,
|
||||
"customfield_24001": null,
|
||||
"customfield_45602": null,
|
||||
"customfield_22601": null,
|
||||
"customfield_47900": null,
|
||||
"lastViewed": null,
|
||||
"customfield_45501": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/customFieldOption/20611",
|
||||
"value": "Desktop",
|
||||
"id": "20611",
|
||||
"disabled": false
|
||||
},
|
||||
"customfield_47001": null,
|
||||
"customfield_16600": null,
|
||||
"customfield_14300": null,
|
||||
"customfield_12000": "0|i0jh73:",
|
||||
"customfield_39500": null,
|
||||
"customfield_14301": null,
|
||||
"customfield_47000": null,
|
||||
"customfield_14304": null,
|
||||
"customfield_32305": null,
|
||||
"customfield_14302": null,
|
||||
"customfield_16601": null,
|
||||
"customfield_18107": null,
|
||||
"customfield_14303": null,
|
||||
"labels": [],
|
||||
"aggregatetimeoriginalestimate": null,
|
||||
"issuelinks": [
|
||||
{
|
||||
"id": "485247",
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issueLink/485247",
|
||||
"type": {
|
||||
"id": "10010",
|
||||
"name": "1 Зависит от таска",
|
||||
"inward": "Parent task",
|
||||
"outward": "Child task",
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issueLinkType/10010"
|
||||
},
|
||||
"inwardIssue": {
|
||||
"id": "494942",
|
||||
"key": "WPCMU-9081",
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issue/494942",
|
||||
"fields": {
|
||||
"summary": "SLOTS-2",
|
||||
"status": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/status/6",
|
||||
"description": "The issue is considered finished, the resolution is correct. Issues which are closed can be reopened.",
|
||||
"iconUrl": "https://jira.crazypanda.ru/images/icons/statuses/closed.png",
|
||||
"name": "Closed",
|
||||
"id": "6",
|
||||
"statusCategory": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/statuscategory/3",
|
||||
"id": 3,
|
||||
"key": "done",
|
||||
"colorName": "green",
|
||||
"name": "Done"
|
||||
}
|
||||
},
|
||||
"issuetype": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issuetype/7",
|
||||
"id": "7",
|
||||
"description": "Created by Jira Software - do not edit or delete. Issue type for a user story.",
|
||||
"iconUrl": "https://jira.crazypanda.ru/secure/viewavatar?size=xsmall&avatarId=16435&avatarType=issuetype",
|
||||
"name": "Story",
|
||||
"subtask": false,
|
||||
"avatarId": 16435
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "502299",
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issueLink/502299",
|
||||
"type": {
|
||||
"id": "11620",
|
||||
"name": "Mention",
|
||||
"inward": "is mentioned by",
|
||||
"outward": "mentions",
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issueLinkType/11620"
|
||||
},
|
||||
"outwardIssue": {
|
||||
"id": "503922",
|
||||
"key": "WPCMU-9682",
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issue/503922",
|
||||
"fields": {
|
||||
"summary": "LOC: SLOTS-2",
|
||||
"status": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/status/5",
|
||||
"description": "Story has been released less then 1.5m ago.",
|
||||
"iconUrl": "https://jira.crazypanda.ru/images/icons/statuses/generic.png",
|
||||
"name": "Resolved",
|
||||
"id": "5",
|
||||
"statusCategory": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/statuscategory/3",
|
||||
"id": 3,
|
||||
"key": "done",
|
||||
"colorName": "green",
|
||||
"name": "Done"
|
||||
}
|
||||
},
|
||||
"issuetype": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issuetype/3",
|
||||
"id": "3",
|
||||
"description": "A task that needs to be done.",
|
||||
"iconUrl": "https://jira.crazypanda.ru/secure/viewavatar?size=xsmall&avatarId=16438&avatarType=issuetype",
|
||||
"name": "Task",
|
||||
"subtask": false,
|
||||
"avatarId": 16438
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"assignee": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/user?username=s.zaigraev",
|
||||
"name": "s.zaigraev",
|
||||
"key": "s.zaigraev",
|
||||
"emailAddress": "s.zaigraev@crazypandagames.com",
|
||||
"avatarUrls": {
|
||||
"48x48": "https://jira.crazypanda.ru/secure/useravatar?ownerId=s.zaigraev&avatarId=35220",
|
||||
"24x24": "https://jira.crazypanda.ru/secure/useravatar?size=small&ownerId=s.zaigraev&avatarId=35220",
|
||||
"16x16": "https://jira.crazypanda.ru/secure/useravatar?size=xsmall&ownerId=s.zaigraev&avatarId=35220",
|
||||
"32x32": "https://jira.crazypanda.ru/secure/useravatar?size=medium&ownerId=s.zaigraev&avatarId=35220"
|
||||
},
|
||||
"displayName": "Заиграев Сергей",
|
||||
"active": true,
|
||||
"timeZone": "Europe/Moscow"
|
||||
},
|
||||
"customfield_46702": null,
|
||||
"customfield_46701": null,
|
||||
"customfield_42901": null,
|
||||
"customfield_42900": null,
|
||||
"customfield_23601": null,
|
||||
"customfield_19300": null,
|
||||
"customfield_44300": null,
|
||||
"components": [],
|
||||
"customfield_44301": null,
|
||||
"customfield_46600": null,
|
||||
"customfield_17003": null,
|
||||
"customfield_17002": null,
|
||||
"customfield_17001": null,
|
||||
"customfield_17000": null,
|
||||
"customfield_15500": null,
|
||||
"customfield_17004": null,
|
||||
"customfield_15503": null,
|
||||
"customfield_15501": null,
|
||||
"customfield_45502": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/customFieldOption/20613",
|
||||
"value": "2",
|
||||
"id": "20613",
|
||||
"disabled": false
|
||||
},
|
||||
"customfield_45400": null,
|
||||
"customfield_43100": null,
|
||||
"customfield_47701": null,
|
||||
"customfield_43101": null,
|
||||
"customfield_18201": null,
|
||||
"customfield_43102": null,
|
||||
"customfield_43103": null,
|
||||
"subtasks": [],
|
||||
"customfield_39400": null,
|
||||
"customfield_14400": null,
|
||||
"reporter": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/user?username=n.andreenko",
|
||||
"name": "n.andreenko",
|
||||
"key": "JIRAUSER64600",
|
||||
"emailAddress": "n.andreenko@crazypandagames.com",
|
||||
"avatarUrls": {
|
||||
"48x48": "https://jira.crazypanda.ru/secure/useravatar?avatarId=27235",
|
||||
"24x24": "https://jira.crazypanda.ru/secure/useravatar?size=small&avatarId=27235",
|
||||
"16x16": "https://jira.crazypanda.ru/secure/useravatar?size=xsmall&avatarId=27235",
|
||||
"32x32": "https://jira.crazypanda.ru/secure/useravatar?size=medium&avatarId=27235"
|
||||
},
|
||||
"displayName": "Андреенко Наталья",
|
||||
"active": true,
|
||||
"timeZone": "Europe/Moscow"
|
||||
},
|
||||
"customfield_14401": null,
|
||||
"customfield_16700": null,
|
||||
"customfield_40507": null,
|
||||
"progress": {
|
||||
"progress": 0,
|
||||
"total": 0
|
||||
},
|
||||
"votes": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issue/WPCMU-9082/votes",
|
||||
"votes": 0,
|
||||
"hasVoted": false
|
||||
},
|
||||
"worklog": {
|
||||
"startAt": 0,
|
||||
"maxResults": 20,
|
||||
"total": 0,
|
||||
"worklogs": []
|
||||
},
|
||||
"customfield_44303": null,
|
||||
"customfield_42802": null,
|
||||
"customfield_44305": null,
|
||||
"customfield_42801": null,
|
||||
"customfield_46500": null,
|
||||
"issuetype": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issuetype/3",
|
||||
"id": "3",
|
||||
"description": "A task that needs to be done.",
|
||||
"iconUrl": "https://jira.crazypanda.ru/secure/viewavatar?size=xsmall&avatarId=16438&avatarType=issuetype",
|
||||
"name": "Task",
|
||||
"subtask": false,
|
||||
"avatarId": 16438
|
||||
},
|
||||
"customfield_19400": null,
|
||||
"customfield_46502": null,
|
||||
"customfield_19401": null,
|
||||
"customfield_46501": null,
|
||||
"project": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/project/15550",
|
||||
"id": "15550",
|
||||
"key": "WPCMU",
|
||||
"name": "World Poker Club Unity Mobile",
|
||||
"projectTypeKey": "software",
|
||||
"avatarUrls": {
|
||||
"48x48": "https://jira.crazypanda.ru/secure/projectavatar?avatarId=27223",
|
||||
"24x24": "https://jira.crazypanda.ru/secure/projectavatar?size=small&avatarId=27223",
|
||||
"16x16": "https://jira.crazypanda.ru/secure/projectavatar?size=xsmall&avatarId=27223",
|
||||
"32x32": "https://jira.crazypanda.ru/secure/projectavatar?size=medium&avatarId=27223"
|
||||
}
|
||||
},
|
||||
"customfield_15602": null,
|
||||
"customfield_17900": null,
|
||||
"customfield_15600": null,
|
||||
"customfield_15601": null,
|
||||
"customfield_34401": null,
|
||||
"customfield_16804": null,
|
||||
"customfield_16803": null,
|
||||
"resolutiondate": "2025-08-29T17:43:43.998+0300",
|
||||
"customfield_41600": null,
|
||||
"customfield_43901": null,
|
||||
"customfield_22801": null,
|
||||
"customfield_43104": null,
|
||||
"customfield_43105": [],
|
||||
"customfield_43106": null,
|
||||
"customfield_43107": null,
|
||||
"customfield_45300": null,
|
||||
"watches": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issue/WPCMU-9082/watchers",
|
||||
"watchCount": 0,
|
||||
"isWatching": false
|
||||
},
|
||||
"customfield_18300": null,
|
||||
"customfield_18301": null,
|
||||
"customfield_16002": null,
|
||||
"customfield_16001": null,
|
||||
"customfield_16000": null,
|
||||
"customfield_49102": 8135645407,
|
||||
"customfield_39300": null,
|
||||
"customfield_49103": "shortFormatDuration({issue.cf49102})",
|
||||
"customfield_49100": "2025-05-27T13:49:38.366+0300",
|
||||
"customfield_16005": 100.0,
|
||||
"customfield_16004": null,
|
||||
"customfield_16003": null,
|
||||
"customfield_18302": null,
|
||||
"customfield_16802": null,
|
||||
"customfield_16801": null,
|
||||
"customfield_14500": null,
|
||||
"customfield_46507": null,
|
||||
"customfield_46509": null,
|
||||
"customfield_46504": null,
|
||||
"customfield_46503": null,
|
||||
"updated": "2025-10-07T17:12:22.404+0300",
|
||||
"customfield_23401": null,
|
||||
"customfield_46506": null,
|
||||
"customfield_46505": null,
|
||||
"customfield_23203": null,
|
||||
"customfield_46400": null,
|
||||
"customfield_23202": null,
|
||||
"customfield_23201": null,
|
||||
"customfield_19500": null,
|
||||
"customfield_17200": null,
|
||||
"timeoriginalestimate": null,
|
||||
"description": "Ссылка на локу: WPCMU-9682",
|
||||
"customfield_11100": null,
|
||||
"customfield_15701": null,
|
||||
"timetracking": {},
|
||||
"customfield_15700": null,
|
||||
"customfield_10005": null,
|
||||
"customfield_41500": null,
|
||||
"summary": "GD: SLOTS-2",
|
||||
"customfield_47500": [],
|
||||
"customfield_47501": [],
|
||||
"customfield_16100": null,
|
||||
"customfield_45200": null,
|
||||
"customfield_49000": null,
|
||||
"customfield_18401": null,
|
||||
"customfield_18402": null,
|
||||
"customfield_16900": null,
|
||||
"customfield_18403": null,
|
||||
"customfield_18404": null,
|
||||
"customfield_14600": null,
|
||||
"duedate": "2025-06-06",
|
||||
"customfield_44902": null,
|
||||
"comment": {
|
||||
"comments": [
|
||||
{
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/issue/494944/comment/598612",
|
||||
"id": "598612",
|
||||
"author": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/user?username=s.zaigraev",
|
||||
"name": "s.zaigraev",
|
||||
"key": "s.zaigraev",
|
||||
"emailAddress": "s.zaigraev@crazypandagames.com",
|
||||
"avatarUrls": {
|
||||
"48x48": "https://jira.crazypanda.ru/secure/useravatar?ownerId=s.zaigraev&avatarId=35220",
|
||||
"24x24": "https://jira.crazypanda.ru/secure/useravatar?size=small&ownerId=s.zaigraev&avatarId=35220",
|
||||
"16x16": "https://jira.crazypanda.ru/secure/useravatar?size=xsmall&ownerId=s.zaigraev&avatarId=35220",
|
||||
"32x32": "https://jira.crazypanda.ru/secure/useravatar?size=medium&ownerId=s.zaigraev&avatarId=35220"
|
||||
},
|
||||
"displayName": "Заиграев Сергей",
|
||||
"active": true,
|
||||
"timeZone": "Europe/Moscow"
|
||||
},
|
||||
"body": "Наборы слотов: https://confluence.crazypanda.ru/pages/viewpage.action?pageId=1458307128\r\nСписок механик (см дочерние страницы): https://confluence.crazypanda.ru/pages/viewpage.action?pageId=1352728643",
|
||||
"updateAuthor": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/user?username=s.zaigraev",
|
||||
"name": "s.zaigraev",
|
||||
"key": "s.zaigraev",
|
||||
"emailAddress": "s.zaigraev@crazypandagames.com",
|
||||
"avatarUrls": {
|
||||
"48x48": "https://jira.crazypanda.ru/secure/useravatar?ownerId=s.zaigraev&avatarId=35220",
|
||||
"24x24": "https://jira.crazypanda.ru/secure/useravatar?size=small&ownerId=s.zaigraev&avatarId=35220",
|
||||
"16x16": "https://jira.crazypanda.ru/secure/useravatar?size=xsmall&ownerId=s.zaigraev&avatarId=35220",
|
||||
"32x32": "https://jira.crazypanda.ru/secure/useravatar?size=medium&ownerId=s.zaigraev&avatarId=35220"
|
||||
},
|
||||
"displayName": "Заиграев Сергей",
|
||||
"active": true,
|
||||
"timeZone": "Europe/Moscow"
|
||||
},
|
||||
"created": "2025-06-06T21:41:52.591+0300",
|
||||
"updated": "2025-06-06T21:41:52.591+0300"
|
||||
}
|
||||
],
|
||||
"maxResults": 1,
|
||||
"total": 1,
|
||||
"startAt": 0
|
||||
},
|
||||
"customfield_44903": null,
|
||||
"customfield_23301": null,
|
||||
"customfield_17300": null,
|
||||
"customfield_46300": null,
|
||||
"customfield_44001": null,
|
||||
"customfield_17302": null,
|
||||
"customfield_17301": null,
|
||||
"fixVersions": [],
|
||||
"customfield_19600": null,
|
||||
"customfield_38800": null,
|
||||
"customfield_13501": null,
|
||||
"customfield_14702": null,
|
||||
"customfield_14703": null,
|
||||
"customfield_41400": null,
|
||||
"customfield_43700": null,
|
||||
"customfield_45103": null,
|
||||
"customfield_47400": null,
|
||||
"customfield_45100": null,
|
||||
"customfield_10100": "9223372036854775807",
|
||||
"customfield_14700": null,
|
||||
"customfield_12400": null,
|
||||
"customfield_14701": null,
|
||||
"customfield_15902": null,
|
||||
"timeestimate": null,
|
||||
"versions": [],
|
||||
"customfield_40202": null,
|
||||
"customfield_40200": null,
|
||||
"customfield_42500": "{}",
|
||||
"customfield_40201": null,
|
||||
"status": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/status/5",
|
||||
"description": "Story has been released less then 1.5m ago.",
|
||||
"iconUrl": "https://jira.crazypanda.ru/images/icons/statuses/generic.png",
|
||||
"name": "Resolved",
|
||||
"id": "5",
|
||||
"statusCategory": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/statuscategory/3",
|
||||
"id": 3,
|
||||
"key": "done",
|
||||
"colorName": "green",
|
||||
"name": "Done"
|
||||
}
|
||||
},
|
||||
"customfield_40100": null,
|
||||
"customfield_15100": null,
|
||||
"customfield_46200": null,
|
||||
"customfield_15101": null,
|
||||
"customfield_15102": null,
|
||||
"customfield_17400": null,
|
||||
"customfield_38700": null,
|
||||
"customfield_19700": "{}",
|
||||
"aggregatetimeestimate": null,
|
||||
"customfield_14806": null,
|
||||
"customfield_45902": null,
|
||||
"customfield_45901": null,
|
||||
"customfield_47302": null,
|
||||
"creator": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/user?username=n.andreenko",
|
||||
"name": "n.andreenko",
|
||||
"key": "JIRAUSER64600",
|
||||
"emailAddress": "n.andreenko@crazypandagames.com",
|
||||
"avatarUrls": {
|
||||
"48x48": "https://jira.crazypanda.ru/secure/useravatar?avatarId=27235",
|
||||
"24x24": "https://jira.crazypanda.ru/secure/useravatar?size=small&avatarId=27235",
|
||||
"16x16": "https://jira.crazypanda.ru/secure/useravatar?size=xsmall&avatarId=27235",
|
||||
"32x32": "https://jira.crazypanda.ru/secure/useravatar?size=medium&avatarId=27235"
|
||||
},
|
||||
"displayName": "Андреенко Наталья",
|
||||
"active": true,
|
||||
"timeZone": "Europe/Moscow"
|
||||
},
|
||||
"customfield_41200": null,
|
||||
"customfield_47300": null,
|
||||
"customfield_45000": null,
|
||||
"customfield_47301": "<a href=\"https://confluence.crazypanda.ru/x/CgBgO\">https://confluence.crazypanda.ru/x/CgBgO</a>",
|
||||
"customfield_16300": null,
|
||||
"aggregateprogress": {
|
||||
"progress": 0,
|
||||
"total": 0
|
||||
},
|
||||
"customfield_39800": null,
|
||||
"customfield_18600": null,
|
||||
"customfield_10200": [
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@1f8ba4cd[id=673,rapidViewId=506,state=CLOSED,name=MOB -Team 1- Sprint 188,startDate=2025-05-27T12:57:00.000+03:00,endDate=2025-06-10T12:57:00.000+03:00,completeDate=2025-06-17T11:28:01.892+03:00,activatedDate=2025-05-27T12:57:24.826+03:00,sequence=674,goal=,autoStartStop=false]",
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@4e0528d1[id=688,rapidViewId=506,state=CLOSED,name=MOB -Team 1- Sprint 189,startDate=2025-06-16T11:29:00.000+03:00,endDate=2025-06-27T11:29:00.000+03:00,completeDate=2025-06-30T11:08:30.366+03:00,activatedDate=2025-06-17T11:30:29.377+03:00,sequence=688,goal=,autoStartStop=false]",
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@68f8b40a[id=692,rapidViewId=506,state=CLOSED,name=MOB -Team 1- Sprint 190,startDate=2025-06-30T11:07:00.000+03:00,endDate=2025-07-11T11:07:00.000+03:00,completeDate=2025-07-14T10:48:35.813+03:00,activatedDate=2025-06-30T11:08:56.638+03:00,sequence=692,goal=,autoStartStop=false]",
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@833683d[id=699,rapidViewId=506,state=CLOSED,name=MOB -Team 1- Sprint 191,startDate=2025-07-14T12:06:00.000+03:00,endDate=2025-07-25T12:06:00.000+03:00,completeDate=2025-07-28T11:15:06.279+03:00,activatedDate=2025-07-14T10:48:57.081+03:00,sequence=699,goal=,autoStartStop=false]",
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@196c70b9[id=714,rapidViewId=506,state=CLOSED,name=MOB -Team 1- Sprint 192,startDate=2025-07-28T11:14:00.000+03:00,endDate=2025-08-08T11:14:00.000+03:00,completeDate=2025-08-11T10:27:17.098+03:00,activatedDate=2025-07-28T11:16:18.567+03:00,sequence=714,goal=,autoStartStop=false]",
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@d6ac476[id=720,rapidViewId=506,state=CLOSED,name=MOB -Team 1- Sprint 193,startDate=2025-08-11T10:25:00.000+03:00,endDate=2025-08-22T10:25:00.000+03:00,completeDate=2025-08-25T17:31:38.356+03:00,activatedDate=2025-08-11T10:28:03.549+03:00,sequence=720,goal=,autoStartStop=false]"
|
||||
],
|
||||
"customfield_18601": null,
|
||||
"customfield_40900": [],
|
||||
"customfield_19002": null,
|
||||
"customfield_19003": null,
|
||||
"customfield_19004": null,
|
||||
"customfield_40000": null,
|
||||
"customfield_46100": null,
|
||||
"customfield_40001": null,
|
||||
"customfield_19000": null,
|
||||
"customfield_46102": null,
|
||||
"timespent": null,
|
||||
"customfield_19001": null,
|
||||
"customfield_46101": null,
|
||||
"customfield_15200": null,
|
||||
"customfield_17500": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/customFieldOption/13601",
|
||||
"value": "No",
|
||||
"id": "13601",
|
||||
"disabled": false
|
||||
},
|
||||
"customfield_38601": null,
|
||||
"aggregatetimespent": null,
|
||||
"customfield_13700": null,
|
||||
"customfield_19800": null,
|
||||
"customfield_10302": null,
|
||||
"customfield_43505": null,
|
||||
"workratio": -1,
|
||||
"customfield_43506": null,
|
||||
"customfield_43507": null,
|
||||
"customfield_45800": null,
|
||||
"customfield_43500": null,
|
||||
"customfield_45801": null,
|
||||
"customfield_43503": null,
|
||||
"customfield_49500": null,
|
||||
"customfield_47202": "<span style=\"color: #ff6600;\">Привет, ты редактируешь Request во внутренние сервисы.<br>\r\nСвободно оставляй тут вопросы, просьбу помощи, баг, запрос на фичу. <br>\r\nПодробное описание, как это работает смотри <a href=\"https://confluence.crazypanda.ru/pages/viewpage.action?pageId=945815562\">тут</a></span></p>",
|
||||
"created": "2025-05-19T13:10:05.955+0300",
|
||||
"customfield_47201": null,
|
||||
"customfield_16402": null,
|
||||
"customfield_16401": null,
|
||||
"customfield_16400": null,
|
||||
"customfield_39700": null,
|
||||
"customfield_10300": null,
|
||||
"customfield_10301": null,
|
||||
"customfield_18701": null,
|
||||
"customfield_13800": null,
|
||||
"customfield_42303": null,
|
||||
"customfield_44600": null,
|
||||
"customfield_44500": null,
|
||||
"customfield_46001": null,
|
||||
"customfield_46000": null,
|
||||
"customfield_46003": null,
|
||||
"customfield_19100": null,
|
||||
"customfield_46002": null,
|
||||
"customfield_17601": null,
|
||||
"customfield_17600": null,
|
||||
"customfield_13000": null,
|
||||
"customfield_38500": null,
|
||||
"customfield_15300": null,
|
||||
"customfield_38501": null,
|
||||
"customfield_38502": null,
|
||||
"customfield_19900": "{}",
|
||||
"attachment": [],
|
||||
"customfield_45700": null,
|
||||
"customfield_41900": null,
|
||||
"customfield_49405": null,
|
||||
"customfield_46810": null,
|
||||
"customfield_43300": null,
|
||||
"customfield_49403": null,
|
||||
"customfield_24902": null,
|
||||
"customfield_24903": null,
|
||||
"customfield_47100": null,
|
||||
"customfield_41000": null,
|
||||
"customfield_49400": null,
|
||||
"customfield_16501": null,
|
||||
"customfield_18801": null,
|
||||
"customfield_16500": null,
|
||||
"customfield_18802": null,
|
||||
"customfield_18800": null,
|
||||
"customfield_13900": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/customFieldOption/11301",
|
||||
"value": "нет",
|
||||
"id": "11301",
|
||||
"disabled": false
|
||||
},
|
||||
"customfield_13902": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/customFieldOption/11307",
|
||||
"value": "нет",
|
||||
"id": "11307",
|
||||
"disabled": false
|
||||
},
|
||||
"customfield_13901": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/customFieldOption/11304",
|
||||
"value": "нет",
|
||||
"id": "11304",
|
||||
"disabled": false
|
||||
},
|
||||
"customfield_46809": null,
|
||||
"customfield_13904": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/customFieldOption/11314",
|
||||
"value": "Не требуется",
|
||||
"id": "11314",
|
||||
"disabled": false
|
||||
},
|
||||
"customfield_46808": null,
|
||||
"customfield_13903": {
|
||||
"self": "https://jira.crazypanda.ru/rest/api/2/customFieldOption/11310",
|
||||
"value": "нет",
|
||||
"id": "11310",
|
||||
"disabled": false
|
||||
},
|
||||
"customfield_46804": null,
|
||||
"customfield_46807": null,
|
||||
"customfield_46806": null,
|
||||
"customfield_42202": null,
|
||||
"customfield_46801": null,
|
||||
"customfield_46800": null,
|
||||
"customfield_46803": null,
|
||||
"customfield_46802": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
# Integration Plan: Parent Story and Story Points for Global Tasks
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the plan to enhance the `fetch_all_tasks_with_due_dates()` method in `client_jira.py` to include parent story information and story points for each task. This enhancement applies **only** to global tasks that appear in the Tasks report table, not to individual assignee-specific tasks. The enhancement will include a caching mechanism to optimize performance when multiple tasks belong to the same parent story.
|
||||
|
||||
## Scope Clarification
|
||||
|
||||
**Included in this enhancement:**
|
||||
- Global tasks fetched via `fetch_all_tasks_with_due_dates()`
|
||||
- Tasks that appear in the Tasks report table
|
||||
- Parent story key and storypoints extraction
|
||||
|
||||
**Excluded from this enhancement:**
|
||||
- Individual assignee-specific tasks
|
||||
- Tasks fetched via other methods
|
||||
- Other report types
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Current Data Flow
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Current Implementation"
|
||||
A[fetch_all_tasks_with_due_dates] --> B[_make_search_request]
|
||||
B --> C[JIRA API]
|
||||
C --> D[Task Data]
|
||||
D --> E[format_task_data]
|
||||
E --> F[Report Generation]
|
||||
F --> G[Spreadsheet Export]
|
||||
end
|
||||
|
||||
subgraph "Current Task Data Structure"
|
||||
T1[issue]
|
||||
T2[start]
|
||||
T3[resolved]
|
||||
T4[due_date]
|
||||
T5[labels]
|
||||
T6[responsible]
|
||||
end
|
||||
```
|
||||
|
||||
### Current Task Data Structure
|
||||
The current task data includes:
|
||||
- issue: Task key
|
||||
- start: Creation date
|
||||
- resolved: Resolution date
|
||||
- due_date: Due date
|
||||
- labels: Task labels
|
||||
- responsible: Responsible person
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Enhanced Data Flow
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Enhanced Implementation"
|
||||
A[fetch_all_tasks_with_due_dates] --> B[_make_search_request]
|
||||
B --> C[JIRA API]
|
||||
C --> D[Task Data]
|
||||
D --> E[Process Parent Stories]
|
||||
E --> F{Parent Story Cache}
|
||||
F -->|Cache Hit| G[Use Cached Data]
|
||||
F -->|Cache Miss| H[Fetch Parent Story]
|
||||
H --> I[Update Cache]
|
||||
I --> J[Merge Task + Story Data]
|
||||
G --> J
|
||||
J --> K[format_task_data]
|
||||
K --> L[Report Generation]
|
||||
L --> M[Spreadsheet Export]
|
||||
end
|
||||
|
||||
subgraph "Enhanced Task Data Structure"
|
||||
T1[issue]
|
||||
T2[start]
|
||||
T3[resolved]
|
||||
T4[due_date]
|
||||
T5[labels]
|
||||
T6[responsible]
|
||||
T7[parent_story]
|
||||
T8[storypoints]
|
||||
end
|
||||
```
|
||||
|
||||
### Parent Story Link Structure
|
||||
Based on the example JSON (`WPCMU-9082.json`), the parent story information is located in:
|
||||
- Path: `fields.issuelinks`
|
||||
- Filter: `type.inward = "Parent task"`
|
||||
- Parent Key: `inwardIssue.key`
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
|
||||
#### 1.1 Create Parent Story Cache Manager
|
||||
**File**: `src/api/story_cache.py`
|
||||
|
||||
```python
|
||||
class StoryCache:
|
||||
"""
|
||||
Manages caching of parent story data to optimize API calls
|
||||
when multiple tasks belong to the same parent story.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = {} # {story_key: storypoints}
|
||||
|
||||
def get(self, story_key: str) -> Optional[int]:
|
||||
"""Get storypoints from cache"""
|
||||
|
||||
def set(self, story_key: str, storypoints: int) -> None:
|
||||
"""Set storypoints in cache"""
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cache"""
|
||||
```
|
||||
|
||||
#### 1.2 Create Parent Story Fetcher
|
||||
**File**: `src/api/client_jira.py`
|
||||
|
||||
```python
|
||||
def _fetch_parent_story_data(jira_url: str, bearer_token: str, story_key: str) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches parent story data including storypoints
|
||||
|
||||
Args:
|
||||
jira_url: URL Jira API
|
||||
bearer_token: Bearer токен для аутентификации
|
||||
story_key: Key of the parent story
|
||||
|
||||
Returns:
|
||||
Dictionary with story data or None if error
|
||||
"""
|
||||
# Implementation to fetch single story by key
|
||||
# Extract storypoints from customfield_10003
|
||||
# Return cached data structure
|
||||
```
|
||||
|
||||
### Phase 2: Integration with Task Fetching
|
||||
|
||||
#### 2.1 Modify fetch_all_tasks_with_due_dates
|
||||
**File**: `src/api/client_jira.py`
|
||||
|
||||
```python
|
||||
def fetch_all_tasks_with_due_dates(
|
||||
jira_url: str,
|
||||
bearer_token: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
max_results: int = 50,
|
||||
story_cache: Optional[StoryCache] = None
|
||||
) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Enhanced version that includes parent story and storypoints data
|
||||
"""
|
||||
# Initialize cache if not provided
|
||||
# Fetch tasks as before
|
||||
# For each task:
|
||||
# - Extract parent story key from issuelinks
|
||||
# - Check cache for storypoints
|
||||
# - Fetch from API if not in cache
|
||||
# - Update cache
|
||||
# - Add parent_story and storypoints to task data
|
||||
```
|
||||
|
||||
#### 2.2 Update Task Data Structure
|
||||
**File**: `src/formatting/reports_data_formatter.py`
|
||||
|
||||
```python
|
||||
def format_task_data(task_issue: Dict, target_tz: Optional[ZoneInfo]) -> Dict:
|
||||
"""
|
||||
Enhanced version that includes parent story and storypoints
|
||||
"""
|
||||
# Extract parent story information
|
||||
parent_story = None
|
||||
storypoints = None
|
||||
|
||||
# Extract from issuelinks where type.inward = "Parent task"
|
||||
issuelinks = task_issue.get('fields', {}).get('issuelinks', [])
|
||||
for link in issuelinks:
|
||||
if link.get('type', {}).get('inward') == 'Parent task':
|
||||
parent_story = link.get('inwardIssue', {}).get('key')
|
||||
break
|
||||
|
||||
# Get storypoints from the enhanced task data
|
||||
storypoints = task_issue.get('parent_storypoints')
|
||||
|
||||
return {
|
||||
"issue": issue_key,
|
||||
"start": formatted_created_date,
|
||||
"resolved": formatted_resolution_date,
|
||||
"due_date": formatted_due_date,
|
||||
"labels": labels_str,
|
||||
"responsible": formatted_responsible,
|
||||
"parent_story": parent_story,
|
||||
"storypoints": storypoints
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Report Integration
|
||||
|
||||
#### 3.1 Update GlobalDataManager with Cache Pre-population
|
||||
**File**: `src/reports/report_generator.py`
|
||||
|
||||
```python
|
||||
class GlobalDataManager:
|
||||
def __init__(self, jira_url: str, bearer_token: str):
|
||||
self.jira_url = jira_url
|
||||
self.bearer_token = bearer_token
|
||||
self.cached_bugs = None
|
||||
self.cached_stories = None
|
||||
self.cached_tasks = None
|
||||
self.story_cache = StoryCache() # New cache manager
|
||||
|
||||
def _populate_story_cache(self, stories: List[Dict]) -> None:
|
||||
"""
|
||||
Pre-populates the story cache with all stories and their storypoints
|
||||
to optimize task processing later.
|
||||
|
||||
Args:
|
||||
stories: List of story dictionaries from JIRA API
|
||||
"""
|
||||
for story in stories:
|
||||
story_key = story.get('key')
|
||||
storypoints = story.get('fields', {}).get('customfield_10003')
|
||||
if story_key and storypoints is not None:
|
||||
self.story_cache.set(story_key, storypoints)
|
||||
|
||||
def fetch_global_data(self, start_date: str, end_date: str, max_results: int = 50) -> Dict:
|
||||
results = {}
|
||||
|
||||
# Get stories first and populate cache
|
||||
self.cached_stories = fetch_all_stories_with_due_dates(
|
||||
self.jira_url, self.bearer_token, start_date, end_date, max_results
|
||||
)
|
||||
results['stories'] = self.cached_stories or []
|
||||
|
||||
# Pre-populate cache with story data for optimization
|
||||
if self.cached_stories:
|
||||
self._populate_story_cache(self.cached_stories)
|
||||
|
||||
# Get bugs
|
||||
self.cached_bugs = fetch_all_bugs(
|
||||
self.jira_url, self.bearer_token, start_date, end_date, max_results
|
||||
)
|
||||
results['bugs'] = self.cached_bugs or []
|
||||
|
||||
# Get tasks with pre-populated cache
|
||||
self.cached_tasks = fetch_all_tasks_with_due_dates(
|
||||
self.jira_url, self.bearer_token, start_date, end_date, max_results, self.story_cache
|
||||
)
|
||||
results['tasks'] = self.cached_tasks or []
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
#### 3.2 Update Report Transformation
|
||||
**File**: `src/reports/transform_report_data.py`
|
||||
|
||||
```python
|
||||
def _transform_task_section(report_data):
|
||||
"""
|
||||
Enhanced version that includes storypoints
|
||||
"""
|
||||
transformed_data = []
|
||||
for task in report_data:
|
||||
transformed_data.append([
|
||||
task['issue'],
|
||||
task['start'],
|
||||
task['resolved'],
|
||||
task['due_date'],
|
||||
task['labels'],
|
||||
task['responsible'],
|
||||
task['parent_story'], # New field
|
||||
task['storypoints'] # New field
|
||||
])
|
||||
return transformed_data
|
||||
```
|
||||
|
||||
#### 3.3 Update Spreadsheet Formatter
|
||||
**File**: `src/formatting/spreadsheet_formatter.py`
|
||||
|
||||
```python
|
||||
def format_task_sheet(reports_data):
|
||||
"""
|
||||
Formats data for 'Tasks' sheet as a single long vertical list.
|
||||
Returns: List of lists for sheet data
|
||||
"""
|
||||
headers = ["Task", "Start", "Resolved", "Due Date", "Labels", "Responsible", "Parent Story", "Story Points"]
|
||||
return _format_single_list_sheet_global_data(reports_data['global_tasks'], headers)
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Parent Story Extraction Logic
|
||||
|
||||
```python
|
||||
def _extract_parent_story_key(task_data: Dict) -> Optional[str]:
|
||||
"""
|
||||
Extracts parent story key from task issuelinks
|
||||
|
||||
Args:
|
||||
task_data: Task data from JIRA API
|
||||
|
||||
Returns:
|
||||
Parent story key or None if not found
|
||||
"""
|
||||
issuelinks = task_data.get('fields', {}).get('issuelinks', [])
|
||||
|
||||
for link in issuelinks:
|
||||
link_type = link.get('type', {})
|
||||
if link_type.get('inward') == 'Parent task':
|
||||
inward_issue = link.get('inwardIssue')
|
||||
if inward_issue:
|
||||
return inward_issue.get('key')
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
Simple in-memory cache without expiration for maximum performance:
|
||||
|
||||
```python
|
||||
def _get_or_fetch_storypoints(
|
||||
story_key: str,
|
||||
jira_url: str,
|
||||
bearer_token: str,
|
||||
story_cache: StoryCache
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Gets storypoints from cache or fetches from API
|
||||
|
||||
Args:
|
||||
story_key: Parent story key
|
||||
jira_url: URL Jira API
|
||||
bearer_token: Bearer токен для аутентификации
|
||||
story_cache: Story cache instance
|
||||
|
||||
Returns:
|
||||
Storypoints value or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
cached_points = story_cache.get(story_key)
|
||||
if cached_points is not None:
|
||||
return cached_points
|
||||
|
||||
# Fetch from API
|
||||
story_data = _fetch_parent_story_data(jira_url, bearer_token, story_key)
|
||||
if story_data:
|
||||
storypoints = story_data.get('fields', {}).get('customfield_10003')
|
||||
story_cache.set(story_key, storypoints)
|
||||
return storypoints
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
**Cache Design:**
|
||||
- Simple dictionary-based storage: `{story_key: storypoints}`
|
||||
- No expiration policy (cache lasts for the duration of report generation)
|
||||
- Automatic clearing when GlobalDataManager is reset
|
||||
- Minimal memory footprint as only storypoints are stored
|
||||
|
||||
## Data Flow Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant ReportGen as Report Generator
|
||||
participant GlobalDM as GlobalDataManager
|
||||
participant JiraAPI as JIRA API
|
||||
participant StoryCache as Story Cache
|
||||
|
||||
Client->>ReportGen: Generate Report
|
||||
ReportGen->>GlobalDM: fetch_global_data
|
||||
|
||||
Note over GlobalDM: Phase 1: Fetch Stories and Pre-populate Cache
|
||||
GlobalDM->>JiraAPI: fetch_all_stories_with_due_dates
|
||||
JiraAPI-->>GlobalDM: Stories List
|
||||
GlobalDM->>StoryCache: Pre-populate with all story storypoints
|
||||
|
||||
Note over GlobalDM: Phase 2: Fetch Tasks with Optimized Cache
|
||||
GlobalDM->>JiraAPI: fetch_all_tasks_with_due_dates
|
||||
JiraAPI-->>GlobalDM: Task List
|
||||
|
||||
loop For Each Task
|
||||
GlobalDM->>GlobalDM: Extract Parent Story Key
|
||||
alt Parent Story Found
|
||||
GlobalDM->>StoryCache: get(story_key)
|
||||
alt Cache Hit (Optimized)
|
||||
StoryCache-->>GlobalDM: Cached Storypoints
|
||||
else Cache Miss (Rare)
|
||||
GlobalDM->>JiraAPI: _fetch_parent_story_data
|
||||
JiraAPI-->>GlobalDM: Story Data
|
||||
GlobalDM->>StoryCache: set(story_key, storypoints)
|
||||
end
|
||||
GlobalDM->>GlobalDM: Merge Task + Story Data
|
||||
end
|
||||
end
|
||||
|
||||
GlobalDM-->>ReportGen: Enhanced Task Data
|
||||
ReportGen-->>Client: Report with Storypoints
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### API Error Handling
|
||||
- Handle cases where parent story is not accessible
|
||||
- Handle cases where story doesn't exist
|
||||
- Implement retry logic for failed API calls
|
||||
- Log errors appropriately
|
||||
|
||||
### Cache Error Handling
|
||||
- Handle cache initialization failures
|
||||
- Implement cache size limits
|
||||
- Handle cache corruption scenarios
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching Benefits
|
||||
- Reduces API calls when multiple tasks share the same parent story
|
||||
- Improves response time for large task sets
|
||||
- Reduces load on JIRA API
|
||||
- **Cache pre-population optimization**: Stories are fetched before tasks, so most parent story data is already cached when processing tasks
|
||||
|
||||
### Cache Pre-population Strategy
|
||||
The implementation includes a key optimization: the cache is pre-populated with all stories and their storypoints before processing tasks. This provides significant benefits:
|
||||
|
||||
1. **Higher cache hit rate**: Since stories are fetched first, most parent stories referenced by tasks will already be in cache
|
||||
2. **Reduced API calls**: Minimizes additional API requests during task processing
|
||||
3. **Faster processing**: Tasks can be processed more efficiently with cached data
|
||||
|
||||
### Memory Management
|
||||
- Simple in-memory cache without expiration
|
||||
- Cache cleared when GlobalDataManager is reset
|
||||
- Minimal memory footprint as only storypoints are stored
|
||||
- Cache is populated once per report generation cycle
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create StoryCache class in `src/api/story_cache.py`
|
||||
2. Create parent story fetcher function in `src/api/client_jira.py`
|
||||
3. Modify `fetch_all_tasks_with_due_dates` to include parent story data
|
||||
4. Update `format_task_data` to include parent story and storypoints
|
||||
5. Update `_transform_task_section` to include new fields
|
||||
6. Update `format_task_sheet` to include new columns
|
||||
7. Update GlobalDataManager to use story cache with pre-population
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
The service runs in Docker Compose and can be tested by sending requests to the running service.
|
||||
|
||||
### Test Procedure
|
||||
1. Start the service with `docker compose up`
|
||||
2. Send a test request to generate a report:
|
||||
```bash
|
||||
curl -X POST "http://localhost:8055/create_report_in_spreadsheet" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"table_id": "1HRhWYzVql1cTv0mfGPd1B0qMXA5yD0xchirlfQJJjEo",
|
||||
"secret": "BRgln-IUjja-1kH6B-XONN9"
|
||||
}'
|
||||
```
|
||||
3. If the report generates successfully without errors, the implementation is working correctly
|
||||
4. The actual validation of the table data and storypoints will be performed manually
|
||||
@@ -0,0 +1,7 @@
|
||||
В данном проекте, в методе, который запирает все таски глобально fetch_all_tasks_with_due_dates() в client_jira.py необходимо добавить новый функционал. К каждой таске необходимо ещё получать дополнительную информацию – родительскую стори и сторипоинты родительской стори. Брать родительскую стори данной таски, из данной стори брать стори-поинты (stripoints), которые там есть, и добавлять потом к таске. При этом важно, поскольку тасок много, и некоторые из них принадлежат одной стори, то необходимо сделать кэширование. То есть мы берём, загружаем таску, лезем проверять её родительскую стори, забираем эти данные и кэшируем стори, чтобы в следующей, когда мы берём следующую таску, скорее всего, она попадёт в эту же стори. Тогда мы сначала проверяем кэш. В кеше достаточно хранить только словарь где ключи стори, а значение – сторипоинты. Если в кэше нет, тогда уже делаем запрос в жиру на получение родиельской стори и её сторипоинтов. То есть это нужно будет делать для каждой таски. Спроектируй архитектуру данных изменений и сохрани план работ в папке docs. Там же можно найти примеры планов от другого проекта. Необходимо аккуратно спроектировать данные изменения и аккуратно имплементировать во все места, где это может использоваться по флоу проекта. А ну и да, нужно будет в итоге в отчете добавить новое поле storypoints, по аналогии с отчетом, где собираются стори.
|
||||
|
||||
Информация о родительской таске находится в `fields.issuelinks`. Там список линков для данной таски. Нам нужна та у которой `type.inward == "Parent task"`. Там уже нужно найти "key" и это будет ключ на родительскую задачу. Забрать информацию по рдительской задаче и там найти сторипоинты. В проекте уже реализован сбор сторипоинтов. Если не указано, то поле пустое
|
||||
|
||||
Пример полной выдачи информации по таски от жиры в `docs\WPCMU-9082.json`
|
||||
|
||||
ВАЖНО! Всегда думай и отвечай на английском!
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для миграции с старой структуры проекта на новую.
|
||||
Этот скрипт помогает в процессе перехода на новую архитектуру проекта.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def backup_old_structure():
|
||||
"""Создание резервной копии старой структуры."""
|
||||
backup_dir = "backup_old_structure"
|
||||
|
||||
if os.path.exists(backup_dir):
|
||||
print(f"Директория {backup_dir} уже существует. Пропускаем создание резервной копии.")
|
||||
return
|
||||
|
||||
print("Создание резервной копии старой структуры...")
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
# Список файлов для резервного копирования
|
||||
files_to_backup = [
|
||||
"app/__init__.py",
|
||||
"app/async_tasks.py",
|
||||
"app/audio_processor.py",
|
||||
"app/audio_sources.py",
|
||||
"app/audio_utils.py",
|
||||
"app/cache.py",
|
||||
"app/context_managers.py",
|
||||
"app/file_manager.py",
|
||||
"app/history_logger.py",
|
||||
"app/logging_config.py",
|
||||
"app/request_logger.py",
|
||||
"app/routes.py",
|
||||
"app/transcriber_service.py",
|
||||
"app/transcriber.py",
|
||||
"app/utils.py",
|
||||
"app/validators.py"
|
||||
]
|
||||
|
||||
for file_path in files_to_backup:
|
||||
if os.path.exists(file_path):
|
||||
backup_path = os.path.join(backup_dir, file_path)
|
||||
os.makedirs(os.path.dirname(backup_path), exist_ok=True)
|
||||
shutil.copy2(file_path, backup_path)
|
||||
print(f"Скопирован файл: {file_path} -> {backup_path}")
|
||||
|
||||
print("Резервная копия создана успешно.")
|
||||
|
||||
def check_new_structure():
|
||||
"""Проверка наличия новой структуры."""
|
||||
required_dirs = [
|
||||
"app/api",
|
||||
"app/core",
|
||||
"app/audio",
|
||||
"app/infrastructure",
|
||||
"app/infrastructure/logging",
|
||||
"app/infrastructure/storage",
|
||||
"app/infrastructure/async_tasks",
|
||||
"app/infrastructure/validation",
|
||||
"app/shared"
|
||||
]
|
||||
|
||||
missing_dirs = []
|
||||
|
||||
for dir_path in required_dirs:
|
||||
if not os.path.exists(dir_path):
|
||||
missing_dirs.append(dir_path)
|
||||
|
||||
if missing_dirs:
|
||||
print("Отсутствуют следующие директории:")
|
||||
for dir_path in missing_dirs:
|
||||
print(f" - {dir_path}")
|
||||
print("Пожалуйста, сначала создайте новую структуру директорий.")
|
||||
return False
|
||||
|
||||
print("Новая структура директорий присутствует.")
|
||||
return True
|
||||
|
||||
def remove_old_files():
|
||||
"""Удаление старых файлов после успешной миграции."""
|
||||
old_files = [
|
||||
"app/async_tasks.py",
|
||||
"app/audio_processor.py",
|
||||
"app/audio_sources.py",
|
||||
"app/audio_utils.py",
|
||||
"app/cache.py",
|
||||
"app/context_managers.py",
|
||||
"app/file_manager.py",
|
||||
"app/history_logger.py",
|
||||
"app/logging_config.py",
|
||||
"app/request_logger.py",
|
||||
"app/routes.py",
|
||||
"app/transcriber_service.py",
|
||||
"app/transcriber.py",
|
||||
"app/utils.py",
|
||||
"app/validators.py"
|
||||
]
|
||||
|
||||
print("\nВНИМАНИЕ: Вы собираетесь удалить старые файлы.")
|
||||
print("Убедитесь, что новая структура работает корректно.")
|
||||
response = input("Продолжить удаление старых файлов? (y/n): ")
|
||||
|
||||
if response.lower() != 'y':
|
||||
print("Удаление отменено.")
|
||||
return
|
||||
|
||||
for file_path in old_files:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
print(f"Удален файл: {file_path}")
|
||||
|
||||
print("Старые файлы удалены.")
|
||||
|
||||
def create_migration_report():
|
||||
"""Создание отчета о миграции."""
|
||||
report = {
|
||||
"migration_status": "completed",
|
||||
"old_structure_backed_up": True,
|
||||
"new_structure_created": True,
|
||||
"files_migrated": [
|
||||
"app/__init__.py -> app/__init__.py (обновлен)",
|
||||
"app/async_tasks.py -> app/infrastructure/async_tasks/manager.py",
|
||||
"app/audio_processor.py -> app/audio/processor.py",
|
||||
"app/audio_sources.py -> app/audio/sources.py",
|
||||
"app/audio_utils.py -> app/audio/utils.py",
|
||||
"app/cache.py -> app/infrastructure/storage/cache.py",
|
||||
"app/context_managers.py -> app/shared/context_managers.py",
|
||||
"app/file_manager.py -> app/infrastructure/storage/file_manager.py",
|
||||
"app/history_logger.py -> app/shared/history_logger.py",
|
||||
"app/logging_config.py -> app/infrastructure/logging/config.py",
|
||||
"app/request_logger.py -> app/infrastructure/logging/request_logger.py",
|
||||
"app/routes.py -> app/api/routes.py",
|
||||
"app/transcriber_service.py -> app/core/transcription_service.py",
|
||||
"app/transcriber.py -> app/core/transcriber.py",
|
||||
"app/utils.py -> app/shared/decorators.py",
|
||||
"app/validators.py -> app/infrastructure/validation/validators.py"
|
||||
],
|
||||
"new_directories": [
|
||||
"app/api/",
|
||||
"app/core/",
|
||||
"app/audio/",
|
||||
"app/infrastructure/",
|
||||
"app/infrastructure/logging/",
|
||||
"app/infrastructure/storage/",
|
||||
"app/infrastructure/async_tasks/",
|
||||
"app/infrastructure/validation/",
|
||||
"app/shared/"
|
||||
]
|
||||
}
|
||||
|
||||
with open("migration_report.json", "w", encoding="utf-8") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print("Отчет о миграции сохранен в файл migration_report.json")
|
||||
|
||||
def main():
|
||||
"""Основная функция миграции."""
|
||||
print("=== Скрипт миграции на новую структуру проекта ===\n")
|
||||
|
||||
# Шаг 1: Создание резервной копии
|
||||
backup_old_structure()
|
||||
|
||||
# Шаг 2: Проверка новой структуры
|
||||
if not check_new_structure():
|
||||
print("Миграция не может быть продолжена.")
|
||||
return
|
||||
|
||||
# Шаг 3: Создание отчета о миграции
|
||||
create_migration_report()
|
||||
|
||||
print("\n=== Миграция завершена ===")
|
||||
print("Новая структура проекта готова к использованию.")
|
||||
print("Резервная копия старой структуры находится в директории backup_old_structure/")
|
||||
print("\nРекомендации:")
|
||||
print("1. Протестируйте новую структуру, запустив приложение.")
|
||||
print("2. Убедитесь, что все функции работают корректно.")
|
||||
print("3. После успешного тестирования запустите скрипт повторно с флагом --cleanup для удаления старых файлов.")
|
||||
|
||||
# Проверка флага очистки
|
||||
import sys
|
||||
if "--cleanup" in sys.argv:
|
||||
remove_old_files()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"migration_status": "completed",
|
||||
"old_structure_backed_up": true,
|
||||
"new_structure_created": true,
|
||||
"files_migrated": [
|
||||
"app/__init__.py -> app/__init__.py (обновлен)",
|
||||
"app/async_tasks.py -> app/infrastructure/async_tasks/manager.py",
|
||||
"app/audio_processor.py -> app/audio/processor.py",
|
||||
"app/audio_sources.py -> app/audio/sources.py",
|
||||
"app/audio_utils.py -> app/audio/utils.py",
|
||||
"app/cache.py -> app/infrastructure/storage/cache.py",
|
||||
"app/context_managers.py -> app/shared/context_managers.py",
|
||||
"app/file_manager.py -> app/infrastructure/storage/file_manager.py",
|
||||
"app/history_logger.py -> app/shared/history_logger.py",
|
||||
"app/logging_config.py -> app/infrastructure/logging/config.py",
|
||||
"app/request_logger.py -> app/infrastructure/logging/request_logger.py",
|
||||
"app/routes.py -> app/api/routes.py",
|
||||
"app/transcriber_service.py -> app/core/transcription_service.py",
|
||||
"app/transcriber.py -> app/core/transcriber.py",
|
||||
"app/utils.py -> app/shared/decorators.py",
|
||||
"app/validators.py -> app/infrastructure/validation/validators.py"
|
||||
],
|
||||
"new_directories": [
|
||||
"app/api/",
|
||||
"app/core/",
|
||||
"app/audio/",
|
||||
"app/infrastructure/",
|
||||
"app/infrastructure/logging/",
|
||||
"app/infrastructure/storage/",
|
||||
"app/infrastructure/async_tasks/",
|
||||
"app/infrastructure/validation/",
|
||||
"app/shared/"
|
||||
]
|
||||
}
|
||||
+17
-10
@@ -1,13 +1,20 @@
|
||||
# Conda environment dependencies
|
||||
|
||||
# Linux FA2 from https://github.com/Dao-AILab/flash-attention/releases
|
||||
flash_attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.13'
|
||||
flash_attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.12'
|
||||
flash_attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.11'
|
||||
flash_attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp310-cp310-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.10'
|
||||
# Tourch
|
||||
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.13'
|
||||
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.12'
|
||||
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.11'
|
||||
|
||||
Flask==3.1
|
||||
waitress
|
||||
librosa
|
||||
transformers==4.49
|
||||
accelerate==1.4
|
||||
# Linux FA2 from https://github.com/Dao-AILab/flash-attention/releases
|
||||
flash_attn @ https://github.com/kingbri1/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu128torch2.7.0cxx11abiFALSE-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.13'
|
||||
flash_attn @ https://github.com/kingbri1/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu128torch2.7.0cxx11abiFALSE-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.12'
|
||||
flash_attn @ https://github.com/kingbri1/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu128torch2.7.0cxx11abiFALSE-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.11'
|
||||
|
||||
Flask==3.1.0
|
||||
flask-cors==4.0.0
|
||||
waitress==3.0.0
|
||||
scipy==1.13.1
|
||||
transformers==4.49.0
|
||||
accelerate==1.4.0
|
||||
python-magic==0.4.27
|
||||
flask-limiter==3.5.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Имя окружения conda
|
||||
CONDA_ENV_NAME="whisper-api-server"
|
||||
CONDA_ENV_NAME="whisper-api"
|
||||
|
||||
# Флаг обновления (по умолчанию false)
|
||||
UPDATE_ENV=false
|
||||
@@ -17,10 +17,14 @@ if ! command -v conda &> /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Флаг, указывающий, было ли создано новое окружение
|
||||
NEW_ENV_CREATED=false
|
||||
|
||||
# Создание окружения conda, если оно не существует
|
||||
if ! conda env list | grep -q "$CONDA_ENV_NAME"; then
|
||||
echo "Создание окружения conda: $CONDA_ENV_NAME"
|
||||
conda create -n "$CONDA_ENV_NAME" python=3.12 -y
|
||||
NEW_ENV_CREATED=true
|
||||
else
|
||||
echo "Окружение conda '$CONDA_ENV_NAME' уже существует."
|
||||
fi
|
||||
@@ -39,8 +43,8 @@ echo "Активация окружения conda: $CONDA_ENV_NAME"
|
||||
source $(dirname "$CONDA_PATH")/../etc/profile.d/conda.sh
|
||||
conda activate "$CONDA_ENV_NAME"
|
||||
|
||||
# Если флаг --update установлен, обновляем зависимости
|
||||
if [[ "$UPDATE_ENV" == true ]]; then
|
||||
# Установка зависимостей при первом создании окружения или при флаге --update
|
||||
if [[ "$NEW_ENV_CREATED" == true || "$UPDATE_ENV" == true ]]; then
|
||||
# Установка зависимостей из requirements.txt
|
||||
if [ -f "requirements.txt" ]; then
|
||||
echo "Установка зависимостей из requirements.txt"
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Имя окружения conda
|
||||
CONDA_ENV_NAME="whisper-merge"
|
||||
|
||||
# Директории для моделей
|
||||
MODEL_A="/mnt/cloud/llm/whisper/whisper-large-v3-russian"
|
||||
MODEL_B="/mnt/cloud/llm/whisper/whisper-large-v3-ru-podlodka"
|
||||
OUTPUT_DIR="/mnt/cloud/llm/whisper/whisper-large-v3-russian+podlodka"
|
||||
|
||||
# Флаг обновления (по умолчанию false)
|
||||
UPDATE_ENV=false
|
||||
|
||||
# Метод слияния (по умолчанию all)
|
||||
MERGE_METHOD="all"
|
||||
|
||||
# Дополнительные параметры (по умолчанию)
|
||||
SLERP_T=0.5
|
||||
TIES_DENSITY=0.8
|
||||
ENCODER_WEIGHTS="0.6,0.4"
|
||||
DECODER_WEIGHTS="0.4,0.6"
|
||||
DIRECT_ALPHA=0.5
|
||||
|
||||
# Проверка аргументов
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case $1 in
|
||||
--update) UPDATE_ENV=true ;;
|
||||
--method) MERGE_METHOD="$2"; shift ;;
|
||||
--slerp-t) SLERP_T="$2"; shift ;;
|
||||
--ties-density) TIES_DENSITY="$2"; shift ;;
|
||||
--encoder-weights) ENCODER_WEIGHTS="$2"; shift ;;
|
||||
--decoder-weights) DECODER_WEIGHTS="$2"; shift ;;
|
||||
--direct-alpha) DIRECT_ALPHA="$2"; shift ;;
|
||||
*) echo "⚠️ Неизвестный параметр: $1"; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Проверка наличия conda
|
||||
if ! command -v conda &> /dev/null; then
|
||||
echo "❌ Conda не установлен. Пожалуйста, установите conda и попробуйте снова."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Создание окружения conda, если оно не существует
|
||||
if ! conda env list | grep -q "$CONDA_ENV_NAME"; then
|
||||
echo "🔹 Создание окружения conda: $CONDA_ENV_NAME"
|
||||
conda create -n "$CONDA_ENV_NAME" python=3.12 -y
|
||||
else
|
||||
echo "✅ Окружение conda '$CONDA_ENV_NAME' уже существует."
|
||||
fi
|
||||
|
||||
# Получение пути к conda
|
||||
CONDA_PATH=$(which conda)
|
||||
|
||||
# Проверка, что путь к conda найден
|
||||
if [ -z "$CONDA_PATH" ]; then
|
||||
echo "❌ Не удалось найти путь к conda."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Активация окружения conda
|
||||
echo "🔹 Активация окружения conda: $CONDA_ENV_NAME"
|
||||
source $(dirname "$CONDA_PATH")/../etc/profile.d/conda.sh
|
||||
conda activate "$CONDA_ENV_NAME"
|
||||
|
||||
# Если флаг --update установлен, обновляем зависимости
|
||||
if [[ "$UPDATE_ENV" == true ]]; then
|
||||
echo "🔹 Установка и обновление зависимостей"
|
||||
pip install --upgrade pip wheel
|
||||
pip install torch torchaudio
|
||||
pip install transformers datasets evaluate accelerate
|
||||
pip install soundfile librosa tqdm
|
||||
fi
|
||||
|
||||
# Проверка установки torch и transformers
|
||||
if ! python -c "import torch, transformers" &> /dev/null; then
|
||||
echo "❌ PyTorch или Transformers не установлены. Запустите с опцией --update."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Создание временного Python-скрипта для слияния
|
||||
cat > merge_models.py << EOL
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import torch
|
||||
from transformers import WhisperForConditionalGeneration, WhisperProcessor
|
||||
from tqdm import tqdm
|
||||
|
||||
def direct_merge(model_a_path, model_b_path, output_path, alpha=0.5):
|
||||
"""Простое прямое слияние весов моделей с коэффициентом alpha"""
|
||||
print(f"🔹 Запуск прямого слияния с alpha={alpha}")
|
||||
|
||||
print(f"📂 Загрузка модели A из {model_a_path}")
|
||||
model_a = WhisperForConditionalGeneration.from_pretrained(model_a_path)
|
||||
|
||||
print(f"📂 Загрузка модели B из {model_b_path}")
|
||||
model_b = WhisperForConditionalGeneration.from_pretrained(model_b_path)
|
||||
|
||||
# Создаем новую модель для хранения объединенных весов
|
||||
print("🔄 Объединение весов моделей...")
|
||||
merged_model = WhisperForConditionalGeneration.from_pretrained(model_a_path)
|
||||
|
||||
# Подсчет общего количества параметров
|
||||
param_count = len(list(merged_model.named_parameters()))
|
||||
|
||||
# Объединение весов
|
||||
with torch.no_grad():
|
||||
for i, (param_name, param) in enumerate(tqdm(merged_model.named_parameters(), total=param_count, desc="Слияние параметров")):
|
||||
if param_name in model_a.state_dict() and param_name in model_b.state_dict():
|
||||
# Объединяем веса с учетом коэффициента альфа
|
||||
merged_param = alpha * model_a.state_dict()[param_name] + (1 - alpha) * model_b.state_dict()[param_name]
|
||||
param.copy_(merged_param)
|
||||
|
||||
# Создаем директорию для выходных данных, если она не существует
|
||||
os.makedirs(output_path, exist_ok=True)
|
||||
|
||||
# Сохраняем объединенную модель
|
||||
print(f"💾 Сохранение объединенной модели в {output_path}")
|
||||
merged_model.save_pretrained(output_path)
|
||||
|
||||
# Сохраняем процессор из модели A
|
||||
print("💾 Сохранение процессора")
|
||||
processor = WhisperProcessor.from_pretrained(model_a_path)
|
||||
processor.save_pretrained(output_path)
|
||||
|
||||
print("✅ Прямое слияние успешно завершено!")
|
||||
return merged_model
|
||||
|
||||
def slerp_weights(model_a, model_b, t):
|
||||
"""Выполняет SLERP (Spherical Linear Interpolation) между весами моделей"""
|
||||
# Получаем словари состояний моделей
|
||||
state_dict_a = model_a.state_dict()
|
||||
state_dict_b = model_b.state_dict()
|
||||
|
||||
# Создаем новый словарь для смешанных весов
|
||||
mixed_weights = {}
|
||||
|
||||
for key in tqdm(state_dict_a.keys(), desc="SLERP слияние"):
|
||||
if key in state_dict_b:
|
||||
# Получаем веса для текущего параметра
|
||||
weight_a = state_dict_a[key].float()
|
||||
weight_b = state_dict_b[key].float()
|
||||
|
||||
# Выполняем SLERP
|
||||
# Нормализуем веса
|
||||
norm_a = torch.norm(weight_a)
|
||||
norm_b = torch.norm(weight_b)
|
||||
|
||||
# Если норма близка к нулю, используем линейную интерполяцию
|
||||
if norm_a < 1e-6 or norm_b < 1e-6:
|
||||
mixed_weights[key] = (1 - t) * weight_a + t * weight_b
|
||||
continue
|
||||
|
||||
weight_a_normalized = weight_a / norm_a
|
||||
weight_b_normalized = weight_b / norm_b
|
||||
|
||||
# Вычисляем косинус угла между векторами
|
||||
cos_theta = torch.sum(weight_a_normalized * weight_b_normalized)
|
||||
cos_theta = torch.clamp(cos_theta, -1.0, 1.0)
|
||||
|
||||
# Вычисляем угол
|
||||
theta = torch.acos(cos_theta)
|
||||
|
||||
# Если угол близок к 0 или π, используем линейную интерполяцию
|
||||
if abs(theta) < 1e-6 or abs(theta - torch.pi) < 1e-6:
|
||||
mixed_weights[key] = (1 - t) * weight_a + t * weight_b
|
||||
else:
|
||||
# SLERP формула: (sin((1-t)*theta) / sin(theta)) * weight_a + (sin(t*theta) / sin(theta)) * weight_b
|
||||
sin_theta = torch.sin(theta)
|
||||
mixed_weights[key] = (torch.sin((1 - t) * theta) / sin_theta) * weight_a + (torch.sin(t * theta) / sin_theta) * weight_b
|
||||
|
||||
# Проверка на NaN
|
||||
if torch.isnan(mixed_weights[key]).any():
|
||||
# Если появились NaN, используем линейную интерполяцию
|
||||
mixed_weights[key] = (1 - t) * weight_a + t * weight_b
|
||||
|
||||
return mixed_weights
|
||||
|
||||
def slerp_merge(model_a_path, model_b_path, output_path, t=0.5):
|
||||
"""Слияние методом SLERP, реализованное напрямую без использования mergekit"""
|
||||
print(f"🔹 Запуск слияния методом SLERP с t={t}")
|
||||
|
||||
print(f"📂 Загрузка модели A из {model_a_path}")
|
||||
model_a = WhisperForConditionalGeneration.from_pretrained(model_a_path)
|
||||
|
||||
print(f"📂 Загрузка модели B из {model_b_path}")
|
||||
model_b = WhisperForConditionalGeneration.from_pretrained(model_b_path)
|
||||
|
||||
# Создаем новую модель для хранения смешанных весов
|
||||
print("🔄 Применение метода SLERP...")
|
||||
merged_model = WhisperForConditionalGeneration.from_pretrained(model_a_path)
|
||||
|
||||
# Получаем смешанные веса
|
||||
mixed_weights = slerp_weights(model_a, model_b, t)
|
||||
|
||||
# Загружаем смешанные веса в модель
|
||||
merged_model.load_state_dict(mixed_weights)
|
||||
|
||||
# Создаем директорию для выходных данных
|
||||
os.makedirs(output_path, exist_ok=True)
|
||||
|
||||
# Сохраняем объединенную модель
|
||||
print(f"💾 Сохранение объединенной модели в {output_path}")
|
||||
merged_model.save_pretrained(output_path)
|
||||
|
||||
# Сохраняем процессор из модели A
|
||||
processor = WhisperProcessor.from_pretrained(model_a_path)
|
||||
processor.save_pretrained(output_path)
|
||||
|
||||
print("✅ Слияние методом SLERP успешно завершено!")
|
||||
return merged_model
|
||||
|
||||
def ties_merge(model_a_path, model_b_path, output_path, density=0.8, encoder_weights=(0.6, 0.4), decoder_weights=(0.4, 0.6)):
|
||||
"""Слияние, реализованное по принципу TIES без использования mergekit"""
|
||||
print(f"🔹 Запуск слияния по принципу TIES с плотностью {density}")
|
||||
print(f" Веса кодировщика: {encoder_weights}, веса декодера: {decoder_weights}")
|
||||
|
||||
print(f"📂 Загрузка модели A из {model_a_path}")
|
||||
model_a = WhisperForConditionalGeneration.from_pretrained(model_a_path)
|
||||
|
||||
print(f"📂 Загрузка модели B из {model_b_path}")
|
||||
model_b = WhisperForConditionalGeneration.from_pretrained(model_b_path)
|
||||
|
||||
# Создаем новую модель для хранения смешанных весов
|
||||
print("🔄 Применение метода TIES...")
|
||||
merged_model = WhisperForConditionalGeneration.from_pretrained(model_a_path)
|
||||
|
||||
# Получаем словари состояний моделей
|
||||
state_dict_a = model_a.state_dict()
|
||||
state_dict_b = model_b.state_dict()
|
||||
|
||||
# Создаем словарь для смешанных весов
|
||||
mixed_weights = {}
|
||||
|
||||
# Для каждого параметра
|
||||
for key in tqdm(state_dict_a.keys(), desc="TIES слияние"):
|
||||
if key in state_dict_b:
|
||||
# Определение типа слоя (кодировщик или декодер)
|
||||
if "encoder" in key:
|
||||
w_a, w_b = encoder_weights
|
||||
elif "decoder" in key:
|
||||
w_a, w_b = decoder_weights
|
||||
else:
|
||||
# Для других параметров используем среднее
|
||||
w_a = (encoder_weights[0] + decoder_weights[0]) / 2
|
||||
w_b = (encoder_weights[1] + decoder_weights[1]) / 2
|
||||
|
||||
# Получаем веса для текущего параметра
|
||||
weight_a = state_dict_a[key]
|
||||
weight_b = state_dict_b[key]
|
||||
|
||||
# Вычисляем абсолютную разницу между весами
|
||||
abs_diff = torch.abs(weight_a - weight_b)
|
||||
|
||||
# Создаем маску для топ-k% элементов с наибольшей разницей
|
||||
# если density=0.8, то мы сохраняем 80% элементов с наибольшей разницей
|
||||
k = int((1 - density) * abs_diff.numel())
|
||||
if k > 0:
|
||||
threshold = torch.kthvalue(abs_diff.flatten(), k).values
|
||||
mask = abs_diff >= threshold
|
||||
else:
|
||||
mask = torch.ones_like(abs_diff, dtype=torch.bool)
|
||||
|
||||
# Создаем смешанные веса:
|
||||
# Для элементов с большой разницей используем веса w_a и w_b
|
||||
# Для остальных используем среднее арифметическое
|
||||
mixed = torch.zeros_like(weight_a)
|
||||
mixed[mask] = w_a * weight_a[mask] + w_b * weight_b[mask]
|
||||
mixed[~mask] = (weight_a[~mask] + weight_b[~mask]) / 2
|
||||
|
||||
mixed_weights[key] = mixed
|
||||
|
||||
# Загружаем смешанные веса в модель
|
||||
merged_model.load_state_dict(mixed_weights)
|
||||
|
||||
# Создаем директорию для выходных данных
|
||||
os.makedirs(output_path, exist_ok=True)
|
||||
|
||||
# Сохраняем объединенную модель
|
||||
print(f"💾 Сохранение объединенной модели в {output_path}")
|
||||
merged_model.save_pretrained(output_path)
|
||||
|
||||
# Сохраняем процессор из модели A
|
||||
processor = WhisperProcessor.from_pretrained(model_a_path)
|
||||
processor.save_pretrained(output_path)
|
||||
|
||||
print("✅ Слияние по принципу TIES успешно завершено!")
|
||||
return merged_model
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Объединение моделей Whisper методами SLERP и TIES")
|
||||
parser.add_argument("--model-a", default="$MODEL_A",
|
||||
help="Путь к первой модели")
|
||||
parser.add_argument("--model-b", default="$MODEL_B",
|
||||
help="Путь к второй модели")
|
||||
parser.add_argument("--output-dir", default="$OUTPUT_DIR",
|
||||
help="Директория для сохранения результатов")
|
||||
parser.add_argument("--method", choices=["slerp", "ties", "direct", "all"], default="$MERGE_METHOD",
|
||||
help="Метод слияния: slerp, ties, direct или all")
|
||||
|
||||
# Параметры для SLERP
|
||||
parser.add_argument("--slerp-t", type=float, default=$SLERP_T,
|
||||
help="Коэффициент интерполяции для SLERP (0-1)")
|
||||
|
||||
# Параметры для TIES
|
||||
parser.add_argument("--ties-density", type=float, default=$TIES_DENSITY,
|
||||
help="Плотность смешивания для TIES (0-1)")
|
||||
parser.add_argument("--encoder-weights", type=str, default="$ENCODER_WEIGHTS",
|
||||
help="Веса для кодировщика в формате '0.6,0.4'")
|
||||
parser.add_argument("--decoder-weights", type=str, default="$DECODER_WEIGHTS",
|
||||
help="Веса для декодера в формате '0.4,0.6'")
|
||||
|
||||
# Параметры для прямого слияния
|
||||
parser.add_argument("--direct-alpha", type=float, default=$DIRECT_ALPHA,
|
||||
help="Коэффициент альфа для прямого слияния (0-1)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Разбор весов для TIES
|
||||
encoder_weights = tuple(map(float, args.encoder_weights.split(',')))
|
||||
decoder_weights = tuple(map(float, args.decoder_weights.split(',')))
|
||||
|
||||
# Создание выходной директории
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
|
||||
# Выполнение слияния выбранным методом
|
||||
if args.method == "direct" or args.method == "all":
|
||||
direct_output = os.path.join(args.output_dir, "direct")
|
||||
direct_merge(args.model_a, args.model_b, direct_output, args.direct_alpha)
|
||||
|
||||
if args.method == "slerp" or args.method == "all":
|
||||
slerp_output = os.path.join(args.output_dir, "slerp")
|
||||
try:
|
||||
slerp_merge(args.model_a, args.model_b, slerp_output, args.slerp_t)
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при слиянии методом SLERP: {e}")
|
||||
print("⚠️ Используется прямое слияние вместо SLERP.")
|
||||
direct_merge(args.model_a, args.model_b, slerp_output, args.slerp_t)
|
||||
|
||||
if args.method == "ties" or args.method == "all":
|
||||
ties_output = os.path.join(args.output_dir, "ties")
|
||||
try:
|
||||
ties_merge(args.model_a, args.model_b, ties_output, args.ties_density, encoder_weights, decoder_weights)
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при слиянии методом TIES: {e}")
|
||||
print("⚠️ Используется прямое слияние вместо TIES.")
|
||||
alpha = (encoder_weights[0] + decoder_weights[0]) / 2
|
||||
direct_merge(args.model_a, args.model_b, ties_output, alpha)
|
||||
|
||||
print("\n✅ Все операции слияния успешно завершены!")
|
||||
print(f"📂 Результаты сохранены в {args.output_dir}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
EOL
|
||||
|
||||
# Запуск Python-скрипта
|
||||
python merge_models.py
|
||||
|
||||
# Удаление временного скрипта
|
||||
rm merge_models.py
|
||||
|
||||
echo ""
|
||||
echo "✅ Процесс слияния завершен. Результаты сохранены в $OUTPUT_DIR"
|
||||
@@ -1,288 +0,0 @@
|
||||
# Полное руководство по объединению моделей Whisper
|
||||
|
||||
## Содержание
|
||||
1. [Введение](#введение)
|
||||
2. [Установка и подготовка](#установка-и-подготовка)
|
||||
3. [Базовое использование](#базовое-использование)
|
||||
4. [Детальные настройки](#детальные-настройки)
|
||||
- [Методы слияния](#методы-слияния)
|
||||
- [Параметры для метода Direct](#параметры-для-метода-direct)
|
||||
- [Параметры для метода SLERP](#параметры-для-метода-slerp)
|
||||
- [Параметры для метода TIES](#параметры-для-метода-ties)
|
||||
5. [Практические рекомендации](#практические-рекомендации)
|
||||
6. [Тестирование и оценка](#тестирование-и-оценка)
|
||||
7. [Устранение неполадок](#устранение-неполадок)
|
||||
|
||||
## Введение
|
||||
|
||||
Скрипт `whisper_merge.sh` позволяет объединять две модели Whisper, обученные на разных датасетах, для получения комбинированной модели с улучшенными характеристиками. Особенно полезно для моделей, обученных на разных типах данных одного языка (например, разные диалекты русского языка, формальная/неформальная речь и т.д.).
|
||||
|
||||
## Установка и подготовка
|
||||
|
||||
1. **Убедитесь, что Conda установлена** в вашей системе.
|
||||
2. **Скачайте скрипт `whisper_merge.sh`** и сделайте его исполняемым:
|
||||
```bash
|
||||
chmod +x whisper_merge.sh
|
||||
```
|
||||
3. **Подготовьте модели для слияния**. По умолчанию скрипт ищет модели по следующим путям:
|
||||
- Модель A: `/mnt/cloud/llm/whisper/whisper-large-v3-russian`
|
||||
- Модель B: `/mnt/cloud/llm/whisper/whisper-large-v3-ru-podlodka`
|
||||
- Директория для результатов: `/mnt/cloud/llm/whisper/whisper-large-v3-russian+podlodka`
|
||||
|
||||
Пути можно изменить, отредактировав переменные `MODEL_A`, `MODEL_B` и `OUTPUT_DIR` в начале скрипта.
|
||||
|
||||
## Базовое использование
|
||||
|
||||
### Первый запуск
|
||||
|
||||
Для первого запуска рекомендуется использовать флаг `--update` для установки всех зависимостей:
|
||||
|
||||
```bash
|
||||
./whisper_merge.sh --update
|
||||
```
|
||||
|
||||
Это создаст окружение conda с именем `whisper-merge` и установит необходимые пакеты.
|
||||
|
||||
### Стандартное слияние
|
||||
|
||||
Для выполнения слияния с настройками по умолчанию:
|
||||
|
||||
```bash
|
||||
./whisper_merge.sh
|
||||
```
|
||||
|
||||
По умолчанию будут использованы следующие параметры:
|
||||
- Метод слияния: `all` (все методы)
|
||||
- SLERP t: `0.5`
|
||||
- TIES density: `0.8`
|
||||
- Encoder weights: `0.6,0.4`
|
||||
- Decoder weights: `0.4,0.6`
|
||||
- Direct alpha: `0.5`
|
||||
|
||||
## Детальные настройки
|
||||
|
||||
### Методы слияния
|
||||
|
||||
Скрипт поддерживает три метода слияния моделей:
|
||||
|
||||
1. **Direct** (`--method direct`) — простое линейное смешивание весов:
|
||||
```bash
|
||||
./whisper_merge.sh --method direct
|
||||
```
|
||||
- **Преимущества**: простота, надежность, меньшие вычислительные требования
|
||||
- **Недостатки**: может не сохранять геометрию пространства весов
|
||||
- **Когда использовать**: для быстрого тестирования или когда модели очень близки
|
||||
|
||||
2. **SLERP** (`--method slerp`) — сферическая линейная интерполяция:
|
||||
```bash
|
||||
./whisper_merge.sh --method slerp
|
||||
```
|
||||
- **Преимущества**: лучше сохраняет геометрию пространства весов
|
||||
- **Недостатки**: требует больше вычислений, может не работать корректно для некоторых моделей
|
||||
- **Когда использовать**: для моделей, где важно сохранение пространственных отношений между параметрами
|
||||
|
||||
3. **TIES** (`--method ties`) — алгоритм Task Induced Expert Selection:
|
||||
```bash
|
||||
./whisper_merge.sh --method ties
|
||||
```
|
||||
- **Преимущества**: позволяет индивидуально настраивать веса для кодировщика и декодера
|
||||
- **Недостатки**: более сложный, требует тонкой настройки параметров
|
||||
- **Когда использовать**: для продвинутого слияния, когда известно, что одна модель лучше работает с определенными аспектами (например, фонетикой или грамматикой)
|
||||
|
||||
4. **All** (`--method all`) — применяет все три метода и сохраняет результаты в отдельные подпапки:
|
||||
```bash
|
||||
./whisper_merge.sh --method all
|
||||
```
|
||||
- **Преимущества**: позволяет сравнить результаты всех методов
|
||||
- **Недостатки**: требует больше времени и дискового пространства
|
||||
- **Когда использовать**: при первоначальном тестировании для выбора оптимального метода
|
||||
|
||||
### Параметры для метода Direct
|
||||
|
||||
#### Direct Alpha (`--direct-alpha`)
|
||||
|
||||
Определяет вес первой модели в процессе слияния:
|
||||
|
||||
```bash
|
||||
./whisper_merge.sh --method direct --direct-alpha 0.7
|
||||
```
|
||||
|
||||
- **Диапазон**: от 0.0 до 1.0
|
||||
- **По умолчанию**: 0.5 (равный вес обеих моделей)
|
||||
- **Влияние**:
|
||||
- `0.0` — полностью использует веса второй модели (MODEL_B)
|
||||
- `0.5` — равный вклад обеих моделей
|
||||
- `1.0` — полностью использует веса первой модели (MODEL_A)
|
||||
- **Рекомендации**:
|
||||
- Если MODEL_A лучше справляется с задачей: увеличьте alpha (`0.6` — `0.8`)
|
||||
- Если MODEL_B лучше справляется с задачей: уменьшите alpha (`0.2` — `0.4`)
|
||||
- Для тонкой настройки изменяйте значение с шагом 0.05
|
||||
|
||||
### Параметры для метода SLERP
|
||||
|
||||
#### SLERP t (`--slerp-t`)
|
||||
|
||||
Определяет соотношение весов при сферической интерполяции:
|
||||
|
||||
```bash
|
||||
./whisper_merge.sh --method slerp --slerp-t 0.3
|
||||
```
|
||||
|
||||
- **Диапазон**: от 0.0 до 1.0
|
||||
- **По умолчанию**: 0.5 (равный вклад)
|
||||
- **Влияние**:
|
||||
- `0.0` — полностью вес на MODEL_A
|
||||
- `1.0` — полностью вес на MODEL_B
|
||||
- Промежуточные значения дают нелинейную интерполяцию между моделями
|
||||
- **Рекомендации**:
|
||||
- В отличие от direct alpha, здесь значение `0` соответствует MODEL_A, а `1` соответствует MODEL_B
|
||||
- SLERP обычно дает более гладкое слияние, чем direct
|
||||
- Для русских моделей обычно хорошо работают значения `0.4` — `0.6`
|
||||
|
||||
### Параметры для метода TIES
|
||||
|
||||
#### TIES Density (`--ties-density`)
|
||||
|
||||
Определяет долю параметров, которые будут смешаны индивидуально, а не усреднены:
|
||||
|
||||
```bash
|
||||
./whisper_merge.sh --method ties --ties-density 0.85
|
||||
```
|
||||
|
||||
- **Диапазон**: от 0.0 до 1.0
|
||||
- **По умолчанию**: 0.8
|
||||
- **Влияние**:
|
||||
- `0.0` — все параметры усредняются
|
||||
- `1.0` — все параметры смешиваются индивидуально по заданным весам
|
||||
- Промежуточные значения определяют процент параметров с наибольшей разницей, которые будут смешаны индивидуально
|
||||
- **Рекомендации**:
|
||||
- Более высокие значения (`0.8` — `0.9`) лучше сохраняют индивидуальные особенности каждой модели
|
||||
- Более низкие значения (`0.5` — `0.7`) дают более стабильный результат
|
||||
- Для русских моделей рекомендуется `0.85`, что позволяет сохранить уникальные особенности обеих моделей
|
||||
|
||||
#### Encoder Weights (`--encoder-weights`)
|
||||
|
||||
Определяет веса моделей для кодировщика (отвечает за обработку аудиосигнала):
|
||||
|
||||
```bash
|
||||
./whisper_merge.sh --method ties --encoder-weights "0.7,0.3"
|
||||
```
|
||||
|
||||
- **Формат**: два числа, разделенные запятой, сумма должна равняться 1.0
|
||||
- **По умолчанию**: "0.6,0.4"
|
||||
- **Влияние**:
|
||||
- Первое число — вес MODEL_A для кодировщика
|
||||
- Второе число — вес MODEL_B для кодировщика
|
||||
- **Рекомендации**:
|
||||
- Если MODEL_A лучше распознает фонетические особенности речи: увеличьте её вес (e.g., "0.7,0.3")
|
||||
- Если MODEL_B лучше распознает акценты или диалекты: увеличьте её вес (e.g., "0.4,0.6")
|
||||
- Для русских моделей, если одна из них лучше обрабатывает различные диалекты, дайте ей больший вес в кодировщике
|
||||
|
||||
#### Decoder Weights (`--decoder-weights`)
|
||||
|
||||
Определяет веса моделей для декодера (отвечает за формирование текста):
|
||||
|
||||
```bash
|
||||
./whisper_merge.sh --method ties --decoder-weights "0.3,0.7"
|
||||
```
|
||||
|
||||
- **Формат**: два числа, разделенные запятой, сумма должна равняться 1.0
|
||||
- **По умолчанию**: "0.4,0.6"
|
||||
- **Влияние**:
|
||||
- Первое число — вес MODEL_A для декодера
|
||||
- Второе число — вес MODEL_B для декодера
|
||||
- **Рекомендации**:
|
||||
- Если MODEL_A лучше справляется с грамматикой: увеличьте её вес (e.g., "0.6,0.4")
|
||||
- Если MODEL_B имеет лучший языковой контекст: увеличьте её вес (e.g., "0.3,0.7")
|
||||
- Для русских моделей, если одна модель лучше обрабатывает грамматические конструкции, дайте ей больший вес в декодере
|
||||
|
||||
## Практические рекомендации
|
||||
|
||||
### Для слияния русских моделей
|
||||
|
||||
1. **Общее правило**: начинайте с метода TIES с близкими к равным весами.
|
||||
|
||||
2. **Если модели обучены на разных типах данных**:
|
||||
```bash
|
||||
./whisper_merge.sh --method ties --ties-density 0.85 --encoder-weights "0.6,0.4" --decoder-weights "0.5,0.5"
|
||||
```
|
||||
Это даст больший приоритет первой модели в распознавании звуков, но обе модели будут равно влиять на грамматику.
|
||||
|
||||
3. **Если одна модель лучше с формальной речью, а другая с разговорной**:
|
||||
```bash
|
||||
./whisper_merge.sh --method ties --ties-density 0.9 --encoder-weights "0.5,0.5" --decoder-weights "0.6,0.4"
|
||||
```
|
||||
Высокая плотность (0.9) позволит сохранить уникальные особенности обеих моделей.
|
||||
|
||||
4. **Для моделей, где первая модель имеет высокое качество, но вторая содержит специфичные улучшения**:
|
||||
```bash
|
||||
./whisper_merge.sh --method slerp --slerp-t 0.3
|
||||
```
|
||||
Это сохранит основу первой модели (70%) и добавит 30% особенностей второй.
|
||||
|
||||
### Для наилучших результатов
|
||||
|
||||
Рекомендуется выполнить несколько экспериментов с разными настройками и сравнить результаты:
|
||||
|
||||
```bash
|
||||
# Эксперимент 1: TIES с приоритетом первой модели
|
||||
./whisper_merge.sh --method ties --ties-density 0.85 --encoder-weights "0.65,0.35" --decoder-weights "0.6,0.4"
|
||||
|
||||
# Эксперимент 2: SLERP с уклоном к первой модели
|
||||
./whisper_merge.sh --method slerp --slerp-t 0.4
|
||||
|
||||
# Эксперимент 3: Прямое слияние
|
||||
./whisper_merge.sh --method direct --direct-alpha 0.6
|
||||
```
|
||||
|
||||
## Тестирование и оценка
|
||||
|
||||
После слияния моделей важно оценить результаты:
|
||||
|
||||
1. **Подготовьте тестовый набор данных**, включающий:
|
||||
- Формальную речь
|
||||
- Разговорную речь
|
||||
- Различные диалекты
|
||||
- Различные шумовые условия
|
||||
- Различные темпы речи
|
||||
|
||||
2. **Выполните транскрипцию** с помощью объединенной модели и сравните с исходными моделями.
|
||||
|
||||
3. **Оцените результаты** по следующим критериям:
|
||||
- Word Error Rate (WER)
|
||||
- Character Error Rate (CER)
|
||||
- Обработка специфичных для русского языка особенностей (падежи, ударения)
|
||||
- Скорость обработки
|
||||
|
||||
## Устранение неполадок
|
||||
|
||||
### Проблемы со слиянием SLERP или TIES
|
||||
|
||||
Если возникают ошибки при использовании методов SLERP или TIES, скрипт автоматически откатывается к методу Direct. Для решения проблем:
|
||||
|
||||
1. **Проверьте совместимость моделей**:
|
||||
- Модели должны иметь одинаковую архитектуру
|
||||
- Версии Whisper должны быть совместимы
|
||||
|
||||
2. **Увеличьте объем памяти**:
|
||||
- SLERP и TIES требуют больше оперативной памяти
|
||||
- Попробуйте запустить на машине с большим объемом RAM
|
||||
|
||||
3. **Проверьте логи** для определения конкретной проблемы
|
||||
|
||||
### Проблемы с окружением Conda
|
||||
|
||||
Если возникают проблемы с созданием или активацией окружения:
|
||||
|
||||
1. **Обновите Conda**:
|
||||
```bash
|
||||
conda update -n base conda
|
||||
```
|
||||
|
||||
2. **Удалите и пересоздайте окружение**:
|
||||
```bash
|
||||
conda env remove -n whisper-merge
|
||||
./whisper_merge.sh --update
|
||||
```
|
||||
|
||||
3. **Проверьте пути к моделям** и убедитесь, что они корректны
|
||||
Reference in New Issue
Block a user