Add new file

This commit is contained in:
Markov Andrey
2026-06-30 09:25:52 +00:00
parent c0d6d68adb
commit 1ba4119183

290
core/utils/config_loader.py Normal file
View File

@@ -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()