Compare commits
32 Commits
de2e66a37c
...
dev
| 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 |
+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)
|
||||||
|
|||||||
+8
-3
@@ -21,16 +21,18 @@ ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"}
|
|||||||
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", "8000"))
|
PORT = int(os.getenv("PORT", "5008"))
|
||||||
HOST = os.getenv("HOST", "localhost")
|
HOST = os.getenv("HOST", "localhost")
|
||||||
|
|
||||||
# GigaAM API Configuration
|
# GigaAM API Configuration
|
||||||
GIGAAM_API_URL = os.getenv("GIGAAM_API_URL", "http://gigaam_api:5001")
|
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_main Webhook Configuration
|
||||||
CALLS_WEB_CLIENT_URL = os.getenv(
|
CALLS_WEB_CLIENT_URL = os.getenv(
|
||||||
"CALLS_WEB_CLIENT_URL",
|
"CALLS_WEB_CLIENT_URL",
|
||||||
"http://calls_web_client:8000"
|
"http://192.168.1.73:8642"
|
||||||
)
|
)
|
||||||
WEBHOOK_ENDPOINT = f"{CALLS_WEB_CLIENT_URL}/api/transcription/webhook"
|
WEBHOOK_ENDPOINT = f"{CALLS_WEB_CLIENT_URL}/api/transcription/webhook"
|
||||||
WEBHOOK_API_KEY = os.getenv("WEBHOOK_API_KEY", "webhook_secret_key")
|
WEBHOOK_API_KEY = os.getenv("WEBHOOK_API_KEY", "webhook_secret_key")
|
||||||
@@ -39,3 +41,6 @@ WEBHOOK_API_KEY = os.getenv("WEBHOOK_API_KEY", "webhook_secret_key")
|
|||||||
ENABLE_AUTO_RESTORE = os.getenv("ENABLE_AUTO_RESTORE", "true").lower() == "true"
|
ENABLE_AUTO_RESTORE = os.getenv("ENABLE_AUTO_RESTORE", "true").lower() == "true"
|
||||||
AUTO_RESTORE_LIMIT = int(os.getenv("AUTO_RESTORE_LIMIT", "100")) # Максимум файлов для восстановления
|
AUTO_RESTORE_LIMIT = int(os.getenv("AUTO_RESTORE_LIMIT", "100")) # Максимум файлов для восстановления
|
||||||
AUTO_RESTORE_DELAY = int(os.getenv("AUTO_RESTORE_DELAY", "5")) # Задержка перед запуском (секунды)
|
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
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ class Audio(Base):
|
|||||||
file_path = Column(Text)
|
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,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"]
|
||||||
|
|||||||
@@ -16,6 +16,31 @@ from apiApp.config import WEBHOOK_ENDPOINT, WEBHOOK_API_KEY
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
ai_conclusion_router = APIRouter()
|
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):
|
class AiConclusionRequest(BaseModel):
|
||||||
"""Модель запроса для сохранения AI заключения"""
|
"""Модель запроса для сохранения AI заключения"""
|
||||||
@@ -25,6 +50,7 @@ class AiConclusionRequest(BaseModel):
|
|||||||
analysis: Dict[str, Any]
|
analysis: Dict[str, Any]
|
||||||
segments: Optional[List[Dict[str, Any]]] = []
|
segments: Optional[List[Dict[str, Any]]] = []
|
||||||
processing_time_seconds: Optional[float] = 0
|
processing_time_seconds: Optional[float] = 0
|
||||||
|
callback_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class AiConclusionResponse(BaseModel):
|
class AiConclusionResponse(BaseModel):
|
||||||
@@ -36,6 +62,46 @@ class AiConclusionResponse(BaseModel):
|
|||||||
error: 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)
|
@ai_conclusion_router.post("/conclusion/save", response_model=AiConclusionResponse)
|
||||||
async def save_ai_conclusion(request: AiConclusionRequest, db: Session = Depends(get_db)):
|
async def save_ai_conclusion(request: AiConclusionRequest, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
@@ -93,31 +159,41 @@ async def save_ai_conclusion(request: AiConclusionRequest, db: Session = Depends
|
|||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"✅ Заключение сохранено для {request.filename}")
|
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 для анализа
|
# Отправляем webhook в Calls_WEB_Client_main для анализа
|
||||||
try:
|
try:
|
||||||
logger.info(f"📤 Отправка webhook в Calls_WEB_Client_main для {request.filename}")
|
if (audio.sourse or "").lower() != "external":
|
||||||
|
logger.info(f"📤 Отправка webhook в Calls_WEB_Client_main для {request.filename}")
|
||||||
|
|
||||||
webhook_payload = {
|
webhook_payload = {
|
||||||
"audio_id": str(audio.id),
|
"audio_id": str(audio.id),
|
||||||
"filename": request.filename,
|
"filename": request.filename,
|
||||||
"transcription": request.transcription,
|
"transcription": request.transcription,
|
||||||
"corrected_transcription": request.corrected_transcription,
|
"corrected_transcription": request.corrected_transcription,
|
||||||
"segments": request.segments,
|
"segments": request.segments,
|
||||||
"processing_time_seconds": request.processing_time_seconds
|
"processing_time_seconds": request.processing_time_seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
webhook_response = requests.post(
|
webhook_response = requests.post(
|
||||||
WEBHOOK_ENDPOINT,
|
WEBHOOK_ENDPOINT,
|
||||||
json=webhook_payload,
|
json=webhook_payload,
|
||||||
headers={"X-Webhook-Key": WEBHOOK_API_KEY},
|
headers={"X-Webhook-Key": WEBHOOK_API_KEY},
|
||||||
timeout=30
|
timeout=30
|
||||||
)
|
)
|
||||||
|
|
||||||
if webhook_response.status_code == 200:
|
if webhook_response.status_code == 200:
|
||||||
logger.info(f"✅ Webhook успешно отправлен для {request.filename}")
|
logger.info(f"✅ Webhook успешно отправлен для {request.filename}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ Webhook вернул статус {webhook_response.status_code}")
|
logger.warning(f"⚠️ Webhook вернул статус {webhook_response.status_code}")
|
||||||
logger.warning(f"Response: {webhook_response.text}")
|
logger.warning(f"Response: {webhook_response.text}")
|
||||||
|
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
logger.error(f"❌ Не удалось подключиться к Calls_WEB_Client_main webhook: {WEBHOOK_ENDPOINT}")
|
logger.error(f"❌ Не удалось подключиться к Calls_WEB_Client_main webhook: {WEBHOOK_ENDPOINT}")
|
||||||
@@ -142,3 +218,25 @@ async def save_ai_conclusion(request: AiConclusionRequest, db: Session = Depends
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
detail=str(e)
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,91 @@
|
|||||||
|
"""
|
||||||
|
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
|
filename = request.filename
|
||||||
file_path = request.file_path
|
file_path = request.file_path
|
||||||
|
|
||||||
@@ -28,6 +116,10 @@
|
|||||||
audio.filename = filename
|
audio.filename = filename
|
||||||
audio.file_size = file_size
|
audio.file_size = file_size
|
||||||
audio.index_date = datetime.utcnow()
|
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.add(audio)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -66,23 +158,64 @@ def process_audio_file(audio_id: str, db: Session):
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"🎵 Запуск распознавания для {audio.filename}")
|
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()
|
||||||
|
|
||||||
# Формируем запрос в GigaAM API
|
# Проверяем что файл существует на диске
|
||||||
|
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
|
from apiApp.config import GIGAAM_API_URL
|
||||||
api_url = f"{GIGAAM_API_URL}/api/call/process"
|
|
||||||
|
# Используем только асинхронный endpoint
|
||||||
|
api_url = f"{GIGAAM_API_URL}/api/call/process/async"
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"filename": audio.filename
|
"filename": audio.filename
|
||||||
}
|
}
|
||||||
|
|
||||||
# Отправляем запрос в GigaAM API
|
|
||||||
import requests
|
import requests
|
||||||
response = requests.post(api_url, json=payload, timeout=10)
|
try:
|
||||||
|
# Отправляем запрос в асинхронный endpoint
|
||||||
|
response = requests.post(api_url, json=payload, timeout=10)
|
||||||
|
|
||||||
if response.status_code == 200 or response.status_code == 202:
|
if response.status_code == 200 or response.status_code == 202:
|
||||||
logger.info(f"✅ Запущено распознавание для {audio.filename}")
|
result = response.json()
|
||||||
else:
|
task_id = result.get('task_id')
|
||||||
logger.error(f"❌ Ошибка запуска распознавания для {audio.filename}: {response.status_code} - {response.text}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка при обработке {audio_id}: {e}")
|
logger.error(f"❌ Ошибка при обработке {audio_id}: {e}")
|
||||||
@@ -107,28 +240,18 @@ async def process_all_pending_audio(
|
|||||||
200 OK + {
|
200 OK + {
|
||||||
"started_count": 15,
|
"started_count": 15,
|
||||||
"pending_files": ["file1.wav", "file2.wav", ...],
|
"pending_files": ["file1.wav", "file2.wav", ...],
|
||||||
"total_pending": 50
|
"total_pending": 50,
|
||||||
|
"skipped_duplicates": 2
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import exists
|
|
||||||
|
|
||||||
limit = request.limit
|
limit = request.limit
|
||||||
|
|
||||||
logger.info(f"🚀 Поиск Audio без AiConclusion (limit={limit})")
|
logger.info(f"🚀 Поиск Audio без AiConclusion (limit={limit})")
|
||||||
|
|
||||||
# Находим все Audio без AiConclusion через подзапрос
|
# Находим все Audio без AiConclusion используя вспомогательную функцию
|
||||||
subquery = db.query(AiConclusion.audio_id).filter(
|
pending_audio = query_audio_without_conclusion(db, limit).all()
|
||||||
AiConclusion.audio_id == Audio.id
|
total_pending = query_audio_without_conclusion(db).count()
|
||||||
)
|
|
||||||
|
|
||||||
pending_audio = db.query(Audio).filter(
|
|
||||||
~exists().where(subquery.exists())
|
|
||||||
).order_by(Audio.index_date.asc()).limit(limit).all()
|
|
||||||
|
|
||||||
total_pending = db.query(Audio).filter(
|
|
||||||
~exists().where(subquery.exists())
|
|
||||||
).count()
|
|
||||||
|
|
||||||
if not pending_audio:
|
if not pending_audio:
|
||||||
logger.info("ℹ️ Нет файлов для распознавания")
|
logger.info("ℹ️ Нет файлов для распознавания")
|
||||||
@@ -136,6 +259,7 @@ async def process_all_pending_audio(
|
|||||||
"started_count": 0,
|
"started_count": 0,
|
||||||
"pending_files": [],
|
"pending_files": [],
|
||||||
"total_pending": 0,
|
"total_pending": 0,
|
||||||
|
"skipped_duplicates": 0,
|
||||||
"message": "Нет файлов без AiConclusion"
|
"message": "Нет файлов без AiConclusion"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +268,7 @@ async def process_all_pending_audio(
|
|||||||
# Добавляем задачи в фон
|
# Добавляем задачи в фон
|
||||||
started_count = 0
|
started_count = 0
|
||||||
pending_files = []
|
pending_files = []
|
||||||
|
skipped_duplicates = 0 # Счётчик дубликатов
|
||||||
|
|
||||||
for audio in pending_audio:
|
for audio in pending_audio:
|
||||||
# Проверяем, что файл существует
|
# Проверяем, что файл существует
|
||||||
@@ -152,25 +277,46 @@ async def process_all_pending_audio(
|
|||||||
logger.warning(f"⚠️ Файл не найден на диске: {audio.filename}")
|
logger.warning(f"⚠️ Файл не найден на диске: {audio.filename}")
|
||||||
continue
|
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}")
|
||||||
|
|
||||||
# Добавляем в фон (асинхронно)
|
# Добавляем в фон (асинхронно)
|
||||||
# В FastAPI используем BackgroundTasks
|
|
||||||
# Но нужно создавать новую сессию для каждого таска
|
|
||||||
pending_files.append(audio.filename)
|
pending_files.append(audio.filename)
|
||||||
started_count += 1
|
started_count += 1
|
||||||
|
|
||||||
# Запускаем обработку в фоне
|
# Запускаем обработку в фоне
|
||||||
# Используем lambda для захвата audio_id
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
process_single_audio,
|
process_single_audio,
|
||||||
str(audio.id)
|
str(audio.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"✅ Запущено распознавание для {started_count} файлов")
|
logger.info(f"✅ Запущено распознавание для {started_count} файлов")
|
||||||
|
if skipped_duplicates > 0:
|
||||||
|
logger.info(f"⏭️ Пропущено дубликатов: {skipped_duplicates}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"started_count": started_count,
|
"started_count": started_count,
|
||||||
"pending_files": pending_files,
|
"pending_files": pending_files,
|
||||||
"total_pending": total_pending
|
"total_pending": total_pending,
|
||||||
|
"skipped_duplicates": skipped_duplicates
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -211,15 +357,7 @@ async def get_pending_audio(
|
|||||||
Список файлов, ожидающих распознавания
|
Список файлов, ожидающих распознавания
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import exists
|
pending_audio = query_audio_without_conclusion(db, limit).all()
|
||||||
|
|
||||||
subquery = db.query(AiConclusion.audio_id).filter(
|
|
||||||
AiConclusion.audio_id == Audio.id
|
|
||||||
)
|
|
||||||
|
|
||||||
pending_audio = db.query(Audio).filter(
|
|
||||||
~exists().where(subquery.exists())
|
|
||||||
).order_by(Audio.index_date.asc()).limit(limit).all()
|
|
||||||
|
|
||||||
files_info = []
|
files_info = []
|
||||||
for audio in pending_audio:
|
for audio in pending_audio:
|
||||||
@@ -234,9 +372,7 @@ async def get_pending_audio(
|
|||||||
"exists_on_disk": exists
|
"exists_on_disk": exists
|
||||||
})
|
})
|
||||||
|
|
||||||
total_pending = db.query(Audio).filter(
|
total_pending = query_audio_without_conclusion(db).count()
|
||||||
~exists().where(subquery.exists())
|
|
||||||
).count()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_pending": total_pending,
|
"total_pending": total_pending,
|
||||||
@@ -261,36 +397,55 @@ async def get_audio_stats(db: Session = Depends(get_db)):
|
|||||||
Статистика по Audio записям
|
Статистика по Audio записям
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import exists
|
|
||||||
|
|
||||||
total_audio = db.query(Audio).count()
|
total_audio = db.query(Audio).count()
|
||||||
|
with_conclusion = total_audio - query_audio_without_conclusion(db).count()
|
||||||
subquery = db.query(AiConclusion.audio_id).filter(
|
without_conclusion = query_audio_without_conclusion(db).count()
|
||||||
AiConclusion.audio_id == Audio.id
|
|
||||||
)
|
|
||||||
|
|
||||||
with_conclusion = db.query(Audio).filter(
|
|
||||||
exists().where(subquery.exists())
|
|
||||||
).count()
|
|
||||||
|
|
||||||
without_conclusion = total_audio - with_conclusion
|
|
||||||
|
|
||||||
# Проверяем существование файлов на диске
|
# Проверяем существование файлов на диске
|
||||||
all_audio = db.query(Audio).all()
|
all_audio = db.query(Audio).all()
|
||||||
existing_count = 0
|
existing_count = 0
|
||||||
|
missing_files = []
|
||||||
|
small_files = [] # Файлы меньше 1KB
|
||||||
|
|
||||||
for audio in all_audio:
|
for audio in all_audio:
|
||||||
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
existing_count += 1
|
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
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
stats = {
|
||||||
"total_audio": total_audio,
|
"total_audio": total_audio,
|
||||||
"with_conclusion": with_conclusion,
|
"with_conclusion": with_conclusion,
|
||||||
"without_conclusion": without_conclusion,
|
"without_conclusion": without_conclusion,
|
||||||
"existing_on_disk": existing_count,
|
"existing_on_disk": existing_count,
|
||||||
"missing_on_disk": total_audio - 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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка при получении статистики: {e}")
|
logger.error(f"❌ Ошибка при получении статистики: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -299,62 +454,71 @@ async def get_audio_stats(db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def auto_restore_on_startup(db: Session, limit: int = 100):
|
@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)
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Автоматическое восстановление распознавания при старте FileAudioAPI
|
Удаляет записи Audio для проблемных файлов
|
||||||
|
|
||||||
Проверяет, есть ли файлы без AiConclusion, и запускает их распознавание
|
Query Parameters:
|
||||||
|
delete_missing: Удалять записи с отсутствующими файлами (default: False)
|
||||||
|
delete_small: Удалять записи с маленькими файлами (default: True)
|
||||||
|
min_size_bytes: Минимальный размер файла в bytes (default: 1000)
|
||||||
|
|
||||||
Args:
|
Returns:
|
||||||
db: Сессия БД
|
Статистику удаления
|
||||||
limit: Максимум файлов для восстановления
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import or_
|
all_audio = db.session.query(Audio).all()
|
||||||
|
|
||||||
# Проверяем, есть ли файлы без AiConclusion
|
deleted_missing = 0
|
||||||
pending_audio = db.query(Audio).filter(
|
deleted_small = 0
|
||||||
or_(
|
|
||||||
Audio.AiConclusion == None,
|
|
||||||
Audio.AiConclusion == ''
|
|
||||||
)
|
|
||||||
).limit(limit).all()
|
|
||||||
|
|
||||||
if not pending_audio:
|
for audio in all_audio:
|
||||||
logger.info("ℹ️ Auto-restore: нет файлов для распознавания")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"🔄 Auto-restore: найдено {len(pending_audio)} файлов без AiConclusion")
|
|
||||||
|
|
||||||
# Запускаем распознавание
|
|
||||||
started_count = 0
|
|
||||||
for audio in pending_audio:
|
|
||||||
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
# Проверяем отсутствие файла
|
||||||
logger.warning(f"⚠️ Файл не найден: {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
|
continue
|
||||||
|
|
||||||
# Отправляем в GigaAM API
|
# Проверяем размер файла
|
||||||
from apiApp.config import GIGAAM_API_URL
|
if delete_small and os.path.exists(file_path):
|
||||||
api_url = f"{GIGAAM_API_URL}/api/call/process"
|
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}")
|
||||||
|
|
||||||
payload = {"filename": audio.filename}
|
db.session.delete(audio)
|
||||||
|
deleted_small += 1
|
||||||
|
|
||||||
try:
|
db.session.commit()
|
||||||
import requests
|
|
||||||
response = requests.post(api_url, json=payload, timeout=5)
|
|
||||||
|
|
||||||
if response.status_code in [200, 202]:
|
return {
|
||||||
logger.info(f"✅ Запущено распознавание: {audio.filename}")
|
"success": True,
|
||||||
started_count += 1
|
"deleted_missing": deleted_missing,
|
||||||
else:
|
"deleted_small": deleted_small,
|
||||||
logger.warning(f"⚠️ Ошибка запуска {audio.filename}: {response.status_code}")
|
"total_deleted": deleted_missing + deleted_small,
|
||||||
|
"message": f"Удалено {deleted_missing + deleted_small} записей"
|
||||||
except Exception as e:
|
}
|
||||||
logger.error(f"❌ Ошибка при запуске {audio.filename}: {e}")
|
|
||||||
|
|
||||||
logger.info(f"🎉 Auto-restore завершено: запущено {started_count} файлов")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка при auto-restore: {e}")
|
db.session.rollback()
|
||||||
|
logger.error(f"❌ Ошибка при очистке: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,393 +0,0 @@
|
|||||||
"""
|
|
||||||
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.config import AUDIOFILES_PATH
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
audio_management_router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
# Формируем запрос в GigaAM API
|
|
||||||
from apiApp.config import GIGAAM_API_URL
|
|
||||||
api_url = f"{GIGAAM_API_URL}/api/call/process"
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"filename": audio.filename
|
|
||||||
}
|
|
||||||
|
|
||||||
# Отправляем запрос в GigaAM API
|
|
||||||
import requests
|
|
||||||
response = requests.post(api_url, json=payload, timeout=10)
|
|
||||||
|
|
||||||
if response.status_code == 200 or response.status_code == 202:
|
|
||||||
logger.info(f"✅ Запущено распознавание для {audio.filename}")
|
|
||||||
else:
|
|
||||||
logger.error(f"❌ Ошибка запуска распознавания для {audio.filename}: {response.status_code} - {response.text}")
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
limit = request.limit
|
|
||||||
|
|
||||||
logger.info(f"🚀 Поиск Audio без AiConclusion (limit={limit})")
|
|
||||||
|
|
||||||
# Находим все Audio без AiConclusion
|
|
||||||
pending_audio = db.query(Audio).filter(
|
|
||||||
Audio.AiConclusion == None
|
|
||||||
).order_by(Audio.index_date.asc()).limit(limit).all()
|
|
||||||
|
|
||||||
total_pending = db.query(Audio).filter(Audio.AiConclusion == None).count()
|
|
||||||
|
|
||||||
if not pending_audio:
|
|
||||||
logger.info("ℹ️ Нет файлов для распознавания")
|
|
||||||
return {
|
|
||||||
"started_count": 0,
|
|
||||||
"pending_files": [],
|
|
||||||
"total_pending": 0,
|
|
||||||
"message": "Нет файлов без AiConclusion"
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"📋 Найдено файлов для обработки: {len(pending_audio)} из {total_pending}")
|
|
||||||
|
|
||||||
# Добавляем задачи в фон
|
|
||||||
started_count = 0
|
|
||||||
pending_files = []
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Добавляем в фон (асинхронно)
|
|
||||||
# В FastAPI используем BackgroundTasks
|
|
||||||
# Но нужно создавать новую сессию для каждого таска
|
|
||||||
pending_files.append(audio.filename)
|
|
||||||
started_count += 1
|
|
||||||
|
|
||||||
# Запускаем обработку в фоне
|
|
||||||
# Используем lambda для захвата audio_id
|
|
||||||
background_tasks.add_task(
|
|
||||||
process_single_audio,
|
|
||||||
str(audio.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Запущено распознавание для {started_count} файлов")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"started_count": started_count,
|
|
||||||
"pending_files": pending_files,
|
|
||||||
"total_pending": total_pending
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = db.query(Audio).filter(
|
|
||||||
Audio.AiConclusion == None
|
|
||||||
).order_by(Audio.index_date.asc()).limit(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 = db.query(Audio).filter(Audio.AiConclusion == None).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 = db.query(Audio).filter(Audio.AiConclusion != None).count()
|
|
||||||
without_conclusion = db.query(Audio).filter(Audio.AiConclusion == None).count()
|
|
||||||
|
|
||||||
# Проверяем существование файлов на диске
|
|
||||||
all_audio = db.query(Audio).all()
|
|
||||||
existing_count = 0
|
|
||||||
for audio in all_audio:
|
|
||||||
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
existing_count += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_audio": total_audio,
|
|
||||||
"with_conclusion": with_conclusion,
|
|
||||||
"without_conclusion": without_conclusion,
|
|
||||||
"existing_on_disk": existing_count,
|
|
||||||
"missing_on_disk": total_audio - existing_count
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка при получении статистики: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def auto_restore_on_startup(db: Session, limit: int = 100):
|
|
||||||
"""
|
|
||||||
Автоматическое восстановление распознавания при старте FileAudioAPI
|
|
||||||
|
|
||||||
Проверяет, есть ли файлы без AiConclusion, и запускает их распознавание
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Сессия БД
|
|
||||||
limit: Максимум файлов для восстановления
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from sqlalchemy import or_
|
|
||||||
|
|
||||||
# Проверяем, есть ли файлы без AiConclusion
|
|
||||||
pending_audio = db.query(Audio).filter(
|
|
||||||
or_(
|
|
||||||
Audio.AiConclusion == None,
|
|
||||||
Audio.AiConclusion == ''
|
|
||||||
)
|
|
||||||
).limit(limit).all()
|
|
||||||
|
|
||||||
if not pending_audio:
|
|
||||||
logger.info("ℹ️ Auto-restore: нет файлов для распознавания")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"🔄 Auto-restore: найдено {len(pending_audio)} файлов без AiConclusion")
|
|
||||||
|
|
||||||
# Запускаем распознавание
|
|
||||||
started_count = 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
|
|
||||||
|
|
||||||
# Отправляем в GigaAM API
|
|
||||||
from apiApp.config import GIGAAM_API_URL
|
|
||||||
api_url = f"{GIGAAM_API_URL}/api/call/process"
|
|
||||||
|
|
||||||
payload = {"filename": audio.filename}
|
|
||||||
|
|
||||||
try:
|
|
||||||
import requests
|
|
||||||
response = requests.post(api_url, json=payload, timeout=5)
|
|
||||||
|
|
||||||
if response.status_code in [200, 202]:
|
|
||||||
logger.info(f"✅ Запущено распознавание: {audio.filename}")
|
|
||||||
started_count += 1
|
|
||||||
else:
|
|
||||||
logger.warning(f"⚠️ Ошибка запуска {audio.filename}: {response.status_code}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка при запуске {audio.filename}: {e}")
|
|
||||||
|
|
||||||
logger.info(f"🎉 Auto-restore завершено: запущено {started_count} файлов")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка при auto-restore: {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()
|
||||||
|
|||||||
@@ -11,3 +11,6 @@ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./speech_analytics.db")
|
|||||||
|
|
||||||
# GigaAM API
|
# GigaAM API
|
||||||
GIGAAM_API_URL = os.getenv("GIGAAM_API_URL", "http://localhost:5001")
|
GIGAAM_API_URL = os.getenv("GIGAAM_API_URL", "http://localhost:5001")
|
||||||
|
|
||||||
|
#App Setting
|
||||||
|
PORT = 5008
|
||||||
|
|||||||
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}")
|
||||||
@@ -11,6 +11,7 @@ from apiApp.routers import audio_router, recognition_router
|
|||||||
from apiApp.routers.ai_conclusion_router import ai_conclusion_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_files_router import audio_files_router
|
||||||
from apiApp.routers.audio_management_router import audio_management_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")
|
print("✅ audio_management_router imported successfully")
|
||||||
|
|
||||||
@@ -63,10 +64,11 @@ 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(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(audio_files_router, prefix=API_V1_PREFIX, tags=["audio_files"])
|
||||||
# audio_management_router без префикса для совместимости с вызовами из Calls_WEB_Client_main
|
app.include_router(external_audio_router, prefix=API_V1_PREFIX)
|
||||||
|
# audio_management_router с префиксом /audio для логической структуры
|
||||||
print("📝 Registering audio_management_router...")
|
print("📝 Registering audio_management_router...")
|
||||||
app.include_router(audio_management_router, tags=["audio_management"])
|
app.include_router(audio_management_router, prefix="/api", tags=["audio_management"])
|
||||||
print("✅ audio_management_router registered at /api/audio/*")
|
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")
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ python-multipart==0.0.12
|
|||||||
aiofiles==24.1.0
|
aiofiles==24.1.0
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
paramiko
|
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"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user