diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..528c498 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,63 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Database +*.db +*.sqlite +*.sqlite3 + +# Uploads (в Docker создаются заново) +uploads/* + +# Docker +Dockerfile +.dockerignore +docker-compose.yml + +# Documentation +README*.md +*.md + +# Environment +.env +.env.local + +# Logs +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..299a9ff --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Database +# DATABASE_URL=sqlite:///./speech_analytics.db +# Для PostgreSQL: +# DATABASE_URL=postgresql://user:password@localhost/speech_analytics + +# 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=8000 +# RELOAD=True diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8a38469 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Базовый образ с Python +FROM python:3.10-slim + +# Установка переменных окружения +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Рабочая директория +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Копирование requirements.txt +COPY requirements.txt . + +# Установка Python зависимостей +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +# Копирование приложения +COPY . . + +# Создание директории для загрузки файлов +RUN mkdir -p /app/uploads + +# Создание директории для базы данных (если используется SQLite) +RUN mkdir -p /app/data + +# Открытие порта +EXPOSE 8000 + +# Команда запуска приложения +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a871fd7 --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ +.PHONY: help build up down restart logs shell test clean install db-migrate + +# Default target +help: + @echo "Available commands:" + @echo " make install - Install Python dependencies" + @echo " make build - Build Docker image" + @echo " make up - Start Docker containers" + @echo " make down - Stop Docker containers" + @echo " make restart - Restart Docker containers" + @echo " make logs - Show Docker logs" + @echo " make shell - Open shell in container" + @echo " make test - Run tests" + @echo " make clean - Clean up containers and volumes" + @echo " make db-migrate - Create database tables" + +# Install dependencies +install: + @echo "Installing dependencies..." + pip3 install -r requirements.txt + +# Build Docker image +build: + @echo "Building Docker image..." + docker-compose build + +# Start containers +up: + @echo "Starting containers..." + docker-compose up -d + @echo "✅ Application started at http://localhost:8000" + @echo "📚 API Documentation: http://localhost:8000/api/v1/docs" + +# Stop containers +down: + @echo "Stopping containers..." + docker-compose down + +# Restart containers +restart: down up + +# Show logs +logs: + docker-compose logs -f app + +# Open shell in container +shell: + docker-compose exec app bash + +# Run tests +test: + @echo "Running tests..." + python3 -m pytest tests/ -v + +# Clean up +clean: + @echo "Cleaning up..." + docker-compose down -v + rm -rf data/*.db + rm -rf uploads/* + +# Create database tables +db-migrate: + @echo "Creating database tables..." + python3 -c "from apiApp.database import Base, engine; Base.metadata.create_all(bind=engine); print('✅ Database tables created')" + +# Development run +dev: + @echo "Starting development server..." + python3 run.py + +# Format code +format: + @echo "Formatting code..." + black apiApp/ + isort apiApp/ + +# Lint code +lint: + @echo "Linting code..." + flake8 apiApp/ + mypy apiApp/ diff --git a/README_DOCKER.md b/README_DOCKER.md new file mode 100644 index 0000000..fb87e80 --- /dev/null +++ b/README_DOCKER.md @@ -0,0 +1,221 @@ +# Docker Deployment Guide + +## Быстрый старт + +### Сборка и запуск с Docker Compose (рекомендуется) + +```bash +# Сборка и запуск +docker-compose up -d + +# Просмотр логов +docker-compose logs -f + +# Остановка +docker-compose down +``` + +Приложение будет доступно по адресу: http://localhost:8000 + +API документация: +- Swagger UI: http://localhost:8000/api/v1/docs +- ReDoc: http://localhost:8000/api/v1/redoc + +### Ручной запуск с Docker + +```bash +# Сборка образа +docker build -t file-audio-api . + +# Запуск контейнера +docker run -d \ + --name file-audio-api \ + -p 8000:8000 \ + -v $(pwd)/uploads:/app/uploads \ + -v $(pwd)/data:/app/data \ + -e DATABASE_URL=sqlite:////app/data/speech_analytics.db \ + file-audio-api + +# Просмотр логов +docker logs -f file-audio-api + +# Остановка и удаление контейнера +docker stop file-audio-api +docker rm file-audio-api +``` + +## Управление данными + +### Загруженные файлы +Файлы сохраняются в директории `./uploads` на хост-машине (монтируются в контейнер). + +### База данных +SQLite база данных находится в `./data/speech_analytics.db`. + +### Бэкап данных + +```bash +# Бэкап базы данных +docker exec file-audio-api cp /app/data/speech_analytics.db /app/backup_$(date +%Y%m%d).db + +# Бэкап загруженных файлов +tar -czf uploads_backup_$(date +%Y%m%d).tar.gz uploads/ +``` + +## Production конфигурация + +### Использование PostgreSQL + +Раскомментируйте секцию `db` в `docker-compose.yml` и измените `DATABASE_URL`: + +```yaml +environment: + - DATABASE_URL=postgresql://speechuser:speechpass@db:5432/speech_analytics +``` + +### Использование Redis для статусов задач + +Раскомментируйте секцию `redis` в `docker-compose.yml` и подключите в коде: + +```python +# apiApp/config.py +import redis +redis_client = redis.from_url("redis://redis:6379") +``` + +### Environment переменные + +Создайте `.env` файл: + +```bash +cp .env.example .env +``` + +И отредактируйте: + +```env +DATABASE_URL=postgresql://user:password@db:5432/speech_analytics +MAX_UPLOAD_SIZE=104857600 +``` + +## Health Check + +```bash +curl http://localhost:8000/health +``` + +Ожидаемый ответ: +```json +{"status": "healthy"} +``` + +## Логи и отладка + +```bash +# Просмотр логов +docker-compose logs -f app + +# Вход в контейнер для отладки +docker-compose exec app bash + +# Перезапуск с пересборкой +docker-compose up -d --build +``` + +## Производительность + +### Настройка количества workers + +Для production рекомендуется использовать несколько workers: + +```bash +docker run -d \ + --name file-audio-api \ + -p 8000:8000 \ + file-audio-api \ + uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +Или в `docker-compose.yml`: + +```yaml +command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] +``` + +## Безопасность + +### Ограничение CORS + +Отредактируйте `main.py`: + +```python +app.add_middleware( + CORSMiddleware, + allow_origins=["https://yourdomain.com"], # Замените на ваш домен + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +``` + +### Добавление SSL + +Используйте reverse proxy (nginx) или: + +```bash +docker run -d \ + --name file-audio-api \ + -p 8443:8443 \ + -v /path/to/certs:/app/certs \ + file-audio-api \ + uvicorn main:app --host 0.0.0.0 --port 8443 --ssl-keyfile /app/certs/key.pem --ssl-certfile /app/certs/cert.pem +``` + +## Troubleshooting + +### Контейнер не запускается + +```bash +# Проверка логов +docker-compose logs app + +# Проверка статуса +docker-compose ps +``` + +### Ошибки с базой данных + +```bash +# Перезапуск с очисткой volumes +docker-compose down -v +docker-compose up -d +``` + +### Проблемы с правами доступа + +```bash +# Установка прав на директорию uploads +chmod -R 755 uploads/ +``` + +## Мониторинг + +### Использование Docker stats + +```bash +docker stats file-audio-api +``` + +### Интеграция с Prometheus + +Добавьте в `docker-compose.yml`: + +```yaml +services: + prometheus: + image: prom/prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" +``` diff --git a/README_FASTAPI.md b/README_FASTAPI.md new file mode 100644 index 0000000..7e7fb7b --- /dev/null +++ b/README_FASTAPI.md @@ -0,0 +1,138 @@ +# Speech Analytics FastAPI + +Сервис для хранения аудиофайлов, индексации файлов, записи и выдачи результатов распознавания, реализованный на FastAPI. + +## Установка зависимостей + +```bash +pip install -r requirements.txt +``` + +## Запуск приложения + +### Разработка с автоперезагрузкой +```bash +python run.py +``` + +### Production режим +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +## API Эндпоинты + +Документация доступна по адресу: +- Swagger UI: http://localhost:8000/api/v1/docs +- ReDoc: http://localhost:8000/api/v1/redoc + +### Аудио файлы + +#### Загрузка аудиофайла +```http +POST /api/v1/upload +Content-Type: multipart/form-data + +file: +``` + +#### Список всех аудиофайлов +```http +GET /api/v1/audio/list?skip=0&limit=100 +``` + +#### Получить информацию об аудиофайле +```http +GET /api/v1/audio/{audio_id} +``` + +#### Скачать аудиофайл +```http +GET /api/v1/audio/file/{audio_id} +``` + +#### Удалить аудиофайл +```http +DELETE /api/v1/audio/delete/{audio_id} +``` + +### Распознавание + +#### Запустить распознавание +```http +POST /api/v1/recognize/{audio_id} +``` + +Возвращает: +- 202 Accepted - распознавание запущено +- 200 OK - уже есть активная задача +- 404 Not Found - аудио не найдено + +#### Получить статус распознавания +```http +GET /api/v1/recognize/{audio_id} +``` + +Статусы: +- `not_started` - распознавание не запускалось +- `processing` - в процессе +- `completed` - завершено +- `error` - ошибка + +#### Получить статус по task_id +```http +GET /api/v1/recognize/task/{task_id} +``` + +#### Получить результат распознавания +```http +GET /api/v1/recognize/{audio_id}/result +``` + +## Структура проекта + +``` +FileAudioAPI/ +├── main.py # Точка входа FastAPI приложения +├── run.py # Скрипт запуска +├── requirements.txt # Зависимости +├── apiApp/ +│ ├── __init__.py +│ ├── config.py # Конфигурация +│ ├── database.py # SQLAlchemy настройка +│ ├── schemas.py # Pydantic модели +│ ├── crud.py # CRUD операции +│ ├── routers/ # API routers +│ │ ├── __init__.py +│ │ ├── audio.py # Эндпоинты для аудио +│ │ └── recognition.py # Эндпоинты для распознавания +│ ├── database/ # SQLAlchemy модели +│ │ ├── Audio.py +│ │ ├── AiConclusion.py +│ │ ├── Operator.py +│ │ └── ConclusionVersion.py +│ └── dbApi/ # Legacy совместимость +│ └── __init__.py +└── uploads/ # Загруженные файлы +``` + +## Конфигурация + +Переменные окружения: +- `DATABASE_URL` - URL базы данных (по умолчанию: sqlite:///./speech_analytics.db) + +## Основные изменения от Flask версии + +1. **FastAPI вместо Flask**: Использование современного асинхронного фреймворка +2. **Pydantic схемы**: Валидация данных с помощью Pydantic +3. **Асинхронная обработка**: BackgroundTasks для фоновых задач +4. **Dependency Injection**: Внедрение зависимостей для сессий БД +5. **Автодокументация**: Автоматическая генерация Swagger/ReDoc +6. **Type hints**: Полная поддержка типизации + +## Примечания + +- Для production рекомендуется использовать PostgreSQL вместо SQLite +- Для хранения статусов задач в production рекомендуется использовать Redis +- Фоновые задачи обрабатываются асинхронно с помощью BackgroundTasks +- Поддерживается загрузка файлов до 100MB diff --git a/START.md b/START.md new file mode 100644 index 0000000..3997572 --- /dev/null +++ b/START.md @@ -0,0 +1,184 @@ +# FileAudioAPI - Quick Start Guide + +## 🚀 Быстрый старт + +### Вариант 1: Запуск с Docker (рекомендуется) + +```bash +# Клонировать репозиторий +cd /Users/petr/SpeechAnalytics/FileAudioAPI + +# Запуск с Docker Compose +make up + +# Или вручную +docker-compose up -d +``` + +Приложение будет доступно по адресу: **http://localhost:8000** + +API документация: +- 📚 Swagger UI: http://localhost:8000/api/v1/docs +- 📖 ReDoc: http://localhost:8000/api/v1/redoc + +### Вариант 2: Локальный запуск + +```bash +# 1. Установить зависимости +make install +# или +pip3 install -r requirements.txt + +# 2. Создать базу данных +make db-migrate + +# 3. Запустить приложение +make dev +# или +python3 run.py +``` + +## 📋 Makefile команды + +```bash +make help # Показать все доступные команды +make build # Собрать Docker образ +make up # Запустить контейнеры +make down # Остановить контейнеры +make logs # Просмотр логов +make shell # Открыть shell в контейнере +make clean # Очистка контейнеров и volumes +``` + +## 🔧 Полная документация + +- [FastAPI API Guide](README_FASTAPI.md) - Документация API +- [Docker Deployment](README_DOCKER.md) - Docker развертывание + +## 📁 Структура проекта + +``` +FileAudioAPI/ +├── apiApp/ +│ ├── schemas/ # Pydantic модели валидации +│ ├── services/ # Business logic (CRUD) +│ ├── routers/ # API endpoints +│ ├── database/ # SQLAlchemy модели +│ ├── dbApi/ # Legacy совместимость +│ ├── config.py # Конфигурация +│ └── database.py # SQLAlchemy настройка +├── main.py # FastAPI приложение +├── Dockerfile # Docker образ +├── docker-compose.yml # Docker Compose конфиг +├── Makefile # Управление проектом +└── requirements.txt # Зависимости +``` + +## 🎯 Основные endpoints + +### Загрузка файла +```bash +curl -X POST http://localhost:8000/api/v1/upload \ + -F "file=@audio.mp3" +``` + +### Список файлов +```bash +curl http://localhost:8000/api/v1/audio/list +``` + +### Запуск распознавания +```bash +curl -X POST http://localhost:8000/api/v1/recognize/{audio_id} +``` + +### Статус распознавания +```bash +curl http://localhost:8000/api/v1/recognize/{audio_id} +``` + +## 🛠️ Разработка + +### Локальный запуск с автоперезагрузкой +```bash +python3 run.py +# или +make dev +``` + +### Запуск тестов +```bash +make test +``` + +### Форматирование кода +```bash +make format +``` + +### Линтер +```bash +make lint +``` + +## 🐛 Troubleshooting + +### Проблема: Module not found +```bash +# Переустановить зависимости +pip3 install -r requirements.txt +``` + +### Проблема: Database error +```bash +# Пересоздать базу данных +make clean +make db-migrate +make up +``` + +### Проблема: Port already in use +```bash +# Изменить порт в docker-compose.yml +# или в main.py +``` + +## 📝 Environment переменные + +Скопируйте `.env.example` в `.env` и настройте: + +```bash +cp .env.example .env +``` + +Доступные переменные: +- `DATABASE_URL` - URL базы данных +- `MAX_UPLOAD_SIZE` - Макс. размер загрузки +- `APP_TITLE` - Название приложения +- `APP_VERSION` - Версия приложения + +## 🔒 Безопасность + +Для production: +1. Измените `CORS` origins в `main.py` +2. Настройте `DATABASE_URL` на PostgreSQL +3. Используйте Redis для хранения статусов задач +4. Добавьте аутентификацию +5. Настройте SSL/HTTPS + +## 📊 Мониторинг + +```bash +# Health check +curl http://localhost:8000/health + +# Docker stats +docker stats file-audio-api + +# Логи +make logs +``` + +## 🤝 Поддержка + +Для вопросов и предложений создайте issue в репозитории. diff --git a/apiApp/__init__.py b/apiApp/__init__.py new file mode 100644 index 0000000..434efd4 --- /dev/null +++ b/apiApp/__init__.py @@ -0,0 +1,4 @@ +# apiApp package +# FastAPI application for Speech Analytics + +__version__ = "1.0.0" diff --git a/apiApp/config.py b/apiApp/config.py new file mode 100644 index 0000000..e73fca1 --- /dev/null +++ b/apiApp/config.py @@ -0,0 +1,19 @@ +import os +from pathlib import Path + +# Базовые пути +BASE_DIR = Path(__file__).resolve().parent.parent +UPLOAD_FOLDER = BASE_DIR / "uploads" +UPLOAD_FOLDER.mkdir(exist_ok=True) + +# Database +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./speech_analytics.db") + +# API Settings +API_V1_PREFIX = "/api/v1" +MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB +ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"} + +# Application +APP_TITLE = "Speech Analytics API" +APP_VERSION = "1.0.0" diff --git a/apiApp/database.py b/apiApp/database.py new file mode 100644 index 0000000..89b41bd --- /dev/null +++ b/apiApp/database.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from apiApp.config import DATABASE_URL + +# Создание engine +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {} +) + +# SessionLocal +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base +Base = declarative_base() + + +# Зависимость для получения сессии БД +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/apiApp/database/AiConclusion.py b/apiApp/database/AiConclusion.py new file mode 100644 index 0000000..43689f4 --- /dev/null +++ b/apiApp/database/AiConclusion.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, UUID, ForeignKey, DateTime, JSON +from sqlalchemy.orm import relationship +from apiApp.database import Base +import uuid +from datetime import datetime + + +class AiConclusion(Base): + __tablename__ = "ai_conclusion" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + audio_id = Column(UUID(as_uuid=True), ForeignKey("audio.id"), nullable=False) + conclusion = Column(JSON, default=lambda: { + "transcription": [], + "ai_transcription": [], + "conclusion": {} + }) + index_date = Column(DateTime, default=datetime.utcnow) + end_date = Column(DateTime) + + audio = relationship("Audio", back_populates="ai_conclusion") \ No newline at end of file diff --git a/apiApp/database/Audio.py b/apiApp/database/Audio.py new file mode 100644 index 0000000..be3df24 --- /dev/null +++ b/apiApp/database/Audio.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, String, DateTime, UUID, ForeignKey, Float, Integer +from sqlalchemy.orm import relationship +from apiApp.database import Base +import uuid +from datetime import datetime + + +class Audio(Base): + __tablename__ = "audio" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + filename = Column(String(255), nullable=False) + index_date = Column(DateTime, default=datetime.utcnow) + file_path = Column(String(500)) + duration = Column(Float) + file_size = Column(Integer) + + ai_conclusion = relationship("AiConclusion", back_populates="audio", cascade="all, delete-orphan") + + def to_dict(self): + return { + "id": str(self.id), + "filename": self.filename, + "index_date": self.index_date.isoformat() if self.index_date else None, + "file_path": self.file_path, + "duration": self.duration, + "file_size": self.file_size + } \ No newline at end of file diff --git a/apiApp/database/ConclusionVersion.py b/apiApp/database/ConclusionVersion.py new file mode 100644 index 0000000..e9dbaac --- /dev/null +++ b/apiApp/database/ConclusionVersion.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, UUID, ForeignKey, Integer, Text +from apiApp.database import Base +import uuid + + +class ConclusionVersion(Base): + __tablename__ = "conclusion_version" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + conclusion_id = Column(UUID(as_uuid=True), ForeignKey("conclusion.id")) + version = Column(Integer) + content = Column(Text) \ No newline at end of file diff --git a/apiApp/database/Operator.py b/apiApp/database/Operator.py new file mode 100644 index 0000000..2edf9f9 --- /dev/null +++ b/apiApp/database/Operator.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, UUID, String, Integer +from sqlalchemy.orm import relationship +from apiApp.database import Base +import uuid + + +class Operator(Base): + __tablename__ = "operator" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + fio = Column(String(100)) + num = Column(Integer) + + calls = relationship("Call", back_populates="operator") + + diff --git a/apiApp/database/__init__.py b/apiApp/database/__init__.py new file mode 100644 index 0000000..2680dd8 --- /dev/null +++ b/apiApp/database/__init__.py @@ -0,0 +1,31 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from apiApp.config import DATABASE_URL + +# Создание engine +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {} +) + +# SessionLocal +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base +Base = declarative_base() + + +# Зависимость для получения сессии БД +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +from apiApp.database.Operator import Operator +from apiApp.database.Audio import Audio +from apiApp.database.AiConclusion import AiConclusion +from apiApp.database.ConclusionVersion import ConclusionVersion \ No newline at end of file diff --git a/apiApp/dbApi/__init__.py b/apiApp/dbApi/__init__.py new file mode 100644 index 0000000..ec33e1f --- /dev/null +++ b/apiApp/dbApi/__init__.py @@ -0,0 +1,65 @@ +# Legacy database API support +# Этот модуль обеспечивает обратную совместимость со старым кодом + +from apiApp.crud import AudioCRUD, AiConclusionCRUD + +class AudioDB: + """ + Совместимый слой для старого API + """ + + @staticmethod + def list(): + from apiApp.database import SessionLocal + db = SessionLocal() + try: + return AudioCRUD.get_all(db) + finally: + db.close() + + @staticmethod + def get(audio_id): + from apiApp.database import SessionLocal + db = SessionLocal() + try: + return AudioCRUD.get_by_id(db, audio_id) + finally: + db.close() + + @staticmethod + def add(data): + from apiApp.database import SessionLocal + from apiApp.schemas import AudioCreate + db = SessionLocal() + try: + audio_data = AudioCreate(**data) + return AudioCRUD.create(db, audio_data, file_path=data.get('file_path', '')) + finally: + db.close() + + @staticmethod + def put(audio_id, data): + from apiApp.database import SessionLocal + db = SessionLocal() + try: + return AudioCRUD.update(db, audio_id, data) + finally: + db.close() + + @staticmethod + def delete(audio_id): + from apiApp.database import SessionLocal + db = SessionLocal() + try: + return AudioCRUD.delete(db, audio_id) + finally: + db.close() + + @staticmethod + def update_recognition_result(audio_id, result): + from apiApp.database import SessionLocal + db = SessionLocal() + try: + return AudioCRUD.update_recognition_result(db, audio_id, result) + finally: + db.close() diff --git a/apiApp/dbApi/audio_db.py b/apiApp/dbApi/audio_db.py new file mode 100644 index 0000000..0f016f9 --- /dev/null +++ b/apiApp/dbApi/audio_db.py @@ -0,0 +1,38 @@ + +from apiApp.database.Audio import Audio +from apiApp.database import db +import datetime + +class AudioDB: + + @staticmethod + def list(): + return Audio.query.all() + + @staticmethod + def get(audio_id): + return Audio.query.get(audio_id) + + @staticmethod + def add(data): + data['datetime'] = datetime.datetime.now() + audio = Audio(**data) + db.session.add(audio) + db.session.commit() + return audio + + @staticmethod + def put(audio_id, data): + audio = Audio.query.get(audio_id) + for key, value in data.items(): + setattr(audio, key, value) + db.session.commit() + return audio + + @staticmethod + def delete(audio_id): + audio = Audio.query.get(audio_id) + db.session.delete(audio) + db.session.commit() + return audio + \ No newline at end of file diff --git a/apiApp/dbApi/conclusion_db.py b/apiApp/dbApi/conclusion_db.py new file mode 100644 index 0000000..e69de29 diff --git a/apiApp/routers/__init__.py b/apiApp/routers/__init__.py new file mode 100644 index 0000000..699ec8f --- /dev/null +++ b/apiApp/routers/__init__.py @@ -0,0 +1,4 @@ +from apiApp.routers.audio import router as audio_router +from apiApp.routers.recognition import router as recognition_router + +__all__ = ["audio_router", "recognition_router"] diff --git a/apiApp/routers/audio.py b/apiApp/routers/audio.py new file mode 100644 index 0000000..d2121e4 --- /dev/null +++ b/apiApp/routers/audio.py @@ -0,0 +1,155 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File as FastAPIFile, status +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from typing import List +import os +import uuid +import aiofiles + +from apiApp.database import get_db +from apiApp.schemas import ( + AudioCreate, + AudioResponse, + AudioListResponse, + MessageResponse +) +from apiApp.services import AudioCRUD +from apiApp.config import UPLOAD_FOLDER, ALLOWED_AUDIO_EXTENSIONS, MAX_UPLOAD_SIZE + +router = APIRouter() + + +@router.post("/upload", response_model=AudioResponse, status_code=status.HTTP_201_CREATED) +async def upload_audio_file( + file: UploadFile = FastAPIFile(...), + db: Session = Depends(get_db) +): + """ + Загрузка аудиофайла на сервер + """ + # Проверка расширения файла + 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" + ) + + # Сохранение файла + file_path = UPLOAD_FOLDER / f"{uuid.uuid4()}{file_ext}" + + 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)}" + ) + + # Создание записи в БД + try: + audio_data = AudioCreate(filename=file.filename) + audio = AudioCRUD.create( + db=db, + audio_data=audio_data, + file_path=str(file_path), + file_size=len(content) + ) + return audio + 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)}" + ) + + +@router.get("/audio/list", response_model=AudioListResponse) +async def list_audio_files( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """ + Получить список всех аудиофайлов + """ + audios = AudioCRUD.get_all(db) + return AudioListResponse( + audios=audios[skip:skip+limit], + count=len(audios) + ) + + +@router.get("/audio/{audio_id}", response_model=AudioResponse) +async def get_audio( + audio_id: uuid.UUID, + db: Session = Depends(get_db) +): + """ + Получить информацию о аудиофайле по ID + """ + audio = AudioCRUD.get_by_id(db, audio_id) + if not audio: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Audio not found" + ) + return audio + + +@router.get("/audio/file/{audio_id}") +async def download_audio_file( + audio_id: uuid.UUID, + db: Session = Depends(get_db) +): + """ + Скачать аудиофайл по ID + """ + audio = AudioCRUD.get_by_id(db, audio_id) + if not audio: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Audio not found" + ) + + if not audio.file_path or not os.path.exists(audio.file_path): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Audio file not found on disk" + ) + + return FileResponse( + path=audio.file_path, + filename=audio.filename, + media_type='audio/mpeg' + ) + + +@router.delete("/audio/delete/{audio_id}", response_model=AudioResponse) +async def delete_audio( + audio_id: uuid.UUID, + db: Session = Depends(get_db) +): + """ + Удалить аудиофайл + """ + audio = AudioCRUD.delete(db, audio_id) + if not audio: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Audio not found" + ) + return audio diff --git a/apiApp/routers/recognition.py b/apiApp/routers/recognition.py new file mode 100644 index 0000000..ba74653 --- /dev/null +++ b/apiApp/routers/recognition.py @@ -0,0 +1,264 @@ +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status +from sqlalchemy.orm import Session +from typing import Dict, Any +import uuid +import os +import asyncio + +from apiApp.database import get_db, Audio +from apiApp.schemas import ( + RecognitionStartResponse, + RecognitionStatus, + ErrorResponse +) +from apiApp.services import AudioCRUD, AiConclusionCRUD +from apiApp.config import UPLOAD_FOLDER + +router = APIRouter() + +# Глобальное хранилище статусов задач (в продакшене лучше использовать Redis) +recognition_tasks: Dict[str, Dict[str, Any]] = {} + + +async def process_recognition(audio_id: uuid.UUID, file_path: str, task_id: str): + """ + Фоновая задача для распознавания аудио + """ + try: + # Обновляем статус на processing + recognition_tasks[task_id] = { + 'audio_id': audio_id, + 'status': 'processing', + 'result': None, + 'error': None + } + + # Проверяем существование файла + if not os.path.exists(file_path): + recognition_tasks[task_id]['status'] = 'error' + recognition_tasks[task_id]['error'] = 'File not found on disk' + return + + # Здесь должна быть реальная логика распознавания + # Например, вызов внешнего API или локальной модели + # result = await your_recognize_function(file_path) + + # Симуляция обработки (в реальном коде убрать) + await asyncio.sleep(2) + + # Пример результата (заменить на реальный) + result = { + 'text': 'Распознанный текст из аудио', + 'confidence': 0.95, + 'duration': 120.5, + 'segments': [ + { + 'start': 0.0, + 'end': 5.2, + 'text': 'Привет, чем могу помочь?', + 'speaker': 'SPEAKER_00' + }, + { + 'start': 5.5, + 'end': 10.8, + 'text': 'Мне нужна информация о услугах', + 'speaker': 'SPEAKER_01' + } + ] + } + + # Обновляем статус на completed + recognition_tasks[task_id]['status'] = 'completed' + recognition_tasks[task_id]['result'] = result + + # Получаем сессию БД (для фоновой задачи) + from apiApp.database import SessionLocal + db = SessionLocal() + + try: + # Создаем или обновляем AI Conclusion + conclusion = AiConclusionCRUD.get_by_audio_id(db, audio_id) + if conclusion: + AiConclusionCRUD.update( + db=db, + conclusion_id=conclusion.id, + conclusion_data={ + "transcription": result.get('segments', []), + "ai_transcription": [result.get('text', '')], + "conclusion": { + "confidence": result.get('confidence', 0.0), + "duration": result.get('duration', 0.0) + } + }, + end_date=True + ) + else: + AiConclusionCRUD.create( + db=db, + audio_id=audio_id, + conclusion={ + "transcription": result.get('segments', []), + "ai_transcription": [result.get('text', '')], + "conclusion": { + "confidence": result.get('confidence', 0.0), + "duration": result.get('duration', 0.0) + } + } + ) + + # Обновляем запись аудио + AudioCRUD.update_recognition_result(db, audio_id, result) + + finally: + db.close() + + except Exception as e: + recognition_tasks[task_id]['status'] = 'error' + recognition_tasks[task_id]['error'] = str(e) + + +@router.post("/recognize/{audio_id}", response_model=RecognitionStartResponse, status_code=status.HTTP_202_ACCEPTED) +async def start_recognition( + audio_id: uuid.UUID, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db) +): + """ + Запуск распознавания аудиофайла + """ + # Проверяем существование аудио + audio = AudioCRUD.get_by_id(db, audio_id) + if not audio: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Audio not found in database" + ) + + # Проверяем, нет ли уже активной задачи + for task_id, task in recognition_tasks.items(): + if task['audio_id'] == audio_id and task['status'] == 'processing': + return RecognitionStartResponse( + status="info", + message="Recognition already in progress", + task_id=task_id, + audio_id=audio_id + ) + + # Проверяем наличие файла + if not audio.file_path or not os.path.exists(audio.file_path): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Audio file not found on disk" + ) + + # Создаем task_id + task_id = str(uuid.uuid4()) + + # Добавляем фоновую задачу + background_tasks.add_task(process_recognition, audio_id, audio.file_path, task_id) + + return RecognitionStartResponse( + status="success", + message="Recognition started", + task_id=task_id, + audio_id=audio_id + ) + + +@router.get("/recognize/{audio_id}", response_model=RecognitionStatus) +async def get_recognition_status( + audio_id: uuid.UUID, + db: Session = Depends(get_db) +): + """ + Получение статуса распознавания по audio_id + """ + # Проверяем существование аудио + audio = AudioCRUD.get_by_id(db, audio_id) + if not audio: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Audio not found" + ) + + # Ищем задачу для данного audio_id + task_info = None + for task_id, task in recognition_tasks.items(): + if task['audio_id'] == audio_id: + task_info = { + 'task_id': task_id, + **task + } + break + + if not task_info: + # Проверяем, есть ли сохраненный результат + conclusion = AiConclusionCRUD.get_by_audio_id(db, audio_id) + if conclusion and conclusion.end_date: + return RecognitionStatus( + task_id="", + audio_id=audio_id, + status="completed", + result=conclusion.conclusion + ) + + return RecognitionStatus( + task_id="", + audio_id=audio_id, + status="not_started" + ) + + return RecognitionStatus(**task_info) + + +@router.get("/recognize/task/{task_id}", response_model=RecognitionStatus) +async def get_recognition_task(task_id: str): + """ + Получение статуса задачи по task_id + """ + if task_id not in recognition_tasks: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + task = recognition_tasks[task_id] + return RecognitionStatus( + task_id=task_id, + audio_id=task['audio_id'], + status=task['status'], + result=task.get('result'), + error=task.get('error') + ) + + +@router.get("/recognize/{audio_id}/result") +async def get_recognition_result( + audio_id: uuid.UUID, + db: Session = Depends(get_db) +): + """ + Получение результата распознавания из базы данных + """ + audio = AudioCRUD.get_by_id(db, audio_id) + if not audio: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Audio not found" + ) + + conclusion = AiConclusionCRUD.get_by_audio_id(db, audio_id) + if not conclusion or not conclusion.end_date: + return { + "status": "not_available", + "message": "Recognition result not available yet", + "audio_id": str(audio_id) + } + + return { + "status": "success", + "audio_id": str(audio_id), + "result": conclusion.conclusion, + "index_date": conclusion.index_date, + "end_date": conclusion.end_date + } diff --git a/apiApp/schemas/__init__.py b/apiApp/schemas/__init__.py new file mode 100644 index 0000000..ecc6745 --- /dev/null +++ b/apiApp/schemas/__init__.py @@ -0,0 +1,2 @@ +from apiApp.schemas.audio_schemas import * +from apiApp.schemas.ai_conclusions_schemas import * diff --git a/apiApp/schemas/ai_conclusions_schemas.py b/apiApp/schemas/ai_conclusions_schemas.py new file mode 100644 index 0000000..d1f5bc0 --- /dev/null +++ b/apiApp/schemas/ai_conclusions_schemas.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any +from datetime import datetime +import uuid + + +class AiConclusionBase(BaseModel): + audio_id: uuid.UUID + conclusion: Optional[Dict[str, Any]] = None + +class AiConclusionCreate(AiConclusionBase): + pass + + +class AiConclusionResponse(BaseModel): + id: uuid.UUID + audio_id: uuid.UUID + conclusion: Dict[str, Any] + index_date: Optional[datetime] = None + end_date: Optional[datetime] = None + + class Config: + from_attributes = True + +class RecognitionStatus(BaseModel): + task_id: str + audio_id: uuid.UUID + status: str # pending, processing, completed, error + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + + +class RecognitionStartResponse(BaseModel): + status: str + message: str + task_id: str + audio_id: uuid.UUID + + +class MessageResponse(BaseModel): + message: str + + +class ErrorResponse(BaseModel): + detail: str diff --git a/apiApp/schemas/audio_schemas.py b/apiApp/schemas/audio_schemas.py new file mode 100644 index 0000000..f0a3783 --- /dev/null +++ b/apiApp/schemas/audio_schemas.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any +from datetime import datetime +import uuid + + +class AudioBase(BaseModel): + filename: str = Field(..., max_length=255) + file_path: Optional[str] = None + duration: Optional[float] = None + file_size: Optional[int] = None + +class AudioCreate(AudioBase): + pass + +class AudioResponse(AudioBase): + id: uuid.UUID + index_date: datetime + + class Config: + from_attributes = True + +class AudioListResponse(BaseModel): + audios: list[AudioResponse] + count: int diff --git a/apiApp/services/__init__.py b/apiApp/services/__init__.py new file mode 100644 index 0000000..e07d689 --- /dev/null +++ b/apiApp/services/__init__.py @@ -0,0 +1,2 @@ +from apiApp.services.audio_service import AudioCRUD +from apiApp.services.ai_conclusion_service import AiConclusionCRUD \ No newline at end of file diff --git a/apiApp/services/ai_conclusion_service.py b/apiApp/services/ai_conclusion_service.py new file mode 100644 index 0000000..4f35aef --- /dev/null +++ b/apiApp/services/ai_conclusion_service.py @@ -0,0 +1,56 @@ +from sqlalchemy.orm import Session +from typing import List, Optional +import os +from apiApp.database import Audio +from apiApp.schemas import AudioCreate +from apiApp.config import UPLOAD_FOLDER +import datetime +import uuid + + + +class AiConclusionCRUD: + + @staticmethod + def get_by_audio_id(db: Session, audio_id: uuid.UUID): + """Получить заключение по audio_id""" + from apiApp.database import AiConclusion + return db.query(AiConclusion).filter(AiConclusion.audio_id == audio_id).first() + + @staticmethod + def create(db: Session, audio_id: uuid.UUID, conclusion: dict = None): + """Создать новое заключение""" + from apiApp.database import AiConclusion + + db_conclusion = AiConclusion( + audio_id=audio_id, + conclusion=conclusion or { + "transcription": [], + "ai_transcription": [], + "conclusion": {} + }, + index_date=datetime.datetime.utcnow() + ) + db.add(db_conclusion) + db.commit() + db.refresh(db_conclusion) + return db_conclusion + + @staticmethod + def update(db: Session, conclusion_id: uuid.UUID, conclusion_data: dict, end_date: bool = False): + """Обновить заключение""" + from apiApp.database import AiConclusion + + db_conclusion = db.query(AiConclusion).filter(AiConclusion.id == conclusion_id).first() + if not db_conclusion: + return None + + if conclusion_data: + db_conclusion.conclusion = conclusion_data + + if end_date: + db_conclusion.end_date = datetime.datetime.utcnow() + + db.commit() + db.refresh(db_conclusion) + return db_conclusion diff --git a/apiApp/services/audio_service.py b/apiApp/services/audio_service.py new file mode 100644 index 0000000..8fce463 --- /dev/null +++ b/apiApp/services/audio_service.py @@ -0,0 +1,79 @@ +from sqlalchemy.orm import Session +from typing import List, Optional +import os +from apiApp.database import Audio +from apiApp.schemas import AudioCreate +from apiApp.config import UPLOAD_FOLDER +import datetime +import uuid + + +class AudioCRUD: + + @staticmethod + def get_all(db: Session) -> List[Audio]: + """Получить все аудиофайлы""" + return db.query(Audio).all() + + @staticmethod + def get_by_id(db: Session, audio_id: uuid.UUID) -> Optional[Audio]: + """Получить аудиофайл по ID""" + return db.query(Audio).filter(Audio.id == audio_id).first() + + @staticmethod + def get_by_filename(db: Session, filename: str) -> Optional[Audio]: + """Получить аудиофайл по имени файла""" + return db.query(Audio).filter(Audio.filename == filename).first() + + @staticmethod + def create(db: Session, audio_data: AudioCreate, file_path: str, file_size: int = None) -> Audio: + """Создать новую запись аудиофайла""" + db_audio = Audio( + filename=audio_data.filename, + file_path=file_path, + index_date=datetime.datetime.utcnow(), + file_size=file_size + ) + db.add(db_audio) + db.commit() + db.refresh(db_audio) + return db_audio + + @staticmethod + def update(db: Session, audio_id: uuid.UUID, audio_data: dict) -> Optional[Audio]: + """Обновить запись аудиофайла""" + db_audio = db.query(Audio).filter(Audio.id == audio_id).first() + if not db_audio: + return None + + for key, value in audio_data.items(): + if hasattr(db_audio, key): + setattr(db_audio, key, value) + + db.commit() + db.refresh(db_audio) + return db_audio + + @staticmethod + def delete(db: Session, audio_id: uuid.UUID) -> Optional[Audio]: + """Удалить аудиофайл""" + db_audio = db.query(Audio).filter(Audio.id == audio_id).first() + if not db_audio: + return None + + # Удаление файла с диска + if db_audio.file_path and os.path.exists(db_audio.file_path): + try: + os.remove(db_audio.file_path) + except Exception as e: + print(f"Error deleting file: {e}") + + db.delete(db_audio) + db.commit() + return db_audio + + @staticmethod + def update_recognition_result(db: Session, audio_id: uuid.UUID, result: dict) -> Optional[Audio]: + """Обновить результат распознавания""" + return AudioCRUD.update(db, audio_id, {"recognition_result": result}) + diff --git a/main.py b/main.py new file mode 100644 index 0000000..41b5135 --- /dev/null +++ b/main.py @@ -0,0 +1,80 @@ +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.exc import SQLAlchemyError +import logging + +from apiApp.config import APP_TITLE, APP_VERSION, API_V1_PREFIX, UPLOAD_FOLDER, DATABASE_URL +from apiApp.database import engine, Base +from apiApp.routers import audio_router, recognition_router + +# Настройка логирования +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Создание FastAPI приложения +app = FastAPI( + title=APP_TITLE, + version=APP_VERSION, + docs_url=f"{API_V1_PREFIX}/docs", + redoc_url=f"{API_V1_PREFIX}/redoc", + openapi_url=f"{API_V1_PREFIX}/openapi.json" +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Глобальный обработчик ошибок SQLAlchemy +@app.exception_handler(SQLAlchemyError) +async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError): + logger.error(f"Database error: {exc}") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Database error occurred"}, + ) + + +# Создание таблиц +@app.on_event("startup") +async def startup_event(): + """Создание таблиц при запуске приложения""" + try: + Base.metadata.create_all(bind=engine) + logger.info("Database tables created successfully") + except Exception as e: + logger.error(f"Error creating database tables: {e}") + + +# Подключение routers +app.include_router(audio_router, prefix=API_V1_PREFIX, tags=["audio"]) +app.include_router(recognition_router, prefix=API_V1_PREFIX, tags=["recognition"]) + +# Статические файлы (для загрузки аудио) +app.mount("/uploads", StaticFiles(directory=str(UPLOAD_FOLDER)), name="uploads") + + +@app.get("/") +async def root(): + return { + "application": APP_TITLE, + "version": APP_VERSION, + "status": "running" + } + + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..15a0d58 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +sqlalchemy==2.0.35 +pydantic==2.9.2 +python-multipart==0.0.12 +aiofiles==24.1.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..23d9af4 --- /dev/null +++ b/run.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +""" +0?CA: FastAPI ?@8;>65=8O 4;O Speech Analytics API +""" +import uvicorn +import sys +from pathlib import Path + +# >102;O5< :>@=52CN 48@5:B>@8N 2 Python path +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=True, # 2B><0B8G5A:0O ?5@5703@C7:0 ?@8 87<5=5=88 :>40 + log_level="info" + ) diff --git a/speech_analytics.db b/speech_analytics.db new file mode 100644 index 0000000..e69de29