feat(inbox): root frontmatter emission at Door B with a caller-owned bundle_id
This commit is contained in:
parent
cd2c7517b0
commit
224121f762
2 changed files with 214 additions and 2 deletions
|
|
@ -21,13 +21,14 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import unicodedata
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import IngestError, MaterializationError, SourceError
|
||||
from .extract import extract_text
|
||||
from .materialize import (
|
||||
_render_root_frontmatter,
|
||||
check_filename_length,
|
||||
link_in_index,
|
||||
parse_frontmatter,
|
||||
|
|
@ -244,6 +245,7 @@ def process_inbox(
|
|||
okf_type: str,
|
||||
gate: Gate,
|
||||
profile: BundleProfile = DEFAULT,
|
||||
root_frontmatter_values: Mapping[str, str] | None = None,
|
||||
) -> InboxResult:
|
||||
"""Convert every file dropped in `inbox_dir` into an OKF concept.
|
||||
|
||||
|
|
@ -261,6 +263,12 @@ def process_inbox(
|
|||
a reserved `okf_type`, and a missing inbox directory.
|
||||
"""
|
||||
validate_ingested_at(ingested_at)
|
||||
# Rendered HERE, before anything is read or written, and the result carried
|
||||
# to the index write at the bottom. `_render_root_frontmatter` refuses a key
|
||||
# the policy does not name, and a refusal must leave no bundle behind --
|
||||
# `materialize.py` states the same rule for Door A, and a door that half-built
|
||||
# a bundle before refusing would be worse than one that never started.
|
||||
root_head = _render_root_frontmatter(root_frontmatter_values or {}, profile=profile)
|
||||
run_rejection = profile.types.rejection(okf_type)
|
||||
if run_rejection is not None:
|
||||
raise MaterializationError(f"okf_type {run_rejection.reason}", code=run_rejection.code)
|
||||
|
|
@ -398,7 +406,9 @@ def process_inbox(
|
|||
if persisted:
|
||||
index_path = bundle / profile.index.name
|
||||
if not index_path.is_file():
|
||||
write_bytes(bundle, profile.index.name, "")
|
||||
write_bytes(bundle, profile.index.name, root_head)
|
||||
else:
|
||||
_refresh_root_frontmatter(index_path, root_head)
|
||||
if profile.index.facets is None:
|
||||
for entry in persisted:
|
||||
link_in_index(
|
||||
|
|
@ -436,6 +446,36 @@ def _validate_facets(structure: DocumentStructure, profile: BundleProfile) -> No
|
|||
raise MaterializationError(str(exc), code="index_facet_invalid") from exc
|
||||
|
||||
|
||||
def _refresh_root_frontmatter(index_path: Path, head: str) -> None:
|
||||
"""Put `head` at the top of an existing index, replacing any block already there.
|
||||
|
||||
Idempotent by construction: the leading block is removed and the current one
|
||||
written, so applying this twice produces the same bytes. That is what makes a
|
||||
second round with the same `bundle_id` a no-op and a rebuild-from-scratch
|
||||
byte-identical to an incremental update -- neither is diffed against the
|
||||
other, both are the same function of the same inputs.
|
||||
|
||||
The block is recognised the way `parse_frontmatter` recognises one: an
|
||||
opening `---` on the very first line, up to the next `---`. Nothing else is
|
||||
read, because a value inside the block is the caller's and this door only
|
||||
ever restates it.
|
||||
"""
|
||||
body = index_path.read_bytes().decode("utf-8")
|
||||
lines = body.splitlines(keepends=True)
|
||||
if lines and lines[0].strip() == "---":
|
||||
for position, line in enumerate(lines[1:], start=1):
|
||||
if line.strip() == "---":
|
||||
rest = lines[position + 1 :]
|
||||
# The blank line the block is separated by belongs to the block.
|
||||
if rest and rest[0].strip() == "":
|
||||
rest = rest[1:]
|
||||
body = "".join(rest)
|
||||
break
|
||||
if body == head:
|
||||
return
|
||||
index_path.write_bytes((head + body).encode("utf-8"))
|
||||
|
||||
|
||||
def _reproject_index(bundle: Path, profile: BundleProfile) -> None:
|
||||
"""Rewrite the managed region of the index from the WHOLE bundle.
|
||||
|
||||
|
|
|
|||
172
tests/test_segmented_identity.py
Normal file
172
tests/test_segmented_identity.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""Bundle identity at Door B: the caller names the bundle, the door mirrors it.
|
||||
|
||||
Order `…2527032751` settled the form: **(c) a root-frontmatter bundle
|
||||
identifier that consumers join on**. Two facts made this new wiring rather than
|
||||
a value change. First, `root_frontmatter` had ZERO references in `inbox.py`:
|
||||
Door B seeded its index with an empty file and no frontmatter block at all,
|
||||
where Door A has carried the machinery since D5. Second, identity had to be
|
||||
CALLER-owned — a bundle is a collection the caller delimits, so hashing its
|
||||
contents would hand it a new identity every time a document was added.
|
||||
|
||||
That is also why there are two identity mechanisms in this library, stated
|
||||
rather than hidden: WITHIN a bundle, Door C keys on a content hash; ACROSS
|
||||
bundles, identity is this caller-assigned `bundle_id`. A consumer meets both.
|
||||
|
||||
S4b is resolved as ONE branch, not two: the root index is the SOURCE (the
|
||||
caller supplies the value exactly once, D5 intact) and each concept MIRRORS
|
||||
it. Two bundles built from identical inputs therefore hold concepts whose
|
||||
paths COLLIDE by construction and whose identity values are disjoint — the
|
||||
collision is the expected behaviour under form (c), not a defect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_okf.errors import MaterializationError
|
||||
from llm_ingestion_okf.inbox import GateDecision, process_inbox
|
||||
from llm_ingestion_okf.profiles import DEFAULT, SEGMENTED_V1, STRUCTURED_V1
|
||||
|
||||
INGESTED_AT = "2026-07-25T12:00:00Z"
|
||||
|
||||
DOCUMENT = "# Brannkonsept\n\nSeksjonering etter N500.\n"
|
||||
|
||||
|
||||
def gate(text: str) -> GateDecision:
|
||||
return GateDecision(sanitized_text=text, disposition="warn")
|
||||
|
||||
|
||||
def drop(inbox: Path, name: str, text: str = DOCUMENT) -> None:
|
||||
inbox.mkdir(parents=True, exist_ok=True)
|
||||
(inbox / name).write_text(text, encoding="utf-8", newline="")
|
||||
|
||||
|
||||
def run(tmp: Path, *, profile=SEGMENTED_V1, values=None, round_name: str = "round"):
|
||||
return process_inbox(
|
||||
tmp / round_name,
|
||||
tmp / "bundle",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
profile=profile,
|
||||
root_frontmatter_values=values,
|
||||
)
|
||||
|
||||
|
||||
def tree(bundle: Path) -> dict[str, bytes]:
|
||||
"""Every file under the bundle, keyed by bundle-relative path."""
|
||||
if not bundle.is_dir():
|
||||
return {}
|
||||
return {
|
||||
str(path.relative_to(bundle)): path.read_bytes()
|
||||
for path in sorted(bundle.rglob("*"))
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
|
||||
# --- additivity: the four shipped profiles do not move --------------------
|
||||
|
||||
|
||||
def test_default_without_values_writes_what_it_wrote_before(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "a", "n500-vegbygging.md")
|
||||
process_inbox(
|
||||
tmp_path / "a",
|
||||
tmp_path / "old",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
profile=DEFAULT,
|
||||
)
|
||||
drop(tmp_path / "b", "n500-vegbygging.md")
|
||||
process_inbox(
|
||||
tmp_path / "b",
|
||||
tmp_path / "new",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
profile=DEFAULT,
|
||||
root_frontmatter_values=None,
|
||||
)
|
||||
assert tree(tmp_path / "new") == tree(tmp_path / "old")
|
||||
assert not (tmp_path / "new" / "index.md").read_text(encoding="utf-8").startswith("---")
|
||||
|
||||
|
||||
def test_structured_v1_without_values_still_writes_no_frontmatter(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "round", "n500-vegbygging.md")
|
||||
run(tmp_path, profile=STRUCTURED_V1)
|
||||
assert not (tmp_path / "bundle" / "index.md").read_text(encoding="utf-8").startswith("---")
|
||||
|
||||
|
||||
# --- emission -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_root_index_opens_with_the_declared_bundle_id(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "round", "n500-vegbygging.md")
|
||||
run(tmp_path, values={"bundle_id": "b-1"})
|
||||
body = (tmp_path / "bundle" / "index.md").read_text(encoding="utf-8")
|
||||
assert body.startswith("---\nbundle_id: b-1\n---\n\n")
|
||||
|
||||
|
||||
def test_the_value_is_written_verbatim_and_unquoted(tmp_path: Path) -> None:
|
||||
# Read as RAW BYTES: a parsed assertion returns the same string whether or
|
||||
# not the emitter added quotes, so it would mask exactly the defect a
|
||||
# consumer's shape gate trips on.
|
||||
drop(tmp_path / "round", "n500-vegbygging.md")
|
||||
run(tmp_path, values={"bundle_id": "svv-n500-2026"})
|
||||
assert b"bundle_id: svv-n500-2026\n" in (tmp_path / "bundle" / "index.md").read_bytes()
|
||||
|
||||
|
||||
def test_a_second_round_does_not_double_the_block(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "one", "n500-vegbygging.md")
|
||||
run(tmp_path, values={"bundle_id": "b-1"}, round_name="one")
|
||||
drop(tmp_path / "two", "v720-tunnel.md")
|
||||
run(tmp_path, values={"bundle_id": "b-1"}, round_name="two")
|
||||
body = (tmp_path / "bundle" / "index.md").read_text(encoding="utf-8")
|
||||
assert body.startswith("---\nbundle_id: b-1\n---\n\n")
|
||||
assert body.count("bundle_id:") == 1
|
||||
|
||||
|
||||
def test_the_index_still_carries_its_entries_below_the_block(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "round", "n500-vegbygging.md")
|
||||
run(tmp_path, values={"bundle_id": "b-1"})
|
||||
body = (tmp_path / "bundle" / "index.md").read_text(encoding="utf-8")
|
||||
assert "](inbox-n500-vegbygging.md)" in body
|
||||
assert body.index("bundle_id") < body.index("](inbox-n500-vegbygging.md)")
|
||||
|
||||
|
||||
# --- fail-fast, before any disk mutation ----------------------------------
|
||||
|
||||
|
||||
def test_a_key_the_policy_does_not_name_is_refused_before_any_write(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "round", "n500-vegbygging.md")
|
||||
before = tree(tmp_path / "bundle")
|
||||
with pytest.raises(MaterializationError) as excinfo:
|
||||
run(tmp_path, values={"okf_version": "0.2"})
|
||||
assert excinfo.value.code == "index_root_frontmatter_unexpected"
|
||||
# The whole point of hoisting the call above the write: a refused run must
|
||||
# leave nothing behind, not a half-built bundle whose index is missing.
|
||||
assert tree(tmp_path / "bundle") == before
|
||||
assert before == {}
|
||||
|
||||
|
||||
def test_a_refusal_leaves_an_existing_bundle_untouched(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "one", "n500-vegbygging.md")
|
||||
run(tmp_path, values={"bundle_id": "b-1"}, round_name="one")
|
||||
before = tree(tmp_path / "bundle")
|
||||
assert before != {}
|
||||
|
||||
drop(tmp_path / "two", "v720-tunnel.md")
|
||||
with pytest.raises(MaterializationError) as excinfo:
|
||||
run(tmp_path, values={"okf_version": "0.2"}, round_name="two")
|
||||
assert excinfo.value.code == "index_root_frontmatter_unexpected"
|
||||
assert tree(tmp_path / "bundle") == before
|
||||
|
||||
|
||||
def test_values_are_refused_against_a_profile_naming_no_root_keys(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "round", "n500-vegbygging.md")
|
||||
with pytest.raises(MaterializationError) as excinfo:
|
||||
run(tmp_path, profile=DEFAULT, values={"bundle_id": "b-1"})
|
||||
assert excinfo.value.code == "index_root_frontmatter_unexpected"
|
||||
assert tree(tmp_path / "bundle") == {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue