kc-beta 0.7.5 → 0.8.1
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/README.md +47 -0
- package/package.json +3 -2
- package/src/agent/engine.js +390 -100
- package/src/agent/pipelines/_advance-hints.js +92 -0
- package/src/agent/pipelines/_milestone-derive.js +247 -13
- package/src/agent/pipelines/skill-authoring.js +30 -1
- package/src/agent/tools/agent-tool.js +2 -2
- package/src/agent/tools/consult-skill.js +15 -0
- package/src/agent/tools/dashboard-render.js +48 -1
- package/src/agent/tools/document-parse.js +31 -2
- package/src/agent/tools/phase-advance.js +17 -13
- package/src/agent/tools/release.js +250 -7
- package/src/agent/tools/sandbox-exec.js +65 -8
- package/src/agent/tools/worker-llm-call.js +95 -15
- package/src/agent/workspace.js +25 -4
- package/src/cli/components.js +4 -1
- package/src/cli/index.js +97 -1
- package/src/config.js +19 -2
- package/src/marathon/driver.js +217 -0
- package/src/marathon/prompts.js +93 -0
- package/template/.env.template +16 -0
- package/template/skills/en/bootstrap-workspace/SKILL.md +14 -0
- package/template/skills/en/quality-control/SKILL.md +9 -0
- package/template/skills/en/skill-authoring/SKILL.md +39 -0
- package/template/skills/en/skill-to-workflow/SKILL.md +53 -0
- package/template/skills/en/work-decomposition/SKILL.md +34 -0
- package/template/skills/phase_skills.yaml +5 -0
- package/template/skills/zh/bootstrap-workspace/SKILL.md +14 -0
- package/template/skills/zh/compliance-judgment/SKILL.md +37 -37
- package/template/skills/zh/document-chunking/SKILL.md +21 -14
- package/template/skills/zh/document-parsing/SKILL.md +65 -65
- package/template/skills/zh/entity-extraction/SKILL.md +68 -68
- package/template/skills/zh/quality-control/SKILL.md +9 -0
- package/template/skills/zh/skill-authoring/SKILL.md +39 -0
- package/template/skills/zh/skill-creator/SKILL.md +204 -200
- package/template/skills/zh/skill-to-workflow/SKILL.md +53 -0
- package/template/skills/zh/tree-processing/SKILL.md +67 -63
- package/template/skills/zh/work-decomposition/SKILL.md +34 -0
- package/template/workflows/common/llm_client.py +168 -0
- package/template/workflows/common/utils.py +132 -0
|
@@ -188,3 +188,56 @@ Worker LLM 通过 SiliconFlow API 访问。连接信息在 `.env` 里:
|
|
|
188
188
|
- `TIER1` 到 `TIER4` —— 各层级的模型名称
|
|
189
189
|
|
|
190
190
|
各模型当前的能力与上下文窗口大小,见 `references/worker-llm-catalog.md`。
|
|
191
|
+
|
|
192
|
+
## 两条访问路径:`worker_llm_call` 工具(优先)vs 直接 HTTP
|
|
193
|
+
|
|
194
|
+
KC 自带一个 `worker_llm_call` 工具。能用就用 —— 引擎能看到每次调用,能统计成本和 token、做限流、并把数据进入审计。v0.8 P2-B 增加了批量模式:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
worker_llm_call({
|
|
198
|
+
tier: "tier1",
|
|
199
|
+
prompts: ["核查文档 A...", "核查文档 B...", "核查文档 C..."],
|
|
200
|
+
system_prompt: "你是合规助手。返回 JSON {verdict, evidence, confidence}。",
|
|
201
|
+
concurrency: 5 // 1-10,默认 5
|
|
202
|
+
})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
返回 `{n_total, n_succeeded, n_failed, total_tokens_in, total_tokens_out, results: [...]}` 摘要。部分失败不会让整批失败。
|
|
206
|
+
|
|
207
|
+
### 规范的 `workflows/common/llm_client.py`(v0.8.1 起作为模板文件随包发布)
|
|
208
|
+
|
|
209
|
+
对于一个 **独立运行** 的 workflow(没有 KC 会话 —— 比如客户把 release 包部署后跑 `python run.py doc.pdf`),workflow 拿不到 `worker_llm_call`。规范的 HTTP 客户端 shim 现在作为模板文件随 kc-beta 一起发布;引擎初始化时会自动把它写入工作区的 `workflows/common/llm_client.py`。**不要自己重写**。直接用这个已经放好的文件:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from workflows.common.llm_client import call
|
|
213
|
+
|
|
214
|
+
result = call(
|
|
215
|
+
tier="tier2",
|
|
216
|
+
prompt=user_prompt,
|
|
217
|
+
system_prompt="你是合规助手。返回 JSON。",
|
|
218
|
+
max_tokens=2048,
|
|
219
|
+
)
|
|
220
|
+
# result = {"response": "...", "model_used": "...", "tier": "tier2",
|
|
221
|
+
# "tokens_in": N, "tokens_out": N}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
shim 做的事:
|
|
225
|
+
- 从 `.env` 读 `LLM_API_KEY` + `LLM_BASE_URL` + `TIER1..4`(多 provider 友好 —— SiliconFlow、OpenAI、Anthropic、阿里、火山等都能用)
|
|
226
|
+
- 以 OpenAI 兼容的 chat completions 格式发请求到配置好的 base URL
|
|
227
|
+
- 每次调用往 `output/llm_ledger.jsonl` 写一行,KC 审计即使在你没走 worker_llm_call 时也能还原成本
|
|
228
|
+
- 如果 `LLM_BASE_URL` 缺失,会显式抛错(不会偷偷回退到某个写死的 vendor URL)
|
|
229
|
+
|
|
230
|
+
**不要自己从零写 llm_client.py**。v0.7.x → v0.8 的三个连续会话里都出现过 agent 自己造轮子 —— 拼出来的版本要么模型 ID 过期、要么写死 SiliconFlow、要么不写 ledger,且对引擎不可见。优先用规范化版本;如果因为某种原因没有,从 kc-beta 安装目录的 `template/workflows/common/llm_client.py` 复制过来(引擎也会在 init 时自动写入 —— 检查 events.jsonl 里的 `workflows_common_populated` 事件)。
|
|
231
|
+
|
|
232
|
+
## sandbox_exec 超时设置(已知耗时长的命令)
|
|
233
|
+
|
|
234
|
+
`sandbox_exec` 默认超时是 120 秒。对于你预期会跑得更久的命令 —— LLM 批处理、大型回归测试、文档解析 —— 显式传 `timeout_ms`(最大 600000ms = 10 分钟)。不要靠把任务切成不必要的小块来绕开默认值;那只会浪费回合数并模糊意图。
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
sandbox_exec({
|
|
238
|
+
command: "python scripts/v2_full_test.py",
|
|
239
|
+
timeout_ms: 480000 // 14 条规则 × 6 篇文档走 worker LLM,预留 8 分钟
|
|
240
|
+
})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
如果已经顶到 10 分钟上限还在超时,把工作拆成多次调用,或者交给子代理(子代理的超时和父进程相互独立)。
|
|
@@ -12,62 +12,65 @@ description: >
|
|
|
12
12
|
|
|
13
13
|
# Tree Processing
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
绝大多数验证规则并不需要整篇文档。它们只需要某个特定的章节、某张特定的表格、或某条特定的披露内容。树就是你在大型文档中高效导航的地图。把一份动辄数百页、上千页的法规摊在工作 LLM 面前,既装不进上下文窗口,也会被无关内容稀释关键事实。建好树之后,验证就从"在整片汪洋里捞针",收敛为"先按图找到房间,再在房间里翻箱倒柜"。
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## 生产级分块方法论
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
对于需要处理大量文档的验证工作流而言,分块机制必须做到精确、一致且快速。"精确"意味着同一份文档被切出的边界总是落在正确的位置;"一致"意味着今天切和明天切的结果一模一样;"快速"意味着不会成为流水线里的瓶颈。要同时满足这三点,几乎只有"用代码固化结构规律"这一条路径。基本路径如下:
|
|
20
20
|
|
|
21
|
-
1.
|
|
22
|
-
2.
|
|
23
|
-
3.
|
|
24
|
-
4.
|
|
25
|
-
5.
|
|
21
|
+
1. **观察**:阅读 3 到 5 份样本文档。记录其结构特征——标题样式、编号方式、章节划分规律。不要只看一份就动手,小样本的偶然格式会把你引向一段过拟合的脆弱正则。
|
|
22
|
+
2. **找出模式**:识别那些保持一致的元素(标题格式、编号约定、目录结构)。同时把不一致的部分单独列出来,这些就是后续脚本需要兜底处理的边缘情形。
|
|
23
|
+
3. **编写代码**:设计一段分块脚本(基于正则的切分器、标题检测器、目录解析器),用代码固化所发现的模式。脚本应当是确定性的、可重入的,并且对输入文本的小幅扰动具有鲁棒性。
|
|
24
|
+
4. **测试**:在样本上运行脚本。验证它产出的分块结果与你人工标注的边界一致。如果出现错切、漏切或越切,先回到第 1 步补充观察样本,再来调整规则。
|
|
25
|
+
5. **部署**:脚本在生产工作流中正式运行。它是确定性的、零成本的、且执行迅速。一份脚本写好,就可以服务于同类型文档的全部后续处理。
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
这与 `document-chunking` 不同(后者用于探索阶段的快速、低成本切分)。生产级分块是一次性的设计投入,但其收益会在同类型文档的所有处理过程中持续兑现。换言之,前者是面向"我先粗略看看这文档大致长什么样"的临时手段,后者是面向"我每天都要处理一千份这种文档"的工程资产。
|
|
28
28
|
|
|
29
|
-
##
|
|
29
|
+
## 为什么要使用树
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
两条理由,都很硬:
|
|
32
32
|
|
|
33
|
-
1.
|
|
34
|
-
2.
|
|
33
|
+
1. **规则带有作用域。**"第 5 章中的风险披露必须包含……"——你需要定位第 5 章,而不是把 1000 页全部读一遍。验证类规则几乎天生就是带作用域的:它要么针对某个章节,要么针对某张表,要么针对某条具体的条款。把规则原原本本喂给一份完整文档,等于在让 LLM 自己先承担"找位置"这件本不该由它承担的工作。
|
|
34
|
+
2. **工作 LLM 有上下文上限。**16K 到 32K 的上下文窗口装不下一份 1000 页的文档。你必须把范围收窄到相关章节。即便是更大的上下文窗口,只要你把无关内容也一起喂进去,准确率就会被稀释,延迟会上升,Token 成本也会随之上涨。
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
树结构同时解决了这两个问题:它告诉你"东西在哪里",并让你只抽取"你真正需要的那部分"。从工程视角看,树是文档的索引;从语义视角看,树是规则与文本之间的桥梁。把这座桥建好,后续每一条规则的验证都会变得简单、可靠、可解释。
|
|
37
37
|
|
|
38
|
-
##
|
|
38
|
+
## 构建树
|
|
39
39
|
|
|
40
|
-
###
|
|
40
|
+
### 步骤 1:发现结构
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
在动手实现树解析器之前,先去探查几份样本文档,找出其结构上的规律。这一步看似只是"翻一翻文档",但它直接决定了后续所有工程决策的下限。请把它当作严肃的需求调研来做,而不是顺手扫一眼。关注以下要素:
|
|
43
43
|
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
44
|
+
- **标题约定**:章是以 "Chapter X" 开头?"第X章"?"Part X"?还是罗马数字?同一份文档中,顶层与子层的标题格式是否一致?中英混排的文档是否两种约定并存?
|
|
45
|
+
- **编号体系**:"1.1.2"、"Article 3"、"(a)(i)"、还是层级化的编号?编号是否每章重置?是否存在跨章共享的全局编号?编号缺位或跳号的情况是个例还是规律?
|
|
46
|
+
- **视觉标记**:加粗字体、更大的字号、水平分隔线、章节前的分页符?这些信息在转成纯文本以后是否还能保留?如果输入是 PDF 解析后的文本,是否需要先在更早的环节注入这些标记?
|
|
47
|
+
- **目录(TOC)**:大多数正式文档都带有目录。它本身就是这份文档自带的树。目录还能告诉你页码区间、官方的层级深度、以及哪些标题是法定的、哪些是排版插入的。
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
在这一步多花点时间。你找到的模式将直接决定:树构建器是一段简单的正则,还是一个复杂的解析器。经验上,凡是受监管发布的法规文档,几乎都遵循同一套排版规范;凡是来自不同机构的合并文档,则常常需要把多套规则同时纳入解析器的考量。
|
|
50
50
|
|
|
51
|
-
###
|
|
51
|
+
### 步骤 2:选择解析器
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
-
|
|
55
|
-
- `^第[一二三四五六七八九十百千]+章`
|
|
56
|
-
- `^Chapter \d+`
|
|
57
|
-
- `^\d+\.\d+(\.\d+)*\s`
|
|
58
|
-
-
|
|
53
|
+
**如果模式足够一致**(在受监管的法规类文档中通常都是一致的):
|
|
54
|
+
- 写一个基于正则的切分器。例如:
|
|
55
|
+
- `^第[一二三四五六七八九十百千]+章` 用于匹配中文的章标题
|
|
56
|
+
- `^Chapter \d+` 用于匹配英文章标题
|
|
57
|
+
- `^\d+\.\d+(\.\d+)*\s` 用于匹配带编号的小节
|
|
58
|
+
- 这种方案快速、确定、可靠。只要正则跑得通,优先选它。不要因为追求"看起来更智能"就放弃确定性的方案——确定性本身就是生产环境最稀缺、最值钱的属性。
|
|
59
|
+
- 在调试阶段,记得为正则写一组小型的单元测试:包括典型的命中样例、明确不应命中的反例,以及容易混淆的边界样例(比如标题中混入的全角空格、不可见控制字符)。
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
-
|
|
62
|
-
-
|
|
61
|
+
**如果模式不一致或根本不存在**:
|
|
62
|
+
- 使用 LLM 引导的"楔入式"切分方法(完整算法见 `rule-extraction/references/chunking-strategies.md`:滚动上下文窗口、K-token 引用比对、Levenshtein 模糊匹配)。
|
|
63
|
+
- 这种方式较慢,且要消耗 LLM 调用,但能处理非结构化的文档。滚动窗口的意义在于:即便是非常巨大的非结构化叶子节点,也可以逐段递进地完成切分。
|
|
64
|
+
- 一个务实的折中是混合策略:能用正则切到的层级先用正则切,正则啃不动的子节点再交给 LLM 引导式切分。这样可以把昂贵的 LLM 调用集中投放在真正需要语义判断的地方。
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
66
|
+
**如果文档自带目录**:
|
|
67
|
+
- 先解析目录。它免费地给了你一棵树的结构,外加每个节点的页码。
|
|
68
|
+
- 然后再用从目录派生出的结构去切分文档正文。
|
|
69
|
+
- 需要注意目录与正文之间偶尔会出现不一致(目录漏列了某节、或正文新增了目录里没有的小节)。把这些差异当作日志输出,便于后续人工核对,而不是默默吞掉。
|
|
67
70
|
|
|
68
|
-
###
|
|
71
|
+
### 步骤 3:构建树
|
|
69
72
|
|
|
70
|
-
|
|
73
|
+
树本身是一种简单的嵌套结构:
|
|
71
74
|
|
|
72
75
|
```
|
|
73
76
|
Document
|
|
@@ -83,40 +86,41 @@ Document
|
|
|
83
86
|
└── Chapter 5: Risk Disclosure (pages 79-120)
|
|
84
87
|
```
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
每个节点都需要存储:标题文本、所在层级、在文档中的起止位置、以及内容规模(以 token 数或字符数计)。在工程实现上,建议同时保留一个稳定的节点 ID(例如从根到当前节点的编号路径),便于后续的引用追踪、缓存命中以及跨规则的复用。父节点和子节点之间通过显式的指针或 ID 关联,这样无论是自顶向下遍历还是自底向上追溯祖先,代价都是常数级的。
|
|
87
90
|
|
|
88
|
-
###
|
|
91
|
+
### 步骤 4:使用树
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
假设有一条规则要求"检查风险披露章节":
|
|
91
94
|
|
|
92
|
-
1.
|
|
93
|
-
-
|
|
94
|
-
-
|
|
95
|
-
2.
|
|
96
|
-
3.
|
|
95
|
+
1. **在树中检索**目标节点。把规则中描述的作用域与节点标题做匹配。
|
|
96
|
+
- 精确匹配:"Chapter 5" → 找到标题为 "Chapter 5" 的节点。这种命中是最理想的情况,可以直接落点,不留歧义。
|
|
97
|
+
- 语义匹配:"风险披露章节" → 查找其标题或内容与"风险披露"相关的节点。这一步可能需要模糊匹配,或者用 LLM 做分类判断。在大型文档中,语义匹配应当先在标题层面尝试命中,只有标题不足以判断时,才下沉到摘要或正文。
|
|
98
|
+
2. **抽取该节点的内容**(必要时也包括其子节点的内容)。抽取时同时记录这次抽取的来源节点 ID,这样验证结论就能反向追溯到文档中的具体位置,而不是悬空于"模型说"。
|
|
99
|
+
3. **检查规模。**如果内容能够塞进工作 LLM 的上下文窗口,就直接使用。如果塞不下,则向下进入子节点,定位到真正需要的那个小节。在下沉过程中,记得保留祖先节点的标题链,使得 LLM 始终知道它正在阅读的是文档中的哪个位置。
|
|
97
100
|
|
|
98
|
-
##
|
|
101
|
+
## "全文 → 章 → 实体" 流水线
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
这是从文档中抽取待验证实体的标准漏斗式收窄过程,也是 KC 推荐的默认验证编排方式。每一步都把范围进一步收紧,把验证任务交给一个能力恰好匹配、上下文恰好够用的环节去完成:
|
|
101
104
|
|
|
102
|
-
1.
|
|
103
|
-
2.
|
|
104
|
-
3.
|
|
105
|
+
1. **全文上下文**:借助树来理解整份文档的结构。知道每样东西分别在哪里。这一步不需要 LLM 真的去读全文,只需要让规则与树之间建立索引关系。
|
|
106
|
+
2. **章节**:导航到这条规则所针对的具体章节。抽取其内容。注意章节边界要严格按照树上的起止位置来,不要凭印象多取或少取,否则验证准确率会被边界噪声拖累。
|
|
107
|
+
3. **实体**:在章节内容内,使用 `entity-extraction` 中的方法,把具体的实体(数字、文本片段、条款)抽取出来。这是最贴近规则原子比对单元的一步。
|
|
105
108
|
|
|
106
|
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
109
|
+
对于上下文窗口为 16K–32K 的工作 LLM:
|
|
110
|
+
- 章节内容加上抽取提示词必须能够装进上下文窗口。把这两部分的预估 Token 数加起来,留出至少 10% 余量给输出。
|
|
111
|
+
- 如果某一章过大,就继续向下进入树的更深层。优先选择能完整覆盖规则作用域的最小子节点。
|
|
112
|
+
- 始终把父级标题链一并附带上,作为定位上下文:例如 "Part II > Chapter 3 > Section 3.1",这样 LLM 才知道这段内容在整份文档中处于什么位置。缺少这条标题链,LLM 容易把同名的小节弄混,尤其是在带有"通则—分则"结构的法规中。
|
|
110
113
|
|
|
111
|
-
##
|
|
114
|
+
## 缓存与复用
|
|
112
115
|
|
|
113
|
-
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
+
每份文档只需构建一次树,然后在所有规则上复用:
|
|
117
|
+
- 把树结构以 JSON 形式与解析后的文档一同保存下来。文件名可以采用 `<doc_id>_tree.json` 这样的稳定模式,便于后续按文档 ID 直接读取。
|
|
118
|
+
- 同一份文档常常会被多条规则命中不同的章节。树让每条规则都能直接跳转到自己关心的位置,而无需重新解析文档。这一点在批量验证场景下尤其重要,它把"O(规则数 × 文档解析)"的代价降到了"O(文档解析)+O(规则数 × 树检索)"。
|
|
119
|
+
- 当文档版本发生变化时,把新旧两版的树做对比,可以快速看出新增、删除、合并、重排的章节,从而决定哪些旧的验证结论需要重跑、哪些可以延续。
|
|
116
120
|
|
|
117
|
-
##
|
|
121
|
+
## 边界情况
|
|
118
122
|
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
+
- **扁平文档**:有些文档完全没有结构化的层级。把整份文档当作一个节点处理。如果其规模超出上下文窗口,则改用 LLM 引导式分块。在这种情形下,要特别注意保留一份原文的连续性索引,以便后续把抽取结果回贴到正确的字符偏移上。
|
|
124
|
+
- **嵌套很深的结构**:某些法律文档有 6 层及以上的嵌套层级。构建时把所有层级都建起来,但对任何一条具体规则,通常只需向下导航 2 到 3 层即可。过度下钻反而会让规则失去其应有的上下文,使得 LLM 看不到关键的限定性表述。
|
|
125
|
+
- **跨章节的交叉引用**:某节可能写有"如第 1.2 节所定义"这样的字样。在抽取时,你可能需要同时从树上多个节点取内容。把它们拼成一个统一的上下文,再交给 LLM。在记录验证依据时,要分别标注每段来源节点,避免把"第 5 章的结论"与"第 1.2 节的定义"混为一谈。
|
|
126
|
+
- **附录与附件**:附录和附件中往往承载着关键的表格和数据。要把它们作为顶层节点纳入树中,不要遗漏。许多披露类规则的"数字真相"恰恰躲在附录的表格里,正文反而只是导引性的描述。
|
|
@@ -15,6 +15,14 @@ KC 的 main agent 是指挥者。指挥者决定下一步做什么——而这
|
|
|
15
15
|
- **进入 rule_extraction 时**。读完法规、拆出规则之后,在宣布该阶段完成之前,先决定这些规则会以什么顺序被处理、是否分组。覆盖审计与 chunk refs 都是这两个决定下游的工作。
|
|
16
16
|
- **进入 skill_authoring 时**。TaskBoard 是空的(引擎不再自动生成 per-rule 任务)。从 `describeState` 读取规则列表,决定分组与顺序,然后为每个工作单元调用 `TaskCreate`。
|
|
17
17
|
- **运行中觉得拆分不对时**。如果 TaskBoard 越走越奇怪(规则按错误顺序累积、明明该合并的两条规则被拆到两个任务里),停下来重新拆分。暂停 5 分钟重新规划的代价,会在接下来 2 条规则里被更合理的形状收回。
|
|
18
|
+
- **任意阶段同时跑 3+ 个并行子目标时**。如果你发现自己在工作记忆里同时拎着多个并行子目标(3+ 条规则 × 文档、finalization 阶段的多份交付物、production_qc 的多个 QC 批次),把它们丢进 TaskBoard 串行处理。v0.7.5 审计显示 distillation 和 production_qc 阶段即便注册表里没显式暴露这个 skill 也能从显式任务化中获益 —— v0.8 P2-E 把它对所有阶段都开放。
|
|
19
|
+
|
|
20
|
+
## 简明判断:什么时候用 TaskBoard
|
|
21
|
+
|
|
22
|
+
- 同时处理 N+ 条规则或 N+ 份文档?→ 开工之前先 `TaskCreate` 每个为一个任务。
|
|
23
|
+
- 一步就能干完的小请求?→ 跳过,直接做。
|
|
24
|
+
- 子代理内部协调?→ 跳过,子代理不暴露 TaskBoard。
|
|
25
|
+
- 任何你心里要靠"待会儿再回来"才能撑住的事?→ 现在就 TaskCreate 出来。长回合下工作记忆会漏掉半成品。
|
|
18
26
|
|
|
19
27
|
## 锁定原则
|
|
20
28
|
|
|
@@ -395,3 +403,29 @@ E2E 历史:
|
|
|
395
403
|
- E2E #7 v071 DS 和 GLM 都没写 PATTERNS.md,但 GLM 写了 6 篇 phase 完成日志和一份内容详尽的 AGENT.md —— 方法论 *捕获了*,只是放在了不同文件里。v0.7.2 把更宽的原则写进 skill:推进之前先持久化,格式灵活。
|
|
396
404
|
|
|
397
405
|
引擎从文件系统推导里程碑(v0.7.0 Group A)会按磁盘事实核验覆盖率,无论你怎么切分工作。TaskBoard 是你的草稿;磁盘才是契约;持久化文件是项目的记忆。
|
|
406
|
+
|
|
407
|
+
## 子代理批处理:滚动窗口写入(rolling-window)
|
|
408
|
+
|
|
409
|
+
当你派发 N 个子代理做批量工作(回归测试、批量核查、并行规则处理)时,**不要**让它们写同一个协调文件。v0.7.5 审计发现:子代理在 `tasks.json` / `rules/catalog.json` / `output/results/summary.json` 上互相抢锁 —— 一个占着工作区锁 5 分钟以上,其他在静默等待。
|
|
410
|
+
|
|
411
|
+
正确的模式:每个子代理写到**自己**专属的、有已知前缀的文件。父代理在所有子代理完成后再做聚合。
|
|
412
|
+
|
|
413
|
+
```
|
|
414
|
+
sub_agents/
|
|
415
|
+
batch-001-regression/
|
|
416
|
+
output/results/v2_regression.json # ❌ 多个子代理共用 — 抢锁
|
|
417
|
+
batch-002-regression/
|
|
418
|
+
output/results/v2_regression.json # ❌ 同一路径,竞争
|
|
419
|
+
|
|
420
|
+
# 改为:
|
|
421
|
+
|
|
422
|
+
output/
|
|
423
|
+
batch_regression_001.json # ✓ 每个子代理一个文件
|
|
424
|
+
batch_regression_002.json # ✓
|
|
425
|
+
batch_regression_003.json # ✓
|
|
426
|
+
# 父代理读所有 batch_regression_*.json,写汇总。
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
引擎信号:如果你在 events.jsonl 里看到 `lock_blocked` 事件出现在子代理工作期间,那就是症状。v0.8 P4-C 加了这个事件发射,让父代理在子代理超时之前就看见冲突。出现就立刻改成滚动窗口写。
|
|
430
|
+
|
|
431
|
+
不要写"用文件锁协调"的子代理批处理。锁原语是用来防止意外并发写入的安全机制,不是队列。用文件系统布局作为协调机制。
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""KC worker-LLM client (v0.8.1 P10-A canonical shim).
|
|
2
|
+
|
|
3
|
+
Distilled workflows use this module to call worker LLMs. Provider-agnostic:
|
|
4
|
+
reads connection info from workspace `.env` so the same workflow can run
|
|
5
|
+
against SiliconFlow, OpenAI, Anthropic, Aliyun, Volcanocloud, etc.
|
|
6
|
+
|
|
7
|
+
Two modes:
|
|
8
|
+
- Inside a KC session: the engine's `worker_llm_call` tool is preferred
|
|
9
|
+
for new code (it tracks cost, applies rate limiting, and writes to
|
|
10
|
+
events.jsonl). This shim is fine if the workflow needs to be
|
|
11
|
+
portable to standalone (no-KC) deployment.
|
|
12
|
+
- Standalone (deployed release bundle): this shim is the only LLM
|
|
13
|
+
access path. Every call writes a line to `output/llm_ledger.jsonl`
|
|
14
|
+
so post-hoc analysis can reconstruct cost and traffic.
|
|
15
|
+
|
|
16
|
+
Required `.env` fields:
|
|
17
|
+
LLM_API_KEY API key for the provider
|
|
18
|
+
LLM_BASE_URL Provider base URL (e.g. https://api.siliconflow.cn/v1)
|
|
19
|
+
TIER1..TIER4 Comma-separated model names per tier
|
|
20
|
+
|
|
21
|
+
Optional:
|
|
22
|
+
LLM_AUTH_TYPE "bearer" (default) | "x-api-key" (Anthropic native)
|
|
23
|
+
LLM_API_FORMAT "openai" (default) — only OpenAI-format chat completions
|
|
24
|
+
are supported by this shim. Use worker_llm_call for
|
|
25
|
+
non-OpenAI-format providers (e.g. Anthropic native).
|
|
26
|
+
|
|
27
|
+
If LLM_BASE_URL is missing, the shim raises explicitly — no silent
|
|
28
|
+
fallback to a hardcoded vendor URL. This avoids accidentally sending
|
|
29
|
+
traffic to siliconflow.cn from an OpenAI-configured workspace.
|
|
30
|
+
|
|
31
|
+
Migration aliases:
|
|
32
|
+
SILICONFLOW_API_KEY → falls back to LLM_API_KEY if the canonical
|
|
33
|
+
name is missing (for workspaces predating v0.8.1).
|
|
34
|
+
"""
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import time
|
|
38
|
+
import urllib.error
|
|
39
|
+
import urllib.request
|
|
40
|
+
|
|
41
|
+
_LEDGER_PATH = os.path.join("output", "llm_ledger.jsonl")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def call(tier="tier2", prompt="", system_prompt=None, max_tokens=4096, timeout_s=120):
|
|
45
|
+
"""Single-prompt chat-completions call.
|
|
46
|
+
|
|
47
|
+
Returns: {response, model_used, tier, tokens_in, tokens_out}.
|
|
48
|
+
Raises: RuntimeError on missing config; urllib HTTPError on transport.
|
|
49
|
+
"""
|
|
50
|
+
if not prompt:
|
|
51
|
+
raise RuntimeError("call() requires a non-empty `prompt`")
|
|
52
|
+
|
|
53
|
+
api_key = _env("LLM_API_KEY") or _env("SILICONFLOW_API_KEY")
|
|
54
|
+
if not api_key:
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
"LLM_API_KEY not configured. Set it in .env or run `kc-beta onboard`."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
base_url = _env("LLM_BASE_URL") or _env("SILICONFLOW_BASE_URL")
|
|
60
|
+
if not base_url:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
"LLM_BASE_URL not configured. Set the canonical name in .env "
|
|
63
|
+
"(e.g. https://api.openai.com/v1 for OpenAI; "
|
|
64
|
+
"https://api.siliconflow.cn/v1 for SiliconFlow). "
|
|
65
|
+
"Run `kc-beta onboard` to configure interactively."
|
|
66
|
+
)
|
|
67
|
+
base_url = base_url.rstrip("/")
|
|
68
|
+
|
|
69
|
+
auth_type = (_env("LLM_AUTH_TYPE") or "bearer").lower()
|
|
70
|
+
api_format = (_env("LLM_API_FORMAT") or "openai").lower()
|
|
71
|
+
if api_format != "openai":
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
f"LLM_API_FORMAT={api_format} not supported by this shim. "
|
|
74
|
+
f"Only `openai` chat-completions wire format is implemented. "
|
|
75
|
+
f"Use the engine's `worker_llm_call` tool for native non-OpenAI providers."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
tier_models = _load_tier_models(tier)
|
|
79
|
+
if not tier_models:
|
|
80
|
+
raise RuntimeError(f"No models configured for {tier.upper()}; check .env TIER1-TIER4")
|
|
81
|
+
|
|
82
|
+
messages = []
|
|
83
|
+
if system_prompt:
|
|
84
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
85
|
+
messages.append({"role": "user", "content": prompt})
|
|
86
|
+
|
|
87
|
+
body = json.dumps(
|
|
88
|
+
{"model": tier_models[0], "messages": messages, "max_tokens": max_tokens}
|
|
89
|
+
).encode("utf-8")
|
|
90
|
+
|
|
91
|
+
headers = {"Content-Type": "application/json"}
|
|
92
|
+
if auth_type == "x-api-key":
|
|
93
|
+
headers["x-api-key"] = api_key
|
|
94
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
95
|
+
else:
|
|
96
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
97
|
+
|
|
98
|
+
req = urllib.request.Request(f"{base_url}/chat/completions", data=body, headers=headers)
|
|
99
|
+
t0 = time.time()
|
|
100
|
+
try:
|
|
101
|
+
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
|
102
|
+
data = json.loads(resp.read())
|
|
103
|
+
except urllib.error.HTTPError as e:
|
|
104
|
+
# Preserve the body for debugging — providers often return useful errors
|
|
105
|
+
err_body = e.read().decode("utf-8", errors="replace")[:500] if e.fp else ""
|
|
106
|
+
raise RuntimeError(f"LLM call HTTP {e.code} from {base_url}: {err_body}") from e
|
|
107
|
+
|
|
108
|
+
usage = data.get("usage") or {}
|
|
109
|
+
result = {
|
|
110
|
+
"response": data["choices"][0]["message"]["content"],
|
|
111
|
+
"model_used": tier_models[0],
|
|
112
|
+
"tier": tier,
|
|
113
|
+
"tokens_in": usage.get("prompt_tokens", 0),
|
|
114
|
+
"tokens_out": usage.get("completion_tokens", 0),
|
|
115
|
+
}
|
|
116
|
+
_write_ledger({
|
|
117
|
+
**result,
|
|
118
|
+
"duration_s": round(time.time() - t0, 3),
|
|
119
|
+
"ts": time.time(),
|
|
120
|
+
"base_url": base_url,
|
|
121
|
+
"auth_type": auth_type,
|
|
122
|
+
})
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _env(key):
|
|
127
|
+
"""Read `key` from process env first, then workspace .env file."""
|
|
128
|
+
v = os.environ.get(key)
|
|
129
|
+
if v:
|
|
130
|
+
return v
|
|
131
|
+
if os.path.exists(".env"):
|
|
132
|
+
try:
|
|
133
|
+
with open(".env", "r", encoding="utf-8") as f:
|
|
134
|
+
for raw in f:
|
|
135
|
+
line = raw.strip()
|
|
136
|
+
if not line or line.startswith("#"):
|
|
137
|
+
continue
|
|
138
|
+
if "=" not in line:
|
|
139
|
+
continue
|
|
140
|
+
k, val = line.split("=", 1)
|
|
141
|
+
if k.strip() == key:
|
|
142
|
+
val = val.strip()
|
|
143
|
+
if (val.startswith('"') and val.endswith('"')) or (
|
|
144
|
+
val.startswith("'") and val.endswith("'")
|
|
145
|
+
):
|
|
146
|
+
val = val[1:-1]
|
|
147
|
+
return val
|
|
148
|
+
except OSError:
|
|
149
|
+
return None
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _load_tier_models(tier):
|
|
154
|
+
raw = _env(tier.upper()) or ""
|
|
155
|
+
return [m.strip() for m in raw.split(",") if m.strip()]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _write_ledger(record):
|
|
159
|
+
try:
|
|
160
|
+
os.makedirs(os.path.dirname(_LEDGER_PATH), exist_ok=True)
|
|
161
|
+
with open(_LEDGER_PATH, "a", encoding="utf-8") as f:
|
|
162
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
163
|
+
except OSError:
|
|
164
|
+
# Ledger is best-effort; never break the workflow over a write failure.
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = ["call"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""KC workflow helpers (v0.8.1 P10-B).
|
|
2
|
+
|
|
3
|
+
Common utilities for distilled workflows. Provider-agnostic, no
|
|
4
|
+
external dependencies. Reusable across rule check.py / workflow.py
|
|
5
|
+
files so that per-rule scripts stay focused on rule-specific logic.
|
|
6
|
+
|
|
7
|
+
Currently:
|
|
8
|
+
strip_annotations(text) — drop reviewer-annotation footers
|
|
9
|
+
from sample documents so per-rule
|
|
10
|
+
check.py regex doesn't false-positive
|
|
11
|
+
on the annotation itself
|
|
12
|
+
|
|
13
|
+
detect_report_type(text) — light-touch report-type classifier
|
|
14
|
+
(年报 / 季报 / 月报 / 周报 / 其他)
|
|
15
|
+
used by rules that gate on report type
|
|
16
|
+
|
|
17
|
+
make_result(rule_id, verdict, evidence, confidence, **kwargs)
|
|
18
|
+
— standardized result dict factory
|
|
19
|
+
"""
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Annotation prefixes that mark reviewer-added footers in sample docs.
|
|
24
|
+
# These should be stripped before keyword/regex matching so per-rule
|
|
25
|
+
# check.py doesn't match the annotation as if it were document content.
|
|
26
|
+
#
|
|
27
|
+
# Added based on E2E #11 贷款 v0.8 audit § 9: 4/14 spot-checks
|
|
28
|
+
# false-positive PASS because samples contain `预期命中点: ...年化利率`
|
|
29
|
+
# footers that the rule's keyword regex matches.
|
|
30
|
+
_ANNOTATION_PREFIXES = (
|
|
31
|
+
"预期命中点",
|
|
32
|
+
"预期结果",
|
|
33
|
+
"预期判定",
|
|
34
|
+
"预期验证",
|
|
35
|
+
"标注",
|
|
36
|
+
"审核标注",
|
|
37
|
+
"Expected",
|
|
38
|
+
"expected",
|
|
39
|
+
"EXPECTED",
|
|
40
|
+
"Annotation",
|
|
41
|
+
"annotation",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def strip_annotations(text, extra_prefixes=None):
|
|
46
|
+
"""Remove reviewer-annotation footers from document text.
|
|
47
|
+
|
|
48
|
+
A line is dropped if it starts with one of the recognized
|
|
49
|
+
annotation prefixes followed by `:` or `:` (Chinese full-width
|
|
50
|
+
colon). All subsequent lines until a blank line or end of text
|
|
51
|
+
are also dropped (annotations are typically multi-line trailing
|
|
52
|
+
blocks).
|
|
53
|
+
|
|
54
|
+
Pass `extra_prefixes` (iterable of strings) to add project-specific
|
|
55
|
+
annotation labels.
|
|
56
|
+
|
|
57
|
+
Returns the cleaned text. Input is never mutated.
|
|
58
|
+
"""
|
|
59
|
+
if not text:
|
|
60
|
+
return text
|
|
61
|
+
prefixes = tuple(_ANNOTATION_PREFIXES)
|
|
62
|
+
if extra_prefixes:
|
|
63
|
+
prefixes = prefixes + tuple(extra_prefixes)
|
|
64
|
+
# Build a pattern matching `<prefix>` + colon (half or full-width)
|
|
65
|
+
pattern = "|".join(re.escape(p) for p in prefixes)
|
|
66
|
+
anno_start = re.compile(rf"^\s*(?:{pattern})\s*[::]")
|
|
67
|
+
|
|
68
|
+
out_lines = []
|
|
69
|
+
in_anno_block = False
|
|
70
|
+
for line in text.split("\n"):
|
|
71
|
+
if anno_start.match(line):
|
|
72
|
+
in_anno_block = True
|
|
73
|
+
continue
|
|
74
|
+
if in_anno_block:
|
|
75
|
+
# End block on a blank line OR a line that doesn't look
|
|
76
|
+
# like annotation continuation (no leading whitespace).
|
|
77
|
+
if not line.strip() or not line.startswith((" ", "\t", "-", "*", "·")):
|
|
78
|
+
in_anno_block = False
|
|
79
|
+
if line.strip():
|
|
80
|
+
out_lines.append(line)
|
|
81
|
+
# Otherwise still inside the annotation block — drop.
|
|
82
|
+
continue
|
|
83
|
+
out_lines.append(line)
|
|
84
|
+
return "\n".join(out_lines)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_REPORT_TYPE_PATTERNS = [
|
|
88
|
+
("年报", re.compile(r"年报|年度报告|annual report", re.IGNORECASE)),
|
|
89
|
+
("半年报", re.compile(r"半年报|半年度报告|interim report", re.IGNORECASE)),
|
|
90
|
+
("季报", re.compile(r"季报|季度报告|quarterly report", re.IGNORECASE)),
|
|
91
|
+
("月报", re.compile(r"月报|月度报告|monthly report", re.IGNORECASE)),
|
|
92
|
+
("周报", re.compile(r"周报|周度报告|weekly report", re.IGNORECASE)),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def detect_report_type(text):
|
|
97
|
+
"""Light-touch report-type classifier.
|
|
98
|
+
|
|
99
|
+
Returns one of: "年报", "半年报", "季报", "月报", "周报", "其他".
|
|
100
|
+
Scans only the first 2000 chars (report-type identifiers usually
|
|
101
|
+
appear in the title or cover page). Used by rules that gate on
|
|
102
|
+
report type (e.g. R02-06/R02-08 are NOT_APPLICABLE for 季报).
|
|
103
|
+
"""
|
|
104
|
+
if not text:
|
|
105
|
+
return "其他"
|
|
106
|
+
head = text[:2000]
|
|
107
|
+
for kind, pattern in _REPORT_TYPE_PATTERNS:
|
|
108
|
+
if pattern.search(head):
|
|
109
|
+
return kind
|
|
110
|
+
return "其他"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def make_result(rule_id, verdict, evidence, confidence=0.7, **kwargs):
|
|
114
|
+
"""Build a standardized check result dict.
|
|
115
|
+
|
|
116
|
+
Required: rule_id, verdict ("PASS" / "FAIL" / "WARNING" / "NOT_APPLICABLE"),
|
|
117
|
+
evidence (string explaining the verdict).
|
|
118
|
+
|
|
119
|
+
Optional: confidence (0.0-1.0), plus any extra fields the rule
|
|
120
|
+
needs (model_used, llm_calls, llm_tokens, comment, etc.).
|
|
121
|
+
"""
|
|
122
|
+
result = {
|
|
123
|
+
"rule_id": rule_id,
|
|
124
|
+
"verdict": verdict,
|
|
125
|
+
"evidence": evidence,
|
|
126
|
+
"confidence": confidence,
|
|
127
|
+
}
|
|
128
|
+
result.update(kwargs)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ["strip_annotations", "detect_report_type", "make_result"]
|