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,292 @@
|
|
|
1
|
+
"""项目脚手架 —— ``pasm-framework new`` 的实现。
|
|
2
|
+
|
|
3
|
+
目标:从"读文档 20 分钟"变成"一条命令拿到能跑的项目"。
|
|
4
|
+
生成的内容刻意保持**零第三方依赖**(只依赖 pasm-framework 本身),
|
|
5
|
+
所以 ``pip install pasm-framework`` 之后立刻就能 ``python main.py``。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List, Tuple
|
|
11
|
+
|
|
12
|
+
from .errors import FrameworkError
|
|
13
|
+
|
|
14
|
+
KINDS = ("app", "chatbot", "game_npc")
|
|
15
|
+
|
|
16
|
+
_MAIN_APP = '''"""最小 AI 应用 —— pasm-framework 脚手架生成。
|
|
17
|
+
|
|
18
|
+
跑起来:python main.py (离线,用内置知识库+模板回复)
|
|
19
|
+
接 LLM:PASM_LLM_API_KEY=sk-xxx PASM_LLM_PROVIDER=deepseek python main.py
|
|
20
|
+
"""
|
|
21
|
+
from pasm_framework import SimpleApplication, capability, load
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MyApp(SimpleApplication):
|
|
25
|
+
"""把每个能力写成一个方法,用 @capability 声明触发词。"""
|
|
26
|
+
|
|
27
|
+
@capability(keywords=("帮助", "help"), description="显示帮助")
|
|
28
|
+
def help(self, text):
|
|
29
|
+
return "我能回答资料库里的问题。试着问我:营业时间 / 怎么退货"
|
|
30
|
+
|
|
31
|
+
@capability(keywords=("时间", "几点"), description="营业时间")
|
|
32
|
+
def hours(self, text):
|
|
33
|
+
return "我们的营业时间是每天 9:00 - 21:00。"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
# preset 决定"开哪些插件":minimal / default / chatbot / api / desktop
|
|
38
|
+
cfg = load(preset_name="default", knowledge_base={
|
|
39
|
+
"enabled": True, "config": {"kb_dir": "./kb"}})
|
|
40
|
+
|
|
41
|
+
app = MyApp("my-app", {"name": "小智", "role": "智能助手"},
|
|
42
|
+
backend_config=cfg)
|
|
43
|
+
|
|
44
|
+
# 导入你的资料(一次性;之后落盘复用)
|
|
45
|
+
app.teach([
|
|
46
|
+
{"title": "退货政策", "content": "签收后 7 天内可无理由退货。",
|
|
47
|
+
"source": "faq", "tags": ["退货"]},
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
for q in ["怎么退货?", "你们几点下班?"]:
|
|
51
|
+
print("我:%s" % q)
|
|
52
|
+
print("AI:%s" % app.ask(q))
|
|
53
|
+
print("-" * 40)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
58
|
+
'''
|
|
59
|
+
|
|
60
|
+
_MAIN_CHATBOT = '''"""站点智能客服 —— pasm-framework 脚手架生成。
|
|
61
|
+
|
|
62
|
+
跑起来(控制台):python main.py
|
|
63
|
+
跑起来(对外服务):python main.py --serve → 浏览器打开 http://127.0.0.1:8080/
|
|
64
|
+
嵌入站点:<iframe src="http://你的主机:8080/" width="420" height="620"></iframe>
|
|
65
|
+
|
|
66
|
+
接 LLM(可选):PASM_LLM_API_KEY=sk-xxx PASM_LLM_PROVIDER=deepseek python main.py
|
|
67
|
+
"""
|
|
68
|
+
import sys
|
|
69
|
+
|
|
70
|
+
from pasm_framework import SimpleApplication, load
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CustomerService(SimpleApplication):
|
|
74
|
+
"""零 LLM 也能"就资料作答、查不到如实说不知道"。"""
|
|
75
|
+
|
|
76
|
+
# 检索不到资料时的话术(面向用户,别写开发提示)
|
|
77
|
+
DEFAULT_UNKNOWN = "抱歉,我暂时没有查到相关资料。你可以换个说法,或联系人工客服 400-000-0000。"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build():
|
|
81
|
+
cfg = load(
|
|
82
|
+
preset_name="chatbot", # 护栏收紧 + 开 HTTP 网关
|
|
83
|
+
knowledge_base={"enabled": True, "config": {"kb_dir": "./kb"}},
|
|
84
|
+
web_gateway={"enabled": True, "config": {"host": "0.0.0.0", "port": 8080}},
|
|
85
|
+
)
|
|
86
|
+
app = CustomerService(
|
|
87
|
+
"shop-cs", {"name": "小智", "role": "智能客服", "tone": "温暖、专业、耐心"},
|
|
88
|
+
backend_config=cfg,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# ====== 把站点资料喂进来(FAQ / 帮助中心 / 商品说明)======
|
|
92
|
+
# 真实项目里可以从 CMS/数据库/接口拉,然后调 app.teach(...)
|
|
93
|
+
app.teach([
|
|
94
|
+
{"title": "退货政策", "content": "商品签收后 7 天内可无理由退货,需保持吊牌完整。",
|
|
95
|
+
"source": "faq", "tags": ["退货", "售后"]},
|
|
96
|
+
{"title": "配送时效", "content": "现货 24 小时内发货,偏远地区 3-5 天送达。",
|
|
97
|
+
"source": "faq", "tags": ["配送", "物流", "发货"]},
|
|
98
|
+
{"title": "发票", "content": "下单时可勾选电子发票,次日发送至注册邮箱。",
|
|
99
|
+
"source": "faq", "tags": ["发票"]},
|
|
100
|
+
{"title": "营业时间", "content": "客服在线时间 9:00-21:00,节假日不休。",
|
|
101
|
+
"source": "faq", "tags": ["时间"]},
|
|
102
|
+
])
|
|
103
|
+
return app
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv=None):
|
|
107
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
108
|
+
app = build()
|
|
109
|
+
|
|
110
|
+
if "--serve" in argv:
|
|
111
|
+
print("网关已启动:http://0.0.0.0:8080/ (Ctrl+C 退出)")
|
|
112
|
+
print("嵌入站点:<iframe src='http://你的主机:8080/' width='420' height='620'></iframe>")
|
|
113
|
+
try:
|
|
114
|
+
app.serve()
|
|
115
|
+
import time
|
|
116
|
+
while True:
|
|
117
|
+
time.sleep(1)
|
|
118
|
+
except KeyboardInterrupt:
|
|
119
|
+
app.close()
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
for q in ["怎么退货?", "多久能发货?", "你们几点上班?",
|
|
123
|
+
"能开增值税专用发票吗?", "你们老板是谁?"]:
|
|
124
|
+
print("客户:%s" % q)
|
|
125
|
+
print("客服:%s" % app.ask(q, session_id="demo-1"))
|
|
126
|
+
print("-" * 50)
|
|
127
|
+
print("资料库统计:%s" % app.plugins.get("knowledge_base").stats())
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
raise SystemExit(main())
|
|
133
|
+
'''
|
|
134
|
+
|
|
135
|
+
_MAIN_NPC = '''"""游戏 NPC —— pasm-framework 脚手架生成(离线、零 LLM)。
|
|
136
|
+
|
|
137
|
+
PASM 的差异点在"身份":NPC 有性格 / 情绪 / 记忆 / 会被玩家反馈塑形。
|
|
138
|
+
跑起来:python main.py
|
|
139
|
+
|
|
140
|
+
要点
|
|
141
|
+
----
|
|
142
|
+
· ``observe`` 写记忆,``feel`` 改情绪,``feedback`` 让行为被玩家塑形;
|
|
143
|
+
· 情绪会影响 NPC 的说话温度(warmth 插件);
|
|
144
|
+
· 状态落盘在 persist_dir,重启后性格/关系还在。
|
|
145
|
+
"""
|
|
146
|
+
from pasm_framework import SimpleApplication, capability, load
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Innkeeper(SimpleApplication):
|
|
150
|
+
"""酒馆老板:记得熟客,被夸奖更热情,被欺负会冷淡。"""
|
|
151
|
+
|
|
152
|
+
@capability(keywords=("买", "酒", "来一"), description="买酒")
|
|
153
|
+
def buy(self, text):
|
|
154
|
+
self.observe(title="玩家买酒", brief=text, tags=["交易"], salience=3)
|
|
155
|
+
self.feel(0.2)
|
|
156
|
+
return "给你满上!%s" % ("今天算你便宜点。" if self.mood > 0.3 else "")
|
|
157
|
+
|
|
158
|
+
@capability(keywords=("聊", "说说", "故事"), description="闲聊")
|
|
159
|
+
def chat_about(self, text):
|
|
160
|
+
hits = self.recall(text, k=2)
|
|
161
|
+
if hits:
|
|
162
|
+
return "这个啊……我记得:%s" % hits[0].get("brief", "")
|
|
163
|
+
return "唉,最近不太平,没什么好说的。"
|
|
164
|
+
|
|
165
|
+
@capability(keywords=("打", "揍", "滚"), description="敌对行为")
|
|
166
|
+
def hostile(self, text):
|
|
167
|
+
self.feel(-0.5)
|
|
168
|
+
self.observe(title="玩家挑衅", brief=text, tags=["敌意"], salience=4)
|
|
169
|
+
return "……你再这样我就叫卫兵了。" if self.mood < -0.3 else "别闹。"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main():
|
|
173
|
+
cfg = load(preset_name="game_npc") # 离线优先,不接 LLM
|
|
174
|
+
npc = Innkeeper("innkeeper-01", {
|
|
175
|
+
"name": "老橡", "role": "酒馆老板",
|
|
176
|
+
"tone": "粗嗓门但热心", "temper": 0.7, "energy": 0.4, "play": 0.6,
|
|
177
|
+
}, backend_config=cfg)
|
|
178
|
+
|
|
179
|
+
script = ["打二两酒", "说说镇上的事", "揍你一顿", "打二两酒"]
|
|
180
|
+
for line in script:
|
|
181
|
+
reply = npc.ask(line, session_id="player-1")
|
|
182
|
+
print("玩家:%s" % line)
|
|
183
|
+
print("老橡:%s (情绪 %.2f)" % (reply, npc.mood))
|
|
184
|
+
print("-" * 50)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|
|
189
|
+
'''
|
|
190
|
+
|
|
191
|
+
_CONFIG_JSON = '''{
|
|
192
|
+
"plugins": {
|
|
193
|
+
"safety": {"enabled": true, "config": {"mode": "warn"}},
|
|
194
|
+
"sessions": {"enabled": true, "config": {"max_history": 20}},
|
|
195
|
+
"knowledge_base": {"enabled": true, "config": {"kb_dir": "./kb"}},
|
|
196
|
+
"warmth": {"enabled": true, "config": {}},
|
|
197
|
+
"observability": {"enabled": true, "config": {}},
|
|
198
|
+
"llm_responder": {"enabled": false, "config": {}},
|
|
199
|
+
"web_gateway": {"enabled": false, "config": {"host": "127.0.0.1", "port": 8080}}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
'''
|
|
203
|
+
|
|
204
|
+
_README = """# {name}
|
|
205
|
+
|
|
206
|
+
由 `pasm-framework new` 生成(模板:**{kind}**)。
|
|
207
|
+
|
|
208
|
+
## 跑起来
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
pip install pasm-framework
|
|
212
|
+
python main.py{serve_hint}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## 换个配置
|
|
216
|
+
|
|
217
|
+
开关表在 `config.json`(也可用环境变量,见下)。代码里这样加载:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from pasm_framework import load
|
|
221
|
+
cfg = load("config.json", env=True)
|
|
222
|
+
app = MyApp("my-app", {{"name": "小智"}}, backend_config=cfg)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
环境变量(优先级高于 `config.json`):
|
|
226
|
+
|
|
227
|
+
| 变量 | 作用 |
|
|
228
|
+
| --- | --- |
|
|
229
|
+
| `PASM_PLUGINS` | `none` / `default` / `safety,sessions,...` |
|
|
230
|
+
| `PASM_KB_DIR` | 知识库目录 |
|
|
231
|
+
| `PASM_SAFETY_MODE` | `warn` 或 `block` |
|
|
232
|
+
| `PASM_LLM_PROVIDER` / `PASM_LLM_MODEL` / `PASM_LLM_API_KEY` | 接入 LLM |
|
|
233
|
+
| `PASM_HTTP_HOST` / `PASM_HTTP_PORT` / `PASM_HTTP_TOKEN` | HTTP 网关与鉴权 |
|
|
234
|
+
|
|
235
|
+
## 下一步
|
|
236
|
+
|
|
237
|
+
- 教程:`docs/tutorials/`(从快速上手到多语言接入)
|
|
238
|
+
- 自检:`pasm-framework doctor`
|
|
239
|
+
- 看插件:`pasm-framework plugins`
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
_REQ = """pasm-framework>=0.2.1
|
|
243
|
+
# 可选:接 LLM 时不需要额外依赖(内置用标准库 urllib)
|
|
244
|
+
# 可选:读 .yaml 配置时需要
|
|
245
|
+
# pyyaml>=6
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
_TEMPLATES: Dict[str, Tuple[str, str]] = {
|
|
249
|
+
"app": (_MAIN_APP, ""),
|
|
250
|
+
"chatbot": (_MAIN_CHATBOT, " --serve"),
|
|
251
|
+
"game_npc": (_MAIN_NPC, ""),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def render(kind: str, name: str) -> Dict[str, str]:
|
|
256
|
+
"""返回 ``{相对路径: 内容}``(纯函数,便于测试与预览)。"""
|
|
257
|
+
if kind not in _TEMPLATES:
|
|
258
|
+
raise FrameworkError(
|
|
259
|
+
"未知模板 %r;可用:%s" % (kind, ", ".join(KINDS)))
|
|
260
|
+
main_py, serve_hint = _TEMPLATES[kind]
|
|
261
|
+
return {
|
|
262
|
+
"main.py": main_py,
|
|
263
|
+
"config.json": _CONFIG_JSON,
|
|
264
|
+
"requirements.txt": _REQ,
|
|
265
|
+
"README.md": _README.format(name=name, kind=kind, serve_hint=serve_hint),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def create(target_dir: str, *, kind: str = "app", name: str = "",
|
|
270
|
+
force: bool = False) -> List[str]:
|
|
271
|
+
"""在 ``target_dir`` 生成项目;返回写出的相对路径列表。
|
|
272
|
+
|
|
273
|
+
``force=False`` 时,若目录已存在同名文件 → 报错,不覆盖用户内容。
|
|
274
|
+
"""
|
|
275
|
+
root = Path(target_dir)
|
|
276
|
+
files = render(kind, name or root.name or "my-app")
|
|
277
|
+
if root.exists() and not root.is_dir():
|
|
278
|
+
raise FrameworkError("目标已存在且不是目录:%s" % root)
|
|
279
|
+
if not force:
|
|
280
|
+
clash = [rel for rel in files if (root / rel).exists()]
|
|
281
|
+
if clash:
|
|
282
|
+
raise FrameworkError(
|
|
283
|
+
"目标目录已存在同名文件:%s(加 --force 覆盖)" % ", ".join(clash))
|
|
284
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
(root / "kb").mkdir(exist_ok=True)
|
|
286
|
+
written: List[str] = []
|
|
287
|
+
for rel, content in files.items():
|
|
288
|
+
p = root / rel
|
|
289
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
p.write_text(content, encoding="utf-8")
|
|
291
|
+
written.append(rel)
|
|
292
|
+
return written
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""CognitiveAssembler + CognitiveService —— 框架与引擎之间的「唯一变动点」。
|
|
2
|
+
|
|
3
|
+
为什么这层必须存在(V1→V2 不重写智能体/技能的核心机制)
|
|
4
|
+
------------------------------------------------------
|
|
5
|
+
`pasm_skills.sdk.BaseAgent` 已经通过 ``CognitiveBackend`` 协议与引擎解耦(见
|
|
6
|
+
``pasm_skills/sdk/backend.py``):它只认识协议,不认识 ``pasm.*``。
|
|
7
|
+
|
|
8
|
+
本模块在协议之上再架一层**装配层**,把"选哪个后端、怎么把它适配成应用可用的认知服务"
|
|
9
|
+
这件事彻底收口:
|
|
10
|
+
|
|
11
|
+
· ``CognitiveBackend`` (来自基座 sdk) —— 引擎侧稳定契约(episode_push/recall/feedback…)。
|
|
12
|
+
· ``CognitiveService`` —— 应用侧稳定契约:在后端外面套一层,
|
|
13
|
+
附带 domain 适配器钩子,但**对外仍是
|
|
14
|
+
CognitiveBackend**(这样 BaseAgent 无需改动)。
|
|
15
|
+
· ``CognitiveAssembler`` —— 装配器:V1 调 :meth:`v1` 拿到 V1 后端;
|
|
16
|
+
V2 只需新增 :meth:`v2` 返回 ``PasmV2Backend``,
|
|
17
|
+
**上层(4 智能体 / 3 技能)一行不改**。
|
|
18
|
+
|
|
19
|
+
所以"唯一变动点"就是这里:引擎大改时,只有 ``CognitiveAssembler.v2``(以及
|
|
20
|
+
``PasmV2Backend``)需要写,应用层完全不动。
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from pasm_skills.sdk.backend import (
|
|
27
|
+
CognitiveBackend,
|
|
28
|
+
create_v1_backend,
|
|
29
|
+
)
|
|
30
|
+
from .adapter import DomainAdapter, NullDomainAdapter
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ============================================================ 认知服务(应用侧契约)
|
|
34
|
+
|
|
35
|
+
class CognitiveService:
|
|
36
|
+
"""包在 ``CognitiveBackend`` 外面的应用侧认知服务。
|
|
37
|
+
|
|
38
|
+
它**实现** ``CognitiveBackend`` 协议(属性 + 方法齐全),因此可以直接作为
|
|
39
|
+
``BaseAgent(backend=...)`` 的入参;同时额外携带 ``domain`` 钩子,
|
|
40
|
+
供 ``BaseApplication`` 在检索/回复时注入领域知识。
|
|
41
|
+
|
|
42
|
+
V1→V2:本类不变。变的只是它内部委托的那个 ``_backend`` 是 V1 还是 V2。
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
backend: CognitiveBackend,
|
|
48
|
+
domain: DomainAdapter = NullDomainAdapter(),
|
|
49
|
+
) -> None:
|
|
50
|
+
# 不变量:入参必须真满足协议,否则就是"变动点"被改坏了——
|
|
51
|
+
# surface-guard 会抓这个(见框架 __init__ 的契约断言)。
|
|
52
|
+
if not isinstance(backend, CognitiveBackend):
|
|
53
|
+
raise TypeError(
|
|
54
|
+
"CognitiveService 需要 CognitiveBackend 协议实现,收到 %r"
|
|
55
|
+
% type(backend).__name__
|
|
56
|
+
)
|
|
57
|
+
self._backend = backend
|
|
58
|
+
self.domain = domain
|
|
59
|
+
|
|
60
|
+
# ---- CognitiveBackend 协议:全部委托给后端 ----
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_core(self) -> bool:
|
|
64
|
+
return bool(getattr(self._backend, "is_core", False))
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def emotion_system(self) -> Optional[Any]:
|
|
68
|
+
return getattr(self._backend, "emotion_system", None)
|
|
69
|
+
|
|
70
|
+
def episode_push(self, *, title, brief, tags, category, salience) -> None:
|
|
71
|
+
self._backend.episode_push(
|
|
72
|
+
title=title, brief=brief, tags=tags,
|
|
73
|
+
category=category, salience=salience,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def recall(self, query: str, k: int = 5) -> List[Dict[str, Any]]:
|
|
77
|
+
# 基础检索走后端;领域知识注入留给 BaseApplication(避免污染协议签名)。
|
|
78
|
+
return self._backend.recall(query, k=k)
|
|
79
|
+
|
|
80
|
+
def feedback(self, kind: str, action: Optional[str] = None) -> Dict[str, float]:
|
|
81
|
+
fn = getattr(self._backend, "feedback", None)
|
|
82
|
+
if fn is not None:
|
|
83
|
+
return fn(kind, action=action)
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
def learn_pick(self, candidates: List[str], base=None, **kw) -> str:
|
|
87
|
+
fn = getattr(self._backend, "learn_pick", None)
|
|
88
|
+
if fn is not None:
|
|
89
|
+
try:
|
|
90
|
+
return fn(candidates, base=base, **kw)
|
|
91
|
+
except TypeError:
|
|
92
|
+
try:
|
|
93
|
+
return fn(candidates)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
import random
|
|
99
|
+
pool = [c for c in candidates if c]
|
|
100
|
+
return random.choice(pool) if pool else ""
|
|
101
|
+
|
|
102
|
+
def counts(self) -> Dict[str, int]:
|
|
103
|
+
fn = getattr(self._backend, "counts", None)
|
|
104
|
+
if fn is not None:
|
|
105
|
+
try:
|
|
106
|
+
return fn()
|
|
107
|
+
except Exception:
|
|
108
|
+
return {}
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ============================================================ 装配器(唯一变动点)
|
|
113
|
+
|
|
114
|
+
class CognitiveAssembler:
|
|
115
|
+
"""把后端装配成应用可用的 ``CognitiveService``。
|
|
116
|
+
|
|
117
|
+
这是 V1↔V2 的唯一变动点:
|
|
118
|
+
|
|
119
|
+
- ``CognitiveAssembler.v1(...)`` → 返回 V1 后端装配出的服务(当前默认)。
|
|
120
|
+
- ``CognitiveAssembler.v2(...)`` → (V2.0 落地时新增)返回 V2 后端装配出的服务。
|
|
121
|
+
上层 ``BaseApplication`` / 4 智能体 / 3 技能只调用装配器,**不直接碰后端类型**,
|
|
122
|
+
因此换引擎 = 改这一处工厂方法。
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def wrap(
|
|
127
|
+
cls,
|
|
128
|
+
backend: CognitiveBackend,
|
|
129
|
+
domain: Optional[DomainAdapter] = None,
|
|
130
|
+
) -> CognitiveService:
|
|
131
|
+
"""把任意 CognitiveBackend 包成 CognitiveService。"""
|
|
132
|
+
return CognitiveService(backend, domain or NullDomainAdapter())
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def v1(
|
|
136
|
+
cls,
|
|
137
|
+
persist_dir,
|
|
138
|
+
persona: Optional[dict] = None,
|
|
139
|
+
*,
|
|
140
|
+
use_core: bool = True,
|
|
141
|
+
stage: int = 0,
|
|
142
|
+
domain: Optional[DomainAdapter] = None,
|
|
143
|
+
) -> CognitiveService:
|
|
144
|
+
"""默认路径:V1 后端(core 优先,降级 light)装配成服务。"""
|
|
145
|
+
backend = create_v1_backend(
|
|
146
|
+
persist_dir, persona, use_core=use_core, stage=stage
|
|
147
|
+
)
|
|
148
|
+
return cls.wrap(backend, domain)
|
|
149
|
+
|
|
150
|
+
# —— V2.0 落地时在此新增(示例,先留接口不实现,避免提前引入 pasm_v2 依赖):
|
|
151
|
+
# @classmethod
|
|
152
|
+
# def v2(cls, persist_dir, persona=None, *, domain=None) -> CognitiveService:
|
|
153
|
+
# from pasm_skills.contrib.v2 import create_v2_backend
|
|
154
|
+
# return cls.wrap(create_v2_backend(persist_dir, persona), domain)
|
pasm_framework/simple.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""SimpleApplication —— 让"最小可用应用"从 30 行降到 3 行。
|
|
2
|
+
|
|
3
|
+
``BaseApplication`` 继承自 ``BaseAgent``,而 ``BaseAgent`` 是抽象基类,
|
|
4
|
+
要求子类实现 ``action_pool()`` 与 ``_render_reply()``。对"我就想快速做个
|
|
5
|
+
客服/问答/NPC"的人来说这是不必要的门槛 —— 本模块把这两个都给了默认实现,
|
|
6
|
+
并额外提供 ``@capability`` 装饰器,让"加一个能力"= 加一个方法。
|
|
7
|
+
|
|
8
|
+
三种用法,由简到繁
|
|
9
|
+
------------------
|
|
10
|
+
1. 最简(0 个方法)::
|
|
11
|
+
|
|
12
|
+
from pasm_framework import SimpleApplication
|
|
13
|
+
app = SimpleApplication("demo", {"name": "小智"})
|
|
14
|
+
print(app.ask("你好"))
|
|
15
|
+
|
|
16
|
+
2. 自定义回复(传函数)::
|
|
17
|
+
|
|
18
|
+
app = SimpleApplication("demo", {"name": "小智"},
|
|
19
|
+
reply=lambda text, facts, mood: "你说的是:" + text)
|
|
20
|
+
|
|
21
|
+
3. 加能力(装饰器)::
|
|
22
|
+
|
|
23
|
+
class Shop(SimpleApplication):
|
|
24
|
+
@capability(keywords=("退货", "退款"))
|
|
25
|
+
def refund(self, text):
|
|
26
|
+
return "退货请点这里:/refund"
|
|
27
|
+
|
|
28
|
+
Shop("shop", {"name": "客服"}).handle("我要退货") # → 能力命中
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
from .application import BaseApplication
|
|
35
|
+
from .config import load
|
|
36
|
+
from .discovery import Capability
|
|
37
|
+
|
|
38
|
+
#: ``reply`` 回调签名:``(text, facts, mood) -> str``
|
|
39
|
+
ReplyFn = Callable[[str, List[Dict[str, Any]], float], str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def capability(
|
|
43
|
+
name: Optional[str] = None,
|
|
44
|
+
keywords: Any = (),
|
|
45
|
+
*,
|
|
46
|
+
predicate: Optional[Callable[[str], bool]] = None,
|
|
47
|
+
description: str = "",
|
|
48
|
+
head_chars: int = 12,
|
|
49
|
+
) -> Callable[[Callable[..., str]], Callable[..., str]]:
|
|
50
|
+
"""把实例方法标记为一个能力(动作)。
|
|
51
|
+
|
|
52
|
+
被标记的方法签名应为 ``def m(self, text: str) -> str``。
|
|
53
|
+
命中后 ``text`` 会作为第一个位置参数传入(即它等价于 ``Capability.run``)。
|
|
54
|
+
|
|
55
|
+
``keywords`` 只在**句首 head_chars 个字**内匹配(框架既有语义,防止
|
|
56
|
+
"我在想退货这事到底……"这种长句被误触发)。
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def deco(fn: Callable[..., str]) -> Callable[..., str]:
|
|
60
|
+
fn.__pasm_capability__ = { # type: ignore[attr-defined]
|
|
61
|
+
"name": name or fn.__name__,
|
|
62
|
+
"keywords": tuple(keywords) if not isinstance(keywords, str)
|
|
63
|
+
else (keywords,),
|
|
64
|
+
"predicate": predicate,
|
|
65
|
+
"description": description,
|
|
66
|
+
"head_chars": head_chars,
|
|
67
|
+
}
|
|
68
|
+
return fn
|
|
69
|
+
|
|
70
|
+
return deco
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SimpleApplication(BaseApplication):
|
|
74
|
+
"""带默认回复实现的通用应用底座。
|
|
75
|
+
|
|
76
|
+
参数
|
|
77
|
+
----
|
|
78
|
+
reply : ReplyFn | None
|
|
79
|
+
自定义回复生成器;缺省用"就资料作答、查不到就如实说"的内置策略。
|
|
80
|
+
unknown : str | None
|
|
81
|
+
检索不到资料时的兜底话术(**面向最终用户**,别写开发提示)。
|
|
82
|
+
backend_config : dict | BackendConfig | None
|
|
83
|
+
插件开关表;也可用 ``preset=`` 直接指定场景预设。
|
|
84
|
+
preset : str | None
|
|
85
|
+
场景预设名(见 :mod:`pasm_framework.config`)。给了 preset 才生效,
|
|
86
|
+
与 ``backend_config`` 同时给时 ``backend_config`` 优先。
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
#: 检索不到资料时的默认话术(面向用户,不暴露"插件/配置"这类内部概念)。
|
|
90
|
+
DEFAULT_UNKNOWN = "抱歉,我暂时没有查到相关资料。你可以换个说法再问我,或联系人工客服。"
|
|
91
|
+
|
|
92
|
+
#: 子类由 ``__init_subclass__`` 自动填充:``[(method_name, spec), ...]``
|
|
93
|
+
_PASM_AUTO_CAPS: List[tuple] = []
|
|
94
|
+
|
|
95
|
+
def __init_subclass__(cls, **kw: Any) -> None:
|
|
96
|
+
super().__init_subclass__(**kw)
|
|
97
|
+
auto: List[tuple] = []
|
|
98
|
+
for attr, obj in vars(cls).items():
|
|
99
|
+
spec = getattr(obj, "__pasm_capability__", None)
|
|
100
|
+
if isinstance(spec, dict):
|
|
101
|
+
auto.append((attr, spec))
|
|
102
|
+
if auto:
|
|
103
|
+
cls._PASM_AUTO_CAPS = auto
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
agent_id: str,
|
|
108
|
+
persona: Optional[Dict[str, Any]] = None,
|
|
109
|
+
*,
|
|
110
|
+
reply: Optional[ReplyFn] = None,
|
|
111
|
+
capabilities: Optional[List[Capability]] = None,
|
|
112
|
+
preset: Optional[str] = None,
|
|
113
|
+
unknown: Optional[str] = None,
|
|
114
|
+
backend_config: Optional[Any] = None,
|
|
115
|
+
**kwargs: Any,
|
|
116
|
+
) -> None:
|
|
117
|
+
self._reply_fn = reply
|
|
118
|
+
self._unknown = unknown or self.DEFAULT_UNKNOWN
|
|
119
|
+
caps = list(capabilities or [])
|
|
120
|
+
# 把 @capability 标记过的方法转成 Capability 对象。
|
|
121
|
+
for method_name, spec in type(self)._PASM_AUTO_CAPS:
|
|
122
|
+
caps.append(Capability(
|
|
123
|
+
spec["name"],
|
|
124
|
+
run=self._make_runner(method_name),
|
|
125
|
+
keywords=spec["keywords"],
|
|
126
|
+
predicate=spec["predicate"],
|
|
127
|
+
description=spec["description"],
|
|
128
|
+
head_chars=spec["head_chars"],
|
|
129
|
+
))
|
|
130
|
+
if backend_config is None and preset is not None:
|
|
131
|
+
backend_config = load(preset_name=preset)
|
|
132
|
+
super().__init__(
|
|
133
|
+
agent_id, persona or {"name": "助手"},
|
|
134
|
+
capabilities=caps or None,
|
|
135
|
+
backend_config=backend_config,
|
|
136
|
+
**kwargs,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _make_runner(method_name: str) -> Callable[..., str]:
|
|
141
|
+
def run(app: Any, text: str) -> str:
|
|
142
|
+
return getattr(app, method_name)(text)
|
|
143
|
+
return run
|
|
144
|
+
|
|
145
|
+
# ---- BaseAgent 抽象方法的默认实现 ----
|
|
146
|
+
def action_pool(self) -> List[str]: # noqa: D102
|
|
147
|
+
return ["reply"]
|
|
148
|
+
|
|
149
|
+
def _render_reply( # noqa: D102
|
|
150
|
+
self, text: str, facts: List[Dict[str, Any]], mood: float
|
|
151
|
+
) -> str:
|
|
152
|
+
if self._reply_fn is not None:
|
|
153
|
+
return self._reply_fn(text, facts, mood)
|
|
154
|
+
# 默认策略:只依据"有来源的知识"作答,不编造。
|
|
155
|
+
knowledge = [f for f in (facts or []) if f.get("source")]
|
|
156
|
+
if knowledge:
|
|
157
|
+
top = knowledge[0]
|
|
158
|
+
body = (top.get("brief") or top.get("title") or "").strip()
|
|
159
|
+
return body or self._unknown
|
|
160
|
+
return self._unknown
|
|
161
|
+
|
|
162
|
+
# ---- 便捷方法 ----
|
|
163
|
+
def ask(self, text: str, session_id: str = "default",
|
|
164
|
+
user_id: Optional[str] = None) -> str:
|
|
165
|
+
"""语义化别名:``handle`` 是框架内部叫法,``ask`` 是给人看的。"""
|
|
166
|
+
return self.handle(text, session_id=session_id, user_id=user_id)
|
|
167
|
+
|
|
168
|
+
def teach(self, items: List[Dict[str, Any]]) -> int:
|
|
169
|
+
"""``ingest`` 的语义化别名(喂资料给知识库,返回新增条数)。
|
|
170
|
+
|
|
171
|
+
两个名字等价,统一由 ``BaseApplication.ingest`` 实现;未启用
|
|
172
|
+
``knowledge_base`` 时会抛 ``FrameworkError`` 指引开启方式。
|
|
173
|
+
"""
|
|
174
|
+
return self.ingest(items)
|