File size: 8,242 Bytes
19729e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
"""技能注册 ``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:  # pragma: no cover - 仅类型检查
    from .services import Services

logger = logging.getLogger(__name__)

#: ``discover`` 可接收的来源类型:已导入的包对象、可导入的包名、或目录路径。
DiscoverSource = Union[ModuleType, str, Path]


class SkillRegistry:
    """技能注册表:发现、注册与查询 Skill。"""

    def __init__(self, svc: "Services" | None = None) -> None:
        self._skills: dict[str, PharmaSkill] = {}
        self.svc = svc
        #: 发现期间的失败记录列表,元素为 ``{"name", "error", "type"}``。
        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

        # 包自身可能没有 __path__(即并非真正的包),此时无可扫描内容。
        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

        # 目录路径:把父目录加入 sys.path,按目录名作为包名导入。
        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:  # noqa: BLE001 - 顶层包导入失败需容错
                self._record_failure(pkg_name, exc)
                return None

        # 普通包名字符串。
        try:
            return importlib.import_module(str(source))
        except Exception as exc:  # noqa: BLE001
            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:  # noqa: BLE001 - 容错核心:任何异常均跳过
            self._record_failure(module_name, exc)

    def _resolve_skill(self, module: ModuleType) -> Optional[PharmaSkill]:
        """按约定优先级从模块解析出一个 ``PharmaSkill`` 实例。"""
        # 1) 模块级 SKILL 实例。
        skill = getattr(module, "SKILL", None)
        if isinstance(skill, PharmaSkill):
            return skill

        # 2) get_skill() 工厂。
        factory = getattr(module, "get_skill", None)
        if callable(factory):
            instance = factory()
            if isinstance(instance, PharmaSkill):
                return instance

        # 3) 模块内定义的具体 PharmaSkill 子类。
        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)
        # 若注入了审计服务则一并记录(当前阶段可能为 None,需容错)。
        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:  # noqa: BLE001 - 审计失败不得影响发现
                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"]