Skip to content

Commit 7123e10

Browse files
authored
Merge pull request #237 from JiusiServe/kb/local-judge-evidence
Judge each rule on the PRs it cites, with full upstream diffs
2 parents 97e1b09 + 0ca0489 commit 7123e10

7 files changed

Lines changed: 382 additions & 5 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,12 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝
282282
的任何变更都会被永久挡住并白白消耗精炼轮次)。没有写出规则 ID 的冲突仍算本次改动的(失败即关闭)。评审提示中带
283283
`changed_rule_ids`。校准不传 `changed`,保持整目录严格判定。首个线上运行即遇到此情况:configuration 目录中 CONF-1a..4a 在
284284
两页重复、DIFF-2s/2t、SERV-4o/4r5 在 main 上已冲突,导致两批 intake 全部 `refine_exhausted`。
285+
286+
## 2026-09-29 L2 按规则取证据
287+
`evidence.for_rule(rule_text, evidence, observer)`:L2 评审每条规则时只给它引用的 PR(`^[PR #N]`;一个都不在批次里则
288+
全部给),并把这些上游合并 PR 的 8 KB 摘录换成合并提交的完整逐文件 diff(服务自己的上游镜像,`MirrorObserver.file_diff`):
289+
规则提到的文件在前,每文件 24 KB、每条规则 48 KB,截断处注明;超出预算的文件列在 `diffs_omitted`。镜像读不到或没有
290+
diff 时保留原摘录(证据不会因此变得更"好过")。只扩展带 `merged_at` 的上游 PR 证据;Copilot 运行经验与外部 PR 证据不变。
291+
`merged_pr` 事件的证据新增 `merge_commit_sha`(旧事件经 `pull()` 查询)。`run_gate(evidence_for=...)` 由 `gate_and_stage`
292+
与事实证明共用同一个观测者(一次镜像同步)。首个线上运行中"所给 diff 在…之前截断"是 L2 判 unsure/fail 的主要原因,
293+
而整批 10 个 PR 的证据(约 105 KB)对每条规则都重复发送;按规则取证据后约 11 KB + 所引 PR 的完整 diff。
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""What the L2 judge sees for ONE rule: the upstream PRs it cites, with full diffs.
2+
3+
An intake event stores a merged PR with an 8 KB diff excerpt (enough to draft
4+
from), and a change set judges every rule against the evidence of its whole
5+
batch. The judge then reads ten PRs' worth of excerpts and still misses the
6+
code a rule describes: "the supplied diff ends before ..." was the most common
7+
reason for an unsure or failed verdict in the first live run.
8+
9+
``for_rule`` narrows the evidence to the PRs the rule cites (``^[PR #N]``, and
10+
the PRs its lifecycle footer names as evidence; all of them when it cites none
11+
we hold) and replaces each excerpt with the full
12+
per-file diffs of the PR as GitHub shows it (its head against its merge
13+
base with main), read from the service's own upstream
14+
mirror: files the rule names first, then source, tests, docs and configuration, within ``PER_FILE`` and
15+
``PER_RULE`` bytes. Only upstream merged-PR evidence is expanded; anything the
16+
mirror cannot answer keeps its excerpt (the judge says "unsure" rather than
17+
guessing), so expansion never makes a verdict easier than today.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import re
23+
24+
from ..knowledge_service.facts import CODE_SPAN, EVIDENCE_PR, PR_CITATION, FactsError
25+
from ..knowledge_service.lifecycle import FOOTER
26+
27+
PER_FILE = 24 * 1024
28+
PER_RULE = 48 * 1024
29+
NOTE = ("full per-file diffs of the merged commit (files the rule names first); "
30+
"a file cut at the byte limit says so")
31+
32+
33+
def _named(text: str) -> list[str]:
34+
out = []
35+
for m in CODE_SPAN.finditer(text):
36+
token = m.group("tok").strip().partition("::")[0].strip("/")
37+
if "/" in token or re.search(r"\.[A-Za-z0-9]{1,5}$", token):
38+
out.append(token)
39+
return out
40+
41+
42+
def _rank(path: str) -> int:
43+
"""Source before tests before docs and configuration (the code a rule describes first)."""
44+
parts = path.lower().split("/")
45+
if any(p in ("docs", "doc", "recipes", "examples") for p in parts[:-1]) or \
46+
parts[-1].endswith((".md", ".rst", ".txt", ".yaml", ".yml", ".json", ".toml")):
47+
return 2
48+
if any(p in ("tests", "test") for p in parts[:-1]) or parts[-1].startswith("test_"):
49+
return 1
50+
return 0
51+
52+
53+
def _matches(path: str, names: list[str]) -> bool:
54+
return any(path == n or path.endswith("/" + n) or n.endswith("/" + path) for n in names)
55+
56+
57+
def _cut(text: str, limit: int) -> str:
58+
data = text.encode("utf-8")
59+
if len(data) <= limit:
60+
return text
61+
return data[:limit].decode("utf-8", "ignore") + f"\n[... cut at {limit} bytes of {len(data)}]\n"
62+
63+
64+
def for_rule(text: str, evidence: list[dict], observer) -> list[dict]:
65+
"""The evidence for one rule (``text``: the rule as proposed, or as it was)."""
66+
cited = {f"PR #{m.group('n')}" for m in PR_CITATION.finditer(text)}
67+
# a retirement's justification lives in its footer (evidence="PR #N"), not in a citation
68+
for line in text.splitlines():
69+
footer = FOOTER.match(line.strip())
70+
if footer:
71+
cited |= {f"PR #{m.group('n')}" for m in EVIDENCE_PR.finditer(footer.group("attrs"))}
72+
items = [e for e in evidence if e.get("source_reference") in cited] or list(evidence)
73+
if observer is None:
74+
return items
75+
names, budget, out = _named(text), PER_RULE, []
76+
for item in items:
77+
number = re.fullmatch(r"PR #(\d+)", str(item.get("source_reference") or ""))
78+
if not number or "merged_at" not in item or budget <= 0:
79+
out.append(item) # not an upstream merged PR (or no room left): as recorded
80+
continue
81+
try:
82+
sha = str(item.get("merge_commit_sha") or observer.pull(int(number.group(1))).get("merge_commit_sha") or "")
83+
if not sha:
84+
raise FactsError("no merge commit")
85+
files = list(item.get("changed_files") or [])
86+
ordered = sorted(files, key=lambda f: (not _matches(f, names), _rank(f)))
87+
diffs: dict[str, str] = {}
88+
for path in ordered:
89+
if budget <= 0:
90+
break
91+
diff = _cut(observer.pr_diff(int(number.group(1)), sha, path), min(PER_FILE, budget))
92+
if diff:
93+
diffs[path] = diff
94+
budget -= len(diff.encode("utf-8"))
95+
except FactsError:
96+
out.append(item)
97+
continue
98+
if not diffs:
99+
out.append(item)
100+
continue
101+
expanded = {k: v for k, v in item.items() if k != "diff_excerpt"}
102+
out.append({**expanded, "diffs": diffs, "diffs_note": NOTE,
103+
"diffs_omitted": [f for f in ordered if f not in diffs]})
104+
return out

‎src/infermatrix_copilot/kb_service/gate.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,14 @@ def _neighbours(files: dict[str, str], path: str, rule_id: str, limit: int = 30)
129129

130130

131131
def judge_block(block: Block, *, base: dict[str, str], head: dict[str, str], evidence: list[dict],
132-
gateway: ModelGateway, judge: ModelRole) -> BlockVerdict:
132+
gateway: ModelGateway, judge: ModelRole, evidence_for=None) -> BlockVerdict:
133+
"""``evidence_for(rule_text, evidence)`` narrows and expands the evidence for
134+
this one block (None: the change set's evidence as recorded)."""
133135
dimensions = DIMENSIONS[block.op]
134136
before = _section_text(base, block.path, block.rule_id) if block.op != "add" else ""
135137
after = _section_text(head, block.path, block.rule_id) if block.op != "purge" else ""
138+
if evidence_for is not None and block.kind == "rule":
139+
evidence = evidence_for(after or before, evidence)
136140
payload = {
137141
"change": {"op": block.op, "page": block.path, "rule_id": block.rule_id,
138142
"before": before, "after": after},
@@ -253,7 +257,8 @@ def run_gate(*, base: dict[str, str], head: dict[str, str], changes: list[Change
253257
external_texts: dict[str, str], evidence: list[dict], gateway: ModelGateway,
254258
judge: ModelRole, release: str, repo_dir: str,
255259
protected_rules: tuple[str, ...] = (),
256-
retire_ratio: float = 0.10, max_files: int = 50, facts=None) -> GateDecision:
260+
retire_ratio: float = 0.10, max_files: int = 50, facts=None,
261+
evidence_for=None) -> GateDecision:
257262
"""``facts`` observes the repository's public upstream (None: the
258263
repository has no upstream to attest, or it publishes nothing)."""
259264
l1 = check_changeset(base, head, changes, external_texts=external_texts, release=release)
@@ -288,8 +293,8 @@ def run_gate(*, base: dict[str, str], head: dict[str, str], changes: list[Change
288293
reasons.append(f"circuit breaker: {removed} of {active_before} rules retired/purged")
289294
if len(changes) > max_files:
290295
reasons.append(f"circuit breaker: {len(changes)} files changed (limit {max_files})")
291-
blocks = [judge_block(b, base=base, head=head, evidence=evidence, gateway=gateway, judge=judge)
292-
for b in l1.blocks]
296+
blocks = [judge_block(b, base=base, head=head, evidence=evidence, gateway=gateway, judge=judge,
297+
evidence_for=evidence_for) for b in l1.blocks]
293298
if any(b.verdict == "fail" for b in blocks):
294299
return GateDecision("fail", reasons + [f"L2 rejected {b.block.rule_id or b.block.path}"
295300
for b in blocks if b.verdict == "fail"], l1, blocks,

‎src/infermatrix_copilot/kb_service/runtime.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,13 +295,14 @@ def gate_and_stage(rt: KbRuntime, lifecycle: RepoLifecycle, owner: str, *, kind:
295295
rt.ledger.heartbeat(owner)
296296
changeset_id = rt.ledger.new_changeset_id(lifecycle.repo, kind)
297297
rule_ids = sorted({op.new_rule_id or op.rule_id for op in operations if (op.new_rule_id or op.rule_id)})
298+
observer = rt.upstream_facts(lifecycle) # one mirror sync for facts and evidence
298299
with trace_context(changeset_id=changeset_id, rule_ids=rule_ids, step="gate"):
299300
decision = run_gate(
300301
base=base, head=head, changes=changes_between(base, head), external_texts=external,
301302
evidence=evidence, gateway=rt.gateway, judge=rt.judge, release=release,
302303
repo_dir=lifecycle.knowledge_dir, protected_rules=lifecycle.protected_rules,
303304
retire_ratio=lifecycle.retire_ratio, max_files=lifecycle.max_files,
304-
facts=rt.upstream_facts(lifecycle))
305+
facts=observer, evidence_for=_evidence_for(observer))
305306
if force_human and decision.status == "pass":
306307
decision.status = "human"
307308
decision.reasons.append(force_human)
@@ -397,6 +398,15 @@ def _pr_body(changeset_id: str, detail: dict) -> str:
397398
return "\n".join(lines)
398399

399400

401+
def _evidence_for(observer):
402+
"""Per-rule evidence with full upstream diffs (the judge's view; see kb_service.evidence)."""
403+
if observer is None:
404+
return None
405+
from .evidence import for_rule
406+
407+
return lambda text, evidence: for_rule(text, evidence, observer)
408+
409+
400410
def service_observer(state_dir: Path, lifecycle: RepoLifecycle, github: GitHubReader):
401411
"""The service's view of a public upstream: its own mirror (shared with the
402412
release sweep) and the read-only GitHub reader. A repository without an

‎src/infermatrix_copilot/kb_service/sources.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ def evidence(self) -> dict:
206206
"title": self.title,
207207
"body": self.body[:MAX_BODY_CHARS],
208208
"merged_at": self.merged_at,
209+
"merge_commit_sha": self.merge_commit_sha,
209210
"changed_files": list(self.changed_files[:MAX_CHANGED_PATHS]),
210211
"diff_excerpt": self.diff_excerpt,
211212
}

‎src/infermatrix_copilot/kb_service/upstream_facts.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,49 @@ def file_text(self, sha: str, path: str) -> str | None:
7373
proc = self._git("cat-file", "blob", f"{sha}:{path}", ok=(0, 1, 128))
7474
return proc.stdout if proc.returncode == 0 else None
7575

76+
def pr_diff(self, number: int, merge_sha: str, path: str) -> str:
77+
"""What PR ``number`` changed in ``path``, as GitHub shows it: from the
78+
merge base of its head with main before it landed (``merge_sha^1``) to
79+
its head. Right for squash, merge-commit and rebase merges alike (for a
80+
rebase merge ``merge_sha`` is only the LAST rebased commit)."""
81+
base, head = self._pr_range(number, merge_sha)
82+
pair = self._pr_files(number, base, head).get(path)
83+
if pair is None:
84+
return ""
85+
# both sides of a rename in the pathspec: rename detection stays on
86+
return self._git("diff", "--no-color", "-U3", "-M", base, head, "--", *dict.fromkeys(pair)).stdout
87+
88+
def _pr_files(self, number: int, base: str, head: str) -> dict[str, tuple[str, ...]]:
89+
"""Every path the PR touches -> (old path, new path), from NUL-separated
90+
name-status (no quoting, so spaces and non-ASCII names are exact)."""
91+
cache = self.__dict__.setdefault("_files", {})
92+
if number not in cache:
93+
fields = self._git("diff", "-M", "--name-status", "-z", base, head).stdout.split("\0")
94+
pairs: dict[str, tuple[str, ...]] = {}
95+
k = 0
96+
while k < len(fields) and fields[k]:
97+
status = fields[k]
98+
if status[:1] in ("R", "C"):
99+
old, new = fields[k + 1], fields[k + 2]
100+
k += 3
101+
else:
102+
old = new = fields[k + 1]
103+
k += 2
104+
pairs[old] = pairs[new] = (old, new)
105+
cache[number] = pairs
106+
return cache[number]
107+
108+
def _pr_range(self, number: int, merge_sha: str) -> tuple[str, str]:
109+
cache = self.__dict__.setdefault("_ranges", {})
110+
if number not in cache:
111+
self._commit(merge_sha)
112+
ref = f"refs/kb-pr/{number}"
113+
self._git("fetch", "--quiet", "origin", f"+refs/pull/{number}/head:{ref}")
114+
head = self._git("rev-parse", f"{ref}^{{commit}}").stdout.strip()
115+
base = self._git("merge-base", f"{merge_sha}^1", head).stdout.strip()
116+
cache[number] = (base, head)
117+
return cache[number]
118+
76119
def pull(self, number: int) -> dict:
77120
try:
78121
data = self._pull(number)

0 commit comments

Comments
 (0)