Update file rag_server.py

This commit is contained in:
Markov Andrey
2026-06-30 14:31:50 +00:00
parent a21fb65b38
commit f3fb6b1f21

View File

@@ -10,6 +10,7 @@ RAG-сервер отдельный HTTP-сервис для обработ
- Эндпоинт /rag/transcribe для транскрибации аудио (SaluteSpeech)
- Использование Pydantic-модели AppConfig для конфигурации.
- Аутентификация через API-ключ (X-API-Key)
- Документирование API через OpenAPI (Swagger UI и ReDoc)
"""
import logging
@@ -26,6 +27,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi import FastAPI, HTTPException, Depends, UploadFile, File as FastAPIFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from pydantic import BaseModel, Field
import uvicorn
@@ -53,14 +55,14 @@ class QueryRequest(BaseModel):
Модель запроса к RAG-серверу на выполнение RAG-запроса.
ИСТОРИЯ НЕ ПЕРЕДАЁТСЯ сервер получает её из БД.
"""
query: str = Field(..., description="Текст запроса пользователя")
user_jid: str = Field(..., description="JID пользователя (без ресурса)")
room_jid: Optional[str] = Field(None, description="JID комнаты (None для личного чата)")
query: str = Field(..., description="Текст запроса пользователя", example="Какой OEE на линии?")
user_jid: str = Field(..., description="JID пользователя (без ресурса)", example="user@domain.ru")
room_jid: Optional[str] = Field(None, description="JID комнаты (None для личного чата)", example="room@conference.domain.ru")
prompts: Dict[str, str] = Field(
default_factory=dict,
description="Словарь промптов (system, synthesis, intent, expand, critique и т.д.)"
)
intent_override: Optional[str] = Field(None, description="Принудительное переопределение намерения")
intent_override: Optional[str] = Field(None, description="Принудительное переопределение намерения", example="METRICS")
last_file_path: Optional[str] = Field(None, description="Путь к последнему загруженному файлу")
last_file_text: Optional[str] = Field(None, description="Текст последнего загруженного файла")
@@ -77,9 +79,9 @@ class QueryResponse(BaseModel):
class IndexRequest(BaseModel):
"""Модель запроса на индексацию документа."""
file_name: str = Field(..., description="Исходное имя файла")
file_name: str = Field(..., description="Исходное имя файла", example="report.docx")
file_text: str = Field(..., description="Извлечённый текст документа")
user_jid: str = Field(..., description="JID владельца документа")
user_jid: str = Field(..., description="JID владельца документа", example="user@domain.ru")
room_jid: Optional[str] = Field(None, description="JID комнаты (если документ комнатный)")
is_global: bool = Field(False, description="Глобальный ли документ")
title: Optional[str] = Field(None, description="Отображаемое название")
@@ -211,16 +213,57 @@ def init_orchestrator(config):
# ============================================================
# FASTAPI ПРИЛОЖЕНИЕ
# FASTAPI ПРИЛОЖЕНИЕ с документацией
# ============================================================
# Описание тегов для группировки эндпоинтов в документации
tags_metadata = [
{
"name": "RAG",
"description": "Основные RAG-операции: выполнение запроса и индексация документов",
},
{
"name": "Media",
"description": "Обработка медиафайлов: OCR для изображений и транскрибация аудио",
},
{
"name": "Health",
"description": "Проверка состояния сервиса",
}
]
app = FastAPI(
title="RAG-сервер",
description="Единый сервис для RAG-обработки запросов и индексации документов",
version="3.0.0"
title="RAG-сервер Эфцекабот",
description="""
Единый сервис для RAG-обработки запросов и индексации документов.
## Аутентификация
Для всех защищённых эндпоинтов (кроме `/health`) требуется передача API-ключа
в заголовке `X-API-Key`.
## История диалога
Сервер самостоятельно хранит историю диалогов в PostgreSQL и использует её
для контекста при ответах. Клиентам не нужно передавать историю в запросах.
## Поддерживаемые форматы файлов
- Документы: DOCX, PDF, XLSX, PPTX, TXT, CSV, JSON
- Изображения: JPEG, PNG, BMP, TIFF (OCR через Tesseract)
- Аудио: OGG, WAV, MP3, AMR, M4A (транскрибация через SaluteSpeech)
- Архивы: ZIP, 7z (рекурсивная распаковка)
""",
version="3.0.0",
openapi_tags=tags_metadata,
contact={
"name": "Андрей Марков, Вениамин Кокорин",
"email": "support@fckbot.ru",
},
license_info={
"name": "ISC",
"url": "https://opensource.org/licenses/ISC",
},
)
# Разрешаем CORS для возможности запросов с других доменов (для тестов)
# Добавляем CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -229,6 +272,46 @@ app.add_middleware(
allow_headers=["*"],
)
# Настраиваем OpenAPI для отображения схемы авторизации
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
tags=app.openapi_tags,
contact=app.contact,
license_info=app.license_info,
)
# Добавляем схему безопасности для API-ключа
openapi_schema["components"]["securitySchemes"] = {
"APIKeyHeader": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API-ключ для доступа к защищённым эндпоинтам",
}
}
# Применяем security ко всем эндпоинтам кроме /health
for path in openapi_schema["paths"]:
if path != "/health":
for method in openapi_schema["paths"][path]:
openapi_schema["paths"][path][method]["security"] = [{"APIKeyHeader": []}]
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
# ============================================================
# ОБРАБОТЧИКИ СОБЫТИЙ
# ============================================================
@app.on_event("startup")
async def startup_event():
@@ -258,7 +341,19 @@ async def shutdown_event():
# ЭНДПОИНТЫ API (все защищены аутентификацией, кроме /health)
# ============================================================
@app.post("/rag/query", response_model=QueryResponse)
@app.post(
"/rag/query",
response_model=QueryResponse,
tags=["RAG"],
summary="Выполнить RAG-запрос",
description="Отправляет запрос пользователя в RAG-пайплайн. "
"Сервер автоматически подгружает историю диалога из БД.",
responses={
200: {"description": "Успешный ответ", "model": QueryResponse},
403: {"description": "Неверный или отсутствующий API-ключ"},
500: {"description": "Внутренняя ошибка сервера"},
}
)
async def rag_query(request: QueryRequest, _: str = Depends(verify_api_key)):
"""
Выполняет RAG-запрос.
@@ -289,7 +384,20 @@ async def rag_query(request: QueryRequest, _: str = Depends(verify_api_key)):
raise HTTPException(status_code=500, detail=str(e))
@app.post("/rag/index", response_model=IndexResponse)
@app.post(
"/rag/index",
response_model=IndexResponse,
tags=["RAG"],
summary="Проиндексировать документ",
description="Загружает текст документа в базу знаний. "
"Документ разбивается на чанки, для каждого генерируются эмбеддинги "
"и сохраняются в Qdrant. Метаданные хранятся в PostgreSQL.",
responses={
200: {"description": "Индексация выполнена", "model": IndexResponse},
403: {"description": "Неверный или отсутствующий API-ключ"},
500: {"description": "Внутренняя ошибка сервера"},
}
)
async def index_document(request: IndexRequest, _: str = Depends(verify_api_key)):
"""Индексирует документ в базу знаний."""
orchestrator = get_orchestrator()
@@ -316,7 +424,17 @@ async def index_document(request: IndexRequest, _: str = Depends(verify_api_key)
raise HTTPException(status_code=500, detail=str(e))
@app.get("/health")
@app.get(
"/health",
tags=["Health"],
summary="Проверка состояния сервиса",
description="Возвращает статус работоспособности RAG-сервера. "
"Не требует аутентификации.",
responses={
200: {"description": "Сервис работает", "content": {"application/json": {"example": {"status": "healthy"}}}},
503: {"description": "Сервис недоступен"},
}
)
async def health_check():
"""Проверка здоровья сервиса (без аутентификации)."""
if _orchestrator is None:
@@ -324,7 +442,20 @@ async def health_check():
return {"status": "healthy"}
@app.post("/rag/vision", response_model=VisionResponse)
@app.post(
"/rag/vision",
response_model=VisionResponse,
tags=["Media"],
summary="Распознать текст на изображении (OCR)",
description="Отправляет изображение на распознавание текста через Tesseract OCR. "
"Поддерживаются форматы: JPEG, PNG, BMP, TIFF.",
responses={
200: {"description": "Текст распознан", "model": VisionResponse},
400: {"description": "Неподдерживаемый формат файла"},
403: {"description": "Неверный или отсутствующий API-ключ"},
500: {"description": "Внутренняя ошибка сервера"},
}
)
async def vision_endpoint(
file: UploadFile = FastAPIFile(...),
_: str = Depends(verify_api_key)
@@ -364,7 +495,20 @@ async def vision_endpoint(
os.unlink(tmp_path)
@app.post("/rag/transcribe", response_model=TranscribeResponse)
@app.post(
"/rag/transcribe",
response_model=TranscribeResponse,
tags=["Media"],
summary="Транскрибировать аудиофайл",
description="Отправляет аудиофайл на распознавание речи через SaluteSpeech. "
"Поддерживаются форматы: OGG, WAV, MP3, AMR, M4A.",
responses={
200: {"description": "Речь распознана", "model": TranscribeResponse},
400: {"description": "Неподдерживаемый формат файла"},
403: {"description": "Неверный или отсутствующий API-ключ"},
500: {"description": "Внутренняя ошибка сервера"},
}
)
async def transcribe_endpoint(
file: UploadFile = FastAPIFile(...),
_: str = Depends(verify_api_key)