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.
- pasm_framework/__init__.py +509 -0
- pasm_framework/__main__.py +282 -0
- pasm_framework/adapter.py +80 -0
- pasm_framework/application.py +426 -0
- pasm_framework/config.py +319 -0
- pasm_framework/demo.py +85 -0
- pasm_framework/discovery.py +91 -0
- pasm_framework/errors.py +26 -0
- pasm_framework/plugins/__init__.py +42 -0
- pasm_framework/plugins/builtins/__init__.py +30 -0
- pasm_framework/plugins/builtins/knowledge_base.py +319 -0
- pasm_framework/plugins/builtins/llm_responder.py +443 -0
- pasm_framework/plugins/builtins/observability.py +64 -0
- pasm_framework/plugins/builtins/safety.py +89 -0
- pasm_framework/plugins/builtins/sessions.py +82 -0
- pasm_framework/plugins/builtins/warmth.py +97 -0
- pasm_framework/plugins/builtins/web_gateway.py +457 -0
- pasm_framework/plugins/core.py +420 -0
- pasm_framework/plugins/registry.py +80 -0
- pasm_framework/py.typed +0 -0
- pasm_framework/scaffold.py +292 -0
- pasm_framework/service.py +154 -0
- pasm_framework/simple.py +174 -0
- pasm_framework/skill.py +77 -0
- pasm_framework-0.3.0.dist-info/METADATA +281 -0
- pasm_framework-0.3.0.dist-info/RECORD +30 -0
- pasm_framework-0.3.0.dist-info/WHEEL +5 -0
- pasm_framework-0.3.0.dist-info/entry_points.txt +2 -0
- pasm_framework-0.3.0.dist-info/licenses/LICENSE +21 -0
- pasm_framework-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""BaseApplication —— 通用 AI 应用底座(建立在 BaseAgent + 框架之上)。
|
|
2
|
+
|
|
3
|
+
定位
|
|
4
|
+
----
|
|
5
|
+
``BaseAgent``(sdk)解决"一个智能体如何记忆/情绪/动作/反馈"。
|
|
6
|
+
``BaseApplication`` 在它之上补上"一个**应用**"才有的 concerns:
|
|
7
|
+
|
|
8
|
+
- 用 ``CognitiveAssembler`` 装配认知后端(V1↔V2 唯一变动点,对子类透明);
|
|
9
|
+
- 用 ``DomainAdapter`` 注入领域知识(NPC/Companion/Tutor 各自实现);
|
|
10
|
+
- 用 ``CapabilityDiscovery`` 做统一的能力路由(消灭散落的 ``if chip==`` 分支);
|
|
11
|
+
- 提供 ``handle()`` 统一入口:先能力路由,再回落 ``chat``。
|
|
12
|
+
|
|
13
|
+
迁移路径(V2.0 时)
|
|
14
|
+
-------------------
|
|
15
|
+
当前 4 个产品智能体直接 ``class X(BaseAgent)``。V2.0 把它们改成
|
|
16
|
+
``class X(BaseApplication)``,并实现 ``DomainAdapter`` + 报到 ``Capability``,
|
|
17
|
+
即可获得"换引擎零改动 + 能力可注册 + 领域可替换"三件套。
|
|
18
|
+
这一步是**一次性的 V2 迁移**,不是"每次引擎改动都要重写的副本"。
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import queue
|
|
23
|
+
import threading
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Union
|
|
26
|
+
|
|
27
|
+
from pasm_skills.sdk.base import BaseAgent
|
|
28
|
+
from pasm_skills.sdk.backend import CognitiveBackend
|
|
29
|
+
from .adapter import DomainAdapter, NullDomainAdapter
|
|
30
|
+
from .discovery import Capability, CapabilityDiscovery
|
|
31
|
+
from .service import CognitiveAssembler
|
|
32
|
+
from .plugins.core import (
|
|
33
|
+
BackendConfig, Message, PluginContext, PluginManager, build_manager,
|
|
34
|
+
)
|
|
35
|
+
from pasm_skills.sdk.backend import _now # 复用基座时钟(与 BaseAgent 一致)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BaseApplication(BaseAgent):
|
|
39
|
+
"""通用 AI 应用底座。
|
|
40
|
+
|
|
41
|
+
参数
|
|
42
|
+
----
|
|
43
|
+
domain : DomainAdapter | None
|
|
44
|
+
领域适配器(NPC/Companion/Tutor 各提供一个);``None`` 走 ``NullDomainAdapter``。
|
|
45
|
+
capabilities : list[Capability] | None
|
|
46
|
+
该应用暴露的能力列表,启动时报到到 ``CapabilityDiscovery``。
|
|
47
|
+
backend : CognitiveBackend | None
|
|
48
|
+
注入式后端(测试 / V2 都用它);缺省由 ``CognitiveAssembler.v1`` 自动装配。
|
|
49
|
+
plugins : PluginManager | None
|
|
50
|
+
插件管理器(Hook 链)。缺省按 ``backend_config`` / 默认开关装配。
|
|
51
|
+
backend_config : dict | BackendConfig | None
|
|
52
|
+
**后端可选开关表**:控制哪些内置插件启用及其配置(即"用户可在后端选择是否使用")。
|
|
53
|
+
形如 ``{"knowledge_base": {"enabled": True, "config": {...}}, ...}``。
|
|
54
|
+
传入后覆盖默认装配;与 ``plugins`` 二选一,``backend_config`` 优先。
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
agent_id: str,
|
|
60
|
+
persona: Dict[str, Any],
|
|
61
|
+
*,
|
|
62
|
+
domain: Optional[DomainAdapter] = None,
|
|
63
|
+
capabilities: Optional[List[Capability]] = None,
|
|
64
|
+
persist_dir: Optional[str | Path] = None,
|
|
65
|
+
use_core: bool = True,
|
|
66
|
+
backend: Optional[CognitiveBackend] = None,
|
|
67
|
+
plugins: Optional[PluginManager] = None,
|
|
68
|
+
backend_config: Optional[Union[Dict[str, Any], BackendConfig]] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.domain: DomainAdapter = domain or NullDomainAdapter()
|
|
71
|
+
self.capabilities = CapabilityDiscovery(capabilities or [])
|
|
72
|
+
|
|
73
|
+
# 装配认知后端:注入优先,否则走 V1 默认路径(V2 时只需把这里换成 .v2)。
|
|
74
|
+
if backend is None:
|
|
75
|
+
backend = CognitiveAssembler.v1(
|
|
76
|
+
Path(persist_dir) if persist_dir is not None
|
|
77
|
+
else (Path.home() / ".pasm-agents" / agent_id),
|
|
78
|
+
persona, use_core=use_core, domain=self.domain,
|
|
79
|
+
)
|
|
80
|
+
super().__init__(
|
|
81
|
+
agent_id, persona, persist_dir=persist_dir,
|
|
82
|
+
use_core=use_core, backend=backend,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# ---- 插件子系统(即插即用)----
|
|
86
|
+
if backend_config is not None:
|
|
87
|
+
cfg = backend_config if isinstance(backend_config, BackendConfig) \
|
|
88
|
+
else BackendConfig(plugins=dict(backend_config))
|
|
89
|
+
self.plugins: PluginManager = build_manager(cfg)
|
|
90
|
+
elif plugins is not None:
|
|
91
|
+
self.plugins = plugins
|
|
92
|
+
else:
|
|
93
|
+
self.plugins = build_manager(None)
|
|
94
|
+
# 启动钩子:各插件 on_init(如 web_gateway 绑定 app 引用)。
|
|
95
|
+
self.plugins.bootstrap(self)
|
|
96
|
+
|
|
97
|
+
# ---- 领域 / 知识库增强检索 ---------------------------------
|
|
98
|
+
def recall(self, query: str, k: int = 5) -> List[Dict[str, Any]]:
|
|
99
|
+
"""跨层检索:引擎记忆 + 领域适配器 + 知识库插件。
|
|
100
|
+
|
|
101
|
+
为什么要把知识库插件也并进来:能力(``Capability``)与领域代码里
|
|
102
|
+
很自然会写 ``self.recall(query)``,但插件检索原本只在 ``handle`` 的
|
|
103
|
+
``on_retrieve`` 阶段发生 —— 于是"在能力里查资料库"总是查不到。
|
|
104
|
+
这里统一合并,让 ``self.recall()`` 在任何上下文都看得到全部知识。
|
|
105
|
+
|
|
106
|
+
去重按 ``title``;凡"有依据的知识"(领域 / 知识库)都带 ``source``,
|
|
107
|
+
纯对话记忆(episodic)不带 —— 应用可据此区分"资料"与"闲聊"。
|
|
108
|
+
"""
|
|
109
|
+
hits = super().recall(query, k=k)
|
|
110
|
+
seen = {h.get("title") for h in hits}
|
|
111
|
+
|
|
112
|
+
if hasattr(self.domain, "knowledge_for"):
|
|
113
|
+
try:
|
|
114
|
+
dom = self.domain.knowledge_for(query) # type: ignore[attr-defined]
|
|
115
|
+
for item in (dom or []):
|
|
116
|
+
if item.get("title") in seen:
|
|
117
|
+
continue
|
|
118
|
+
it = dict(item)
|
|
119
|
+
it.setdefault("source",
|
|
120
|
+
"domain:%s" % type(self.domain).__name__)
|
|
121
|
+
hits.append(it)
|
|
122
|
+
seen.add(it.get("title"))
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
# 知识库插件:注意用插件自己的 recall(不是本方法),避免递归。
|
|
127
|
+
plugins = getattr(self, "plugins", None)
|
|
128
|
+
if plugins is not None:
|
|
129
|
+
kb = plugins.get("knowledge_base")
|
|
130
|
+
if kb is not None and plugins.is_enabled("knowledge_base"):
|
|
131
|
+
try:
|
|
132
|
+
for item in kb.recall(query, k=k):
|
|
133
|
+
if item.get("title") in seen:
|
|
134
|
+
continue
|
|
135
|
+
hits.append(item)
|
|
136
|
+
seen.add(item.get("title"))
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
return hits[:k] if k else hits
|
|
141
|
+
|
|
142
|
+
# ---- 对话(覆盖基类,支持注入已检索事实)-----------------
|
|
143
|
+
def chat(self, text: str, facts: Optional[List[Dict[str, Any]]] = None) -> str:
|
|
144
|
+
"""与基类同语义,但允许调用方传入已合并的检索事实(如插件检索到的资料库)。
|
|
145
|
+
|
|
146
|
+
不传时与 ``BaseAgent.chat`` 完全一致(离线模板回复 + 写入记忆)。
|
|
147
|
+
"""
|
|
148
|
+
f = facts if facts is not None else self.recall(text, k=3)
|
|
149
|
+
mood = self.mood
|
|
150
|
+
self.state.total_interactions += 1
|
|
151
|
+
self.state.last_active = _now()
|
|
152
|
+
self.observe(
|
|
153
|
+
title="对话:%s" % text[:24],
|
|
154
|
+
brief=text, tags=[text[:4]] if text else [],
|
|
155
|
+
salience=2, category="对话",
|
|
156
|
+
)
|
|
157
|
+
# 位置传参(而非 text=/facts=/mood=):基座 sdk 用的是关键字传参,
|
|
158
|
+
# 于是子类必须把参数名一字不差地写成 text/facts/mood,否则运行时
|
|
159
|
+
# 才炸 TypeError —— 对使用者太不友好。这里放宽为按位置传,
|
|
160
|
+
# 任何参数名都能工作(签名顺序仍是 text, facts, mood)。
|
|
161
|
+
return self._render_reply(text, f, mood)
|
|
162
|
+
|
|
163
|
+
# ---- 统一入口:插件链 + 能力路由 → 回落 chat --------------
|
|
164
|
+
def _run(
|
|
165
|
+
self,
|
|
166
|
+
text: str,
|
|
167
|
+
*,
|
|
168
|
+
session_id: str = "default",
|
|
169
|
+
user_id: Optional[str] = None,
|
|
170
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
171
|
+
stream_sink: Optional[Callable[[str], None]] = None,
|
|
172
|
+
) -> Message:
|
|
173
|
+
"""跑完整插件管线并返回终态 ``Message``(``handle`` / ``stream`` 共用)。
|
|
174
|
+
|
|
175
|
+
抽出来的理由:``handle``(一次性返回)与 ``stream``(流式返回)
|
|
176
|
+
必须是**同一条路径** —— 否则"唯一出口"承诺会在流式上失效
|
|
177
|
+
(护栏只覆盖其中一条)。
|
|
178
|
+
|
|
179
|
+
管线:
|
|
180
|
+
1. ``on_message_in`` 安全扫描 / 会话绑定 / 语言;``stop`` 则跳过生成;
|
|
181
|
+
2. 能力路由:命中 ``Capability`` → 执行(动作类优先);
|
|
182
|
+
3. ``on_retrieve`` 插件贡献资料(如知识库)→ 合并进 ``msg.facts``;
|
|
183
|
+
4. 回复生成:能力未命中时跑 ``on_reply``(LLM)→ 否则回落 ``chat``;
|
|
184
|
+
4b. ``on_reply_final`` 收尾(护栏脱敏 / 温度润色)—— **唯一出口**;
|
|
185
|
+
5. ``on_learn`` 插件自学习(知识库沉淀 / 会话落地 / 反馈)。
|
|
186
|
+
|
|
187
|
+
「唯一出口」的含义:无论回复来自能力 / LLM / 模板兜底 / 被拦截,
|
|
188
|
+
出站前都必然经过 ``on_reply_final``。护栏因此不可能被某条路径绕过。
|
|
189
|
+
"""
|
|
190
|
+
msg = Message(role="user", text=text, session_id=session_id,
|
|
191
|
+
user_id=user_id, meta=meta or {})
|
|
192
|
+
if stream_sink is not None:
|
|
193
|
+
msg.stream_sink = stream_sink
|
|
194
|
+
ctx = PluginContext(self, msg, {})
|
|
195
|
+
|
|
196
|
+
# 1. 入站预处理
|
|
197
|
+
self.plugins.run_hooks("on_message_in", ctx)
|
|
198
|
+
if msg.stop:
|
|
199
|
+
# 被拦截也要走收尾阶段 —— 否则"唯一出口"承诺不成立:
|
|
200
|
+
# 某个插件塞进 msg.reply 的文本(可能含 PII)会绕过脱敏。
|
|
201
|
+
if not msg.reply:
|
|
202
|
+
msg.reply = "(已被安全策略拦截)"
|
|
203
|
+
self.plugins.run_hooks("on_reply_final", ctx)
|
|
204
|
+
return msg
|
|
205
|
+
|
|
206
|
+
# 2. 能力路由(动作类显式触发优先)
|
|
207
|
+
cap = None
|
|
208
|
+
if msg.route_to:
|
|
209
|
+
cap = self.capabilities.get(msg.route_to)
|
|
210
|
+
else:
|
|
211
|
+
cap = self.capabilities.match(text)
|
|
212
|
+
|
|
213
|
+
# 3. 检索增强:插件贡献事实
|
|
214
|
+
self.plugins.run_hooks("on_retrieve", ctx)
|
|
215
|
+
base_facts = self.recall(text, k=5)
|
|
216
|
+
seen = {f.get("title") for f in base_facts}
|
|
217
|
+
for f in msg.facts:
|
|
218
|
+
if f.get("title") not in seen:
|
|
219
|
+
base_facts.append(f)
|
|
220
|
+
seen.add(f.get("title"))
|
|
221
|
+
msg.facts = base_facts
|
|
222
|
+
|
|
223
|
+
# 4. 回复生成
|
|
224
|
+
if cap is not None:
|
|
225
|
+
try:
|
|
226
|
+
reply = cap.run(self, text)
|
|
227
|
+
if reply:
|
|
228
|
+
# 标记来源:能力产出的是"确定性结果"(可能是一段精确文本、
|
|
229
|
+
# 一个链接、一段结构化内容),风格类插件(如 warmth)默认
|
|
230
|
+
# 不应去改写它;而护栏类插件(如 safety 脱敏)仍然必须生效。
|
|
231
|
+
msg.meta["generated_by"] = "capability:%s" % cap.name
|
|
232
|
+
except Exception:
|
|
233
|
+
reply = ""
|
|
234
|
+
else:
|
|
235
|
+
reply = ""
|
|
236
|
+
if not reply:
|
|
237
|
+
# 生成阶段:让 on_reply 插件(LLM 响应器)产出内容。
|
|
238
|
+
self.plugins.run_hooks("on_reply", ctx)
|
|
239
|
+
reply = msg.reply or ""
|
|
240
|
+
if not reply:
|
|
241
|
+
# 仍无生成来源 → 模板兜底。
|
|
242
|
+
# 没有"有依据的知识"(仅闲聊记忆不算)时标记"答不上来",
|
|
243
|
+
# 供 knowledge_base 自学习时跳过(不沉淀垃圾)。
|
|
244
|
+
if not any(f.get("source") for f in base_facts):
|
|
245
|
+
msg.meta["no_answer"] = True
|
|
246
|
+
reply = self.chat(text, facts=base_facts)
|
|
247
|
+
msg.reply = reply
|
|
248
|
+
|
|
249
|
+
# 4b. 收尾阶段(唯一出口):护栏脱敏 / 温度润色对**所有**路径生效。
|
|
250
|
+
# 注意:必须放在模板兜底**之后**,否则离线路径会绕过护栏。
|
|
251
|
+
self.plugins.run_hooks("on_reply_final", ctx)
|
|
252
|
+
reply = msg.reply or reply
|
|
253
|
+
|
|
254
|
+
# 5. 学习 / 落地
|
|
255
|
+
self.plugins.run_hooks("on_learn", ctx)
|
|
256
|
+
return msg
|
|
257
|
+
|
|
258
|
+
def handle(
|
|
259
|
+
self,
|
|
260
|
+
text: str,
|
|
261
|
+
*,
|
|
262
|
+
session_id: str = "default",
|
|
263
|
+
user_id: Optional[str] = None,
|
|
264
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
265
|
+
) -> str:
|
|
266
|
+
"""处理用户输入的统一入口(一次性返回完整回复)。
|
|
267
|
+
|
|
268
|
+
不启用任何插件时,行为与 v0.1.0 完全一致(能力路由 → 回落 chat)。
|
|
269
|
+
需要逐字上屏请用 :meth:`stream`。
|
|
270
|
+
"""
|
|
271
|
+
return self._run(text, session_id=session_id, user_id=user_id,
|
|
272
|
+
meta=meta).reply or ""
|
|
273
|
+
|
|
274
|
+
# ---- 流式入口(SSE 数据源)---------------------------------
|
|
275
|
+
def stream(
|
|
276
|
+
self,
|
|
277
|
+
text: str,
|
|
278
|
+
*,
|
|
279
|
+
session_id: str = "default",
|
|
280
|
+
user_id: Optional[str] = None,
|
|
281
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
282
|
+
) -> Iterator[Dict[str, Any]]:
|
|
283
|
+
"""流式处理:产出事件字典,供 SSE / WebSocket 逐条下发。
|
|
284
|
+
|
|
285
|
+
事件类型
|
|
286
|
+
--------
|
|
287
|
+
· ``{"type": "delta", "text": "..."}`` 增量片段(可直接追加显示)
|
|
288
|
+
· ``{"type": "replace", "text": "全文"}`` 护栏/润色改写过内容时下发,
|
|
289
|
+
客户端应**替换**整条回复
|
|
290
|
+
· ``{"type": "done", "session_id": ..., "chars": N}``
|
|
291
|
+
· ``{"type": "error", "error": "..."}``
|
|
292
|
+
|
|
293
|
+
为什么需要 ``replace``:流式是在**生成中**把片段推给客户端的,
|
|
294
|
+
而护栏(``on_reply_final``)在**生成后**才跑。若收尾阶段改写了内容
|
|
295
|
+
(例如脱敏),已经流出去的片段就不等于最终文本 —— 此时补发
|
|
296
|
+
``replace`` 让客户端纠正,护栏因此**不会被流式绕过**。
|
|
297
|
+
|
|
298
|
+
管线与 :meth:`handle` **完全相同**(同一个 ``_run``),
|
|
299
|
+
区别只是多了 ``stream_sink``,让生成类插件(如 LLM)能逐块外推。
|
|
300
|
+
"""
|
|
301
|
+
q: "queue.Queue" = queue.Queue()
|
|
302
|
+
_END = object()
|
|
303
|
+
box: Dict[str, Any] = {}
|
|
304
|
+
|
|
305
|
+
def sink(chunk: str) -> None:
|
|
306
|
+
if chunk:
|
|
307
|
+
q.put(chunk)
|
|
308
|
+
|
|
309
|
+
def work() -> None:
|
|
310
|
+
try:
|
|
311
|
+
box["msg"] = self._run(
|
|
312
|
+
text, session_id=session_id, user_id=user_id,
|
|
313
|
+
meta=meta, stream_sink=sink,
|
|
314
|
+
)
|
|
315
|
+
except Exception as ex: # noqa: BLE001
|
|
316
|
+
box["error"] = str(ex)
|
|
317
|
+
finally:
|
|
318
|
+
q.put(_END)
|
|
319
|
+
|
|
320
|
+
worker = threading.Thread(target=work, daemon=True)
|
|
321
|
+
worker.start()
|
|
322
|
+
|
|
323
|
+
streamed: List[str] = []
|
|
324
|
+
while True:
|
|
325
|
+
item = q.get()
|
|
326
|
+
if item is _END:
|
|
327
|
+
break
|
|
328
|
+
streamed.append(item)
|
|
329
|
+
yield {"type": "delta", "text": item}
|
|
330
|
+
|
|
331
|
+
if "error" in box:
|
|
332
|
+
yield {"type": "error", "error": box["error"]}
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
msg: Message = box["msg"]
|
|
336
|
+
final = msg.reply or ""
|
|
337
|
+
acc = "".join(streamed)
|
|
338
|
+
if acc and final != acc:
|
|
339
|
+
# 收尾阶段改写了内容(脱敏/润色)→ 让客户端整条替换。
|
|
340
|
+
yield {"type": "replace", "text": final}
|
|
341
|
+
elif not acc and final:
|
|
342
|
+
# 没走流式(能力命中 / 模板兜底 / 被拦截)→ 整块下发。
|
|
343
|
+
yield {"type": "delta", "text": final}
|
|
344
|
+
yield {"type": "done", "session_id": msg.session_id, "chars": len(final)}
|
|
345
|
+
|
|
346
|
+
# ---- 知识摄取(资料库)------------------------------------
|
|
347
|
+
def ingest(self, items: List[Dict[str, Any]]) -> int:
|
|
348
|
+
"""把资料写进知识库,返回新增条数(需启用 ``knowledge_base`` 插件)。
|
|
349
|
+
|
|
350
|
+
这是**应用级统一入口**,与 REST 的 ``POST /api/ingest`` 同名同义:
|
|
351
|
+
站点 FAQ / 产品文档 / 历史工单都从这里进来,形成资料库并参与检索与
|
|
352
|
+
「自学」(``on_learn`` 会把优质问答回沉)。
|
|
353
|
+
|
|
354
|
+
``SimpleApplication.teach`` 与 ``CustomerServiceAgent.ingest_faq``
|
|
355
|
+
都是本方法的别名 —— 三个名字一个含义,统一先认 ``ingest``。
|
|
356
|
+
|
|
357
|
+
未启用知识库时**抛错而不是静默返回 0**:否则「我明明喂了资料,为什么
|
|
358
|
+
答不上来」会成为最难查的一类问题。
|
|
359
|
+
"""
|
|
360
|
+
kb = self.plugins.get("knowledge_base")
|
|
361
|
+
if kb is None or not self.plugins.is_enabled("knowledge_base"):
|
|
362
|
+
from .errors import FrameworkError
|
|
363
|
+
raise FrameworkError(
|
|
364
|
+
"ingest() 需要启用 'knowledge_base' 插件:backend_config 里设 "
|
|
365
|
+
"{'knowledge_base': {'enabled': True, 'config': {'kb_dir': '...'}}}"
|
|
366
|
+
)
|
|
367
|
+
return kb.ingest(items)
|
|
368
|
+
|
|
369
|
+
# ---- 插件指标 / 健康 --------------------------------------
|
|
370
|
+
def plugin_metrics(self) -> Dict[str, Any]:
|
|
371
|
+
out: Dict[str, Any] = {
|
|
372
|
+
"enabled": self.plugins.enabled_names(),
|
|
373
|
+
"all": self.plugins.names(),
|
|
374
|
+
}
|
|
375
|
+
obs = self.plugins.get("observability")
|
|
376
|
+
if obs is not None and self.plugins.is_enabled("observability"):
|
|
377
|
+
try:
|
|
378
|
+
out["observability"] = obs.metrics()
|
|
379
|
+
except Exception:
|
|
380
|
+
pass
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
# ---- 对外服务(Web 网关)----------------------------------
|
|
384
|
+
def serve(self, host: Optional[str] = None, port: Optional[int] = None,
|
|
385
|
+
**kw) -> None:
|
|
386
|
+
"""启动 HTTP 网关(需已启用 ``web_gateway`` 插件)。
|
|
387
|
+
|
|
388
|
+
站点可用 ``<iframe src="http://<host>:<port>/">`` 嵌入智能客服,
|
|
389
|
+
或用 ``POST /api/chat`` 对接。未启用网关插件时抛 ``FrameworkError`` 指引。
|
|
390
|
+
|
|
391
|
+
``host`` / ``port`` 缺省为 ``None`` —— 表示"用插件配置里的值"
|
|
392
|
+
(即 ``backend_config["web_gateway"]["config"]``)。只有在显式传入时
|
|
393
|
+
才覆盖配置,避免"配置里写了 9000,``serve()`` 却起了 8080"的困惑。
|
|
394
|
+
"""
|
|
395
|
+
gw = self.plugins.get("web_gateway")
|
|
396
|
+
if gw is None or not self.plugins.is_enabled("web_gateway"):
|
|
397
|
+
from .errors import FrameworkError
|
|
398
|
+
raise FrameworkError(
|
|
399
|
+
"serve() 需要启用 'web_gateway' 插件:"
|
|
400
|
+
"在 backend_config 中设置 {'web_gateway': {'enabled': True}} 后重试。"
|
|
401
|
+
)
|
|
402
|
+
gw.start(self, host=host, port=port, **kw)
|
|
403
|
+
|
|
404
|
+
def close(self) -> None:
|
|
405
|
+
"""优雅关闭:跑插件 on_shutdown(如停掉网关线程)。"""
|
|
406
|
+
try:
|
|
407
|
+
self.plugins.shutdown(self)
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
try:
|
|
411
|
+
self.save()
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
# ---- 应用级快照(给 API / 调试用) ------------------------
|
|
416
|
+
def app_summary(self) -> Dict[str, Any]:
|
|
417
|
+
s = self.summary()
|
|
418
|
+
s["domain"] = type(self.domain).__name__
|
|
419
|
+
s["capabilities"] = self.capabilities.names()
|
|
420
|
+
s["plugins"] = self.plugins.enabled_names()
|
|
421
|
+
# 配置里写了但没匹配到插件的名字(多半拼错)—— 暴露出来,别让它静默。
|
|
422
|
+
unknown = self.plugins.unknown()
|
|
423
|
+
if unknown:
|
|
424
|
+
s["plugin_config_unknown"] = unknown
|
|
425
|
+
s["plugin_metrics"] = self.plugin_metrics()
|
|
426
|
+
return s
|