Files
chemenu/tools/chemenu/commands/index_build.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

326 lines
12 KiB
Python

"""Deterministically regenerate the wiki's catalog from every page's frontmatter.
This replaces manual statistics counting and manual sorted-row insertion, which
was a repeated source of errors (miscounts, wrong alphabetical position) when
done by hand.
The catalog is **sharded**, not one file. `kb/index.md` is a map: statistics,
one row per collection and per area, and a link to the shard that lists those
pages. The tables themselves live in a generated `INDEX.md` inside each
collection, and an area that grows past `SHARD_THRESHOLD` rows gets its own.
Why: a single flat catalog has to be read in full to answer any question about
it, so its cost grows with the wiki while the answer being looked for does not.
At a few hundred pages that is tens of thousands of tokens spent to learn three
filenames. The wiki's own `Index Scaling` page sets the threshold used here.
The map stays small enough to browse; `wikitool search` answers everything else.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
import typer
from chemenu import config
from chemenu.commands._util import rel_path, success
from chemenu.kb_collections import iter_kb_collections
from chemenu.page import Page
from chemenu.kb_scan import GENERATED_INDEX, load_kb_pages
from chemenu.type_resolver import resolver
app = typer.Typer(help="Manage the generated wiki catalog (kb/index.md + per-collection INDEX.md).")
TABLE_HEADER = "| Page | Type | Summary | Last Modified |"
TABLE_SEP = "|------|------|---------|----------------|"
SUMMARY_HEADINGS = ("Description", "Definition", "Summary")
# Rows per area before it is split into its own shard. From the wiki's own
# `Index Scaling` page ("split table sections at >50 entries"), kept as a plain
# number so growth is handled by arithmetic rather than by a judgment call.
SHARD_THRESHOLD = 50
# Display title for pages sitting directly in a collection root rather than in
# an area subdirectory.
UNGROUPED_TITLE = "All"
DO_NOT_EDIT = "<!-- Generated by `wikitool index rebuild`. Do not hand-edit. -->"
def _summary(page: Page) -> str:
fm_summary = page.frontmatter.get("summary")
if fm_summary:
return str(fm_summary).strip()
for heading in SUMMARY_HEADINGS:
match = re.search(rf"^## {heading}\s*\n+(.+)", page.body, re.MULTILINE)
if match:
line = match.group(1).strip().splitlines()[0].strip()
if line and not line.upper().startswith("TODO"):
return line[:117] + "..." if len(line) > 120 else line
return "TODO: add summary"
def _last_modified(page: Page) -> str:
for key in ("modified", "date", "created"):
value = page.frontmatter.get(key)
if value:
return str(value)
return date.fromtimestamp(page.path.stat().st_mtime).isoformat()
def _type_label(page: Page) -> str:
return page.subtype or page.kind or "unknown"
def _table(pages: list[Page]) -> list[str]:
lines = [TABLE_HEADER, TABLE_SEP]
for page in sorted(pages, key=lambda p: p.title.lower()):
lines.append(
f"| [[{page.title}]] | {_type_label(page)} | {_summary(page)} | {_last_modified(page)} |"
)
return lines
def _anchor(title: str) -> str:
"""GitHub-style heading anchor, so the map can deep-link into a shard."""
slug = re.sub(r"[^a-z0-9\s-]", "", title.lower())
return re.sub(r"\s+", "-", slug.strip())
@dataclass
class Area:
"""One grouping inside a collection: a subdirectory, or the collection root
for pages that sit directly in it."""
name: str
title: str
pages: list[Page] = field(default_factory=list)
own_shard: bool = False
@property
def count(self) -> int:
return len(self.pages)
@dataclass
class Collection:
name: str
areas: list[Area] = field(default_factory=list)
@property
def count(self) -> int:
return sum(area.count for area in self.areas)
def _area_titles() -> dict[str, str]:
"""Display titles for entity areas, taken from the entity type-spec's own
`layout:` rather than a hardcoded map - so a new subtype names its own
section by adding a type-spec, with no code change."""
layout = resolver.get_layout(resolver.find_type_by_name("entity")) or {}
return {spec.get("dir", key): spec.get("title", key.title()) for key, spec in layout.items()}
def group_pages(kb_dir: Path, pages: dict[str, Page]) -> list[Collection]:
"""Group pages by their physical location: collection directory, then area
subdirectory.
Location rather than `kind` because a shard lives in the directory it
describes, and the two agree by construction: a type-spec's `base_dir:` is
what put the page there.
"""
titles = _area_titles()
grouped: dict[str, dict[str, Area]] = {}
# Seed from the collections that exist on disk, not only from the ones that
# happen to hold pages: an empty collection is a real (if unfilled) part of
# the wiki, and dropping it from the map would hide it from every reader.
for collection_dir in iter_kb_collections(kb_dir):
grouped.setdefault(collection_dir.name, {})
for page in sorted(pages.values(), key=lambda p: p.title.lower()):
try:
parts = page.path.relative_to(kb_dir).parts
except ValueError: # pragma: no cover - pages always live under kb_dir
continue
if len(parts) < 2:
collection_name, area_name = "(kb root)", ""
else:
collection_name = parts[0]
area_name = parts[1] if len(parts) > 2 else ""
areas = grouped.setdefault(collection_name, {})
area = areas.get(area_name)
if area is None:
title = titles.get(area_name, area_name.title()) if area_name else UNGROUPED_TITLE
area = Area(name=area_name, title=title)
areas[area_name] = area
area.pages.append(page)
collections = []
for name in sorted(grouped):
ordered = sorted(grouped[name].values(), key=lambda a: (a.name == "", a.title.lower()))
for area in ordered:
area.own_shard = bool(area.name) and area.count > SHARD_THRESHOLD
collections.append(Collection(name=name, areas=ordered))
return collections
def build_area_shard(area: Area) -> str:
lines = [DO_NOT_EDIT, "", f"# {area.title}", "", f"{area.count} page(s).", ""]
lines.extend(_table(area.pages))
lines.append("")
return "\n".join(lines) + "\n"
def build_collection_shard(collection: Collection) -> str:
lines = [DO_NOT_EDIT, "", f"# kb/{collection.name}/ - Index", ""]
lines.append(f"{collection.count} page(s). Regenerated by `wikitool index rebuild`.")
lines.append("")
for area in collection.areas:
lines.append(f"## {area.title}")
lines.append("")
if area.own_shard:
lines.append(
f"{area.count} page(s) - listed in "
f"[{area.name}/{GENERATED_INDEX}]({area.name}/{GENERATED_INDEX})."
)
else:
lines.extend(_table(area.pages))
lines.append("")
return "\n".join(lines) + "\n"
def build_index_map(collections: list[Collection]) -> str:
"""The root catalog: counts and pointers, no page rows.
Deliberately carries no summaries. A summary is what makes a hit worth
opening, and that judgment belongs where the hit is produced - `search` and
the shards - not in a file every reader pays for in full.
"""
totals = {c.name: c.count for c in collections}
total = sum(totals.values())
lines = [
DO_NOT_EDIT,
"",
"# Wiki Index",
"",
"A map of the wiki, not a catalog of it: counts and pointers only.",
"",
"To *find* a page, search instead of reading this file:",
"",
'- `tools/wikitool search "<text>"` - ranked text search, with summaries',
"- `tools/wikitool search --field entity_type=system --field 'confidence<0.6'`"
" - structured query over frontmatter",
"",
"The page tables live in a generated `INDEX.md` inside each collection, linked below.",
"",
"## Statistics",
"",
f"- **Total Pages:** {total}",
]
for name in sorted(totals):
lines.append(f"- **{name.title()}:** {totals[name]}")
lines.append(f"- **Last Updated:** {date.today().isoformat()}")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Collections")
lines.append("")
lines.append("| Collection | Pages | Index |")
lines.append("|------------|------:|-------|")
for collection in collections:
target = f"{collection.name}/{GENERATED_INDEX}"
lines.append(f"| `{collection.name}/` | {collection.count} | [{target}]({target}) |")
lines.append("")
for collection in collections:
listed = [area for area in collection.areas if area.name]
if not listed:
continue
lines.append(f"### {collection.name}/")
lines.append("")
lines.append("| Area | Pages | Index |")
lines.append("|------|------:|-------|")
for area in listed:
if area.own_shard:
target = f"{collection.name}/{area.name}/{GENERATED_INDEX}"
else:
target = f"{collection.name}/{GENERATED_INDEX}#{_anchor(area.title)}"
lines.append(f"| {area.title} | {area.count} | [{target}]({target}) |")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Notes")
lines.append("")
lines.append(
"This map and every `INDEX.md` under `kb/` are generated by "
"`wikitool index rebuild`. Do not hand-edit them."
)
lines.append("")
lines.append("To add a new page, run `wikitool new ...`, then `wikitool index rebuild`.")
return "\n".join(lines) + "\n"
def plan_index(kb_dir: Path) -> dict[Path, str]:
"""Every file the catalog consists of, as {path: content}.
Returning the whole plan instead of writing as it goes is what makes the
stale-shard sweep possible: anything named `INDEX.md` that is not in the
plan is a leftover from a collection or area that no longer exists.
"""
collections = group_pages(kb_dir, load_kb_pages(kb_dir))
plan: dict[Path, str] = {kb_dir / "index.md": build_index_map(collections)}
for collection in collections:
collection_dir = kb_dir / collection.name
if not collection_dir.is_dir():
continue
plan[collection_dir / GENERATED_INDEX] = build_collection_shard(collection)
for area in collection.areas:
if area.own_shard:
plan[collection_dir / area.name / GENERATED_INDEX] = build_area_shard(area)
return plan
def stale_shards(kb_dir: Path, plan: dict[Path, str]) -> list[Path]:
"""Generated shards on disk that the current plan does not produce."""
return sorted(p for p in kb_dir.rglob(GENERATED_INDEX) if p not in plan)
def build_index(kb_dir: Path) -> str:
"""The root map. Kept as a named function because callers (and tests) ask
for "the index" meaning the entry point, not the whole plan."""
return build_index_map(group_pages(kb_dir, load_kb_pages(kb_dir)))
@app.command("rebuild")
def index_rebuild(
dry_run: bool = typer.Option(
False, "--dry-run", help="Print what would be written instead of writing it"
),
):
plan = plan_index(config.KB_DIR)
stale = stale_shards(config.KB_DIR, plan)
if dry_run:
for path in sorted(plan):
typer.echo(f"--- {rel_path(path)}")
# nl=False: the content already ends in a newline.
typer.echo(plan[path], nl=False)
for path in stale:
typer.echo(f"--- would remove stale shard: {rel_path(path)}")
return
for path, content in plan.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
for path in stale:
path.unlink()
shards = len(plan) - 1
removed = f", removed {len(stale)} stale" if stale else ""
success(f"Rebuilt {rel_path(config.INDEX_FILE)} and {shards} shard(s){removed}")