Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion doc/architecture/SPEC/kb_service.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# kb_service/ —— 规范

<!-- verified-against: 2026-09-30 -->
<!-- verified-against: 2026-10-01 -->

`知识服务核心:仓库配置、账本、outbox、CLI · refactor-status: new`

Expand Down Expand Up @@ -477,3 +477,26 @@ x 必须为正。因阈值停下的调用(`stop_reason="max_budget"`)→ `Mo

## 2026-09-30 新索引每页只登记一次(kb init)
`init_stages.unlink_listed(text, listed)`:新建 `_index.md` 时,生成的导语里指向目录清单已列出页面的行内链接改成纯文本标签(支持尖括号目标与标题;锚点、`./` 忽略;图片与其他目标不动)。改写后再用 `l1.link_targets`(CommonMark 解析器)核验:若仍有指向清单页面的链接(改写不认识的写法,如引用式链接),整段可选导语丢弃并记入清单,绝不登记两次。目录清单是每个页面唯一的登记,知识树校验拒绝同一页面登记两次。回归来自 jiuwenswarm 试点。测试:`test_kb_init_skeleton.py`。

## 2026-10-01 kb init:由 adapter manifest 路由的仓库(回归:afd-plugin 首次真实运行,PR #265)
Direct 的路由优先级是:知识侧 `repos/<repo>/_routes.yaml` 存在则用它,否则回退到 adapter manifest 的
`review_routes`(`{prefix, owner, doc}`)。kb init 不得改变这一优先级下的路由结果,也不得把规则写进 briefing 文档:
- `init_coverage.owners_from_review_routes(manifest)`:把 `review_routes` 按 (owner, doc) 归组成 `Owner`(path = doc,
前缀按声明顺序;同名 owner 的第二个 doc 以 doc 的 stem 作后缀);`owner_table(routes_text, manifest)` →
`("routes_file" | "manifest" | "none", owners)`,与 Direct 同一优先级:routes 文件有 owner 才算 `routes_file`;文件存在但 `owners: []` 时和 Direct 一样退回 manifest(且该文件保持不动——填入 owner 会覆盖 manifest 路由),只有 manifest 也没有路由时空文件才是 init 填写的知识侧表(损坏的 routes 文件抛错,绝不静默换成 manifest)。
每个阶段在 `_inputs` 里算出 `route_source` / `owners`,覆盖率、吸收、deepen 分组一律用它。
- **manifest 路由的仓库**:任何阶段都不写 `_routes.yaml`(写了会覆盖 manifest,把 `ready` 变成 `scope_fallback`、
改变金样路由)。skeleton 把生成器的 owner 提案里 manifest 尚未路由的前缀、modules 阶段本要追加的前缀 / 新 owner,
一律变成清单项 `review_routes (adapter PR): {prefix, owner, doc}`(`review_route_line`;adapter 是人工把关的,
由 adapter PR 落地);modules 阶段仍写地图卡与分组页,覆盖率的 after 按"这些建议已合入"计算并在 PR 正文说明来源;
deepen 阶段把规则写到 manifest 所指的 owner 页(`_rule_page_for`:owner 页是规则页则就地追加,manifest 指向尚不存在的
`rules*.md` 则创建它,否则旁边的 `rules.md` / `rules-code.md`)。文档不变量规则的落点:由 `_paths_named`(证据行 +
规则正文里反引号命名的路径,`facts.claims_in`)经 `most_specific` 找到最具体覆盖最多路径的 owner(`_owner_for`),
无 owner 覆盖的留在规则页。三个阶段都在记录里标 `coverage.routes_source`。
- **briefing 文档只读**:`briefing_docs(manifest)` = `knowledge.briefing_docs` ∪ `briefing_docs_extra` ∪
`performance_briefing_docs`;`adapters.base` 按硬上限渲染它们、超出部分静默截断(afd-plugin 的 16 条规则把索引挤出了
briefing)。init 永不向这些页面追加规则或正文(`_writable_page` 改写到同目录的 `rules-init.md` 并记入清单;仓库规则页是
briefing 文档时 skeleton 的规则页就是 `rules-init.md`);索引登记行不在此限(知识树校验要求新页面登记)。
- 没有 routes 文件也没有 `review_routes` 的仓库:modules / deepen `blocked`(消息指明两种来源都没有)。
测试:`test_kb_init_manifest_routed.py`(manifest 路由的 toy:三阶段 dry run 不写 `_routes.yaml`、规则落在 owner 页、
briefing 文档不动、清单含建议项、覆盖率来源;知识侧路由的 toy 行为不变)。
72 changes: 70 additions & 2 deletions src/infermatrix_copilot/kb_service/init_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@

An empty population counts as fully covered (ratio 1.0): there is nothing a
route could miss.

The owner table comes from ``owner_table``: a knowledge-side ``_routes.yaml``
when the repository has one, else the adapter manifest's ``review_routes``
(the precedence ``direct_routing`` applies), else nothing. A repository routed
by its manifest is measured exactly like one routed by a table; what differs
is that init never writes its routes (see ``init_stages``).
"""

from __future__ import annotations
Expand Down Expand Up @@ -82,9 +88,71 @@ def load_owners(routes_yaml_text: str) -> list[Owner]:
return out


def owners_from_review_routes(manifest: Mapping | None) -> list[Owner]:
"""The owner table an adapter manifest's ``review_routes`` declares
(``{prefix, owner, doc}`` items, the form ``direct_routing`` falls back to
when a repository has no knowledge-side ``_routes.yaml``): one owner per
(owner, doc) pair in declaration order, its prefixes in declaration order.
An owner name that recurs with a second doc is suffixed with that doc's
stem so names stay unique. Items without a prefix or a doc are skipped."""
routes = (manifest or {}).get("review_routes") or []
if not isinstance(routes, list):
return []
grouped: dict[tuple[str, str], list[str]] = {}
for item in routes:
if not isinstance(item, dict):
continue
prefix = str(item.get("prefix") or "").replace("\\", "/").strip()
doc = str(item.get("doc") or "").strip()
if not prefix or not doc:
continue
owner = str(item.get("owner") or prefix.rstrip("/")).strip()
grouped.setdefault((owner, doc), [])
if prefix not in grouped[(owner, doc)]:
grouped[(owner, doc)].append(prefix)
taken: set[str] = set()
out = []
for (owner, doc), prefixes in grouped.items():
name = owner if owner not in taken else f"{owner}/{PurePosixPath(doc).stem}"
n = 1
while name in taken: # two docs with the same stem (components/a/rules.md, components/b/rules.md)
n += 1
name = f"{owner}/{PurePosixPath(doc).stem}-{n}"
taken.add(name)
out.append(Owner(name, doc, tuple(prefixes)))
return out


def owner_table(routes_text: str | None, manifest: Mapping | None) -> tuple[str, list[Owner]]:
"""Where a repository's routes come from and the owners they define, with
the precedence ``direct_routing`` applies: ``("routes_file", ...)`` for a
knowledge-side ``_routes.yaml`` that names owners, else ``("manifest", ...)``
for adapter ``review_routes``, else ``("none", [])``. Direct falls back to
the manifest when the routes file has NO owners, so a valid but empty file
defers to manifest routes too (and must stay untouched: filling it would
override the manifest); only without manifest routes is an empty file the
knowledge-side table init fills. A malformed routes file raises
(``load_owners``); it is never silently replaced by the manifest."""
file_owners = load_owners(routes_text) if routes_text is not None else None
if file_owners:
return "routes_file", file_owners
manifest_owners = owners_from_review_routes(manifest)
if manifest_owners:
return "manifest", manifest_owners
return ("routes_file", []) if file_owners is not None else ("none", [])


def reaches(path: str, prefix: str) -> bool:
"""``prefix`` covers ``path``. A directory prefix ``pkg/`` also covers the
directory itself named without its slash (``facts.claims_in`` strips it
from a rule's backticked ``pkg/``), so a rule about a directory reaches
the owner of that directory."""
return path.startswith(prefix) or (prefix.endswith("/") and path == prefix[:-1])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match manifest prefixes case-insensitively

For a manifest route whose prefix casing differs from the repository path, this case-sensitive comparison disagrees with production Direct routing: _adapter_changed_file_routes() case-folds both the changed path and prefix before matching. In that case init treats files that Direct successfully routes as uncovered, emits duplicate route suggestions, and can omit or misplace deepen rules. Normalize with the same case-folding semantics used by Direct.

Useful? React with 👍 / 👎.



def routes_file(path: str, owners: Sequence[Owner]) -> list[Owner]:
"""Every owner whose scope prefixes reach ``path``, in routing order."""
return [o for o in owners if any(path.startswith(p) for p in o.prefixes)]
return [o for o in owners if any(reaches(path, p) for p in o.prefixes)]


def _page_depth(owner: Owner) -> int:
Expand All @@ -100,7 +168,7 @@ def most_specific(path: str, owners: Sequence[Owner]) -> list[Owner]:
best: tuple[int, int] | None = None
out: list[Owner] = []
for owner in owners:
length = max((len(p) for p in owner.prefixes if path.startswith(p)), default=-1)
length = max((len(p) for p in owner.prefixes if reaches(path, p)), default=-1)
if length < 0:
continue
key = (length, _page_depth(owner))
Expand Down
54 changes: 15 additions & 39 deletions src/infermatrix_copilot/kb_service/init_deepen.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
screening — the docs redundancy filter (D5), evidence inside the group's
files, pinned claims, placement, the advisory judge — and are appended to the
owner's rule page (never editing an existing rule; a full page spills to a
sibling). The pass stops when the rule-bearing PR-weighted coverage reaches
sibling; a briefing doc is never written to). Owners come from the
repository's ``_routes.yaml`` or, when it has none, from the adapter
manifest's ``review_routes`` (``init_coverage.owner_table``): a repository
routed by its manifest is deepened onto the pages the manifest routes to.
The pass stops when the rule-bearing PR-weighted coverage reaches
``init.coverage_target``, the budget runs out, or no hot group is left, and it
always says which in the record's notes (and so the PR body): a pass never
ends below the target without the reason and the hottest files still
Expand All @@ -37,9 +41,8 @@

import yaml

from ..knowledge_service.lifecycle import LifecycleError, Page
from .init_budget import BudgetExhausted
from .init_coverage import Owner, load_owners, make_include, most_specific, pr_weighted_coverage
from .init_coverage import Owner, make_include, most_specific, pr_weighted_coverage
from .init_modules import scan_modules
from .init_stages import (
ROUTES_NAME, _Candidate, _fence, _numbered, _one_line, _Stage, _title_of, adapter_missing,
Expand Down Expand Up @@ -119,13 +122,10 @@ def _build(self, tree: Path) -> InitRecord:

init = self.lifecycle.init
routes_path = f"{self.repo_dir}/{ROUTES_NAME}"
text = self.base.get(routes_path)
if text is None:
return self._blocked([f"{routes_path} does not exist: run (and merge) the earlier stages first"])
try:
owners = load_owners(text)
except (ValueError, yaml.YAMLError) as exc:
return self._blocked([f"{routes_path} is not a valid route table: {exc}"])
if self.route_source == "none":
return self._blocked([f"{routes_path} does not exist and the adapter declares no review_routes: "
"run (and merge) the earlier stages first"])
owners = self.owners # the routes file, or the adapter manifest's review_routes (same precedence as Direct)
flip, problems = self._flip()
if problems:
return self._blocked(problems)
Expand Down Expand Up @@ -183,6 +183,7 @@ def _build(self, tree: Path) -> InitRecord:
"uncovered_hot": [list(item) for item in self._without_rules(prs, owners, include)[:MAX_HOT_LIST]],
"unrouted_hot": [list(item) for item in current.uncovered_hot[:MAX_HOT_LIST]],
"target": init.coverage_target,
"routes_source": self.route_source,
}
from ..knowledge_service.pinned_claims import Evidence

Expand Down Expand Up @@ -234,15 +235,6 @@ def _without_rules(self, prs, owners: list[Owner], include) -> list[tuple[str, i
return sorted(missed.items(), key=lambda item: (-item[1], item[0]))

# -- rule pages ------------------------------------------------------------------
def _active_rules(self, path: str) -> int:
text = self.head.get(path)
if text is None or not path.endswith(".md"):
return 0
try:
return sum(1 for s in Page.parse(text).rules() if s.footer.status == "active")
except (LifecycleError, yaml.YAMLError):
return 0

def _rule_pages(self, owners: list[Owner]) -> set[str]:
"""Owner paths that bear rules: the page itself holds an active rule,
or (an entry or prose page) a page in the SAME directory does — never a
Expand All @@ -253,33 +245,17 @@ def _rule_pages(self, owners: list[Owner]) -> set[str]:
if self._active_rules(owner.path):
bearing.add(owner.path)
continue
redirected = self._redirect_of(owner.path)
if redirected != owner.path and self._active_rules(redirected):
bearing.add(owner.path) # a briefing doc's rules live beside it
continue
if owner.path in self.head and self._is_rule_page(owner.path):
continue # a rule page owns its rules itself: an empty one bears none
directory = PurePosixPath(owner.path).parent
if any(PurePosixPath(p).parent == directory and self._active_rules(p) for p in self.head):
bearing.add(owner.path)
return bearing

def _is_rule_page(self, path: str) -> bool:
try:
page = Page.parse(self.head[path])
return page.frontmatter_data().get("type") == "rule" or bool(page.rules())
except (KeyError, LifecycleError, yaml.YAMLError):
return False

def _rule_page_for(self, owner: Owner) -> str:
"""Where an owner's new rules go: its own page when that is a rule
page, else ``rules.md`` beside it (``rules-code.md`` when a prose
``rules.md`` is in the way)."""
if owner.path in self.head and self._is_rule_page(owner.path):
return owner.path
directory = str(PurePosixPath(owner.path).parent)
for name in ("rules.md", "rules-code.md"):
path = f"{directory}/{name}"
if path not in self.head or self._is_rule_page(path):
return path
raise InitError(f"no rule page can be placed beside {owner.path}")

# -- one module --------------------------------------------------------------------
def _code_payload(self, tree: Path, files: list[str]) -> list[dict]:
out, used = [], 0
Expand Down
49 changes: 40 additions & 9 deletions src/infermatrix_copilot/kb_service/init_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from .init_budget import BudgetExhausted
from .init_coverage import ROOT_MODULE, Owner, load_owners, module_coverage, most_specific, routes_file
from .init_stages import (
_SLUG, ROUTES_NAME, _fence, _one_line, _page_frontmatter, _slug, _Stage, _title_of,
_SLUG, ROUTES_NAME, _fence, _one_line, _page_frontmatter, _slug, _Stage, _title_of, review_route_line,
)
from .init_support import InitError, InitRecord, generate

Expand Down Expand Up @@ -249,13 +249,23 @@ class _Modules(_Stage):
def _build(self, tree: Path) -> InitRecord:
routes_path = f"{self.repo_dir}/{ROUTES_NAME}"
text = self.base.get(routes_path)
if text is None:
return self._blocked([f"{routes_path} does not exist: run (and merge) the skeleton stage first"])
try:
doc = yaml.safe_load(text) or {}
load_owners(text)
except (ValueError, yaml.YAMLError) as exc:
return self._blocked([f"{routes_path} is not a valid route table: {exc}"])
if self.route_source == "routes_file":
try:
doc = yaml.safe_load(text) or {}
load_owners(text)
except (ValueError, yaml.YAMLError) as exc:
return self._blocked([f"{routes_path} is not a valid route table: {exc}"])
elif self.route_source == "manifest":
# the adapter manifest routes this repository: the same table, kept
# in memory only — what this stage would append becomes suggested
# review_routes entries, never a routes file (it would take
# precedence over the manifest and change every route)
doc = {"schema_version": 1, "owners": [
{"owner": o.owner, "path": o.path, "signals": [], "scope_prefixes": list(o.prefixes)}
for o in self.owners]}
else:
return self._blocked([f"{routes_path} does not exist and the adapter declares no review_routes: "
"run (and merge) the skeleton stage first"])
self.routes_path, self.routes_before = routes_path, doc
self.routes = copy.deepcopy(doc)
if not isinstance(self.routes.get("owners"), list):
Expand Down Expand Up @@ -289,6 +299,7 @@ def _build(self, tree: Path) -> InitRecord:
"modules": {"before": round(before.ratio, 4), "after": round(after.ratio, 4), "total": len(modules)},
"unrouted": after.uncovered,
"cards": {c.module: c.page for c in cards},
"routes_source": self.route_source,
}
problems = routes_append_only(self.routes_before, self.routes)
if problems:
Expand Down Expand Up @@ -523,8 +534,28 @@ def _append_links(self, index: str, links: list[tuple[str, str]]) -> None:
text += f"- [{title}]({link})\n"
self.head[index] = text

def _suggest_routes(self) -> None:
"""Every owner or prefix this stage would have appended to a routes
file, as the exact ``review_routes`` entries the adapter manifest
lacks (adapters are human-gated: an adapter PR, never a route file)."""
before = {str(o["owner"]): o for o in self.routes_before.get("owners") or []}
count = 0
for owner in self.routes.get("owners") or []:
known = before.get(str(owner["owner"]))
had = list((known or {}).get("scope_prefixes") or [])
for prefix in owner.get("scope_prefixes") or []:
if prefix in had:
continue
self.record.checklist.append(review_route_line(prefix, str(owner["owner"]), str(owner["path"])))
count += 1
self.record.notes.append("routes come from the adapter manifest (review_routes): kb init writes no "
f"{ROUTES_NAME}; {count} review_routes entr{'y is' if count == 1 else 'ies are'} "
"suggested on the checklist")

def _conclude(self, rules, evidence, other=None, check_other=None) -> InitRecord:
if self.routes != self.routes_before:
if self.route_source == "manifest":
self._suggest_routes()
elif self.routes != self.routes_before:
header = []
for line in self.base[self.routes_path].splitlines(keepends=True):
if not line.startswith("#"):
Expand Down
Loading
Loading