From f3fb6b1f2135f38bca3d6d1b15d20511613576e1 Mon Sep 17 00:00:00 2001 From: Markov Andrey Date: Tue, 30 Jun 2026 14:31:50 +0000 Subject: [PATCH] Update file rag_server.py --- rag/rag_server.py | 176 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 160 insertions(+), 16 deletions(-) diff --git a/rag/rag_server.py b/rag/rag_server.py index 8b56fd4..0bad34f 100644 --- a/rag/rag_server.py +++ b/rag/rag_server.py @@ -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)