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

程式負責事實、模型撰寫句子:生成文件的事實注入流程

Let Code Write the Facts, Let the Model Write the Sentences: A Fact-Injection Build for Generated Docs

AI 導讀

Avery Lin提出一套生成文件流程,由收集腳本提取可核對的選項值,模型只撰寫解説句,並在渲染時注入事實。流程以佔位符連接資料與文案,並用測試檢查未解析鍵及字面事實值洩漏;理由、遷移指引和警告仍由人員負責。這套流程適合有機器可讀事實來源的文件,不適合以設計理念為主或規模很小的文件。

正文

The most useful split in model-assisted documentation is not prose versus code; it is facts versus sentences. When a model is allowed to restate a default value or a flag name, a wrong answer becomes indistinguishable from a right one at review time. The workflow below hands every checkable value to a collector script and every connective sentence to a drafting model. Facts are injected at render time from a checked-in artifact, so prose physically cannot originate them.

Two definitions keep the rest of this article honest. "Facts" means anything derivable from source: option names, defaults, requiredness, endpoint paths, enum members, and hard limits. "Sentences" means the explanation around those values, which is cheap to regenerate and expensive to get wrong in a subtle way.

What the model may draft, and what a human must own

The ownership table is the whole design compressed into rows. Anything with a mechanical source gets a key; anything requiring intent gets a name.

Content Owner Reason
Option names, defaults, requiredness collector script re-derivable from source on every commit
Reference table row order script deterministic sort beats narrative flow
One-line definition per option model may draft low blast radius, fast to review
Why the option exists, when to avoid it human intent is not present in the code
Migration note after a default change human needs release context and deprecation intent
Fenced examples human, pinned to a tested file must actually execute
Warnings and security caveats human liability does not transfer to a generator

Three rows are non-negotiable in my workflow: rationale, migration guidance, and caveats. A model may propose sentences for them, but a named human signs the merged version.

Step 1 — Derive a facts artifact from source

Start by making the checkable values a build output rather than a paragraph. The collector below is a template; swap the shell-out for your CLI's help output, your OpenAPI file, or your config schema loader.

# collect_facts.py — emit machine-derivable values as JSON
import json, subprocess, sys

def cli_options():
    raw = subprocess.run(["mytool", "--help", "--format", "json"],
                         capture_output=True, text=True, check=True).stdout
    opts = json.loads(raw)["options"]
    return {o["name"].lstrip("-").replace("-", "_"): {
        "flag": o["name"],
        "default": str(o.get("default", "")),
        "required": bool(o.get("required", False)),
    } for o in sorted(opts, key=lambda o: o["name"])}

json.dump({"cli": cli_options()}, sys.stdout, indent=2, sort_keys=True)

Run it into a tracked file and review the diff like any other code change:

python collect_facts.py > facts/facts.json
python -m json.tool facts/facts.json | head -20
git diff --stat facts/facts.json

If the collected diff is empty, the docs reference cannot legally change. That property alone removes a large class of stale-value bugs before any model is involved.

Step 2 — Declare each page as fragments plus required keys

Keep a small manifest that names the page, its prose fragments, and the fact keys the page must cover. This manifest is what the guard test iterates over, so it stays short by design.

{"pages": [{"name": "cli-reference", "fragments": ["intro", "options"],
  "required_keys": ["cli.output.flag", "cli.verbose.default"]}]}

Prose fragments then contain placeholders instead of values. Note that the fragment below never states a default; it only describes what the default means in practice.

### --{{fact:cli.output.flag}}

Writes machine-readable output when you set the flag to {{fact:cli.output.default}}.
Leave it unset for human-readable logs in interactive terminals.

### --{{fact:cli.verbose.flag}}

Default: {{fact:cli.verbose.default}}. Raise verbosity only when you need to
attribute latency to a specific stage.

Step 3 — Constrain the drafting model to placeholders only

Give the drafting model a contract rather than an example to imitate. My contract has three clauses, and the third one is what makes the pipeline fail loudly instead of quietly.

  1. Use a {{fact:...}} token for every value; never type a flag name, default, or limit literally.
  2. If a needed value has no key, emit {{fact:MISSING:describe_what_you_need}} and stop that section.
  3. Do not add warnings, migration advice, or version claims; those are human-authored slots.

The drafting step is where disposable model output helps most, because a rejected fragment costs a rerun and nothing else. I run those fragments through MonkeyCode's free model access, which the operator describes as available without payment, so regenerating a paragraph after a wording change is not a budget decision. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Step 4 — Render and guard the page

The renderer resolves placeholders and refuses to emit a page with unresolved keys. Substitution failures are collected first so you see every broken key in one run, not one per attempt.

# render_docs.py
import json, pathlib, re, sys

ROOT = pathlib.Path(__file__).resolve().parent
TOKEN = re.compile(r"\{\{fact:([a-z0-9_.\[\]-]+)\}\}")

def lookup(facts, dotted):
    cur = facts
    for part in dotted.split("."):
        cur = cur[int(part)] if isinstance(cur, list) else cur[part]
    return cur

def render(page, facts):
    src = (ROOT / "prose" / f"{page['name']}.md").read_text()
    missing = []

    def sub(m):
        try:
            return str(lookup(facts, m.group(1)))
        except (KeyError, IndexError, ValueError):
            missing.append(m.group(1))
            return f"<!--UNRESOLVED:{m.group(1)}-->"

    out = TOKEN.sub(sub, src)
    if missing:
        raise SystemExit(f"{page['name']}: unresolved keys {sorted(set(missing))}")
    return out

if __name__ == "__main__":
    facts = json.loads((ROOT / "facts" / "facts.json").read_text())
    pages = json.loads((ROOT / "pages.json").read_text())["pages"]
    for page in pages:
        pathlib.Path("dist").mkdir(exist_ok=True)
        (pathlib.Path("dist") / f"{page['name']}.md").write_text(render(page, facts))

The second half of the artifact is the leak test. It strips placeholders, then asserts that no literal fact value survives in the prose, which is the mechanical version of "the model may not originate values".

# tests/test_docs_facts.py
import json, pathlib, re, pytest

ROOT = pathlib.Path(__file__).resolve().parents[1]
TOKEN = re.compile(r"\{\{fact:[^}]+\}\}")
ALLOW = {"stdout", "stderr"}  # words that legitimately collide with fact values

def leaves(node):
    if isinstance(node, dict):
        for v in node.values():
            yield from leaves(v)
    elif isinstance(node, list):
        for v in node:
            yield from leaves(v)
    else:
        yield str(node)

@pytest.mark.parametrize("page", json.loads((ROOT / "pages.json").read_text())["pages"],
                         ids=lambda p: p["name"])
def test_no_literal_facts_in_prose(page):
    values = {v for v in leaves(json.loads((ROOT / "facts" / "facts.json").read_text()))
              if len(v) >= 3} - ALLOW
    prose = "".join((ROOT / "prose" / f"{f}.md").read_text() for f in page["fragments"])
    stripped = TOKEN.sub("", prose)
    leaks = sorted(v for v in values if v in stripped)
    assert not leaks, f"{page['name']}: literal facts leaked into prose: {leaks}"

Run the whole loop in one command so the guard, not the reviewer, catches drift:

python collect_facts.py > facts/facts.json && python render_docs.py && pytest tests/test_docs_facts.py -q

Step 5 — Keep a human gate with four questions

Automation shrinks the review surface but never removes it. Four questions cover almost every fragment that survives the leak test.

  1. Does any sentence assert behavior that has no corresponding fact key or tested example?
  2. Does the fragment give advice, name a version, or promise stability the project does not guarantee?
  3. Is every fenced example pinned to a file that runs in CI, rather than typed into prose?
  4. Who signs the rationale, migration, and caveat sentences for this page?

A fragment that fails question two is not edited; it is deleted and rewritten as a human-owned slot. That keeps the model's contribution to connective tissue and keeps accountability attached to a person.

Limitations and who should not use this

The approach only pays off where a machine-readable source exists for the facts. Design rationale, tutorials, postmortems, and architecture narratives have no collector, so the pipeline reduces to expensive prose storage. The leak test is also lexical: a paraphrased fact, such as "disabled unless you ask", passes while still stating a default. Short fact values below three characters collide with ordinary words often enough to need the allowlist shown above. Finally, the collector is code you now maintain, and a quietly broken collector produces a confidently wrong page.

Skip this workflow if your team lacks a single source of truth for option values, or if your docs are mostly persuasion rather than reference. It is also a poor fit for very small surfaces with four stable flags, where a plain Markdown file reviewed by hand is cheaper than a render step and a guard.

If you want to test the drafting half without changing your pipeline, run steps one and four locally first and keep the model out until the fact artifact is clean. The free model access and free server option are useful for repeatable fragment redrafts and for keeping the collect-render-test loop reachable from a shared environment, though neither substitutes for the ownership table. Start with the table, not the model, and the rest of the build follows.

來源:DEV Community · dev.to