diff --git a/.claude/settings.json b/.claude/settings.json index 6baca72..3798f86 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,10 @@ "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)" + "Bash(git -C /Users/serge/Develop/nnp-whisper-api-server log --oneline main..cleanup/dead-code)", + "Bash(ssh orange:*)", + "Bash(curl -s http://stt.ai.gray/health)", + "Bash(curl -s http://stt.ai.gray/v1/models)" ] } } diff --git a/app/__init__.py b/app/__init__.py index 6d81669..4be6cc7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,6 +3,8 @@ и запуска сервиса распознавания речи. """ +__all__ = ['WhisperServiceAPI'] + import os import logging from flask import Flask @@ -68,7 +70,7 @@ class WhisperServiceAPI: def run(self) -> None: """Запуск сервиса через Waitress.""" - self.logger.info(f"Запуск сервиса на 0.0.0.0:{self.port}") + self.logger.info("Запуск сервиса на 0.0.0.0:%s", self.port) waitress.serve(self.app, host='0.0.0.0', port=self.port) def create_app(self) -> Flask: diff --git a/app/audio/__init__.py b/app/audio/__init__.py index e69de29..6303932 100644 --- a/app/audio/__init__.py +++ b/app/audio/__init__.py @@ -0,0 +1 @@ +"""Модуль audio — обработка и подготовка аудиофайлов.""" diff --git a/app/audio/utils.py b/app/audio/utils.py index 729df9b..c9f656e 100644 --- a/app/audio/utils.py +++ b/app/audio/utils.py @@ -6,7 +6,7 @@ import os import subprocess import wave import numpy as np -from scipy.signal import resample as scipy_resample +from scipy.signal import resample_poly import logging from typing import Tuple @@ -27,21 +27,21 @@ def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]: try: with wave.open(file_path, 'rb') as wav_file: if wav_file.getnchannels() != 1: - logger.warning(f"Файл {file_path} не моно-аудио") + logger.warning("Файл %s не моно-аудио", file_path) frames = wav_file.readframes(-1) audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0 sampling_rate = wav_file.getframerate() if sampling_rate != sr: - num_samples = int(len(audio_array) * sr / sampling_rate) - audio_array = scipy_resample(audio_array, num_samples) + gcd = np.gcd(sr, sampling_rate) + audio_array = resample_poly(audio_array, sr // gcd, sampling_rate // gcd) sampling_rate = sr return audio_array, sampling_rate except Exception as e: - logger.error(f"Ошибка при загрузке аудио {file_path}: {e}") + logger.error("Ошибка при загрузке аудио %s: %s", file_path, e) raise diff --git a/app/core/__init__.py b/app/core/__init__.py index e69de29..8e391ee 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Модуль core — основные компоненты сервиса распознавания речи.""" diff --git a/app/core/config.py b/app/core/config.py index 04af7e1..11ce8bb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -27,11 +27,11 @@ def load_config(config_path: str) -> Dict: try: with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) - logger.info(f"Конфигурация успешно загружена из {config_path}") + logger.info("Конфигурация успешно загружена из %s", config_path) return config except FileNotFoundError as e: - logger.error(f"Файл конфигурации не найден: {e}") + logger.error("Файл конфигурации не найден: %s", e) raise except json.JSONDecodeError as e: - logger.error(f"Ошибка при загрузке конфигурации: {e}") + logger.error("Ошибка при загрузке конфигурации: %s", e) raise \ No newline at end of file diff --git a/app/infrastructure/__init__.py b/app/infrastructure/__init__.py index e69de29..64f634e 100644 --- a/app/infrastructure/__init__.py +++ b/app/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Модуль infrastructure — вспомогательные компоненты сервиса.""" diff --git a/app/infrastructure/async_tasks.py b/app/infrastructure/async_tasks.py index 645e8a9..936b731 100644 --- a/app/infrastructure/async_tasks.py +++ b/app/infrastructure/async_tasks.py @@ -2,6 +2,7 @@ Модуль async_tasks.py содержит функции для асинхронной обработки задач. """ +import copy import uuid import time from typing import Dict, Any, Callable, Optional @@ -27,6 +28,7 @@ class AsyncTaskManager: """ self._lock = Lock() self.tasks: Dict[str, Dict[str, Any]] = {} + self._start_cleanup_thread() def run_task(self, func: Callable, *args, **kwargs) -> str: """ @@ -91,14 +93,14 @@ class AsyncTaskManager: self.tasks[task_id]["result"] = result self.tasks[task_id]["completed_at"] = time.time() - logger.info(f"Задача {task_id} завершена успешно") + logger.info("Задача %s завершена успешно", task_id) except Exception as e: with self._lock: 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}") + logger.error("Задача %s завершилась с ошибкой: %s", task_id, e) def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]: """ @@ -112,22 +114,37 @@ class AsyncTaskManager: """ with self._lock: task = self.tasks.get(task_id) - return dict(task) if task else None + return copy.deepcopy(task) if task else None + + def _start_cleanup_thread(self) -> None: + """Запуск фонового потока для периодической очистки старых задач.""" + def _cleanup_loop(): + while True: + time.sleep(600) # Каждые 10 минут + with self._lock: + self._cleanup_old_tasks() + + thread = Thread(target=_cleanup_loop, daemon=True) + thread.start() # Глобальный экземпляр менеджера асинхронных задач task_manager = AsyncTaskManager() -def transcribe_audio_async(file_path: str, transcriber) -> str: +def transcribe_audio_async(file_path: str, transcription_service, params: Dict = None) -> str: """ Асинхронная транскрибация аудиофайла. Args: file_path: Путь к аудиофайлу. - transcriber: Экземпляр транскрайбера. + transcription_service: Экземпляр TranscriptionService. + params: Параметры транскрибации. Returns: ID задачи. """ - return task_manager.run_task(transcriber.process_file, file_path) \ No newline at end of file + params = params or {} + def _transcribe(): + return transcription_service.transcribe(file_path, "async_task", params) + return task_manager.run_task(_transcribe) \ No newline at end of file diff --git a/app/infrastructure/log.py b/app/infrastructure/log.py index 8f17cae..44294eb 100644 --- a/app/infrastructure/log.py +++ b/app/infrastructure/log.py @@ -91,7 +91,8 @@ class RequestLogger: return g.start_time = time.time() self.logger.info( - f"{request.method} {request.path} от {self._get_client_ip()}", + "%s %s от %s", + request.method, request.path, self._get_client_ip(), extra={"type": "request"} ) @@ -100,7 +101,8 @@ class RequestLogger: return response processing_time = time.time() - getattr(g, 'start_time', time.time()) self.logger.info( - f"{response.status_code} за {processing_time:.3f} сек", + "%s за %.3f сек", + response.status_code, processing_time, extra={"type": "response"} ) return response diff --git a/app/infrastructure/storage.py b/app/infrastructure/storage.py index e0826ff..b5d04b3 100644 --- a/app/infrastructure/storage.py +++ b/app/infrastructure/storage.py @@ -3,7 +3,6 @@ """ import os -import uuid import tempfile import logging @@ -20,15 +19,15 @@ def create_temp_file(suffix: str = ".wav") -> str: Returns: Путь к временному файлу. """ - temp_dir = tempfile.mkdtemp() - temp_path = os.path.join(temp_dir, f"{uuid.uuid4()}{suffix}") - logger.debug(f"Создан временный файл: {temp_path}") + fd, temp_path = tempfile.mkstemp(suffix=suffix) + os.close(fd) + logger.debug("Создан временный файл: %s", temp_path) return temp_path def cleanup_temp_files(file_paths: list) -> None: """ - Удаляет временные файлы и их директории. + Удаляет временные файлы. Args: file_paths: Список путей к файлам для удаления. @@ -37,10 +36,6 @@ def cleanup_temp_files(file_paths: list) -> None: 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("Удалён временный файл: %s", path) except Exception as e: - logger.warning(f"Не удалось очистить временный файл {path}: {e}") + logger.warning("Не удалось очистить временный файл %s: %s", path, e) diff --git a/app/infrastructure/validation.py b/app/infrastructure/validation.py index 13e1453..9069408 100644 --- a/app/infrastructure/validation.py +++ b/app/infrastructure/validation.py @@ -4,7 +4,7 @@ import os import magic -from typing import Dict, List, BinaryIO +from typing import Dict, List import logging # Получаем логгер из централизованной настройки @@ -38,64 +38,6 @@ class FileValidator: ["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: """ Валидирует расширение файла. @@ -109,46 +51,12 @@ class FileValidator: 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)}") + logger.warning("Попытка загрузки файла с неразрешенным расширением '%s'. " + "Имя файла: %s. Разрешенные расширения: %s", file_extension, 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-тип - def validate_file_by_path(self, file_path: str, filename: str) -> bool: """ Валидирует файл по пути на диске. @@ -182,7 +90,6 @@ class FileValidator: except ValidationError: raise except Exception as e: - logger.warning(f"Не удалось определить MIME-тип файла: {e}") + logger.warning("Не удалось определить MIME-тип файла: %s", e) return True - diff --git a/requirements.txt b/requirements.txt index cbb6889..c5e58d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ scipy==1.13.1 transformers==4.49.0 accelerate==1.4.0 python-magic==0.4.27 +requests==2.32.3 diff --git a/server.sh b/server.sh index 1a612a7..ab2360f 100755 --- a/server.sh +++ b/server.sh @@ -48,7 +48,10 @@ if [[ "$NEW_ENV_CREATED" == true || "$UPDATE_ENV" == true ]]; then # Установка зависимостей из requirements.txt if [ -f "requirements.txt" ]; then echo "Установка зависимостей из requirements.txt" - pip install --no-cache-dir -r requirements.txt + if ! pip install --no-cache-dir -r requirements.txt; then + echo "Ошибка при установке зависимостей." + exit 1 + fi else echo "Файл requirements.txt не найден. Убедитесь, что он находится в той же директории, что и скрипт." exit 1