codebee 0.1.16 → 0.1.18
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 +18 -2
- package/README.md +3 -3
- package/app/core/jobs.py +23 -10
- package/app/core/knowledge.py +371 -0
- package/app/core/pipeline.py +91 -7
- package/app/core/store.py +13 -1
- package/app/main.py +13 -0
- package/app/ui/app.js +159 -15
- package/app/ui/i18n.js +196 -2
- package/app/ui/index.html +29 -8
- package/app/ui/style.css +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,10 +4,26 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
|
|
|
4
4
|
`<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
|
|
5
5
|
README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
|
|
6
6
|
|
|
7
|
+
## v0.1.18(2026-09-20)
|
|
8
|
+
|
|
9
|
+
- 知识库:运行产出自动整理成可复用知识——编排者从产出材料(调研报告/文档)提炼知识条目(草稿态),人工「转正」后按任务类型+相关性自动注入同类任务的规划与评审提示词;与经验库分工(教训记「别这么做」,知识记「已知是这样」);设置页独立「知识库」子页,搜索/状态/标签过滤+手动新建(即个人知识管理库);事实带账龄,as_of 超 90 天注入时自动标注「可能过期」
|
|
10
|
+
- 封面生成:智谱 CogView 接线(CODEBEE_IMAGE_WATERMARK=1 才保留供应商水印);封面落运行目录,专用通道内嵌缩略图
|
|
11
|
+
- 七猫发布纠偏:目标读者/分类旧数据不自洽时按官方目录反查,不再死在流程中段
|
|
12
|
+
- 队列补队治理:CLI 安装/升级等管理操作也纳入启动补队与巡检(selfupgrade 除外),排队不再无人接案
|
|
13
|
+
|
|
14
|
+
## v0.1.17(2026-09-20)
|
|
15
|
+
|
|
16
|
+
- 任务宪章:工作目录 .codebee/constitution.md 一次定义质量原则(代码规范/文风/测试要求),每次运行自动注入所有智能体——注入体系五层就位(宪章>圣经>模块库>经验>记忆)
|
|
17
|
+
- 代码评审证据锚定:每个问题必须带「文件名:行号」+一行关键代码引用,无证据不报(借鉴 pr-af)
|
|
18
|
+
- 调研报告证据链:同结论双源交叉验证、来源行内标明、事实与观点分离、结论先行+风险局限结构(借鉴 gpt-researcher)
|
|
19
|
+
- 评审维度写前自查:起草提示词亮出评审打分维度,作者提前对齐重点
|
|
20
|
+
- 圣经连续性锁:跨章设定原样沿用不问义改写,每章用词一致读者不出戏(借鉴 drama-skills)
|
|
21
|
+
|
|
7
22
|
## v0.1.16(2026-09-19)
|
|
8
23
|
|
|
9
|
-
- 帮助中心:首启欢迎引导升级为「左目录 +
|
|
10
|
-
- 就地帮助问号:模型接入、CLI
|
|
24
|
+
- 帮助中心:首启欢迎引导升级为「左目录 + 右内容」多章帮助中心,八章全就绪——快速上手、模型接入与绑定(多 Key 轮换 / 协议徽章解读 / 报错速查 / 免费档起步)、功能一览、连载创作流程、发布上架、代码任务与版本、自动化与技能市场、常见问题(9 条踩坑 FAQ)。首启仍只自动弹一次落在「快速上手」;手动打开(设置导航/关于页/Ctrl+K/**F1**)徽章与标题显示「帮助中心」,原三处「使用引导」入口统一更名
|
|
25
|
+
- 就地帮助问号:模型接入、CLI 绑定、智能体目录、自动化、插件市场、禅道、用量统计、经验库八个设置页头 + Git 工作台/代码版本隔离/蜂巢工作台/作品信息四处工作台头部共十二处新增小「?」,点击直达帮助中心对应章节;供应商空状态与「还没有装好的 CLI」空状态附「看帮助」链接
|
|
26
|
+
- 帮助中心新增「代码任务与版本」章(任务分支隔离 / 版本裁决合并与丢弃 / 蜂巢与步骤阅读指引),「自动化与技能市场」章补禅道 Bug 自动修复小节
|
|
11
27
|
|
|
12
28
|
## v0.1.15(2026-09-19)
|
|
13
29
|
|
package/README.md
CHANGED
|
@@ -21,10 +21,10 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
21
21
|
不会把你的任务内容交给任何第三方。
|
|
22
22
|
|
|
23
23
|
<!-- relnotes:start -->
|
|
24
|
-
### 最新版更新内容(v0.1.
|
|
24
|
+
### 最新版更新内容(v0.1.18)
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
26
|
+
- 新增「知识库」:产出材料自动提炼成知识条目,转正后按任务类型自动注入规划与评审——你的个人知识管理库
|
|
27
|
+
- 新增封面生成(智谱 CogView);七猫发布分类纠偏;管理操作排队补队治理
|
|
28
28
|
<!-- relnotes:end -->
|
|
29
29
|
|
|
30
30
|
---
|
package/app/core/jobs.py
CHANGED
|
@@ -382,11 +382,15 @@ def _in_resume_backoff(run, now=None):
|
|
|
382
382
|
|
|
383
383
|
|
|
384
384
|
def requeue_pending(limit=10, max_age_s=None):
|
|
385
|
-
"""把遗留的 queued
|
|
385
|
+
"""把遗留的 queued 运行重新入队(启动补队与运行期巡检共用)。
|
|
386
386
|
|
|
387
387
|
队列在内存里,进程一死排队项就没人管了(2026-09-18 七猫 r-162724
|
|
388
388
|
排队僵尸案:续跑副本 created 后服务重启,Timer 随进程蒸发,运行永远
|
|
389
389
|
停在「排队中」)。同任务已有在跑/排队的不重复补。
|
|
390
|
+
mgmt 同样纳入:CLI 安装/升级 job 也只存在于内存队列,worker 线程
|
|
391
|
+
起失败(杀软挂起 Thread.start)或入队丢失后永远「排队中」,还堵住
|
|
392
|
+
同条目去重闸(2026-09-19 三连 CLI 升级排队无人接案)。selfupgrade
|
|
393
|
+
不补——升级本体有进程替换语义,自动重排不可控。
|
|
390
394
|
|
|
391
395
|
max_age_s:巡检模式只补「卡了超过该秒数」的,刚入队的正常排队不掺和;
|
|
392
396
|
None(启动模式)全量补。resume_enqueue_at 未到点的续跑副本两种模式都
|
|
@@ -400,18 +404,27 @@ def requeue_pending(limit=10, max_age_s=None):
|
|
|
400
404
|
for run in store.list_runs(200):
|
|
401
405
|
if n >= limit:
|
|
402
406
|
break
|
|
403
|
-
|
|
407
|
+
kind = run.get("kind")
|
|
408
|
+
if run.get("status") != "queued" or kind not in ("orchestration", "mgmt"):
|
|
404
409
|
continue
|
|
405
|
-
if not run.get("task_id") or not _recent(run):
|
|
406
|
-
continue
|
|
407
|
-
if _in_resume_backoff(run):
|
|
408
|
-
continue # 退避窗口内的续跑副本:到点 Timer 自会入队
|
|
409
410
|
if max_age_s is not None and _age_s(run) < max_age_s:
|
|
410
411
|
continue
|
|
411
|
-
if
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
412
|
+
if kind == "orchestration":
|
|
413
|
+
if not run.get("task_id") or not _recent(run):
|
|
414
|
+
continue
|
|
415
|
+
if _in_resume_backoff(run):
|
|
416
|
+
continue # 退避窗口内的续跑副本:到点 Timer 自会入队
|
|
417
|
+
if _task_active_run(run["task_id"], exclude_run_id=run["id"]):
|
|
418
|
+
continue # 同任务已有更活跃的运行,别再排一份
|
|
419
|
+
else:
|
|
420
|
+
eid = run.get("entry_id")
|
|
421
|
+
if eid:
|
|
422
|
+
other = store.active_mgmt_run(eid)
|
|
423
|
+
if other and other.get("id") != run["id"]:
|
|
424
|
+
continue # 同条目已有更活跃的 run,去重闸语义收敛
|
|
425
|
+
_QUEUE.put({"kind": kind, "run_id": run["id"],
|
|
426
|
+
"task_id": run.get("task_id"),
|
|
427
|
+
"entry_id": run.get("entry_id"), "op": run.get("op")})
|
|
415
428
|
n += 1
|
|
416
429
|
except Exception:
|
|
417
430
|
pass
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""知识库(Knowledge):运行产出自动整理形成的可复用知识 + 个人知识管理入口。
|
|
3
|
+
|
|
4
|
+
与经验库(skills.py)的分工边界:
|
|
5
|
+
- 教训(lessons)记「别这么做」——负面规则,来源=评审暴露的问题(verdict/issues);
|
|
6
|
+
- 知识(knowledge)记「已知是这样」——领域事实/平台规则/结论/方法论,
|
|
7
|
+
来源=run 产出材料(调研报告/文档)。两者输入源不同,提炼互不双写。
|
|
8
|
+
|
|
9
|
+
质量闸门:自动提炼的条目一律落 draft(草稿态),人工「转正」后才参与注入——
|
|
10
|
+
垃圾知识进了提示词比没有知识更糟;手动新建即视为已确认(直接 approved)。
|
|
11
|
+
|
|
12
|
+
注入纪律(与教训反着来):教训是全量小注(top-8),知识默认不注入——
|
|
13
|
+
scope 命中且库里有 approved 条目才成块,按与任务目标的相关性取 top,
|
|
14
|
+
独立预算截断(KNOWLEDGE_BUDGET),绝不挤占经验包/圣经的空间。
|
|
15
|
+
选择依据在单次注入内恒定(goal 固定、id 唯一),同一任务字节稳定,
|
|
16
|
+
不碎供应商前缀缓存。
|
|
17
|
+
|
|
18
|
+
事实会过期:竞品/平台类知识每条带 as_of(事实采集日),超过 STALE_DAYS
|
|
19
|
+
注入时自动标注「可能过期」,提示模型自行核实时效。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from . import paths, skills
|
|
31
|
+
|
|
32
|
+
_LOCK = threading.RLock()
|
|
33
|
+
_FILE = paths.DATA_DIR / "knowledge.json"
|
|
34
|
+
|
|
35
|
+
KNOWLEDGE_BUDGET = 3000 # 注入块字符预算(独立于经验包的 MAX_INJECT_CHARS)
|
|
36
|
+
KNOWLEDGE_MAX_INJECT = 6 # 单次注入条数上限
|
|
37
|
+
STALE_DAYS = 90 # as_of 超过该天数标注「可能过期」
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _now():
|
|
41
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load():
|
|
45
|
+
try:
|
|
46
|
+
return json.loads(_FILE.read_text(encoding="utf-8"))
|
|
47
|
+
except Exception:
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _save(data):
|
|
52
|
+
try:
|
|
53
|
+
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=1),
|
|
55
|
+
encoding="utf-8")
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _entry_id(scope, title):
|
|
61
|
+
"""去重指纹:scope + 归一化标题(沿用教训库的 _norm_title 标点剥离)。"""
|
|
62
|
+
h = hashlib.sha256(("%s|%s" % (scope, skills._norm_title(title)))
|
|
63
|
+
.encode("utf-8")).hexdigest()[:12]
|
|
64
|
+
return "kb-" + h
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_entries(scope=None, status=None, tag=None, only_enabled=False):
|
|
68
|
+
with _LOCK:
|
|
69
|
+
items = list(_load().get("entries") or [])
|
|
70
|
+
if scope:
|
|
71
|
+
items = [x for x in items if x.get("scope") in (scope, "*")]
|
|
72
|
+
if status:
|
|
73
|
+
items = [x for x in items if (x.get("status") or "draft") == status]
|
|
74
|
+
if tag:
|
|
75
|
+
items = [x for x in items if tag in (x.get("tags") or [])]
|
|
76
|
+
if only_enabled:
|
|
77
|
+
items = [x for x in items if x.get("enabled", True)]
|
|
78
|
+
items.sort(key=lambda x: (x.get("updated_at") or x.get("created_at") or ""),
|
|
79
|
+
reverse=True)
|
|
80
|
+
return items
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
84
|
+
as_of=None, status="draft"):
|
|
85
|
+
"""写入/合并一条知识。同 scope 同标题视为同一条(指纹去重):
|
|
86
|
+
- 已有条目是 approved 且新条是 draft:不覆盖正文(人工确认过的内容优先),
|
|
87
|
+
新内容记入 revisions 作为修订候选,seen+1;
|
|
88
|
+
- 其余情况(已有是 draft,或新条是 approved):正文取新。
|
|
89
|
+
返回条目;title/body 为空返回 None。"""
|
|
90
|
+
title = str(title or "").strip()[:80]
|
|
91
|
+
body = str(body or "").strip()[:1500]
|
|
92
|
+
if not title or not body:
|
|
93
|
+
return None
|
|
94
|
+
tags = [str(t).strip()[:20] for t in (tags or []) if str(t).strip()][:6]
|
|
95
|
+
status = status if status in ("draft", "approved") else "draft"
|
|
96
|
+
scope = str(scope or "*").strip() or "*"
|
|
97
|
+
kid = _entry_id(scope, title)
|
|
98
|
+
with _LOCK:
|
|
99
|
+
data = _load()
|
|
100
|
+
items = data.setdefault("entries", [])
|
|
101
|
+
for it in items:
|
|
102
|
+
if it.get("id") != kid:
|
|
103
|
+
continue
|
|
104
|
+
it["seen"] = int(it.get("seen") or 1) + 1
|
|
105
|
+
if status == "approved" or it.get("status") != "approved":
|
|
106
|
+
it["body"] = body
|
|
107
|
+
if status == "approved":
|
|
108
|
+
it["status"] = "approved"
|
|
109
|
+
else:
|
|
110
|
+
it.setdefault("revisions", []).append(
|
|
111
|
+
{"at": _now(), "body": body, "source": source})
|
|
112
|
+
it["revisions"] = it["revisions"][-5:] # 最多留 5 条修订候选
|
|
113
|
+
for tg in tags:
|
|
114
|
+
if tg not in (it.get("tags") or []):
|
|
115
|
+
it["tags"] = (it.get("tags") or []) + [tg]
|
|
116
|
+
it["tags"] = (it.get("tags") or [])[:8]
|
|
117
|
+
if as_of:
|
|
118
|
+
it["as_of"] = str(as_of)[:10]
|
|
119
|
+
if source:
|
|
120
|
+
it["source"] = source
|
|
121
|
+
if source_file:
|
|
122
|
+
it["source_file"] = source_file
|
|
123
|
+
it["updated_at"] = _now()
|
|
124
|
+
_save(data)
|
|
125
|
+
return it
|
|
126
|
+
it = {"id": kid, "scope": scope, "title": title, "body": body,
|
|
127
|
+
"tags": tags, "status": status, "enabled": True,
|
|
128
|
+
"source": source, "source_file": source_file,
|
|
129
|
+
"as_of": str(as_of or _now()[:10])[:10],
|
|
130
|
+
"revisions": [], "hits": 0, "seen": 1,
|
|
131
|
+
"created_at": _now(), "updated_at": _now(), "kind": "knowledge"}
|
|
132
|
+
items.append(it)
|
|
133
|
+
_save(data)
|
|
134
|
+
return it
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def entry_op(entry_id, op, fields=None):
|
|
138
|
+
"""新建/编辑/转正/启停/删除。返回错误或 None。
|
|
139
|
+
|
|
140
|
+
create:手动新建直接 approved(人工录入即确认);edit:改标题/正文/标签/
|
|
141
|
+
范围/账龄,不改状态——转正必须显式走 approve,保持人工闸门。
|
|
142
|
+
"""
|
|
143
|
+
if op not in ("create", "edit", "approve", "enable", "disable", "delete"):
|
|
144
|
+
return "未知操作 " + str(op)
|
|
145
|
+
if op == "create":
|
|
146
|
+
f = fields or {}
|
|
147
|
+
it = upsert_entry(f.get("scope") or "*", f.get("title") or "",
|
|
148
|
+
f.get("body") or "", tags=f.get("tags"),
|
|
149
|
+
as_of=(f.get("as_of") or "").strip() or None,
|
|
150
|
+
status="approved")
|
|
151
|
+
return None if it else "标题与正文不能为空"
|
|
152
|
+
with _LOCK:
|
|
153
|
+
data = _load()
|
|
154
|
+
items = data.get("entries") or []
|
|
155
|
+
hit = next((x for x in items if x.get("id") == entry_id), None)
|
|
156
|
+
if not hit:
|
|
157
|
+
return "知识条目不存在"
|
|
158
|
+
if op == "delete":
|
|
159
|
+
data["entries"] = [x for x in items if x.get("id") != entry_id]
|
|
160
|
+
elif op == "approve":
|
|
161
|
+
hit["status"] = "approved"
|
|
162
|
+
hit["updated_at"] = _now()
|
|
163
|
+
elif op == "edit":
|
|
164
|
+
f = fields or {}
|
|
165
|
+
if str(f.get("title") or "").strip():
|
|
166
|
+
hit["title"] = str(f["title"]).strip()[:80]
|
|
167
|
+
if str(f.get("body") or "").strip():
|
|
168
|
+
hit["body"] = str(f["body"]).strip()[:1500]
|
|
169
|
+
if "tags" in f:
|
|
170
|
+
hit["tags"] = [str(t).strip()[:20]
|
|
171
|
+
for t in (f.get("tags") or []) if str(t).strip()][:8]
|
|
172
|
+
if str(f.get("scope") or "").strip():
|
|
173
|
+
hit["scope"] = str(f["scope"]).strip()[:30]
|
|
174
|
+
if str(f.get("as_of") or "").strip():
|
|
175
|
+
hit["as_of"] = str(f["as_of"]).strip()[:10]
|
|
176
|
+
hit["updated_at"] = _now()
|
|
177
|
+
else:
|
|
178
|
+
hit["enabled"] = (op == "enable")
|
|
179
|
+
_save(data)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _is_stale(as_of):
|
|
184
|
+
try:
|
|
185
|
+
d = time.strptime(str(as_of or "")[:10], "%Y-%m-%d")
|
|
186
|
+
return (time.mktime(time.localtime()) - time.mktime(d)) / 86400.0 > STALE_DAYS
|
|
187
|
+
except Exception:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def block_for(task):
|
|
192
|
+
"""生成注入提示词的知识块。默认不注入:scope 命中且存在 approved 条目才成块,
|
|
193
|
+
按与任务目标/上下文的相关性取 top(复用教训库的 bigram 检索),独立预算截断。
|
|
194
|
+
无命中返回 ""——字节稳定,不碎供应商前缀缓存。"""
|
|
195
|
+
scope = (task or {}).get("type") or "*"
|
|
196
|
+
entries = [x for x in list_entries(scope, only_enabled=True)
|
|
197
|
+
if (x.get("status") or "draft") == "approved"]
|
|
198
|
+
if not entries:
|
|
199
|
+
return ""
|
|
200
|
+
probe = (skills._text_bigrams((task or {}).get("goal"))
|
|
201
|
+
| skills._text_bigrams((task or {}).get("context"))
|
|
202
|
+
| skills._text_bigrams((task or {}).get("title")))
|
|
203
|
+
if probe:
|
|
204
|
+
def rank(x):
|
|
205
|
+
grams = (skills._text_bigrams(x.get("title"))
|
|
206
|
+
| skills._text_bigrams(x.get("body"))
|
|
207
|
+
| set(x.get("tags") or []))
|
|
208
|
+
return (-len(probe & grams), x.get("id") or "")
|
|
209
|
+
entries = sorted(entries, key=rank)
|
|
210
|
+
else:
|
|
211
|
+
entries.sort(key=lambda x: x.get("id") or "")
|
|
212
|
+
entries = entries[:KNOWLEDGE_MAX_INJECT]
|
|
213
|
+
|
|
214
|
+
lines, used = [], []
|
|
215
|
+
for x in entries:
|
|
216
|
+
stale = _is_stale(x.get("as_of"))
|
|
217
|
+
mark = ("(事实截至 %s,可能过期,请自行核实时效)" % x.get("as_of") if stale
|
|
218
|
+
else ("(事实截至 %s)" % x.get("as_of") if x.get("as_of") else ""))
|
|
219
|
+
lines.append("- **%s**%s:%s" % (x["title"], mark, x["body"]))
|
|
220
|
+
used.append(x["id"])
|
|
221
|
+
text = "## 知识库(已确认的领域知识,供参考)\n\n" + "\n".join(lines)
|
|
222
|
+
if len(text) > KNOWLEDGE_BUDGET:
|
|
223
|
+
text = text[:KNOWLEDGE_BUDGET] + "\n…(已截断)"
|
|
224
|
+
if used:
|
|
225
|
+
_bump_hits(used)
|
|
226
|
+
return text
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _bump_hits(ids):
|
|
230
|
+
with _LOCK:
|
|
231
|
+
data = _load()
|
|
232
|
+
dirty = False
|
|
233
|
+
for it in (data.get("entries") or []):
|
|
234
|
+
if it.get("id") in ids:
|
|
235
|
+
it["hits"] = int(it.get("hits") or 0) + 1
|
|
236
|
+
dirty = True
|
|
237
|
+
if dirty:
|
|
238
|
+
_save(data)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------- 自动整理
|
|
242
|
+
|
|
243
|
+
KNOWLEDGE_PROMPT = """你是编排系统的知识管理员。下面是一次任务的目标与它的产出材料(调研报告/文档等)。
|
|
244
|
+
请从产出中提炼**可长期复用的知识条目**:领域事实、平台规则、结论、方法论。
|
|
245
|
+
注意:只提炼事实性/结论性内容;「下次要避免什么」这类负面教训由另一个复盘流程负责,你不要写。
|
|
246
|
+
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
247
|
+
{"entries": [{"title": "≤20 字的知识标题", "body": "具体结论(≤200 字,自含上下文,脱离本次任务也能看懂)", "tags": ["1-3 个检索标签"], "as_of": "YYYY-MM-DD(事实采集日)"}]}
|
|
248
|
+
最多 3 条,只保留有明确复用价值的;产出里没有值得沉淀的就返回空数组。
|
|
249
|
+
|
|
250
|
+
## 任务类型
|
|
251
|
+
__TYPE__
|
|
252
|
+
|
|
253
|
+
## 任务目标
|
|
254
|
+
__GOAL__
|
|
255
|
+
|
|
256
|
+
## 产出材料(节选)
|
|
257
|
+
__MATERIAL__"""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _read_text(p):
|
|
261
|
+
"""产物读取:utf-8 严格优先,GBK 兜底(codex/pwsh 落盘编码不一)。"""
|
|
262
|
+
try:
|
|
263
|
+
return p.read_text(encoding="utf-8")
|
|
264
|
+
except Exception:
|
|
265
|
+
try:
|
|
266
|
+
return p.read_text(encoding="gbk", errors="replace")
|
|
267
|
+
except Exception:
|
|
268
|
+
return ""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _pick_material(run):
|
|
272
|
+
"""挑本次 run 的产出材料喂给编排者:最近的 .md/.txt 文档各截 4000 字,
|
|
273
|
+
外加运行目录里的评审报告。总量封顶 10000 字。"""
|
|
274
|
+
from . import store
|
|
275
|
+
try:
|
|
276
|
+
wd, files = store.run_artifacts(run.get("id"))
|
|
277
|
+
except Exception:
|
|
278
|
+
return ""
|
|
279
|
+
chunks = []
|
|
280
|
+
try:
|
|
281
|
+
rp = paths.RUNS_DIR / (run.get("id") or "") / "report.md"
|
|
282
|
+
if rp.is_file():
|
|
283
|
+
t = _read_text(rp)
|
|
284
|
+
if t.strip():
|
|
285
|
+
chunks.append("### 评审报告(节选)\n" + t[:4000])
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
docs = [f for f in (files or [])
|
|
289
|
+
if str(f.get("name", "")).lower().endswith((".md", ".txt"))
|
|
290
|
+
and int(f.get("size") or 0) < 500_000]
|
|
291
|
+
for f in docs[:3]:
|
|
292
|
+
t = _read_text(Path(wd) / f["name"])
|
|
293
|
+
if t.strip():
|
|
294
|
+
chunks.append("### " + f["name"] + "\n" + t[:4000])
|
|
295
|
+
return "\n\n".join(chunks)[:10000]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def learn_from_run(run_id):
|
|
299
|
+
"""运行结束后由编排者从产出材料提炼知识条目(草稿态)。返回写入条数。
|
|
300
|
+
|
|
301
|
+
知识没有便宜的兜底路径:无编排者/无产出/非真实运行(mock)一律静默跳过,
|
|
302
|
+
宁缺毋滥——教训库的规则兜底搬到这里只会制造垃圾知识。
|
|
303
|
+
"""
|
|
304
|
+
from . import store
|
|
305
|
+
run = store.get_run(run_id)
|
|
306
|
+
if not run:
|
|
307
|
+
return 0
|
|
308
|
+
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
309
|
+
if not task:
|
|
310
|
+
return 0
|
|
311
|
+
if all((s.get("agent") or "").startswith("mock") for s in (run.get("steps") or [])):
|
|
312
|
+
return 0
|
|
313
|
+
material = _pick_material(run)
|
|
314
|
+
if not material:
|
|
315
|
+
return 0
|
|
316
|
+
n = 0
|
|
317
|
+
try:
|
|
318
|
+
from . import modelhub, runner
|
|
319
|
+
orch = modelhub.resolve_orchestrator()
|
|
320
|
+
if not orch:
|
|
321
|
+
return 0
|
|
322
|
+
prov, model = orch
|
|
323
|
+
prompt = (KNOWLEDGE_PROMPT
|
|
324
|
+
.replace("__TYPE__", str(task.get("type")))
|
|
325
|
+
.replace("__GOAL__", (task.get("goal") or "")[:600])
|
|
326
|
+
.replace("__MATERIAL__", material))
|
|
327
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
|
|
328
|
+
if not res.get("ok"):
|
|
329
|
+
return 0
|
|
330
|
+
data = runner.extract_json(res.get("text") or "")
|
|
331
|
+
raw = (data or {}).get("entries") if isinstance(data, dict) else None
|
|
332
|
+
if not isinstance(raw, list):
|
|
333
|
+
return 0
|
|
334
|
+
for x in raw[:3]:
|
|
335
|
+
if not (isinstance(x, dict) and x.get("title") and x.get("body")):
|
|
336
|
+
continue
|
|
337
|
+
as_of = str(x.get("as_of") or "").strip()
|
|
338
|
+
if not re.match(r"^\d{4}-\d{2}-\d{2}$", as_of):
|
|
339
|
+
as_of = _now()[:10]
|
|
340
|
+
if upsert_entry(task.get("type") or "*", x["title"], x["body"],
|
|
341
|
+
tags=x.get("tags"), source=run_id,
|
|
342
|
+
as_of=as_of, status="draft"):
|
|
343
|
+
n += 1
|
|
344
|
+
except Exception:
|
|
345
|
+
return n
|
|
346
|
+
return n
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def learn_async(run_id):
|
|
350
|
+
"""异步提炼(不阻塞任务收尾,与教训沉淀同款语义)。"""
|
|
351
|
+
def _run():
|
|
352
|
+
try:
|
|
353
|
+
learn_from_run(run_id)
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
threading.Thread(target=_run, name="kb-learn", daemon=True).start()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def view():
|
|
360
|
+
"""知识库总览(给 UI/API):条目 + 标签聚合 + 草稿数 + 账龄标记。"""
|
|
361
|
+
entries = list_entries()
|
|
362
|
+
tags, drafts = {}, 0
|
|
363
|
+
for x in entries:
|
|
364
|
+
for tg in (x.get("tags") or []):
|
|
365
|
+
tags[tg] = tags.get(tg, 0) + 1
|
|
366
|
+
if (x.get("status") or "draft") != "approved":
|
|
367
|
+
drafts += 1
|
|
368
|
+
x["stale"] = _is_stale(x.get("as_of"))
|
|
369
|
+
return {"entries": entries,
|
|
370
|
+
"tags": sorted(tags, key=lambda t: (-tags[t], t)),
|
|
371
|
+
"tag_counts": tags, "drafts": drafts, "total": len(entries)}
|