feat(frontmatter): --frontmatter KEY=VALUE stamps a key on every concept of a run

K3-19 b. `okf build --frontmatter KEY=VALUE`, repeatable, split on the FIRST
'=' (`cli.frontmatter_from_flags`) because a publisher's address carries '='
itself. The value is written verbatim on ONE line: the block form of a
`sources` list is invisible to this package's line-oriented readers, so the
flow form is the only one that survives them. Also reachable as
`build(frontmatter=...)`, `measure(concept_frontmatter_values=...)`,
`process_inbox(concept_frontmatter_values=...)` and
`render_inbox_concept(concept_frontmatter_values=...)`, keyword-only with
defaults, so every existing call site is source-compatible.

Precedence: a stated value beats what the document declares, which beats the
file name. A run may ADD any key and REPLACE only `sources` and
`description` -- the two with a derived layer below the flag. Every other key
the door writes is refused by `inbox.validate_concept_frontmatter` before a
proposal is written (`run_frontmatter_invalid`): measured from the bytes, owned
by another argument, the ownership stamp a later run reads back (including
Door A's `ingest_manifest`, which would make that door claim a Door B file),
or a derived facet whose `derived` marker would go on naming a replaced value.
A value that would not read back as stated -- empty, multi-line, or padded,
since `parse_frontmatter` strips -- is refused too.

SPEC SS 4.1 "Extensions" lets a producer add any key and SS 11 forbids a
consumer to reject one. Without the flag nothing moves: a test holds the
flagged tree to the plain one minus exactly the stated line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-11 03:14:46 +02:00
commit 912b85026b
5 changed files with 171 additions and 2 deletions

View file

@ -70,14 +70,14 @@ from __future__ import annotations
import argparse
import sys
import tempfile
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from functools import partial
from pathlib import Path
from .corpus import LOG_NAME, CorpusReport, load_plans, measure
from .errors import IngestError
from .extract import declared_identity
from .inbox import walk_inbox
from .inbox import validate_concept_frontmatter, walk_inbox
from .materialize import reduce_to_id_grammar
from .profiles import SEGMENTED_OKF_V0_2, STRUCTURED_V1, BundleProfile
from .propose import ProposerError, heading_reserve_applies
@ -251,6 +251,32 @@ DEFAULT_PDF_OUTLINE = False
DEFAULT_STAMP = "1970-01-01T00:00:00Z"
def frontmatter_from_flags(pairs: Sequence[str]) -> dict[str, str]:
"""`--frontmatter KEY=VALUE`, split on the FIRST `=` and only there.
The first `=` because the value is the one that needs the rest: a
publisher's address carries `?languageCode=nb`, and a split on every `=`
would cut the `sources` flow mapping in half. Key and value are validated
by the door (`validate_concept_frontmatter`); this only refuses what is not
a pair, and a key named twice -- which of two values was meant is a guess.
"""
values: dict[str, str] = {}
for pair in pairs:
key, sep, value = pair.partition("=")
if not sep:
raise IngestError(
f"--frontmatter {pair!r} has no '='; the form is KEY=VALUE",
code="run_frontmatter_invalid",
)
if key in values:
raise IngestError(
f"--frontmatter names {key!r} twice; refusing to pick one of the two values",
code="run_frontmatter_invalid",
)
values[key] = value
return values
def _document_prefixes(inbox: Path, walked: Sequence[Path]) -> dict[Path, str]:
"""Each document's directory: the name it declares, else its file name.
@ -405,6 +431,7 @@ def build(
pdf_headings_reserve: bool = DEFAULT_PDF_HEADINGS_RESERVE,
ocr: bool = DEFAULT_OCR,
pdf_outline: bool = DEFAULT_PDF_OUTLINE,
frontmatter: Mapping[str, str] | None = None,
) -> CorpusReport:
"""Folder in, bundle out. The whole command, minus argument parsing.
@ -422,6 +449,11 @@ def build(
"""
if proposed_at is None:
proposed_at = ingested_at
# Refused HERE, before one proposal is written: a stated key that is wrong
# is wrong for every document, and the proposal pass is the long half.
concept_values = validate_concept_frontmatter(
frontmatter or {}, profile=SEGMENTED_OKF_V0_2 if segments else STRUCTURED_V1
)
# Bound to THIS run's outline minimum, once, so the proposer and the door
# cannot be handed two different thresholds for the same question.
reserve = (
@ -438,6 +470,7 @@ def build(
pdf_headings=pdf_headings,
heading_reserve=reserve,
ocr=ocr,
concept_frontmatter_values=concept_values,
)
_write_log(bundle, report, profile=STRUCTURED_V1)
return report
@ -495,6 +528,7 @@ def build(
pdf_headings=pdf_headings,
heading_reserve=reserve,
ocr=ocr,
concept_frontmatter_values=concept_values,
)
_write_log(bundle, report, profile=SEGMENTED_OKF_V0_2)
return report
@ -584,6 +618,20 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
build_parser.add_argument(
"--okf-type", default="reference", help="okf_type for every concept and proposal"
)
build_parser.add_argument(
"--frontmatter",
action="append",
default=None,
metavar="KEY=VALUE",
help=(
"stamp KEY: VALUE on every concept of this run; repeatable. Split on "
"the FIRST '=' and written verbatim on ONE line, so a flow mapping such "
"as 'sources=[{ resource: <url>, title: <t> }]' survives whole. Adds "
"any key, and REPLACES only sources and description -- the two with "
"a layer below them (the document's own identity, else the file "
"name). Every other key the door writes itself is refused"
),
)
build_parser.add_argument(
"--plans-dir",
type=Path,
@ -934,6 +982,7 @@ def main(argv: list[str] | None = None) -> int:
pdf_headings_reserve=args.pdf_headings == "font-reserve",
ocr=args.ocr,
pdf_outline=args.pdf_outline,
frontmatter=frontmatter_from_flags(args.frontmatter or ()),
)
except (IngestError, OSError, ValueError) as exc:
print(f"{CLI_ID}: FAILED - {exc}", file=sys.stderr)