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,319 @@
|
|
|
1
|
+
"""knowledge_base —— 站点数据自学资料库插件(零依赖,智能客服核心)。
|
|
2
|
+
|
|
3
|
+
这是「智能客服能自学、记忆、成长」的关键拼图:
|
|
4
|
+
· **摄取(ingest)**:把站点 FAQ / 文档 / 商品说明变成结构化资料库;
|
|
5
|
+
· **检索(recall)**:在 ``on_retrieve`` 阶段把相关资料注入 ``msg.facts``,
|
|
6
|
+
让回复有依据、不瞎编;
|
|
7
|
+
· **自学习(grow)**:每次高质量问答再沉淀成 QA 对,资料库越用越厚 → 成长。
|
|
8
|
+
|
|
9
|
+
检索当前用「关键词重叠 + 时效/重要度」打分(零依赖);后续可平滑替换为向量检索,
|
|
10
|
+
对外契约(``recall(query,k) -> [fact]``)不变。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
import threading
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from ..core import BasePlugin, Message, PluginContext
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _norm(text: str) -> str:
|
|
26
|
+
return (text or "").strip().lower()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _tokenize(text: str) -> List[str]:
|
|
30
|
+
"""中英文混合的轻量分词:词 + 中文相邻二字(bigram)。
|
|
31
|
+
|
|
32
|
+
只产出长度 >= 2 的候选,**不做单字匹配** —— 单字重叠(如"么""你")
|
|
33
|
+
会造成大量误命中,是客服答非所问的常见根因。
|
|
34
|
+
"""
|
|
35
|
+
t = _norm(text)
|
|
36
|
+
t = re.sub(r"[\s\W_]+", " ", t)
|
|
37
|
+
toks: List[str] = []
|
|
38
|
+
for w in t.split(" "):
|
|
39
|
+
if not w:
|
|
40
|
+
continue
|
|
41
|
+
toks.append(w)
|
|
42
|
+
chars = re.findall(r"[\u4e00-\u9fff]", w)
|
|
43
|
+
for i in range(len(chars) - 1):
|
|
44
|
+
toks.append(chars[i] + chars[i + 1])
|
|
45
|
+
return toks
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class KnowledgeBasePlugin(BasePlugin):
|
|
49
|
+
"""资料库:摄取 → 检索 → 自学习。"""
|
|
50
|
+
|
|
51
|
+
name = "knowledge_base"
|
|
52
|
+
version = "0.1.0"
|
|
53
|
+
|
|
54
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
|
55
|
+
super().__init__(config)
|
|
56
|
+
self._kb_dir = self.config.get("kb_dir") or (
|
|
57
|
+
str(Path(os.path.expanduser("~/.pasm_framework/kb")))
|
|
58
|
+
)
|
|
59
|
+
self._auto_learn: bool = bool(self.config.get("auto_learn", True))
|
|
60
|
+
self._min_qa_len: int = int(self.config.get("auto_learn_min_len", 12))
|
|
61
|
+
self._top_k: int = int(self.config.get("top_k", 5))
|
|
62
|
+
# 检索下限:命中分低于此值视为"没命中"。默认 0 表示不启用
|
|
63
|
+
# (保持与早期版本一致的行为);高精度场景可调高。
|
|
64
|
+
self._min_score: float = float(self.config.get("min_score", 0.0))
|
|
65
|
+
self._lock = threading.RLock()
|
|
66
|
+
self._entries: List[Dict[str, Any]] = []
|
|
67
|
+
self._seq = 0
|
|
68
|
+
# —— 检索加速:倒排索引 + 每条目的分词缓存 ——
|
|
69
|
+
# 不加这两样时,每次 recall 都要对**全库**重新分词(O(n·m)),
|
|
70
|
+
# 资料库一大就明显变慢;索引把每次检索降到只算候选集。
|
|
71
|
+
self._index: Dict[str, set] = {}
|
|
72
|
+
self._index_dirty: bool = True
|
|
73
|
+
self._tokens_cache: Dict[int, set] = {}
|
|
74
|
+
# 已落盘条目数:用于"只追加新增",避免每次 ingest 全量重写。
|
|
75
|
+
self._persisted: int = 0
|
|
76
|
+
self._ensure_dir()
|
|
77
|
+
self._load()
|
|
78
|
+
|
|
79
|
+
# ---- 持久化 ----
|
|
80
|
+
def _ensure_dir(self) -> None:
|
|
81
|
+
try:
|
|
82
|
+
Path(self._kb_dir).mkdir(parents=True, exist_ok=True)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
def _path(self) -> Path:
|
|
87
|
+
return Path(self._kb_dir) / "kb.jsonl"
|
|
88
|
+
|
|
89
|
+
def _load(self) -> None:
|
|
90
|
+
p = self._path()
|
|
91
|
+
if not p.exists():
|
|
92
|
+
return
|
|
93
|
+
try:
|
|
94
|
+
with p.open("r", encoding="utf-8") as f:
|
|
95
|
+
for line in f:
|
|
96
|
+
line = line.strip()
|
|
97
|
+
if not line:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
self._entries.append(json.loads(line))
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
self._seq = max([e.get("id", 0) for e in self._entries], default=0)
|
|
104
|
+
self._persisted = len(self._entries)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def _save(self) -> None:
|
|
109
|
+
"""全量重写(压缩 / 迁移用)。日常写入请用 :meth:`_append_new`。"""
|
|
110
|
+
p = self._path()
|
|
111
|
+
try:
|
|
112
|
+
tmp = p.with_suffix(".jsonl.tmp")
|
|
113
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
114
|
+
for e in self._entries:
|
|
115
|
+
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
|
116
|
+
os.replace(tmp, p)
|
|
117
|
+
self._persisted = len(self._entries)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
def _append_new(self) -> None:
|
|
122
|
+
"""只把"尚未落盘的新条目"追加写入。
|
|
123
|
+
|
|
124
|
+
旧实现在每次写入时全量重写整个 jsonl —— ingest 十万条就是 O(n²)。
|
|
125
|
+
条目是只追加(append-only)的,因此追加写等价且是 O(新增条数)。
|
|
126
|
+
"""
|
|
127
|
+
new = self._entries[self._persisted:]
|
|
128
|
+
if not new:
|
|
129
|
+
return
|
|
130
|
+
try:
|
|
131
|
+
with self._path().open("a", encoding="utf-8") as f:
|
|
132
|
+
for e in new:
|
|
133
|
+
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
|
134
|
+
self._persisted = len(self._entries)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# ---- 摄取 ----
|
|
139
|
+
def ingest(self, items: List[Dict[str, Any]]) -> int:
|
|
140
|
+
"""摄取资料库条目。每条:``{title, content, source, tags}``。
|
|
141
|
+
|
|
142
|
+
返回新增条数。这是"站点数据自然形成资料库"的入口——
|
|
143
|
+
应用启动时把 FAQ / 帮助文档喂进来即可。
|
|
144
|
+
"""
|
|
145
|
+
added = 0
|
|
146
|
+
with self._lock:
|
|
147
|
+
for it in (items or []):
|
|
148
|
+
title = str(it.get("title") or it.get("content") or "")[:120]
|
|
149
|
+
content = str(it.get("content") or it.get("title") or "")
|
|
150
|
+
if not content.strip():
|
|
151
|
+
continue
|
|
152
|
+
self._seq += 1
|
|
153
|
+
self._entries.append({
|
|
154
|
+
"id": self._seq,
|
|
155
|
+
"title": title,
|
|
156
|
+
"content": content,
|
|
157
|
+
"source": str(it.get("source") or "ingest"),
|
|
158
|
+
"tags": list(it.get("tags") or []),
|
|
159
|
+
"kind": "doc",
|
|
160
|
+
"ts": time.time(),
|
|
161
|
+
"hits": 0,
|
|
162
|
+
})
|
|
163
|
+
added += 1
|
|
164
|
+
if added:
|
|
165
|
+
self._append_new()
|
|
166
|
+
self._index_dirty = True
|
|
167
|
+
return added
|
|
168
|
+
|
|
169
|
+
def ingest_qa(self, question: str, answer: str, source: str = "self-learn") -> None:
|
|
170
|
+
"""沉淀一条 QA 对(自学习 / 成长)。"""
|
|
171
|
+
q, a = _norm(question), _norm(answer)
|
|
172
|
+
if not q or not a:
|
|
173
|
+
return
|
|
174
|
+
with self._lock:
|
|
175
|
+
self._seq += 1
|
|
176
|
+
self._entries.append({
|
|
177
|
+
"id": self._seq,
|
|
178
|
+
"title": q[:120],
|
|
179
|
+
"content": "问:%s\n答:%s" % (question, answer),
|
|
180
|
+
"source": source,
|
|
181
|
+
"tags": _tokenize(question)[:8],
|
|
182
|
+
"kind": "qa",
|
|
183
|
+
"ts": time.time(),
|
|
184
|
+
"hits": 0,
|
|
185
|
+
})
|
|
186
|
+
self._append_new()
|
|
187
|
+
self._index_dirty = True
|
|
188
|
+
|
|
189
|
+
# ---- 索引 ----
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _entry_fields(e: Dict[str, Any]) -> tuple:
|
|
192
|
+
"""把条目分词成 ``(title, tags, content)`` 三组 —— 分字段是为了加权。
|
|
193
|
+
|
|
194
|
+
为什么必须分字段:不分时"标题命中"和"正文顺带提到"同权,会出现
|
|
195
|
+
「问退货,答运费(因为正文里有一句"退货运费由我方承担")」这类
|
|
196
|
+
答非所问。标题命中显然更该排前面。
|
|
197
|
+
"""
|
|
198
|
+
def tk(v: Any) -> set:
|
|
199
|
+
return set(t for t in _tokenize(str(v or "")) if len(t) >= 2)
|
|
200
|
+
|
|
201
|
+
return (tk(e.get("title")), tk(" ".join(e.get("tags", []) or [])),
|
|
202
|
+
tk(e.get("content")))
|
|
203
|
+
|
|
204
|
+
def _fields_of(self, pos: int, e: Dict[str, Any]) -> tuple:
|
|
205
|
+
"""带缓存的条目分词:同一份资料只分词一次。"""
|
|
206
|
+
cached = self._tokens_cache.get(pos)
|
|
207
|
+
if cached is None:
|
|
208
|
+
cached = self._entry_fields(e)
|
|
209
|
+
self._tokens_cache[pos] = cached
|
|
210
|
+
return cached
|
|
211
|
+
|
|
212
|
+
def _ensure_index(self) -> None:
|
|
213
|
+
"""惰性重建倒排索引:token → 条目下标集合。
|
|
214
|
+
|
|
215
|
+
条目只追加,故下标稳定。索引把检索从"遍历全库"降到"只看候选集"。
|
|
216
|
+
"""
|
|
217
|
+
if not self._index_dirty:
|
|
218
|
+
return
|
|
219
|
+
idx: Dict[str, set] = {}
|
|
220
|
+
for pos, e in enumerate(self._entries):
|
|
221
|
+
title, tags, content = self._fields_of(pos, e)
|
|
222
|
+
for t in (title | tags | content):
|
|
223
|
+
idx.setdefault(t, set()).add(pos)
|
|
224
|
+
self._index = idx
|
|
225
|
+
self._index_dirty = False
|
|
226
|
+
|
|
227
|
+
# ---- 检索 ----
|
|
228
|
+
def recall(self, query: str, k: int = 0) -> List[Dict[str, Any]]:
|
|
229
|
+
k = k or self._top_k
|
|
230
|
+
q = _norm(query)
|
|
231
|
+
if not q:
|
|
232
|
+
return []
|
|
233
|
+
# 只用长度>=2 的词 / 中文 bigram 做重叠匹配(不做单字匹配),
|
|
234
|
+
# 避免「么」「你」这类高频单字造成大量误命中。
|
|
235
|
+
q_tokens = set(t for t in _tokenize(query) if len(t) >= 2)
|
|
236
|
+
if not q_tokens:
|
|
237
|
+
return []
|
|
238
|
+
with self._lock:
|
|
239
|
+
self._ensure_index()
|
|
240
|
+
# 候选集 = 命中任一 query token 的条目(倒排索引给出,无需全库扫描)
|
|
241
|
+
cand: set = set()
|
|
242
|
+
for t in q_tokens:
|
|
243
|
+
posts = self._index.get(t)
|
|
244
|
+
if posts:
|
|
245
|
+
cand |= posts
|
|
246
|
+
entries = self._entries
|
|
247
|
+
|
|
248
|
+
scored = []
|
|
249
|
+
for pos in cand:
|
|
250
|
+
if pos >= len(entries):
|
|
251
|
+
continue
|
|
252
|
+
e = entries[pos]
|
|
253
|
+
title, tags, content = self._fields_of(pos, e)
|
|
254
|
+
n_title = len(q_tokens & title)
|
|
255
|
+
n_tags = len(q_tokens & tags)
|
|
256
|
+
n_content = len(q_tokens & content)
|
|
257
|
+
overlap = n_title + n_tags + n_content
|
|
258
|
+
if overlap == 0:
|
|
259
|
+
continue
|
|
260
|
+
# 分字段加权:标题 3 > 标签 2 > 正文 1。
|
|
261
|
+
overlap_w = n_title * 3.0 + n_tags * 2.0 + n_content * 1.0
|
|
262
|
+
precision = overlap / max(1, len(q_tokens))
|
|
263
|
+
recency = 1.0 / (1.0 + (time.time() - float(e.get("ts", 0))) / (86400 * 30))
|
|
264
|
+
kind_boost = 1.15 if e.get("kind") == "qa" else 1.0
|
|
265
|
+
score = (overlap_w * 2.0 + precision * 2.0) \
|
|
266
|
+
* (0.6 + 0.4 * recency) * kind_boost
|
|
267
|
+
if score < self._min_score:
|
|
268
|
+
continue
|
|
269
|
+
scored.append((score, e))
|
|
270
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
271
|
+
out = []
|
|
272
|
+
for score, e in scored[:k]:
|
|
273
|
+
out.append({
|
|
274
|
+
"title": e.get("title"),
|
|
275
|
+
"brief": (e.get("content") or "")[:300],
|
|
276
|
+
"tags": e.get("tags", []),
|
|
277
|
+
"source": "knowledge_base:%s" % e.get("source"),
|
|
278
|
+
"kb_id": e.get("id"),
|
|
279
|
+
"kind": e.get("kind", "doc"),
|
|
280
|
+
"sal": round(score, 3),
|
|
281
|
+
"score": round(score, 3),
|
|
282
|
+
})
|
|
283
|
+
e["hits"] = e.get("hits", 0) + 1
|
|
284
|
+
# 注意:读路径**不落盘**。hits 只是统计量,为它全量重写 jsonl
|
|
285
|
+
# 会让每次问答都变成 O(n) 磁盘写(旧实现的问题)。
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
# ---- Hook ----
|
|
289
|
+
def on_retrieve(self, ctx: PluginContext) -> None:
|
|
290
|
+
msg: Message = ctx.message
|
|
291
|
+
hits = self.recall(msg.text, k=self._top_k)
|
|
292
|
+
for h in hits:
|
|
293
|
+
msg.add_fact(h)
|
|
294
|
+
|
|
295
|
+
def on_learn(self, ctx: PluginContext) -> None:
|
|
296
|
+
if not self._auto_learn:
|
|
297
|
+
return
|
|
298
|
+
msg: Message = ctx.message
|
|
299
|
+
if msg.error or not msg.reply:
|
|
300
|
+
return
|
|
301
|
+
# 「答不上来」的兜底回复不沉淀,避免污染资料库(见 BaseApplication 设置的标记)。
|
|
302
|
+
if msg.meta.get("no_answer"):
|
|
303
|
+
return
|
|
304
|
+
# 只沉淀"有实质内容"的问答,避免噪声污染资料库。
|
|
305
|
+
if len(_norm(msg.reply)) < self._min_qa_len:
|
|
306
|
+
return
|
|
307
|
+
# 已有正式资料(doc)覆盖则不重复沉淀;高置信命中也跳过。
|
|
308
|
+
existing = self.recall(msg.text, k=3)
|
|
309
|
+
if any(e.get("kind") == "doc" and e.get("score", 0) >= 1.0 for e in existing):
|
|
310
|
+
return
|
|
311
|
+
if existing and max(e.get("score", 0) for e in existing) >= 6.0:
|
|
312
|
+
return
|
|
313
|
+
self.ingest_qa(msg.text, msg.reply)
|
|
314
|
+
|
|
315
|
+
def stats(self) -> Dict[str, Any]:
|
|
316
|
+
with self._lock:
|
|
317
|
+
docs = sum(1 for e in self._entries if e.get("kind") == "doc")
|
|
318
|
+
qas = sum(1 for e in self._entries if e.get("kind") == "qa")
|
|
319
|
+
return {"total": len(self._entries), "docs": docs, "qa": qas}
|