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
9 changes: 9 additions & 0 deletions doc/architecture/SPEC/kb_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,12 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝
的任何变更都会被永久挡住并白白消耗精炼轮次)。没有写出规则 ID 的冲突仍算本次改动的(失败即关闭)。评审提示中带
`changed_rule_ids`。校准不传 `changed`,保持整目录严格判定。首个线上运行即遇到此情况:configuration 目录中 CONF-1a..4a 在
两页重复、DIFF-2s/2t、SERV-4o/4r5 在 main 上已冲突,导致两批 intake 全部 `refine_exhausted`。

## 2026-09-29 L2 按规则取证据
`evidence.for_rule(rule_text, evidence, observer)`:L2 评审每条规则时只给它引用的 PR(`^[PR #N]`;一个都不在批次里则
全部给),并把这些上游合并 PR 的 8 KB 摘录换成合并提交的完整逐文件 diff(服务自己的上游镜像,`MirrorObserver.file_diff`):
规则提到的文件在前,每文件 24 KB、每条规则 48 KB,截断处注明;超出预算的文件列在 `diffs_omitted`。镜像读不到或没有
diff 时保留原摘录(证据不会因此变得更"好过")。只扩展带 `merged_at` 的上游 PR 证据;Copilot 运行经验与外部 PR 证据不变。
`merged_pr` 事件的证据新增 `merge_commit_sha`(旧事件经 `pull()` 查询)。`run_gate(evidence_for=...)` 由 `gate_and_stage`
与事实证明共用同一个观测者(一次镜像同步)。首个线上运行中"所给 diff 在…之前截断"是 L2 判 unsure/fail 的主要原因,
而整批 10 个 PR 的证据(约 105 KB)对每条规则都重复发送;按规则取证据后约 11 KB + 所引 PR 的完整 diff。
104 changes: 104 additions & 0 deletions src/infermatrix_copilot/kb_service/evidence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""What the L2 judge sees for ONE rule: the upstream PRs it cites, with full diffs.

An intake event stores a merged PR with an 8 KB diff excerpt (enough to draft
from), and a change set judges every rule against the evidence of its whole
batch. The judge then reads ten PRs' worth of excerpts and still misses the
code a rule describes: "the supplied diff ends before ..." was the most common
reason for an unsure or failed verdict in the first live run.

``for_rule`` narrows the evidence to the PRs the rule cites (``^[PR #N]``, and
the PRs its lifecycle footer names as evidence; all of them when it cites none
we hold) and replaces each excerpt with the full
per-file diffs of the PR as GitHub shows it (its head against its merge
base with main), read from the service's own upstream
mirror: files the rule names first, then source, tests, docs and configuration, within ``PER_FILE`` and
``PER_RULE`` bytes. Only upstream merged-PR evidence is expanded; anything the
mirror cannot answer keeps its excerpt (the judge says "unsure" rather than
guessing), so expansion never makes a verdict easier than today.
"""

from __future__ import annotations

import re

from ..knowledge_service.facts import CODE_SPAN, EVIDENCE_PR, PR_CITATION, FactsError
from ..knowledge_service.lifecycle import FOOTER

PER_FILE = 24 * 1024
PER_RULE = 48 * 1024
NOTE = ("full per-file diffs of the merged commit (files the rule names first); "
"a file cut at the byte limit says so")


def _named(text: str) -> list[str]:
out = []
for m in CODE_SPAN.finditer(text):
token = m.group("tok").strip().partition("::")[0].strip("/")
if "/" in token or re.search(r"\.[A-Za-z0-9]{1,5}$", token):
out.append(token)
return out


def _rank(path: str) -> int:
"""Source before tests before docs and configuration (the code a rule describes first)."""
parts = path.lower().split("/")
if any(p in ("docs", "doc", "recipes", "examples") for p in parts[:-1]) or \
parts[-1].endswith((".md", ".rst", ".txt", ".yaml", ".yml", ".json", ".toml")):
return 2
if any(p in ("tests", "test") for p in parts[:-1]) or parts[-1].startswith("test_"):
return 1
return 0


def _matches(path: str, names: list[str]) -> bool:
return any(path == n or path.endswith("/" + n) or n.endswith("/" + path) for n in names)


def _cut(text: str, limit: int) -> str:
data = text.encode("utf-8")
if len(data) <= limit:
return text
return data[:limit].decode("utf-8", "ignore") + f"\n[... cut at {limit} bytes of {len(data)}]\n"


def for_rule(text: str, evidence: list[dict], observer) -> list[dict]:
"""The evidence for one rule (``text``: the rule as proposed, or as it was)."""
cited = {f"PR #{m.group('n')}" for m in PR_CITATION.finditer(text)}
# a retirement's justification lives in its footer (evidence="PR #N"), not in a citation
for line in text.splitlines():
footer = FOOTER.match(line.strip())
if footer:
cited |= {f"PR #{m.group('n')}" for m in EVIDENCE_PR.finditer(footer.group("attrs"))}
items = [e for e in evidence if e.get("source_reference") in cited] or list(evidence)
if observer is None:
return items
names, budget, out = _named(text), PER_RULE, []
for item in items:
number = re.fullmatch(r"PR #(\d+)", str(item.get("source_reference") or ""))
if not number or "merged_at" not in item or budget <= 0:
out.append(item) # not an upstream merged PR (or no room left): as recorded
Comment on lines +78 to +79

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 Stop retaining excerpts after exhausting the rule budget

When the selected PRs consume PER_RULE, every remaining cited PR is appended with its original diff excerpt. A rule citing several large PRs can therefore include 48 KB of expanded diffs plus another 8 KB per remaining PR, defeating the stated per-rule limit and potentially recreating the oversized judge prompts this change is intended to prevent. Preserve only metadata and an omission marker once the budget is exhausted.

Useful? React with 👍 / 👎.

continue
try:
sha = str(item.get("merge_commit_sha") or observer.pull(int(number.group(1))).get("merge_commit_sha") or "")
if not sha:
raise FactsError("no merge commit")
files = list(item.get("changed_files") or [])

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 Preserve every changed path needed for diff expansion

For PRs changing more than 50 files, this list comes from PullRequest.evidence(), which truncates changed_files to MAX_CHANGED_PATHS. If a rule names a file after that cutoff, the new expansion cannot prioritize or retrieve its diff; once any earlier file produces a diff, the recorded excerpt is also removed, so the judge receives unrelated files while the relevant file is not even reported in diffs_omitted. Retain or obtain the complete changed-path list for expansion.

Useful? React with 👍 / 👎.

ordered = sorted(files, key=lambda f: (not _matches(f, names), _rank(f)))
diffs: dict[str, str] = {}
for path in ordered:
if budget <= 0:
break
diff = _cut(observer.pr_diff(int(number.group(1)), sha, path), min(PER_FILE, budget))
if diff:
diffs[path] = diff
budget -= len(diff.encode("utf-8"))
except FactsError:
out.append(item)
continue
if not diffs:
out.append(item)
continue
expanded = {k: v for k, v in item.items() if k != "diff_excerpt"}
out.append({**expanded, "diffs": diffs, "diffs_note": NOTE,
"diffs_omitted": [f for f in ordered if f not in diffs]})
return out
13 changes: 9 additions & 4 deletions src/infermatrix_copilot/kb_service/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,14 @@ def _neighbours(files: dict[str, str], path: str, rule_id: str, limit: int = 30)


def judge_block(block: Block, *, base: dict[str, str], head: dict[str, str], evidence: list[dict],
gateway: ModelGateway, judge: ModelRole) -> BlockVerdict:
gateway: ModelGateway, judge: ModelRole, evidence_for=None) -> BlockVerdict:
"""``evidence_for(rule_text, evidence)`` narrows and expands the evidence for
this one block (None: the change set's evidence as recorded)."""
dimensions = DIMENSIONS[block.op]
before = _section_text(base, block.path, block.rule_id) if block.op != "add" else ""
after = _section_text(head, block.path, block.rule_id) if block.op != "purge" else ""
if evidence_for is not None and block.kind == "rule":
evidence = evidence_for(after or before, evidence)
payload = {
"change": {"op": block.op, "page": block.path, "rule_id": block.rule_id,
"before": before, "after": after},
Expand Down Expand Up @@ -253,7 +257,8 @@ def run_gate(*, base: dict[str, str], head: dict[str, str], changes: list[Change
external_texts: dict[str, str], evidence: list[dict], gateway: ModelGateway,
judge: ModelRole, release: str, repo_dir: str,
protected_rules: tuple[str, ...] = (),
retire_ratio: float = 0.10, max_files: int = 50, facts=None) -> GateDecision:
retire_ratio: float = 0.10, max_files: int = 50, facts=None,
evidence_for=None) -> GateDecision:
"""``facts`` observes the repository's public upstream (None: the
repository has no upstream to attest, or it publishes nothing)."""
l1 = check_changeset(base, head, changes, external_texts=external_texts, release=release)
Expand Down Expand Up @@ -288,8 +293,8 @@ def run_gate(*, base: dict[str, str], head: dict[str, str], changes: list[Change
reasons.append(f"circuit breaker: {removed} of {active_before} rules retired/purged")
if len(changes) > max_files:
reasons.append(f"circuit breaker: {len(changes)} files changed (limit {max_files})")
blocks = [judge_block(b, base=base, head=head, evidence=evidence, gateway=gateway, judge=judge)
for b in l1.blocks]
blocks = [judge_block(b, base=base, head=head, evidence=evidence, gateway=gateway, judge=judge,
evidence_for=evidence_for) for b in l1.blocks]
if any(b.verdict == "fail" for b in blocks):
return GateDecision("fail", reasons + [f"L2 rejected {b.block.rule_id or b.block.path}"
for b in blocks if b.verdict == "fail"], l1, blocks,
Expand Down
12 changes: 11 additions & 1 deletion src/infermatrix_copilot/kb_service/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,14 @@ def gate_and_stage(rt: KbRuntime, lifecycle: RepoLifecycle, owner: str, *, kind:
rt.ledger.heartbeat(owner)
changeset_id = rt.ledger.new_changeset_id(lifecycle.repo, kind)
rule_ids = sorted({op.new_rule_id or op.rule_id for op in operations if (op.new_rule_id or op.rule_id)})
observer = rt.upstream_facts(lifecycle) # one mirror sync for facts and evidence
with trace_context(changeset_id=changeset_id, rule_ids=rule_ids, step="gate"):
decision = run_gate(
base=base, head=head, changes=changes_between(base, head), external_texts=external,
evidence=evidence, gateway=rt.gateway, judge=rt.judge, release=release,
repo_dir=lifecycle.knowledge_dir, protected_rules=lifecycle.protected_rules,
retire_ratio=lifecycle.retire_ratio, max_files=lifecycle.max_files,
facts=rt.upstream_facts(lifecycle))
facts=observer, evidence_for=_evidence_for(observer))
if force_human and decision.status == "pass":
decision.status = "human"
decision.reasons.append(force_human)
Expand Down Expand Up @@ -397,6 +398,15 @@ def _pr_body(changeset_id: str, detail: dict) -> str:
return "\n".join(lines)


def _evidence_for(observer):
"""Per-rule evidence with full upstream diffs (the judge's view; see kb_service.evidence)."""
if observer is None:
return None
from .evidence import for_rule

return lambda text, evidence: for_rule(text, evidence, observer)


def service_observer(state_dir: Path, lifecycle: RepoLifecycle, github: GitHubReader):
"""The service's view of a public upstream: its own mirror (shared with the
release sweep) and the read-only GitHub reader. A repository without an
Expand Down
1 change: 1 addition & 0 deletions src/infermatrix_copilot/kb_service/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ def evidence(self) -> dict:
"title": self.title,
"body": self.body[:MAX_BODY_CHARS],
"merged_at": self.merged_at,
"merge_commit_sha": self.merge_commit_sha,
"changed_files": list(self.changed_files[:MAX_CHANGED_PATHS]),
"diff_excerpt": self.diff_excerpt,
}
Expand Down
43 changes: 43 additions & 0 deletions src/infermatrix_copilot/kb_service/upstream_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,49 @@ def file_text(self, sha: str, path: str) -> str | None:
proc = self._git("cat-file", "blob", f"{sha}:{path}", ok=(0, 1, 128))
return proc.stdout if proc.returncode == 0 else None

def pr_diff(self, number: int, merge_sha: str, path: str) -> str:
"""What PR ``number`` changed in ``path``, as GitHub shows it: from the
merge base of its head with main before it landed (``merge_sha^1``) to
its head. Right for squash, merge-commit and rebase merges alike (for a
rebase merge ``merge_sha`` is only the LAST rebased commit)."""
base, head = self._pr_range(number, merge_sha)
pair = self._pr_files(number, base, head).get(path)
if pair is None:
return ""
# both sides of a rename in the pathspec: rename detection stays on
return self._git("diff", "--no-color", "-U3", "-M", base, head, "--", *dict.fromkeys(pair)).stdout

def _pr_files(self, number: int, base: str, head: str) -> dict[str, tuple[str, ...]]:
"""Every path the PR touches -> (old path, new path), from NUL-separated
name-status (no quoting, so spaces and non-ASCII names are exact)."""
cache = self.__dict__.setdefault("_files", {})
if number not in cache:
fields = self._git("diff", "-M", "--name-status", "-z", base, head).stdout.split("\0")
pairs: dict[str, tuple[str, ...]] = {}
k = 0
while k < len(fields) and fields[k]:
status = fields[k]
if status[:1] in ("R", "C"):
old, new = fields[k + 1], fields[k + 2]
k += 3
else:
old = new = fields[k + 1]
k += 2
pairs[old] = pairs[new] = (old, new)
cache[number] = pairs
return cache[number]

def _pr_range(self, number: int, merge_sha: str) -> tuple[str, str]:
cache = self.__dict__.setdefault("_ranges", {})
if number not in cache:
self._commit(merge_sha)
ref = f"refs/kb-pr/{number}"
self._git("fetch", "--quiet", "origin", f"+refs/pull/{number}/head:{ref}")
head = self._git("rev-parse", f"{ref}^{{commit}}").stdout.strip()
base = self._git("merge-base", f"{merge_sha}^1", head).stdout.strip()
cache[number] = (base, head)
return cache[number]

def pull(self, number: int) -> dict:
try:
data = self._pull(number)
Expand Down
Loading
Loading