""" Загрузка и валидация YAML-конфигурации монитора. Основной конфигурационный файл: config/config.yaml Сценарии загружаются из config/scenarios/*.yaml """ from __future__ import annotations import enum import logging import os from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional import yaml log = logging.getLogger("ses_monitor.config") class ZabbixMode(enum.Enum): """Режим доставки метрик в Zabbix.""" SENDER = "sender" # zabbix_sender — активный push USERPARAMETER = "userparameter" # UserParameter — пассивный опрос агентом @classmethod def from_str(cls, value: str) -> ZabbixMode: """Создаёт режим из строки (регистронезависимо).""" for mode in cls: if mode.value.lower() == value.strip().lower(): return mode raise ValueError(f"Неизвестный режим Zabbix: '{value}'. Допустимые: {[m.value for m in cls]}") @dataclass class SESConfig: """Настройки подключения к SES серверу.""" base_url: str = "http://10.101.60.31:6190" robot_id: str = "" # ID робота задаётся в config.yaml под конкретный проект channel: str = "monitoring" request_timeout: int = 30 robot_list_endpoint: str = "/ses/robot/list" # или "/ses/robot/listall" @dataclass class ZabbixConfig: """Настройки отправки метрик в Zabbix.""" enabled: bool = True mode: ZabbixMode = ZabbixMode.SENDER # "sender" или "userparameter" # ---- Общие поля для обоих режимов ---- host: str = "" # Задаётся в config.yaml: zabbix.host (техническое имя узла сети) # ---- Поля режима sender ---- server: str = "127.0.0.1" port: int = 10051 sender_path: str = "/usr/bin/zabbix_sender" # ---- Поля режима userparameter ---- metrics_file: str = "/var/tmp/ses_monitor/metrics.json" agentd_config_dir: str = "/opt/ses-monitor/config" @dataclass class MonitoringConfig: """Общие настройки мониторинга.""" liveness_interval: int = 30 # Интервал liveness-проверок, секунды archive_check_interval: int = 600 # Интервал проверки архива, секунды log_enabled: bool = False # Пока отключено — POST /ses/log тяжёлый scenarios_dir: str = "config/scenarios" scenario_default_timeout: int = 30 step_time_monitoring: bool = True # Мониторинг времени ответа из архива @dataclass class LoggingConfig: """Настройки логирования.""" level: str = "INFO" file: Optional[str] = None format: str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" @dataclass class ModelsMonitoringConfig: """Настройки мониторинга внутренних моделей SES (SEE, SMC, TTS, QAS, SPR).""" enabled: bool = True # Порог уверенности SEE ниже которого считаем проблемой (0..1) see_confidence_threshold: float = 0.5 # Порог уверенности SMC ниже которого считаем проблемой (0..1) smc_confidence_threshold: float = 0.5 # Отслеживать ли голосовые ответы TTS tts_enabled: bool = True # Отслеживать ли наполненность QAS qas_enabled: bool = True # Отслеживать ли наполненность SPR (распознавание речи: аудио→текст) spr_enabled: bool = True @dataclass class AppConfig: """Корневая конфигурация приложения.""" ses: SESConfig = field(default_factory=SESConfig) zabbix: ZabbixConfig = field(default_factory=ZabbixConfig) monitoring: MonitoringConfig = field(default_factory=MonitoringConfig) models: ModelsMonitoringConfig = field(default_factory=ModelsMonitoringConfig) logging: LoggingConfig = field(default_factory=LoggingConfig) @dataclass class ScenarioStep: """Один шаг диалогового сценария.""" send: str # Текст сообщения, которое отправляем роботу expect: List[Dict[str, Any]] = field(default_factory=list) # Каждое ожидание — dict с ключами: # path: str (путь в JSON-ответе, например "answer[0].messages[0]") # contains/equals/not_contains/not_empty/regex: условие @dataclass class ArchiveCheckConfig: """Настройки проверки архива для сценария.""" enabled: bool = False lookback_minutes: int = 10 expect: List[Dict[str, Any]] = field(default_factory=list) @dataclass class ScenarioConfig: """Конфигурация одного сценария мониторинга.""" name: str = "" description: str = "" enabled: bool = True interval: int = 300 # Интервал запуска, секунды timeout: int = 30 robot_id: Optional[str] = None # Если None — берётся из config.yaml channel: Optional[str] = None restart_closed: bool = False steps: List[ScenarioStep] = field(default_factory=list) archive_check: Optional[ArchiveCheckConfig] = None def _dict_to_scenario_step(data: Dict[str, Any]) -> ScenarioStep: """Преобразует словарь из YAML в ScenarioStep.""" return ScenarioStep( send=data["send"], expect=data.get("expect", []), ) def _dict_to_scenario_config(data: Dict[str, Any]) -> ScenarioConfig: """Преобразует словарь из YAML в ScenarioConfig.""" steps = [_dict_to_scenario_step(s) for s in data.get("steps", [])] archive_data = data.get("archive_check") archive_check = None if archive_data: archive_check = ArchiveCheckConfig( enabled=archive_data.get("enabled", False), lookback_minutes=archive_data.get("lookback_minutes", 10), expect=archive_data.get("expect", []), ) return ScenarioConfig( name=data.get("name", ""), description=data.get("description", ""), enabled=data.get("enabled", True), interval=data.get("interval", 300), timeout=data.get("timeout", 30), robot_id=data.get("robot_id"), channel=data.get("channel"), restart_closed=data.get("restart_closed", False), steps=steps, archive_check=archive_check, ) def _build_config(data: Dict[str, Any]) -> AppConfig: """Собирает AppConfig из распарсенного YAML-словаря.""" ses_data = data.get("ses", {}) ses = SESConfig( base_url=ses_data.get("base_url", SESConfig.base_url), robot_id=ses_data.get("robot_id", SESConfig.robot_id), channel=ses_data.get("channel", SESConfig.channel), request_timeout=ses_data.get("request_timeout", SESConfig.request_timeout), robot_list_endpoint=ses_data.get("robot_list_endpoint", SESConfig.robot_list_endpoint), ) zbx_data = data.get("zabbix", {}) mode_raw = zbx_data.get("mode", "sender") try: mode = ZabbixMode.from_str(mode_raw) except ValueError: mode = ZabbixMode.SENDER log.warning("Неизвестный режим Zabbix '%s', используется 'sender'.", mode_raw) zabbix = ZabbixConfig( enabled=zbx_data.get("enabled", ZabbixConfig.enabled), mode=mode, host=zbx_data.get("host", ZabbixConfig.host), server=zbx_data.get("server", ZabbixConfig.server), port=zbx_data.get("port", ZabbixConfig.port), sender_path=zbx_data.get("sender_path", ZabbixConfig.sender_path), metrics_file=zbx_data.get("metrics_file", ZabbixConfig.metrics_file), agentd_config_dir=zbx_data.get("agentd_config_dir", ZabbixConfig.agentd_config_dir), ) mon_data = data.get("monitoring", {}) monitoring = MonitoringConfig( liveness_interval=mon_data.get("liveness_interval", MonitoringConfig.liveness_interval), archive_check_interval=mon_data.get("archive_check_interval", MonitoringConfig.archive_check_interval), log_enabled=mon_data.get("log_enabled", MonitoringConfig.log_enabled), scenarios_dir=mon_data.get("scenarios_dir", MonitoringConfig.scenarios_dir), scenario_default_timeout=mon_data.get("scenario_default_timeout", MonitoringConfig.scenario_default_timeout), step_time_monitoring=mon_data.get("step_time_monitoring", MonitoringConfig.step_time_monitoring), ) models_data = data.get("models_monitoring", {}) models_cfg = ModelsMonitoringConfig( enabled=models_data.get("enabled", ModelsMonitoringConfig.enabled), see_confidence_threshold=models_data.get( "see_confidence_threshold", ModelsMonitoringConfig.see_confidence_threshold ), smc_confidence_threshold=models_data.get( "smc_confidence_threshold", ModelsMonitoringConfig.smc_confidence_threshold ), tts_enabled=models_data.get("tts_enabled", ModelsMonitoringConfig.tts_enabled), qas_enabled=models_data.get("qas_enabled", ModelsMonitoringConfig.qas_enabled), spr_enabled=models_data.get("spr_enabled", ModelsMonitoringConfig.spr_enabled), ) log_data = data.get("logging", {}) logging_cfg = LoggingConfig( level=log_data.get("level", LoggingConfig.level), file=log_data.get("file"), format=log_data.get("format", LoggingConfig.format), ) return AppConfig(ses=ses, zabbix=zabbix, monitoring=monitoring, models=models_cfg, logging=logging_cfg) def load_app_config(config_path: Optional[str] = None) -> AppConfig: """Загружает основную конфигурацию из YAML-файла. Args: config_path: Путь к config.yaml. Если None — ищет config/config.yaml относительно корня проекта. Returns: AppConfig с заполненными полями. Raises: FileNotFoundError: Файл конфигурации не найден. yaml.YAMLError: Ошибка парсинга YAML. """ if config_path is None: # Автоопределение пути: от места запуска ищем config/config.yaml candidates = [ Path("config/config.yaml"), Path("/opt/ses-monitor/config/config.yaml"), ] # Добавляем относительный путь от этого файла this_dir = Path(__file__).resolve().parent.parent candidates.insert(0, this_dir / "config" / "config.yaml") found = None for c in candidates: if c.exists(): found = c break if found is None: raise FileNotFoundError( "Файл конфигурации не найден. Проверьте config/config.yaml " "или укажите путь явно." ) config_path = str(found) with open(config_path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) if data is None: data = {} return _build_config(data) def load_scenarios(scenarios_dir: str) -> List[ScenarioConfig]: """Загружает все YAML-файлы сценариев из указанного каталога. Args: scenarios_dir: Путь к каталогу со сценариями. Returns: Список конфигураций сценариев. Notes: Файлы сценариев читаются из `*.yaml` и `*.yml` в указанном каталоге. Сценарии с `enabled: false` пропускаются. """ dir_path = Path(scenarios_dir) if not dir_path.exists(): raise FileNotFoundError(f"Каталог сценариев не найден: {scenarios_dir}") scenarios: List[ScenarioConfig] = [] for yaml_file in sorted(dir_path.glob("*.yaml")): try: with open(yaml_file, "r", encoding="utf-8") as f: data = yaml.safe_load(f) if data is None: continue scenario = _dict_to_scenario_config(data) if scenario.enabled: scenarios.append(scenario) except (yaml.YAMLError, KeyError) as e: raise RuntimeError(f"Ошибка чтения сценария {yaml_file}: {e}") from e for yml_file in sorted(dir_path.glob("*.yml")): try: with open(yml_file, "r", encoding="utf-8") as f: data = yaml.safe_load(f) if data is None: continue scenario = _dict_to_scenario_config(data) if scenario.enabled: scenarios.append(scenario) except (yaml.YAMLError, KeyError) as e: raise RuntimeError(f"Ошибка чтения сценария {yml_file}: {e}") from e return scenarios