llm-ingestion-okf/tests/test_render_hook.py
Kjell Tore Guttormsen 55a09d6c8e feat(profiles): a profile may name a per-suffix renderer
Arm E, capability only. A profile MAY name a renderer per suffix; no
domain-aware renderer is written here, that stays a Non-Goal, and `_RENDERERS`
is empty on purpose so the emptiness reads as a decision rather than an
omission.

THE LAYERING IS THE DESIGN, not an implementation detail. `extract.py` is the
extraction registry and must not import the contract layer, or the dependency
runs backwards and the registry stops standing on its own. So `extract_text`
gains a keyword-only `renderer: Callable[[str], str] | None`, knowing nothing
about profiles, and `inbox.py` -- which already holds the profile at that call
site -- resolves a NAME to a function. A test asserts extract.py still contains
no reference to the profile layer, because that constraint is the whole reason
the parameter is shaped this way.

The renderer runs AFTER extraction, never instead of it, so it never has to
re-implement a reader and the two cannot drift. The default is identity, which
is what keeps the five byte-pinned goldens byte-pinned -- asserted per suffix
rather than once.

An unknown renderer NAME is refused rather than falling back to identity: a
silent fallback would produce a bundle that looks rendered and is not, which is
the failure mode this arm exists to make visible. That needed a registered code
(`unknown_renderer`) and its test -- slightly beyond the step's named files,
but the capability cannot ship without defining what an unknown name does.

`tests/test_profile.py`'s exact-field-set assertion went red, as the plan's risk
table predicted. Updated deliberately with the reason recorded: that assertion
exists so a field cannot arrive without someone deciding it should, and its red
run is the mechanism working.

Suite 917 -> 926. All five goldens byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:19:18 +02:00

126 lines
4.7 KiB
Python

"""A profile may name a per-suffix renderer. Arm E, capability only.
No domain-aware renderer is written here -- that stays a Non-Goal, and naming
it as unassigned is part of the point. What this pins is the seam: a profile
CAN name one, the default is identity, and the layering direction does not move
to make it possible.
The layering matters more than it looks. `extract.py` is the extraction
registry and `profiles.py` is the contract layer; if extraction had to import
profiles to find a renderer, the dependency would run backwards and the
registry would stop being usable on its own. Instead `extract_text` takes a
callable it knows nothing about, and `inbox.py` -- which already holds the
profile -- is what resolves a name to a function.
"""
from __future__ import annotations
import warnings
from pathlib import Path
import pytest
from llm_ingestion_okf.extract import extract_text
from llm_ingestion_okf.profiles import DEFAULT, SEGMENTED_V1, BundleProfile
FIXTURES = Path(__file__).parent / "fixtures"
def _shout(text: str) -> str:
"""A synthetic renderer: recognisable in output, dependent on nothing.
Modelled on the `_SYNTHETIC` instrument used elsewhere -- a real renderer
would prove that a real renderer works; this proves the HOOK is called, and
called with the extracted text.
"""
return f"<<{text}>>"
def test_a_profile_may_carry_renderers_and_absence_is_none() -> None:
"""`None` means the profile does not have the capability, never "off".
That is the established shape here: `segmentation` reads the same way, and
every downstream check is `is not None`. A boolean flag would make
"capability absent" and "capability present but disabled" the same value.
"""
assert DEFAULT.renderers is None
assert SEGMENTED_V1.renderers is None
assert "renderers" in {f for f in BundleProfile.__dataclass_fields__}
def test_every_existing_profile_constructs_unchanged() -> None:
"""The field is defaulted, so no existing constructor moved."""
from llm_ingestion_okf import profiles as profiles_module
for name in dir(profiles_module):
value = getattr(profiles_module, name)
if isinstance(value, BundleProfile):
assert value.renderers is None, f"{name} gained a renderer unasked"
def test_extraction_without_a_renderer_is_byte_identical(
tmp_path: Path,
) -> None:
"""The default is identity, asserted per suffix rather than once.
A hook whose default is not identity would move every golden in the repo,
which is the failure this pins.
"""
cases = {
"note.md": b"# Title\n\nBody\n",
"note.txt": b"plain text\n",
"data.json": b'{"a": 1}\n',
"page.html": b"<p>hello</p>",
}
for name, payload in cases.items():
assert extract_text(name, payload) == extract_text(name, payload, renderer=None)
def test_a_named_renderer_is_what_reaches_the_caller() -> None:
text = extract_text("note.md", b"# Title\n\nBody\n", renderer=_shout)
assert text == "<<# Title\n\nBody\n>>"
def test_the_renderer_runs_after_extraction_not_instead_of_it() -> None:
"""It receives EXTRACTED text, not raw bytes.
A renderer handed the raw bytes would have to re-implement extraction, and
the two would drift. The csv case makes the ordering visible: the renderer
sees the rendered markdown table, not the comma-separated source.
"""
seen: list[str] = []
def capture(text: str) -> str:
seen.append(text)
return text
extract_text("rows.csv", b"a,b\n1,2\n", renderer=capture)
assert seen and "|" in seen[0], "the renderer did not receive extracted text"
assert "a,b" not in seen[0].split("\n")[0] or "|" in seen[0]
def test_a_renderer_applies_to_binary_types_too() -> None:
data = (FIXTURES / "two-line-krav.docx").read_bytes()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
text = extract_text("krav.docx", data, renderer=_shout)
assert text.startswith("<<") and text.endswith(">>")
def test_the_extraction_registry_does_not_import_the_profile_layer() -> None:
"""The layering direction, asserted rather than intended.
This is the constraint that shaped the design: the hook is a callable
parameter precisely so extraction never needs to know what a profile is.
"""
source = (
Path(__file__).resolve().parents[1] / "src" / "llm_ingestion_okf" / "extract.py"
).read_text(encoding="utf-8")
assert "profiles" not in source, "extract.py must not reach into the profile layer"
def test_renderer_is_keyword_only() -> None:
"""Positional call sites stay source-compatible, which is the repo's rule
for every new public parameter."""
with pytest.raises(TypeError):
extract_text("note.md", b"x", _shout) # type: ignore[misc]