Skip to content

Commit 1d84a91

Browse files
authored
Merge pull request #267 from JiusiServe/kb/init-quick-maps
kb init: every routed owner page carries a Direct quick map
2 parents 8083c91 + 7d5d582 commit 1d84a91

7 files changed

Lines changed: 770 additions & 14 deletions

File tree

‎doc/architecture/SPEC/direct_routing.md‎

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

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

55
`LOC ~880 · Direct 模式完整策略包与仓库中立的知识路由 · refactor-status: stable`
66

@@ -21,7 +21,7 @@ Direct policy bundle)、`direct_knowledge_routes`、
2121
- **repo 守卫最先跑**:不支持的仓库在任何路由计算之前被拒 —— 修的是一个
2222
真实历史 bug(守卫曾排在空 intent 提前返回之后,向不支持的仓库泄漏
2323
owner 知识)。
24-
- **quick map fail-closed**:`_direct_quick_map` 返回内嵌代码地图与状态
24+
- **quick map fail-closed**:`_direct_quick_map(path)` 读文件后委托给 `_direct_quick_map_text(text)`(同一提取逻辑;kb init 用后者在落盘前检查它要路由的页面)返回内嵌代码地图与状态
2525
`{ok, truncated, unavailable}`,`truncated` 不是装饰 —— 把残图当全图
2626
与缺图同罪、且更难察觉;`_direct_route` 据此置
2727
`read_required = status != "ok"`("自己去打开"是真回退,"什么都不给

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,30 @@ Direct 的路由优先级是:知识侧 `repos/<repo>/_routes.yaml` 存在则
500500
- 没有 routes 文件也没有 `review_routes` 的仓库:modules / deepen `blocked`(消息指明两种来源都没有)。
501501
测试:`test_kb_init_manifest_routed.py`(manifest 路由的 toy:三阶段 dry run 不写 `_routes.yaml`、规则落在 owner 页、
502502
briefing 文档不动、清单含建议项、覆盖率来源;知识侧路由的 toy 行为不变)。
503+
504+
## 2026-10-01 路由到的页面必须带 Direct 快速入口(kb init)
505+
Direct 的产品就是内嵌地图:知识侧 `_routes.yaml` 路由到的每个 owner 页都要有 `## … Direct …` 段
506+
(`direct_routing._direct_quick_map_text`:第一个这样的标题到下一个 `## `,正文非空、≤3500 字符),
507+
`test_every_routed_page_yields_a_quick_map` 逐页把关;#265 路由到的页面没有。`init_quick_maps`:
508+
- `render_quick_map(page_text, signals=, prefixes=, key_files=)`:确定性渲染 `## 代码快速入口(Direct)`(标题以格式的 ID
509+
扫描器 `ANY_RULE_HEADING` 读不成 ID 的词开头——`## Direct …` 会被读成 ID `Direct`、两页即 `duplicate_rule_id`;Direct 只要求标题行含 Direct)
510+
(第二行是标记 `<!-- kb-init:quick-map -->`):页面上每条 active 规则一行(触发行或标题 | 规则 ID |
511+
规则里反引号命名的路径,否则 owner 前缀),没有规则的页面(地图卡、入口页)一行(触发词 | 入口 | 关键文件或前缀);
512+
≤3000 字符,超出从末尾丢行并补一行 `| … | … | … |`,绝不超过 3500。
513+
- `with_quick_map(page_text, section)`:就地替换 init 自己写的段(带标记),没有则插到标题后第一个 `## ` 段之前;
514+
没有标记的 Direct 段是人写的地图,原样保留。该标题不含 ` — `,永远不是规则标题
515+
(`RULE_HEADING` / `_TOP_RULE`),L1 把它当 prose 块,页面上的规则不受影响。
516+
- `_Stage._refresh_quick_maps()`(`_conclude` 开头,三阶段共用):head 里 routes 文件的每个 owner 页,本阶段改动过的
517+
重新渲染(deepen 追加规则后行数随之更新;init 段之后页面新增的内容——如追加到入口页的索引行——保留在段尾),
518+
还没有地图的补上,未改动且已有地图的不动(路由前缀漂移不制造改动);`mapped()` 只在加上地图仍在页面容量内时
519+
返回结果,否则该页不写并作为阻断问题上报(不能留下少一行的旧地图);briefing 文档不写(记入清单,随后被检查挡下);skeleton 从既有 `_index.md`
520+
推导 owner 时跳过 briefing 文档(清单),`_owners_with_room` 把没有容量放地图的既有 owner 页改指到本阶段承接了
521+
它溢出规则的页(`_place` 记录的 `_spilled_from`;同目录的其他新页——比如种子页——装的是别的知识,不算),没有这样的页就
522+
丢弃该 owner 并记入清单。
523+
- 容量:`_apply` 在 `apply_operations` 之后把每个写入的规则页连同渲染出的地图(按 routes 文件里该页 owner 的真实触发词与前缀,`_map_inputs`)一起过 `page_over_capacity`(`_check_capacity_with_map`),超出即报 page full,由 `_place` 溢出到同级页——地图每条规则一行,只量规则会让页面
524+
在加地图后越界;manifest 路由的仓库不写地图,也就不按地图量(否则装得下的规则会被溢出到 manifest 不路由的页面,
525+
Direct 看不到它)。
526+
- 阻断检查:`validate_change(..., quick_map_pages=owner_pages(routes_text))` → `quick_map_problems`
527+
用生产提取器逐页检查,缺页或 `unavailable` 即 blocked 并点名页面;本次写入或改动的页面被服务端截断(`truncated`,CI 拒绝新增的截断)也 blocked。manifest 路由的仓库没有 routes 文件、
528+
不检查(Direct 的 manifest 回退对其页面报 `read_required`)。
529+
测试:`test_kb_init_quick_maps.py`。

‎src/infermatrix_copilot/direct_routing.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,15 @@ def _signal_matches(text: str, signal: str) -> bool:
287287

288288

289289
def _direct_quick_map(path: str, max_chars: int = 3500) -> tuple[str, str]:
290-
"""Return the embedded Direct code map and its status, never the whole page.
290+
"""Return the embedded Direct code map of the page at ``path`` and its
291+
status (``_direct_quick_map_text`` on the file's text)."""
292+
return _direct_quick_map_text(Path(path).read_text(encoding="utf-8"), max_chars)
293+
294+
295+
def _direct_quick_map_text(text: str, max_chars: int = 3500) -> tuple[str, str]:
296+
"""Return the embedded Direct code map in ``text`` and its status, never
297+
the whole page. Text-taking so a producer (kb init) can check a page it
298+
has not written to disk yet against the same extraction the server runs.
291299
292300
Status is ``ok`` / ``truncated`` / ``unavailable``. The last two both mean the
293301
host cannot rely on the excerpt alone.
@@ -298,7 +306,7 @@ def _direct_quick_map(path: str, max_chars: int = 3500) -> tuple[str, str]:
298306
A partial map presented as whole is the same lie as a missing one, just harder
299307
to notice.
300308
"""
301-
lines = Path(path).read_text(encoding="utf-8").splitlines()
309+
lines = text.splitlines()
302310
start = next(
303311
(
304312
index for index, line in enumerate(lines)
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
"""Direct quick maps on the pages kb init routes to.
2+
3+
Direct's product is the embedded map: for a repository routed by a
4+
knowledge-side ``_routes.yaml``, every owner page must carry a ``## … Direct …``
5+
section (``direct_routing._direct_quick_map_text``: the first such heading up to
6+
the next ``## ``, non-empty, at most 3500 characters), and
7+
``test_every_routed_page_yields_a_quick_map`` fails CI on any page that does
8+
not. The first real run (afd-plugin skeleton, PR #265) routed pages that had
9+
none.
10+
11+
kb init therefore renders that section deterministically on every owner page
12+
of the routes file it writes or extends — the rules on the page as rows, or one
13+
row for a map card or an entry page — keeps it the first section after the
14+
title, regenerates it whenever it appends rules to the page (deepen), and
15+
blocks a change whose routed pages would not yield a map. A map init did not
16+
write (no ``MARKER``) is somebody's hand-written map and is never rewritten. A
17+
repository routed by its adapter manifest carries no routes file and is exempt:
18+
Direct's manifest fallback reports ``read_required`` for its pages.
19+
20+
Standard library plus the knowledge format; nothing here names a repository.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import re
26+
from dataclasses import replace
27+
from typing import Iterable, Mapping, Sequence
28+
29+
from ..knowledge_service.facts import CODE_SPAN, PATH
30+
from ..knowledge_service.lifecycle import LifecycleError, Page, Section
31+
32+
# The ID scanner of the knowledge format (``lifecycle.ANY_RULE_HEADING``) reads
33+
# ``## Word ...`` as the ID ``Word`` and L1 refuses one ID on two pages, so the
34+
# heading opens with a word that scanner cannot take for an ID; Direct only
35+
# needs "Direct" somewhere on the heading line.
36+
QUICK_MAP_HEADING = "## 代码快速入口(Direct)"
37+
MARKER = "<!-- kb-init:quick-map -->"
38+
MAX_CHARS = 3000 # what init renders (rows are dropped past it)
39+
HARD_CAP = 3500 # what Direct serves whole (``_direct_quick_map`` cap)
40+
MAX_CELL = 80
41+
MAX_SOURCES = 3
42+
_DIRECT = re.compile(r"^##\s+.*Direct", re.IGNORECASE)
43+
_TRIGGER = re.compile(r"^\s*[-*]\s*(?:\*\*)?触发(?:\*\*)?\s*[::]\s*(?P<text>.+?)\s*$")
44+
_TITLE = re.compile(r"^#{2,3}\s+[A-Za-z0-9][A-Za-z0-9-]{1,40}\s+[—-]\s+(?P<title>.+?)\s*$")
45+
_HEADER = ("| PR 描述在做什么 | 精确规则 | 第一批 live 源码 |", "|---|---|---|")
46+
_MORE = "| … | … | … |"
47+
48+
49+
def _cell(text: str, limit: int = MAX_CELL) -> str:
50+
text = " ".join(str(text).split()).replace("|", "\\|")
51+
return text if len(text) <= limit else text[:limit - 1].rstrip() + "…"
52+
53+
54+
def _paths_in(text: str, limit: int = MAX_SOURCES) -> list[str]:
55+
"""The repository paths (``path`` or ``path::Symbol``) ``text`` names in
56+
backticks, in order of first appearance."""
57+
out: list[str] = []
58+
for match in CODE_SPAN.finditer(text):
59+
token = match.group("tok").strip()
60+
if PATH.fullmatch(token.split("::", 1)[0]) and token not in out:
61+
out.append(token)
62+
if len(out) >= limit:
63+
break
64+
return out
65+
66+
67+
def _trigger(section: Section) -> str:
68+
for line in section.text.splitlines()[1:]:
69+
match = _TRIGGER.match(line)
70+
if match:
71+
return match.group("text")
72+
match = _TITLE.match(section.heading)
73+
return match.group("title") if match else section.rule_id
74+
75+
76+
def _sources(paths: Sequence[str], fallback: Sequence[str]) -> str:
77+
chosen = list(paths) or list(fallback)[:MAX_SOURCES]
78+
return "、".join(f"`{p}`" for p in chosen) if chosen else "—"
79+
80+
81+
def render_quick_map(page_text: str, *, signals: Sequence[str], prefixes: Sequence[str],
82+
key_files: Sequence[str] = ()) -> str:
83+
"""The Direct section for ``page_text``: one row per active rule on the
84+
page (its 触发 line or title, its ID, the paths it names), or one row for
85+
a page without rules (a map card or an entry page: the owner's signals,
86+
its key files or scope prefixes). Deterministic; at most ``MAX_CHARS``
87+
(rows are dropped from the end behind a ``…`` row), never over
88+
``HARD_CAP``."""
89+
try:
90+
rules = [s for s in Page.parse(page_text).rules() if s.footer.status == "active"]
91+
except LifecycleError:
92+
rules = []
93+
rows: list[str] = []
94+
for section in rules:
95+
rows.append(f"| {_cell(_trigger(section))} | {section.rule_id} | "
96+
f"{_sources(_paths_in(section.text), prefixes)} |")
97+
if not rows:
98+
files = list(key_files) or _paths_in(page_text, MAX_SOURCES)
99+
rows.append(f"| {_cell('、'.join(signals) or '(无触发词)')} | 入口 | {_sources(files, prefixes)} |")
100+
lead = (("触发词:" + "、".join(_cell(s, 40) for s in list(signals)[:12]) + "。") if signals else "")
101+
lead += "PR 描述或改动命中下表一行时,先读该行的规则,再读页面其余部分。"
102+
103+
def render(kept: list[str], more: bool) -> str:
104+
return "\n".join([QUICK_MAP_HEADING, MARKER, "", lead, "", *_HEADER, *kept, *([_MORE] if more else [])])
105+
106+
kept, more = list(rows), False
107+
text = render(kept, more)
108+
while len(text) > MAX_CHARS and kept:
109+
kept.pop()
110+
more = True
111+
text = render(kept, more)
112+
if len(text) > HARD_CAP: # pragma: no cover - the lead alone is far under the cap
113+
raise ValueError(f"quick map cannot fit under {HARD_CAP} characters")
114+
return text + "\n"
115+
116+
117+
def _after_table(section_text: str) -> str:
118+
"""The lines of init's map section that follow ITS table (content the
119+
page gained after the map was written: an index line, prose, even a
120+
second table); "" when there are none. The generated table is the run of
121+
``|`` lines right after the ``|---|---|---|`` separator, so anything past
122+
that run — including another table behind prose — is the page's."""
123+
lines = section_text.splitlines()
124+
separator = next((i for i, line in enumerate(lines) if line.strip() == _HEADER[1]), None)
125+
if separator is None:
126+
return ""
127+
end = separator + 1
128+
while end < len(lines) and lines[end].startswith("|"):
129+
end += 1
130+
return "\n".join(lines[end:])
131+
132+
133+
def has_map(page_text: str) -> bool:
134+
"""The page carries a Direct section (init's or hand-written)."""
135+
return any(_DIRECT.match(s.heading) for s in Page.parse(page_text).sections)
136+
137+
138+
def has_hand_written_map(page_text: str) -> bool:
139+
"""A Direct section that init did not write (no ``MARKER``)."""
140+
for section in Page.parse(page_text).sections:
141+
if _DIRECT.match(section.heading):
142+
return MARKER not in section.text
143+
return False
144+
145+
146+
def with_quick_map(page_text: str, section_text: str) -> str:
147+
"""``page_text`` with ``section_text`` as its Direct section: init's own
148+
map replaced in place, a page without one gets it as the first section
149+
after the title, a hand-written map is left exactly as it is."""
150+
page = Page.parse(page_text)
151+
body = section_text.rstrip("\n") + "\n\n"
152+
sections = list(page.sections)
153+
for index, section in enumerate(sections):
154+
if _DIRECT.match(section.heading):
155+
if MARKER not in section.text:
156+
return page_text
157+
# what the page gained after the map (an index line appended to an
158+
# entry page, say) belongs to the page, not to the map: keep it
159+
tail = _after_table(section.text)
160+
last = index == len(sections) - 1
161+
trailing = section.text[len(section.text.rstrip("\n")):] if last else "\n\n"
162+
rendered = section_text.rstrip("\n") + ("\n\n" + tail.rstrip("\n") if tail.strip() else "")
163+
sections[index] = Section(rendered + (trailing or "\n"))
164+
return replace(page, sections=tuple(sections)).render()
165+
head = page.head if page.head.endswith("\n\n") else page.head.rstrip("\n") + "\n\n"
166+
if not sections:
167+
body = section_text.rstrip("\n") + "\n"
168+
return replace(page, head=head, sections=(Section(body), *sections)).render()
169+
170+
171+
def mapped(page_text: str, *, signals: Sequence[str], prefixes: Sequence[str]) -> str | None:
172+
"""``page_text`` with its Direct quick map, or None when the map would
173+
push the page over the knowledge format's capacity (``page_over_capacity``:
174+
the caller keeps the page as it is and says so)."""
175+
from ..knowledge_service.ops import page_over_capacity
176+
177+
text = with_quick_map(page_text, render_quick_map(page_text, signals=signals, prefixes=prefixes))
178+
return None if page_over_capacity(text) else text
179+
180+
181+
def map_inputs(routes_text: str | None, page: str) -> tuple[list[str], list[str]]:
182+
"""The (signals, scope_prefixes) the routes file gives ``page``'s first
183+
owner — what its map is rendered with — or empty lists."""
184+
import yaml
185+
186+
try:
187+
doc = yaml.safe_load(routes_text or "") or {}
188+
except yaml.YAMLError:
189+
return [], []
190+
for owner in (doc.get("owners") or []) if isinstance(doc, dict) else []:
191+
if isinstance(owner, dict) and str(owner.get("path") or "") == page:
192+
return ([str(x) for x in owner.get("signals") or []],
193+
[str(x) for x in owner.get("scope_prefixes") or []])
194+
return [], []
195+
196+
197+
def refresh_quick_maps(head: Mapping[str, str], base: Mapping[str, str], routes_text: str | None, *,
198+
briefing_docs: Iterable[str]) -> tuple[dict[str, str], list[str], list[str]]:
199+
"""``head`` with init's Direct quick map on every owner page of the
200+
routes file that this change touched or that has no map yet, plus the
201+
checklist lines and the BLOCKING problems that produced. An owner page
202+
unchanged since ``base`` that already has a map is left alone (routing
203+
drift makes no churn); a briefing doc is never written (checklist; the
204+
presence check then blocks); a page whose regenerated map no longer
205+
fits the format's capacity is a problem, never a stale map."""
206+
out, checklist, problems = dict(head), [], []
207+
off_limits = set(briefing_docs)
208+
try:
209+
pages = owner_pages(routes_text)
210+
except ValueError:
211+
return out, checklist, problems # the routes file is reported by the validators
212+
for page in pages:
213+
text = out.get(page)
214+
if text is None or not page.endswith(".md"):
215+
continue
216+
if page in off_limits:
217+
checklist.append(f"{page} is a briefing doc (knowledge.briefing_docs): init does not write its "
218+
"Direct quick map; add one by hand or route another page")
219+
continue
220+
if text == base.get(page) and has_map(text):
221+
continue
222+
signals, prefixes = map_inputs(routes_text, page)
223+
with_map = mapped(text, signals=signals, prefixes=prefixes)
224+
if with_map is None:
225+
problems.append(f"quick map: {page} has no room for its Direct quick map (page capacity): "
226+
"split the page by hand, or route another page")
227+
continue
228+
out[page] = with_map
229+
return out, checklist, problems
230+
231+
232+
def owner_pages(routes_text: str | None) -> list[str]:
233+
"""The owner pages a routes file names, in routing order, each once."""
234+
from .init_coverage import load_owners
235+
236+
if routes_text is None:
237+
return []
238+
return list(dict.fromkeys(o.path for o in load_owners(routes_text)))
239+
240+
241+
def quick_map_problems(files: Mapping[str, str], pages: Iterable[str],
242+
base: Mapping[str, str] | None = None) -> list[str]:
243+
"""Why Direct could not serve the routed ``pages`` of ``files``: the same
244+
extraction the server runs (``_direct_quick_map_text``), page by page.
245+
Blocking: a route without a map hands the host nothing, and a map the
246+
server would truncate is not whole — CI refuses newly truncated maps,
247+
so a page this change wrote or changed (``base``) may not truncate."""
248+
from ..direct_routing import _direct_quick_map_text
249+
250+
problems = []
251+
for page in pages:
252+
text = files.get(page)
253+
if text is None:
254+
problems.append(f"quick map: routed page {page} does not exist")
255+
continue
256+
_, status = _direct_quick_map_text(text)
257+
if status == "unavailable":
258+
problems.append(f"quick map: {page} yields no Direct quick map (a routed page needs a "
259+
f"`{QUICK_MAP_HEADING[3:]}` section with a non-empty body)")
260+
elif status == "truncated" and (base is None or base.get(page) != text):
261+
problems.append(f"quick map: {page}: the Direct section as served exceeds {HARD_CAP} characters "
262+
"(truncated); keep the map first and short, and start the rules with a `## ` "
263+
"heading so level-three rules are not part of it")
264+
return problems

0 commit comments

Comments
 (0)