Refactor logging, clean dead code, fix temp files and async tasks

- Replace f-string logging with % formatting throughout
- Fix create_temp_file: use mkstemp() instead of mkdtemp() (no leaked dirs)
- Add cleanup background thread and copy.deepcopy to AsyncTaskManager
- Fix transcribe_audio_async signature to accept transcription_service + params
- Use resample_poly instead of scipy_resample for better audio quality
- Remove unused validate_file/BinaryIO methods from FileValidator
- Add requests to requirements.txt
- Add error handling for pip install in server.sh
- Add __all__ and module docstrings to __init__.py files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Serge Zaigraeff
2026-03-31 00:43:53 +03:00
co-authored by Claude Sonnet 4.6
parent 1e778cae86
commit ca2e41e0f5
13 changed files with 60 additions and 127 deletions
+4 -1
View File
@@ -2,7 +2,10 @@
"permissions": { "permissions": {
"allow": [ "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 -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)"
] ]
} }
} }
+3 -1
View File
@@ -3,6 +3,8 @@
и запуска сервиса распознавания речи. и запуска сервиса распознавания речи.
""" """
__all__ = ['WhisperServiceAPI']
import os import os
import logging import logging
from flask import Flask from flask import Flask
@@ -68,7 +70,7 @@ class WhisperServiceAPI:
def run(self) -> None: def run(self) -> None:
"""Запуск сервиса через Waitress.""" """Запуск сервиса через 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) waitress.serve(self.app, host='0.0.0.0', port=self.port)
def create_app(self) -> Flask: def create_app(self) -> Flask:
+1
View File
@@ -0,0 +1 @@
"""Модуль audio — обработка и подготовка аудиофайлов."""
+5 -5
View File
@@ -6,7 +6,7 @@ import os
import subprocess import subprocess
import wave import wave
import numpy as np import numpy as np
from scipy.signal import resample as scipy_resample from scipy.signal import resample_poly
import logging import logging
from typing import Tuple from typing import Tuple
@@ -27,21 +27,21 @@ def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
try: try:
with wave.open(file_path, 'rb') as wav_file: with wave.open(file_path, 'rb') as wav_file:
if wav_file.getnchannels() != 1: if wav_file.getnchannels() != 1:
logger.warning(f"Файл {file_path} не моно-аудио") logger.warning("Файл %s не моно-аудио", file_path)
frames = wav_file.readframes(-1) frames = wav_file.readframes(-1)
audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0 audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
sampling_rate = wav_file.getframerate() sampling_rate = wav_file.getframerate()
if sampling_rate != sr: if sampling_rate != sr:
num_samples = int(len(audio_array) * sr / sampling_rate) gcd = np.gcd(sr, sampling_rate)
audio_array = scipy_resample(audio_array, num_samples) audio_array = resample_poly(audio_array, sr // gcd, sampling_rate // gcd)
sampling_rate = sr sampling_rate = sr
return audio_array, sampling_rate return audio_array, sampling_rate
except Exception as e: except Exception as e:
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}") logger.error("Ошибка при загрузке аудио %s: %s", file_path, e)
raise raise
+1
View File
@@ -0,0 +1 @@
"""Модуль core — основные компоненты сервиса распознавания речи."""
+3 -3
View File
@@ -27,11 +27,11 @@ def load_config(config_path: str) -> Dict:
try: try:
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
logger.info(f"Конфигурация успешно загружена из {config_path}") logger.info("Конфигурация успешно загружена из %s", config_path)
return config return config
except FileNotFoundError as e: except FileNotFoundError as e:
logger.error(f"Файл конфигурации не найден: {e}") logger.error("Файл конфигурации не найден: %s", e)
raise raise
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Ошибка при загрузке конфигурации: {e}") logger.error("Ошибка при загрузке конфигурации: %s", e)
raise raise
+1
View File
@@ -0,0 +1 @@
"""Модуль infrastructure — вспомогательные компоненты сервиса."""
+23 -6
View File
@@ -2,6 +2,7 @@
Модуль async_tasks.py содержит функции для асинхронной обработки задач. Модуль async_tasks.py содержит функции для асинхронной обработки задач.
""" """
import copy
import uuid import uuid
import time import time
from typing import Dict, Any, Callable, Optional from typing import Dict, Any, Callable, Optional
@@ -27,6 +28,7 @@ class AsyncTaskManager:
""" """
self._lock = Lock() self._lock = Lock()
self.tasks: Dict[str, Dict[str, Any]] = {} self.tasks: Dict[str, Dict[str, Any]] = {}
self._start_cleanup_thread()
def run_task(self, func: Callable, *args, **kwargs) -> str: 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]["result"] = result
self.tasks[task_id]["completed_at"] = time.time() self.tasks[task_id]["completed_at"] = time.time()
logger.info(f"Задача {task_id} завершена успешно") logger.info("Задача %s завершена успешно", task_id)
except Exception as e: except Exception as e:
with self._lock: with self._lock:
self.tasks[task_id]["status"] = "failed" self.tasks[task_id]["status"] = "failed"
self.tasks[task_id]["error"] = str(e) self.tasks[task_id]["error"] = str(e)
self.tasks[task_id]["completed_at"] = time.time() 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]]: def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
""" """
@@ -112,22 +114,37 @@ class AsyncTaskManager:
""" """
with self._lock: with self._lock:
task = self.tasks.get(task_id) 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() 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: Args:
file_path: Путь к аудиофайлу. file_path: Путь к аудиофайлу.
transcriber: Экземпляр транскрайбера. transcription_service: Экземпляр TranscriptionService.
params: Параметры транскрибации.
Returns: Returns:
ID задачи. ID задачи.
""" """
return task_manager.run_task(transcriber.process_file, file_path) params = params or {}
def _transcribe():
return transcription_service.transcribe(file_path, "async_task", params)
return task_manager.run_task(_transcribe)
+4 -2
View File
@@ -91,7 +91,8 @@ class RequestLogger:
return return
g.start_time = time.time() g.start_time = time.time()
self.logger.info( 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"} extra={"type": "request"}
) )
@@ -100,7 +101,8 @@ class RequestLogger:
return response return response
processing_time = time.time() - getattr(g, 'start_time', time.time()) processing_time = time.time() - getattr(g, 'start_time', time.time())
self.logger.info( self.logger.info(
f"{response.status_code} за {processing_time:.3f} сек", "%s за %.3f сек",
response.status_code, processing_time,
extra={"type": "response"} extra={"type": "response"}
) )
return response return response
+6 -11
View File
@@ -3,7 +3,6 @@
""" """
import os import os
import uuid
import tempfile import tempfile
import logging import logging
@@ -20,15 +19,15 @@ def create_temp_file(suffix: str = ".wav") -> str:
Returns: Returns:
Путь к временному файлу. Путь к временному файлу.
""" """
temp_dir = tempfile.mkdtemp() fd, temp_path = tempfile.mkstemp(suffix=suffix)
temp_path = os.path.join(temp_dir, f"{uuid.uuid4()}{suffix}") os.close(fd)
logger.debug(f"Создан временный файл: {temp_path}") logger.debug("Создан временный файл: %s", temp_path)
return temp_path return temp_path
def cleanup_temp_files(file_paths: list) -> None: def cleanup_temp_files(file_paths: list) -> None:
""" """
Удаляет временные файлы и их директории. Удаляет временные файлы.
Args: Args:
file_paths: Список путей к файлам для удаления. file_paths: Список путей к файлам для удаления.
@@ -37,10 +36,6 @@ def cleanup_temp_files(file_paths: list) -> None:
try: try:
if os.path.exists(path): if os.path.exists(path):
os.remove(path) os.remove(path)
logger.debug(f"Удалён временный файл: {path}") logger.debug("Удалён временный файл: %s", path)
temp_dir = os.path.dirname(path)
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
os.rmdir(temp_dir)
except Exception as e: except Exception as e:
logger.warning(f"Не удалось очистить временный файл {path}: {e}") logger.warning("Не удалось очистить временный файл %s: %s", path, e)
+4 -97
View File
@@ -4,7 +4,7 @@
import os import os
import magic import magic
from typing import Dict, List, BinaryIO from typing import Dict, List
import logging import logging
# Получаем логгер из централизованной настройки # Получаем логгер из централизованной настройки
@@ -38,64 +38,6 @@ class FileValidator:
["audio/wav", "audio/mpeg", "audio/ogg", ["audio/wav", "audio/mpeg", "audio/ogg",
"audio/flac", "audio/mp4"]) "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: 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): if not any(filename.lower().endswith(ext.lower()) for ext in self.allowed_extensions):
# Логирование попытки загрузки файла с неразрешенным расширением # Логирование попытки загрузки файла с неразрешенным расширением
file_extension = os.path.splitext(filename)[1] file_extension = os.path.splitext(filename)[1]
logger.warning(f"Попытка загрузки файла с неразрешенным расширением '{file_extension}'. " logger.warning("Попытка загрузки файла с неразрешенным расширением '%s'. "
f"Имя файла: {filename}. Разрешенные расширения: {', '.join(self.allowed_extensions)}") "Имя файла: %s. Разрешенные расширения: %s", file_extension, filename, ", ".join(self.allowed_extensions))
raise ValidationError(f"Расширение файла не разрешено. " raise ValidationError(f"Расширение файла не разрешено. "
f"Разрешенные расширения: {', '.join(self.allowed_extensions)}") 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: def validate_file_by_path(self, file_path: str, filename: str) -> bool:
""" """
Валидирует файл по пути на диске. Валидирует файл по пути на диске.
@@ -182,7 +90,6 @@ class FileValidator:
except ValidationError: except ValidationError:
raise raise
except Exception as e: except Exception as e:
logger.warning(f"Не удалось определить MIME-тип файла: {e}") logger.warning("Не удалось определить MIME-тип файла: %s", e)
return True return True
+1
View File
@@ -17,3 +17,4 @@ scipy==1.13.1
transformers==4.49.0 transformers==4.49.0
accelerate==1.4.0 accelerate==1.4.0
python-magic==0.4.27 python-magic==0.4.27
requests==2.32.3
+4 -1
View File
@@ -48,7 +48,10 @@ if [[ "$NEW_ENV_CREATED" == true || "$UPDATE_ENV" == true ]]; then
# Установка зависимостей из requirements.txt # Установка зависимостей из requirements.txt
if [ -f "requirements.txt" ]; then if [ -f "requirements.txt" ]; then
echo "Установка зависимостей из requirements.txt" 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 else
echo "Файл requirements.txt не найден. Убедитесь, что он находится в той же директории, что и скрипт." echo "Файл requirements.txt не найден. Убедитесь, что он находится в той же директории, что и скрипт."
exit 1 exit 1