Update file rag_server.py
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user