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) - Эндпоинт /rag/transcribe для транскрибации аудио (SaluteSpeech)
- Использование Pydantic-модели AppConfig для конфигурации. - Использование Pydantic-модели AppConfig для конфигурации.
- Аутентификация через API-ключ (X-API-Key) - Аутентификация через API-ключ (X-API-Key)
- Документирование API через OpenAPI (Swagger UI и ReDoc)
""" """
import logging 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 import FastAPI, HTTPException, Depends, UploadFile, File as FastAPIFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import uvicorn import uvicorn
@@ -53,14 +55,14 @@ class QueryRequest(BaseModel):
Модель запроса к RAG-серверу на выполнение RAG-запроса. Модель запроса к RAG-серверу на выполнение RAG-запроса.
ИСТОРИЯ НЕ ПЕРЕДАЁТСЯ сервер получает её из БД. ИСТОРИЯ НЕ ПЕРЕДАЁТСЯ сервер получает её из БД.
""" """
query: str = Field(..., description="Текст запроса пользователя") query: str = Field(..., description="Текст запроса пользователя", example="Какой OEE на линии?")
user_jid: str = Field(..., description="JID пользователя (без ресурса)") user_jid: str = Field(..., description="JID пользователя (без ресурса)", example="user@domain.ru")
room_jid: Optional[str] = Field(None, description="JID комнаты (None для личного чата)") room_jid: Optional[str] = Field(None, description="JID комнаты (None для личного чата)", example="room@conference.domain.ru")
prompts: Dict[str, str] = Field( prompts: Dict[str, str] = Field(
default_factory=dict, default_factory=dict,
description="Словарь промптов (system, synthesis, intent, expand, critique и т.д.)" 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_path: Optional[str] = Field(None, description="Путь к последнему загруженному файлу")
last_file_text: Optional[str] = Field(None, description="Текст последнего загруженного файла") last_file_text: Optional[str] = Field(None, description="Текст последнего загруженного файла")
@@ -77,9 +79,9 @@ class QueryResponse(BaseModel):
class IndexRequest(BaseModel): class IndexRequest(BaseModel):
"""Модель запроса на индексацию документа.""" """Модель запроса на индексацию документа."""
file_name: str = Field(..., description="Исходное имя файла") file_name: str = Field(..., description="Исходное имя файла", example="report.docx")
file_text: str = Field(..., description="Извлечённый текст документа") 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 комнаты (если документ комнатный)") room_jid: Optional[str] = Field(None, description="JID комнаты (если документ комнатный)")
is_global: bool = Field(False, description="Глобальный ли документ") is_global: bool = Field(False, description="Глобальный ли документ")
title: Optional[str] = Field(None, 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( app = FastAPI(
title="RAG-сервер", title="RAG-сервер Эфцекабот",
description="Единый сервис для RAG-обработки запросов и индексации документов", description="""
version="3.0.0" Единый сервис для 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -229,6 +272,46 @@ app.add_middleware(
allow_headers=["*"], 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") @app.on_event("startup")
async def startup_event(): async def startup_event():
@@ -258,7 +341,19 @@ async def shutdown_event():
# ЭНДПОИНТЫ API (все защищены аутентификацией, кроме /health) # ЭНДПОИНТЫ 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)): async def rag_query(request: QueryRequest, _: str = Depends(verify_api_key)):
""" """
Выполняет RAG-запрос. Выполняет RAG-запрос.
@@ -289,7 +384,20 @@ async def rag_query(request: QueryRequest, _: str = Depends(verify_api_key)):
raise HTTPException(status_code=500, detail=str(e)) 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)): async def index_document(request: IndexRequest, _: str = Depends(verify_api_key)):
"""Индексирует документ в базу знаний.""" """Индексирует документ в базу знаний."""
orchestrator = get_orchestrator() 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)) 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(): async def health_check():
"""Проверка здоровья сервиса (без аутентификации).""" """Проверка здоровья сервиса (без аутентификации)."""
if _orchestrator is None: if _orchestrator is None:
@@ -324,7 +442,20 @@ async def health_check():
return {"status": "healthy"} 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( async def vision_endpoint(
file: UploadFile = FastAPIFile(...), file: UploadFile = FastAPIFile(...),
_: str = Depends(verify_api_key) _: str = Depends(verify_api_key)
@@ -364,7 +495,20 @@ async def vision_endpoint(
os.unlink(tmp_path) 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( async def transcribe_endpoint(
file: UploadFile = FastAPIFile(...), file: UploadFile = FastAPIFile(...),
_: str = Depends(verify_api_key) _: str = Depends(verify_api_key)