Speiler MAF I2 fra shared/ingest-spec.md alene: manifest → CSV-konnektor → materialisert OKF-bundle, byte-identisk med den delte golden-fasiten. - ingest.py: ManifestContract (pydantic, fail-fast, file-kilde, verdict-reservasjon §3, id-grammatikk, max_rows), CSV-konnektor (boundary-checked fail-closed), materialisering (§5-frontmatter eksakt rekkefølge, markdown-tabell m/ escaping, LF-only, SHA-256 manifest-stamp), index-generering (§6), replacement §3/§5. - okf.py: _parse_index_entry — tolererer frontmatterløs index (method-spec §3: index rendres via body = summary, ikke som typet concept-fil). Golden var spec-konform; D7-okf var strengere enn standarden. Scoped: non-index concept- filer krever fortsatt type (honesty-test). - examples/ingest-golden-file/: repo-lokal golden (byte-frossen kopi av I2s fasit). - Speiltester (I2s load-bearing-sett, alle detach-bevist røde): golden byte-fasit + mutasjonskontroller · provenance/navigability/verdict-reservasjon/re-ingest-safety · kontrakt fail-fast/max_rows/boundary/kollisjon · spec-integritet §11. - docs/2026-07-04-I3-brief.md: brief + de to operatør-avgjorte beslutningene. Suite 239 passed uten nøkkel/nettverk (189 + 50 nye) · ruff + mypy --strict rene. [skip-docs] README + docs/extending.md er bevisst utsatt til I7 per sesjonsplan (programmet batcher ingest-doc der, avgrenset til det D7 faktisk har — CSV nå, SQL/HTTP senere). Endringen er dokumentert i docs/2026-07-04-I3-brief.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MM6BWb1hWmJZuXFZ7rjxT
134 lines
5.4 KiB
Python
134 lines
5.4 KiB
Python
"""OKF bundle navigation and read-context rendering (method-spec §3 Step 1).
|
|
|
|
The read-context is built by NAVIGATING the bundle with progressive disclosure —
|
|
never by stuffing the whole bundle (or keyword-retrieved chunks) into the prompt.
|
|
Navigation starts at ``index.md`` and follows its intra-bundle cross-links; broken
|
|
or bundle-escaping links are tolerated (skipped, never raised — the OKF robustness
|
|
rule), while a missing ``index.md`` is an error (no entry point). ``type: verdict``
|
|
files are EXCLUDED from rendering: prior verdicts reach the hypothesis prompt ONLY
|
|
via the gated experience fold (see ``experience``), never via context rendering.
|
|
|
|
Pure stdlib by design — the context seam imports no agent toolkit (§11).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
_INDEX_FILENAME = "index.md"
|
|
_INDEX_TYPE = "index"
|
|
_VERDICT_TYPE = "verdict"
|
|
# (reference) the intra-bundle cross-link pattern, per §3 Step 1.
|
|
_CROSSLINK_PATTERN = re.compile(r"\]\(([^)]+\.md)\)")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConceptFile:
|
|
"""One parsed OKF concept file: frontmatter (``type`` required) plus body."""
|
|
|
|
path: Path
|
|
frontmatter: dict[str, str]
|
|
body: str
|
|
|
|
@property
|
|
def type(self) -> str:
|
|
return self.frontmatter["type"]
|
|
|
|
@property
|
|
def title(self) -> str:
|
|
return self.frontmatter.get("title", self.path.stem)
|
|
|
|
|
|
def _strip_matching_quotes(value: str) -> str:
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
return value[1:-1]
|
|
return value
|
|
|
|
|
|
def parse_concept_file(path: Path) -> ConceptFile:
|
|
"""Parse frontmatter (leading ``---`` block, line-oriented ``key: value``) + body.
|
|
|
|
The single required field is ``type``; unknown fields are preserved as strings.
|
|
"""
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
if not lines or lines[0].strip() != "---":
|
|
raise ValueError(f"{path.name}: missing frontmatter block")
|
|
frontmatter: dict[str, str] = {}
|
|
body_start = len(lines)
|
|
for i, line in enumerate(lines[1:], start=1):
|
|
if line.strip() == "---":
|
|
body_start = i + 1
|
|
break
|
|
key, sep, value = line.partition(":")
|
|
if sep:
|
|
frontmatter[key.strip()] = _strip_matching_quotes(value.strip())
|
|
if "type" not in frontmatter:
|
|
raise ValueError(f"{path.name}: frontmatter lacks the required field 'type'")
|
|
return ConceptFile(
|
|
path=path, frontmatter=frontmatter, body="\n".join(lines[body_start:]).strip()
|
|
)
|
|
|
|
|
|
def _parse_index_entry(index_path: Path) -> ConceptFile:
|
|
"""Parse the bundle entry point, tolerating a frontmatter-less index (§3 Step 1).
|
|
|
|
method-spec §3 Step 1 renders the index as "the index body (the summary)" and
|
|
treats every OTHER file as a "non-index concept file" (`## {type}: {title}`
|
|
section) — so the index is NOT a concept file, and it MAY omit frontmatter: a
|
|
generated index carrying only ``bundle_summary`` + cross-links (ingest spec §6,
|
|
frozen in the ingest golden) is valid. A frontmatter-ful index (the curated
|
|
convention, ``type: index``) still parses normally, its extra fields preserved.
|
|
When frontmatter is absent, the whole file is the body and ``type`` defaults to
|
|
``index`` so downstream ``.type`` reads never raise.
|
|
"""
|
|
text = index_path.read_text(encoding="utf-8")
|
|
lines = text.splitlines()
|
|
if lines and lines[0].strip() == "---":
|
|
return parse_concept_file(index_path)
|
|
return ConceptFile(path=index_path, frontmatter={"type": _INDEX_TYPE}, body=text.strip())
|
|
|
|
|
|
def navigate_bundle(bundle_dir: Path) -> list[ConceptFile]:
|
|
"""Navigate from ``index.md`` — deterministic order: index first, links first-seen.
|
|
|
|
Targets containing a path separator are out-of-bundle and skipped; resolution is
|
|
boundary-checked against the bundle directory (fail-closed); broken links are
|
|
skipped, never raised. Repeated links are de-duplicated. The index entry point
|
|
may omit frontmatter (``_parse_index_entry``); non-index concept files still
|
|
require ``type``.
|
|
"""
|
|
index_path = bundle_dir / _INDEX_FILENAME
|
|
if not index_path.is_file():
|
|
raise FileNotFoundError(
|
|
f"bundle has no entry point: missing {_INDEX_FILENAME} in {bundle_dir}"
|
|
)
|
|
index = _parse_index_entry(index_path)
|
|
bundle_root = bundle_dir.resolve()
|
|
concepts = [index]
|
|
seen = {_INDEX_FILENAME}
|
|
for target in _CROSSLINK_PATTERN.findall(index_path.read_text(encoding="utf-8")):
|
|
if "/" in target or "\\" in target or target in seen:
|
|
continue
|
|
seen.add(target)
|
|
resolved = (bundle_dir / target).resolve()
|
|
if not resolved.is_relative_to(bundle_root) or not resolved.is_file():
|
|
continue
|
|
concepts.append(parse_concept_file(resolved))
|
|
return concepts
|
|
|
|
|
|
def bundle_context(bundle_dir: Path) -> str:
|
|
"""Render the read-context: index body, then ``## {type}: {title}`` sections.
|
|
|
|
Empty sections are dropped. ``type: verdict`` files are excluded — the verdict
|
|
layer must never leak into the read-context (§3 Step 1, load-bearing §11).
|
|
"""
|
|
index, *concepts = navigate_bundle(bundle_dir)
|
|
sections = [index.body] if index.body else []
|
|
for concept in concepts:
|
|
if concept.type == _VERDICT_TYPE or not concept.body:
|
|
continue
|
|
sections.append(f"## {concept.type}: {concept.title}\n\n{concept.body}")
|
|
return "\n\n".join(sections)
|