跳到正文
DEV Community· Avery Lin·· 3 小時前AI 評分57

引用還是具名負責人:模型撰寫文件的路由閘門

Citation or a Named Owner: A Routing Gate for Model-Drafted Docs

AI 導讀

Avery Lin提出一套模型撰寫文件的路由閘門,按段落的證據與責任狀態分為四級,只有證據可解析且已簽署的模型段落才可通過 CI。系統以 HTML 註解標出人工作業段落,並在生成提示詞前將其剔除;簽署則綁定段落雜湊。作者指出,引用解析只能確認目標在程式庫中存在,不能證明段落描述正確。

正文

A paragraph drafted by a model should reach main only when it resolves to something in the repository or carries a human's name. Everything else is a paragraph nobody has agreed to defend, and the usual review queue hides that fact behind a green check. This article describes a small router I use to enforce that rule, plus the parts of documentation a model should never draft at all.

The gate splits a Markdown page into paragraph-sized blocks, tries to resolve every [[ev:...]] pointer against the codebase, and returns one of four tiers per block. Blocks that resolve and carry a ledger signature pass; blocks that resolve without a signature wait for a named reviewer; blocks with no evidence are blocked outright. Human-owned blocks are marked with an invisible owner comment and are stripped out of the prompt before the model ever sees the page. If you have read the debate about reviewers acting as a proxy between a team and a model, this is the mechanical version of that complaint.

The rule: a citation or a name

The rule is deliberately binary, because three-way ownership conversations never terminate. A single sentence of documentation is either tied to an artifact that a script can find, or it belongs to a person who accepts responsibility. Tone, warnings, migration advice, and anything legal fall in the second category by definition, no matter how confident the model sounds.

This also fixes a review pathology that is easy to miss. When every paragraph is equally reviewable, reviewers skim and sign everything, which is exactly the proxy behaviour that upstream discussions of AI-assisted work keep warning about. Making sign-off paragraph-scoped and hash-bound makes blanket approval visible as a suspiciously fast ledger.

The four tiers

Tier Who writes it Who signs it Merge rule
merge_ready model, from evidence a human, once, bound to the paragraph hash passes CI
unsigned model, from evidence a named human blocked until signed
unowned model or human nobody blocked; assign an owner or delete
human_owned humans only the owner named in the marker model output for that block is discarded

The tier names are the whole contract, and the pipeline only needs to understand four strings. Everything below is the implementation that produces them.

Step 1 — Mark the human-owned zone with an invisible comment

Use an HTML comment immediately after the heading, so the marker disappears in the rendered page. The heading text stays clean for readers, and the owner name is greppable in review diffs.

## Security posture
<!-- owner: security-team -->
Data at rest is encrypted, and the rotation schedule is documented internally.

Anything matching <!-- owner: ... --> is treated as human-owned, and this takes precedence over evidence. A block can have perfect citations and still be human-owned, which is the correct outcome for security, pricing, deprecation policy, and support commitments. Before building the drafting prompt, drop these blocks from the input entirely, rather than instructing the model to leave them alone.

Step 2 — Give the model a pointer vocabulary it cannot invent

The model may only cite four kinds of pointer, each with a mechanically checkable shape. Anything else is prose, and prose is owned by a person.

## Retry policy

The client retries idempotent requests three times with exponential backoff [[ev:cfg:settings.retry_attempts]].
The budget is pinned by a test [[ev:test:test_retry_budget]] and the helper lives in [[ev:sym:RetryPolicy]].

sym targets a definition, test targets a collected test id, cfg targets a configuration key, and issue is accepted syntactically but never resolved. Issue pointers are always human-owned, because a script cannot verify what a tracker link claims. Keep the vocabulary small: every new pointer type is a new way for a confident sentence to look verified when it is not.

Step 3 — Resolve pointers against the repo and fail closed

The gate is about eighty lines of standard library and shell calls, and it exits non-zero when any block is not merge_ready. Resolution is intentionally crude; a grep that finds nothing is a rejection, not a warning.

#!/usr/bin/env python3
"""evidence_gate.py: route every doc block to a citation or a named owner."""
import hashlib, json, re, subprocess, sys
from pathlib import Path

POINTER = re.compile(r"\[\[ev:(sym|test|cfg|issue):([^\]\s]+)\]\]")
OWNER = re.compile(r"<!--\s*owner:\s*([a-z0-9_.-]+)\s*-->")
HEADING = re.compile(r"^(#{1,6})\s+(.+)$")


def split_blocks(text: str) -> list[dict]:
    blocks, heading, buf = [], "", []

    def flush() -> None:
        block = "\n".join(buf).strip()
        if block:
            blocks.append({"heading": heading, "text": block,
                           "sha": hashlib.sha256(block.encode()).hexdigest()[:16]})

    for line in text.splitlines():
        match = HEADING.match(line)
        if match:
            flush(); buf = []; heading = match.group(2).strip(); continue
        if not line.strip():
            flush(); buf = []; continue
        buf.append(line)
    flush()
    return blocks


def grep(pattern: str, root: Path) -> list[str]:
    done = subprocess.run(["grep", "-rIlE", "--exclude-dir=.git", pattern, str(root)],
                          capture_output=True, text=True)
    return [line for line in done.stdout.splitlines() if line]


def collected_tests(root: Path) -> set[str]:
    done = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q"],
                          cwd=root, capture_output=True, text=True)
    return {line.split("::")[-1] for line in done.stdout.splitlines() if "::" in line}


def resolve(kind: str, target: str, root: Path, tests: set[str]) -> list[str]:
    if kind == "sym":
        return grep(rf"(def|class|const|function)\s+{re.escape(target)}\b", root)
    if kind == "cfg":
        key = target.split(":")[-1]
        return grep(rf"^\s*{re.escape(key)}\s*[:=]", root)
    if kind == "test":
        return [target] if target.split("::")[-1] in tests else []
    return []  # issue links are not verifiable from the repo


def route(docs: Path, root: Path, signed: set[str]) -> list[dict]:
    tests = collected_tests(root)
    rows = []
    for block in split_blocks(docs.read_text(encoding="utf-8")):
        owner = OWNER.search(block["text"])
        evidence, unresolved = [], []
        for kind, target in POINTER.findall(block["text"]):
            hits = resolve(kind, target, root, tests)
            (evidence if hits else unresolved).append({"kind": kind, "target": target, "hits": hits})
        if owner:
            tier, who = "human_owned", owner.group(1)
        elif unresolved or not evidence:
            tier, who = "unowned", None
        elif block["sha"] not in signed:
            tier, who = "unsigned", None
        else:
            tier, who = "merge_ready", None
        rows.append({**block, "tier": tier, "owner": who,
                     "evidence": evidence, "unresolved": unresolved})
    return rows


if __name__ == "__main__":
    docs = Path(sys.argv[1])
    root = Path(sys.argv[2] if len(sys.argv) > 2 else ".")
    ledger = Path("signatures.json")
    signed = set(json.loads(ledger.read_text())) if ledger.exists() else set()
    rows = route(docs, root, signed)
    print(json.dumps(rows, indent=2))
    blocked = [row for row in rows if row["tier"] != "merge_ready"]
    print(f"{len(rows)} blocks, {len(blocked)} blocked", file=sys.stderr)
    sys.exit(1 if blocked else 0)

Run it from the repository root, so that the grep and the test collection see the same tree the docs describe.

python -m pytest --collect-only -q | grep '::' | head   # sanity-check test ids
python evidence_gate.py docs/retries.md . > routing.json

The JSON output is the review artifact, and it is intentionally boring. Each row lists the block hash, its tier, the files that satisfied each pointer, and the pointers that failed. This is where the drafting pass runs naturally on MonkeyCode's free model access, with the resolver and the test collection on the free server option; both are operator-supplied availability claims, and I make no assumptions about quotas, model names, or throughput. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Step 4 — Hash the paragraph so a re-draft voids the old signature

Signatures must attach to text, not to sections, or a regenerated paragraph inherits an approval it never earned. That is why the ledger stores only the sixteen-character block hash, and why a single changed word returns the block to unsigned.

# sign.py <block-sha> — append one human sign-off to the ledger
import json, sys
from pathlib import Path
ledger = Path("signatures.json")
signed = set(json.loads(ledger.read_text())) if ledger.exists() else set()
signed.add(sys.argv[1])
ledger.write_text(json.dumps(sorted(signed), indent=2) + "\n")

Example output for the snippet in step 2 looks like the following, and it is illustrative rather than a benchmark. The retry block resolves both pointers and waits for a signature, while the security block is human-owned and never enters a prompt.

[
  {"heading": "Retry policy", "sha": "9f2c41ab77d0e3c5", "tier": "unsigned",
   "evidence": [{"kind": "cfg", "target": "settings.retry_attempts", "hits": ["./settings.toml"]},
                {"kind": "test", "target": "test_retry_budget", "hits": ["test_retry_budget"]}],
   "unresolved": []},
  {"heading": "Security posture", "sha": "1c8de40a55b9f271", "tier": "human_owned",
   "owner": "security-team", "evidence": [], "unresolved": []}
]

Step 5 — Wire the gate into CI and cap the human queue

Put the script in the docs job, cache the pytest --collect-only output, and fail the build on any tier other than merge_ready. Caching matters on large suites, because collection can take longer than the gate itself and will otherwise dominate the job.

A useful second rule is a hard cap on unsigned blocks per pull request, for example five. When the cap is exceeded, the author must either find more evidence or move content into a human-owned block and assign an owner. That single limit prevents the pattern where a model regenerates a whole page and the review queue silently becomes a reading queue.

What this gate cannot prove

Pointer resolution proves existence, not correctness, which is the most important limitation here. A citation can point at a real configuration key that the paragraph describes incorrectly, and no hash will notice. Symbol lookup is also grep-based, so renames produce false rejections until the pointer is updated, which is annoying but safe.

Parametrised test ids drift when fixtures change, and issue pointers never resolve by design, so a tracker-heavy page stays effectively human-owned. Formatting-only edits invalidate signatures, so reformat before signing rather than after. None of these costs are hidden in the tier names, and none of them justify skipping the gate on a page that has already shipped wrong numbers.

Who should not use this

Skip this approach if your documentation lives outside the code repository, because the resolver has nothing to search. Skip it if no named humans can own security, pricing, or migration blocks, since the gate will manufacture a queue nobody drains. Skip it for marketing pages and regulation-bound text, where the correct default is human authorship from the first draft.

For everything else, start with one page and one owner, and let the blocked count tell you whether the model is drafting what the repository can actually support.

來源:DEV Community · dev.to