llm-ingestion-okf/tests/test_k2_office_fixtures.py
Kjell Tore Guttormsen 6ff18fd703 feat(propose,extract,cli): a title that ends in a number, and a converter's own anchor in a concept id
Round 9: the four rests in STATE's NESTE that needed no operator decision.

CLAUSE 1 CLASSIFIED BY THE NUMBER, NOT THE TITLE. `_TRAILING_PAGE_NUMBER`
admitted a candidate into a contents run by asking whether the title ended in
an integer -- a question about the number. A drawing's dimension chain, a
schematic's labels, a door schedule, a coordinate column and a soil-layer
table all end in integers and name nothing. Measured over the 43-document
corpus: 68 candidates discarded over 11 of 39 readable documents, of which
19 over 5 documents are data rows.

That corrects round 8's own decomposition. Its "four misclassified numeric
tables and seven real contents listings" needs each document on one side, and
two of the eleven are both. Read across all 68 titles rather than the
three-title sample: 5 documents carry a data row, 8 carry a real entry.

`--contents-name` requires a NAME to survive stripping the page number. The
threshold is SWEPT, not chosen, and collapses at both ends: at an alphabetic
run of 1 a door schedule keeps a stray `V` and 13 of 19 are rescued; at 3 the
two-letter section name `VA` stops being a name, falls out of run membership,
and takes `RIB`, `MMI` and `Tittelfelt` below `CONTENTS_RUN` with it -- one
acronym costing four REAL entries. At 2: 16 of 19 rescued, 0 of 49 regressed.
The three not rescued carry a real word and are named rather than rounded off.

THE CONVERTER'S ANCHOR WAS IN THE CONCEPT ID. Pandoc writes a sheet as
`## <name> {#sheet-N}` and a titled slide as `## <title> {#slide-N}`. Because
a filename is reduced FROM the title, the anchor reached both. Operator
authorised the strip 2026-09-09 after the exposure was counted: 2 of 810
concepts on the previous default bundle, 2 of 1108 on Arm B, 1 of 26 on the
operator's folder. Two ids renamed, one of which `portfolio-optimiser` has
cited in writing; both are in the report so that message can be sent.

One rule in one function, read by BOTH title-forming sites -- a rule in only
one would leave the id and the title naming the same concept differently. The
known-negative is the point: `Mal for {kundenavn}` is a title an author wrote.

odt/rtf/pptx MEASURED END TO END FOR THE FIRST TIME, on hand-built documents,
because the corpus denominator is genuinely zero (86 files: 66 pdf, 10 docx,
4 xlsx, 2 zip, 2 smc, 2 doc). `_EVIDENCE` gains a third class rather than
stretching an existing one: `constructed` means the row has met a document,
but not one anyone wrote for their own purposes. odt 1 of 1 declared headings;
pptx 2 of 2 on a deck that declares slide titles and 0 of 2 on one that does
not -- round 7's reading of pptx was a fixture property, not the format; rtf
0 segments, because the container has no heading style and the author's title
is bold text. rtf is the one open finding.

ACCEPTANCE, all four. The 12-position reference is label-identical in BOTH
readings (pdf 7/8, docx 3/3, xlsx 0/1 or 1/1, sheet 10/12 or 11/12). One K2
bundle carrying both changes: 453 concepts / 865 md, hit@8 [1,1,1,1,1,None]
on it AND on Arm B, with the known-negative still reproducing on the new
bytes. `okf project` byte-equal to `okf build`, `diff -r` empty. Consumer
cost is a re-run: 436/832 -> 453/865, digest 21af4a1aa98315cf.

Three published numbers corrected: README's 596 tests (1515), README's "15
concepts out" for `okf project` (that was the O6 defect; it is 26), and O6's
print-mode method, which does not reproduce without --allowedTools.

Report: docs/2026-09-09-k3-runde9-restene.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:45:08 +02:00

118 lines
4.6 KiB
Python

"""The K2 denominator for `pptx`/`odt`/`rtf`: a synthetic fixture set, N = 3.
`docs/2026-09-04-k2-pptx-odt-rtf.md` measured the corpus denominator for these
three office rows and found it ZERO -- `K2/trinn1` holds 43 files and not one
of them is a `pptx`, an `odt` or an `rtf`. So those three rows in
`extract._EVIDENCE` read `unmeasured`: they work by construction and had never
met a document anyone wrote.
This module is the smallest thing that changes that without inventing a corpus:
three hand-laid documents carrying the SAME content in three containers, and a
hand-counted fasit committed beside them. It pins the one property a report
cannot assert for itself -- that all three go through Door B, offline, and land
as concepts -- so the numbers in
`docs/2026-09-07-k2-pptx-odt-rtf-fixtures.md` stay re-measurable rather than
becoming a memory of a run.
WHAT IT DOES NOT DO. Three synthetic documents in one house style are not a
corpus. A green run here says the row is exercised, never that the format is
covered. Round 9 (2026-09-09) measured all three end to end for the first time
and moved them `unmeasured` -> `constructed`, a third evidence class defined in
`extract._EVIDENCE`: the row has met a document, but not one anyone wrote for
their own purposes. This suite asserts that class rather than the old one.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
import pytest
from llm_ingestion_okf.extract import _EVIDENCE
from llm_ingestion_okf.inbox import GateDecision, process_inbox
pytest.importorskip("pypandoc", reason="office conversion needs the [extract] extra")
INGESTED_AT = "2026-09-07T00:00:00Z"
WARN = "warn"
FIXTURES = Path(__file__).parent / "fixtures"
DROP = FIXTURES / "k2-office"
FASIT = FIXTURES / "k2-office-fasit.json"
#: The three containers, one per row this fixture set exists to reach.
EXPECTED_FILES = (
"krav-presentasjon.pptx",
"krav-rikt-tekstformat.rtf",
"krav-tekstdokument.odt",
)
@dataclass
class StubGuard:
"""The pinned guard's surface, offline. A TEST DOUBLE -- never in `src/`."""
def __call__(self, text: str) -> GateDecision:
return GateDecision(sanitized_text=text, disposition=WARN, reasons=())
def test_the_drop_directory_holds_exactly_the_three_containers() -> None:
"""N = 3, and N is read off the directory rather than asserted from memory.
The generator and the fasit live one level UP on purpose: Door B walks this
directory recursively, so anything else parked here would enter the run and
the denominator would stop being three.
"""
assert sorted(path.name for path in DROP.iterdir()) == list(EXPECTED_FILES)
def test_all_three_go_through_door_b(tmp_path: Path) -> None:
"""3/3 merged, offline, through the same call the operator makes."""
result = process_inbox(
DROP,
tmp_path / "bundle",
INGESTED_AT,
okf_type="note",
gate=StubGuard(), # type: ignore[arg-type]
)
assert [item.source_file for item in result.persisted] == list(EXPECTED_FILES)
assert (result.quarantined, result.rejected, result.failed) == ((), (), ())
def test_every_persisted_concept_carries_the_documents_own_strings(tmp_path: Path) -> None:
"""A concept that arrived empty would still count as persisted.
So the count is not the whole assertion: each written concept has to carry
the title, a label from the pairing table and a cell from the grid -- one
string from each of the three structures the fixture is built out of.
"""
bundle = tmp_path / "bundle"
result = process_inbox(
DROP,
bundle,
INGESTED_AT,
okf_type="note",
gate=StubGuard(), # type: ignore[arg-type]
)
for item in result.persisted:
text = (bundle / item.path).read_text(encoding="utf-8")
assert "Kravspesifikasjon for tunnelbelysning" in text
assert "Årsdøgntrafikk:" in text
assert "Klasse C" in text
def test_the_fasit_is_hand_counted_and_committed_beside_the_fixtures() -> None:
"""The denominator the report divides by, frozen before the measurement ran."""
fasit = json.loads(FASIT.read_text(encoding="utf-8"))
assert sorted(fasit["documents"]) == list(EXPECTED_FILES)
for name in EXPECTED_FILES:
entry = fasit["documents"][name]
assert entry["cells"] == fasit["shared"]["cells"]
assert entry["pairs"] == fasit["shared"]["pairs"]
def test_three_synthetic_documents_do_not_make_a_row_measured() -> None:
"""One house style, N = 3, written by us -- that is exercise, not coverage."""
assert [_EVIDENCE[key] for key in (".pptx", ".odt", ".rtf")] == ["constructed"] * 3