llm-ingestion-okf/tests/test_xml_sts.py
Kjell Tore Guttormsen 5bb3b045c2 test(extract): the two registries are coupled, and only a whole run says so
RED, 12 failing. Two corrections to the previous commit's tests, both found by
running rather than by reading.

ONE, AND IT IS A REAL GAP THIS COMMIT CLOSES: `okf build` on an STS document
does not fail in the extractor at all -- it fails two layers away, in
`segmentation.observed_extractor_version`, with `no extractor version is known
for extractor_id 'xml'`. That coupling is deliberate and its own comment says
so: a row added to the extraction registry and not to `_STDLIB_EXTRACTOR_IDS`
"fails loudly on the first proposal for that type". No unit test of an
extractor can see it, because the missing thing is the version the plan is
KEYED to, not the text. The previous commit's tests would all have gone green
with the build still refusing every file. This test is the one that would not
have.

TWO: the expectation that the proposer strips a numbering token out of an ATX
title was wrong about `propose`, not about this reader. Measured: `_ATX` keeps
the whole title and fills `number` in addition, and `_NUMBERED` needs at least
one dot, so `1 Bruksomraade` gets no number while `1.1 Omfang` gets one and
keeps it. Both shapes are now asserted, along with the table-wrap arriving as
ONE `rule:table-block` candidate rather than one per row.

pytest -q: 12 failed, 1554 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 03:36:13 +02:00

228 lines
9.7 KiB
Python

"""`.xml`: a NISO-STS document carries its own structure, and it was unreadable.
MEASURED OUTSIDE THIS REPOSITORY AND REPRODUCED HERE: the zip a publisher's
own viewer delivers as "Html" holds 0 html, 1 xml and 109 images, and `okf
build` on it gave **110 of 110 unreadable, 0 plans, exit 2** -- `.xml` was in
neither extractor registry. The one xml file IS the whole product: R761
Prosesskoden:2025, the same document round 12 met as a 701-page PDF, in
NISO-STS form. It carries 7 715 `<sec>`, **2 761 of them with a `<title>`**,
and a `<sec>`-nesting depth distribution of 28/118/500/1141/868/97/9 that is
row-for-row identical to the fasit built from that same file.
THE CEILING IS THEREFORE STRUCTURAL AND NOT COMPUTED: 2 761 of 2 761. The PDF
arm reached 2 759 of 2 761 by bridging from (page, y) to a line index; here the
publisher states the structure in elements, so nothing is recovered and nothing
is guessed.
WHAT THESE TESTS PIN, each because the measurement said it could go wrong:
- `<label>` carries the number and `<title>` carries the text. Emitting only
`<title>` scores 0 of 2 761 while every line of the code looks right, because
the number is what okf reduces to a directory name.
- a `<sec>` with a `<label>` and NO `<title>` is never a heading. 4 954 of the
7 715 are lettered points (`a)`, `c)`) inside a process description, and one
heading each would bury the document's own 2 761.
- the ONE titled section with no label -- `Forord` -- emitted without a
numbering token, because the corrected instrument key looks it up by title.
- a `<table-wrap>` as ONE markdown table block. The PDF path delivered 0 of 10.
- XML that is NOT STS producing text and ONE plan, never zero and never an
invented structure from element names.
- a DTD REFUSED rather than parsed. Measured on this machine's interpreter
(3.14.0, pyexpat 2.7.3): an external entity is refused by the stdlib, but the
billion-laughs limit comes from libexpat >= 2.4.0 and NOT from Python, while
`pyproject.toml` requires only `>=3.10`. Refusing every DTD is a guarantee
about this code; relying on the parser is a guarantee about the machine.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from llm_ingestion_okf import extract, propose
from llm_ingestion_okf.errors import IngestError
FIXTURES = Path(__file__).parent / "fixtures"
STS = FIXTURES / "sts-mini.xml"
GENERIC = FIXTURES / "generic-feed.xml"
BOMB = FIXTURES / "xml-doctype-bomb.xml"
MALFORMED = FIXTURES / "xml-malformed.xml"
STS_EXPECTED = """\
Testnormal for fiksturbruk
# Forord
Dette forordet baerer ingen nummerering.
# 1 Bruksomraade
Normalen gjelder for testing av ekstraktoren.
## 1.1 Omfang
Omfanget dekker hele arbeidet.
### 1.1.1 Materialer
Materialene skal vaere godkjente.
a) Betong skal ha fasthetsklasse B35.
b) Armering skal vaere av klasse B500NC.
# 2 Tabeller
Tabellen under er en enkelt tabellblokk.
Tabell 2-1
| Prosess | Enhet |
| --- | --- |
| Sprengning | kubikkmeter |
Foerste punkt i lista.
Andre punkt i lista."""
def test_an_sts_document_becomes_the_markdown_the_office_rows_produce() -> None:
"""The whole emission, asserted as one string rather than in pieces.
A per-property test would pass on an output whose LINES were right and
whose order was not, and order is what every boundary grammar reads.
"""
assert extract.extract_text(STS.name, STS.read_bytes()) == STS_EXPECTED
def test_the_label_and_the_title_reach_the_proposer_as_one_heading() -> None:
"""Title, LEVEL and OFFSET -- not "it found more than one"."""
text = extract.extract_text(STS.name, STS.read_bytes())
candidates = propose.find_candidates(text)
# The TITLE keeps its numbering token and the `number` field carries it as
# well -- that is `propose`'s own documented ATX behaviour and this reader
# does not reach into it. `1 Bruksomraade` gets no `number` because
# `_NUMBERED` requires at least one dot, which is why both shapes are here.
assert [(c.rule, c.number, c.title, c.level) for c in candidates] == [
("rule:heading", None, "Forord", 1),
("rule:heading", None, "1 Bruksomraade", 1),
("rule:heading", "1.1", "1.1 Omfang", 2),
("rule:heading", "1.1.1", "1.1.1 Materialer", 3),
("rule:heading", None, "2 Tabeller", 1),
# The table-wrap, as ONE candidate rather than one per row.
("rule:table-block", None, "Tabell linje 15", 9),
]
candidates = [c for c in candidates if c.rule == "rule:heading"]
# The offsets, stated against the text itself: a candidate naming the right
# title at the wrong offset would pass the list above.
for candidate, line in zip(
candidates,
["# Forord", "# 1 Bruksomraade", "## 1.1 Omfang", "### 1.1.1 Materialer", "# 2 Tabeller"],
strict=True,
):
assert text[candidate.start : candidate.start + len(line)] == line
def test_a_lettered_point_is_a_body_line_and_never_a_heading() -> None:
"""4 954 of R761's 7 715 `<sec>` are these. One heading each buries 2 761."""
text = extract.extract_text(STS.name, STS.read_bytes())
assert "a) Betong skal ha fasthetsklasse B35." in text.split("\n")
assert not any(line.startswith("#") and "Betong" in line for line in text.split("\n"))
assert [c.title for c in propose.find_candidates(text) if "Betong" in c.title] == []
def test_a_table_wrap_is_one_table_block_with_its_label_above_it() -> None:
"""The separator line is what makes it a BLOCK and not two pipe lines."""
lines = extract.extract_text(STS.name, STS.read_bytes()).split("\n")
at = lines.index("Tabell 2-1")
assert lines[at + 1 : at + 4] == [
"| Prosess | Enhet |",
"| --- | --- |",
"| Sprengning | kubikkmeter |",
]
def test_the_text_survives_the_markup_exactly() -> None:
"""EXACT, not a percentage: strip what the emission added and compare."""
import xml.etree.ElementTree as ET
text = extract.extract_text(STS.name, STS.read_bytes())
source = ET.fromstring(STS.read_text(encoding="utf-8"))
kept = []
for line in text.split("\n"):
if set(line) <= set("| -:"):
continue # the table separator, which no source character produced
kept.append(line.lstrip("#").replace("|", ""))
emitted = "".join("".join(kept).split())
original = "".join("".join(source.itertext()).split())
assert emitted == original
def test_generic_xml_keeps_its_text_and_invents_no_structure() -> None:
"""One plan with the content preserved -- never zero, never element names
promoted to headings. The schema test is NAMED (`<standard>` root, or any
`<sec>`), so a document that is not STS is not guessed at."""
text = extract.extract_text(GENERIC.name, GENERIC.read_bytes())
assert "Tunnelen er stengt for vedlikehold natt til fredag." in text
assert "Redusert framkommelighet" in text
assert not any(line.startswith("#") for line in text.split("\n"))
assert propose.find_candidates(text) == []
def test_a_document_type_declaration_is_refused_and_never_expanded() -> None:
"""A guarantee about this code, not about the machine's libexpat."""
data = BOMB.read_bytes()
assert b"<!DOCTYPE" in data # the fixture really carries one
with pytest.raises(IngestError) as caught:
extract.extract_text(BOMB.name, data)
assert caught.value.code == "extractor_xml_doctype"
# The expansion must not appear ANYWHERE, including in the error text.
assert "SPRENGSTOFF" not in str(caught.value)
def test_malformed_xml_raises_a_typed_error_and_not_a_parse_error() -> None:
"""Not a leaked `ParseError`, and not zero concepts in silence."""
with pytest.raises(IngestError) as caught:
extract.extract_text(MALFORMED.name, MALFORMED.read_bytes())
assert caught.value.code == "extractor_xml_parse_error"
def test_xml_is_a_core_type_and_never_reaches_the_converter() -> None:
"""Stdlib parsing, so `[extract]` would make a stdlib type binary-dependent
-- and the converter is a second parser this hardening never sees."""
from llm_ingestion_okf.extract import (
_CORE_EXTRACTORS,
_OPTIONAL_EXTRACTORS,
_PANDOC_FORMATS,
)
assert ".xml" in _CORE_EXTRACTORS
assert ".xml" not in _OPTIONAL_EXTRACTORS
assert ".xml" not in _PANDOC_FORMATS
def test_a_bundle_is_built_end_to_end_from_an_sts_document(tmp_path: Path) -> None:
"""The registries are COUPLED, and only a run through the whole chain says so.
`segmentation._STDLIB_EXTRACTOR_IDS` names the ids whose extracted text is
versioned by this package's own literal, and its own comment says a row
added to the extraction registry and not there "fails loudly on the first
proposal for that type". It does -- and no unit test of the extractor can
see it, because the failure is two layers away in the version the plan is
keyed to. This was found by running `okf build`, not by a test, which is
why the test exists now.
"""
from llm_ingestion_okf import cli
from llm_ingestion_okf.segmentation import _STDLIB_EXTRACTOR_IDS, observed_extractor_version
assert "xml" in _STDLIB_EXTRACTOR_IDS
assert observed_extractor_version("xml")
folder = tmp_path / "docs"
folder.mkdir()
(folder / "sts-mini.xml").write_bytes(STS.read_bytes())
bundle = tmp_path / "bundle"
report = cli.build(folder, bundle=bundle, bundle_id="mini", okf_version="0.2")
assert report.codes == ()
assert report.unaccounted == ()
titles = sorted(
line.split(":", 1)[1].strip()
for concept in bundle.rglob("*.md")
if concept.name not in ("index.md", "log.md") and concept.parent != bundle
for line in concept.read_text(encoding="utf-8").splitlines()
if line.startswith("title:")
)
assert titles, "an STS document must produce concepts"
assert "Forord" in titles