API для работы с файлами, индексация файлов и результатов распощнавания

This commit is contained in:
poturaevpetr
2025-12-24 16:05:14 +05:00
parent 4f23dc8664
commit c39b348472
31 changed files with 1739 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# apiApp package
# FastAPI application for Speech Analytics
__version__ = "1.0.0"
+19
View File
@@ -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"
+25
View File
@@ -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()
+21
View File
@@ -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")
+28
View File
@@ -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
}
+12
View File
@@ -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)
+16
View File
@@ -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")
+31
View File
@@ -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
+65
View File
@@ -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()
+38
View File
@@ -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
View File
+4
View File
@@ -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"]
+155
View File
@@ -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
+264
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
from apiApp.schemas.audio_schemas import *
from apiApp.schemas.ai_conclusions_schemas import *
+45
View File
@@ -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
+25
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
from apiApp.services.audio_service import AudioCRUD
from apiApp.services.ai_conclusion_service import AiConclusionCRUD
+56
View File
@@ -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
+79
View File
@@ -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})