# -*- coding: utf-8 -*- """ Загрузчик конфигурации с использованием Pydantic. Читает YAML-файлы (bot.conf и rag.conf), подставляет переменные окружения, создаёт экземпляр AppConfig. """ import os import yaml import logging from pathlib import Path from typing import Optional, Dict, Any from dotenv import load_dotenv from .config_models import AppConfig, DatabaseConfig, QdrantConfig, GigaChatConfig, \ EmbeddingConfig, ChunkingConfig, RAGConfig, HTTPServerConfig, HTTPConfig, \ SummarizationConfig, PathsConfig, FeaturesConfig, CleanupConfig, FilePermissionsConfig, \ AIConfig, PromptsConfig, PromptTemperatures logger = logging.getLogger(__name__) # Загружаем переменные окружения из .env load_dotenv() def load_config(profile_dir: str) -> AppConfig: """ Загружает конфигурацию из профиля. Аргументы: profile_dir: путь к каталогу профиля бота (например, /usr/local/etc/bots/metabot) Возвращает: AppConfig: экземпляр модели конфигурации. """ profile_path = Path(profile_dir).resolve() bot_config_path = profile_path / "bot.conf" if not bot_config_path.exists(): raise FileNotFoundError(f"Файл конфигурации бота не найден: {bot_config_path}") # Загружаем bot.conf with open(bot_config_path, 'r', encoding='utf-8') as f: bot_raw = yaml.safe_load(f) or {} # Загружаем rag.conf (общий для всех ботов) – ищем в родительском каталоге или рядом rag_config_path = profile_path.parent / "rag" / "rag.conf" if not rag_config_path.exists(): # fallback: возможно, rag.conf лежит в той же директории, что и profile rag_config_path = profile_path / "rag.conf" if not rag_config_path.exists(): raise FileNotFoundError(f"Файл rag.conf не найден: {rag_config_path}") with open(rag_config_path, 'r', encoding='utf-8') as f: rag_raw = yaml.safe_load(f) or {} # Глубокое слияние: bot.conf имеет приоритет над rag.conf merged = deep_merge(rag_raw, bot_raw) # Извлекаем переменные окружения env_vars = { 'XMPP_PASSWORD': os.getenv('XMPP_PASSWORD'), 'DB_PASSWORD': os.getenv('DB_PASSWORD'), 'GIGACHAT_API_KEY': os.getenv('GIGACHAT_API_KEY'), 'SALUTE_SPEECH_AUTH': os.getenv('SALUTE_SPEECH_AUTH'), } # Подставляем пароли и ключи в секции # В database: password_env -> фактический пароль if 'database' in merged and 'password_env' in merged['database']: env_name = merged['database']['password_env'] merged['database']['password'] = env_vars.get(env_name) if not merged['database']['password']: raise ValueError(f"Переменная окружения {env_name} не задана или пуста") # удаляем password_env, чтобы не передавать в модель del merged['database']['password_env'] # Аналогично для gigachat if 'gigachat' in merged and 'credentials_env' in merged['gigachat']: env_name = merged['gigachat']['credentials_env'] merged['gigachat']['api_key'] = env_vars.get(env_name) if not merged['gigachat']['api_key']: raise ValueError(f"Переменная окружения {env_name} не задана или пуста") del merged['gigachat']['credentials_env'] # Для XMPP пароля if 'password_env' in merged: env_name = merged['password_env'] merged['xmpp_password'] = env_vars.get(env_name) if not merged['xmpp_password']: raise ValueError(f"Переменная окружения {env_name} не задана или пуста") del merged['password_env'] # Для SaluteSpeech (если есть в секции features или отдельно) # У нас он может быть в .env, но в модели нет отдельного поля; добавим в итоговый объект merged['salute_speech_auth'] = env_vars.get('SALUTE_SPEECH_AUTH') # Преобразуем пути в абсолютные с учётом profile_dir paths_section = merged.get('paths', {}) # Если пути относительные, дополняем их относительно profile_dir for key in ['data_dir', 'temp_dir', 'prompts_dir', 'upload_dir', 'log_dir']: if key in paths_section: p = Path(paths_section[key]) if not p.is_absolute(): paths_section[key] = str(profile_path / p) # log_filename оставляем как есть # Добавляем profile_dir в merged для передачи в модель merged['profile_dir'] = str(profile_path) # Создаём экземпляр AppConfig (Pydantic выполнит валидацию) try: config = AppConfig(**merged) except Exception as e: raise ValueError(f"Ошибка валидации конфигурации: {e}") # Дополнительно загружаем промпты из файлов prompts_dir = config.prompts_dir_path if prompts_dir and prompts_dir.exists(): prompts_content = {} 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: prompts_content[prompt_name] = f.read() except Exception as e: logger.warning(f"Не удалось загрузить промпт {prompt_name} из {file_path}: {e}") else: logger.warning(f"Файл промпта {filename} не найден в {prompts_dir}") config.prompts_content = prompts_content else: logger.warning(f"Каталог промптов не существует: {prompts_dir}") return config def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: """ Рекурсивное слияние словарей: override имеет приоритет над base. """ result = base.copy() for key, value in override.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = deep_merge(result[key], value) else: result[key] = value return result