Skip to content

Commit 6bc50a8

Browse files
authored
Merge pull request #248 from JiusiServe/kb/init-skeleton
kb init: skeleton stage (docs map, doc invariants, seeds) with its own runtime, budget and publisher
2 parents 498ec9f + 7e98f94 commit 6bc50a8

14 files changed

Lines changed: 2687 additions & 10 deletions

File tree

‎doc/architecture/SPEC/engine/steps/knowledge.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# engine/steps/knowledge.py —— 规范
22

3-
<!-- verified-against: 2026-09-28 -->
3+
<!-- verified-against: 2026-09-30 -->
44

55
`知识服务的 knowledge.* 步骤 · refactor-status: new`
66

@@ -12,10 +12,17 @@ auto_merge 仓库写 outbox 的 `open_pr` 项,shadow 只记录)。逻辑全
1212
## 不变量
1313
- 只处理 `params.repo` 指定的一个仓库;未知或未启用的仓库 → BLOCKED。
1414
- 步骤之间只经 `state_updates` 传递纯数据(`kb_repo`、`kb_changeset`、`kb_changeset_status`)。
15-
- 从不直接写 GitHub;发布只经签名 outbox 由 GPU 盒发布器执行。
15+
- 从不直接写 GitHub;发布只经签名 outbox 由 GPU 盒发布器执行(唯一例外:`knowledge.init`,见下)。
1616

1717
## 测试
1818
`test_kb_intake_gate.py`。
1919

2020
## 2026-09-28 合并、巡检、激活步骤
2121
新增 `knowledge.advance_merges`、`knowledge.sweep`、`knowledge.activate`;`kb run` 路径自行持有租约,调度器路径复用其租约。
22+
23+
## 2026-09-30 `knowledge.init`(kb init)
24+
参数 `repo`、`stage`、`dry_run`(缺省 true)、`pin`。用自己的 `InitRuntime`(`kb_service/init_support.py`),**不**经
25+
`_runtime`/`_lifecycle`:服务运行时会打开 `kb.db`,且 `_lifecycle` 拒绝未启用的仓库,而为未启用的仓库建库正是 init 的目的。
26+
调 `init_stages.run_stage`;`InitError`/`NotImplementedError` 与状态 `blocked` 的记录都返回 BLOCKED。`state_updates`:
27+
`kb_init_stage`、`kb_init_status`、`kb_init_pr`。它是本模块里唯一直接写 GitHub 的步骤:经仓库主人的 `gh` 开一个由人合并的
28+
PR,且只在 `ALLOW_PUSH=1` 与 `ALLOW_POST=1` 同时成立、非 dry run、上游非私有时。测试:`test_kb_init_skeleton.py`。

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,59 @@ knowledge_root)` 是运行时检查:种子必须是知识树中已存在的文
333333
中,`uncovered_hot` 按次数降序、路径升序;`churn_by_module(prs, modules)` 按最长模块前缀归属(已折叠目录归到折入的
334334
祖先),降序。空集合的比例记为 1.0(没有可漏的路由)。`ROOT_MODULE` 与 `profiles.establish.ROOT_MODULE` 相同,
335335
由测试钉住(本包不依赖 `profiles`)。源根与变更路径按与 `profiles.establish.normalize_root` 相同的规则规范化(`.`、`./` 表示仓库根,接纳一切路径)。测试:`test_kb_init_coverage.py`。
336+
337+
338+
## 2026-09-30 单次调用花费阈值(kb init 预算硬上限的一块积木)
339+
`ModelGateway.call_json(..., max_budget_usd=x)`:x 是**停止阈值**,不是硬上限 —— 花费达到 x 后不再发起新请求,但越过 x 的
340+
那个请求照样计费。硬上限由调用方负责:调用前预留 x + 单个请求的最坏情况。transport 的 `stops_at_spend` 为 False 时在发出调用
341+
**之前**抛 `ModelUnavailable("<provider> cannot stop a call at a spend threshold")`(fail-closed,绝不用 `max_tokens` 近似);
342+
x 必须为正。因阈值停下的调用(`stop_reason="max_budget"`)→ `ModelUnavailable`,并作为失败记录(不会成为训练样本)。
343+
`ModelReply.cost_usd` 取自 `usage["cost_usd"]`(未知为 None),trace 记录同时写入 `cost_usd` 与 `max_budget_usd`。
344+
不传上限的现有调用方行为不变:该参数根本不会发给 transport。
345+
`runtime.trace_recorder` 把阈值与花费持久化到 `model_call` 记录的 `result.max_budget_usd` / `result.cost_usd`
346+
(未设或未知为 null),包括 transport 抛错的失败记录。
347+
348+
## 2026-09-30 kb init:阶段 1(skeleton)、运行时、预算与发布
349+
设计见 kb-init 设计 v3。`kb init REPO --stage S [--dry-run] [--pin SHA]` 经 playbook `kb-init`(单步
350+
`knowledge.init`)运行;`kb init REPO --suggest-seeds` 只打印候选种子(不调模型)。CLI 在打开任何账本**之前**分派
351+
`init`:kb init 从不碰服务的 `kb.db`、outbox、`kb serve`/`kb publish`。
352+
353+
- `init_budget`:`Budget(limit)` 的 `reserve(amount)` 在调用**之前**预留,放不下抛 `BudgetExhausted`;块内
354+
`charge(cost)` 记实际花费,未知/非法花费或块内异常按整笔预留计。生成调用预留 = 阈值 `generator_call_usd` +
355+
单个请求最坏值 `input_bytes × 1.25 × in_price + max_output_tokens × out_price`(claude 的 `--max-budget-usd`
356+
只拦下一个请求)。价格表 `DEFAULT_PRICES`(claude-opus-5-5 = $4/$20 每百万 token、128000 输出上限,2026-09-25
357+
标价)可由 `KB_INIT_PRICES` JSON 覆盖;没有价格的生成模型在任何调用之前被拒(`PriceError`)。判定调用按
358+
`judge_call_usd` 固定记账 —— 这是记账约定,不是花费上限。
359+
- `config.InitConfig` 增加 `generator_call_usd`(>0,缺省 2.0)与 `harness_overhead_bytes`(≥0,缺省 200000)。
360+
- `init_support`:`InitRuntime`(自己的运行时:registry、`ModelGateway`(trace 写到 `<state>/init/traces`)、
361+
知识库克隆 `KB_INIT_KNOWLEDGE_CLONE` 或 `<state>/init/knowledge-repo`、价格表、环境);`InitRecord`(每阶段一个
362+
`<state>/init/<repo>/<stage>.json`:钉点、知识库基点、输入摘要、花费、种子来源、逐规则证据、判定、丢弃原因、清单、
363+
问题、未完成项、PR);`UpstreamPin`(上游 bare 镜像、`resolve`、只读 `export`(拒绝链接与越界成员)、
364+
`PinnedObserver`);`collect_docs`;`generate`/`judge`(先预留);`claim_problems`(设计 §9.1);
365+
`other_path_problems`(`knowledge/` 之外的路径必须在 `INIT_PATHS` 内且有专门检查);`run_knowledge_validators`
366+
(在基点树叠加改动后跑 CI 同款的 `check_knowledge_tree.py` 与 `check_wiki_lint.py`);`InitPublisher`
367+
(临时索引在确切基点上构建一个确定性提交,推到**必须不存在**的分支 —— `--force-with-lease=<ref>:`,经 `gh`
368+
开 PR 并确认 head;dry run 把树与 PR 正文写到本地)。只在 `ALLOW_PUSH=1` 且 `ALLOW_POST=1` 时发布;私有上游
369+
永远 dry run。与 kb 发布器一样不经 `push.guard_push`:它推的是全新分支、从不强推、只写 `INIT_PATHS`。
370+
- `init_stages`(唯一允许调用 `check_changeset(bootstrap=True)` 的模块,由 L1 测试钉住):`run_stage` 与
371+
`validate_change`(设计 §9.3:L1 changeset(bootstrap)、`check_tree`、`check_index_links`、钉点声明与证据、
372+
`knowledge/` 之外的路径)。skeleton:文档语料 → 地图调用(入口页、架构页、owner 路由、general 链接)→ 文档不变量
373+
调用 → 每个 `repos/<other>/` 种子规则页一次改写调用 → 逐条筛查:D5 去掉与文档重复的行、证据在钉点可取且哈希入记录、
374+
钉点声明成立、在一棵逐条累积的树上落位(页满则转到兄弟页 `rules-doc-invariants.md` / `<stem>-<n>.md`,**先落位再
375+
判定**,判定看到的就是规则最终所在的页)→ 咨询性判定(fail 剔除,unsure/unjudged 标出)→ 经 `ops.apply_operations`
376+
写入(新规则页先建壳并登记到目录索引;已有规则只追加,从不修改)→ 新目录建 `_index.md`、已有目录只追加链接、
377+
仓库未登记时登记到 `repos/_index.md`(`INIT_PATHS` 允许这一个共享文件);`_routes.yaml` 只在不存在时生成:先由
378+
已有子目录入口页**确定性**地得出 owner(页中反引号里、在钉点存在、落在 `source_roots` 内的代码路径;`pkg/**` 即目录
379+
`pkg/`),再补模型提出的 owner(scope 前缀须在钉点存在)→ 已有页面的问题进清单(钉点下不成立的声明、与文档重复的行、
380+
没有路由到达的页、表格索引的样式)→ 检查 → dry run 或 PR。钉点缺省取上游镜像的 `HEAD`(远端默认分支,不假设
381+
`main`;每次同步都按 `ls-remote --symref` 刷新镜像的 `HEAD`,默认分支改名或删除后也跟得上)。没有可用 owner 时的
382+
兜底路由用规范化后的 `source_roots`(`./pkg/` → `pkg/`),源根是仓库本身或未设置时用钉点下的全部顶层目录。发布时 `KB_INIT_GIT_AUTHOR` 缺失在任何模型调用之前拒绝。推送之前先把这次发布(基点、分支、文件、标题、
383+
正文、作者、时间)写成 `<stage>-publish.json`(`save_prepared`),记录状态为 `publishing`;推送/开 PR 失败记为
384+
`blocked` 并保留它,重跑时直接完成**同一个**发布(同输入重建出同一提交,已推送的分支与已开的 PR 都复用,不再调模型)。
385+
dry run 每次整体替换输出目录,不残留上一次的页面;有待完成的发布时拒绝 dry run(不覆盖它)。init 写的非规则页
386+
(入口页简介、架构页、目录标题)经 `neutral_headings`:代码之外的 ATX 标题改为粗体段落,其余以 `#` 开头的行转义为
387+
`\#`,代码块里的此类行缩进一个空格 —— 没有任何行以 `#` 开头,`RULE_HEADING`/`ANY_RULE_HEADING` 永远匹配不到,
388+
模型写的 `## Overview` 或 `## SERV-1 — x` 不会变成规则 ID。模型提出的 owner 页必须
389+
是本仓库目录下已有或本阶段创建的页面,指向其他仓库的页面一律丢弃并记入 notes。仓库标签不在 `doc/knowledge/SCHEMA.md` 分类法里、种子不存在、生成模型不可用或无价格 → 记录为 `blocked`,
390+
不产生 PR。同输入摘要重跑直接返回记录;已发布记录的输入变了则拒绝。`modules`/`deepen`/`harvest-calibration` 抛
391+
`NotImplementedError`。测试:`test_kb_init_skeleton.py`、`test_kb_init_config.py`。

‎doc/architecture/SPEC/playbooks/PLAYBOOKS.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# playbooks/*.yaml —— 规范
22

3-
<!-- verified-against: 2026-09-29 -->
3+
<!-- verified-against: 2026-09-30 -->
44

55
`9 个文件 · 声明式编排数据 · refactor-status: ok`
66

@@ -57,3 +57,7 @@ success, steps[]`。
5757

5858
## kb-merge / kb-sweep / kb-activate(candidate)
5959
知识服务的合并推进、发版巡检与快照激活;candidate 状态,由 `kb serve` 调度或 `kb run` 手动运行。
60+
61+
## kb-init(candidate)
62+
为一个仓库建知识库:单步 `knowledge.init`,每次运行一个阶段(skeleton → modules → deepen + 生命周期开关 →
63+
harvest-calibration),每阶段一个由人合并的 PR。只由 `kb init REPO --stage S` 运行,不经 `kb serve`,不写服务账本。

‎doc/architecture/SPEC/profiles/establish.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
## 公开契约
1111
`fact_id`、`build_doc_corpus`、`is_redundant`、`extract_directives`、
12-
`scan_modules`、`scan_modules_at_depth`、`normalize_root`、`ROOT_MODULE`、`HUMAN_DOC_NAMES`。
12+
`scan_modules`、`scan_modules_at_depth`、`normalize_root`、`ROOT_MODULE`、`HUMAN_DOC_NAMES`、`doc_files`。
1313

1414
## 不变量
1515
- `is_redundant`(对 README+docs 做 6 词 shingle)会丢弃任何**仓库自己的文档已经写过**
@@ -19,6 +19,9 @@
1919
- `scan_modules_at_depth`(kb init 的模块定义):源根以下至多 `depth` 层目录为模块,更深的文件归最深的那层祖先;
2020
非空行少于 `min_loc` 的模块由深到浅折入父目录模块,直到稳定;源根永不折叠(`normalize_root` 先把 `.`、`./pkg`、`/pkg/` 等写法规范化,仓库根为 `""` 且包含其他根,保证折叠必然终止);嵌套源根各自拥有自己的文件;
2121
每一层都跳过隐藏目录与非代码目录;`exclude` 按仓库相对路径做 fnmatch;未知语言返回 `{}`;结果确定、有序。
22+
- `build_doc_corpus(..., globs=None)`:不给 `globs` 时语料与原来完全相同(README* + docs/**/*.md);给出时读
23+
`doc_files(repo, globs)` —— 按 glob 顺序、再按路径排序,每个文件只读一次,只收普通文件,解析后逃出仓库的
24+
符号链接一律跳过。kb init 用它按 adapter 的 `doc_globs` 建 D5 语料,归一化与 briefing 路径是同一份。
2225

2326
## 边界 —— 不属于这里
2427
纯确定性 helper —— 不含 LLM、不写 store、不含 step 逻辑。
@@ -27,7 +30,7 @@
2730
仅 stdlib。
2831

2932
## 测试
30-
`test_profile_steps.py`(冗余过滤、模块扫描、指令抽取);`test_kb_init_coverage.py`(按深度的模块扫描)。
33+
`test_profile_steps.py`(冗余过滤、模块扫描、指令抽取);`test_kb_init_coverage.py`(按深度的模块扫描);`test_kb_init_skeleton.py`(`doc_files` 与带 `globs` 的语料)。
3134

3235
## 重构备注
3336
纯函数 —— 易测、易复用。冗余过滤器是承载 ETH 研究结论的关键防线;**保持它确定性**。

‎playbooks/kb-init.yaml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# kb init for ONE repository: one stage per run (skeleton, then modules, then
2+
# deepen + lifecycle flip, then harvest-calibration), each opened as a
3+
# human-merged PR (design kb-init v3). Runs outside the knowledge service: it
4+
# never writes the service ledger. Candidate.
5+
name: kb-init
6+
version: 1
7+
status: candidate
8+
provenance:
9+
created_by: human
10+
source: kb init design v3 (bootstrap a repository's knowledge base)
11+
task_kinds: [knowledge_maintenance]
12+
repos: []
13+
requires: []
14+
params:
15+
repo: "knowledge repository name"
16+
stage: "skeleton | modules | deepen | harvest-calibration"
17+
dry_run: "true (default): write the tree and PR body locally; false needs ALLOW_PUSH=1 and ALLOW_POST=1"
18+
pin: "upstream commit to pin (default: the default branch head)"
19+
steps:
20+
- {id: init, step: knowledge.init}
21+
success: the stage's PR is open (or its dry run is written), or the record says why it is blocked

‎src/infermatrix_copilot/engine/steps/knowledge.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,40 @@ async def activate_snapshot(ctx: StepContext) -> StepResult:
149149
return StepResult(False, FailureKind.ESCALATE, f"activation refused: {exc}")
150150
return StepResult(True, summary=f"active snapshot {sha}",
151151
outputs={"state_updates": {"kb_active_snapshot": sha, "kb_snapshot_path": str(snapshot)}})
152+
153+
154+
def _init_runtime(ctx: StepContext):
155+
"""kb init's own runtime: never the service's (that opens kb.db), and it
156+
serves disabled repositories — bootstrapping one is the point."""
157+
from pathlib import Path
158+
159+
from ...kb_service.cli import DEFAULT_STATE_DIR
160+
from ...kb_service.init_support import InitRuntime
161+
162+
return InitRuntime.from_env(ctx.settings, state_dir=Path(_STATE_DIR or DEFAULT_STATE_DIR))
163+
164+
165+
@step("knowledge.init", kind="agent", risk="knowledge",
166+
description="kb init: bootstrap one repository's knowledge base, one human-merged stage at a time")
167+
async def init_stage(ctx: StepContext) -> StepResult:
168+
from ...kb_service.init_stages import run_stage
169+
from ...kb_service.init_support import InitError
170+
171+
repo = str(ctx.params.get("repo") or ctx.state.get("kb_repo") or "")
172+
stage = str(ctx.params.get("stage") or "")
173+
dry_run = str(ctx.params.get("dry_run", "true")).lower() not in ("0", "false", "no")
174+
pin = str(ctx.params.get("pin") or "") or None
175+
try:
176+
rt = _init_runtime(ctx)
177+
lifecycle = rt.registry.get(repo)
178+
if lifecycle is None:
179+
return StepResult(False, FailureKind.BLOCKED, f"no adapter declares knowledge repo {repo!r}")
180+
record = run_stage(rt, lifecycle, stage, dry_run=dry_run, pin=pin)
181+
except (InitError, NotImplementedError) as exc:
182+
return StepResult(False, FailureKind.BLOCKED, str(exc))
183+
updates = {"kb_init_stage": stage, "kb_init_status": record.status, "kb_init_pr": dict(record.pr)}
184+
if record.status == "blocked":
185+
return StepResult(False, FailureKind.BLOCKED, "; ".join(record.problems)[:2000],
186+
outputs={"state_updates": updates})
187+
return StepResult(True, summary=f"kb init {stage} for {repo}: {record.status}",
188+
outputs={"state_updates": updates})

‎src/infermatrix_copilot/kb_service/cli.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
kb traces [--kind K] [--changeset ID] [--rule ID] [--limit N] query trace/1 records
1515
kb replay --record ID --model PROVIDER:MODEL[:EFFORT] re-ask a recorded call
1616
kb export --out FILE [--role judge|generator] dataset (calibration-safe)
17+
kb init REPO --stage STAGE [--dry-run] [--pin SHA]
18+
bootstrap a repository's knowledge base, one
19+
human-merged stage at a time (skeleton, modules,
20+
deepen, harvest-calibration); never touches kb.db
21+
kb init REPO --suggest-seeds rank existing knowledge pages worth seeding from (no model call)
1722
kb publish (--remote HOST:/STATE_DIR | --local DIR) [--once]
1823
the publisher (GPU box, owner's gh login): perform
1924
the signed outbox items; writes need ALLOW_POST=1
@@ -103,6 +108,36 @@ def _refresh(state_dir: Path, ledger, registry) -> None:
103108
_outbox(state_dir, ledger).transition(lambda: None)
104109

105110

111+
def _init_command(args, state_dir: Path) -> int:
112+
from ..config import Settings
113+
114+
if args.suggest_seeds == bool(args.stage):
115+
print("kb init: pass exactly one of --stage or --suggest-seeds", file=sys.stderr)
116+
return 2
117+
if args.suggest_seeds:
118+
from .init_stages import suggest_seeds
119+
from .init_support import InitError, InitRuntime
120+
121+
try:
122+
rt = InitRuntime.from_env(Settings(), state_dir=state_dir)
123+
lifecycle = rt.registry.get(args.repo)
124+
if lifecycle is None:
125+
print(f"no adapter declares knowledge repo {args.repo!r}", file=sys.stderr)
126+
return 2
127+
for path, score, shared in suggest_seeds(rt, lifecycle):
128+
print(f"{score:6.3f} {path} ({', '.join(shared[:8])})")
129+
except InitError as exc:
130+
print(str(exc), file=sys.stderr)
131+
return 1
132+
return 0
133+
from .runner import run_playbook
134+
135+
params = {"stage": args.stage, "dry_run": "true" if args.dry_run else "false", "pin": args.pin or ""}
136+
outcome, run_dir = run_playbook(Settings(), "kb-init", args.repo, state_dir=state_dir, params=params)
137+
print(f"kb init {args.repo} {args.stage}: {outcome.status} ({run_dir})")
138+
return 0 if outcome.status == "done" else 1
139+
140+
106141
def main(argv: list[str] | None = None) -> int:
107142
parser = argparse.ArgumentParser(prog="infermatrix-copilot kb")
108143
parser.add_argument("--state-dir", help="default: $KB_STATE_DIR or ~/.infermatrix-copilot/kb")
@@ -142,6 +177,13 @@ def main(argv: list[str] | None = None) -> int:
142177
export = sub.add_parser("export")
143178
export.add_argument("--out", required=True)
144179
export.add_argument("--role", default="judge", choices=("judge", "generator"))
180+
init = sub.add_parser("init")
181+
init.add_argument("repo")
182+
init.add_argument("--stage", choices=("skeleton", "modules", "deepen", "harvest-calibration"))
183+
init.add_argument("--dry-run", action="store_true",
184+
help="write the tree and PR body under the state directory instead of opening a PR")
185+
init.add_argument("--pin", help="upstream commit to pin (default: the default branch head)")
186+
init.add_argument("--suggest-seeds", action="store_true")
145187
publish = sub.add_parser("publish")
146188
where = publish.add_mutually_exclusive_group(required=True)
147189
where.add_argument("--remote", help="host:/absolute/path of the service state directory (over ssh)")
@@ -163,6 +205,9 @@ def main(argv: list[str] | None = None) -> int:
163205
return _publish(args)
164206
if args.command in {"traces", "replay", "export"}:
165207
return _traces_command(args, _state_dir(args.state_dir))
208+
if args.command == "init":
209+
# before any ledger is opened: kb init never touches the service's kb.db
210+
return _init_command(args, _state_dir(args.state_dir))
166211

167212
state_dir = _state_dir(args.state_dir)
168213
ledger = _ledger(state_dir)

0 commit comments

Comments
 (0)