pasm-framework 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,420 @@
1
+ """插件子系统核心 —— pasm-framework 的「即插即用」骨架。
2
+
3
+ 为什么需要这一层
4
+ ----------------
5
+ v0.1.0 的 ``BaseApplication`` 把所有 concern(会话、检索增强、回复生成、护栏…)
6
+ 都写死在类里,用户无法"在后端选择是否使用某项功能"。本模块把应用拆成一条
7
+ **Hook 链**,每个通用能力做成一个**插件(Plugin)**,挂在链上:
8
+
9
+ - ``Message`` —— 在链上流动的结构化消息(用户/助手/系统);
10
+ - ``PluginContext`` —— 插件可调用的共享上下文(app + message + 配置 + 工具方法);
11
+ - ``BasePlugin`` —— 插件基类(所有 Hook 默认空实现,子类按需覆盖);
12
+ - ``PluginManager`` —— 按注册顺序跑 Hook 链,支持 enable/disable;
13
+ - ``BackendConfig`` —— 后端配置(dict),控制每个插件"是否启用 + 配置";
14
+ - ``build_manager`` —— 从 ``BackendConfig`` 装配出 ``PluginManager``。
15
+
16
+ 设计铁律(不破坏防腐层)
17
+ ----------------------
18
+ 1. **零依赖**:本模块与 7 个 builtin 插件都不强制任何外部包;
19
+ ``llm_responder`` 用标准库 ``urllib`` 发请求,缺包也不崩。
20
+ 2. **可发现**:除 builtin 外,支持 ``importlib.metadata`` 的 entry-point
21
+ (group = ``pasm_framework.plugins``)自动发现第三方插件。
22
+ 3. **向后兼容**:不启用任何插件时,``BaseApplication.handle`` 行为与 v0.1.0 完全一致
23
+ (能力路由 → 回落 chat)。
24
+
25
+ 两个回复阶段的区别(v0.2.1 起,别混用)
26
+ --------------------------------------
27
+ · ``on_reply`` —— **生成**阶段:谁来产出 assistant 文本(``llm_responder``)。
28
+ 只有"还没有回复"时才被期待产出内容;此时 ``msg.reply`` 通常为空。
29
+ · ``on_reply_final`` —— **收尾**阶段:对**已经定稿**的回复做后处理
30
+ (``safety`` 脱敏、``warmth`` 润色)。由 ``BaseApplication`` 在
31
+ "所有回复路径汇合之后的唯一出口"调用,因此**恰好跑一次**。
32
+
33
+ 为什么需要 ``on_reply_final``:v0.2.0 把护栏/润色挂在 ``on_reply`` 上,
34
+ 而模板兜底回复(未开 LLM 的离线路径)在 ``on_reply`` **之后**才生成,
35
+ 导致兜底回复绕过了护栏。拆出收尾阶段后,LLM 回复与模板回复走同一条出口。
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import time
40
+ from dataclasses import dataclass, field
41
+ from typing import (
42
+ Any, Callable, Dict, List, Optional, Protocol, runtime_checkable,
43
+ )
44
+
45
+
46
+ # ============================================================ 结构化消息
47
+
48
+ @dataclass
49
+ class Message:
50
+ """在插件链上流动的结构化消息。
51
+
52
+ 字段分三类:
53
+ · 输入(应用填写):``role`` / ``text`` / ``session_id`` / ``user_id`` / ``meta``;
54
+ · 插件可写的中间态:``facts``(检索到的知识) / ``reply``(助手回复) /
55
+ ``route_to``(指定走哪个能力)/ ``stop``(短路,仍走 ``on_reply_final`` 收尾)/
56
+ ``error``(安全拦截原因);
57
+ · 只读元信息:``created_at``。
58
+ """
59
+
60
+ role: str = "user" # "user" | "assistant" | "system"
61
+ text: str = ""
62
+ session_id: str = "default"
63
+ user_id: Optional[str] = None
64
+ meta: Dict[str, Any] = field(default_factory=dict)
65
+
66
+ # —— 插件可写中间态 ——
67
+ facts: List[Dict[str, Any]] = field(default_factory=list)
68
+ reply: str = ""
69
+ route_to: Optional[str] = None # 指定走某个能力名(绕过关键词匹配)
70
+ stop: bool = False # 置 True 则跳过能力路由与回复生成(如安全拦截);
71
+ # 但仍会经过 on_reply_final 收尾(护栏必须对拦截文案生效)
72
+ error: Optional[str] = None # 安全/校验失败原因
73
+
74
+ #: 流式增量出口(v0.3.0)。非 ``None`` 时,"生成"类插件可以把逐块结果实时推给调用方;
75
+ #: 同时**仍必须**把完整文本写进 ``reply`` —— 因为 ``on_reply_final`` 收尾阶段
76
+ #: 只能看到 ``reply``。二者不一致时,``BaseApplication.stream`` 会补发
77
+ #: ``replace`` 事件纠正下游(护栏才不会被流式绕过)。
78
+ stream_sink: Optional[Callable[[str], None]] = None
79
+
80
+ created_at: float = field(default_factory=time.time)
81
+
82
+ def add_fact(self, fact: Dict[str, Any]) -> None:
83
+ if isinstance(fact, dict) and fact:
84
+ self.facts.append(fact)
85
+
86
+ def set_reply(self, text: str) -> None:
87
+ if text:
88
+ self.reply = text
89
+
90
+
91
+ # ============================================================ 插件上下文
92
+
93
+ class PluginContext:
94
+ """插件在每个 Hook 调用时拿到的共享上下文。
95
+
96
+ 插件应当只通过本对象与宿主交互(不直接碰 ``BaseApplication`` 内部),
97
+ 保证解耦与可替换。
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ app: Any,
103
+ message: Message,
104
+ config: Dict[str, Any],
105
+ ) -> None:
106
+ self.app = app
107
+ self.message = message
108
+ self.config = config or {}
109
+ # 插件私有运行时存储(不持久化;需要持久化请在插件内自己落盘)。
110
+ self.store: Dict[str, Any] = {}
111
+
112
+ # —— 便捷工具(全部委托给 app,保持插件无状态)——
113
+ def recall(self, query: str, k: int = 5) -> List[Dict[str, Any]]:
114
+ return self.app.recall(query, k=k)
115
+
116
+ def observe(self, title: str, brief: str = "", tags: Optional[List[str]] = None,
117
+ *, salience: int = 1, category: str = "日常") -> None:
118
+ self.app.observe(title=title, brief=brief, tags=tags,
119
+ salience=salience, category=category)
120
+
121
+ def mood(self) -> float:
122
+ return float(getattr(self.app, "mood", 0.0) or 0.0)
123
+
124
+ def persona(self) -> Dict[str, Any]:
125
+ return dict(getattr(self.app, "persona", {}) or {})
126
+
127
+
128
+ # ============================================================ 插件契约
129
+
130
+ @runtime_checkable
131
+ class Plugin(Protocol):
132
+ """插件契约(运行时协议)。
133
+
134
+ 所有 Hook 都是**可选**的:框架用 ``hasattr`` 探测,缺失就跳过。
135
+ 这样"最小插件"只需实现它关心的那一个 Hook。
136
+ """
137
+
138
+ name: str
139
+ version: str
140
+
141
+ def on_init(self, ctx: PluginContext) -> None: ...
142
+ def on_message_in(self, ctx: PluginContext) -> None: ...
143
+ def on_retrieve(self, ctx: PluginContext) -> None: ...
144
+ def on_reply(self, ctx: PluginContext) -> None: ...
145
+ def on_reply_final(self, ctx: PluginContext) -> None: ...
146
+ def on_learn(self, ctx: PluginContext) -> None: ...
147
+ def on_shutdown(self, ctx: PluginContext) -> None: ...
148
+
149
+
150
+ class BasePlugin:
151
+ """插件基类:所有 Hook 默认空实现,子类按需覆盖。
152
+
153
+ 比 Protocol 更适合 builtin 插件(少写样板,且能放共享构造逻辑)。
154
+ """
155
+
156
+ #: 子类覆盖
157
+ name: str = "base"
158
+ version: str = "0.1.0"
159
+
160
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
161
+ self.config = dict(config or {})
162
+
163
+ # —— Hook 默认空实现(子类覆盖)——
164
+ def on_init(self, ctx: PluginContext) -> None:
165
+ pass
166
+
167
+ def on_message_in(self, ctx: PluginContext) -> None:
168
+ pass
169
+
170
+ def on_retrieve(self, ctx: PluginContext) -> None:
171
+ pass
172
+
173
+ def on_reply(self, ctx: PluginContext) -> None:
174
+ """生成阶段:产出 assistant 回复(如 LLM)。"""
175
+ pass
176
+
177
+ def on_reply_final(self, ctx: PluginContext) -> None:
178
+ """收尾阶段:对定稿回复做后处理(护栏 / 润色)。恰好跑一次。"""
179
+ pass
180
+
181
+ def on_learn(self, ctx: PluginContext) -> None:
182
+ pass
183
+
184
+ def on_shutdown(self, ctx: PluginContext) -> None:
185
+ pass
186
+
187
+ def __repr__(self) -> str: # pragma: no cover
188
+ return "<%s v%s>" % (self.name, self.version)
189
+
190
+
191
+ # ============================================================ 插件管理器
192
+
193
+ # 链上的 Hook 顺序(执行顺序即列表顺序)。
194
+ # 注意 on_reply(生成)与 on_reply_final(收尾)是**两个不同阶段**,
195
+ # 由 BaseApplication.handle 在各自的时间点分别触发,不要合并。
196
+ HOOKS: List[str] = [
197
+ "on_init",
198
+ "on_message_in",
199
+ "on_retrieve",
200
+ "on_reply",
201
+ "on_reply_final",
202
+ "on_learn",
203
+ "on_shutdown",
204
+ ]
205
+
206
+
207
+ class PluginManager:
208
+ """按注册顺序编排插件 Hook 链。
209
+
210
+ 用法:
211
+ pm = PluginManager()
212
+ pm.register(SafetyPlugin())
213
+ pm.run_hooks("on_message_in", ctx)
214
+ """
215
+
216
+ def __init__(self) -> None:
217
+ self._plugins: List[BasePlugin] = []
218
+ self._enabled: Dict[str, bool] = {}
219
+ self._bootstrapped = False
220
+ # 配置里出现、但既不是内置插件也没有 class/instance 的名字。
221
+ # 多半是拼写错误 —— 静默忽略会让人以为"插件生效了",故显式记录。
222
+ self._unknown: List[str] = []
223
+
224
+ def unknown(self) -> List[str]:
225
+ """返回"配置里有、但没匹配到任何插件"的名字(疑似拼写错误)。"""
226
+ return list(self._unknown)
227
+
228
+ def register(self, plugin: BasePlugin, *, enabled: bool = True) -> None:
229
+ if not hasattr(plugin, "name"):
230
+ raise TypeError("插件必须有 name 属性")
231
+ self._plugins.append(plugin)
232
+ self._enabled[plugin.name] = enabled
233
+
234
+ def names(self) -> List[str]:
235
+ return [p.name for p in self._plugins]
236
+
237
+ def enabled_names(self) -> List[str]:
238
+ return [p.name for p in self._plugins if self._enabled.get(p.name, True)]
239
+
240
+ def enable(self, name: str) -> None:
241
+ if name in self._enabled:
242
+ self._enabled[name] = True
243
+
244
+ def disable(self, name: str) -> None:
245
+ if name in self._enabled:
246
+ self._enabled[name] = False
247
+
248
+ def is_enabled(self, name: str) -> bool:
249
+ return self._enabled.get(name, False)
250
+
251
+ def get(self, name: str) -> Optional[BasePlugin]:
252
+ for p in self._plugins:
253
+ if p.name == name:
254
+ return p
255
+ return None
256
+
257
+ def run_hooks(self, hook: str, ctx: PluginContext) -> None:
258
+ """跑某个 Hook 的所有已启用插件。
259
+
260
+ 短路规则:若 ``ctx.message.stop`` 已在某插件被置 True,
261
+ 则后续插件的**同一批**(当前 hook 内)仍会跑完(便于多插件协作),
262
+ 但 ``BaseApplication`` 会在阶段边界检查 ``stop`` 决定是否进入下一阶段。
263
+ 各插件内部可自行判断 ``ctx.message.stop`` 选择是否提前返回。
264
+ """
265
+ if hook not in HOOKS:
266
+ raise ValueError("未知 hook: %s" % hook)
267
+ for p in self._plugins:
268
+ if not self._enabled.get(p.name, True):
269
+ continue
270
+ fn = getattr(p, hook, None)
271
+ if fn is None:
272
+ continue
273
+ try:
274
+ fn(ctx)
275
+ except Exception as ex: # noqa: BLE001
276
+ # 单个插件失败不应拖垮整条链;记录但不抛出。
277
+ # 可观测插件会捕获该异常并计入错误计数。
278
+ ctx.store.setdefault("_plugin_errors", []).append(
279
+ {"plugin": p.name, "hook": hook, "error": str(ex)}
280
+ )
281
+
282
+ def bootstrap(self, app: Any, message: Optional[Message] = None) -> None:
283
+ """应用启动时调用一次:跑所有 on_init。
284
+
285
+ ``message`` 可为空(初始化阶段还没有具体消息),
286
+ 插件应容忍 ``ctx.message`` 为占位对象。
287
+ """
288
+ if self._bootstrapped:
289
+ return
290
+ msg = message or Message(role="system", text="")
291
+ for p in self._plugins:
292
+ if not self._enabled.get(p.name, True):
293
+ continue
294
+ try:
295
+ p.on_init(PluginContext(app, msg, p.config))
296
+ except Exception: # noqa: BLE001
297
+ pass
298
+ self._bootstrapped = True
299
+
300
+ def shutdown(self, app: Any) -> None:
301
+ msg = Message(role="system", text="")
302
+ for p in self._plugins:
303
+ if not self._enabled.get(p.name, True):
304
+ continue
305
+ try:
306
+ p.on_shutdown(PluginContext(app, msg, p.config))
307
+ except Exception: # noqa: BLE001
308
+ pass
309
+
310
+
311
+ # ============================================================ 后端配置
312
+
313
+ @dataclass
314
+ class BackendConfig:
315
+ """后端配置(即"用户可在后端选择是否使用"的开关表)。
316
+
317
+ ``plugins`` 是一个 dict:键是插件名,值是
318
+ ``{"enabled": bool, "config": {...}}``。未列出的插件走 ``defaults``。
319
+ """
320
+
321
+ plugins: Dict[str, Dict[str, Any]] = field(default_factory=dict)
322
+ defaults: Dict[str, Dict[str, Any]] = field(default_factory=dict)
323
+
324
+ def entry(self, name: str) -> Dict[str, Any]:
325
+ if name in self.plugins:
326
+ return self.plugins[name]
327
+ return self.defaults.get(name, {"enabled": False, "config": {}})
328
+
329
+ def enabled(self, name: str) -> bool:
330
+ return bool(self.entry(name).get("enabled", False))
331
+
332
+ def config_of(self, name: str) -> Dict[str, Any]:
333
+ return dict(self.entry(name).get("config", {}) or {})
334
+
335
+
336
+ def build_manager(
337
+ config: Optional[BackendConfig] = None,
338
+ *,
339
+ include_builtins: bool = True,
340
+ discover_entrypoints: bool = True,
341
+ ) -> PluginManager:
342
+ """从 ``BackendConfig`` 装配出 ``PluginManager``。
343
+
344
+ 三条来源,按顺序:
345
+ 1. **内置插件** —— :func:`pasm_framework.plugins.registry.builtin_plugins`;
346
+ 2. **entry-point 插件** —— group=``pasm_framework.plugins`` 自动发现;
347
+ 3. **内联自定义插件** —— 配置项里写 ``class`` 或 ``instance``:
348
+
349
+ .. code-block:: python
350
+
351
+ backend_config = {
352
+ "my_rule": {"enabled": True, "class": MyRulePlugin},
353
+ "knowledge_base": {"enabled": True, "config": {...}},
354
+ }
355
+
356
+ 这样用户不发布包也能接入自己的插件(框架是"通用底层",必须留这个口)。
357
+
358
+ 若 ``config`` 为 None,使用 :func:`default_config` 的默认开关。
359
+
360
+ 配置里出现但没有任何插件匹配的名字,会记进 ``pm.unknown()`` 而不是被
361
+ 静默丢弃 —— 拼错插件名是最常见的"以为开了其实没开"来源。
362
+ """
363
+ from .registry import builtin_plugins, discover_plugins, default_config
364
+
365
+ # 宽容:允许直接传 dict(与 BaseApplication(backend_config={...}) 保持一致)。
366
+ if config is not None and not isinstance(config, BackendConfig):
367
+ config = BackendConfig(plugins=dict(config))
368
+ cfg = config or default_config()
369
+ pm = PluginManager()
370
+
371
+ if include_builtins:
372
+ for name, plugin_cls in builtin_plugins().items():
373
+ entry = cfg.entry(name)
374
+ enabled = entry.get("enabled", False)
375
+ plugin = plugin_cls(config=entry.get("config", {}))
376
+ pm.register(plugin, enabled=enabled)
377
+
378
+ if discover_entrypoints:
379
+ for plugin in discover_plugins():
380
+ name = getattr(plugin, "name", None)
381
+ if name is None:
382
+ continue
383
+ if pm.get(name) is not None: # 同名的内置插件优先,避免重复挂链
384
+ continue
385
+ entry = cfg.entry(name)
386
+ enabled = entry.get("enabled", False)
387
+ pm.register(plugin, enabled=enabled)
388
+
389
+ # ---- 内联自定义插件 ----
390
+ for name, raw in cfg.plugins.items():
391
+ if pm.get(name) is not None:
392
+ continue
393
+ spec = raw if isinstance(raw, dict) else {}
394
+ obj = spec.get("instance")
395
+ if obj is None:
396
+ cls = spec.get("class")
397
+ if cls is None:
398
+ pm._unknown.append(name) # 既不是内置、也没给类 → 疑似拼错
399
+ continue
400
+ try:
401
+ obj = cls(config=spec.get("config", {}))
402
+ except TypeError:
403
+ # 宽容:也接受只收一个位置参数的构造函数。
404
+ obj = cls(spec.get("config", {}))
405
+ if not isinstance(obj, BasePlugin):
406
+ pm._unknown.append(name)
407
+ continue
408
+ # 允许配置名与插件自带 name 不同(便于同一插件多实例)。
409
+ if isinstance(raw, dict) and raw.get("as"):
410
+ obj.name = str(raw["as"])
411
+ pm.register(obj, enabled=bool(spec.get("enabled", True)))
412
+
413
+ return pm
414
+
415
+
416
+ def default_config() -> BackendConfig:
417
+ """默认后端配置:安全 / 会话 / 知识库 / 温度 / 可观测默认开;
418
+ LLM 响应器与 Web 网关默认关(需显式开启,因为要密钥 / 端口)。"""
419
+ from .registry import default_config as _dc
420
+ return _dc()
@@ -0,0 +1,80 @@
1
+ """插件注册表 + 默认配置 + 第三方插件发现。
2
+
3
+ - :func:`builtin_plugins` —— 内置 7 个插件的 name→class 映射;
4
+ - :func:`default_config` —— 默认后端开关(安全/会话/知识库/温度/可观测开,
5
+ LLM/网关关);
6
+ - :func:`discover_plugins` —— 通过 ``importlib.metadata`` 的 entry-point
7
+ (group=``pasm_framework.plugins``)自动发现第三方插件。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, List, Type
12
+
13
+ from .core import BackendConfig, BasePlugin
14
+
15
+ # 默认开关:哪些是"开箱即用的安全/基础能力",哪些需要用户显式开启。
16
+ _DEFAULT_ENABLED = {
17
+ "safety": True,
18
+ "sessions": True,
19
+ "knowledge_base": True,
20
+ "warmth": True,
21
+ "observability": True,
22
+ "llm_responder": False, # 需要密钥 / 网络
23
+ "web_gateway": False, # 需要端口
24
+ }
25
+
26
+
27
+ def builtin_plugins() -> Dict[str, Type[BasePlugin]]:
28
+ """返回内置插件 name→类 映射。"""
29
+ from .builtins import ( # 延迟导入,避免与 core 的早期循环
30
+ KnowledgeBasePlugin, LLMResponderPlugin, ObservabilityPlugin,
31
+ SafetyPlugin, SessionsPlugin, WarmthPlugin, WebGatewayPlugin,
32
+ )
33
+ return {
34
+ "sessions": SessionsPlugin,
35
+ "knowledge_base": KnowledgeBasePlugin,
36
+ "llm_responder": LLMResponderPlugin,
37
+ "warmth": WarmthPlugin,
38
+ "safety": SafetyPlugin,
39
+ "observability": ObservabilityPlugin,
40
+ "web_gateway": WebGatewayPlugin,
41
+ }
42
+
43
+
44
+ def default_config() -> BackendConfig:
45
+ """默认后端配置:基础/安全能力默认开,LLM/网关默认关。"""
46
+ plugins: Dict[str, Dict[str, object]] = {}
47
+ for name, enabled in _DEFAULT_ENABLED.items():
48
+ plugins[name] = {"enabled": enabled, "config": {}}
49
+ return BackendConfig(plugins=plugins)
50
+
51
+
52
+ def discover_plugins() -> List[BasePlugin]:
53
+ """通过 entry-point 发现第三方插件(group=``pasm_framework.plugins``)。
54
+
55
+ 第三方包在 ``pyproject`` 里声明:
56
+ [project.entry-points."pasm_framework.plugins"]
57
+ my_plugin = "my_pkg.my_module:MyPlugin"
58
+ 其中 ``MyPlugin`` 是 ``BasePlugin`` 的实例或可调用返回实例。
59
+ """
60
+ found: List[BasePlugin] = []
61
+ try:
62
+ from importlib.metadata import entry_points
63
+ except Exception: # pragma: no cover
64
+ return found
65
+ try:
66
+ eps = entry_points()
67
+ group = eps.select(group="pasm_framework.plugins") if hasattr(eps, "select") \
68
+ else eps.get("pasm_framework.plugins", [])
69
+ except Exception:
70
+ return found
71
+ for ep in group:
72
+ try:
73
+ obj = ep.load()
74
+ inst = obj() if isinstance(obj, type) else obj
75
+ if isinstance(inst, BasePlugin):
76
+ found.append(inst)
77
+ except Exception:
78
+ # 第三方插件加载失败不应影响框架启动。
79
+ continue
80
+ return found
File without changes