跳到正文
DEV Community· Avery Lin·· 2 小時前AI 評分60

Markdown 區域標記與雜湊閘門如何保護人工負責的文件內容

Marker-Delimited Doc Regions: A Hash Gate That Keeps Model Drafts Out of Human-Owned Text

AI 導讀

文章示範以 Markdown 標記區分模型起草區和人工負責區,並用 Python 檢查器比對起草區 ID 與人工區文本的正規化雜湊,發現越界修改便令檢查失敗。檢查器不評估文字品質或主張真偽,雜湊亦不代表內容可信;作者建議在 pre-commit 和 CI 執行檢查,並為合法修改更新 manifest。文中披露文章是 MonkeyCode 產品推廣內容,並提及其免費模型存取與免費伺服器選項。

正文

The boundary between what a model may draft and what a human must own survives only if it is machine-readable and checked on every commit. This article puts that boundary inside the Markdown file itself, then adds a small Python gate that fails a pull request when drafting crosses it. The gate says nothing about writing quality and nothing about whether a claim is true; it enforces ownership, which is the constraint review processes lose first when drafts arrive faster than reviewers. Everything below is ordinary file parsing, so the approach works with any drafting model and any editor.

The failure this prevents

Most documentation pipelines treat a page as one unit of review, so a model asked to tighten a summary can also rewrite the on-call rotation and the deprecation warning. Reviewers then read the diff as a whole, approve the paragraphs they recognize, and skim the ones they do not. The result is a sentence that reads as finished, sits in a section no named person owns, and gets quoted by a support engineer three weeks later. Region markers change the unit of review from the page to the paragraph, and hashing the human-owned paragraphs turns ownership into a check that runs without a reviewer in the room.

Two region kinds, one rule each

Region kind Who may edit it What the gate verifies
scope:draft the drafting model, any time the set of draft region ids is unchanged
scope:human a named person, through review the normalized text hash matches the manifest

The rule for a draft region is deliberately loose, because that is where drafting adds value. The rule for a human region is deliberately strict, because a silent edit there is the failure this workflow exists to catch. The gate compares identifiers and hashes and never parses prose, so it stays cheap enough to run on every commit.

Step 1: mark the regions in the page

Markers are HTML comments, which disappear from rendered output and stay visible in raw diffs. Use one region per paragraph cluster, put the owner in the id when ownership is not obvious, and never nest regions.

# Rate limits

<!-- scope:draft id=summary -->
The API returns 429 responses when a client exceeds its window.
<!-- /scope:draft -->

<!-- scope:human id=limits-owner-sre -->
The current window is 60 seconds and is configured per key.
Contact #platform-sre before changing it.
<!-- /scope:human -->

Three details matter. Ids are stable identifiers rather than titles, so renaming a heading does not invalidate the manifest. A human region should contain only statements that need accountability, not every sentence on the page. Marker lines are excluded from the hash, which lets a person rename an id without pretending the content changed.

Step 2: hash only the text a human owns

Hashing raw bytes fails on trailing whitespace and editor reflow, so normalize before digesting. Strip trailing spaces on each line, drop leading and trailing blank lines, and join with a single newline. Do not collapse interior spaces and do not strip inside fenced code blocks, because that is where a changed value hides.

# tools/scopecheck.py (part 1: the hashing core)
import hashlib


def normalize(lines):
    kept = [line.rstrip() for line in lines]
    while kept and not kept[0].strip():
        kept.pop(0)
    while kept and not kept[-1].strip():
        kept.pop()
    return '\n'.join(kept)


def digest(lines):
    body = normalize(lines).encode('utf-8')
    return 'sha256:' + hashlib.sha256(body).hexdigest()[:16]

A truncated 16-character digest is enough here because the comparison set is small and local. It is not a security control, and it should not be treated as proof that a paragraph is trustworthy. Its only job is to detect that bytes under a protected marker moved since the manifest was written.

Step 3: parse, compare, and exit non-zero

The checker walks the docs tree, parses markers line by line, and refuses malformed markup before comparing anything. A nested region, an unclosed region, or a duplicated id is a hard error with exit code 2, because a gate that guesses is worse than a gate that stops.

# tools/scopecheck.py (part 2: the checker, same file as part 1)
import argparse
import json
import re
import sys
from pathlib import Path

MARKER = re.compile(
    r'^<!--\s*(?P<close>/)?scope:(?P<kind>draft|human)\s+id=(?P<rid>[A-Za-z0-9_.-]+)\s*-->\s*$'
)


class BadMarkup(Exception):
    pass


def parse(path):
    regions, stack = {}, []
    for lineno, line in enumerate(path.read_text(encoding='utf-8').splitlines(), 1):
        found = MARKER.match(line)
        if found is None:
            if stack:
                stack[-1]['lines'].append(line)
            continue
        rid, kind = found.group('rid'), found.group('kind')
        if found.group('close') is None:
            if stack:
                raise BadMarkup(f'{path}:{lineno}: nested region in {stack[-1]["rid"]}')
            stack.append({'rid': rid, 'kind': kind, 'line': lineno, 'lines': []})
            continue
        if not stack:
            raise BadMarkup(f'{path}:{lineno}: close marker without an open marker')
        opened = stack.pop()
        if (opened['rid'], opened['kind']) != (rid, kind):
            raise BadMarkup(f'{path}:{lineno}: close marker does not match {opened["rid"]}')
        if rid in regions:
            raise BadMarkup(f'{path}:{lineno}: duplicate region id {rid}')
        regions[rid] = {'kind': kind, 'line': opened['line'], 'digest': digest(opened['lines'])}
    if stack:
        raise BadMarkup(f'{path}: unclosed region {stack[-1]["rid"]}')
    return regions


def scan(root):
    state = {}
    for path in sorted(root.rglob('*.md')):
        regions = parse(path)
        state[str(path.relative_to(root))] = {
            'draft': sorted(r for r, v in regions.items() if v['kind'] == 'draft'),
            'human': {r: v['digest'] for r, v in regions.items() if v['kind'] == 'human'},
        }
    return state


def compare(baseline, current):
    problems = []
    for name, expected in baseline.items():
        actual = current.get(name)
        if actual is None:
            problems.append(f'{name}: file is gone but the manifest still lists it')
            continue
        if actual['draft'] != expected['draft']:
            problems.append(f'{name}: draft region ids changed')
        for rid, want in expected['human'].items():
            got = actual['human'].get(rid)
            if got is None:
                problems.append(f'{name}: human region {rid} is missing')
            elif got != want:
                problems.append(f'{name}: human region {rid} was edited ({want} -> {got})')
        for rid in actual['human']:
            if rid not in expected['human']:
                problems.append(f'{name}: human region {rid} is not in the manifest')
    for name in current:
        if name not in baseline:
            problems.append(f'{name}: has regions but no manifest entry, run --update')
    return problems


def main(argv=None):
    ap = argparse.ArgumentParser()
    ap.add_argument('root', type=Path)
    ap.add_argument('--manifest', type=Path, default=Path('.docscope/manifest.json'))
    ap.add_argument('--update', action='store_true')
    ap.add_argument('--reviewed-by', default='')
    args = ap.parse_args(argv)
    try:
        current = scan(args.root)
    except BadMarkup as exc:
        print(f'scopecheck: {exc}', file=sys.stderr)
        return 2
    if args.update:
        if not args.reviewed_by:
            print('scopecheck: --update requires --reviewed-by', file=sys.stderr)
            return 2
        args.manifest.parent.mkdir(parents=True, exist_ok=True)
        payload = json.dumps(current, indent=2, sort_keys=True) + '\n'
        args.manifest.write_text(payload, encoding='utf-8')
        print(f'scopecheck: manifest written, reviewed by {args.reviewed_by}')
        return 0
    if args.manifest.exists():
        baseline = json.loads(args.manifest.read_text(encoding='utf-8'))
    else:
        baseline = {}
    problems = compare(baseline, current)
    for problem in problems:
        print(f'scopecheck: {problem}', file=sys.stderr)
    return 1 if problems else 0


if __name__ == '__main__':
    raise SystemExit(main())

Two design choices in that script are worth defending. Draft regions are tracked by id rather than by hash, so routine rewriting creates no manifest churn and reviewers only see human-owned changes. And --update refuses to run without --reviewed-by, so refreshing the baseline always records a person, which turns the manifest into a lightweight approval log instead of a cache.

Step 4: run it before the diff is reviewed

Run the gate locally through a pre-commit hook and again in CI, so a missing hook cannot become the reason a bad edit merges.

python tools/scopecheck.py docs --manifest .docscope/manifest.json
# .git/hooks/pre-commit
#!/bin/sh
python tools/scopecheck.py docs --manifest .docscope/manifest.json || {
  echo 'Human-owned doc region changed. Review it, then re-run with:'
  echo '  python tools/scopecheck.py docs --update --reviewed-by "$(git config user.name)"'
  exit 1
}
- name: human-owned doc regions unchanged
  run: python tools/scopecheck.py docs --manifest .docscope/manifest.json

When a person legitimately edits a protected paragraph, the flow is to edit, run the update command with their own name, and commit the page and the manifest together. The manifest diff then shows exactly which protected regions moved, which is a far shorter thing to review than a regenerated page.

Step 5: test the gate before trusting it

A gate that has never failed has never been tested. These four tests cover the cases that matter, and they finish in well under a second.

# tests/test_scopecheck.py
import pytest

import scopecheck

PAGE = '''# Rate limits

<!-- scope:draft id=summary -->
The API returns 429 responses when a client exceeds its window.
<!-- /scope:draft -->

<!-- scope:human id=limits-owner-sre -->
The current window is 60 seconds and is configured per key.
<!-- /scope:human -->
'''


def build(tmp_path, name, text):
    doc = tmp_path / name / 'docs' / 'rate-limits.md'
    doc.parent.mkdir(parents=True)
    doc.write_text(text, encoding='utf-8')
    return doc.parent


def test_draft_rewrite_is_allowed(tmp_path):
    before = scopecheck.scan(build(tmp_path, 'a', PAGE))
    after = scopecheck.scan(build(tmp_path, 'b', PAGE.replace('429 responses', 'HTTP 429')))
    assert scopecheck.compare(before, after) == []


def test_human_edit_is_flagged(tmp_path):
    before = scopecheck.scan(build(tmp_path, 'a', PAGE))
    after = scopecheck.scan(build(tmp_path, 'b', PAGE.replace('60 seconds', '30 seconds')))
    assert 'edited' in scopecheck.compare(before, after)[0]


def test_removed_human_region_is_flagged(tmp_path):
    before = scopecheck.scan(build(tmp_path, 'a', PAGE))
    stripped = PAGE.replace('<!-- scope:human id=limits-owner-sre -->\n', '')
    stripped = stripped.replace('<!-- /scope:human -->', '')
    after = scopecheck.scan(build(tmp_path, 'b', stripped))
    assert scopecheck.compare(before, after)


def test_nested_markers_are_rejected(tmp_path):
    bad = '<!-- scope:draft id=a -->\n<!-- scope:draft id=b -->\n<!-- /scope:draft -->\n'
    with pytest.raises(scopecheck.BadMarkup):
        scopecheck.scan(build(tmp_path, 'a', bad))

Run it with pytest -q tests/test_scopecheck.py. If the human-edit test passes while the manifest is untouched, the gate is decorative and you should not rely on it yet.

Where the drafting half fits

Region markers only matter if something drafts inside them, and a drafting job needs somewhere to run. MonkeyCode's free model access and free server option, both as described by the operator, cover those two pieces: the model access drafts only scope:draft regions against a fixed prompt, and the server option runs the batch job without a workstation left awake. The prompt stays boring on purpose, because the gate, not the instruction, defines the boundary.

You may rewrite text between scope:draft markers only.
Return the full file. Do not add, remove, or rename any marker.
Do not touch text between scope:human markers.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. If you want to try the drafting half without a billing setup, start with the free model access and let the gate tell you quickly whether drafts stay inside their regions.

What this gate does not prove

  • It proves a human region is byte-identical, not correct; a stale paragraph nobody touched passes happily.
  • It cannot stop semantic drift inside draft regions, so claim-level review still applies to everything the model rewrites.
  • Marker removal is caught only through the draft id set, so a rewrite that consistently reshapes the whole file is out of scope.
  • It says nothing about files outside the scanned root, about content in a CMS, or about translations, which need their own manifest per locale.

Who should not adopt this

Teams with a single documentation author gain little, because the hash gate duplicates a review step that already happens every time. Repositories where docs live in a product CMS should fix the publishing path before adding markers. And teams whose protected sections change weekly will turn --update into a reflex, at which point the manifest becomes a rubber stamp and stops meaning anything.

Adoption checklist

  1. Mark two pages rather than twenty, and pick sections that genuinely need a named owner.
  2. Generate the manifest with --reviewed-by set to a real reviewer name.
  3. Add the CI step and confirm it fails on a deliberate edit inside a human region.
  4. Write the four tests, run them, and only then enable the drafting job.

來源:DEV Community · dev.to