Files
fckbot/core/utils/config_loader.py
Markov Andrey 1ba4119183 Add new file
2026-06-30 09:25:52 +00:00

290 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()