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,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}