Files
chemenu/tools/chemenu/commands/cite_cmd.py
T
torben 18ae28f918
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s
Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
2026-09-01 16:26:14 +02:00

221 lines
8.8 KiB
Python

"""`wikitool cite ...` - real GFM footnote citations.
A citation marker is `[^cite-id]` in a page's prose, resolved by a
`[^cite-id]: [[Source - X]]` (or `[[Source - X|file.md]]`) definition line in
the page's trailing Footnotes block (see chemenu.provenance for the
regexes and cite_id() derivation). AGENTS.md invariant 1 forbids hand-writing
generated structure, and a cite-id is exactly that - an author must never
compute or paste one by hand. `cite add` is the only way to get one onto a
page; `cite sync` is the only way to reconcile a page's block after prose
edits changed which ids are actually referenced.
None of this writes the inline `[^cite-id]` reference into prose: where a
citation belongs in a sentence is an editorial call, same as the prose itself
(see tools/CONTRACT.md's design notes). `cite add` prints the marker to paste
in; the LLM places it.
"""
from __future__ import annotations
from typing import Optional
import typer
from chemenu import config
from chemenu.commands._util import fail, rel_path, success
from chemenu.frontmatter_io import write_page
from chemenu.page import Page
from chemenu.kb_scan import load_kb_pages
from chemenu.provenance import (
CITE_REF_RE,
cite_id,
cite_block_heading,
render_page_body,
split_cite_block,
unique_cite_id,
)
app = typer.Typer(help="Manage [^cite-id] footnote citations and their Footnotes definition blocks.")
def _find_page(pages: dict[str, Page], title: str) -> Page:
if title not in pages:
fail(f"No page titled '{title}' found under wiki/.")
return pages[title]
@app.command("id")
def cite_id_command(
title: str = typer.Option(..., "--title", help="Source page title, e.g. 'Source - Docker Cheatsheet'"),
file: Optional[str] = typer.Option(None, "--file", help="Qualifier for a multi-file source, e.g. 'storage-model.md'"),
):
"""Print the deterministic id cite_id() would derive for (--title, --file).
Read-only preview - does not check the id is actually free on any given
page (two pages, or two distinct pairs on one page, can share this base
id; `cite add`/`cite sync` are what apply the real -2/-3 suffixing).
"""
typer.echo(cite_id(title, file))
def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) -> tuple[str, str, bool]:
"""Ensure `page` has a Footnotes definition for (source_title, qualifier)
and that source_title is in its frontmatter `sources:`. Returns
(cite_id_to_use, new_body, changed) - reuses an existing definition for
the same pair instead of minting a duplicate id."""
head, definitions = split_cite_block(page.body)
existing_id = next(
(cid for cid, pair in definitions.items() if pair == (source_title, qualifier)),
None,
)
if existing_id is not None:
marker_id = existing_id
block_changed = False
else:
marker_id = unique_cite_id(set(definitions), source_title, qualifier)
definitions[marker_id] = (source_title, qualifier)
block_changed = True
sources = page.frontmatter.setdefault("sources", [])
sources_changed = source_title not in sources
if sources_changed:
sources.append(source_title)
new_body = render_page_body(head, definitions, cite_block_heading(page.body))
changed = block_changed or sources_changed or new_body != page.body
return marker_id, new_body, changed
@app.command("add")
def cite_add(
page_title: str = typer.Option(..., "--page", help="Exact title of the page to add a citation on"),
source: str = typer.Option(..., "--source", help="Exact title of the source page being cited, e.g. 'Source - X'"),
file: Optional[str] = typer.Option(None, "--file", help="Qualifier for a multi-file source, e.g. 'storage-model.md'"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview instead of writing"),
):
"""Upsert a Footnotes definition for `--source` (reusing it if the page
already cites the same source/file pair) and ensure `--source` is in the
page's frontmatter `sources:`. Prints the `[^cite-id]` marker to paste
into the prose - placing it is still the caller's job.
"""
pages = load_kb_pages(config.KB_DIR)
page = _find_page(pages, page_title)
if source not in pages:
fail(f"No page titled '{source}' found under wiki/ - citing a page that doesn't exist would be a dangling reference.")
marker_id, new_body, changed = upsert_citation(page, source, file)
marker = f"[^{marker_id}]"
if dry_run:
state = "would update" if changed else "already up to date"
typer.echo(f"[dry-run] '{page_title}': {state}")
typer.echo(f"marker: {marker}")
typer.echo("No files written (--dry-run).")
return
if changed:
write_page(page.path, page.frontmatter, new_body)
typer.echo(f"marker: {marker}")
success(
f"{'Updated' if changed else 'Already up to date:'} '{page_title}' cites '{source}'"
+ (f" ({file})" if file else "")
+ f". Paste {marker} at the point in the prose the fact appears."
)
def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]:
"""Reconcile one page's Footnotes block against its actual `[^id]`
references: prune definitions nothing references any more, and re-render
the block in first-reference order. Never mints or recomputes an id from
a title - a reference with no definition is reported, not guessed at.
Returns (new_body, changed, pruned_ids, undefined_ref_ids).
"""
head, definitions = split_cite_block(page.body)
referenced_ids = [m.group(1) for m in CITE_REF_RE.finditer(head)]
referenced_set = set(referenced_ids)
if not definitions and not referenced_ids:
# No citation content at all - leave the page's whitespace exactly as
# it is. Without this, re-rendering an empty block still normalizes
# trailing newlines, which would make `cite sync --all` rewrite every
# page in the wiki instead of just the ones it actually has work to do.
return page.body, False, [], []
pruned = [cid for cid in definitions if cid not in referenced_set]
undefined = sorted({cid for cid in referenced_ids if cid not in definitions})
ordered: dict[str, tuple[str, Optional[str]]] = {}
seen: set[str] = set()
for cid in referenced_ids:
if cid in definitions and cid not in seen:
ordered[cid] = definitions[cid]
seen.add(cid)
new_body = render_page_body(head, ordered, cite_block_heading(page.body))
changed = new_body != page.body
return new_body, changed, pruned, undefined
@app.command("sync")
def cite_sync(
page_title: Optional[str] = typer.Option(None, "--page", help="Sync just this page"),
all_pages: bool = typer.Option(False, "--all", help="Sync every page under wiki/"),
dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change instead of writing"),
):
"""Prune orphan Footnotes definitions and re-render each page's block in
first-reference order. Reports any `[^id]` reference left with no
definition - that is an editorial gap (a citation whose `cite add` never
ran, or a hand-typed id), not something this command can fix."""
if bool(page_title) == bool(all_pages):
fail("Provide exactly one of --page or --all")
pages = load_kb_pages(config.KB_DIR)
targets = [_find_page(pages, page_title)] if page_title else sorted(pages.values(), key=lambda p: p.path)
touched: list[str] = []
undefined_report: dict[str, list[str]] = {}
failed: list[str] = []
for page in targets:
new_body, changed, pruned, undefined = sync_page(page)
title = page.path.stem
if undefined:
undefined_report[title] = undefined
if not changed:
continue
touched.append(title)
if dry_run:
continue
try:
write_page(page.path, page.frontmatter, new_body)
except OSError as exc:
failed.append(f"{title} ({exc})")
if failed:
fail(
f"Synced {len(touched) - len(failed)}/{len(touched)} page(s) before a write failed: "
f"{', '.join(failed)}. Safe to retry - each page's re-render is idempotent."
)
verb = "Would update" if dry_run else "Updated"
if touched:
typer.echo(f"{verb} {len(touched)} page(s):")
for title in touched:
typer.echo(f" - {title}")
else:
typer.echo("No pages needed a Footnotes block change.")
if undefined_report:
typer.echo("")
typer.echo("Undefined [^id] reference(s) - run `cite add` for these, or fix the typo:")
for title, ids in undefined_report.items():
typer.echo(f" - {title}: {', '.join(ids)}")
if dry_run:
typer.echo("No files written (--dry-run).")
return
if not touched and not undefined_report:
success("Every Footnotes block already matches its page's references.")