模型撰寫文件的頁面責任閘門:自由起草、按類發布
Draft Freely, Publish Narrowly: A Page Ownership Gate for Model-Written Docs
文章提出按頁面責任分類管理模型撰寫的文件,透過 TOML 清單及 Python 檢查器限制模型起草與文件發布。分類包括可推導、解讀型和承諾型頁面;承諾型頁面不能由模型起草,並須由指定負責人撰寫及定期覆核。檢查器會核對頁面登記、分類及負責人,檢查承諾型頁面的覆核期限,並阻止草稿連結至導覽檔案。
Draft Freely, Publish Narrowly: A Page Ownership Gate for Model-Written Docs
Documentation pipelines rarely fail because the model writes badly; they fail because nobody defined which pages a model is allowed to finish. A weak paragraph is visible in the diff, but ownership is invisible, so review attention lands on pages where the risk was already low. The fix is structural rather than stylistic: classify every page before drafting starts, then let a small checker enforce that classification on every pull request. The artifact below is a TOML manifest plus a Python gate that runs in about a second on a few hundred files.
The only question that matters: does this page promise something?
Prose quality is not the useful axis for splitting model work from human work, because models now produce acceptable prose in most genres. The useful axis is consequence: what does a reader do differently after believing this page, and who is accountable if the page is wrong? Pages that merely restate repository facts can be drafted and reviewed cheaply, while pages that commit the project to future behavior need a named human on the hook. Three classes capture that distinction well enough to automate around.
| Class | Typical content | Model rights | Human obligation | Gate |
|---|---|---|---|---|
derivable |
CLI flags, config keys, schema fields, exit codes | May draft end-to-end from source | Spot-check against a generated dump | Must exist in manifest; no staleness rule |
interpretive |
Migration guides, architecture rationale, tutorials | May draft; human rewrites the reasoning | Own the argument and the examples | Review on promotion, then quarterly |
promissory |
Support windows, retention, SLAs, security guarantees, pricing | May not draft; may only be linted | Author, sign, and re-review on a short cycle | Owner required; short review window; no model-drafted flag |
The distinction that matters in practice is between derivable and promissory, because those two are where teams lose the most time to the wrong review. Derivable pages are cheap to verify mechanically, so humans should not be reading them closely at all. Promissory pages are expensive to verify because the source of truth lives outside the repository, so they should never enter a generation loop in the first place.
Step 1: Register every page before you generate anything
The manifest is deliberately boring: one record per page, one class, one owner, one review date. Registration is the gate, because a page that is absent from the manifest fails CI and therefore cannot slip into the published navigation. Keep the file inside docs/ so it travels with the content it governs.
# docs/ownership.toml
[defaults]
review_window_days = 90
[[page]]
path = "docs/reference/config.md"
class = "derivable"
owner = "docs-oncall"
source_of_truth = ["src/config/schema.ts", "scripts/dump-config.ts"]
[[page]]
path = "docs/guides/migrating-to-v4.md"
class = "interpretive"
owner = "docs-oncall"
last_human_review = "2026-09-30"
[[page]]
path = "docs/legal/data-retention.md"
class = "promissory"
owner = "compliance-lead"
review_window_days = 30
last_human_review = "2026-10-06"
Two design choices are worth defending explicitly. First, ownership is a role rather than a person, so the gate survives someone changing teams. Second, review_window_days is per page, because a retention statement and a CLI reference do not decay at the same rate.
Step 2: Put the gate in a script you can run locally
The checker below does four things: it fails on unregistered pages, validates class and owner, ages promissory pages against their window, and rejects drafts that are linked from navigation files. It is intentionally declarative and never tries to judge prose, because a lint that guesses at meaning produces arguments instead of fixes.
#!/usr/bin/env python3
"""ownctl.py - ownership gate for model-drafted documentation (Python 3.11+)."""
from __future__ import annotations
import argparse
import datetime as dt
import pathlib
import sys
import tomllib
CLASSES = {"derivable", "interpretive", "promissory"}
NAV_FILES = ("README.md", "docs/SUMMARY.md", "mkdocs.yml", "docusaurus.config.js")
def load_config(root: pathlib.Path) -> dict:
with (root / "docs" / "ownership.toml").open("rb") as fh:
cfg = tomllib.load(fh)
defaults = cfg.get("defaults", {})
pages = {}
for page in cfg.get("page", []):
page.setdefault("class", "interpretive")
page.setdefault("review_window_days", defaults.get("review_window_days", 90))
pages[page["path"]] = page
return {"pages": pages, "draft_dir": defaults.get("draft_dir", ".drafts")}
def cmd_check(args: argparse.Namespace) -> int:
root = pathlib.Path(args.root).resolve()
cfg = load_config(root)
today = dt.date.today()
errors: list[str] = []
on_disk = {p.relative_to(root).as_posix() for p in (root / "docs").rglob("*.md")}
for unregistered in sorted(on_disk - cfg["pages"].keys()):
errors.append(f"unregistered page: {unregistered}")
for path, page in sorted(cfg["pages"].items()):
target = root / path
if not target.exists():
errors.append(f"manifest points at a missing file: {path}")
continue
if page["class"] not in CLASSES:
errors.append(f"{path}: unknown class {page['class']!r}")
if not page.get("owner"):
errors.append(f"{path}: no owner")
if page["class"] != "promissory":
continue
if page.get("model_drafted"):
errors.append(f"{path}: promissory pages must not be model-drafted")
stamped = page.get("last_human_review")
if not stamped:
errors.append(f"{path}: promissory page has no review date")
continue
age = (today - dt.date.fromisoformat(stamped)).days
if age > page["review_window_days"]:
errors.append(
f"{path}: promissory page is {age}d stale "
f"(window {page['review_window_days']}d, owner {page['owner']})"
)
draft_dir = root / cfg["draft_dir"]
if draft_dir.exists():
for draft in sorted(draft_dir.rglob("*.md")):
for nav in NAV_FILES:
nav_path = root / nav
if nav_path.exists() and draft.stem in nav_path.read_text(encoding="utf-8"):
errors.append(f"{draft.relative_to(root)}: draft linked from {nav}")
for err in errors:
print(f"FAIL {err}")
print(f"checked {len(cfg['pages'])} page(s), {len(errors)} problem(s)")
return 1 if errors else 0
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--root", default=".")
parser.set_defaults(func=cmd_check)
args = parser.parse_args()
sys.exit(args.func(args))
An illustrative run over a synthetic fixture looks like the block below; the point is the failure vocabulary, not the counts.
$ python scripts/ownctl.py check --root .
FAIL docs/legal/data-retention.md: promissory page is 41d stale (window 30d, owner compliance-lead)
FAIL docs/howto/webhooks.md: unregistered page: docs/howto/webhooks.md
FAIL .drafts/migrating-to-v4.md: draft linked from mkdocs.yml
checked 34 page(s), 3 problem(s)
$ echo $?
1
Step 3: Generate drafts outside the published tree
The single most useful convention is path separation: the model only ever writes into .drafts/, and promotion is an explicit command. Because the gate rejects drafts that are linked from navigation, a forgotten draft cannot leak into a published site through a theme's automatic sidebar. Drafting is also where a free execution surface actually helps, since the work is bursty.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Two operator-supplied availability claims shape the workflow here: free model access, and a free server option. Those make the bursty part cheap, because a first pass over twenty derivable pages is a batch job nobody wants to schedule capacity for. No model names, quotas, or hardware are assumed below; those come from your own account view, and you should re-read them rather than trusting a blog post dated earlier than your signup.
The transport assumes an OpenAI-compatible /chat/completions path; if your base URL or auth header differs, only _post changes.
# scripts/draft.py - one page, one prompt, writes to .drafts/
import json
import os
import pathlib
import urllib.request
BASE_URL = os.environ["MONKEYCODE_BASE_URL"].rstrip("/")
API_KEY = os.environ["MONKEYCODE_API_KEY"]
MODEL = os.environ["MODEL"] # copy a current name from your account's model list
def _post(payload: dict) -> dict:
req = urllib.request.Request(
f"{BASE_URL}/chat/completions",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}"},
)
with urllib.request.urlopen(req, timeout=180) as resp:
return json.load(resp)
def draft(page: pathlib.Path, sources: list[pathlib.Path]) -> str:
evidence = "\n\n".join(f"<file path='{s}'>\n{s.read_text()}\n</file>" for s in sources)
payload = {
"model": MODEL,
"temperature": 0.2,
"messages": [
{"role": "system", "content": "Write reference documentation from the supplied files only. "
"Never describe behavior that is not visible in them. "
"List open questions at the end under 'Unverified'."},
{"role": "user", "content": f"Page: {page}\n\n{evidence}"},
],
}
out = _post(payload)["choices"][0]["message"]["content"]
header = f"---\nstatus: draft-model\nmodel: {MODEL}\nsources: {[s.name for s in sources]}\n---\n"
dest = pathlib.Path(".drafts") / page.name
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(header + out)
return str(dest)
The Unverified list is the most valuable output of the run, because it turns silent guessing into a review checklist. When a draft comes back with eight open questions, the page probably belongs in interpretive instead of derivable.
Step 4: Make promotion a command with an audit trail
Promotion is where human ownership becomes real, so it should be a single command that stamps a reviewer, a date, and the class. Refusing to promote promissory pages is the core rule: that class must be written by a person who can be held to the statement.
def cmd_promote(args: argparse.Namespace) -> int:
root = pathlib.Path(args.root).resolve()
cfg = load_config(root)
src = root / args.draft
if not src.exists():
sys.exit(f"no such draft: {args.draft}")
page = cfg["pages"].get(args.to)
if page is None:
sys.exit(f"{args.to} is not registered in docs/ownership.toml")
if page["class"] == "promissory":
sys.exit(f"{args.to} is promissory; author it by hand")
stamp = dt.date.today().isoformat()
body = src.read_text(encoding="utf-8")
front = (
"---\n"
"status: reviewed\n"
f"last_human_review: {stamp}\n"
f"reviewer: {args.reviewer}\n"
f"ownership_class: {page['class']}\n"
"---\n"
)
dst = root / args.to
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(front + body.split("---\n", 2)[-1].lstrip("\n"), encoding="utf-8")
src.unlink()
print(f"promoted {args.draft} -> {args.to} (reviewer {args.reviewer})")
return 0
python scripts/ownctl.py check --root .
python scripts/draft.py docs/reference/config.md
python scripts/ownctl.py promote --root . --draft .drafts/config.md \
--to docs/reference/config.md --reviewer docs-oncall
Step 5: Wire the gate into CI as a required check
A gate that only runs locally is a suggestion, so make it a required job on pull requests. Keep it dependency-free and fast, because a slow check gets disabled during incidents.
name: docs-ownership
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python scripts/ownctl.py check --root .
Pair the job with a scheduled run once a week so stale promissory pages surface on a calendar rather than during an audit. The scheduled job should open an issue naming the owner instead of failing the default branch silently.
What to measure, and how to know the split is wrong
The gate is only worth its ceremony if it changes review behavior, so record three numbers per month and compare them against a baseline month before adoption. These are method definitions, not results from any deployment.
| Metric | Definition | Direction that suggests the split works |
|---|---|---|
| Review latency by class | Median hours from draft to promotion |
derivable falls well below interpretive
|
| Rework rate | Share of promotions that are edited within 14 days | Falling for derivable, flat elsewhere |
| Stale promissory pages | Count failing the window check on the scheduled run | Falling after the owner routing lands |
A rising rework rate on derivable pages is the clearest signal that a page was misclassified. The remedy is to reclassify it as interpretive and accept human authorship of the reasoning, instead of adding more prompt instructions.
Limitations and who should skip this
The manifest is page-level, so a single page mixing a config table with a support commitment cannot be split automatically; in practice those cases should be separated into two pages. The class is self-declared, which means the gate enforces consistency rather than truth, and a determined author can mislabel a page without any tool noticing. The staleness check assumes last_human_review is updated honestly, so promotion must be the only path that writes that field in your process. Finally, nothing here validates the prose itself; it validates who is allowed to finish it.
Skip this approach if you are a single maintainer with a dozen pages, because the manifest becomes a second thing to maintain for no review savings. Skip it as well if your docs are generated entirely from code annotations, where the source of truth already implies the class. Teams without a designated docs owner should appoint one first, since a gate with no owner just produces failing builds.
If you do run a batch drafting pass on a free server, keep the promotion command as the only write path into docs/ and let the manifest, not the model, decide what a page is allowed to be.
來源:DEV Community · dev.to