Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dca198d3c | |||
| fe0bed7f58 | |||
| 94633288c3 | |||
| 15925c0217 | |||
| e161681bc9 | |||
| 5360ea7f9c | |||
| d460728b2e | |||
| 8e1175c9bf | |||
| 014271e929 | |||
| ae4cfa0119 | |||
| db62f9ef74 | |||
| bf507b5363 | |||
| 9fc60e8d19 | |||
| f3e3da1696 | |||
| b8da32f44a | |||
| 688417990f | |||
| e31440f91e | |||
| 163b019b31 | |||
| cf25f364d1 | |||
| 34a31cc4d6 | |||
| ebef51aa48 | |||
| 013dd9a7d4 | |||
| c1a9ef27f1 | |||
| ac8e881ee3 | |||
| 21dafb5681 | |||
| 9f65a9398a | |||
| b8af963e9f | |||
| c4dec7644d | |||
| 2df1761d34 | |||
| 59b5676c84 | |||
| ddbebea001 | |||
| 86245ce1fb | |||
| de2e66a37c | |||
| ba93ac1970 | |||
| e5081e71a3 | |||
| 51c361f99d | |||
| a361078ef9 | |||
| 6efd32f368 | |||
| 3930ea7021 | |||
| 76b5b26c75 | |||
| 168151fe7f | |||
| 094b17ee93 | |||
| cceaf65576 | |||
| 0f8e6be08c | |||
| 5a1e296d41 | |||
| 7bb64b3c42 | |||
| 1a573362c9 | |||
| 2a61e8422d | |||
| f58c95708f | |||
| efa4d775e4 | |||
| ebfb48a2b8 | |||
| 577dc277e0 | |||
| c2289bfc45 | |||
| fa4c46173f | |||
| 711d7b6b1e | |||
| 8b471c771a | |||
| d4db29f710 | |||
| cfcc84eac9 |
@@ -0,0 +1,26 @@
|
|||||||
|
# Database
|
||||||
|
# DATABASE_URL=sqlite:///./speech_analytics.db
|
||||||
|
# Для PostgreSQL:
|
||||||
|
DATABASE_URL=postgresql://postgres_test:test_user@postgres_test:5432/audiofiles_db
|
||||||
|
|
||||||
|
# API Settings
|
||||||
|
API_V1_PREFIX=/api/v1
|
||||||
|
MAX_UPLOAD_SIZE=104857600 # 100MB in bytes
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_TITLE=Speech Analytics API
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
|
||||||
|
# Server
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=5056
|
||||||
|
RELOAD=True
|
||||||
|
|
||||||
|
#SFTP
|
||||||
|
SFPT_HOSTNAME = 192.168.1.150
|
||||||
|
SFPT_USERNAME = monitor
|
||||||
|
SFPT_PASSWORD = Audio4analy6!6
|
||||||
|
|
||||||
|
FILESAPTH = audiofiles
|
||||||
|
|
||||||
|
GIGAAM_API_URL = http://localhost:5001
|
||||||
+3
-2
@@ -32,7 +32,8 @@ RUN mkdir -p /app/uploads
|
|||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
# Открытие порта
|
# Открытие порта
|
||||||
EXPOSE 8000
|
EXPOSE 5008
|
||||||
|
|
||||||
# Команда запуска приложения
|
# Команда запуска приложения
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
# CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
CMD ["python", "run.py"]
|
||||||
@@ -1,3 +1,226 @@
|
|||||||
# FileAudioAPI
|
# FileAudioAPI
|
||||||
|
|
||||||
Сервис для хранения файлов аудио, индексации файлов, записи и выдачи результатов распознавания
|
## Назначение и роль в экосистеме
|
||||||
|
|
||||||
|
**FileAudioAPI** — сервис **учёта аудио и результатов распознавания** между диском, GPU-распознаванием и веб-клиентом. На **FastAPI**: хранит метаданные и статусы в БД, складывает файлы в общий каталог **`AUDIOFILES_PATH`** (тот же volume `audiofiles`, что у **GigaAM_API** и **Calls_WEB_Client_main**), инициирует ASR в **GigaAM_API**, принимает обратно текст/заключение, при необходимости дергает webhook в **Calls_WEB_Client_main**.
|
||||||
|
|
||||||
|
| Направление | Роль |
|
||||||
|
|-------------|------|
|
||||||
|
| **Внутренние файлы** | Регистрация, очереди `process-all`, статусы `recognition_*`, связь с `AiConclusion`. |
|
||||||
|
| **Внешние файлы** | Загрузка с опциональным `callback_url`, сохранение под UUID-именем в общей папке, **всегда** постановка на распознавание в GigaAM. |
|
||||||
|
| **Интеграция** | `GIGAAM_API_URL`, `CALLS_WEB_CLIENT_URL` / `WEBHOOK_API_KEY` для доставки результатов в основное приложение. |
|
||||||
|
|
||||||
|
Swagger: **`/api/v1/docs`** (см. ниже).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Сервис для хранения аудиофайлов, индексации файлов, записи и выдачи результатов распознавания, реализованный на **FastAPI**.
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Перейти в директорию сервиса (от корня репозитория SpeechAnalytics)
|
||||||
|
cd FileAudioAPI
|
||||||
|
|
||||||
|
# Запуск с Docker (рекомендуется)
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Или использовать Makefile
|
||||||
|
make up
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу: **http://localhost:8000**
|
||||||
|
|
||||||
|
API документация:
|
||||||
|
- 📚 **Swagger UI**: http://localhost:8000/api/v1/docs
|
||||||
|
- 📖 **ReDoc**: http://localhost:8000/api/v1/redoc
|
||||||
|
|
||||||
|
## 📚 Документация
|
||||||
|
|
||||||
|
- 📘 [Quick Start Guide](START.md) - Быстрый старт
|
||||||
|
- 📗 [API Documentation](README_FASTAPI.md) - Полная документация API
|
||||||
|
- 📕 [Docker Deployment](README_DOCKER.md) - Развертывание в Docker
|
||||||
|
|
||||||
|
## 🎯 Возможности
|
||||||
|
|
||||||
|
- ✅ Загрузка аудиофайлов (MP3, WAV, OGG, FLAC, M4A, AAC)
|
||||||
|
- ✅ Хранение файлов с автоматической индексацией
|
||||||
|
- ✅ Асинхронное распознавание аудио в фоне
|
||||||
|
- ✅ Отслеживание статуса распознавания
|
||||||
|
- ✅ Получение результатов распознавания
|
||||||
|
- ✅ RESTful API с автоматической документацией
|
||||||
|
- ✅ Валидация данных с Pydantic
|
||||||
|
- ✅ Docker поддержка
|
||||||
|
|
||||||
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
apiApp/
|
||||||
|
├── schemas/ # Pydantic модели (валидация)
|
||||||
|
├── services/ # Business logic (CRUD операции)
|
||||||
|
├── routers/ # API endpoints (контроллеры)
|
||||||
|
├── database/ # SQLAlchemy модели
|
||||||
|
├── dbApi/ # Legacy compatibility layer
|
||||||
|
├── config.py # Конфигурация
|
||||||
|
└── database.py # SQLAlchemy настройка
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Технологии
|
||||||
|
|
||||||
|
- **FastAPI** - Современный веб-фреймворк
|
||||||
|
- **SQLAlchemy 2.0** - ORM для работы с БД
|
||||||
|
- **Pydantic** - Валидация данных
|
||||||
|
- **Uvicorn** - ASGI сервер
|
||||||
|
- **Docker** - Контейнеризация
|
||||||
|
|
||||||
|
## 📋 Основные endpoints
|
||||||
|
|
||||||
|
| Метод | Endpoint | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| POST | `/api/v1/upload` | Загрузка аудиофайла |
|
||||||
|
| GET | `/api/v1/audio/list` | Список всех файлов |
|
||||||
|
| GET | `/api/v1/audio/{id}` | Информация о файле |
|
||||||
|
| GET | `/api/v1/audio/file/{id}` | Скачать файл |
|
||||||
|
| DELETE | `/api/v1/audio/delete/{id}` | Удалить файл |
|
||||||
|
| POST | `/api/v1/recognize/{id}` | Запустить распознавание |
|
||||||
|
| GET | `/api/v1/recognize/{id}` | Статус распознавания |
|
||||||
|
| GET | `/api/v1/recognize/task/{task_id}` | Статус по task_id |
|
||||||
|
| GET | `/api/v1/recognize/{id}/result` | Результат распознавания |
|
||||||
|
|
||||||
|
## 🛠️ Установка и запуск
|
||||||
|
|
||||||
|
### С Docker (рекомендуется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Использование Makefile
|
||||||
|
make up
|
||||||
|
|
||||||
|
# Или напрямую с Docker Compose
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
make logs
|
||||||
|
# или
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Локально
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка зависимостей
|
||||||
|
make install
|
||||||
|
# или
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
# Создание базы данных
|
||||||
|
make db-migrate
|
||||||
|
|
||||||
|
# Запуск
|
||||||
|
make dev
|
||||||
|
# или
|
||||||
|
python3 run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Makefile команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make help # Показать все команды
|
||||||
|
make build # Собрать Docker образ
|
||||||
|
make up # Запустить контейнеры
|
||||||
|
make down # Остановить контейнеры
|
||||||
|
make restart # Перезапустить
|
||||||
|
make logs # Логи
|
||||||
|
make shell # Shell в контейнере
|
||||||
|
make test # Запуск тестов
|
||||||
|
make clean # Очистка
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Примеры использования
|
||||||
|
|
||||||
|
### Загрузка файла
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/upload \
|
||||||
|
-F "file=@audio.mp3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск распознавания
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/recognize/{audio_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение статуса
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/v1/recognize/{audio_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение результата
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/v1/recognize/{audio_id}/result
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Production
|
||||||
|
|
||||||
|
Для production развертывания:
|
||||||
|
|
||||||
|
1. Используйте PostgreSQL вместо SQLite
|
||||||
|
2. Настройте Redis для хранения статусов задач
|
||||||
|
3. Ограничьте CORS origins
|
||||||
|
4. Добавьте аутентификацию
|
||||||
|
5. Настройте SSL/HTTPS
|
||||||
|
6. Используйте несколько workers
|
||||||
|
|
||||||
|
Подробнее в [Docker Deployment Guide](README_DOCKER.md)
|
||||||
|
|
||||||
|
## 🧪 Тестирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск тестов
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Или напрямую
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Мониторинг
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
|
||||||
|
# Docker stats
|
||||||
|
docker stats file-audio-api
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
docker-compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Рефакторинг
|
||||||
|
|
||||||
|
Проект был успешно рефакторин из Flask в FastAPI с улучшением архитектуры:
|
||||||
|
|
||||||
|
**Основные изменения:**
|
||||||
|
- ✅ Многослойная архитектура (schemas → services → routers)
|
||||||
|
- ✅ Разделение по доменам
|
||||||
|
- ✅ Асинхронная обработка задач
|
||||||
|
- ✅ Автодокументация API
|
||||||
|
- ✅ Type hints и валидация
|
||||||
|
- ✅ Docker поддержка
|
||||||
|
|
||||||
|
## 📄 Лицензия
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
|
## 👨💻 Автор
|
||||||
|
|
||||||
|
Speech Analytics Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
📖 Более подробная информация в документации:
|
||||||
|
- [Quick Start](START.md)
|
||||||
|
- [API Docs](README_FASTAPI.md)
|
||||||
|
- [Docker Guide](README_DOCKER.md)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
UPLOAD_FOLDER = BASE_DIR / "uploads"
|
UPLOAD_FOLDER = BASE_DIR / "uploads"
|
||||||
UPLOAD_FOLDER.mkdir(exist_ok=True)
|
UPLOAD_FOLDER.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Audio files path (shared with Calls_WEB_Client_main)
|
||||||
|
AUDIOFILES_PATH = os.getenv("AUDIOFILES_PATH", "/app/audiofiles")
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./speech_analytics.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./speech_analytics.db")
|
||||||
|
|
||||||
@@ -17,3 +20,27 @@ ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"}
|
|||||||
# Application
|
# Application
|
||||||
APP_TITLE = "Speech Analytics API"
|
APP_TITLE = "Speech Analytics API"
|
||||||
APP_VERSION = "1.0.0"
|
APP_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
PORT = int(os.getenv("PORT", "5008"))
|
||||||
|
HOST = os.getenv("HOST", "localhost")
|
||||||
|
|
||||||
|
# GigaAM API Configuration
|
||||||
|
GIGAAM_API_URL = os.getenv("GIGAAM_API_URL", "http://192.168.1.73:5002")
|
||||||
|
|
||||||
|
AUDIOFILES_PATH = os.path.join(os.getcwd(), os.getenv("FILESAPTH", "audiofiles"))
|
||||||
|
|
||||||
|
# Calls_WEB_Client_main Webhook Configuration
|
||||||
|
CALLS_WEB_CLIENT_URL = os.getenv(
|
||||||
|
"CALLS_WEB_CLIENT_URL",
|
||||||
|
"http://192.168.1.73:8642"
|
||||||
|
)
|
||||||
|
WEBHOOK_ENDPOINT = f"{CALLS_WEB_CLIENT_URL}/api/transcription/webhook"
|
||||||
|
WEBHOOK_API_KEY = os.getenv("WEBHOOK_API_KEY", "webhook_secret_key")
|
||||||
|
|
||||||
|
# Auto-restore recognition on startup
|
||||||
|
ENABLE_AUTO_RESTORE = os.getenv("ENABLE_AUTO_RESTORE", "true").lower() == "true"
|
||||||
|
AUTO_RESTORE_LIMIT = int(os.getenv("AUTO_RESTORE_LIMIT", "100")) # Максимум файлов для восстановления
|
||||||
|
AUTO_RESTORE_DELAY = int(os.getenv("AUTO_RESTORE_DELAY", "5")) # Задержка перед запуском (секунды)
|
||||||
|
|
||||||
|
# Recognition retry policy (FileAudioAPI side)
|
||||||
|
MAX_RECOGNITION_ATTEMPTS = int(os.getenv("MAX_RECOGNITION_ATTEMPTS", "3"))
|
||||||
+5
-1
@@ -6,7 +6,11 @@ from apiApp.config import DATABASE_URL
|
|||||||
# Создание engine
|
# Создание engine
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {},
|
||||||
|
pool_pre_ping=True, # Проверять соединение перед использованием
|
||||||
|
pool_recycle=600, # Пересоздавать соединения каждые 10 минут
|
||||||
|
pool_size=10, # Размер пула соединений
|
||||||
|
max_overflow=20 # Дополнительные соединения при пиковой нагрузке
|
||||||
)
|
)
|
||||||
|
|
||||||
# SessionLocal
|
# SessionLocal
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ class AiConclusion(Base):
|
|||||||
index_date = Column(DateTime, default=datetime.utcnow)
|
index_date = Column(DateTime, default=datetime.utcnow)
|
||||||
end_date = Column(DateTime)
|
end_date = Column(DateTime)
|
||||||
|
|
||||||
audio = relationship("Audio", back_populates="ai_conclusion")
|
audio = relationship("Audio", back_populates="ai_conclusion")
|
||||||
|
versions = relationship("ConclusionVersion", back_populates="ai_conclusion")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, String, DateTime, UUID, ForeignKey, Float, Integer
|
from sqlalchemy import Column, String, DateTime, Text, UUID, ForeignKey, Float, Integer
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from apiApp.database import Base
|
from apiApp.database import Base
|
||||||
import uuid
|
import uuid
|
||||||
@@ -9,11 +9,16 @@ class Audio(Base):
|
|||||||
__tablename__ = "audio"
|
__tablename__ = "audio"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
filename = Column(String(255), nullable=False)
|
filename = Column(Text, nullable=False)
|
||||||
index_date = Column(DateTime, default=datetime.utcnow)
|
index_date = Column(DateTime, default=datetime.utcnow)
|
||||||
file_path = Column(String(500))
|
file_path = Column(Text)
|
||||||
duration = Column(Float)
|
duration = Column(Float)
|
||||||
file_size = Column(Integer)
|
file_size = Column(Integer)
|
||||||
|
sourse = Column(Text, default="internal")
|
||||||
|
recognition_status = Column(Text, default="pending", index=True) # pending, processing, completed, failed
|
||||||
|
recognition_attempts = Column(Integer, default=0)
|
||||||
|
recognition_last_error = Column(Text, nullable=True)
|
||||||
|
recognition_last_attempt_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
ai_conclusion = relationship("AiConclusion", back_populates="audio", cascade="all, delete-orphan")
|
ai_conclusion = relationship("AiConclusion", back_populates="audio", cascade="all, delete-orphan")
|
||||||
|
|
||||||
@@ -24,5 +29,10 @@ class Audio(Base):
|
|||||||
"index_date": self.index_date.isoformat() if self.index_date else None,
|
"index_date": self.index_date.isoformat() if self.index_date else None,
|
||||||
"file_path": self.file_path,
|
"file_path": self.file_path,
|
||||||
"duration": self.duration,
|
"duration": self.duration,
|
||||||
"file_size": self.file_size
|
"file_size": self.file_size,
|
||||||
|
"sourse": self.sourse,
|
||||||
|
"recognition_status": self.recognition_status,
|
||||||
|
"recognition_attempts": self.recognition_attempts,
|
||||||
|
"recognition_last_error": self.recognition_last_error,
|
||||||
|
"recognition_last_attempt_at": self.recognition_last_attempt_at.isoformat() if self.recognition_last_attempt_at else None,
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from sqlalchemy import Column, UUID, ForeignKey, Integer, Text
|
from sqlalchemy import Column, UUID, ForeignKey, Integer, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
from apiApp.database import Base
|
from apiApp.database import Base
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ class ConclusionVersion(Base):
|
|||||||
__tablename__ = "conclusion_version"
|
__tablename__ = "conclusion_version"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
conclusion_id = Column(UUID(as_uuid=True), ForeignKey("conclusion.id"))
|
conclusion_id = Column(UUID(as_uuid=True), ForeignKey("ai_conclusion.id"))
|
||||||
version = Column(Integer)
|
version = Column(Integer)
|
||||||
content = Column(Text)
|
content = Column(Text)
|
||||||
|
|
||||||
|
ai_conclusion = relationship("AiConclusion", back_populates="versions")
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ class Operator(Base):
|
|||||||
fio = Column(String(100))
|
fio = Column(String(100))
|
||||||
num = Column(Integer)
|
num = Column(Integer)
|
||||||
|
|
||||||
calls = relationship("Call", back_populates="operator")
|
# TODO: Добавить relationship когда будет создана модель Call
|
||||||
|
# calls = relationship("Call", back_populates="operator")
|
||||||
|
|
||||||
|
|||||||
@@ -28,4 +28,7 @@ def get_db():
|
|||||||
from apiApp.database.Operator import Operator
|
from apiApp.database.Operator import Operator
|
||||||
from apiApp.database.Audio import Audio
|
from apiApp.database.Audio import Audio
|
||||||
from apiApp.database.AiConclusion import AiConclusion
|
from apiApp.database.AiConclusion import AiConclusion
|
||||||
from apiApp.database.ConclusionVersion import ConclusionVersion
|
from apiApp.database.ConclusionVersion import ConclusionVersion
|
||||||
|
|
||||||
|
# Все модели должны быть импортированы здесь для правильной работы SQLAlchemy metadata
|
||||||
|
# Это гарантирует, что все relationship будут работать корректно
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from apiApp.routers.audio import router as audio_router
|
from apiApp.routers.audio import router as audio_router
|
||||||
from apiApp.routers.recognition import router as recognition_router
|
from apiApp.routers.recognition import router as recognition_router
|
||||||
|
from apiApp.routers.external_audio import router as external_audio_router
|
||||||
__all__ = ["audio_router", "recognition_router"]
|
__all__ = ["audio_router", "recognition_router", "external_audio_router"]
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""
|
||||||
|
API endpoints для получения AI заключений от GigaAM API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from apiApp.database import get_db
|
||||||
|
from apiApp.database.Audio import Audio
|
||||||
|
from apiApp.database.AiConclusion import AiConclusion
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from apiApp.config import WEBHOOK_ENDPOINT, WEBHOOK_API_KEY
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
ai_conclusion_router = APIRouter()
|
||||||
|
|
||||||
|
def _send_callback(callback_url: str, audio: Audio, conclusion_data: Dict[str, Any]) -> None:
|
||||||
|
"""Отправляем результат клиенту по callback_url (не храним в БД)."""
|
||||||
|
try:
|
||||||
|
callback_url = (callback_url or "").strip()
|
||||||
|
if not callback_url:
|
||||||
|
return
|
||||||
|
if not callback_url.startswith(("http://", "https://")):
|
||||||
|
logger.warning(f"⚠️ Некорректный callback_url для {audio.filename}: {callback_url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"audio_id": str(audio.id),
|
||||||
|
"filename": audio.filename,
|
||||||
|
"result": conclusion_data
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.post(callback_url, json=payload, timeout=30)
|
||||||
|
if 200 <= resp.status_code < 300:
|
||||||
|
logger.info(f"✅ Callback успешно отправлен для {audio.filename}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Callback вернул статус {resp.status_code} для {audio.filename}")
|
||||||
|
logger.warning(f"Response: {resp.text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при отправке callback для {audio.filename}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class AiConclusionRequest(BaseModel):
|
||||||
|
"""Модель запроса для сохранения AI заключения"""
|
||||||
|
filename: str
|
||||||
|
transcription: str
|
||||||
|
corrected_transcription: str
|
||||||
|
analysis: Dict[str, Any]
|
||||||
|
segments: Optional[List[Dict[str, Any]]] = []
|
||||||
|
processing_time_seconds: Optional[float] = 0
|
||||||
|
callback_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AiConclusionResponse(BaseModel):
|
||||||
|
"""Модель ответа"""
|
||||||
|
success: bool
|
||||||
|
message: Optional[str] = None
|
||||||
|
audio_id: Optional[str] = None
|
||||||
|
filename: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecognitionFailedRequest(BaseModel):
|
||||||
|
filename: str
|
||||||
|
error: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConclusionByFilenameResponse(BaseModel):
|
||||||
|
"""Заключение по имени файла"""
|
||||||
|
filename: str
|
||||||
|
audio_id: str
|
||||||
|
conclusion: Dict[str, Any]
|
||||||
|
index_date: Optional[datetime] = None
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
@ai_conclusion_router.get("/conclusion/by-filename/{filename}", response_model=ConclusionByFilenameResponse)
|
||||||
|
async def get_conclusion_by_filename(filename: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Возвращает заключение по распознаванию по имени файла.
|
||||||
|
Имя файла задаётся в path (то же, что сохранено в БД при загрузке).
|
||||||
|
"""
|
||||||
|
audio = db.query(Audio).filter(Audio.filename == filename).first()
|
||||||
|
if not audio:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Файл не найден: {filename}")
|
||||||
|
|
||||||
|
conclusion_row = db.query(AiConclusion).filter(AiConclusion.audio_id == audio.id).first()
|
||||||
|
if not conclusion_row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Заключение по распознаванию для файла не найдено: {filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConclusionByFilenameResponse(
|
||||||
|
filename=audio.filename,
|
||||||
|
audio_id=str(audio.id),
|
||||||
|
conclusion=conclusion_row.conclusion or {},
|
||||||
|
index_date=conclusion_row.index_date,
|
||||||
|
end_date=conclusion_row.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ai_conclusion_router.post("/conclusion/save", response_model=AiConclusionResponse)
|
||||||
|
async def save_ai_conclusion(request: AiConclusionRequest, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Сохраняет AI заключение от GigaAM API
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- **filename**: Имя файла (например, "in-xxx.wav")
|
||||||
|
- **transcription**: Исходная транскрибация
|
||||||
|
- **corrected_transcription**: Исправленная транскрибация
|
||||||
|
- **analysis**: Результат анализа (sentiment, topics, summary)
|
||||||
|
- **segments**: Сегменты диалога (опционально)
|
||||||
|
- **processing_time_seconds**: Время обработки (опционально)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"📥 Получено AI заключение для файла: {request.filename}")
|
||||||
|
|
||||||
|
# Находим аудио файл по имени
|
||||||
|
audio = db.query(Audio).filter(Audio.filename == request.filename).first()
|
||||||
|
|
||||||
|
if not audio:
|
||||||
|
logger.warning(f"⚠️ Файл {request.filename} не найден в БД")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f'Файл {request.filename} не найден'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем, есть ли уже заключение
|
||||||
|
existing_conclusion = db.query(AiConclusion).filter(
|
||||||
|
AiConclusion.audio_id == audio.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
conclusion_data = {
|
||||||
|
"transcription": request.transcription,
|
||||||
|
"ai_transcription": request.corrected_transcription,
|
||||||
|
"conclusion": request.analysis,
|
||||||
|
"segments": request.segments,
|
||||||
|
"processing_time_seconds": request.processing_time_seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing_conclusion:
|
||||||
|
# Обновляем существующее заключение
|
||||||
|
logger.info(f"🔄 Обновление существующего заключения для {request.filename}")
|
||||||
|
existing_conclusion.conclusion = conclusion_data
|
||||||
|
existing_conclusion.end_date = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
# Создаём новое заключение
|
||||||
|
logger.info(f"✅ Создание нового заключения для {request.filename}")
|
||||||
|
new_conclusion = AiConclusion(
|
||||||
|
audio_id=audio.id,
|
||||||
|
conclusion=conclusion_data,
|
||||||
|
index_date=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(new_conclusion)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"✅ Заключение сохранено для {request.filename}")
|
||||||
|
|
||||||
|
# Обновляем статус распознавания у Audio
|
||||||
|
audio.recognition_status = "completed"
|
||||||
|
audio.recognition_last_error = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Для внешних файлов — отправляем результат клиенту из FileAudioAPI
|
||||||
|
if (audio.sourse or "").lower() == "external" and request.callback_url:
|
||||||
|
_send_callback(request.callback_url, audio, conclusion_data)
|
||||||
|
|
||||||
|
# Отправляем webhook в Calls_WEB_Client_main для анализа
|
||||||
|
try:
|
||||||
|
if (audio.sourse or "").lower() != "external":
|
||||||
|
logger.info(f"📤 Отправка webhook в Calls_WEB_Client_main для {request.filename}")
|
||||||
|
|
||||||
|
webhook_payload = {
|
||||||
|
"audio_id": str(audio.id),
|
||||||
|
"filename": request.filename,
|
||||||
|
"transcription": request.transcription,
|
||||||
|
"corrected_transcription": request.corrected_transcription,
|
||||||
|
"segments": request.segments,
|
||||||
|
"processing_time_seconds": request.processing_time_seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
webhook_response = requests.post(
|
||||||
|
WEBHOOK_ENDPOINT,
|
||||||
|
json=webhook_payload,
|
||||||
|
headers={"X-Webhook-Key": WEBHOOK_API_KEY},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if webhook_response.status_code == 200:
|
||||||
|
logger.info(f"✅ Webhook успешно отправлен для {request.filename}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Webhook вернул статус {webhook_response.status_code}")
|
||||||
|
logger.warning(f"Response: {webhook_response.text}")
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
logger.error(f"❌ Не удалось подключиться к Calls_WEB_Client_main webhook: {WEBHOOK_ENDPOINT}")
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning(f"⚠️ Таймаут при отправке webhook для {request.filename}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при отправке webhook: {e}")
|
||||||
|
|
||||||
|
return AiConclusionResponse(
|
||||||
|
success=True,
|
||||||
|
message='Заключение сохранено и отправлено на анализ',
|
||||||
|
audio_id=str(audio.id),
|
||||||
|
filename=request.filename
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при сохранении заключения: {e}")
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ai_conclusion_router.post("/conclusion/failed", response_model=AiConclusionResponse)
|
||||||
|
async def mark_recognition_failed(request: RecognitionFailedRequest, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Помечает распознавание как failed для файла (чтобы auto-restore не пытался бесконечно).
|
||||||
|
Используется GigaAM_API при невозможности получить результат.
|
||||||
|
"""
|
||||||
|
audio = db.query(Audio).filter(Audio.filename == request.filename).first()
|
||||||
|
if not audio:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Файл не найден: {request.filename}")
|
||||||
|
|
||||||
|
audio.recognition_status = "failed"
|
||||||
|
audio.recognition_last_error = request.error
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return AiConclusionResponse(
|
||||||
|
success=True,
|
||||||
|
message="Recognition marked as failed",
|
||||||
|
audio_id=str(audio.id),
|
||||||
|
filename=audio.filename
|
||||||
|
)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
API endpoints для обслуживания аудиофайлов
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from apiApp.database import get_db
|
||||||
|
from apiApp.database.Audio import Audio
|
||||||
|
from apiApp.config import AUDIOFILES_PATH
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
audio_files_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@audio_files_router.get("/audio/{filename}")
|
||||||
|
async def get_audio_file(filename: str):
|
||||||
|
"""
|
||||||
|
Возвращает аудиофайл для стриминга/воспроизведения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Имя аудиофайла (например, "in-xxx.wav")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse с аудиофайлом
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Проверяем расширение файла
|
||||||
|
allowed_extensions = ['.wav', '.mp3', '.ogg', '.m4a', '.flac']
|
||||||
|
if not any(filename.lower().endswith(ext) for ext in allowed_extensions):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f'Неподдерживаемый формат файла. Разрешены: {", ".join(allowed_extensions)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Формируем путь к файлу
|
||||||
|
file_path = os.path.join(AUDIOFILES_PATH, filename)
|
||||||
|
|
||||||
|
# Проверяем существование файла
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
logger.warning(f"⚠️ Файл не найден: {file_path}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f'Файл {filename} не найден'
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"🎵 Отдача аудио: {filename}")
|
||||||
|
|
||||||
|
# Определяем MIME тип
|
||||||
|
import mimetypes
|
||||||
|
mime_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
if mime_type is None:
|
||||||
|
mime_type = 'audio/wav'
|
||||||
|
|
||||||
|
# Возвращаем файл как поток
|
||||||
|
def iterfile():
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
data = f.read(8192)
|
||||||
|
while data:
|
||||||
|
yield data
|
||||||
|
data = f.read(8192)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iterfile(),
|
||||||
|
media_type=mime_type,
|
||||||
|
headers={
|
||||||
|
'Content-Disposition': f'inline; filename="{filename}"',
|
||||||
|
'Accept-Ranges': 'bytes'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при отдаче аудио: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f'Ошибка при отдаче файла: {str(e)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@audio_files_router.get("/audio/{filename}/info")
|
||||||
|
async def get_audio_info(filename: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Возвращает информацию об аудиофайле
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Имя аудиофайла
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON с информацией о файле
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Ищем информацию в БД
|
||||||
|
audio = db.query(Audio).filter(Audio.filename == filename).first()
|
||||||
|
|
||||||
|
if not audio:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f'Файл {filename} не найден в БД'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем существование файла
|
||||||
|
file_path = os.path.join(AUDIOFILES_PATH, filename)
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f'Файл {filename} не найден на диске'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем размер файла
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
# Определяем длительность (приблизительно)
|
||||||
|
# В идеале использовать библиотеку типа mutagen или soundfile
|
||||||
|
duration = None
|
||||||
|
try:
|
||||||
|
import wave
|
||||||
|
with wave.open(file_path, 'r') as wav_file:
|
||||||
|
frames = wav_file.getnframes()
|
||||||
|
rate = wav_file.getframerate()
|
||||||
|
duration = frames / float(rate) if rate > 0 else 0
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"filename": filename,
|
||||||
|
"file_size_bytes": file_size,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"created_at": audio.index_date.isoformat() if audio.index_date else None,
|
||||||
|
"audio_id": str(audio.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при получении информации: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@audio_files_router.get("/audio/list")
|
||||||
|
async def list_audio_files(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Возвращает список аудиофайлов
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
limit: Максимальное количество файлов (default: 100)
|
||||||
|
offset: Смещение для пагинации (default: 0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON со списком файлов
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Получаем список файлов из БД
|
||||||
|
query = db.query(Audio).order_by(Audio.index_date.desc())
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
audio_files = query.offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
files_info = []
|
||||||
|
for audio in audio_files:
|
||||||
|
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||||
|
exists = os.path.exists(file_path)
|
||||||
|
|
||||||
|
files_info.append({
|
||||||
|
"filename": audio.filename,
|
||||||
|
"file_size": audio.file_size,
|
||||||
|
"created_at": audio.index_date.isoformat() if audio.index_date else None,
|
||||||
|
"exists": exists,
|
||||||
|
"audio_id": str(audio.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"total": total,
|
||||||
|
"count": len(files_info),
|
||||||
|
"files": files_info
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при получении списка файлов: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@audio_files_router.get("/audio/{filename}/download")
|
||||||
|
async def download_audio_file(filename: str):
|
||||||
|
"""
|
||||||
|
Возвращает аудиофайл для скачивания
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Имя аудиофайла
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileResponse для скачивания
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Формируем путь к файлу
|
||||||
|
file_path = os.path.join(AUDIOFILES_PATH, filename)
|
||||||
|
|
||||||
|
# Проверяем существование файла
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f'Файл {filename} не найден'
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"📥 Скачивание аудио: {filename}")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
media_type='audio/wav',
|
||||||
|
filename=filename
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при скачивании: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Исправленная версия для проверки Audio без AiConclusion
|
||||||
|
"""
|
||||||
|
from sqlalchemy import exists
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_without_conclusion(db, limit=100):
|
||||||
|
"""
|
||||||
|
Находит все Audio, у которых нет AiConclusion
|
||||||
|
|
||||||
|
Использует подзапрос через exists, так как AiConclusion - это relationship
|
||||||
|
"""
|
||||||
|
# Импортируем модели
|
||||||
|
from apiApp.database.Audio import Audio
|
||||||
|
from apiApp.database.AiConclusion import AiConclusion
|
||||||
|
|
||||||
|
# Создаём подзапрос для проверки наличия AiConclusion
|
||||||
|
subquery = db.query(AiConclusion.audio_id).filter(
|
||||||
|
AiConclusion.audio_id == Audio.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Находим Audio без AiConclusion
|
||||||
|
pending_audio = db.query(Audio).filter(
|
||||||
|
~exists().where(subquery.exists())
|
||||||
|
).order_by(Audio.index_date.asc()).limit(limit).all()
|
||||||
|
|
||||||
|
# Считаем total
|
||||||
|
total_pending = db.query(Audio).filter(
|
||||||
|
~exists().where(subquery.exists())
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return pending_audio, total_pending
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
"""
|
||||||
|
API endpoints для управления аудиофайлами (регистрация и пакетная обработка)
|
||||||
|
Используется Calls_WEB_Client_main для оркестрации процесса распознавания
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from apiApp.database import get_db
|
||||||
|
from apiApp.database.Audio import Audio
|
||||||
|
from apiApp.database.AiConclusion import AiConclusion
|
||||||
|
from apiApp.config import AUDIOFILES_PATH, MAX_RECOGNITION_ATTEMPTS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
audio_management_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def query_audio_without_conclusion(db, limit=None):
|
||||||
|
"""
|
||||||
|
Возвращает запрос для поиска Audio без AiConclusion
|
||||||
|
|
||||||
|
Использует exists() подзапрос, так как AiConclusion - это relationship
|
||||||
|
"""
|
||||||
|
from sqlalchemy import exists, not_
|
||||||
|
|
||||||
|
# Используем более простой подзапрос
|
||||||
|
subquery = exists().where(
|
||||||
|
AiConclusion.audio_id == Audio.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Берём только те, которые еще можно/нужно распознавать
|
||||||
|
query = db.query(Audio).filter(
|
||||||
|
~subquery
|
||||||
|
).filter(
|
||||||
|
Audio.sourse == "internal"
|
||||||
|
).filter(
|
||||||
|
(Audio.recognition_status.in_(["pending", "processing"])) |
|
||||||
|
((Audio.recognition_status == "failed") & (Audio.recognition_attempts < MAX_RECOGNITION_ATTEMPTS))
|
||||||
|
).order_by(Audio.index_date.asc())
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
class AudioRegisterRequest(BaseModel):
|
||||||
|
"""Запрос на регистрацию аудиофайла"""
|
||||||
|
filename: str
|
||||||
|
file_path: str # Полный путь к файлу в общей папке audiofiles
|
||||||
|
|
||||||
|
|
||||||
|
class AudioProcessAllRequest(BaseModel):
|
||||||
|
"""Запрос на пакетное распознавание"""
|
||||||
|
limit: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class AudioRegisterResponse(BaseModel):
|
||||||
|
"""Ответ на регистрацию аудиофайла"""
|
||||||
|
id: str
|
||||||
|
filename: str
|
||||||
|
file_size: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@audio_management_router.post("/audio/register", response_model=AudioRegisterResponse, status_code=201)
|
||||||
|
async def register_audio_file(
|
||||||
|
request: AudioRegisterRequest,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Регистрация аудиофайла в БД (без копирования файла)
|
||||||
|
|
||||||
|
Создаёт запись в таблице Audio для файла, который уже находится
|
||||||
|
в общей папке audiofiles. НЕ копирует файл, только создаёт запись в БД.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: {filename: "in-xxx.wav", file_path: "/app/audiofiles/in-xxx.wav"}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
201 Created + информация о созданной записи
|
||||||
|
400 Bad Request если файл уже зарегистрирован
|
||||||
|
404 Not Found если файл не существует на диске
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
filename = request.filename
|
||||||
|
file_path = request.file_path
|
||||||
|
|
||||||
|
logger.info(f"📝 Регистрация файла: {filename}")
|
||||||
|
|
||||||
|
# Проверяем, что файл уже существует в общей папке
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
logger.error(f"❌ Файл не найден: {file_path}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f'Файл не найден на диске: {file_path}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем, что файл не был уже зарегистрирован
|
||||||
|
existing_audio = db.query(Audio).filter(Audio.filename == filename).first()
|
||||||
|
if existing_audio:
|
||||||
|
logger.warning(f"⚠️ Файл уже зарегистрирован: {filename}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f'Файл {filename} уже зарегистрирован в БД'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем размер файла
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
# Создаём запись в БД
|
||||||
|
audio = Audio()
|
||||||
|
audio.filename = filename
|
||||||
|
audio.file_size = file_size
|
||||||
|
audio.index_date = datetime.utcnow()
|
||||||
|
audio.recognition_status = "pending"
|
||||||
|
audio.recognition_attempts = 0
|
||||||
|
audio.recognition_last_error = None
|
||||||
|
audio.recognition_last_attempt_at = None
|
||||||
|
|
||||||
|
db.add(audio)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(audio)
|
||||||
|
|
||||||
|
logger.info(f"✅ Файл зарегистрирован: {filename} (audio_id={audio.id})")
|
||||||
|
|
||||||
|
return AudioRegisterResponse(
|
||||||
|
id=str(audio.id),
|
||||||
|
filename=audio.filename,
|
||||||
|
file_size=audio.file_size,
|
||||||
|
created_at=audio.index_date
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"❌ Ошибка при регистрации файла: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f'Ошибка при регистрации: {str(e)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_audio_file(audio_id: str, db: Session):
|
||||||
|
"""
|
||||||
|
Фоновая обработка одного аудиофайла
|
||||||
|
|
||||||
|
Отправляет запрос в GigaAM API для распознавания
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
audio = db.query(Audio).filter(Audio.id == audio_id).first()
|
||||||
|
if not audio:
|
||||||
|
logger.error(f"❌ Audio {audio_id} не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"🎵 Запуск распознавания для {audio.filename}")
|
||||||
|
audio.recognition_status = "processing"
|
||||||
|
audio.recognition_attempts = (audio.recognition_attempts or 0) + 1
|
||||||
|
audio.recognition_last_attempt_at = datetime.utcnow()
|
||||||
|
audio.recognition_last_error = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Проверяем что файл существует на диске
|
||||||
|
from apiApp.config import AUDIOFILES_PATH
|
||||||
|
import os
|
||||||
|
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
logger.error(f"❌ Файл не найден на диске в FileAudioAPI: {file_path}")
|
||||||
|
audio.recognition_status = "failed"
|
||||||
|
audio.recognition_last_error = f"File not found on disk: {file_path}"
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
logger.info(f"📁 Файл найден: {file_path} (размер: {file_size} bytes)")
|
||||||
|
|
||||||
|
# Формируем запрос в GigaAM API - ТОЛЬКО асинхронный endpoint
|
||||||
|
from apiApp.config import GIGAAM_API_URL
|
||||||
|
|
||||||
|
# Используем только асинхронный endpoint
|
||||||
|
api_url = f"{GIGAAM_API_URL}/api/call/process/async"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"filename": audio.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
import requests
|
||||||
|
try:
|
||||||
|
# Отправляем запрос в асинхронный endpoint
|
||||||
|
response = requests.post(api_url, json=payload, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200 or response.status_code == 202:
|
||||||
|
result = response.json()
|
||||||
|
task_id = result.get('task_id')
|
||||||
|
logger.info(f"✅ Задача добавлена в очередь для {audio.filename} (task_id: {task_id})")
|
||||||
|
else:
|
||||||
|
error_detail = response.text
|
||||||
|
logger.error(f"❌ Ошибка запуска распознавания для {audio.filename}: {response.status_code}")
|
||||||
|
logger.error(f" Detail: {error_detail}")
|
||||||
|
audio.recognition_status = "failed"
|
||||||
|
audio.recognition_last_error = f"GigaAM start failed: {response.status_code} {error_detail}"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error(f"❌ Таймаут при отправке задачи для {audio.filename}")
|
||||||
|
audio.recognition_status = "failed"
|
||||||
|
audio.recognition_last_error = "Timeout when starting recognition in GigaAM"
|
||||||
|
db.commit()
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
logger.error(f"❌ Ошибка подключения к GigaAM API для {audio.filename}: {e}")
|
||||||
|
audio.recognition_status = "failed"
|
||||||
|
audio.recognition_last_error = f"Connection error when starting recognition in GigaAM: {e}"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при обработке {audio_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@audio_management_router.post("/audio/process-all")
|
||||||
|
async def process_all_pending_audio(
|
||||||
|
request: AudioProcessAllRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Запуск распознавания для всех Audio без AiConclusion
|
||||||
|
|
||||||
|
Находит все записи Audio, у которых нет AiConclusion, и запускает
|
||||||
|
распознавание для них (до указанного лимита).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: {limit: 100} - максимум файлов для обработки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200 OK + {
|
||||||
|
"started_count": 15,
|
||||||
|
"pending_files": ["file1.wav", "file2.wav", ...],
|
||||||
|
"total_pending": 50,
|
||||||
|
"skipped_duplicates": 2
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
limit = request.limit
|
||||||
|
|
||||||
|
logger.info(f"🚀 Поиск Audio без AiConclusion (limit={limit})")
|
||||||
|
|
||||||
|
# Находим все Audio без AiConclusion используя вспомогательную функцию
|
||||||
|
pending_audio = query_audio_without_conclusion(db, limit).all()
|
||||||
|
total_pending = query_audio_without_conclusion(db).count()
|
||||||
|
|
||||||
|
if not pending_audio:
|
||||||
|
logger.info("ℹ️ Нет файлов для распознавания")
|
||||||
|
return {
|
||||||
|
"started_count": 0,
|
||||||
|
"pending_files": [],
|
||||||
|
"total_pending": 0,
|
||||||
|
"skipped_duplicates": 0,
|
||||||
|
"message": "Нет файлов без AiConclusion"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"📋 Найдено файлов для обработки: {len(pending_audio)} из {total_pending}")
|
||||||
|
|
||||||
|
# Добавляем задачи в фон
|
||||||
|
started_count = 0
|
||||||
|
pending_files = []
|
||||||
|
skipped_duplicates = 0 # Счётчик дубликатов
|
||||||
|
|
||||||
|
for audio in pending_audio:
|
||||||
|
# Проверяем, что файл существует
|
||||||
|
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
logger.warning(f"⚠️ Файл не найден на диске: {audio.filename}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ПРОВЕРКА НА ДУБЛИКАТЫ: Проверяем, не обрабатывается ли файл уже
|
||||||
|
import requests
|
||||||
|
try:
|
||||||
|
# Проверяем статус через GigaAM API
|
||||||
|
check_url = f"{os.getenv('GIGAAM_API_URL', 'http://gigaam_api:5001')}/api/call/check-status"
|
||||||
|
check_response = requests.post(
|
||||||
|
check_url,
|
||||||
|
json={"filename": audio.filename},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if check_response.status_code == 200:
|
||||||
|
check_data = check_response.json()
|
||||||
|
if check_data.get('in_queue', False):
|
||||||
|
logger.info(f"⏭️ Файл уже в очереди GigaAM: {audio.filename}")
|
||||||
|
skipped_duplicates += 1
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
# Если проверка упала - всё равно продолжаем
|
||||||
|
logger.debug(f"Не удалось проверить статус {audio.filename}: {e}")
|
||||||
|
|
||||||
|
# Добавляем в фон (асинхронно)
|
||||||
|
pending_files.append(audio.filename)
|
||||||
|
started_count += 1
|
||||||
|
|
||||||
|
# Запускаем обработку в фоне
|
||||||
|
background_tasks.add_task(
|
||||||
|
process_single_audio,
|
||||||
|
str(audio.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Запущено распознавание для {started_count} файлов")
|
||||||
|
if skipped_duplicates > 0:
|
||||||
|
logger.info(f"⏭️ Пропущено дубликатов: {skipped_duplicates}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"started_count": started_count,
|
||||||
|
"pending_files": pending_files,
|
||||||
|
"total_pending": total_pending,
|
||||||
|
"skipped_duplicates": skipped_duplicates
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при запуске пакетного распознавания: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f'Ошибка: {str(e)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_single_audio(audio_id: str):
|
||||||
|
"""
|
||||||
|
Обработка одного аудиофайла в фоне
|
||||||
|
|
||||||
|
Создаёт новую DB сессию для обработки
|
||||||
|
"""
|
||||||
|
from apiApp.database import SessionLocal
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
process_audio_file(audio_id, db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@audio_management_router.get("/audio/pending")
|
||||||
|
async def get_pending_audio(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
limit: int = 100
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получить список Audio без AiConclusion
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
limit: Максимум файлов (default: 100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список файлов, ожидающих распознавания
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pending_audio = query_audio_without_conclusion(db, limit).all()
|
||||||
|
|
||||||
|
files_info = []
|
||||||
|
for audio in pending_audio:
|
||||||
|
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||||
|
exists = os.path.exists(file_path)
|
||||||
|
|
||||||
|
files_info.append({
|
||||||
|
"audio_id": str(audio.id),
|
||||||
|
"filename": audio.filename,
|
||||||
|
"file_size": audio.file_size,
|
||||||
|
"created_at": audio.index_date.isoformat() if audio.index_date else None,
|
||||||
|
"exists_on_disk": exists
|
||||||
|
})
|
||||||
|
|
||||||
|
total_pending = query_audio_without_conclusion(db).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_pending": total_pending,
|
||||||
|
"count": len(files_info),
|
||||||
|
"files": files_info
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при получении списка: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@audio_management_router.get("/audio/stats")
|
||||||
|
async def get_audio_stats(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Получить статистику по аудиофайлам
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Статистика по Audio записям
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
total_audio = db.query(Audio).count()
|
||||||
|
with_conclusion = total_audio - query_audio_without_conclusion(db).count()
|
||||||
|
without_conclusion = query_audio_without_conclusion(db).count()
|
||||||
|
|
||||||
|
# Проверяем существование файлов на диске
|
||||||
|
all_audio = db.query(Audio).all()
|
||||||
|
existing_count = 0
|
||||||
|
missing_files = []
|
||||||
|
small_files = [] # Файлы меньше 1KB
|
||||||
|
|
||||||
|
for audio in all_audio:
|
||||||
|
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
existing_count += 1
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
if file_size < 1000:
|
||||||
|
small_files.append({
|
||||||
|
"audio_id": str(audio.id),
|
||||||
|
"filename": audio.filename,
|
||||||
|
"file_size": file_size,
|
||||||
|
"index_date": audio.index_date.isoformat() if audio.index_date else None
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
missing_files.append({
|
||||||
|
"audio_id": str(audio.id),
|
||||||
|
"filename": audio.filename,
|
||||||
|
"index_date": audio.index_date.isoformat() if audio.index_date else None
|
||||||
|
})
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_audio": total_audio,
|
||||||
|
"with_conclusion": with_conclusion,
|
||||||
|
"without_conclusion": without_conclusion,
|
||||||
|
"existing_on_disk": existing_count,
|
||||||
|
"missing_on_disk": total_audio - existing_count,
|
||||||
|
"small_files_count": len(small_files)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Добавляем списки проблемных файлов (первые 50 каждого типа)
|
||||||
|
if missing_files:
|
||||||
|
stats["missing_files_sample"] = missing_files[:50]
|
||||||
|
logger.warning(f"⚠️ Найдено {len(missing_files)} отсутствующих файлов")
|
||||||
|
|
||||||
|
if small_files:
|
||||||
|
stats["small_files_sample"] = small_files[:50]
|
||||||
|
logger.warning(f"⚠️ Найдено {len(small_files)} файлов меньше 1KB")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при получении статистики: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@audio_management_router.delete("/audio/cleanup")
|
||||||
|
async def cleanup_invalid_audio_files(
|
||||||
|
delete_missing: bool = False,
|
||||||
|
delete_small: bool = True,
|
||||||
|
min_size_bytes: int = 1000,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Удаляет записи Audio для проблемных файлов
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
delete_missing: Удалять записи с отсутствующими файлами (default: False)
|
||||||
|
delete_small: Удалять записи с маленькими файлами (default: True)
|
||||||
|
min_size_bytes: Минимальный размер файла в bytes (default: 1000)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Статистику удаления
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
all_audio = db.session.query(Audio).all()
|
||||||
|
|
||||||
|
deleted_missing = 0
|
||||||
|
deleted_small = 0
|
||||||
|
|
||||||
|
for audio in all_audio:
|
||||||
|
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||||
|
|
||||||
|
# Проверяем отсутствие файла
|
||||||
|
if delete_missing and not os.path.exists(file_path):
|
||||||
|
logger.info(f"🗑️ Удаление записи с отсутствующим файлом: {audio.filename}")
|
||||||
|
db.session.delete(audio)
|
||||||
|
deleted_missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверяем размер файла
|
||||||
|
if delete_small and os.path.exists(file_path):
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
if file_size < min_size_bytes:
|
||||||
|
logger.info(f"🗑️ Удаление записи с маленьким файлом ({file_size} bytes): {audio.filename}")
|
||||||
|
# Удаляем и файл тоже
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
logger.info(f" Файл удалён: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" Не удалось удалить файл: {e}")
|
||||||
|
|
||||||
|
db.session.delete(audio)
|
||||||
|
deleted_small += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"deleted_missing": deleted_missing,
|
||||||
|
"deleted_small": deleted_small,
|
||||||
|
"total_deleted": deleted_missing + deleted_small,
|
||||||
|
"message": f"Удалено {deleted_missing + deleted_small} записей"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"❌ Ошибка при очистке: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File as FastAPIFile, Form, status
|
||||||
|
from apiApp.database import get_db
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import os, uuid
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from apiApp.config import ALLOWED_AUDIO_EXTENSIONS, MAX_UPLOAD_SIZE, AUDIOFILES_PATH, GIGAAM_API_URL
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
|
from apiApp.schemas import AudioCreate
|
||||||
|
from apiApp.services import AudioCRUD
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/external_audio",
|
||||||
|
tags=["Внешние аудиофайлы"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_external_audio(
|
||||||
|
file: UploadFile = FastAPIFile(...),
|
||||||
|
callback_url: str = Form(None, description="URL для отправки результата распознавания (опционально). Если передан — FileAudioAPI вызовет GigaAM и затем отправит результат на этот URL."),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Загрузка внешнего аудиофайла. Файл сохраняется в общей папке.
|
||||||
|
Если передан callback_url — автоматически отправляется на распознавание в GigaAM,
|
||||||
|
а результат придёт на callback_url.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Проверка расширения файла
|
||||||
|
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||||
|
if file_ext not in ALLOWED_AUDIO_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"File extension not allowed. Allowed: {', '.join(ALLOWED_AUDIO_EXTENSIONS)}"
|
||||||
|
)
|
||||||
|
content = await file.read()
|
||||||
|
# Проверка размера файла
|
||||||
|
if len(content) > MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"File too large. Maximum size: {MAX_UPLOAD_SIZE / (1024*1024)}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохранение в общей папке под именем uuid+ext; это же имя передаём в GigaAM
|
||||||
|
safe_name = f"{uuid.uuid4()}{file_ext}"
|
||||||
|
upload_dir = Path(AUDIOFILES_PATH) if isinstance(AUDIOFILES_PATH, str) else AUDIOFILES_PATH
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path = upload_dir / safe_name
|
||||||
|
try:
|
||||||
|
async with aiofiles.open(file_path, 'wb') as f:
|
||||||
|
await f.write(content)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error saving file: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создание записи в БД (filename = имя файла на диске, его же передаём в GigaAM)
|
||||||
|
try:
|
||||||
|
audio_data = AudioCreate(filename=safe_name)
|
||||||
|
audio = AudioCRUD.create(
|
||||||
|
db=db,
|
||||||
|
audio_data=audio_data,
|
||||||
|
file_path=str(file_path),
|
||||||
|
file_size=len(content),
|
||||||
|
sourse="external"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error creating database record: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Всегда запускаем распознавание в GigaAM; callback_url передаём только если передан
|
||||||
|
cb = callback_url.strip() if (callback_url and callback_url.strip()) else None
|
||||||
|
task_id, recognition_error = send_to_recognition(filename=safe_name, callback_url=cb)
|
||||||
|
|
||||||
|
# Ответ: запись аудио + данные по постановке в очередь распознавания
|
||||||
|
result = {
|
||||||
|
"id": str(audio.id),
|
||||||
|
"filename": audio.filename,
|
||||||
|
"index_date": audio.index_date.isoformat() if audio.index_date else None,
|
||||||
|
"file_path": audio.file_path,
|
||||||
|
"file_size": audio.file_size,
|
||||||
|
"sourse": audio.sourse,
|
||||||
|
}
|
||||||
|
if task_id:
|
||||||
|
result["recognition_task_id"] = task_id
|
||||||
|
result["recognition_status_url"] = f"{GIGAAM_API_URL.rstrip('/')}/api/call/task/{task_id}"
|
||||||
|
if recognition_error:
|
||||||
|
result["recognition_error"] = recognition_error
|
||||||
|
return result
|
||||||
|
|
||||||
|
def send_to_recognition(filename: str, callback_url: str | None = None) -> tuple:
|
||||||
|
"""
|
||||||
|
Отправка аудиофайла на распознавание в GigaAM.
|
||||||
|
Файл должен уже лежать в общей папке под именем filename.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(task_id или None, recognition_error или None)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
gigaam_url = f"{GIGAAM_API_URL.rstrip('/')}/api/call/external/process"
|
||||||
|
payload = {"filename": filename}
|
||||||
|
if callback_url:
|
||||||
|
payload["callback_url"] = callback_url
|
||||||
|
resp = requests.post(
|
||||||
|
gigaam_url,
|
||||||
|
json=payload,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if resp.status_code == 202:
|
||||||
|
data = resp.json()
|
||||||
|
task_id = data.get("task_id")
|
||||||
|
logger.info(f"✅ Внешний файл {filename} поставлен в очередь GigaAM, task_id={task_id}")
|
||||||
|
return (task_id, None)
|
||||||
|
recognition_error = resp.text or f"HTTP {resp.status_code}"
|
||||||
|
logger.warning(f"⚠️ GigaAM не принял задачу {filename}: {recognition_error}")
|
||||||
|
return (None, recognition_error)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.warning(f"⚠️ Ошибка вызова GigaAM для {filename}: {e}")
|
||||||
|
return (None, str(e))
|
||||||
@@ -26,13 +26,20 @@ class AudioCRUD:
|
|||||||
return db.query(Audio).filter(Audio.filename == filename).first()
|
return db.query(Audio).filter(Audio.filename == filename).first()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(db: Session, audio_data: AudioCreate, file_path: str, file_size: int = None) -> Audio:
|
def create(
|
||||||
|
db: Session,
|
||||||
|
audio_data: AudioCreate,
|
||||||
|
file_path: str,
|
||||||
|
file_size: int = None,
|
||||||
|
sourse: str = "internal"
|
||||||
|
) -> Audio:
|
||||||
"""Создать новую запись аудиофайла"""
|
"""Создать новую запись аудиофайла"""
|
||||||
db_audio = Audio(
|
db_audio = Audio(
|
||||||
filename=audio_data.filename,
|
filename=audio_data.filename,
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
index_date=datetime.datetime.utcnow(),
|
index_date=datetime.datetime.utcnow(),
|
||||||
file_size=file_size
|
file_size=file_size,
|
||||||
|
sourse=sourse
|
||||||
)
|
)
|
||||||
db.add(db_audio)
|
db.add(db_audio)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from autoLoader.database import *
|
||||||
|
from autoLoader.loader import Loader
|
||||||
|
loader = Loader()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
# SFTP конфигурация
|
||||||
|
SFTP_HOSTNAME = os.getenv("SFTP_HOSTNAME", "192.168.1.150")
|
||||||
|
SFTP_USERNAME = os.getenv("SFTP_USERNAME", "monitor")
|
||||||
|
SFTP_PASSWORD = os.getenv("SFTP_PASSWORD", "Audio4analy6!6")
|
||||||
|
FILESAPTH = os.getenv("FILESAPTH", "audiofiles")
|
||||||
|
|
||||||
|
# База данных
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./speech_analytics.db")
|
||||||
|
|
||||||
|
# GigaAM API
|
||||||
|
GIGAAM_API_URL = os.getenv("GIGAAM_API_URL", "http://localhost:5001")
|
||||||
|
|
||||||
|
#App Setting
|
||||||
|
PORT = 5008
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
from autoLoader.config import DATABASE_URL
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
# Создаём engine, но используем Base из apiApp.database
|
||||||
|
from apiApp.database import Base, engine
|
||||||
|
|
||||||
|
# SessionLocal (используем тот же engine)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
# Зависимость для получения сессии БД (для FastAPI)
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Контекстный менеджер для использования в loader
|
||||||
|
@contextmanager
|
||||||
|
def get_db_session():
|
||||||
|
"""Контекстный менеджер для работы с БД в loader"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Импортируем ТОЛЬКО нужные модели из apiApp.database
|
||||||
|
# НЕ импортируем из autoLoader.database, чтобы избежать дублирования таблиц
|
||||||
|
from apiApp.database.Audio import Audio
|
||||||
|
from apiApp.database.AiConclusion import AiConclusion
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .loader import Loader
|
||||||
|
from .connector import ConnectorSFTP
|
||||||
|
from .recognition_checker import RecognitionChecker, process_pending_files
|
||||||
|
|
||||||
|
__all__ = ['Loader', 'ConnectorSFTP', 'RecognitionChecker', 'process_pending_files']
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from autoLoader.config import SFTP_HOSTNAME, SFTP_USERNAME, SFTP_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
|
import socket
|
||||||
|
def check_connection():
|
||||||
|
"""Проверка доступности сервера"""
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(10)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = sock.connect_ex((SFTP_HOSTNAME, 22))
|
||||||
|
if result == 0:
|
||||||
|
print("Порт 22 доступен")
|
||||||
|
else:
|
||||||
|
print(f"Порт 22 недоступен. Код ошибки: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка проверки соединения: {e}")
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
# Перед подключением вызовите проверку
|
||||||
|
# check_connection()
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
class ConnectorSFTP():
|
||||||
|
def __init__(self):
|
||||||
|
self.sftp = None
|
||||||
|
self.ssh = paramiko.SSHClient()
|
||||||
|
|
||||||
|
def connect(self, remote_path: str):
|
||||||
|
try:
|
||||||
|
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
self.ssh.connect(
|
||||||
|
hostname=SFTP_HOSTNAME,
|
||||||
|
username=SFTP_USERNAME,
|
||||||
|
password=SFTP_PASSWORD,
|
||||||
|
)
|
||||||
|
self.sftp = self.ssh.open_sftp()
|
||||||
|
remote_path = remote_path.lstrip('/') # Удаляем начальный слэш, если есть
|
||||||
|
self.sftp.chdir(remote_path) # Переходим в директори
|
||||||
|
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
print("Ошибка аутентификации. Проверьте имя пользователя и пароль.")
|
||||||
|
return None, None
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
print(f"Ошибка SSH: {e}")
|
||||||
|
return None, None
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Директория {remote_path} не найдена на сервере.")
|
||||||
|
return None, None
|
||||||
|
except IOError as e:
|
||||||
|
print(f"Ошибка ввода-вывода: {e}")
|
||||||
|
return None, None
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
from autoLoader.config import FILESAPTH
|
||||||
|
from autoLoader.loader.connector import ConnectorSFTP
|
||||||
|
import datetime, os
|
||||||
|
from autoLoader.database import Audio, get_db_session
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
local_path = os.path.join(os.getcwd(), FILESAPTH)
|
||||||
|
|
||||||
|
class Loader():
|
||||||
|
def __init__(self):
|
||||||
|
self.call_types = ['in']
|
||||||
|
self.loaded_files = [] # Список загруженных файлов
|
||||||
|
|
||||||
|
def filter_call(self, filename: str):
|
||||||
|
if filename.split("-")[0] in self.call_types:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_database(self):
|
||||||
|
"""Проверяет существование таблиц в БД"""
|
||||||
|
from autoLoader.database import engine
|
||||||
|
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if 'audio' not in existing_tables:
|
||||||
|
print("❌ Таблица 'audio' не существует в базе данных!")
|
||||||
|
print("💡 Запустите 'python init_db.py' для создания таблиц")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def send_to_recognition(self, limit: int = None):
|
||||||
|
"""
|
||||||
|
Отправляет загруженные файлы на распознавание
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Максимальное количество файлов для отправки
|
||||||
|
"""
|
||||||
|
if not self.loaded_files:
|
||||||
|
print("⏭️ Нет файлов для отправки на распознавание")
|
||||||
|
return
|
||||||
|
|
||||||
|
from autoLoader.loader import RecognitionChecker
|
||||||
|
|
||||||
|
print(f"\n🎤 Отправка {len(self.loaded_files)} файлов на распознавание...")
|
||||||
|
|
||||||
|
checker = RecognitionChecker()
|
||||||
|
|
||||||
|
# Проверяем доступность API
|
||||||
|
if not checker.check_api_availability():
|
||||||
|
print("❌ GigaAM API недоступен. Пропускаем распознавание.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем БД
|
||||||
|
if not checker.check_database():
|
||||||
|
print("❌ База данных не готова. Пропускаем распознавание.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Фильтруем только что загруженные файлы без заключения
|
||||||
|
files_to_send = []
|
||||||
|
for audio in self.loaded_files:
|
||||||
|
# Проверяем, есть ли у файла заключение
|
||||||
|
with get_db_session() as db:
|
||||||
|
from autoLoader.database import AiConclusion
|
||||||
|
existing_conclusion = db.query(AiConclusion).filter(
|
||||||
|
AiConclusion.audio_id == audio.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_conclusion:
|
||||||
|
print(f"⏭️ Файл {audio.filename} уже имеет заключение")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Конвертируем SQLAlchemy объект в словарь
|
||||||
|
files_to_send.append({
|
||||||
|
'id': str(audio.id),
|
||||||
|
'filename': audio.filename,
|
||||||
|
'file_size': audio.file_size,
|
||||||
|
'index_date': audio.index_date.isoformat() if audio.index_date else None
|
||||||
|
})
|
||||||
|
|
||||||
|
if not files_to_send:
|
||||||
|
print("✅ Все файлы уже имеют заключения")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Отправляем файлы параллельно
|
||||||
|
print(f"🚀 Отправка {len(files_to_send)} файлов параллельно...")
|
||||||
|
results = checker.send_to_recognition_parallel(files_to_send)
|
||||||
|
|
||||||
|
print(f"✅ Отправлено {results['sent']} из {results['total']} файлов на распознавание")
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
# Проверяем БД перед началом работы
|
||||||
|
if not self.check_database():
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
date_now = datetime.datetime.now()# - datetime.timedelta(days=1)
|
||||||
|
remote_path = f"/{date_now.strftime('%Y/%m/%d')}"
|
||||||
|
|
||||||
|
connector = ConnectorSFTP()
|
||||||
|
connector.connect(remote_path=remote_path)
|
||||||
|
|
||||||
|
sftp_client = connector.sftp
|
||||||
|
ssh_client = connector.ssh
|
||||||
|
|
||||||
|
if sftp_client is None or ssh_client is None:
|
||||||
|
print("Не удалось подключиться к SFTP. Завершение работы.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
listdir = sftp_client.listdir()
|
||||||
|
os.makedirs(local_path, exist_ok = True)
|
||||||
|
|
||||||
|
for file in listdir:
|
||||||
|
if self.filter_call(filename=file):
|
||||||
|
remote_file = f"{file}".lstrip('/')
|
||||||
|
filepath = os.path.join(local_path, file)
|
||||||
|
|
||||||
|
# Проверяем, существует ли файл локально
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
print(f"Файл уже существует локально: {file}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Скачиваем файл
|
||||||
|
sftp_client.get(remote_file, filepath)
|
||||||
|
print(f"📥 Скачан файл: {remote_file}")
|
||||||
|
|
||||||
|
# Получаем размер файла
|
||||||
|
file_size = os.path.getsize(filepath)
|
||||||
|
|
||||||
|
# Сохраняем в БД через контекстный менеджер
|
||||||
|
with get_db_session() as db:
|
||||||
|
# Проверяем, есть ли уже такой файл в БД
|
||||||
|
existing_audio = db.query(Audio).filter(Audio.filename == file).first()
|
||||||
|
if existing_audio:
|
||||||
|
print(f"⏭️ Файл {file} уже есть в БД, пропускаем")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Создаём новую запись
|
||||||
|
audio = Audio()
|
||||||
|
audio.index_date = datetime.datetime.now()
|
||||||
|
audio.filename = file
|
||||||
|
audio.file_size = file_size
|
||||||
|
|
||||||
|
db.add(audio)
|
||||||
|
db.flush() # Чтобы получить audio.id
|
||||||
|
|
||||||
|
# Сохраняем в список для последующей отправки
|
||||||
|
self.loaded_files.append(audio)
|
||||||
|
|
||||||
|
print(f"✅ Файл {file} сохранён в БД")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка при обработке файла {remote_file}: {e}")
|
||||||
|
# Если файл скачался, но ошибка в БД - удаляем файл
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
print(f"🗑️ Файл {file} удалён из-за ошибки")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Закрываем соединения
|
||||||
|
sftp_client.close()
|
||||||
|
ssh_client.close()
|
||||||
|
|
||||||
|
# После загрузки всех файлов отправляем на распознавание
|
||||||
|
print(f"\n📊 Загружено файлов: {len(self.loaded_files)}")
|
||||||
|
self.send_to_recognition()
|
||||||
|
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""
|
||||||
|
Исправленная версия recognition_checker.py
|
||||||
|
Избегает detached instance error путём использования словарей вместо SQLAlchemy объектов
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
from autoLoader.database import get_db_session, Audio, AiConclusion
|
||||||
|
from autoLoader.config import GIGAAM_API_URL
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RecognitionChecker:
|
||||||
|
"""Класс для проверки и отправки файлов на распознавание"""
|
||||||
|
|
||||||
|
def __init__(self, api_url: Optional[str] = None, max_workers: int = 5):
|
||||||
|
"""
|
||||||
|
Инициализация checker
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: URL API GigaAM для распознавания (если None, берётся из config)
|
||||||
|
max_workers: Максимальное количество параллельных запросов
|
||||||
|
"""
|
||||||
|
# Если api_url не передан, берём из config.py
|
||||||
|
if api_url is None:
|
||||||
|
api_url = GIGAAM_API_URL
|
||||||
|
|
||||||
|
self.api_url = f"{api_url}/api/call/process"
|
||||||
|
self.timeout = 10 # таймаут запроса в секундах
|
||||||
|
self.max_workers = max_workers # количество параллельных потоков
|
||||||
|
|
||||||
|
# Thread-safe счётчики для статистики
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._sent_count = 0
|
||||||
|
self._failed_count = 0
|
||||||
|
|
||||||
|
logger.info(f"✅ RecognitionChecker инициализирован с URL: {self.api_url}")
|
||||||
|
logger.info(f"📊 Параллельная отправка: до {max_workers} запросов одновременно")
|
||||||
|
|
||||||
|
def check_database(self) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет существование необходимых таблиц в БД
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если таблицы существуют, иначе False
|
||||||
|
"""
|
||||||
|
from autoLoader.database import engine
|
||||||
|
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
required_tables = ['audio', 'ai_conclusion']
|
||||||
|
missing_tables = [t for t in required_tables if t not in existing_tables]
|
||||||
|
|
||||||
|
if missing_tables:
|
||||||
|
logger.error(f"❌ Отсутствуют таблицы: {missing_tables}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_files_without_conclusion(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Находит все файлы, у которых нет AI заключения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Ограничение количества файлов (None = все)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список словарей с информацией о файлах (избегаем detached instance)
|
||||||
|
"""
|
||||||
|
if not self.check_database():
|
||||||
|
logger.error("❌ База данных не готова")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session() as db:
|
||||||
|
# Подзапрос: находим все audio_id, у которых есть заключение
|
||||||
|
from sqlalchemy import distinct
|
||||||
|
|
||||||
|
audio_with_conclusion = db.query(
|
||||||
|
distinct(AiConclusion.audio_id)
|
||||||
|
).filter(
|
||||||
|
AiConclusion.audio_id.isnot(None)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Извлекаем ID из кортежей
|
||||||
|
conclusion_ids = [row[0] for row in audio_with_conclusion]
|
||||||
|
|
||||||
|
# Находим все audio, у которых нет заключения
|
||||||
|
if conclusion_ids:
|
||||||
|
audio_objects = db.query(Audio).filter(
|
||||||
|
~Audio.id.in_(conclusion_ids)
|
||||||
|
).all()
|
||||||
|
else:
|
||||||
|
# Если заключений нет вообще - все файлы без заключения
|
||||||
|
audio_objects = db.query(Audio).all()
|
||||||
|
|
||||||
|
# Конвертируем в словари внутри сессии БД
|
||||||
|
files_data = []
|
||||||
|
for audio in audio_objects:
|
||||||
|
files_data.append({
|
||||||
|
'id': str(audio.id),
|
||||||
|
'filename': audio.filename,
|
||||||
|
'file_size': audio.file_size,
|
||||||
|
'index_date': audio.index_date.isoformat() if audio.index_date else None
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"📊 Найдено файлов без заключения: {len(files_data)}")
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
return files_data[:limit]
|
||||||
|
|
||||||
|
return files_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при поиске файлов: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def send_to_recognition(self, audio_data: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Отправляет файл на распознавание в GigaAM API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Словарь с данными об аудио файле
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно отправлен, иначе False
|
||||||
|
"""
|
||||||
|
filename = audio_data.get('filename')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"filename": filename
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"📤 [Thread-{threading.current_thread().name}] Отправка файла {filename} на распознавание...")
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
self.api_url,
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200 or response.status_code == 202:
|
||||||
|
logger.info(f"✅ [Thread-{threading.current_thread().name}] Файл {filename} успешно отправлен")
|
||||||
|
|
||||||
|
# Thread-safe обновление счётчиков
|
||||||
|
with self._lock:
|
||||||
|
self._sent_count += 1
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ [Thread-{threading.current_thread().name}] Ошибка API {response.status_code}: {response.text}")
|
||||||
|
|
||||||
|
# Thread-safe обновление счётчиков
|
||||||
|
with self._lock:
|
||||||
|
self._failed_count += 1
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error(f"❌ [Thread-{threading.current_thread().name}] Таймаут при отправке файла {filename}")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._failed_count += 1
|
||||||
|
|
||||||
|
return False
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
logger.error(f"❌ [Thread-{threading.current_thread().name}] Не удалось подключиться к API {self.api_url}")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._failed_count += 1
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ [Thread-{threading.current_thread().name}] Ошибка при отправке {filename}: {e}")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._failed_count += 1
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_to_recognition_parallel(self, audio_list: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Отправляет несколько файлов на распознавание параллельно
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_list: Список словарей с данными об аудио файлах
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с результатами отправки
|
||||||
|
"""
|
||||||
|
if not audio_list:
|
||||||
|
logger.info("⏭️ Список файлов пуст, нечего отправлять")
|
||||||
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сбрасываем счётчики
|
||||||
|
self._sent_count = 0
|
||||||
|
self._failed_count = 0
|
||||||
|
|
||||||
|
logger.info(f"🚀 Начинаем параллельную отправку {len(audio_list)} файлов")
|
||||||
|
logger.info(f"📊 Количество потоков: {self.max_workers}")
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"total": len(audio_list),
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Используем ThreadPoolExecutor для параллельной отправки
|
||||||
|
with ThreadPoolExecutor(max_workers=self.max_workers, thread_name_prefix="SendReq") as executor:
|
||||||
|
# Запускаем все задачи
|
||||||
|
future_to_audio = {
|
||||||
|
executor.submit(self.send_to_recognition, audio): audio
|
||||||
|
for audio in audio_list
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обрабатываем результаты по мере завершения
|
||||||
|
for future in as_completed(future_to_audio):
|
||||||
|
audio = future_to_audio[future]
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = future.result()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"filename": audio.get('filename'),
|
||||||
|
"audio_id": audio.get('id'),
|
||||||
|
"success": success
|
||||||
|
}
|
||||||
|
|
||||||
|
results["files"].append(result)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"❌ Файл {audio.get('filename')} сгенерировал исключение: {exc}")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"filename": audio.get('filename'),
|
||||||
|
"audio_id": audio.get('id'),
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc)
|
||||||
|
}
|
||||||
|
|
||||||
|
results["files"].append(result)
|
||||||
|
|
||||||
|
# Получаем итоговую статистику из счётчиков
|
||||||
|
results["sent"] = self._sent_count
|
||||||
|
results["failed"] = self._failed_count
|
||||||
|
|
||||||
|
# Логирование итогов
|
||||||
|
logger.info(f"📊 Итого параллельной отправки:")
|
||||||
|
logger.info(f" - Всего: {results['total']}")
|
||||||
|
logger.info(f" - Отправлено: {results['sent']}")
|
||||||
|
logger.info(f" - Ошибок: {results['failed']}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def process_all_pending(self, limit: Optional[int] = None, parallel: bool = True) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Находит и отправляет все файлы без заключения на распознавание
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Максимальное количество файлов для обработки
|
||||||
|
parallel: Использовать параллельную отправку (по умолчанию True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с результатами обработки
|
||||||
|
"""
|
||||||
|
logger.info("🔍 Поиск файлов без AI заключения...")
|
||||||
|
|
||||||
|
files_without_conclusion = self.get_files_without_conclusion(limit)
|
||||||
|
|
||||||
|
if not files_without_conclusion:
|
||||||
|
logger.info("✅ Все файлы обработаны")
|
||||||
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Выбираем метод отправки
|
||||||
|
if parallel:
|
||||||
|
logger.info("🚀 Используем параллельную отправку")
|
||||||
|
return self.send_to_recognition_parallel(files_without_conclusion)
|
||||||
|
else:
|
||||||
|
logger.info("📤 Используем последовательную отправку")
|
||||||
|
results = {
|
||||||
|
"total": len(files_without_conclusion),
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for audio in files_without_conclusion:
|
||||||
|
success = self.send_to_recognition(audio)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"filename": audio.get('filename'),
|
||||||
|
"audio_id": audio.get('id'),
|
||||||
|
"success": success
|
||||||
|
}
|
||||||
|
|
||||||
|
results["files"].append(result)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
results["sent"] += 1
|
||||||
|
else:
|
||||||
|
results["failed"] += 1
|
||||||
|
|
||||||
|
# Логирование итогов
|
||||||
|
logger.info(f"📊 Итого:")
|
||||||
|
logger.info(f" - Всего: {results['total']}")
|
||||||
|
logger.info(f" - Отправлено: {results['sent']}")
|
||||||
|
logger.info(f" - Ошибок: {results['failed']}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def check_api_availability(self) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет доступность GigaAM API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если API доступен, иначе False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Проверяем health endpoint или просто подключение
|
||||||
|
response = requests.get(
|
||||||
|
self.api_url.replace("/process", "/status"), # Пробуем /status
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in [200, 404]: # 404 тоже ок - API работает
|
||||||
|
logger.info("✅ GigaAM API доступен")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
logger.warning("⚠️ GigaAM API недоступен")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Ошибка проверки API: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Удобная функция для запуска из командной строки
|
||||||
|
def process_pending_files(api_url: Optional[str] = None, limit: Optional[int] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Обрабатывает все файлы без заключения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: URL GigaAM API (если None, берётся из config.py)
|
||||||
|
limit: Максимальное количество файлов для обработки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Результаты обработки
|
||||||
|
"""
|
||||||
|
checker = RecognitionChecker(api_url)
|
||||||
|
|
||||||
|
# Проверяем доступность API
|
||||||
|
if not checker.check_api_availability():
|
||||||
|
logger.error("❌ GigaAM API недоступен. Проверьте, запущен ли сервис.")
|
||||||
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"error": "API unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обрабатываем файлы
|
||||||
|
return checker.process_all_pending(limit)
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"""
|
||||||
|
Класс для проверки файлов без AI заключения и отправки на распознавание
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from typing import List, Optional
|
||||||
|
import logging
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from autoLoader.database import get_db_session, Audio, AiConclusion
|
||||||
|
from autoLoader.config import GIGAAM_API_URL
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RecognitionChecker:
|
||||||
|
"""Класс для проверки и отправки файлов на распознавание"""
|
||||||
|
|
||||||
|
def __init__(self, api_url: Optional[str] = None, max_workers: int = 5):
|
||||||
|
"""
|
||||||
|
Инициализация checker
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: URL API GigaAM для распознавания (если None, берётся из config)
|
||||||
|
max_workers: Максимальное количество параллельных запросов
|
||||||
|
"""
|
||||||
|
# Если api_url не передан, берём из config.py
|
||||||
|
if api_url is None:
|
||||||
|
api_url = GIGAAM_API_URL
|
||||||
|
|
||||||
|
self.api_url = f"{api_url}/api/call/process"
|
||||||
|
self.timeout = 10 # таймаут запроса в секундах
|
||||||
|
self.max_workers = max_workers # количество параллельных потоков
|
||||||
|
|
||||||
|
# Thread-safe счётчики для статистики
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._sent_count = 0
|
||||||
|
self._failed_count = 0
|
||||||
|
|
||||||
|
logger.info(f"✅ RecognitionChecker инициализирован с URL: {self.api_url}")
|
||||||
|
logger.info(f"📊 Параллельная отправка: до {max_workers} запросов одновременно")
|
||||||
|
|
||||||
|
def check_database(self) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет существование необходимых таблиц в БД
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если таблицы существуют, иначе False
|
||||||
|
"""
|
||||||
|
from autoLoader.database import engine
|
||||||
|
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
required_tables = ['audio', 'ai_conclusion']
|
||||||
|
missing_tables = [t for t in required_tables if t not in existing_tables]
|
||||||
|
|
||||||
|
if missing_tables:
|
||||||
|
logger.error(f"❌ Отсутствуют таблицы: {missing_tables}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_files_without_conclusion(self, limit: Optional[int] = None) -> List[Audio]:
|
||||||
|
"""
|
||||||
|
Находит все файлы, у которых нет AI заключения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Ограничение количества файлов (None = все)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список объектов Audio без заключения
|
||||||
|
"""
|
||||||
|
if not self.check_database():
|
||||||
|
logger.error("❌ База данных не готова")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session() as db:
|
||||||
|
# Подзапрос: находим все audio_id, у которых есть заключение
|
||||||
|
from sqlalchemy import distinct
|
||||||
|
|
||||||
|
audio_with_conclusion = db.query(
|
||||||
|
distinct(AiConclusion.audio_id)
|
||||||
|
).filter(
|
||||||
|
AiConclusion.audio_id.isnot(None)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Извлекаем ID из кортежей
|
||||||
|
conclusion_ids = [row[0] for row in audio_with_conclusion]
|
||||||
|
|
||||||
|
# Находим все audio, у которых нет заключения
|
||||||
|
if conclusion_ids:
|
||||||
|
files_without_conclusion = db.query(Audio).filter(
|
||||||
|
~Audio.id.in_(conclusion_ids)
|
||||||
|
).all()
|
||||||
|
else:
|
||||||
|
# Если заключений нет вообще - все файлы без заключения
|
||||||
|
files_without_conclusion = db.query(Audio).all()
|
||||||
|
|
||||||
|
logger.info(f"📊 Найдено файлов без заключения: {len(files_without_conclusion)}")
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
return files_without_conclusion[:limit]
|
||||||
|
|
||||||
|
return files_without_conclusion
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при поиске файлов: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def send_to_recognition(self, audio: Audio) -> bool:
|
||||||
|
"""
|
||||||
|
Отправляет файл на распознавание в GigaAM API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio: Объект Audio для распознавания
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно отправлен, иначе False
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"filename": audio.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"📤 [Thread-{threading.current_thread().name}] Отправка файла {audio.filename} на распознавание...")
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
self.api_url,
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200 or response.status_code == 202:
|
||||||
|
logger.info(f"✅ [Thread-{threading.current_thread().name}] Файл {audio.filename} успешно отправлен")
|
||||||
|
|
||||||
|
# Thread-safe обновление счётчиков
|
||||||
|
with self._lock:
|
||||||
|
self._sent_count += 1
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ [Thread-{threading.current_thread().name}] Ошибка API {response.status_code}: {response.text}")
|
||||||
|
|
||||||
|
# Thread-safe обновление счётчиков
|
||||||
|
with self._lock:
|
||||||
|
self._failed_count += 1
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error(f"❌ [Thread-{threading.current_thread().name}] Таймаут при отправке файла {audio.filename}")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._failed_count += 1
|
||||||
|
|
||||||
|
return False
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
logger.error(f"❌ [Thread-{threading.current_thread().name}] Не удалось подключиться к API {self.api_url}")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._failed_count += 1
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ [Thread-{threading.current_thread().name}] Ошибка при отправке {audio.filename}: {e}")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._failed_count += 1
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_to_recognition_parallel(self, audio_list: List[Audio]) -> dict:
|
||||||
|
"""
|
||||||
|
Отправляет несколько файлов на распознавание параллельно
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_list: Список объектов Audio для распознавания
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с результатами отправки
|
||||||
|
"""
|
||||||
|
if not audio_list:
|
||||||
|
logger.info("⏭️ Список файлов пуст, нечего отправлять")
|
||||||
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сбрасываем счётчики
|
||||||
|
self._sent_count = 0
|
||||||
|
self._failed_count = 0
|
||||||
|
|
||||||
|
logger.info(f"🚀 Начинаем параллельную отправку {len(audio_list)} файлов")
|
||||||
|
logger.info(f"📊 Количество потоков: {self.max_workers}")
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"total": len(audio_list),
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Используем ThreadPoolExecutor для параллельной отправки
|
||||||
|
with ThreadPoolExecutor(max_workers=self.max_workers, thread_name_prefix="SendReq") as executor:
|
||||||
|
# Запускаем все задачи
|
||||||
|
future_to_audio = {
|
||||||
|
executor.submit(self.send_to_recognition, audio): audio
|
||||||
|
for audio in audio_list
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обрабатываем результаты по мере завершения
|
||||||
|
for future in as_completed(future_to_audio):
|
||||||
|
audio = future_to_audio[future]
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = future.result()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"filename": audio.filename,
|
||||||
|
"audio_id": str(audio.id),
|
||||||
|
"success": success
|
||||||
|
}
|
||||||
|
|
||||||
|
results["files"].append(result)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"❌ Файл {audio.filename} сгенерировал исключение: {exc}")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"filename": audio.filename,
|
||||||
|
"audio_id": str(audio.id),
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc)
|
||||||
|
}
|
||||||
|
|
||||||
|
results["files"].append(result)
|
||||||
|
|
||||||
|
# Получаем итоговую статистику из счётчиков
|
||||||
|
results["sent"] = self._sent_count
|
||||||
|
results["failed"] = self._failed_count
|
||||||
|
|
||||||
|
# Логирование итогов
|
||||||
|
logger.info(f"📊 Итого параллельной отправки:")
|
||||||
|
logger.info(f" - Всего: {results['total']}")
|
||||||
|
logger.info(f" - Отправлено: {results['sent']}")
|
||||||
|
logger.info(f" - Ошибок: {results['failed']}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def process_all_pending(self, limit: Optional[int] = None, parallel: bool = True) -> dict:
|
||||||
|
"""
|
||||||
|
Находит и отправляет все файлы без заключения на распознавание
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Максимальное количество файлов для обработки
|
||||||
|
parallel: Использовать параллельную отправку (по умолчанию True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с результатами обработки
|
||||||
|
"""
|
||||||
|
logger.info("🔍 Поиск файлов без AI заключения...")
|
||||||
|
|
||||||
|
files_without_conclusion = self.get_files_without_conclusion(limit)
|
||||||
|
|
||||||
|
if not files_without_conclusion:
|
||||||
|
logger.info("✅ Все файлы обработаны")
|
||||||
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Выбираем метод отправки
|
||||||
|
if parallel:
|
||||||
|
logger.info("🚀 Используем параллельную отправку")
|
||||||
|
return self.send_to_recognition_parallel(files_without_conclusion)
|
||||||
|
else:
|
||||||
|
logger.info("📤 Используем последовательную отправку")
|
||||||
|
results = {
|
||||||
|
"total": len(files_without_conclusion),
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for audio in files_without_conclusion:
|
||||||
|
success = self.send_to_recognition(audio)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"filename": audio.filename,
|
||||||
|
"audio_id": str(audio.id),
|
||||||
|
"success": success
|
||||||
|
}
|
||||||
|
|
||||||
|
results["files"].append(result)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
results["sent"] += 1
|
||||||
|
else:
|
||||||
|
results["failed"] += 1
|
||||||
|
|
||||||
|
# Логирование итогов
|
||||||
|
logger.info(f"📊 Итого:")
|
||||||
|
logger.info(f" - Всего: {results['total']}")
|
||||||
|
logger.info(f" - Отправлено: {results['sent']}")
|
||||||
|
logger.info(f" - Ошибок: {results['failed']}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def check_api_availability(self) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет доступность GigaAM API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если API доступен, иначе False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Проверяем health endpoint или просто подключение
|
||||||
|
response = requests.get(
|
||||||
|
self.api_url.replace("/process", "/status"), # Пробуем /status
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in [200, 404]: # 404 тоже ок - API работает
|
||||||
|
logger.info("✅ GigaAM API доступен")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
logger.warning("⚠️ GigaAM API недоступен")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Ошибка проверки API: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Удобная функция для запуска из командной строки
|
||||||
|
def process_pending_files(api_url: Optional[str] = None, limit: Optional[int] = None):
|
||||||
|
"""
|
||||||
|
Обрабатывает все файлы без заключения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: URL GigaAM API (если None, берётся из config.py)
|
||||||
|
limit: Максимальное количество файлов для обработки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Результаты обработки
|
||||||
|
"""
|
||||||
|
checker = RecognitionChecker(api_url)
|
||||||
|
|
||||||
|
# Проверяем доступность API
|
||||||
|
if not checker.check_api_availability():
|
||||||
|
logger.error("❌ GigaAM API недоступен. Проверьте, запущен ли сервис.")
|
||||||
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"sent": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"error": "API unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обрабатываем файлы
|
||||||
|
return checker.process_all_pending(limit)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Пример использования
|
||||||
|
import sys
|
||||||
|
|
||||||
|
api_url = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:5001/api/call/process"
|
||||||
|
limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
||||||
|
|
||||||
|
results = process_pending_files(api_url, limit)
|
||||||
|
|
||||||
|
print(f"\n📊 Результаты:")
|
||||||
|
print(f"Всего: {results['total']}")
|
||||||
|
print(f"Отправлено: {results['sent']}")
|
||||||
|
print(f"Ошибок: {results['failed']}")
|
||||||
Executable
+143
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Пример использования параллельной отправки файлов на распознавание
|
||||||
|
"""
|
||||||
|
from autoLoader.loader import RecognitionChecker
|
||||||
|
from autoLoader.database import get_db_session, Audio
|
||||||
|
from autoLoader.config import GIGAAM_API_URL
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
def example_parallel_send():
|
||||||
|
"""Пример параллельной отправки"""
|
||||||
|
print("🚀 Пример параллельной отправки файлов\n")
|
||||||
|
|
||||||
|
# Создаём checker с настройкой количества потоков
|
||||||
|
checker = RecognitionChecker(
|
||||||
|
api_url=GIGAAM_API_URL,
|
||||||
|
max_workers=10 # До 10 параллельных запросов
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем доступность API
|
||||||
|
if not checker.check_api_availability():
|
||||||
|
print("❌ GigaAM API недоступен")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем БД
|
||||||
|
if not checker.check_database():
|
||||||
|
print("❌ База данных не готова")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем файлы без заключения
|
||||||
|
print("🔍 Поиск файлов без заключения...")
|
||||||
|
files_without_conclusion = checker.get_files_without_conclusion(limit=20)
|
||||||
|
|
||||||
|
if not files_without_conclusion:
|
||||||
|
print("✅ Все файлы уже обработаны")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📊 Найдено файлов: {len(files_without_conclusion)}\n")
|
||||||
|
|
||||||
|
# Отправляем параллельно
|
||||||
|
print("🚀 Начинаем параллельную отправку...\n")
|
||||||
|
results = checker.send_to_recognition_parallel(files_without_conclusion)
|
||||||
|
|
||||||
|
# Выводим результаты
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"📊 РЕЗУЛЬТАТЫ:")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Всего файлов: {results['total']}")
|
||||||
|
print(f"✅ Отправлено: {results['sent']}")
|
||||||
|
print(f"❌ Ошибок: {results['failed']}")
|
||||||
|
|
||||||
|
# Детали по каждому файлу
|
||||||
|
if results.get('files'):
|
||||||
|
print(f"\n📋 Детали:")
|
||||||
|
for file_result in results['files']:
|
||||||
|
status = "✅" if file_result['success'] else "❌"
|
||||||
|
print(f" {status} {file_result['filename']}")
|
||||||
|
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def example_sequential_send():
|
||||||
|
"""Пример последовательной отправки (для сравнения)"""
|
||||||
|
print("📤 Пример последовательной отправки файлов\n")
|
||||||
|
|
||||||
|
checker = RecognitionChecker(api_url=GIGAAM_API_URL)
|
||||||
|
|
||||||
|
if not checker.check_api_availability():
|
||||||
|
print("❌ GigaAM API недоступен")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем файлы
|
||||||
|
files_without_conclusion = checker.get_files_without_conclusion(limit=5)
|
||||||
|
|
||||||
|
if not files_without_conclusion:
|
||||||
|
print("✅ Все файлы уже обработаны")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📊 Найдено файлов: {len(files_without_conclusion)}\n")
|
||||||
|
|
||||||
|
# Отправляем последовательно (parallel=False)
|
||||||
|
results = checker.process_all_pending(limit=5, parallel=False)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"📊 РЕЗУЛЬТАТЫ:")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Всего файлов: {results['total']}")
|
||||||
|
print(f"✅ Отправлено: {results['sent']}")
|
||||||
|
print(f"❌ Ошибок: {results['failed']}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def example_send_specific_files():
|
||||||
|
"""Пример отправки конкретных файлов"""
|
||||||
|
print("🎯 Пример отправки конкретных файлов\n")
|
||||||
|
|
||||||
|
checker = RecognitionChecker(max_workers=5)
|
||||||
|
|
||||||
|
# Получаем конкретные файлы из БД
|
||||||
|
with get_db_session() as db:
|
||||||
|
# Например, последние 10 файлов
|
||||||
|
audio_files = db.query(Audio).order_by(Audio.index_date.desc()).limit(10).all()
|
||||||
|
|
||||||
|
if not audio_files:
|
||||||
|
print("⏭️ Файлы не найдены")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📊 Выбрано файлов: {len(audio_files)}\n")
|
||||||
|
|
||||||
|
# Отправляем параллельно
|
||||||
|
results = checker.send_to_recognition_parallel(audio_files)
|
||||||
|
|
||||||
|
print(f"\n✅ Отправлено: {results['sent']} из {results['total']}\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Выбор примера
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
example_type = sys.argv[1]
|
||||||
|
else:
|
||||||
|
print("Выберите пример:")
|
||||||
|
print(" python example_parallel_send.py parallel - Параллельная отправка")
|
||||||
|
print(" python example_parallel_send.py sequential - Последовательная отправка")
|
||||||
|
print(" python example_parallel_send.py specific - Отправка конкретных файлов")
|
||||||
|
print("\nИспользование по умолчанию: parallel\n")
|
||||||
|
example_type = "parallel"
|
||||||
|
|
||||||
|
if example_type == "parallel":
|
||||||
|
example_parallel_send()
|
||||||
|
elif example_type == "sequential":
|
||||||
|
example_sequential_send()
|
||||||
|
elif example_type == "specific":
|
||||||
|
example_send_specific_files()
|
||||||
|
else:
|
||||||
|
print(f"❌ Неизвестный пример: {example_type}")
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Скрипт для инициализации базы данных
|
||||||
|
Создаёт все необходимые таблицы
|
||||||
|
"""
|
||||||
|
from apiApp.database import Base, engine
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""Создаёт все таблицы в базе данных"""
|
||||||
|
print("🔧 Инициализация базы данных...")
|
||||||
|
|
||||||
|
# Проверяем существующие таблицы
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if existing_tables:
|
||||||
|
print(f"📋 Существующие таблицы: {', '.join(existing_tables)}")
|
||||||
|
|
||||||
|
# Создаём все таблицы
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Проверяем результат
|
||||||
|
inspector = inspect(engine)
|
||||||
|
all_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
print(f"✅ Создано таблиц: {len(all_tables)}")
|
||||||
|
for table in all_tables:
|
||||||
|
print(f" - {table}")
|
||||||
|
|
||||||
|
return all_tables
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
tables = init_database()
|
||||||
|
print(f"\n🎉 База данных готова! Создано таблиц: {len(tables)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Ошибка при создании таблиц: {e}")
|
||||||
|
exit(1)
|
||||||
@@ -5,9 +5,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from apiApp.config import APP_TITLE, APP_VERSION, API_V1_PREFIX, UPLOAD_FOLDER, DATABASE_URL
|
from apiApp.config import APP_TITLE, APP_VERSION, API_V1_PREFIX, UPLOAD_FOLDER, DATABASE_URL, PORT, HOST
|
||||||
from apiApp.database import engine, Base
|
from apiApp.database import engine, Base
|
||||||
from apiApp.routers import audio_router, recognition_router
|
from apiApp.routers import audio_router, recognition_router
|
||||||
|
from apiApp.routers.ai_conclusion_router import ai_conclusion_router
|
||||||
|
from apiApp.routers.audio_files_router import audio_files_router
|
||||||
|
from apiApp.routers.audio_management_router import audio_management_router
|
||||||
|
from apiApp.routers.external_audio import router as external_audio_router
|
||||||
|
|
||||||
|
print("✅ audio_management_router imported successfully")
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -48,7 +54,7 @@ async def startup_event():
|
|||||||
"""Создание таблиц при запуске приложения"""
|
"""Создание таблиц при запуске приложения"""
|
||||||
try:
|
try:
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
logger.info("Database tables created successfully")
|
logger.info("✅ Database tables created successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating database tables: {e}")
|
logger.error(f"Error creating database tables: {e}")
|
||||||
|
|
||||||
@@ -56,6 +62,13 @@ async def startup_event():
|
|||||||
# Подключение routers
|
# Подключение routers
|
||||||
app.include_router(audio_router, prefix=API_V1_PREFIX, tags=["audio"])
|
app.include_router(audio_router, prefix=API_V1_PREFIX, tags=["audio"])
|
||||||
app.include_router(recognition_router, prefix=API_V1_PREFIX, tags=["recognition"])
|
app.include_router(recognition_router, prefix=API_V1_PREFIX, tags=["recognition"])
|
||||||
|
app.include_router(ai_conclusion_router, prefix=API_V1_PREFIX, tags=["ai_conclusion"])
|
||||||
|
app.include_router(audio_files_router, prefix=API_V1_PREFIX, tags=["audio_files"])
|
||||||
|
app.include_router(external_audio_router, prefix=API_V1_PREFIX)
|
||||||
|
# audio_management_router с префиксом /audio для логической структуры
|
||||||
|
print("📝 Registering audio_management_router...")
|
||||||
|
app.include_router(audio_management_router, prefix="/api", tags=["audio_management"])
|
||||||
|
print("✅ audio_management_router registered at /audio/*")
|
||||||
|
|
||||||
# Статические файлы (для загрузки аудио)
|
# Статические файлы (для загрузки аудио)
|
||||||
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_FOLDER)), name="uploads")
|
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_FOLDER)), name="uploads")
|
||||||
@@ -74,7 +87,21 @@ async def root():
|
|||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
@app.get("/routes")
|
||||||
|
async def list_routes():
|
||||||
|
"""Отладка: список всех роутов"""
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
routes = []
|
||||||
|
for route in app.routes:
|
||||||
|
if isinstance(route, APIRoute):
|
||||||
|
routes.append({
|
||||||
|
"path": route.path,
|
||||||
|
"methods": list(route.methods),
|
||||||
|
"name": route.name
|
||||||
|
})
|
||||||
|
return {"routes": routes}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
uvicorn.run(app, host=HOST, port=PORT)
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ sqlalchemy==2.0.35
|
|||||||
pydantic==2.9.2
|
pydantic==2.9.2
|
||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
aiofiles==24.1.0
|
aiofiles==24.1.0
|
||||||
|
psycopg2-binary
|
||||||
|
paramiko
|
||||||
|
requests
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from apiApp.config import PORT
|
||||||
|
|
||||||
# >102;O5< :>@=52CN 48@5:B>@8N 2 Python path
|
# >102;O5< :>@=52CN 48@5:B>@8N 2 Python path
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
@@ -13,7 +14,7 @@ if __name__ == "__main__":
|
|||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"main:app",
|
"main:app",
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=8000,
|
port=PORT,
|
||||||
reload=True, # 2B><0B8G5A:0O ?5@5703@C7:0 ?@8 87<5=5=88 :>40
|
reload=False, # 2B><0B8G5A:0O ?5@5703@C7:0 ?@8 87<5=5=88 :>40
|
||||||
log_level="info"
|
log_level="info"
|
||||||
)
|
)
|
||||||
|
|||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Скрипт для проверки и отправки файлов на распознавание
|
||||||
|
"""
|
||||||
|
from autoLoader.loader.recognition_checker import process_pending_files
|
||||||
|
from autoLoader.config import GIGAAM_API_URL
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Загружаем .env файл
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Главная функция"""
|
||||||
|
# Получаем параметры из командной строки
|
||||||
|
api_url = sys.argv[1] if len(sys.argv) > 1 else GIGAAM_API_URL
|
||||||
|
limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
||||||
|
|
||||||
|
print(f"🚀 Запуск проверки файлов на распознавание")
|
||||||
|
print(f"📡 API URL: {api_url}")
|
||||||
|
print(f"📂 Config URL: {GIGAAM_API_URL}")
|
||||||
|
if limit:
|
||||||
|
print(f"📊 Лимит: {limit} файлов")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Запускаем проверку
|
||||||
|
results = process_pending_files(api_url=api_url, limit=limit)
|
||||||
|
|
||||||
|
# Итоги
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"📊 ИТОГИ ОБРАБОТКИ:")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Всего файлов без заключения: {results['total']}")
|
||||||
|
print(f"✅ Отправлено на распознавание: {results['sent']}")
|
||||||
|
print(f"❌ Ошибок при отправке: {results['failed']}")
|
||||||
|
|
||||||
|
if 'error' in results:
|
||||||
|
print(f"\n⚠️ {results['error']}")
|
||||||
|
|
||||||
|
# Детали по каждому файлу
|
||||||
|
if results.get('files'):
|
||||||
|
print(f"\n📋 Детали:")
|
||||||
|
for file_result in results['files']:
|
||||||
|
status = "✅" if file_result['success'] else "❌"
|
||||||
|
print(f" {status} {file_result['filename']}")
|
||||||
|
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
# Код выхода
|
||||||
|
if results['failed'] > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from autoLoader.loader import Loader
|
||||||
|
from apiApp.database import Base, engine
|
||||||
|
|
||||||
|
# Создаём таблицы, если они не существуют
|
||||||
|
print("🔧 Создание таблиц базы данных...")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
print("✅ Таблицы созданы")
|
||||||
|
|
||||||
|
# Запускаем загрузчик
|
||||||
|
print("\n🚀 Запуск AutoLoader...")
|
||||||
|
loader_instance = Loader()
|
||||||
|
loader_instance.load()
|
||||||
|
print("✅ AutoLoader завершил работу")
|
||||||
Reference in New Issue
Block a user