diff --git a/core/utils/config_loader.py b/core/utils/config_loader.py new file mode 100644 index 0000000..b909c8a --- /dev/null +++ b/core/utils/config_loader.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +""" +Загрузчик конфигурации бота из bot.conf и переменных окружения. +Собирает все настройки в единый объект BotConfig. +""" + +import os +import yaml +import logging +from pathlib import Path +from typing import List, Optional, Any, Dict + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + + +class BotConfig: + """ + Единый объект конфигурации для одного профиля бота. + Все параметры читаются из bot.conf, core.conf и переменных окружения. + При отсутствии любого параметра – ошибка. + """ + + def __init__(self, profile_dir: str): + self.profile_dir = Path(profile_dir).resolve() + self.bot_config_path = self.profile_dir / "bot.conf" + if not self.bot_config_path.exists(): + raise FileNotFoundError(f"Файл профиля не найден: {self.bot_config_path}") + + with open(self.bot_config_path, 'r', encoding='utf-8') as f: + self.bot_raw = yaml.safe_load(f) + + # Загружаем core.conf + core_config_path = self.profile_dir.parent / "core" / "core.conf" + if core_config_path.exists(): + with open(core_config_path, 'r', encoding='utf-8') as f: + self.core_raw = yaml.safe_load(f) or {} + else: + self.core_raw = {} + + # Объединяем: bot.conf имеет приоритет над core.conf + self.raw = self._merge_configs(self.core_raw, self.bot_raw) + + # ----- 1. Обязательные поля (без них бот не запустится) ----- + self.name: str = self._get_required("name") + self.jid: str = self._get_required("jid") + self.password_env: str = self._get_required("password_env") + self.xmpp_password: str = self._get_env(self.password_env) + + # ----- 2. XMPP параметры (обязательные) ----- + self.xmpp_resource = self._get_required("resource") + self.status_message = self._get_required("status_message") + self.xmpp_server = self._get_required("xmpp_server") + self.xmpp_port = int(self._get_required("xmpp_port")) + self.xmpp_use_ssl = bool(self._get_required("xmpp_use_ssl")) + + # ----- 3. Настройки базы данных (PostgreSQL) ----- + db_cfg = self._get_required_section("database") + self.db_name = self._get_required_from_section(db_cfg, "database") + self.db_type = self._get_required_from_section(db_cfg, "type") + self.db_host = self._get_required_from_section(db_cfg, "host") + self.db_port = int(self._get_required_from_section(db_cfg, "port")) + self.db_user = self._get_required_from_section(db_cfg, "user") + self.db_password_env = self._get_required_from_section(db_cfg, "password_env") + self.db_password = self._get_env(self.db_password_env) + + # SSL параметры (опциональны, но если секция ssl не задана – будет ошибка, если нет) + self.db_ssl = self._get_optional_from_section(db_cfg, "ssl", default=None) + if self.db_ssl is None: + raise ValueError("В секции database отсутствует обязательный параметр 'ssl'") + self.db_ssl_mode = self._get_optional_from_section(db_cfg, "ssl_mode", default=None) + if self.db_ssl_mode is None: + raise ValueError("В секции database отсутствует обязательный параметр 'ssl_mode'") + + # ----- 4. Настройки Qdrant ----- + qdrant_cfg = self._get_required_section("qdrant") + self.qdrant_collection = self._get_required_from_section(qdrant_cfg, "collection") + self.qdrant_host = self._get_required_from_section(qdrant_cfg, "host") + self.qdrant_port = int(self._get_required_from_section(qdrant_cfg, "port")) + self.qdrant_grpc_port = int(self._get_required_from_section(qdrant_cfg, "grpc_port")) + self.qdrant_vector_size = int(self._get_required_from_section(qdrant_cfg, "vector_size")) + self.qdrant_distance = self._get_required_from_section(qdrant_cfg, "distance") + + # ----- 5. Настройки GigaChat ----- + gc_cfg = self._get_required_section("gigachat") + self.gigachat_credentials_env = self._get_required_from_section(gc_cfg, "credentials_env") + self.gigachat_api_key = self._get_env(self.gigachat_credentials_env) + self.gigachat_model_embedding = self._get_required_from_section(gc_cfg, "model_embedding") + self.gigachat_model_generation = self._get_required_from_section(gc_cfg, "model_generation") + self.gigachat_timeout = int(self._get_required_from_section(gc_cfg, "timeout")) + self.gigachat_temperature = float(self._get_required_from_section(gc_cfg, "temperature")) + + # ----- 6. Кэш эмбеддингов ----- + emb_cfg = self._get_required_section("embedding") + self.embedding_cache_size = int(self._get_required_from_section(emb_cfg, "cache_size")) + self.embedding_model = self._get_required_from_section(emb_cfg, "model") + self.embedding_timeout = int(self._get_required_from_section(emb_cfg, "timeout")) + self.embedding_verify_ssl = bool(self._get_required_from_section(emb_cfg, "verify_ssl_certs")) + + # ----- 7. Разбиение документов на чанки ----- + chunk_cfg = self._get_required_section("chunking") + self.chunking_enabled = bool(self._get_required_from_section(chunk_cfg, "enabled")) + self.chunk_size_tokens = int(self._get_required_from_section(chunk_cfg, "chunk_size_tokens")) + self.overlap_tokens = int(self._get_required_from_section(chunk_cfg, "overlap_tokens")) + self.chunking_strategy = self._get_required_from_section(chunk_cfg, "strategy") + self.chunking_approx_chunk_chars = int(self._get_required_from_section(chunk_cfg, "approx_chunk_chars")) + self.chunking_approx_overlap_chars = int(self._get_required_from_section(chunk_cfg, "approx_overlap_chars")) + + # ----- 8. Пути к файлам и каталогам ----- + paths = self._get_required_section("paths") + self.data_dir = (self.profile_dir / paths.get("data_dir")).resolve() + self.temp_dir = (self.profile_dir / paths.get("temp_dir")).resolve() + self.prompts_dir = (self.profile_dir / paths.get("prompts_dir")).resolve() + self.upload_dir = Path(paths.get("upload_dir")).resolve() + self.log_dir = Path(paths.get("log_dir")).resolve() + log_filename = paths.get("log_filename") + if not log_filename: + raise ValueError("В секции paths отсутствует log_filename") + self.log_file = self.log_dir / log_filename.format(name=self.name) + + # ----- 9. HTTP Upload ----- + http_up = self._get_required_section("http_upload") + self.upload_base_url = http_up.get("base_url") + + # ----- 10. Настройки AI (промпты, модель) ----- + ai_cfg = self._get_required_section("ai") + self.ai_model = ai_cfg.get("model") + if not self.ai_model: + self.ai_model = self.gigachat_model_generation + self.ai_temperature = float(ai_cfg.get("temperature", self.gigachat_temperature)) + self.ai_timeout = int(ai_cfg.get("timeout", self.gigachat_timeout)) + + prompts_cfg = ai_cfg.get("prompts") + if not prompts_cfg: + raise ValueError("В секции ai отсутствует подсекция prompts") + + # промпты – обязательные файлы + self.system_prompt_file = self.prompts_dir / prompts_cfg.get("system") + self.synthesis_prompt_file = self.prompts_dir / prompts_cfg.get("synthesis") + self.quality_prompt_file = self.prompts_dir / prompts_cfg.get("quality") + self.rerank_prompt_file = self.prompts_dir / prompts_cfg.get("rerank") + self.expand_prompt_file = self.prompts_dir / prompts_cfg.get("expand") + self.intent_prompt_file = self.prompts_dir / prompts_cfg.get("intent") + self.metrics_prompt_file = self.prompts_dir / prompts_cfg.get("metrics") + self.summary_prompt_file = self.prompts_dir / prompts_cfg.get("summary") + self.consistency_prompt_file = self.prompts_dir / prompts_cfg.get("consistency") + self.critique_prompt_file = self.prompts_dir / prompts_cfg.get("critique") + self.spellcheck_prompt_file = self.prompts_dir / prompts_cfg.get("spellcheck") + self.generate_document_prompt_file = self.prompts_dir / prompts_cfg.get("generate_document") + + # ----- 11. Параметры RAG ----- + rag_cfg = self._get_required_section("rag") + self.rag_default_top_k = int(rag_cfg.get("default_top_k")) + self.rag_metrics_top_k = int(rag_cfg.get("metrics_top_k")) + self.rag_contradiction_top_k = int(rag_cfg.get("contradiction_top_k")) + self.max_context_tokens = int(rag_cfg.get("max_context_tokens")) + + # ----- 12. Веб-скрапер ----- + ws_cfg = self._get_required_section("web_scraper") + self.ws_max_pages = int(ws_cfg.get("max_pages")) + self.ws_max_depth = int(ws_cfg.get("max_depth")) + + # ----- 13. Включение/отключение функций ----- + feat = self._get_required_section("features") + self.file_processing = bool(feat.get("file_processing")) + self.surgical_replace = bool(feat.get("surgical_replace")) + self.voice_recognition = bool(feat.get("voice_recognition")) + self.vision = bool(feat.get("vision")) + self.archive_support = bool(feat.get("archive_support")) + self.ocr = bool(feat.get("ocr")) + self.allow_public_knowledge = bool(feat.get("allow_public_knowledge")) + self.max_file_size_mb = int(feat.get("max_file_size_mb")) + self.max_archive_files = int(feat.get("max_archive_files")) + self.enable_intent_classification = bool(feat.get("enable_intent_classification")) + self.enable_self_critique = bool(feat.get("enable_self_critique")) + self.surgical_keywords = feat.get("surgical_keywords") + if not isinstance(self.surgical_keywords, list): + raise ValueError("surgical_keywords должен быть списком строк") + self.mention_keyword = feat.get("mention_keyword", self.name) # если не указано, используем имя бота + + # ----- 14. Очистка старых файлов ----- + clean_cfg = self._get_required_section("cleanup") + self.temp_cleanup_days = int(clean_cfg.get("temp_days")) + self.upload_cleanup_days = int(clean_cfg.get("upload_days")) + self.cleanup_interval = int(clean_cfg.get("interval_seconds")) + self.user_data_cleanup_days = int(clean_cfg.get("user_data_days")) + + # ----- 15. Таймауты HTTP ----- + http_cfg = self._get_required_section("http") + self.http_download_timeout = int(http_cfg.get("download_timeout")) + self.http_upload_timeout = int(http_cfg.get("upload_timeout")) + + # ----- 16. Права на создаваемые файлы ----- + fp_cfg = self._get_required_section("file_permissions") + self.file_mode = int(fp_cfg.get("mode"), 8) # восьмеричное + + # ----- 17. Администраторы ----- + self.admin_jids = self._get_required("admin_jids") + if not isinstance(self.admin_jids, list): + raise ValueError("admin_jids должен быть списком JID") + + # ----- 18. SaluteSpeech (из .env) ----- + self.salute_speech_auth = os.getenv("SALUTE_SPEECH_AUTH", "") + if not self.salute_speech_auth: + raise ValueError("Переменная окружения SALUTE_SPEECH_AUTH не задана") + + # ----- 19. Log level ----- + log_level_str = self._get_required("log_level") + self.log_level = getattr(logging, log_level_str.upper(), logging.INFO) + + # ----- 20. Настройки функций (intent, expand, metrics, summary, consistency, critique, rerank) ----- + # Все они читаются из соответствующих секций, но сами секции могут отсутствовать? + # Поскольку мы требуем всё явно, то каждая секция должна быть. + self.intent_temperature = float(self._get_required_from_section(self._get_required_section("intent"), "temperature")) + self.expand_temperature = float(self._get_required_from_section(self._get_required_section("expand"), "temperature")) + self.metrics_temperature = float(self._get_required_from_section(self._get_required_section("metrics"), "temperature")) + self.summary_temperature = float(self._get_required_from_section(self._get_required_section("summary"), "temperature")) + self.summary_max_chars = int(self._get_required_from_section(self._get_required_section("summary"), "max_chars")) + self.consistency_temperature = float(self._get_required_from_section(self._get_required_section("consistency"), "temperature")) + self.consistency_max_fragments = int(self._get_required_from_section(self._get_required_section("consistency"), "max_fragments")) + self.critique_temperature = float(self._get_required_from_section(self._get_required_section("critique"), "temperature")) + self.rerank_temperature = float(self._get_required_from_section(self._get_required_section("rerank"), "temperature")) + self.rerank_min_length = int(self._get_required_from_section(self._get_required_section("rerank"), "min_length")) + self.generate_document_temperature = float(self._get_required_from_section(self._get_required_section("generate_document"), "temperature")) + + # ----- 21. Совместимость с функциями: создаём вложенные объекты ----- + self.intent = {"temperature": self.intent_temperature} + self.expand = {"temperature": self.expand_temperature} + self.metrics = {"temperature": self.metrics_temperature} + self.summary = {"temperature": self.summary_temperature, "max_chars": self.summary_max_chars} + self.consistency = {"temperature": self.consistency_temperature, "max_fragments": self.consistency_max_fragments} + self.critique = {"temperature": self.critique_temperature} + self.rerank = {"temperature": self.rerank_temperature, "min_length": self.rerank_min_length} + + # Создаём директории + self.data_dir.mkdir(parents=True, exist_ok=True) + self.temp_dir.mkdir(parents=True, exist_ok=True) + self.prompts_dir.mkdir(parents=True, exist_ok=True) + self.upload_dir.mkdir(parents=True, exist_ok=True) + self.log_dir.mkdir(parents=True, exist_ok=True) + + # ------------------------------------------------------------------ + # Вспомогательные методы + # ------------------------------------------------------------------ + def _merge_configs(self, core: dict, bot: dict) -> dict: + """Рекурсивное слияние: bot имеет приоритет над core.""" + merged = core.copy() + for key, value in bot.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = self._merge_configs(merged[key], value) + else: + merged[key] = value + return merged + + def _get_required(self, key: str) -> Any: + """Возвращает значение ключа из объединённого конфига. Если нет – ошибка.""" + if key not in self.raw: + raise KeyError(f"В конфигурации (bot.conf или core.conf) отсутствует обязательный ключ: '{key}'") + return self.raw[key] + + def _get_required_section(self, section: str) -> dict: + """Возвращает словарь-секцию. Если секции нет – ошибка.""" + if section not in self.raw: + raise KeyError(f"В конфигурации отсутствует обязательная секция: '{section}'") + if not isinstance(self.raw[section], dict): + raise TypeError(f"Секция '{section}' должна быть словарём") + return self.raw[section] + + def _get_required_from_section(self, section_dict: dict, key: str) -> Any: + """Возвращает значение ключа внутри секции. Если нет – ошибка.""" + if key not in section_dict: + raise KeyError(f"В секции отсутствует обязательный ключ: '{key}'") + return section_dict[key] + + def _get_optional_from_section(self, section_dict: dict, key: str, default=None): + """Возвращает значение или default, если ключ отсутствует (не вызывает ошибку).""" + return section_dict.get(key, default) + + def _get_env(self, env_var: str) -> str: + """Читает переменную окружения. Если нет – ошибка.""" + value = os.getenv(env_var) + if value is None or value.strip() == "": + raise ValueError( + f"Переменная окружения {env_var} не задана или пуста. " + f"Бот не может запуститься без неё." + ) + return value.strip() \ No newline at end of file