Remove dead code, backup files, and unused modules
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b28bc632bb
commit
64c5a53d5b
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git -C /Users/serge/Develop/nnp-whisper-api-server log --oneline -10 --all)",
|
||||||
|
"Bash(git -C /Users/serge/Develop/nnp-whisper-api-server log --oneline main..cleanup/dead-code)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Whisper API Server -- Project Bible
|
||||||
|
|
||||||
|
Local, OpenAI-compatible speech recognition API service using the Whisper model. Supports multiple audio input methods (file upload, URL, base64, local path), hardware acceleration (CUDA/MPS/CPU), audio preprocessing pipeline, and async transcription.
|
||||||
|
|
||||||
|
**Development rules and coding standards: see `RULES.md`**
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
* **Backend**: Python 3.12+, Flask, Waitress (WSGI). Entry: `server.py`.
|
||||||
|
* **ML**: PyTorch, Hugging Face Transformers (Whisper), Flash Attention 2.
|
||||||
|
* **Audio**: FFmpeg, SoX (external), scipy (resampling).
|
||||||
|
* **Validation**: python-magic (MIME detection).
|
||||||
|
* **Environment**: Conda. Setup: `server.sh`.
|
||||||
|
* **Language**: Code comments and docstrings in Russian (project convention).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
server.py # Entry point, argparse, launches WhisperServiceAPI
|
||||||
|
app/__init__.py # WhisperServiceAPI: Flask init, wires all components
|
||||||
|
app/core/
|
||||||
|
config.py # load_config() from JSON
|
||||||
|
transcriber.py # WhisperTranscriber: model load, device select, inference
|
||||||
|
transcription_service.py # TranscriptionService: orchestrates source -> validate -> transcribe -> log
|
||||||
|
app/audio/
|
||||||
|
processor.py # AudioProcessor: WAV convert, normalize, compress, speedup, silence
|
||||||
|
sources.py # AudioSource (abstract) + UploadedFile/URL/Base64/LocalFile sources
|
||||||
|
utils.py # AudioUtils: load audio as numpy, get duration via ffprobe
|
||||||
|
app/api/
|
||||||
|
routes.py # All Flask endpoints (OpenAI-compatible + local + async)
|
||||||
|
app/infrastructure/
|
||||||
|
storage/cache.py # SimpleCache with TTL
|
||||||
|
storage/file_manager.py # TempFileManager: temp file lifecycle with context managers
|
||||||
|
logging/config.py # setup_logging(): console + rotating file handler
|
||||||
|
logging/request_logger.py # RequestLogger: HTTP request/response middleware
|
||||||
|
validation/validators.py # FileValidator: size, extension, MIME checks
|
||||||
|
async_tasks/manager.py # AsyncTaskManager: thread-based async with status tracking
|
||||||
|
app/shared/
|
||||||
|
history_logger.py # HistoryLogger: saves transcription results as JSON by date
|
||||||
|
decorators.py # log_invalid_file_request decorator
|
||||||
|
context_managers.py # open_file context manager
|
||||||
|
app/static/
|
||||||
|
index.html # Built-in web UI client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Flask Request
|
||||||
|
-> RequestLogger middleware (logs request)
|
||||||
|
-> Routes (endpoint handler)
|
||||||
|
-> TranscriptionService.transcribe_from_source()
|
||||||
|
-> AudioSource.get_audio_file() # fetch from upload/URL/base64/local
|
||||||
|
-> FileValidator.validate_file() # size/extension/MIME
|
||||||
|
-> WhisperTranscriber.process_file()
|
||||||
|
-> AudioProcessor.process_audio() # WAV 16kHz, normalize, compress, speedup, silence
|
||||||
|
-> WhisperTranscriber.transcribe() # model inference
|
||||||
|
-> HistoryLogger.save() # persist result JSON
|
||||||
|
-> JSON Response (text, processing_time, duration, model)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/` | Web UI |
|
||||||
|
| GET | `/health` | Service status |
|
||||||
|
| GET | `/config` | Current configuration |
|
||||||
|
| GET | `/v1/models` | List models (OpenAI-compatible) |
|
||||||
|
| GET | `/v1/models/<id>` | Model details |
|
||||||
|
| POST | `/v1/audio/transcriptions` | Transcribe uploaded file (multipart) |
|
||||||
|
| POST | `/v1/audio/transcriptions/url` | Transcribe from URL |
|
||||||
|
| POST | `/v1/audio/transcriptions/base64` | Transcribe from base64 |
|
||||||
|
| POST | `/v1/audio/transcriptions/async` | Async transcription |
|
||||||
|
| GET | `/v1/tasks/<task_id>` | Async task status |
|
||||||
|
| POST | `/local/transcriptions` | Transcribe local server file |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings in `config.json`. Key parameters:
|
||||||
|
|
||||||
|
* `service_port`: server port (default 5042)
|
||||||
|
* `model_path`: path to Whisper model directory
|
||||||
|
* `language`: recognition language
|
||||||
|
* `device_id`: GPU index for CUDA
|
||||||
|
* Audio processing: `norm_level`, `compand_params`, `audio_speed_factor`, `audio_rate`
|
||||||
|
* Inference: `chunk_length_s`, `batch_size`, `max_new_tokens`, `temperature`
|
||||||
|
* `file_validation`: max size, allowed extensions/MIME types
|
||||||
|
* `request_logging`: excluded endpoints, sensitive headers
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
* **Config-driven**: All behavior controlled via `config.json`. No hardcoded model paths or thresholds.
|
||||||
|
* **Source abstraction**: `AudioSource` ABC unifies all input methods. New sources implement `get_audio_file()`.
|
||||||
|
* **Temp file lifecycle**: `TempFileManager` with context managers ensures cleanup even on errors.
|
||||||
|
* **OpenAI compatibility**: `/v1/audio/transcriptions` matches OpenAI API contract for drop-in replacement.
|
||||||
|
* **Device fallback**: CUDA -> MPS -> CPU, with Flash Attention 2 attempted first on CUDA.
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Whisper API server project structure
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
### 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` module
|
|
||||||
|
|
||||||
#### app/\_\_init\_\_.py
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
The main application class, initializes the service, loads the configuration, and starts the server using Waitress.
|
|
||||||
|
|
||||||
### WhisperTranscriber
|
|
||||||
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 (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
|
|
||||||
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
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
The service is designed to provide maximum flexibility in use and integration with existing systems that support the OpenAI Whisper API.
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
# Реорганизация проекта 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. Проверьте, что все работает корректно
|
|
||||||
|
|
||||||
## Заключение
|
|
||||||
|
|
||||||
Новая структура проекта обеспечивает лучшую организацию кода и упрощает его поддержку. Все функции сохранены, а код стал более читаемым и масштабируемым.
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Development Rules
|
||||||
|
|
||||||
|
Living document. Updated after each session.
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
Non-negotiable. Violation = stop and fix before continuing.
|
||||||
|
|
||||||
|
* Branch freshness: Run `git fetch origin && git log HEAD..origin/dev --oneline` before work. Rebase if non-empty.
|
||||||
|
* Post-refactor verification: Verify all imports compile (`python -m py_compile <file>`) after dependency changes.
|
||||||
|
* Impact assessment: Ask how changes affect the rest of the project before implementation.
|
||||||
|
* 3+ Iteration Pivot: If a problem requires 3+ iterative fixes, propose a radical architectural simplification.
|
||||||
|
* Dependency removal audit: `grep -r 'import.*package'` all consumers, verify replacement covers every use case before removing.
|
||||||
|
|
||||||
|
## Git Strategy
|
||||||
|
|
||||||
|
* **Branch First**: Create feature branch from `dev` for multi-file or non-trivial changes. Minor features (single-concern, <=3 files) may be committed directly to `dev`. Never work directly on `main`.
|
||||||
|
* **Base Branch**: `dev` contains latest stable changes. Always branch from `dev`.
|
||||||
|
* **Release**: `dev` merged into `main` for production. Never push directly to `main`.
|
||||||
|
* **Clean Up**: Delete feature branches after merge into `dev`.
|
||||||
|
|
||||||
|
## Coding Constraints
|
||||||
|
|
||||||
|
* Use standard library and framework built-ins. No custom algorithms when a one-liner exists.
|
||||||
|
* No over-engineering, no redundant abstractions. Simplest tool for the job.
|
||||||
|
* No nested conditional chains. Use lookup dictionaries and early returns.
|
||||||
|
* Crash on missing configs/dependencies. No default values for critical data (model_path, language).
|
||||||
|
* Semantic naming and strict type hints mandatory.
|
||||||
|
* Extract shared utilities only for genuinely reusable operations.
|
||||||
|
* All code comments and docstrings in Russian (project convention).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
* No formal test suite yet. Verify changes with:
|
||||||
|
- `python -m py_compile <file>` for syntax/import checks on each modified file.
|
||||||
|
- `python -c "from app import WhisperServiceAPI"` for full import chain validation.
|
||||||
|
- Manual API testing via web UI or curl against running server.
|
||||||
|
|
||||||
|
## Self-Review (after 3+ file changes)
|
||||||
|
|
||||||
|
1. `git diff --stat` -- verify only expected files changed.
|
||||||
|
2. Full diff review -- incomplete guards, duplicated literals, formatting.
|
||||||
|
3. Grep for old names after renames.
|
||||||
|
4. `python -m py_compile` on every modified `.py` file.
|
||||||
|
|
||||||
|
## Audio Processing Rules
|
||||||
|
|
||||||
|
* FFmpeg and SoX are external dependencies. Always check subprocess return codes.
|
||||||
|
* Temp files must be created via `TempFileManager.temp_file()` context manager -- never raw `tempfile.mktemp`.
|
||||||
|
* Audio pipeline order matters: convert to WAV 16kHz -> normalize -> compress/expand -> speedup -> add silence.
|
||||||
|
|
||||||
|
## Flask / API Rules
|
||||||
|
|
||||||
|
* New endpoints go in `app/api/routes.py` inside `_register_routes()`.
|
||||||
|
* All transcription endpoints delegate to `TranscriptionService.transcribe_from_source()`.
|
||||||
|
* New audio input methods: subclass `AudioSource`, implement `get_audio_file()`.
|
||||||
|
* File validation runs through `FileValidator` -- never validate inline in routes.
|
||||||
|
* Configuration values accessed via `self.config.get()` with sensible defaults, except critical params which must crash if missing.
|
||||||
@@ -63,26 +63,8 @@ class WhisperServiceAPI:
|
|||||||
# Регистрация маршрутов
|
# Регистрация маршрутов
|
||||||
routes = Routes(self.app, self.transcriber, self.config, self.file_validator)
|
routes = Routes(self.app, self.transcriber, self.config, self.file_validator)
|
||||||
|
|
||||||
# Регистрация обработчиков очистки
|
|
||||||
self._register_cleanup_handlers()
|
|
||||||
|
|
||||||
self.logger.info("WhisperServiceAPI успешно инициализирован")
|
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:
|
def run(self, host: str = '0.0.0.0', debug: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Запуск Flask приложения.
|
Запуск Flask приложения.
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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
|
|
||||||
@@ -184,12 +184,6 @@ class Routes:
|
|||||||
response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator)
|
response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator)
|
||||||
return jsonify(response), status_code
|
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'])
|
@self.app.route('/v1/audio/transcriptions/async', methods=['POST'])
|
||||||
@log_invalid_file_request
|
@log_invalid_file_request
|
||||||
def transcribe_async():
|
def transcribe_async():
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from typing import Dict, Tuple
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
from ..infrastructure.storage.file_manager import temp_file_manager
|
||||||
from ..shared.context_managers import open_file
|
|
||||||
|
|
||||||
logger = logging.getLogger('app.audio_processor')
|
logger = logging.getLogger('app.audio_processor')
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Dict, Tuple
|
from typing import Dict, Tuple
|
||||||
|
|||||||
@@ -99,26 +99,6 @@ class AsyncTaskManager:
|
|||||||
Информация о задаче или None, если задача не найдена.
|
Информация о задаче или None, если задача не найдена.
|
||||||
"""
|
"""
|
||||||
return self.tasks.get(task_id)
|
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} удалена из-за устаревания")
|
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр менеджера асинхронных задач
|
# Глобальный экземпляр менеджера асинхронных задач
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, Optional, Callable
|
from typing import Dict, Any, Optional
|
||||||
from functools import wraps
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger('app.cache')
|
logger = logging.getLogger('app.cache')
|
||||||
@@ -90,39 +89,5 @@ class SimpleCache:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Глобальные экземпляры кэша
|
# Глобальный экземпляр кэша
|
||||||
model_cache = SimpleCache(ttl=3600) # Кэш для метаданных модели (1 час)
|
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
|
|
||||||
@@ -98,20 +98,6 @@ class TempFileManager:
|
|||||||
yield temp_file
|
yield temp_file
|
||||||
finally:
|
finally:
|
||||||
self.cleanup_temp_files([temp_file])
|
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()
|
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр менеджера временных файлов
|
# Глобальный экземпляр менеджера временных файлов
|
||||||
|
|||||||
@@ -32,18 +32,3 @@ def open_file(file_path: str, mode: str = 'rb') -> Generator[BinaryIO, None, Non
|
|||||||
finally:
|
finally:
|
||||||
if file_obj:
|
if file_obj:
|
||||||
file_obj.close()
|
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
|
|
||||||
@@ -11,8 +11,6 @@ import string
|
|||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger('app.history')
|
logger = logging.getLogger('app.history')
|
||||||
|
|
||||||
class HistoryLogger:
|
class HistoryLogger:
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
"""
|
|
||||||
Главный модуль приложения, содержащий класс 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
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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)
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль audio_processor.py содержит класс AudioProcessor, предназначенный для предобработки аудиофайлов
|
|
||||||
перед их использованием в системах распознавания речи. Класс предоставляет методы для конвертации
|
|
||||||
аудио в формат WAV с частотой дискретизации 16 кГц, нормализации уровня громкости,
|
|
||||||
добавления тишины в начало записи, а также для удаления временных файлов, созданных в процессе обработки.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
Инициализация обработчика аудио.
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль audio_sources.py содержит абстрактный класс AudioSource и его конкретные реализации
|
|
||||||
для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import tempfile
|
|
||||||
import base64
|
|
||||||
import requests
|
|
||||||
import abc
|
|
||||||
from typing import Dict, Tuple, Optional, BinaryIO
|
|
||||||
|
|
||||||
from .utils import logger
|
|
||||||
|
|
||||||
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)}"
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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}")
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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()
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль history_logger.py содержит класс HistoryLogger для журналирования результатов
|
|
||||||
транскрибации.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
from .utils import logger
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль request_logger.py содержит middleware для логирования входящих запросов и ответов.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from flask import request, g
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class RequestLogger:
|
|
||||||
"""
|
|
||||||
Middleware для логирования входящих запросов и ответов.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, app=None, config: Optional[Dict] = None):
|
|
||||||
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'
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль routes.py содержит классы для регистрации маршрутов API
|
|
||||||
для сервиса распознавания речи.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from flask import request, jsonify
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
from .transcriber_service import TranscriptionService
|
|
||||||
from .audio_sources import (
|
|
||||||
UploadedFileSource,
|
|
||||||
URLSource,
|
|
||||||
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.
|
|
||||||
|
|
||||||
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 .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,273 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль transcriber.py содержит класс WhisperTranscriber, который использует модель Whisper от
|
|
||||||
OpenAI для транскрибации аудиофайлов в текст. Класс включает в себя методы для загрузки модели,
|
|
||||||
обработки аудио (с использованием класса AudioProcessor), и выполнения транскрибации.
|
|
||||||
Обрабатывает выбор устройства (CPU, CUDA, MPS) для выполнения вычислений и обеспечивает
|
|
||||||
возможность использования Flash Attention 2 для ускорения работы модели на поддерживаемых GPU.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
from typing import Dict, Tuple, Union
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import torch
|
|
||||||
from transformers import (
|
|
||||||
WhisperForConditionalGeneration,
|
|
||||||
WhisperProcessor,
|
|
||||||
pipeline,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .audio_processor import AudioProcessor
|
|
||||||
from .audio_utils import AudioUtils
|
|
||||||
from .file_manager import temp_file_manager
|
|
||||||
from .utils import logger
|
|
||||||
|
|
||||||
|
|
||||||
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("Модель успешно загружена и готова к использованию")
|
|
||||||
|
|
||||||
# Метод _load_audio удален, так как его функциональность перенесена в AudioUtils
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль 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
|
|
||||||
+3
-3
@@ -15,8 +15,8 @@
|
|||||||
"device_id": 0,
|
"device_id": 0,
|
||||||
"file_validation": {
|
"file_validation": {
|
||||||
"max_file_size_mb": 500,
|
"max_file_size_mb": 500,
|
||||||
"allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga"],
|
"allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga", ".aac", ".webm"],
|
||||||
"allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a"]
|
"allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"]
|
||||||
},
|
},
|
||||||
"allowed_directories": [],
|
"allowed_directories": [],
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -25,6 +25,6 @@
|
|||||||
"request_logging": {
|
"request_logging": {
|
||||||
"exclude_endpoints": ["/health", "/static"],
|
"exclude_endpoints": ["/health", "/static"],
|
||||||
"sensitive_headers": ["authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"],
|
"sensitive_headers": ["authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"],
|
||||||
"log_debug": true
|
"log_debug": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,662 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,576 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
В данном проекте, в методе, который запирает все таски глобально fetch_all_tasks_with_due_dates() в client_jira.py необходимо добавить новый функционал. К каждой таске необходимо ещё получать дополнительную информацию – родительскую стори и сторипоинты родительской стори. Брать родительскую стори данной таски, из данной стори брать стори-поинты (stripoints), которые там есть, и добавлять потом к таске. При этом важно, поскольку тасок много, и некоторые из них принадлежат одной стори, то необходимо сделать кэширование. То есть мы берём, загружаем таску, лезем проверять её родительскую стори, забираем эти данные и кэшируем стори, чтобы в следующей, когда мы берём следующую таску, скорее всего, она попадёт в эту же стори. Тогда мы сначала проверяем кэш. В кеше достаточно хранить только словарь где ключи стори, а значение – сторипоинты. Если в кэше нет, тогда уже делаем запрос в жиру на получение родиельской стори и её сторипоинтов. То есть это нужно будет делать для каждой таски. Спроектируй архитектуру данных изменений и сохрани план работ в папке docs. Там же можно найти примеры планов от другого проекта. Необходимо аккуратно спроектировать данные изменения и аккуратно имплементировать во все места, где это может использоваться по флоу проекта. А ну и да, нужно будет в итоге в отчете добавить новое поле storypoints, по аналогии с отчетом, где собираются стори.
|
|
||||||
|
|
||||||
Информация о родительской таске находится в `fields.issuelinks`. Там список линков для данной таски. Нам нужна та у которой `type.inward == "Parent task"`. Там уже нужно найти "key" и это будет ключ на родительскую задачу. Забрать информацию по рдительской задаче и там найти сторипоинты. В проекте уже реализован сбор сторипоинтов. Если не указано, то поле пустое
|
|
||||||
|
|
||||||
Пример полной выдачи информации по таски от жиры в `docs\WPCMU-9082.json`
|
|
||||||
|
|
||||||
ВАЖНО! Всегда думай и отвечай на английском!
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"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/"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user