# -*- 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()