| """技能注册 ``SkillRegistry``:自动发现、注册与查询(需求 1.1、1.3)。 |
| |
| 启动时扫描 ``skills/`` 包下的每个子包 / 模块,导入并查找实现了 |
| ``PharmaSkill`` 契约的技能实例,按 ``SkillMeta.id`` 注册(需求 1.1)。 |
| |
| **关键容错保证(需求 1.3)**:单个 Skill 的导入或实例化抛出异常时,注册器以 |
| ``try/except`` 捕获、记录失败并**跳过**该 Skill,绝不影响其余 Skill 的注册与 |
| 整体应用的运行。失败信息收集到 ``self.failures`` 并经 ``logging`` 输出;若注入了 |
| ``audit`` 服务,则一并落审计。 |
| |
| 发现约定(与 design.md「2. 技能注册 Registry」一致),按以下优先级在每个候选 |
| 模块中解析技能实例: |
| |
| 1. 模块级变量 ``SKILL``(且为 ``PharmaSkill`` 实例)。 |
| 2. 工厂函数 ``get_skill()``(返回 ``PharmaSkill`` 实例)。 |
| 3. 在模块内**定义**的 ``PharmaSkill`` 具体子类(非抽象),实例化其首个匹配者。 |
| |
| 注册表通过 ``all()`` 按 ``SkillMeta.order`` 排序输出,供导航使用;``get(id)`` |
| 按 id 精确查询。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import importlib |
| import inspect |
| import logging |
| import pkgutil |
| import sys |
| from pathlib import Path |
| from types import ModuleType |
| from typing import TYPE_CHECKING, Optional, Union |
|
|
| from .skill_base import PharmaSkill |
|
|
| if TYPE_CHECKING: |
| from .services import Services |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| DiscoverSource = Union[ModuleType, str, Path] |
|
|
|
|
| class SkillRegistry: |
| """技能注册表:发现、注册与查询 Skill。""" |
|
|
| def __init__(self, svc: "Services" | None = None) -> None: |
| self._skills: dict[str, PharmaSkill] = {} |
| self.svc = svc |
| |
| self.failures: list[dict] = [] |
|
|
| |
| |
| |
| def discover(self, source: DiscoverSource = "skills") -> None: |
| """扫描 ``source`` 下的所有子模块 / 子包并注册其中的 Skill。 |
| |
| ``source`` 可为: |
| |
| - 已导入的包对象(``ModuleType``); |
| - 可导入的包名字符串(默认 ``"skills"``); |
| - 指向技能目录的 ``Path`` / 路径字符串(其父目录会被临时加入 |
| ``sys.path`` 以便按包名导入)。 |
| |
| 单个子模块导入或技能实例化失败时被捕获并跳过(需求 1.3)。 |
| """ |
| package = self._resolve_package(source) |
| if package is None: |
| return |
|
|
| |
| pkg_path = getattr(package, "__path__", None) |
| if pkg_path is None: |
| logger.warning("发现源 %r 不是包,跳过扫描。", source) |
| return |
|
|
| for mod_info in pkgutil.iter_modules(pkg_path): |
| full_name = f"{package.__name__}.{mod_info.name}" |
| self._load_one(full_name) |
|
|
| def _resolve_package(self, source: DiscoverSource) -> Optional[ModuleType]: |
| """把 ``source`` 解析为已导入的包对象;解析失败返回 ``None``。""" |
| if isinstance(source, ModuleType): |
| return source |
|
|
| |
| if isinstance(source, Path) or ( |
| isinstance(source, str) and ("/" in source or "\\" in source) |
| ): |
| skills_path = Path(source) |
| if not skills_path.exists(): |
| logger.error("技能目录不存在:%s", skills_path) |
| return None |
| parent = str(skills_path.parent) |
| if parent not in sys.path: |
| sys.path.insert(0, parent) |
| pkg_name = skills_path.name |
| try: |
| return importlib.import_module(pkg_name) |
| except Exception as exc: |
| self._record_failure(pkg_name, exc) |
| return None |
|
|
| |
| try: |
| return importlib.import_module(str(source)) |
| except Exception as exc: |
| self._record_failure(str(source), exc) |
| return None |
|
|
| def _load_one(self, module_name: str) -> None: |
| """导入单个候选模块、解析其 Skill 实例并注册;失败则跳过(需求 1.3)。""" |
| try: |
| module = importlib.import_module(module_name) |
| skill = self._resolve_skill(module) |
| if skill is None: |
| logger.debug("模块 %s 未发现 Skill,跳过。", module_name) |
| return |
| self.register(skill) |
| except Exception as exc: |
| self._record_failure(module_name, exc) |
|
|
| def _resolve_skill(self, module: ModuleType) -> Optional[PharmaSkill]: |
| """按约定优先级从模块解析出一个 ``PharmaSkill`` 实例。""" |
| |
| skill = getattr(module, "SKILL", None) |
| if isinstance(skill, PharmaSkill): |
| return skill |
|
|
| |
| factory = getattr(module, "get_skill", None) |
| if callable(factory): |
| instance = factory() |
| if isinstance(instance, PharmaSkill): |
| return instance |
|
|
| |
| for _, obj in inspect.getmembers(module, inspect.isclass): |
| if ( |
| issubclass(obj, PharmaSkill) |
| and obj is not PharmaSkill |
| and not inspect.isabstract(obj) |
| and obj.__module__ == module.__name__ |
| ): |
| return obj() |
|
|
| return None |
|
|
| def register(self, skill: PharmaSkill) -> None: |
| """按 ``meta.id`` 注册一个 Skill 实例(重复 id 覆盖并告警)。""" |
| meta = getattr(skill, "meta", None) |
| skill_id = getattr(meta, "id", None) |
| if not skill_id: |
| raise ValueError(f"Skill {skill!r} 缺少 meta.id,无法注册。") |
| if skill_id in self._skills: |
| logger.warning("Skill id 冲突:%s 已存在,将被覆盖。", skill_id) |
| self._skills[skill_id] = skill |
| logger.info("已注册 Skill:%s", skill_id) |
|
|
| def _record_failure(self, name: str, exc: Exception) -> None: |
| """记录一次发现失败:写入 ``failures``、日志,并尽力落审计。""" |
| self.failures.append( |
| {"name": name, "error": str(exc), "type": type(exc).__name__} |
| ) |
| logger.warning("加载 Skill 失败,已跳过:%s(%s)", name, exc) |
| |
| audit = getattr(self.svc, "audit", None) |
| recorder = getattr(audit, "record_error", None) |
| if callable(recorder): |
| try: |
| recorder("skill_load_failed", name, str(exc)) |
| except Exception: |
| logger.debug("审计记录 Skill 加载失败时再次出错。", exc_info=True) |
|
|
| |
| |
| |
| def all(self) -> list[PharmaSkill]: |
| """返回全部已注册 Skill,按 ``SkillMeta.order`` 升序排列。""" |
| return sorted( |
| self._skills.values(), |
| key=lambda s: getattr(s.meta, "order", 100), |
| ) |
|
|
| def get(self, skill_id: str) -> Optional[PharmaSkill]: |
| """按 id 精确查询;不存在返回 ``None``。""" |
| return self._skills.get(skill_id) |
|
|
| def __len__(self) -> int: |
| return len(self._skills) |
|
|
| def __contains__(self, skill_id: object) -> bool: |
| return skill_id in self._skills |
|
|
|
|
| __all__ = ["SkillRegistry"] |
|
|