codebee 0.1.48 → 0.1.50
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.
- package/CHANGELOG.md +30 -0
- package/README.md +4 -150
- package/app/core/aiflavor.py +94 -2
- package/app/core/automation.py +55 -2
- package/app/core/builtin_agent.py +450 -32
- package/app/core/knowledge.py +432 -405
- package/app/core/manager.py +76 -3
- package/app/core/modelhub.py +53 -5
- package/app/core/pipeline.py +162 -88
- package/app/core/planner.py +45 -0
- package/app/core/preview.py +293 -0
- package/app/core/runner.py +336 -144
- package/app/core/settings.py +7 -1
- package/app/core/store.py +78 -7
- package/app/core/volumes.py +7 -5
- package/app/core/zentao.py +63 -13
- package/app/main.py +66 -1
- package/app/ui/app.js +741 -167
- package/app/ui/i18n.js +64 -31
- package/app/ui/index.html +57 -12
- package/app/ui/style.css +230 -33
- package/package.json +1 -1
package/app/core/knowledge.py
CHANGED
|
@@ -1,405 +1,432 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""知识库(Knowledge):运行产出自动整理形成的可复用知识 + 个人知识管理入口。
|
|
3
|
-
|
|
4
|
-
与经验库(skills.py)的分工边界:
|
|
5
|
-
- 经验(lessons)记「下次怎么做」——流程规范、实践方法和评审暴露的教训;
|
|
6
|
-
- 知识(knowledge)记「已知是什么」——领域事实、外部规则和可验证结论。
|
|
7
|
-
运行产出中的实践方法会分流进经验库,事实才进入知识库,避免同一方法双写。
|
|
8
|
-
|
|
9
|
-
质量闸门:条目默认直接转正(approved)参与注入——用户拍板:人工把关太重,
|
|
10
|
-
提炼提示词里的「只保留有明确复用价值的」约束兜质量。status 字段保留 draft
|
|
11
|
-
枚举向后兼容,手动新建同样直接 approved;migrate_drafts_approved() 在启动时
|
|
12
|
-
把历史草稿一次性转正。
|
|
13
|
-
|
|
14
|
-
注入纪律(与教训反着来):教训是全量小注(top-8),知识默认不注入——
|
|
15
|
-
scope 命中且库里有 approved 条目才成块,按与任务目标的相关性取 top,
|
|
16
|
-
独立预算截断(KNOWLEDGE_BUDGET),绝不挤占经验包/圣经的空间。
|
|
17
|
-
选择依据在单次注入内恒定(goal 固定、id 唯一),同一任务字节稳定,
|
|
18
|
-
不碎供应商前缀缓存。
|
|
19
|
-
|
|
20
|
-
事实会过期:竞品/平台类知识每条带 as_of(事实采集日),超过 STALE_DAYS
|
|
21
|
-
注入时自动标注「可能过期」,提示模型自行核实时效。
|
|
22
|
-
"""
|
|
23
|
-
from __future__ import annotations
|
|
24
|
-
|
|
25
|
-
import hashlib
|
|
26
|
-
import json
|
|
27
|
-
import re
|
|
28
|
-
import threading
|
|
29
|
-
import time
|
|
30
|
-
from pathlib import Path
|
|
31
|
-
|
|
32
|
-
from . import paths, skills
|
|
33
|
-
|
|
34
|
-
_LOCK = threading.RLock()
|
|
35
|
-
_FILE = paths.DATA_DIR / "knowledge.json"
|
|
36
|
-
|
|
37
|
-
KNOWLEDGE_BUDGET = 3000 # 注入块字符预算(独立于经验包的 MAX_INJECT_CHARS)
|
|
38
|
-
KNOWLEDGE_MAX_INJECT = 6 # 单次注入条数上限
|
|
39
|
-
STALE_DAYS = 90 # as_of 超过该天数标注「可能过期」
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _now():
|
|
43
|
-
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _load():
|
|
47
|
-
try:
|
|
48
|
-
return json.loads(_FILE.read_text(encoding="utf-8"))
|
|
49
|
-
except Exception:
|
|
50
|
-
return {}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _save(data):
|
|
54
|
-
try:
|
|
55
|
-
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
-
_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=1),
|
|
57
|
-
encoding="utf-8")
|
|
58
|
-
except Exception:
|
|
59
|
-
pass
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _entry_id(scope, title):
|
|
63
|
-
"""去重指纹:scope + 归一化标题(沿用教训库的 _norm_title 标点剥离)。"""
|
|
64
|
-
h = hashlib.sha256(("%s|%s" % (scope, skills._norm_title(title)))
|
|
65
|
-
.encode("utf-8")).hexdigest()[:12]
|
|
66
|
-
return "kb-" + h
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def list_entries(scope=None, status=None, tag=None, only_enabled=False):
|
|
70
|
-
with _LOCK:
|
|
71
|
-
items = list(_load().get("entries") or [])
|
|
72
|
-
if scope:
|
|
73
|
-
items = [x for x in items if x.get("scope") in (scope, "*")]
|
|
74
|
-
if status:
|
|
75
|
-
items = [x for x in items if (x.get("status") or "draft") == status]
|
|
76
|
-
if tag:
|
|
77
|
-
items = [x for x in items if tag in (x.get("tags") or [])]
|
|
78
|
-
if only_enabled:
|
|
79
|
-
items = [x for x in items if x.get("enabled", True)]
|
|
80
|
-
items.sort(key=lambda x: (x.get("updated_at") or x.get("created_at") or ""),
|
|
81
|
-
reverse=True)
|
|
82
|
-
return items
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def
|
|
304
|
-
"""
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def
|
|
394
|
-
"""
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""知识库(Knowledge):运行产出自动整理形成的可复用知识 + 个人知识管理入口。
|
|
3
|
+
|
|
4
|
+
与经验库(skills.py)的分工边界:
|
|
5
|
+
- 经验(lessons)记「下次怎么做」——流程规范、实践方法和评审暴露的教训;
|
|
6
|
+
- 知识(knowledge)记「已知是什么」——领域事实、外部规则和可验证结论。
|
|
7
|
+
运行产出中的实践方法会分流进经验库,事实才进入知识库,避免同一方法双写。
|
|
8
|
+
|
|
9
|
+
质量闸门:条目默认直接转正(approved)参与注入——用户拍板:人工把关太重,
|
|
10
|
+
提炼提示词里的「只保留有明确复用价值的」约束兜质量。status 字段保留 draft
|
|
11
|
+
枚举向后兼容,手动新建同样直接 approved;migrate_drafts_approved() 在启动时
|
|
12
|
+
把历史草稿一次性转正。
|
|
13
|
+
|
|
14
|
+
注入纪律(与教训反着来):教训是全量小注(top-8),知识默认不注入——
|
|
15
|
+
scope 命中且库里有 approved 条目才成块,按与任务目标的相关性取 top,
|
|
16
|
+
独立预算截断(KNOWLEDGE_BUDGET),绝不挤占经验包/圣经的空间。
|
|
17
|
+
选择依据在单次注入内恒定(goal 固定、id 唯一),同一任务字节稳定,
|
|
18
|
+
不碎供应商前缀缓存。
|
|
19
|
+
|
|
20
|
+
事实会过期:竞品/平台类知识每条带 as_of(事实采集日),超过 STALE_DAYS
|
|
21
|
+
注入时自动标注「可能过期」,提示模型自行核实时效。
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from . import paths, skills
|
|
33
|
+
|
|
34
|
+
_LOCK = threading.RLock()
|
|
35
|
+
_FILE = paths.DATA_DIR / "knowledge.json"
|
|
36
|
+
|
|
37
|
+
KNOWLEDGE_BUDGET = 3000 # 注入块字符预算(独立于经验包的 MAX_INJECT_CHARS)
|
|
38
|
+
KNOWLEDGE_MAX_INJECT = 6 # 单次注入条数上限
|
|
39
|
+
STALE_DAYS = 90 # as_of 超过该天数标注「可能过期」
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _now():
|
|
43
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load():
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(_FILE.read_text(encoding="utf-8"))
|
|
49
|
+
except Exception:
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _save(data):
|
|
54
|
+
try:
|
|
55
|
+
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=1),
|
|
57
|
+
encoding="utf-8")
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _entry_id(scope, title):
|
|
63
|
+
"""去重指纹:scope + 归一化标题(沿用教训库的 _norm_title 标点剥离)。"""
|
|
64
|
+
h = hashlib.sha256(("%s|%s" % (scope, skills._norm_title(title)))
|
|
65
|
+
.encode("utf-8")).hexdigest()[:12]
|
|
66
|
+
return "kb-" + h
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def list_entries(scope=None, status=None, tag=None, only_enabled=False):
|
|
70
|
+
with _LOCK:
|
|
71
|
+
items = list(_load().get("entries") or [])
|
|
72
|
+
if scope:
|
|
73
|
+
items = [x for x in items if x.get("scope") in (scope, "*")]
|
|
74
|
+
if status:
|
|
75
|
+
items = [x for x in items if (x.get("status") or "draft") == status]
|
|
76
|
+
if tag:
|
|
77
|
+
items = [x for x in items if tag in (x.get("tags") or [])]
|
|
78
|
+
if only_enabled:
|
|
79
|
+
items = [x for x in items if x.get("enabled", True)]
|
|
80
|
+
items.sort(key=lambda x: (x.get("updated_at") or x.get("created_at") or ""),
|
|
81
|
+
reverse=True)
|
|
82
|
+
return items
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _title_sim(a, b):
|
|
86
|
+
"""标题近似度:字符 bigram 的包含度(交集 / 较短者,中文友好零分词)。
|
|
87
|
+
用包含度而非 Jaccard:「X」vs「X 指南」的较短者完全被包含 →1.0,
|
|
88
|
+
Jaccard 却因并集分母只有 0.71——追加后缀是自动提炼最常见的重复形态。
|
|
89
|
+
复用 skills 的 bigram 工具,口径与教训库一致。"""
|
|
90
|
+
ga, gb = skills._text_bigrams(a), skills._text_bigrams(b)
|
|
91
|
+
if not ga or not gb:
|
|
92
|
+
return 0.0
|
|
93
|
+
return len(ga & gb) / float(min(len(ga), len(gb)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_NEAR_DUP_SIM = 0.8 # 同 scope 标题包含度 ≥0.8 视为同一条(近似题合并)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
100
|
+
as_of=None, status="draft"):
|
|
101
|
+
"""写入/合并一条知识。同 scope 同标题视为同一条(指纹去重):
|
|
102
|
+
- 已有条目是 approved 且新条是 draft:不覆盖正文(人工确认过的内容优先),
|
|
103
|
+
新内容记入 revisions 作为修订候选,seen+1;
|
|
104
|
+
- 其余情况(已有是 draft,或新条是 approved):正文取新。
|
|
105
|
+
近似题合并(自动学习防膨胀):同 scope 标题 bigram 包含度 ≥0.8 也
|
|
106
|
+
视为同一条——自动提炼每次 done 运行都跑,「X 优化」与「X 优化指南」
|
|
107
|
+
会各建一条把库撑爆;归并走与精确同题完全相同的合并/修订语义。
|
|
108
|
+
返回条目;title/body 为空返回 None。"""
|
|
109
|
+
title = str(title or "").strip()[:80]
|
|
110
|
+
body = str(body or "").strip()[:1500]
|
|
111
|
+
if not title or not body:
|
|
112
|
+
return None
|
|
113
|
+
tags = [str(t).strip()[:20] for t in (tags or []) if str(t).strip()][:6]
|
|
114
|
+
status = status if status in ("draft", "approved") else "draft"
|
|
115
|
+
scope = str(scope or "*").strip() or "*"
|
|
116
|
+
kid = _entry_id(scope, title)
|
|
117
|
+
with _LOCK:
|
|
118
|
+
data = _load()
|
|
119
|
+
items = data.setdefault("entries", [])
|
|
120
|
+
hit = next((x for x in items if x.get("id") == kid), None)
|
|
121
|
+
if hit is None:
|
|
122
|
+
# 近似题扫描(精确未命中才做;<4 字符标题噪声大,只认精确同题)
|
|
123
|
+
if len(title) >= 4:
|
|
124
|
+
for x in items:
|
|
125
|
+
if x.get("scope") != scope:
|
|
126
|
+
continue
|
|
127
|
+
xt = str(x.get("title") or "")
|
|
128
|
+
if len(xt) >= 4 and _title_sim(title, xt) >= _NEAR_DUP_SIM:
|
|
129
|
+
hit = x
|
|
130
|
+
break
|
|
131
|
+
if hit is not None:
|
|
132
|
+
it = hit
|
|
133
|
+
it["seen"] = int(it.get("seen") or 1) + 1
|
|
134
|
+
if status == "approved" or it.get("status") != "approved":
|
|
135
|
+
it["body"] = body
|
|
136
|
+
if status == "approved":
|
|
137
|
+
it["status"] = "approved"
|
|
138
|
+
else:
|
|
139
|
+
it.setdefault("revisions", []).append(
|
|
140
|
+
{"at": _now(), "body": body, "source": source})
|
|
141
|
+
it["revisions"] = it["revisions"][-5:] # 最多留 5 条修订候选
|
|
142
|
+
for tg in tags:
|
|
143
|
+
if tg not in (it.get("tags") or []):
|
|
144
|
+
it["tags"] = (it.get("tags") or []) + [tg]
|
|
145
|
+
it["tags"] = (it.get("tags") or [])[:8]
|
|
146
|
+
if as_of:
|
|
147
|
+
it["as_of"] = str(as_of)[:10]
|
|
148
|
+
if source:
|
|
149
|
+
it["source"] = source
|
|
150
|
+
if source_file:
|
|
151
|
+
it["source_file"] = source_file
|
|
152
|
+
it["updated_at"] = _now()
|
|
153
|
+
_save(data)
|
|
154
|
+
return it
|
|
155
|
+
it = {"id": kid, "scope": scope, "title": title, "body": body,
|
|
156
|
+
"tags": tags, "status": status, "enabled": True,
|
|
157
|
+
"source": source, "source_file": source_file,
|
|
158
|
+
"as_of": str(as_of or _now()[:10])[:10],
|
|
159
|
+
"revisions": [], "hits": 0, "seen": 1,
|
|
160
|
+
"created_at": _now(), "updated_at": _now(), "kind": "knowledge"}
|
|
161
|
+
items.append(it)
|
|
162
|
+
_save(data)
|
|
163
|
+
return it
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def entry_op(entry_id, op, fields=None):
|
|
167
|
+
"""新建/编辑/转正/启停/删除。返回错误或 None。
|
|
168
|
+
|
|
169
|
+
create:手动新建直接 approved(人工录入即确认);edit:改标题/正文/标签/
|
|
170
|
+
范围/账龄,不改状态——转正必须显式走 approve,保持人工闸门。
|
|
171
|
+
"""
|
|
172
|
+
if op not in ("create", "edit", "approve", "enable", "disable", "delete"):
|
|
173
|
+
return "未知操作 " + str(op)
|
|
174
|
+
if op == "create":
|
|
175
|
+
f = fields or {}
|
|
176
|
+
it = upsert_entry(f.get("scope") or "*", f.get("title") or "",
|
|
177
|
+
f.get("body") or "", tags=f.get("tags"),
|
|
178
|
+
as_of=(f.get("as_of") or "").strip() or None,
|
|
179
|
+
status="approved")
|
|
180
|
+
return None if it else "标题与正文不能为空"
|
|
181
|
+
with _LOCK:
|
|
182
|
+
data = _load()
|
|
183
|
+
items = data.get("entries") or []
|
|
184
|
+
hit = next((x for x in items if x.get("id") == entry_id), None)
|
|
185
|
+
if not hit:
|
|
186
|
+
return "知识条目不存在"
|
|
187
|
+
if op == "delete":
|
|
188
|
+
data["entries"] = [x for x in items if x.get("id") != entry_id]
|
|
189
|
+
elif op == "approve":
|
|
190
|
+
hit["status"] = "approved"
|
|
191
|
+
hit["updated_at"] = _now()
|
|
192
|
+
elif op == "edit":
|
|
193
|
+
f = fields or {}
|
|
194
|
+
if str(f.get("title") or "").strip():
|
|
195
|
+
hit["title"] = str(f["title"]).strip()[:80]
|
|
196
|
+
if str(f.get("body") or "").strip():
|
|
197
|
+
hit["body"] = str(f["body"]).strip()[:1500]
|
|
198
|
+
if "tags" in f:
|
|
199
|
+
hit["tags"] = [str(t).strip()[:20]
|
|
200
|
+
for t in (f.get("tags") or []) if str(t).strip()][:8]
|
|
201
|
+
if str(f.get("scope") or "").strip():
|
|
202
|
+
hit["scope"] = str(f["scope"]).strip()[:30]
|
|
203
|
+
if str(f.get("as_of") or "").strip():
|
|
204
|
+
hit["as_of"] = str(f["as_of"]).strip()[:10]
|
|
205
|
+
hit["updated_at"] = _now()
|
|
206
|
+
else:
|
|
207
|
+
hit["enabled"] = (op == "enable")
|
|
208
|
+
_save(data)
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _is_stale(as_of):
|
|
213
|
+
try:
|
|
214
|
+
d = time.strptime(str(as_of or "")[:10], "%Y-%m-%d")
|
|
215
|
+
return (time.mktime(time.localtime()) - time.mktime(d)) / 86400.0 > STALE_DAYS
|
|
216
|
+
except Exception:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def block_for(task):
|
|
221
|
+
"""生成注入提示词的知识块。默认不注入:scope 命中且存在 approved 条目才成块,
|
|
222
|
+
按与任务目标/上下文的相关性取 top(复用教训库的 bigram 检索),独立预算截断。
|
|
223
|
+
无命中返回 ""——字节稳定,不碎供应商前缀缓存。"""
|
|
224
|
+
scope = (task or {}).get("type") or "*"
|
|
225
|
+
entries = [x for x in list_entries(scope, only_enabled=True)
|
|
226
|
+
if (x.get("status") or "draft") == "approved"]
|
|
227
|
+
if not entries:
|
|
228
|
+
return ""
|
|
229
|
+
probe = (skills._text_bigrams((task or {}).get("goal"))
|
|
230
|
+
| skills._text_bigrams((task or {}).get("context"))
|
|
231
|
+
| skills._text_bigrams((task or {}).get("title")))
|
|
232
|
+
if probe:
|
|
233
|
+
def rank(x):
|
|
234
|
+
grams = (skills._text_bigrams(x.get("title"))
|
|
235
|
+
| skills._text_bigrams(x.get("body"))
|
|
236
|
+
| set(x.get("tags") or []))
|
|
237
|
+
return (-len(probe & grams), x.get("id") or "")
|
|
238
|
+
entries = sorted(entries, key=rank)
|
|
239
|
+
else:
|
|
240
|
+
entries.sort(key=lambda x: x.get("id") or "")
|
|
241
|
+
entries = entries[:KNOWLEDGE_MAX_INJECT]
|
|
242
|
+
|
|
243
|
+
lines, used = [], []
|
|
244
|
+
for x in entries:
|
|
245
|
+
stale = _is_stale(x.get("as_of"))
|
|
246
|
+
mark = ("(事实截至 %s,可能过期,请自行核实时效)" % x.get("as_of") if stale
|
|
247
|
+
else ("(事实截至 %s)" % x.get("as_of") if x.get("as_of") else ""))
|
|
248
|
+
lines.append("- **%s**%s:%s" % (x["title"], mark, x["body"]))
|
|
249
|
+
used.append(x["id"])
|
|
250
|
+
text = "## 知识库(已确认的领域知识,供参考)\n\n" + "\n".join(lines)
|
|
251
|
+
if len(text) > KNOWLEDGE_BUDGET:
|
|
252
|
+
text = text[:KNOWLEDGE_BUDGET] + "\n…(已截断)"
|
|
253
|
+
if used:
|
|
254
|
+
_bump_hits(used)
|
|
255
|
+
return text
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _bump_hits(ids):
|
|
259
|
+
with _LOCK:
|
|
260
|
+
data = _load()
|
|
261
|
+
dirty = False
|
|
262
|
+
for it in (data.get("entries") or []):
|
|
263
|
+
if it.get("id") in ids:
|
|
264
|
+
it["hits"] = int(it.get("hits") or 0) + 1
|
|
265
|
+
dirty = True
|
|
266
|
+
if dirty:
|
|
267
|
+
_save(data)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------- 自动整理
|
|
271
|
+
|
|
272
|
+
KNOWLEDGE_PROMPT = """你是编排系统的知识管理员。下面是一次任务的目标与它的产出材料(调研报告/文档等)。
|
|
273
|
+
请从产出中提炼可长期复用的内容,并明确分成两类:
|
|
274
|
+
- fact:回答「是什么」,只含领域事实、外部规则和可验证结论,进入知识库;
|
|
275
|
+
- practice:回答「怎么做」,含流程、操作方法和正向实践,进入经验库;此类必须填写 category。
|
|
276
|
+
「下次要避免什么」这类负面教训由评审复盘流程负责,不要重复提炼。
|
|
277
|
+
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
278
|
+
{"entries": [{"kind": "fact 或 practice", "title": "≤20 字的标题", "body": "具体内容(≤200 字,自含上下文,脱离本次任务也能看懂)", "category": "practice 的经验分类", "tags": ["1-3 个检索标签"], "as_of": "YYYY-MM-DD(fact 的事实采集日)"}]}
|
|
279
|
+
category 只能从以下枚举选择:__CATEGORIES__。
|
|
280
|
+
最多 3 条,只保留有明确复用价值的;产出里没有值得沉淀的就返回空数组。
|
|
281
|
+
|
|
282
|
+
## 任务类型
|
|
283
|
+
__TYPE__
|
|
284
|
+
|
|
285
|
+
## 任务目标
|
|
286
|
+
__GOAL__
|
|
287
|
+
|
|
288
|
+
## 产出材料(节选)
|
|
289
|
+
__MATERIAL__"""
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _read_text(p):
|
|
293
|
+
"""产物读取:utf-8 严格优先,GBK 兜底(codex/pwsh 落盘编码不一)。"""
|
|
294
|
+
try:
|
|
295
|
+
return p.read_text(encoding="utf-8")
|
|
296
|
+
except Exception:
|
|
297
|
+
try:
|
|
298
|
+
return p.read_text(encoding="gbk", errors="replace")
|
|
299
|
+
except Exception:
|
|
300
|
+
return ""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _pick_material(run):
|
|
304
|
+
"""挑本次 run 的产出材料喂给编排者:最近的 .md/.txt 文档各截 4000 字,
|
|
305
|
+
外加运行目录里的评审报告。总量封顶 10000 字。"""
|
|
306
|
+
from . import store
|
|
307
|
+
try:
|
|
308
|
+
wd, files = store.run_artifacts(run.get("id"))
|
|
309
|
+
except Exception:
|
|
310
|
+
return ""
|
|
311
|
+
chunks = []
|
|
312
|
+
try:
|
|
313
|
+
rp = paths.RUNS_DIR / (run.get("id") or "") / "report.md"
|
|
314
|
+
if rp.is_file():
|
|
315
|
+
t = _read_text(rp)
|
|
316
|
+
if t.strip():
|
|
317
|
+
chunks.append("### 评审报告(节选)\n" + t[:4000])
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
docs = [f for f in (files or [])
|
|
321
|
+
if str(f.get("name", "")).lower().endswith((".md", ".txt"))
|
|
322
|
+
and int(f.get("size") or 0) < 500_000]
|
|
323
|
+
for f in docs[:3]:
|
|
324
|
+
t = _read_text(Path(wd) / f["name"])
|
|
325
|
+
if t.strip():
|
|
326
|
+
chunks.append("### " + f["name"] + "\n" + t[:4000])
|
|
327
|
+
return "\n\n".join(chunks)[:10000]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def learn_from_run(run_id):
|
|
331
|
+
"""运行结束后从产出提炼事实与实践并分流入库。返回总写入条数。
|
|
332
|
+
|
|
333
|
+
知识没有便宜的兜底路径:无编排者/无产出/非真实运行(mock)一律静默跳过,
|
|
334
|
+
宁缺毋滥——教训库的规则兜底搬到这里只会制造垃圾知识。只有正常跑完(done)
|
|
335
|
+
的运行才提炼:失败/取消的运行产物是半成品,据此沉淀的知识会污染知识库。
|
|
336
|
+
"""
|
|
337
|
+
from . import store
|
|
338
|
+
run = store.get_run(run_id)
|
|
339
|
+
if not run:
|
|
340
|
+
return 0
|
|
341
|
+
if (run.get("status") or "") != "done":
|
|
342
|
+
return 0
|
|
343
|
+
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
344
|
+
if not task:
|
|
345
|
+
return 0
|
|
346
|
+
if all((s.get("agent") or "").startswith("mock") for s in (run.get("steps") or [])):
|
|
347
|
+
return 0
|
|
348
|
+
material = _pick_material(run)
|
|
349
|
+
if not material:
|
|
350
|
+
return 0
|
|
351
|
+
n = 0
|
|
352
|
+
try:
|
|
353
|
+
from . import modelhub, runner
|
|
354
|
+
orch = modelhub.resolve_orchestrator()
|
|
355
|
+
if not orch:
|
|
356
|
+
return 0
|
|
357
|
+
prov, model = orch
|
|
358
|
+
prompt = (KNOWLEDGE_PROMPT
|
|
359
|
+
.replace("__CATEGORIES__", "、".join(skills.LESSON_CATEGORIES))
|
|
360
|
+
.replace("__TYPE__", str(task.get("type")))
|
|
361
|
+
.replace("__GOAL__", (task.get("goal") or "")[:600])
|
|
362
|
+
.replace("__MATERIAL__", material))
|
|
363
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
|
|
364
|
+
if not res.get("ok"):
|
|
365
|
+
return 0
|
|
366
|
+
data = runner.extract_json(res.get("text") or "")
|
|
367
|
+
raw = (data or {}).get("entries") if isinstance(data, dict) else None
|
|
368
|
+
if not isinstance(raw, list):
|
|
369
|
+
return 0
|
|
370
|
+
for x in raw[:3]:
|
|
371
|
+
if not (isinstance(x, dict) and x.get("title") and x.get("body")):
|
|
372
|
+
continue
|
|
373
|
+
kind = str(x.get("kind") or "fact").strip().lower()
|
|
374
|
+
if kind == "practice":
|
|
375
|
+
if skills.upsert_lesson(task.get("type") or "*", x["title"],
|
|
376
|
+
x["body"], source=run_id,
|
|
377
|
+
category=x.get("category"),
|
|
378
|
+
dim="%s %s" % (x["title"], x["body"])):
|
|
379
|
+
n += 1
|
|
380
|
+
continue
|
|
381
|
+
as_of = str(x.get("as_of") or "").strip()
|
|
382
|
+
if not re.match(r"^\d{4}-\d{2}-\d{2}$", as_of):
|
|
383
|
+
as_of = _now()[:10]
|
|
384
|
+
if upsert_entry(task.get("type") or "*", x["title"], x["body"],
|
|
385
|
+
tags=x.get("tags"), source=run_id,
|
|
386
|
+
as_of=as_of, status="approved"):
|
|
387
|
+
n += 1
|
|
388
|
+
except Exception:
|
|
389
|
+
return n
|
|
390
|
+
return n
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def learn_async(run_id):
|
|
394
|
+
"""异步提炼(不阻塞任务收尾,与教训沉淀同款语义)。"""
|
|
395
|
+
def _run():
|
|
396
|
+
try:
|
|
397
|
+
learn_from_run(run_id)
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
threading.Thread(target=_run, name="kb-learn", daemon=True).start()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def migrate_drafts_approved():
|
|
404
|
+
"""一次性迁移:草稿闸门退役(默认直接转正),把历史 draft 全部转正。
|
|
405
|
+
|
|
406
|
+
幂等——没有 draft 时零写入。返回转正条数。"""
|
|
407
|
+
with _LOCK:
|
|
408
|
+
data = _load()
|
|
409
|
+
items = data.get("entries") or []
|
|
410
|
+
dirty = [x for x in items if (x.get("status") or "draft") != "approved"]
|
|
411
|
+
if not dirty:
|
|
412
|
+
return 0
|
|
413
|
+
for x in dirty:
|
|
414
|
+
x["status"] = "approved"
|
|
415
|
+
x["updated_at"] = _now()
|
|
416
|
+
_save(data)
|
|
417
|
+
return len(dirty)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def view():
|
|
421
|
+
"""知识库总览(给 UI/API):条目 + 标签聚合 + 草稿数 + 账龄标记。"""
|
|
422
|
+
entries = list_entries()
|
|
423
|
+
tags, drafts = {}, 0
|
|
424
|
+
for x in entries:
|
|
425
|
+
for tg in (x.get("tags") or []):
|
|
426
|
+
tags[tg] = tags.get(tg, 0) + 1
|
|
427
|
+
if (x.get("status") or "draft") != "approved":
|
|
428
|
+
drafts += 1
|
|
429
|
+
x["stale"] = _is_stale(x.get("as_of"))
|
|
430
|
+
return {"entries": entries,
|
|
431
|
+
"tags": sorted(tags, key=lambda t: (-tags[t], t)),
|
|
432
|
+
"tag_counts": tags, "drafts": drafts, "total": len(entries)}
|