Files
chemenu/tools/chemenu/commands/new_page.py
T
torben 54d9540c08
CI / verify (push) Successful in 56s
Release / release (push) Successful in 36s
stack: Konfidenz-Mechanismus ersatzlos entfernt, Korpus migriert (schliesst #60, #86)
Files changed:
- .wikitool-kb.json
- AGENTS.md
- CHANGES.md
- INSTALL-MCP.md
- INSTALL.md
- README.md
- VERSION
- instructions/capture-session.md
- instructions/dev/issue-tracking.md
- instructions/german-terminology.md
- instructions/kb-profiles.md
- instructions/migrate-corpus.md
- instructions/migrations/5.0.0-confidence-removal.md
- instructions/private-instance.md
- instructions/setup-instance.md
- instructions/wiki-lint/SKILL.md
- instructions/wiki-manage/SKILL.md
- instructions/wiki-query/SKILL.md
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/concepts/architectures/Consolidation Tiers.md
- kb/concepts/architectures/Context Isolation.md
- kb/concepts/architectures/Cross-platform Agent Skills.md
- kb/concepts/architectures/Episodic Memory.md
- kb/concepts/architectures/Hybrid Search.md
- kb/concepts/architectures/Implementation Spectrum.md
- kb/concepts/architectures/Knowledge Graph.md
- kb/concepts/architectures/LLM Wiki Pattern.md
- kb/concepts/architectures/MCP-Leseserver.md
- kb/concepts/architectures/Memory Lifecycle.md
- kb/concepts/architectures/OKF Compatibility.md
- kb/concepts/architectures/Optional Instance Context File.md
- kb/concepts/architectures/Personalization Plane.md
- kb/concepts/architectures/Procedural Memory.md
- kb/concepts/architectures/RAG.md
- kb/concepts/architectures/Scale Ceiling.md
- kb/concepts/architectures/Semantic Memory.md
- kb/concepts/architectures/Three-Layer Architecture.md
- kb/concepts/architectures/Token Economics.md
- kb/concepts/architectures/Working Memory.md
- kb/concepts/decisions/Delete Rather Than Anonymize.md
- kb/concepts/decisions/Denylist over Allowlist.md
- kb/concepts/decisions/Diff-Reviewable Agent Edits.md
- kb/concepts/decisions/Dual Licensing by File Plan.md
- kb/concepts/decisions/Issue Label Scheme.md
- kb/concepts/decisions/KB Stack Versioning.md
- kb/concepts/decisions/Structural Enforcement over Documented Rule.md
- kb/concepts/patterns/Audit Trail.md
- kb/concepts/patterns/BM25.md
- kb/concepts/patterns/Command Round-Trip Integrity.md
- kb/concepts/patterns/Confidence Scoring.md
- kb/concepts/patterns/Contradiction Resolution.md
- kb/concepts/patterns/Entity Extraction.md
- kb/concepts/patterns/Filter on Ingest.md
- kb/concepts/patterns/Forgetting.md
- kb/concepts/patterns/Graph Traversal.md
- kb/concepts/patterns/Mesh Sync.md
- kb/concepts/patterns/Quality Scoring.md
- kb/concepts/patterns/Reciprocal Rank Fusion.md
- kb/concepts/patterns/Self-Healing.md
- kb/concepts/patterns/Shared vs Private.md
- kb/concepts/patterns/Typed Relationships.md
- kb/concepts/patterns/Vector Search.md
- kb/concepts/patterns/Work Coordination.md
- kb/concepts/problems/Ambient Environment Dependency.md
- kb/concepts/problems/Detect-Repair Asymmetry.md
- kb/concepts/problems/Green Suite Blind Spot.md
- kb/concepts/problems/Naming Convention Conflict.md
- kb/concepts/problems/Write-Once Frontmatter Fields.md
- kb/concepts/protocols/CPPC.md
- kb/concepts/protocols/Modbus.md
- kb/concepts/protocols/SSD TRIM.md
- kb/concepts/workflows/Anti-Cramming Heuristic.md
- kb/concepts/workflows/Bulk Operations.md
- kb/concepts/workflows/CI Integration.md
- kb/concepts/workflows/Checkpoint Audit.md
- kb/concepts/workflows/Claude Code Auto Mode.md
- kb/concepts/workflows/Content Quality Control.md
- kb/concepts/workflows/Crystallization.md
- kb/concepts/workflows/Event-Driven Automation.md
- kb/concepts/workflows/Hooks.md
- kb/concepts/workflows/Index Scaling.md
- kb/concepts/workflows/Iteration and Cost Limits.md
- kb/concepts/workflows/KB Migration.md
- kb/concepts/workflows/Knowledge Compounding.md
- kb/concepts/workflows/Lint Workflow.md
- kb/concepts/workflows/Mass-Update Gate.md
- kb/concepts/workflows/Multi-Agent Collaboration.md
- kb/concepts/workflows/Privacy and Governance.md
- kb/concepts/workflows/Publish-Remote Gate.md
- kb/concepts/workflows/Quality and Self-Correction.md
- kb/concepts/workflows/Semantic Lint Automation.md
- kb/concepts/workflows/Session Orientation.md
- kb/concepts/workflows/Split Merge Reclassify.md
- kb/concepts/workflows/Split Threshold.md
- kb/concepts/workflows/Stub Threshold.md
- kb/concepts/workflows/Supersession.md
- kb/concepts/workflows/User Management.md
- kb/concepts/workflows/Workflow Extraction.md
- kb/concepts/workflows/Workflow Orchestration.md
- kb/entities/people/Andrej Karpathy.md
- kb/entities/people/E3DC GmbH.md
- kb/entities/people/Rohit Gupta.md
- kb/entities/people/Vannevar Bush.md
- kb/entities/projects/BCDModule.md
- kb/entities/projects/Chemenu.md
- kb/entities/projects/andybalholm-edl.md
- kb/entities/projects/goresponsiveness.md
- kb/entities/projects/ha-core.md
- kb/entities/projects/hacs-e3dc.md
- kb/entities/projects/hacs-integration-blueprint.md
- kb/entities/projects/llm-wiki-skills.md
- kb/entities/projects/plugnburn-edl.md
- kb/entities/projects/wiki-skills-vanillaflava.md
- kb/entities/projects/wiki-skills.md
- kb/entities/systems/AGENTS.md.md
- kb/entities/systems/CLAUDE.md.md
- kb/entities/systems/E3DC.md
- kb/entities/systems/ENVIRONMENT.md.md
- kb/entities/systems/Memex.md
- kb/entities/systems/Tolkien Gateway.md
- kb/entities/technologies/Arch Linux.md
- kb/entities/technologies/Disk Encryption.md
- kb/entities/technologies/Docker.md
- kb/entities/technologies/GRUB.md
- kb/entities/technologies/Gitea Actions.md
- kb/entities/technologies/Gitea.md
- kb/entities/technologies/Go.md
- kb/entities/technologies/Home Assistant.md
- kb/entities/technologies/Kernel PM Governors.md
- kb/entities/technologies/LVM.md
- kb/entities/technologies/Linux Kernel.md
- kb/entities/technologies/MQTT.md
- kb/entities/technologies/OPC UA.md
- kb/entities/technologies/Python.md
- kb/entities/technologies/Rust.md
- kb/entities/technologies/Wine GE.md
- kb/entities/technologies/Wine-Staging.md
- kb/entities/technologies/acpi-cpufreq.md
- kb/entities/technologies/amd-pstate.md
- kb/entities/technologies/iii Engine.md
- kb/entities/tools/AUR.md
- kb/entities/tools/Act Runner.md
- kb/entities/tools/Agent Memory.md
- kb/entities/tools/Aura.md
- kb/entities/tools/Bottles.md
- kb/entities/tools/ChatGPT.md
- kb/entities/tools/Claude Code.md
- kb/entities/tools/Codex CLI.md
- kb/entities/tools/Dataview.md
- kb/entities/tools/GPG.md
- kb/entities/tools/GitHub Copilot.md
- kb/entities/tools/Gitea MCP Server.md
- kb/entities/tools/Lutris.md
- kb/entities/tools/Marp.md
- kb/entities/tools/Mistral Vibe.md
- kb/entities/tools/NotebookLM.md
- kb/entities/tools/Obsidian Web Clipper.md
- kb/entities/tools/Obsidian.md
- kb/entities/tools/OpenAI Codex.md
- kb/entities/tools/OpenCode.md
- kb/entities/tools/Pi.md
- kb/entities/tools/Proton.md
- kb/entities/tools/Steam.md
- kb/entities/tools/Wine.md
- kb/entities/tools/awesome-llm-wiki.md
- kb/entities/tools/farzaa gist.md
- kb/entities/tools/gdeploy.md
- kb/entities/tools/makepkg.md
- kb/entities/tools/pascalandy schema.md
- kb/entities/tools/qmd.md
- kb/entities/tools/wikitool.md
- kb/index.md
- kb/log.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/api.py
- tools/chemenu/cli.py
- tools/chemenu/commands/confidence_decay.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/index_build.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/search.py
- tools/chemenu/commands/touch.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/conventions.py
- tools/chemenu/corpus_diff.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/lint_core.py
- tools/chemenu/mcp/server.py
- tools/chemenu/page.py
- tools/chemenu/search/base.py
- tools/chemenu/search/filters.py
- tools/chemenu/search/ripgrep.py
- tools/chemenu/search/service.py
- tools/chemenu/search/types.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_api.py
- tools/chemenu/tests/test_confidence_decay.py
- tools/chemenu/tests/test_corpus_diff.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_frontmatter_io.py
- tools/chemenu/tests/test_index_build.py
- tools/chemenu/tests/test_kb_scan.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_page_ops.py
- tools/chemenu/tests/test_provenance.py
- tools/chemenu/tests/test_raw_cmd.py
- tools/chemenu/tests/test_search.py
- tools/chemenu/tests/test_touch.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/tests/test_xref.py
- tools/chemenu/version.py
- types/concept.md
- types/concept.schema.yaml
- types/entity.md
- types/entity.schema.yaml
- types/instruction.md
- types/type-spec.md
2026-09-10 19:51:48 +02:00

347 lines
15 KiB
Python

"""Scaffold new wiki pages from type-spec templates.
These commands produce structurally-correct frontmatter and a body
skeleton with TODO placeholders by loading templates from type-spec files.
The prose (Description, Summary, Key Takeaways, ...) is still written by the
LLM afterwards with its normal edit tool.
The split between type-spec templates and LLM-provided prose is intentional:
type definitions (naming, frontmatter shape, directory placement, templates) are
deterministic and stored in /types/; the content is judgment and provided by the LLM.
Frontmatter defaults, enum validity, and required-ness all come from the
type's `.schema.yaml` (via `TypeResolver`) - nothing here re-declares them.
Directory placement for subtype-driven types (currently just entities) also
comes from the type-spec, via its `layout:` frontmatter (see
`TypeResolver.get_layout`) - not a hand-maintained Python dict.
"""
from __future__ import annotations
from pathlib import Path
import datetime
from typing import Any, Dict, Optional
import re
import typer
from chemenu import config
from chemenu.commands._util import (
check_collision,
check_raw_files_exist,
fail,
parse_set_fields,
rel_path,
success,
)
from chemenu.frontmatter_io import write_page
from chemenu.type_resolver import resolver
def _default_summary(summary: str) -> str:
"""Scaffold-time placeholder for an unfilled --summary, so schema
validation's `summary` minLength requirement doesn't block page creation.
Matches the same "TODO: add summary" text index_build.py already falls
back to when a page has no frontmatter summary."""
return summary.strip() or "TODO: add summary"
def _resolve_type_and_get_template(type_path: str, source_dir: Path = None):
"""Resolve a type path, load the type-spec, and extract its template."""
type_spec = resolver.load_type_spec(type_path, source_dir)
template = resolver.extract_template(type_spec)
return type_spec, template
def _enum_help(type_path: str, field_name: str) -> str:
"""Build a help string from the schema's own enum, so any text listing a
field's valid values can never drift from what the schema accepts."""
return f"One of: {'|'.join(resolver.get_enum(type_path, field_name))}"
def _parse_date(field_name: str, text: str) -> datetime.date:
"""A `--set <date field>=<value>` as a real date, or a refusal naming it."""
try:
return datetime.date.fromisoformat(text)
except ValueError:
fail(f"--set {field_name}={text!r} must be YYYY-MM-DD.")
def _build_frontmatter(
type_path: str, schema: Optional[Dict[str, Any]], today: datetime.date, explicit: Dict[str, Any]
) -> Dict[str, Any]:
"""Build a page's frontmatter dict in schema-declaration order.
`explicit` supplies every CLI-derived value the caller already has;
fields not in `explicit` get a type-appropriate default (today's date for
date-formatted fields, the scaffold placeholder for `summary`, the
schema's own `default:` where declared, an empty list for arrays), or are
omitted entirely if optional with no sensible default (e.g.
`source_url`). This is what lets frontmatter shape - and scaffold-time
defaults like `provenance: general` - follow the schema instead of being
hand-declared per CLI command.
"""
frontmatter: Dict[str, Any] = {"type": type_path}
for field_name, field_schema in (schema or {}).get("properties", {}).items():
if field_name == "type":
continue
if field_name in explicit:
value = explicit[field_name]
# A `--set date=2026-08-23` arrives as a string; the corpus stores
# dates as `datetime.date`, and the schema already says which fields
# those are. Converting here keeps one representation on disk instead
# of leaving it to whoever wrote the CLI call.
if field_schema.get("format") == "date" and isinstance(value, str):
value = _parse_date(field_name, value)
frontmatter[field_name] = value
elif field_name == "summary":
frontmatter[field_name] = _default_summary("")
elif field_name == "author":
resolved_author = config.default_author()
if resolved_author is None:
fail(
"No author configured for this instance. Set `git config user.name`, "
"or export WIKI_AUTHOR to override it, then retry."
)
frontmatter[field_name] = resolved_author
elif field_schema.get("format") == "date":
frontmatter[field_name] = today
elif "default" in field_schema:
frontmatter[field_name] = field_schema["default"]
elif field_schema.get("type") == "array":
frontmatter[field_name] = []
# Carry through any caller-supplied field the schema doesn't declare,
# rather than silently dropping it: a typo'd `--set` must surface as a
# validation error (via `additionalProperties: false`) instead of being
# quietly ignored, and a schema that does allow extra properties should
# keep them.
for field_name, value in explicit.items():
if field_name not in frontmatter:
frontmatter[field_name] = value
return frontmatter
def _filter_bullets(value: Any) -> str:
"""Render a list frontmatter field as `- [[item]]` bullet lines."""
items = value or []
return "\n".join(f"- [[{item}]]" for item in items) if items else "- None identified"
def _filter_join(value: Any) -> str:
"""Comma-join a list frontmatter field."""
return ", ".join(value or [])
def _filter_capitalize(value: Any) -> str:
return str(value).capitalize()
def _filter_table_header(value: Any) -> str:
"""Render an array field as wikilinked markdown table column headers."""
return " | ".join(f"[[{item}]]" for item in (value or []))
def _filter_table_sep(value: Any) -> str:
"""Render the markdown table separator row for an array field, one
column per item."""
return "|".join("--------" for _ in (value or [])) or "--------"
def _filter_table_cells(value: Any) -> str:
"""Render a placeholder table body row, one cell per array item."""
return " | ".join("..." for _ in (value or []))
_TEMPLATE_FILTERS = {
"bullets": _filter_bullets,
"join": _filter_join,
"capitalize": _filter_capitalize,
"table_header": _filter_table_header,
"table_sep": _filter_table_sep,
"table_cells": _filter_table_cells,
}
def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str:
"""Apply variable substitutions to a template string.
Supports:
- `{field}` - plain substitution from `variables[field]`
- `{field|filter}` - apply a named filter (bullets, join, capitalize)
to `variables[field]`'s value, so templates can render list/enum
frontmatter fields directly instead of the caller precomputing a
separate display-only variable for each one
- `{field|literal text}` - literal fallback if `field` isn't in
`variables` at all and the suffix isn't a recognized filter name
"""
def replace_match(match: re.Match) -> str:
full_match = match.group(0)
var_name = match.group(1)
if '|' in var_name:
var_name, suffix = var_name.split('|', 1)
var_name = var_name.strip()
suffix = suffix.strip()
if var_name not in variables:
return suffix
value = variables[var_name]
filter_fn = _TEMPLATE_FILTERS.get(suffix)
return filter_fn(value) if filter_fn else str(value)
return str(variables.get(var_name, full_match))
pattern = r'\{([^}]+)\}'
return re.sub(pattern, replace_match, template)
def _page_subdir(subtype: Optional[str], type_path: str) -> Optional[str]:
"""Return the subtype-driven subdirectory under a type's `base_dir`, from
the type-spec's own `layout:` frontmatter. Thin wrapper over
`TypeResolver.subtype_dir` - the one place this computation lives, shared
with `move` and `lint`'s misplaced-page finding."""
return resolver.subtype_dir(type_path, subtype)
def _target_dir(type_path: str, frontmatter: Dict[str, Any]) -> Path:
"""Resolve where an instance of this type is written: `<root>/<base_dir>`,
plus a subtype subdirectory when the type declares a `layout:`.
Thin wrapper over `TypeResolver.compute_target_dir` - the one placement
rule, also used by `move` and `lint`'s misplaced-page finding -
converting its `ValueError` into the CLI's normal friendly-failure path.
`base_dir` is resolved against `config.KB_DIR` by default, so tests that
point KB_DIR at a temporary fixture wiki can never write into the real
`kb/`. A type-spec declaring `root: repo` resolves against `config.ROOT`
instead - for artifacts that are agent-directed material rather than
knowledge, and so live outside the knowledge layer."""
try:
return resolver.compute_target_dir(type_path, frontmatter)
except ValueError as exc:
fail(str(exc))
def _validate_or_fail(frontmatter: Dict[str, Any], type_path: str, source_dir: Path) -> None:
"""Validate frontmatter against its type-spec schema, converting a
ValueError into the CLI's normal friendly-failure path instead of an
uncaught traceback. The schema's own error message (which already names
the offending field and, for enums, lists the valid values) is shown
as-is - there is no separate hand-maintained validity check to keep in
sync with it."""
try:
resolver.validate_frontmatter(frontmatter, type_path, source_dir)
except ValueError as exc:
fail(str(exc))
def _load_type_or_fail(type_path: str, source_dir: Path):
"""Resolve a type path and load its type-spec + template, converting an
unresolvable/invalid `--type` into the CLI's normal friendly-failure path."""
try:
return _resolve_type_and_get_template(type_path, source_dir)
except ValueError as exc:
fail(str(exc))
def new_page_command(
type_name: str = typer.Argument(
...,
help="Type name, e.g. entity|concept|source|comparison (see `wikitool types list`)",
),
name: str = typer.Option(..., "--name", help="Page title (a type's title_prefix is added automatically)"),
type_path_override: str = typer.Option(
"", "--type", help="Override the type-spec path (defaults to the one named by TYPE_NAME)"
),
set_fields: Optional[list[str]] = typer.Option(
None,
"--set",
help="Frontmatter field, repeatable: --set entity_type=tool --set tags=a,b. Array values split on commas (escape a literal one as \\,); repeating --set for an array field appends instead of replacing",
),
):
"""Scaffold a new wiki page of any type.
The type's own type-spec drives everything: which frontmatter fields
exist and are required (its `.schema.yaml`), their scaffold defaults
(schema `default:`), where the page is written (`base_dir` + `layout`),
what prefixes its title (`title_prefix`), and its body skeleton (the
type-spec's template). Adding a new type therefore needs no change here.
"""
type_path = type_path_override or resolver.find_type_by_name(type_name)
if not type_path:
available = sorted(fm.get("name") for _, fm in resolver.list_type_specs())
fail(f"No type-spec named '{type_name}'. Available: {', '.join(available)}")
today = datetime.date.today()
try:
title_prefix = resolver.get_title_prefix(type_path)
root = resolver.get_root(type_path)
except ValueError as exc:
fail(str(exc))
page_title = f"{title_prefix}{name}"
if root == "kb":
# Title collisions matter because wikilinks resolve by title alone, so
# two pages sharing a stem are indistinguishable to every link in the
# wiki. Artifacts outside kb/ are not addressed by title and are not
# part of that namespace, so the check does not apply to them.
check_collision(page_title)
schema = resolver.get_schema(type_path)
explicit = parse_set_fields(set_fields, schema)
declared = (schema or {}).get("properties", {})
if "summary" in declared:
explicit.setdefault("summary", _default_summary(""))
if "name" in declared:
# The CLI already has this value; a type that stores its own name in
# frontmatter should not have to be told it twice.
explicit.setdefault("name", name)
if "description" in declared:
explicit.setdefault("description", "TODO: add description")
frontmatter = _build_frontmatter(type_path, schema, today, explicit)
# Capture fields (Gitea #67, e.g. `fidelity`/`authority` on a source page)
# are deliberately absent from `required:` - putting them there would make
# every existing instance's source pages stop validating, a
# boundary-crossing change (version-parts.md). The requirement is instead
# enforced here, in the tool, exactly like `source_type`'s no-default
# refusal (#66) reads from the schema alone: without a `default:` and
# omitted from `explicit`, a capture field simply never lands in
# `frontmatter`, so its absence has to be caught before it is silently
# written as a page with no capture record at all.
try:
capture_fields = resolver.get_capture_fields(type_path)
except ValueError as exc:
fail(str(exc))
missing_capture = [f for f in capture_fields if not frontmatter.get(f)]
if missing_capture:
fail(
f"Type {type_path} requires capture field(s) {', '.join(missing_capture)} - pass them "
"explicitly, e.g. --set fidelity=verbatim --set authority=reporting. "
"new does not guess them (see raw/CONTRACT.md)."
)
guessed_unknown = [f for f in capture_fields if frontmatter.get(f) == "unknown"]
if guessed_unknown:
fail(
f"Capture field(s) {', '.join(guessed_unknown)} cannot be set to 'unknown' here - that "
"value is backfill-only, written only by `wikitool touch` on a page predating this rule."
)
target_dir = _target_dir(type_path, frontmatter)
_type_spec, template = _load_type_or_fail(type_path, target_dir)
_validate_or_fail(frontmatter, type_path, target_dir)
if "raw_files" in frontmatter:
check_raw_files_exist(frontmatter["raw_files"])
path = target_dir / f"{page_title}.md"
body = _apply_template_variables(
template,
{
**frontmatter,
"name": name,
"today": today.isoformat(),
},
)
write_page(path, frontmatter, body)
success(f"Created {rel_path(path)}")