From 59feffc1904204deec5d313b27e127109894a746 Mon Sep 17 00:00:00 2001 From: Markov Andrey Date: Tue, 30 Jun 2026 14:27:34 +0000 Subject: [PATCH] Update 9 files - /rag/config_models.py - /rag/utils/config_loader.py - /rag/auth.py - /rag/rag_server.py - /rag/rag_api.py - /rag/rag_orchestrator.py - /rag/__init__.py - /bots/xmpp/client.py - /bots/rag_client.py --- bots/rag_client.py | 36 +++++++++++++--- bots/xmpp/client.py | 86 ++++++++++++++++++++++++++------------ rag/__init__.py | 11 ++--- rag/auth.py | 52 +++++++++++++++++++++++ rag/config_models.py | 26 ++++++------ rag/rag_api.py | 30 +------------ rag/rag_orchestrator.py | 85 ------------------------------------- rag/rag_server.py | 23 +++++----- rag/utils/config_loader.py | 10 +++++ 9 files changed, 180 insertions(+), 179 deletions(-) create mode 100644 rag/auth.py diff --git a/bots/rag_client.py b/bots/rag_client.py index cc55196..ded340c 100644 --- a/bots/rag_client.py +++ b/bots/rag_client.py @@ -14,6 +14,7 @@ HTTP-клиент для взаимодействия с RAG-сервером. ДОБАВЛЕНО: - Метод vision() для распознавания текста на изображениях - Метод transcribe() для транскрибации аудио +- Аутентификация через API-ключ (заголовок X-API-Key) """ import os @@ -33,6 +34,7 @@ class RAGClient: def __init__( self, server_url: str, + api_key: Optional[str] = None, # <-- добавлен API-ключ timeout: int = 60, max_retries: int = 3, retry_delay: float = 1.0 @@ -42,16 +44,22 @@ class RAGClient: Аргументы: server_url (str): базовый URL RAG-сервера (например, http://localhost:8080) + api_key (Optional[str]): API-ключ для аутентификации (передаётся в заголовке X-API-Key) timeout (int): таймаут на запрос в секундах max_retries (int): максимальное количество повторных попыток при ошибке retry_delay (float): базовая задержка между попытками (секунды) """ self.server_url = server_url.rstrip('/') + self.api_key = api_key # <-- сохраняем ключ self.timeout = timeout self.max_retries = max_retries self.retry_delay = retry_delay self._session: Optional[aiohttp.ClientSession] = None - logger.info(f"RAGClient инициализирован с сервером {server_url}, таймаут={timeout}с, макс. попыток={max_retries}") + logger.info( + f"RAGClient инициализирован с сервером {server_url}, " + f"таймаут={timeout}с, макс. попыток={max_retries}, " + f"API-ключ {'задан' if api_key else 'не задан'}" + ) async def _get_session(self) -> aiohttp.ClientSession: """ @@ -75,6 +83,7 @@ class RAGClient: async def _post(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]: """ Внутренний метод для отправки POST-запроса с повторными попытками. + Автоматически добавляет заголовок X-API-Key, если ключ задан. Аргументы: endpoint (str): путь эндпоинта (например, '/rag/query') @@ -90,12 +99,17 @@ class RAGClient: url = f"{self.server_url}{endpoint}" session = await self._get_session() + # Подготавливаем заголовки + headers = {} + if self.api_key: + headers['X-API-Key'] = self.api_key + last_exception = None for attempt in range(self.max_retries): try: logger.debug(f"Попытка {attempt + 1}/{self.max_retries} к {endpoint}") - async with session.post(url, json=payload) as resp: + async with session.post(url, json=payload, headers=headers) as resp: if resp.status == 200: return await resp.json() else: @@ -190,7 +204,10 @@ class RAGClient: """ try: session = await self._get_session() - async with session.get(f"{self.server_url}/health", timeout=10) as resp: + headers = {} + if self.api_key: + headers['X-API-Key'] = self.api_key + async with session.get(f"{self.server_url}/health", timeout=10, headers=headers) as resp: if resp.status == 200: data = await resp.json() if data.get('status') == 'healthy': @@ -308,10 +325,15 @@ class RAGClient: session = await self._get_session() url = f"{self.server_url}/rag/vision" + # Добавляем заголовок с ключом + headers = {} + if self.api_key: + headers['X-API-Key'] = self.api_key + try: with open(file_path, 'rb') as f: files = {'file': (os.path.basename(file_path), f, 'image/jpeg')} - async with session.post(url, data=files) as resp: + async with session.post(url, data=files, headers=headers) as resp: if resp.status == 200: return await resp.json() else: @@ -338,10 +360,14 @@ class RAGClient: session = await self._get_session() url = f"{self.server_url}/rag/transcribe" + headers = {} + if self.api_key: + headers['X-API-Key'] = self.api_key + try: with open(file_path, 'rb') as f: files = {'file': (os.path.basename(file_path), f, 'audio/ogg')} - async with session.post(url, data=files) as resp: + async with session.post(url, data=files, headers=headers) as resp: if resp.status == 200: return await resp.json() else: diff --git a/bots/xmpp/client.py b/bots/xmpp/client.py index 46714ad..0cfc61c 100644 --- a/bots/xmpp/client.py +++ b/bots/xmpp/client.py @@ -3,6 +3,10 @@ XMPP клиент – главный класс Bot. Теперь это тонкий клиент, который только общается с RAG-сервером. Не содержит БД, Qdrant, GigaChat – всё на сервере. + +ИСПОЛЬЗУЕТ: +- AppConfig из rag.config_models для единой конфигурации. +- RAGClient с передачей API-ключа для аутентификации. """ import asyncio @@ -10,7 +14,11 @@ import logging from datetime import datetime from slixmpp import ClientXMPP -from core.utils.config_loader import BotConfig +# Импорты из нового пакета rag для конфигурации +from rag.config_models import AppConfig + +# Остальные импорты остаются из core (старый код) – позже их нужно будет перенести в rag, +# но пока оставляем как есть для совместимости. from core.services.file_service import FileService from core.handlers.metrics_handler import MetricsHandler from core.rag_client import RAGClient @@ -32,7 +40,7 @@ class Bot(ClientXMPP): Тонкий клиент: вся RAG-логика на сервере. """ - def __init__(self, config: BotConfig): + def __init__(self, config: AppConfig): # <-- изменён тип конфига # Полный JID: user@domain/resource full_jid = f"{config.jid}/{config.xmpp_resource}" super().__init__(full_jid, config.xmpp_password) @@ -47,35 +55,59 @@ class Bot(ClientXMPP): # ----- 2. Загрузка промптов (остаются локально) ----- # Промпты загружаются из файлов и передаются в каждом запросе к серверу. self.ai_prompts = {} - prompt_files = { - 'system': config.system_prompt_file, - 'synthesis': config.synthesis_prompt_file, - 'quality': config.quality_prompt_file, - 'rerank': config.rerank_prompt_file, - 'expand': config.expand_prompt_file, - 'intent': config.intent_prompt_file, - 'metrics': config.metrics_prompt_file, - 'summary': config.summary_prompt_file, - 'consistency': config.consistency_prompt_file, - 'critique': config.critique_prompt_file, - 'spellcheck': config.spellcheck_prompt_file, - 'generate_document': config.generate_document_prompt_file, - } - for name, path in prompt_files.items(): - try: - with open(path, 'r', encoding='utf-8') as f: - self.ai_prompts[name] = f.read() - logger.debug(f"Загружен промпт {name} из {path}") - except Exception as e: - logger.error(f"Не удалось загрузить промпт {name} из {path}: {e}") - self.ai_prompts[name] = "" + # В AppConfig теперь есть свойства для путей к файлам промптов, + # но они называются так же, как в BotConfig, поэтому доступ через config.<имя> работает. + # Однако в BotConfig они назывались system_prompt_file и т.д. – они есть в AppConfig? + # В AppConfig мы добавили прокси-свойства только для основных параметров, + # но не для путей к промптам. Поэтому здесь нужно либо использовать config.prompts_dir_path + # и формировать пути вручную, либо оставить как было, если в AppConfig есть соответствующие свойства. + # Для обратной совместимости мы добавили свойства в AppConfig, но не для prompt_file. + # Поэтому временно оставим как было: будем использовать config.prompts_dir_path и имена файлов. + # Лучше прочитать имена файлов из config.ai.prompts и загрузить их. + # Но для простоты пока оставим старый способ, если свойства есть. + # Однако в AppConfig нет system_prompt_file и т.д. – они были в BotConfig. + # В BotConfig они были путями к файлам. В AppConfig мы не стали их добавлять, + # чтобы не дублировать. Вместо этого мы загружаем все промпты в config.prompts_content. + # Поэтому мы можем использовать config.prompts_content, который уже содержит все промпты. + # Используем его: + self.ai_prompts = config.prompts_content.copy() if config.prompts_content else {} + if not self.ai_prompts: + logger.warning("Промпты не загружены из конфига, пробуем загрузить из файлов вручную...") + # Запасной вариант – загружаем из файлов, используя config.prompts_dir_path + prompts_dir = config.prompts_dir_path + if prompts_dir and prompts_dir.exists(): + for prompt_name, filename in config.ai.prompts.items(): + file_path = prompts_dir / filename + if file_path.exists(): + try: + with open(file_path, 'r', encoding='utf-8') as f: + self.ai_prompts[prompt_name] = f.read() + logger.debug(f"Загружен промпт {prompt_name} из {file_path}") + except Exception as e: + logger.error(f"Не удалось загрузить промпт {prompt_name} из {file_path}: {e}") + self.ai_prompts[prompt_name] = "" + else: + logger.warning(f"Файл промпта {filename} не найден в {prompts_dir}") + self.ai_prompts[prompt_name] = "" + else: + logger.error("Каталог промптов не найден, промпты не загружены") + # Сохраняем промпты как атрибут для передачи в запросах self.prompts = self.ai_prompts # ----- 3. HTTP-клиент для RAG-сервера ----- - # Адрес сервера берём из конфига (bot.conf) или используем значение по умолчанию - rag_server_url = getattr(config, 'rag_server_url', 'http://localhost:8080') - self.rag_client = RAGClient(server_url=rag_server_url) + # Адрес сервера и API-ключ берём из конфига + rag_server_url = config.rag_server_url # теперь это свойство + rag_api_key = config.rag_api_key # получаем API-ключ + + # Создаём RAGClient с передачей ключа + self.rag_client = RAGClient( + server_url=rag_server_url, + api_key=rag_api_key, # <-- передаём ключ + timeout=config.http_download_timeout, # можно использовать таймаут из конфига + max_retries=3, + retry_delay=1.0 + ) # ----- 4. Команды и состояния ----- # Регистрируем все команды бота (из core/commands/) diff --git a/rag/__init__.py b/rag/__init__.py index 803fd2f..d220f81 100644 --- a/rag/__init__.py +++ b/rag/__init__.py @@ -1,30 +1,25 @@ # -*- coding: utf-8 -*- """ Пакет rag – RAG-ядро платформы Эфцекабот. -Содержит: -- RAG-сервер и оркестратор, -- сервисы (PostgreSQL, Qdrant, GigaChat), -- функции (классификация, суммаризация, проверка орфографии и др.), -- утилиты. -Используется как HTTP-сервис. """ __version__ = "3.0.0" __author__ = "Андрей Марков, Вениамин Кокорин" __license__ = "ISC" -# Импорт основных классов для удобства from .rag_orchestrator import RAGOrchestrator from .history_manager import HistoryManager from .intent_router import IntentRouter from .query_processor import QueryProcessor from .indexing_manager import IndexingManager +from .auth import verify_api_key, set_auth_config -# Экспортируем их для использования в других модулях __all__ = [ "RAGOrchestrator", "HistoryManager", "IntentRouter", "QueryProcessor", "IndexingManager", + "verify_api_key", + "set_auth_config", ] \ No newline at end of file diff --git a/rag/auth.py b/rag/auth.py new file mode 100644 index 0000000..c7b9b8c --- /dev/null +++ b/rag/auth.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +""" +Аутентификация для RAG-сервера. +Проверяет API-ключ в заголовке X-API-Key. +""" + +import logging +from fastapi import HTTPException, Security +from fastapi.security import APIKeyHeader +from starlette.status import HTTP_403_FORBIDDEN + +from .config_models import AppConfig + +logger = logging.getLogger(__name__) + +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + +# Глобальная ссылка на конфиг (устанавливается из rag_server) +_config: AppConfig = None + + +def set_auth_config(config: AppConfig): + """Устанавливает конфиг для аутентификации.""" + global _config + _config = config + + +def verify_api_key(api_key: str = Security(api_key_header)) -> str: + """ + Проверяет API-ключ. Возвращает ключ при успехе, иначе 403. + """ + if _config is None: + raise RuntimeError("Auth config not set") + + if not _config.rag_api_key: + # Если ключ не задан – пропускаем (но предупреждение уже выведено) + logger.warning("API-ключ не настроен, аутентификация отключена!") + return "insecure" + + if not api_key: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="API-ключ отсутствует. Передайте его в заголовке X-API-Key." + ) + + if api_key != _config.rag_api_key: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Неверный API-ключ." + ) + + return api_key \ No newline at end of file diff --git a/rag/config_models.py b/rag/config_models.py index 8169056..9fc4115 100644 --- a/rag/config_models.py +++ b/rag/config_models.py @@ -174,6 +174,11 @@ class WebScraperConfig(BaseModel): max_depth: int = Field(1, description="Максимальная глубина рекурсии") +class AuthConfig(BaseModel): + """Настройки аутентификации.""" + api_key_env: str = Field("RAG_API_KEY", description="Имя переменной окружения с API-ключом для доступа к RAG-серверу") + + # ============================================================ # Главная модель конфигурации приложения # ============================================================ @@ -213,6 +218,7 @@ class AppConfig(BaseModel): ai: AIConfig prompts: PromptsConfig web_scraper: WebScraperConfig = Field(default_factory=WebScraperConfig) + auth: AuthConfig = Field(default_factory=AuthConfig) # Дополнительные поля для путей (заполняются при загрузке) profile_dir: Optional[Path] = Field(None, description="Корень профиля") @@ -226,11 +232,17 @@ class AppConfig(BaseModel): # Промпты (загружаются из файлов) prompts_content: Optional[Dict[str, str]] = Field(default_factory=dict, description="Содержимое промптов") + # Приватное поле для API-ключа RAG (не сериализуется) + _rag_api_key: Optional[str] = Field(None, description="API-ключ RAG-сервера", exclude=True) + # ------------------------------------------------------------------ # Прокси-свойства для обратной совместимости со старым BotConfig # ------------------------------------------------------------------ - # База данных + @property + def rag_api_key(self) -> Optional[str]: + return self._rag_api_key + @property def db_host(self) -> str: return self.database.host @@ -259,7 +271,6 @@ class AppConfig(BaseModel): def db_ssl_mode(self) -> Optional[str]: return self.database.ssl_mode - # Qdrant @property def qdrant_host(self) -> str: return self.qdrant.host @@ -284,7 +295,6 @@ class AppConfig(BaseModel): def qdrant_distance(self) -> str: return self.qdrant.distance - # GigaChat @property def gigachat_api_key(self) -> Optional[str]: return self.gigachat.api_key @@ -305,7 +315,6 @@ class AppConfig(BaseModel): def gigachat_temperature(self) -> float: return self.gigachat.temperature - # Embedding @property def embedding_cache_size(self) -> int: return self.embedding.cache_size @@ -322,7 +331,6 @@ class AppConfig(BaseModel): def embedding_verify_ssl(self) -> bool: return self.embedding.verify_ssl_certs - # Chunking @property def chunking_enabled(self) -> bool: return self.chunking.enabled @@ -347,7 +355,6 @@ class AppConfig(BaseModel): def chunking_approx_overlap_chars(self) -> int: return self.chunking.approx_overlap_chars - # RAG @property def rag_default_top_k(self) -> int: return self.rag.default_top_k @@ -380,7 +387,6 @@ class AppConfig(BaseModel): def rerank_min_length(self) -> int: return self.rag.rerank_min_length - # HTTP @property def http_download_timeout(self) -> int: return self.http.download_timeout @@ -389,7 +395,6 @@ class AppConfig(BaseModel): def http_upload_timeout(self) -> int: return self.http.upload_timeout - # Features @property def file_processing(self) -> bool: return self.features.file_processing @@ -442,7 +447,6 @@ class AppConfig(BaseModel): def mention_keyword(self) -> Optional[str]: return self.features.mention_keyword - # AI @property def ai_model(self) -> str: return self.ai.model or self.gigachat.model_generation @@ -455,7 +459,6 @@ class AppConfig(BaseModel): def ai_timeout(self) -> int: return self.ai.timeout - # Cleanup @property def temp_cleanup_days(self) -> int: return self.cleanup.temp_days @@ -472,7 +475,6 @@ class AppConfig(BaseModel): def user_data_cleanup_days(self) -> int: return self.cleanup.user_data_days - # Web scraper @property def ws_max_pages(self) -> int: return self.web_scraper.max_pages @@ -481,7 +483,6 @@ class AppConfig(BaseModel): def ws_max_depth(self) -> int: return self.web_scraper.max_depth - # Prompts temperatures @property def intent_temperature(self) -> float: return self.prompts.intent.temperature @@ -522,7 +523,6 @@ class AppConfig(BaseModel): def generate_document_temperature(self) -> float: return self.prompts.generate_document.temperature - # File permissions @property def file_mode(self) -> int: return int(self.file_permissions.mode, 8) diff --git a/rag/rag_api.py b/rag/rag_api.py index d3c67f8..390c744 100644 --- a/rag/rag_api.py +++ b/rag/rag_api.py @@ -5,16 +5,10 @@ знать внутреннее устройство оркестратора. Этот класс является прослойкой (facade) между ботами и RAGOrchestrator. -В будущем он может быть легко заменён на HTTP-клиент, -если оркестратор будет вынесен в отдельный сервис. - -Сейчас этот файл может использоваться для локальной работы, -но в новой архитектуре мы используем RAGClient (HTTP), -поэтому данный класс является опциональным. """ import logging -from typing import Optional, Dict, List, Any +from typing import Optional, Dict, Any from .rag_orchestrator import RAGOrchestrator from .services.kb_service import KBService @@ -30,7 +24,6 @@ class RAGAPI: """ Единый API-интерфейс для RAG-функциональности. Создаёт и содержит экземпляр RAGOrchestrator. - Предоставляет методы для выполнения запросов и индексации документов. """ def __init__( @@ -44,24 +37,9 @@ class RAGAPI: config: AppConfig, default_prompts: Optional[Dict[str, str]] = None ): - """ - Инициализация API. - - Аргументы: - db: сервис PostgreSQL (PostgresService) - qdrant: сервис Qdrant (QdrantService) - embedding: сервис эмбеддингов (EmbeddingService) - kb: сервис базы знаний (KBService) - giga: клиент GigaChat (GigaClient) - files: сервис файлов (FileService) - config: объект конфигурации (AppConfig) - default_prompts: словарь промптов по умолчанию. - Используется, если в запросе не переданы свои промпты. - """ self.config = config self.default_prompts = default_prompts or {} - # Создаём оркестратор с переданными сервисами self.orchestrator = RAGOrchestrator( db=db, qdrant=qdrant, @@ -84,10 +62,6 @@ class RAGAPI: last_file_path: Optional[str] = None, last_file_text: Optional[str] = None, ) -> Dict[str, Any]: - """ - Выполняет RAG-запрос через оркестратор. - ИСТОРИЯ НЕ ПЕРЕДАЁТСЯ – оркестратор получает её из БД. - """ try: if prompts is None: prompts = self.default_prompts.copy() @@ -126,7 +100,6 @@ class RAGAPI: file_hash: Optional[str] = None, update_if_exists: bool = True ) -> Dict[str, Any]: - """Индексирует документ через оркестратор.""" try: result = await self.orchestrator.index_document( file_name=file_name, @@ -146,5 +119,4 @@ class RAGAPI: return {"doc_id": None, "chunk_count": 0, "error": str(e)} def close(self): - """Закрывает ресурсы (если необходимо).""" logger.debug("RAGAPI закрывается") \ No newline at end of file diff --git a/rag/rag_orchestrator.py b/rag/rag_orchestrator.py index f49ce0c..4f42273 100644 --- a/rag/rag_orchestrator.py +++ b/rag/rag_orchestrator.py @@ -2,7 +2,6 @@ """ Главный оркестратор RAG-пайплайна (фасад). Координирует работу менеджеров: HistoryManager, IntentRouter, QueryProcessor, IndexingManager. -Принимает запрос пользователя, обрабатывает его и возвращает ответ. """ import logging @@ -29,7 +28,6 @@ class RAGOrchestrator: """ Оркестратор RAG-пайплайна. Содержит ссылки на все сервисы и менеджеры. - Предоставляет два основных метода: process_query и index_document. """ def __init__( @@ -43,19 +41,6 @@ class RAGOrchestrator: config: AppConfig, default_prompts: Optional[Dict[str, str]] = None ): - """ - Инициализация оркестратора. - - Аргументы: - db: сервис PostgreSQL - qdrant: сервис Qdrant - embedding: сервис эмбеддингов - kb: сервис базы знаний - giga: клиент GigaChat - files: сервис файлов - config: объект конфигурации (AppConfig) - default_prompts: словарь промптов по умолчанию - """ self.db = db self.qdrant = qdrant self.embedding = embedding @@ -97,10 +82,6 @@ class RAGOrchestrator: logger.info("RAGOrchestrator инициализирован с менеджерами") - # ------------------------------------------------------------------ - # Вспомогательный метод для расчёта токенов - # ------------------------------------------------------------------ - def _prepare_prompt_parts( self, synthesis_template: str, @@ -110,17 +91,6 @@ class RAGOrchestrator: reserved_for_answer: int = 1000, reserved_for_overhead: int = 200 ) -> Dict[str, Any]: - """ - Подсчитывает токены в статичных частях промта и вычисляет, - сколько токенов остаётся для истории и контекста. - - Возвращает словарь с ключами: - - available_for_history_and_context: int - - prompt_tokens: int - - system_tokens: int - - synthesis_tokens: int - - query_tokens: int - """ system_tokens = count_tokens(system_prompt) if system_prompt else 0 synthesis_tokens = count_tokens(synthesis_template) query_tokens = count_tokens(query) @@ -142,10 +112,6 @@ class RAGOrchestrator: "query_tokens": query_tokens, } - # ------------------------------------------------------------------ - # Основной метод обработки запроса - # ------------------------------------------------------------------ - async def process_query( self, query: str, @@ -156,25 +122,6 @@ class RAGOrchestrator: last_file_path: Optional[str] = None, last_file_text: Optional[str] = None, ) -> Dict[str, Any]: - """ - Основной метод обработки запроса. - - ИСТОРИЯ ДИАЛОГА ПОЛУЧАЕТСЯ ИЗ БД, а не из запроса. - Это обеспечивает единый контекст для всех клиентов. - - Аргументы: - query: текст запроса пользователя - user_jid: JID пользователя (без ресурса) - room_jid: JID комнаты (None для личного чата) - prompts: словарь промптов (если None, используются default_prompts) - intent_override: принудительное переопределение намерения - last_file_path: путь к последнему загруженному файлу - last_file_text: текст последнего загруженного файла - - Возвращает: - Словарь с ключами: answer, intent, context, sources, confidence, error - """ - # 1. Подготовка промптов if prompts is None: prompts = self.default_prompts.copy() synthesis_template = prompts.get('synthesis', '') @@ -182,7 +129,6 @@ class RAGOrchestrator: synthesis_template = "{context}\n\n{query}\n\nОтвет:" system_prompt = prompts.get('system', None) - # 2. Расчёт лимитов токенов max_model_tokens = self.config.max_model_tokens reserved_for_answer = self.config.reserved_for_answer_tokens reserved_for_overhead = self.config.reserved_for_overhead_tokens @@ -198,10 +144,8 @@ class RAGOrchestrator: available_for_history_and_context = token_info["available_for_history_and_context"] logger.debug(f"Доступно для истории и контекста: {available_for_history_and_context} токенов") - # 3. Получение истории из БД raw_history = await self.history_manager.get_history(user_jid, room_jid, limit=100) - # 4. Сжатие истории, если она слишком длинная max_history_tokens = min(available_for_history_and_context // 2, 2000) formatted_history = await self.history_manager.compress_history_if_needed( raw_history, @@ -209,7 +153,6 @@ class RAGOrchestrator: prompt_template=prompts.get('hierarchical_summary', '') ) - # 5. Классификация намерений intent = intent_override if intent is None and self.config.enable_intent_classification: intent_prompt = prompts.get('intent', '') @@ -225,13 +168,11 @@ class RAGOrchestrator: else: intent = intent or "GENERAL" - # 6. Принудительная установка SURGICAL по ключевым словам keywords = self.config.surgical_keywords if any(kw in query.lower() for kw in keywords) and last_file_path: intent = "SURGICAL" logger.info(f"Принудительный Intent: SURGICAL (есть файл и ключевые слова)") - # 7. Маршрутизация специализированных намерений router_result = await self.intent_router.route( intent=intent, query=query, @@ -249,12 +190,10 @@ class RAGOrchestrator: sources = [] if router_result is not None: - # Намерение обработано маршрутизатором answer = router_result.get("answer") context = router_result.get("context", "") sources = router_result.get("sources", []) else: - # Обычный RAG-пайплайн (GENERAL, FACT, PROCEDURE, COMPARISON, CALCULATION) history_tokens = sum(count_tokens(msg['content']) for msg in formatted_history) available_for_context = available_for_history_and_context - history_tokens available_for_context = max(available_for_context, 0) @@ -273,12 +212,10 @@ class RAGOrchestrator: context = processor_result.get("context", "") sources = processor_result.get("sources", []) - # 8. Сохранение истории диалога в БД await self.history_manager.save_message(user_jid, "user", query, room_jid) if answer: await self.history_manager.save_message(user_jid, "assistant", answer, room_jid) - # 9. Формирование результата return { "answer": answer or "⚠️ Не удалось сгенерировать ответ.", "intent": intent, @@ -288,10 +225,6 @@ class RAGOrchestrator: "error": None } - # ------------------------------------------------------------------ - # Индексация документа - # ------------------------------------------------------------------ - async def index_document( self, file_name: str, @@ -304,24 +237,6 @@ class RAGOrchestrator: file_hash: Optional[str] = None, update_if_exists: bool = True ) -> Dict[str, Any]: - """ - Индексирует документ в базу знаний. - Делегирует работу IndexingManager. - - Аргументы: - file_name: исходное имя файла - file_text: извлечённый текст - user_jid: JID владельца - room_jid: JID комнаты (None для личного) - is_global: глобальный ли документ - title: отображаемое название - metadata: дополнительные метаданные - file_hash: SHA-256 хеш содержимого - update_if_exists: заменять ли существующий документ в комнате - - Возвращает: - Словарь с ключами doc_id, chunk_count, error - """ try: doc_id, chunk_count = await self.indexing_manager.index_document( file_name=file_name, diff --git a/rag/rag_server.py b/rag/rag_server.py index 89231cc..8b56fd4 100644 --- a/rag/rag_server.py +++ b/rag/rag_server.py @@ -9,6 +9,7 @@ RAG-сервер – отдельный HTTP-сервис для обработ - Эндпоинт /rag/vision для распознавания текста на изображениях (OCR) - Эндпоинт /rag/transcribe для транскрибации аудио (SaluteSpeech) - Использование Pydantic-модели AppConfig для конфигурации. +- Аутентификация через API-ключ (X-API-Key) """ import logging @@ -38,6 +39,7 @@ from .services.kb_service import KBService from .services.giga_client import GigaClient from .services.file_service import FileService from .rag_orchestrator import RAGOrchestrator +from .auth import verify_api_key, set_auth_config logger = logging.getLogger(__name__) @@ -253,11 +255,11 @@ async def shutdown_event(): # ============================================================ -# ЭНДПОИНТЫ API +# ЭНДПОИНТЫ API (все защищены аутентификацией, кроме /health) # ============================================================ @app.post("/rag/query", response_model=QueryResponse) -async def rag_query(request: QueryRequest): +async def rag_query(request: QueryRequest, _: str = Depends(verify_api_key)): """ Выполняет RAG-запрос. ИСТОРИЯ БЕРЁТСЯ СЕРВЕРОМ ИЗ БД (не передаётся в запросе). @@ -288,7 +290,7 @@ async def rag_query(request: QueryRequest): @app.post("/rag/index", response_model=IndexResponse) -async def index_document(request: IndexRequest): +async def index_document(request: IndexRequest, _: str = Depends(verify_api_key)): """Индексирует документ в базу знаний.""" orchestrator = get_orchestrator() @@ -316,21 +318,16 @@ async def index_document(request: IndexRequest): @app.get("/health") async def health_check(): - """Проверка здоровья сервиса.""" + """Проверка здоровья сервиса (без аутентификации).""" if _orchestrator is None: return {"status": "unhealthy", "message": "Orchestrator not initialized"} return {"status": "healthy"} -# ============================================================ -# НОВЫЕ ЭНДПОИНТЫ: OCR и транскрибация -# ============================================================ - @app.post("/rag/vision", response_model=VisionResponse) async def vision_endpoint( file: UploadFile = FastAPIFile(...), - user_jid: str = None, - room_jid: str = None + _: str = Depends(verify_api_key) ): """ Распознаёт текст на изображении через OCR (Tesseract). @@ -370,8 +367,7 @@ async def vision_endpoint( @app.post("/rag/transcribe", response_model=TranscribeResponse) async def transcribe_endpoint( file: UploadFile = FastAPIFile(...), - user_jid: str = None, - room_jid: str = None + _: str = Depends(verify_api_key) ): """ Транскрибирует аудиофайл через SaluteSpeech. @@ -450,6 +446,9 @@ def main(): logger.info(f"Запуск RAG-сервера с профилем {args.profile_dir}") logger.info(f"Хост: {args.host}, порт: {args.port}") + # Устанавливаем конфиг для аутентификации + set_auth_config(config) + # Инициализируем оркестратор try: init_orchestrator(config) diff --git a/rag/utils/config_loader.py b/rag/utils/config_loader.py index 37d50e2..c16252b 100644 --- a/rag/utils/config_loader.py +++ b/rag/utils/config_loader.py @@ -101,12 +101,22 @@ def load_config(profile_dir: str) -> AppConfig: if 'web_scraper' not in merged: merged['web_scraper'] = {'max_pages': 5, 'max_depth': 1} + # Добавляем секцию auth, если её нет + if 'auth' not in merged: + merged['auth'] = {} + # Создаём экземпляр AppConfig try: config = AppConfig(**merged) except Exception as e: raise ValueError(f"Ошибка валидации конфигурации: {e}") + # Загружаем API-ключ для RAG-сервера + api_key_env = merged.get('auth', {}).get('api_key_env', 'RAG_API_KEY') + config._rag_api_key = os.getenv(api_key_env) + if not config._rag_api_key: + logger.warning(f"Переменная окружения {api_key_env} не задана. Аутентификация на RAG-сервере отключена (НЕ БЕЗОПАСНО!)") + # Загружаем промпты из файлов prompts_dir = config.prompts_dir_path if prompts_dir and prompts_dir.exists():