from __future__ import annotations import json import time from pathlib import Path from typing import Any, Dict, Optional import yaml from db.connection import DBConnectionManager class LLMRegistry: """集中式 LLM 配置注册器。 读取优先级: 1. 优先读取 MySQL(t_llm_config); 2. MySQL 不可用或无数据时,回退读取 config.yaml 的 llm 节点。 """ _cache: Dict[str, Any] = { # cache_until: 缓存过期时间戳,避免每次调用都打数据库; # data: 最近一次成功读取并归一化后的 llm 配置对象。 "cache_until": 0.0, "data": {}, } @classmethod def get_root_config_path(cls) -> Path: return Path(__file__).resolve().parents[2] / "config.yaml" @classmethod def _load_llm_from_yaml(cls) -> Dict[str, Any]: """从 YAML 读取 llm 配置(兜底来源)。""" path = cls.get_root_config_path() if not path.exists(): return {} with open(path, "r", encoding="utf-8") as fp: root = yaml.safe_load(fp) or {} llm_config = root.get("llm", {}) or {} if not isinstance(llm_config, dict): return {} return llm_config @staticmethod def _loads_json(value: Any) -> Dict[str, Any]: """把数据库 JSON 字段统一解析为 dict。""" if isinstance(value, dict): return value if isinstance(value, str): try: obj = json.loads(value) return obj if isinstance(obj, dict) else {} except json.JSONDecodeError: return {} return {} @classmethod def _load_llm_from_mysql(cls) -> Dict[str, Any]: """从 MySQL 读取 llm 配置。 注意: 1. 该函数必须“无副作用失败”,即任何异常都返回空 dict,交由上层做 YAML 回退; 2. 不依赖 Robot 实例,直接走 DBConnectionManager 单例,便于在插件调用链路中复用。 """ try: db_manager = DBConnectionManager.get_instance() if not db_manager or not db_manager.mysql_pool: return {} conn = db_manager.get_mysql_connection() try: with conn.cursor(dictionary=True) as cursor: cursor.execute( """ SELECT default_backend, backends_json, scenes_json FROM t_llm_config WHERE id = 1 LIMIT 1 """ ) row = cursor.fetchone() or {} finally: conn.close() if not row: return {} return { "default_backend": str(row.get("default_backend") or "").strip(), "backends": cls._loads_json(row.get("backends_json")), "scenes": cls._loads_json(row.get("scenes_json")), } except Exception: return {} @classmethod def _normalize_llm_config(cls, llm_config: Dict[str, Any]) -> Dict[str, Any]: """统一规整 llm 配置结构,避免下游出现类型分支。""" data = llm_config if isinstance(llm_config, dict) else {} default_backend = str(data.get("default_backend") or "").strip() backends = data.get("backends", {}) or {} scenes = data.get("scenes", {}) or {} if not isinstance(backends, dict): backends = {} if not isinstance(scenes, dict): scenes = {} return { "default_backend": default_backend, "backends": backends, "scenes": scenes, } @classmethod def get_llm_config(cls) -> Dict[str, Any]: """获取运行时 LLM 配置(优先 MySQL,失败回退 YAML)。""" now = time.time() if cls._cache.get("cache_until", 0.0) > now and cls._cache.get("data"): return cls._cache["data"] llm_config = cls._load_llm_from_mysql() if not llm_config: llm_config = cls._load_llm_from_yaml() normalized = cls._normalize_llm_config(llm_config) # 轻量缓存 3 秒:兼顾“后台编辑后较快生效”和“降低高频调用的 DB 压力”。 cls._cache = { "cache_until": now + 3.0, "data": normalized, } return normalized @classmethod def get_default_backend(cls) -> str: """读取全局默认后端名称。""" llm_config = cls.get_llm_config() return str(llm_config.get("default_backend", "") or "").strip() @classmethod def get_backend(cls, backend_name: str) -> Dict[str, Any]: if not backend_name: return {} llm_config = cls.get_llm_config() backends = llm_config.get("backends", {}) or {} backend = backends.get(backend_name, {}) or {} return dict(backend) if isinstance(backend, dict) else {} @classmethod def get_scenes(cls) -> Dict[str, str]: """读取 llm.scenes 场景路由配置,返回 scene->backend 的映射。""" llm_config = cls.get_llm_config() raw_scenes = llm_config.get("scenes", {}) or {} if not isinstance(raw_scenes, dict): return {} normalized: Dict[str, str] = {} for raw_scene, raw_backend in raw_scenes.items(): scene_name = str(raw_scene or "").strip() backend_name = str(raw_backend or "").strip() if not scene_name or not backend_name: continue normalized[scene_name] = backend_name return normalized @classmethod def get_scene_backend_name(cls, scene_name: str) -> str: """根据场景名解析后端名;若场景不存在则自动回退 default_backend。""" name = str(scene_name or "").strip() if not name: return cls.get_default_backend() scenes = cls.get_scenes() backend_name = str(scenes.get(name, "") or "").strip() if backend_name: return backend_name return cls.get_default_backend() @classmethod def resolve_by_scene(cls, scene_name: str) -> Dict[str, Any]: """按场景解析最终后端配置,并附带 scene 字段用于链路追踪。""" backend_name = cls.get_scene_backend_name(scene_name) backend = cls.get_backend(backend_name) if backend_name: backend["backend"] = backend_name if scene_name: backend["scene"] = str(scene_name).strip() return backend @classmethod def resolve(cls, local_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: local = dict(local_config or {}) # 严格模式说明: # 1. 统一只认 scene 作为路由入口,避免 backend/backend_ref 等多入口并存; # 2. 若未声明 scene,则视为“调用方直接给出完整连接参数”,原样返回 local。 scene_name = local.get("scene") or "" scene_name = str(scene_name).strip() if scene_name: merged = cls.resolve_by_scene(scene_name) merged.update(local) # 约定:只要声明了 scene,就以 scene 路由结果为准。 # 这样后台切换 scene 绑定时,无需改插件配置即可全局生效。 merged["backend"] = cls.get_scene_backend_name(scene_name) merged["scene"] = scene_name return merged return local