feat(tools): the proposer scopes a document's segments under a caller's prefix
Measured on the K2 corpus 2026-09-03: 39 documents proposed 618 entries under 601 distinct paths -- 17 paths were claimed by two documents each. Section numbering is document-local (`1 Innledning` is in most procurement documents), so this is structural, not unlucky. Every collision reaches Door B's gate, which refuses per DOCUMENT, so those documents would land as coded rejections rather than concepts and a corpus run could not be built at all. `--path-prefix` is an argument and not something the tool derives: the proposer sees ONE document and cannot know what else is in the bundle. It is reduced to the id grammar before anything is read, and a prefix that reduces to nothing is refused rather than silently producing the unscoped paths the caller asked to avoid. Without the flag every artifact already produced is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c859d9bbfe
commit
a5e129d413
2 changed files with 104 additions and 8 deletions
|
|
@ -230,3 +230,54 @@ def test_an_unknown_file_type_exits_two(tmp_path: Path, capsys: pytest.CaptureFi
|
|||
code = okf_propose_segments.main([str(source), "--out", str(tmp_path / "o")])
|
||||
assert code == 2
|
||||
assert "okf-propose-segments" in capsys.readouterr().err
|
||||
|
||||
|
||||
# --- the per-document scope a multi-document corpus needs ------------------
|
||||
|
||||
|
||||
def test_a_path_prefix_scopes_every_entry_under_one_directory(tmp_path: Path) -> None:
|
||||
"""Measured on the K2 corpus, 2026-09-03: 39 documents proposed 618 entries
|
||||
under 601 distinct paths -- **17 paths were claimed by two documents each**.
|
||||
Every one of them would hit Door B's collision gate, and the gate refuses
|
||||
per DOCUMENT, so those documents would land as coded rejections instead of
|
||||
concepts. Section numbering is document-local (`1 Innledning` is in most of
|
||||
them), so the collision is structural rather than unlucky.
|
||||
|
||||
The scope is an argument and not something this tool invents: the proposer
|
||||
sees one document and has no way of knowing what else is in the bundle, so
|
||||
the caller who does supplies the prefix.
|
||||
"""
|
||||
out = tmp_path / "plan.json"
|
||||
assert (
|
||||
okf_propose_segments.main(
|
||||
[str(write(tmp_path)), "--out", str(out), "--path-prefix", "Del II Bilag 3.1"]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
assert payload["entries"]
|
||||
for entry in payload["entries"]:
|
||||
assert entry["path"].startswith("del-ii-bilag-3-1/")
|
||||
# Still a valid plan: the prefix passes the same path grammar as any other
|
||||
# component, rather than being spliced in behind the validator's back.
|
||||
assert parse_segmentation_plan(payload).entries
|
||||
|
||||
|
||||
def test_without_a_prefix_the_artifact_is_byte_identical(tmp_path: Path) -> None:
|
||||
"""Additive. Every plan already produced stays exactly what it was."""
|
||||
first = tmp_path / "a.json"
|
||||
second = tmp_path / "b.json"
|
||||
source = write(tmp_path)
|
||||
okf_propose_segments.main([str(source), "--out", str(first)])
|
||||
okf_propose_segments.main([str(source), "--out", str(second), "--path-prefix", ""])
|
||||
assert first.read_bytes() == second.read_bytes()
|
||||
|
||||
|
||||
def test_a_prefix_that_reduces_to_nothing_is_refused(tmp_path: Path) -> None:
|
||||
"""A prefix of punctuation would otherwise become an empty component and
|
||||
silently produce the unscoped paths the caller asked to avoid."""
|
||||
code = okf_propose_segments.main(
|
||||
[str(write(tmp_path)), "--out", str(tmp_path / "p.json"), "--path-prefix", "###"]
|
||||
)
|
||||
assert code == 2
|
||||
assert not (tmp_path / "p.json").exists()
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ def find_candidates(text: str) -> list[Candidate]:
|
|||
return candidates
|
||||
|
||||
|
||||
def _segment_path(candidate: Candidate, taken: set[str]) -> str:
|
||||
def _segment_path(candidate: Candidate, taken: set[str], prefix: str = "") -> str:
|
||||
title = unicodedata.normalize("NFC", candidate.title)
|
||||
# The section number becomes the DIRECTORY, so leaving it in the stem too
|
||||
# yields `3-1/3-1-brannkonsept.md` -- correct and unreadable.
|
||||
|
|
@ -259,17 +259,28 @@ def _segment_path(candidate: Candidate, taken: set[str]) -> str:
|
|||
if not stem:
|
||||
stem = "seksjon"
|
||||
directory = reduce_to_id_grammar(candidate.number or "") if candidate.number else ""
|
||||
path = f"{directory}/{stem}.md" if directory else f"{stem}.md"
|
||||
# The caller's scope comes FIRST and is never deduplicated against: it is
|
||||
# the same for every entry in this document by construction, and that is
|
||||
# the whole point -- one document's sections must not be able to claim
|
||||
# another's path.
|
||||
head = f"{prefix}/" if prefix else ""
|
||||
path = f"{head}{directory}/{stem}.md" if directory else f"{head}{stem}.md"
|
||||
suffix = 2
|
||||
while path in taken:
|
||||
path = f"{directory}/{stem}-{suffix}.md" if directory else f"{stem}-{suffix}.md"
|
||||
path = f"{head}{directory}/{stem}-{suffix}.md" if directory else f"{head}{stem}-{suffix}.md"
|
||||
suffix += 1
|
||||
taken.add(path)
|
||||
return path
|
||||
|
||||
|
||||
def build_plan(
|
||||
source: Path, text: str, source_bytes: bytes, *, okf_type: str, proposed_at: str
|
||||
source: Path,
|
||||
text: str,
|
||||
source_bytes: bytes,
|
||||
*,
|
||||
okf_type: str,
|
||||
proposed_at: str,
|
||||
path_prefix: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""The artifact. Every entry PROPOSED, the plan itself never adjudicated."""
|
||||
taken: set[str] = set()
|
||||
|
|
@ -279,7 +290,7 @@ def build_plan(
|
|||
entries.append(
|
||||
{
|
||||
"segment_id": f"p{len(entries) + 1}",
|
||||
"path": _segment_path(candidate, taken),
|
||||
"path": _segment_path(candidate, taken, path_prefix),
|
||||
"title": candidate.title,
|
||||
"okf_type": okf_type,
|
||||
"span": [candidate.start, candidate.end],
|
||||
|
|
@ -323,7 +334,17 @@ def build_plan(
|
|||
}
|
||||
|
||||
|
||||
def run(source: Path, out: Path, *, okf_type: str, proposed_at: str) -> int:
|
||||
def run(source: Path, out: Path, *, okf_type: str, proposed_at: str, path_prefix: str = "") -> int:
|
||||
# Reduced HERE, before anything is read: a prefix that survives to the
|
||||
# entries as an empty component would produce exactly the unscoped paths
|
||||
# the caller asked to avoid, and would do it silently.
|
||||
scope = reduce_to_id_grammar(path_prefix) if path_prefix else ""
|
||||
if path_prefix and not scope:
|
||||
raise ProposerError(
|
||||
f"--path-prefix {path_prefix!r} reduces to nothing under the id grammar "
|
||||
"([a-z0-9][a-z0-9-]*); refusing to write unscoped paths under a scope "
|
||||
"that was asked for"
|
||||
)
|
||||
if not source.is_file():
|
||||
raise ProposerError(f"source is not a file: {source}")
|
||||
try:
|
||||
|
|
@ -335,7 +356,14 @@ def run(source: Path, out: Path, *, okf_type: str, proposed_at: str) -> int:
|
|||
except IngestError as exc:
|
||||
raise ProposerError(f"cannot extract text from {source.name}: {exc}") from exc
|
||||
|
||||
payload = build_plan(source, text, source_bytes, okf_type=okf_type, proposed_at=proposed_at)
|
||||
payload = build_plan(
|
||||
source,
|
||||
text,
|
||||
source_bytes,
|
||||
okf_type=okf_type,
|
||||
proposed_at=proposed_at,
|
||||
path_prefix=scope,
|
||||
)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes((json.dumps(payload, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
|
||||
print(
|
||||
|
|
@ -354,6 +382,17 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
parser.add_argument("source", type=Path, help="the document to segment")
|
||||
parser.add_argument("--out", type=Path, required=True, help="where to write the artifact")
|
||||
parser.add_argument("--okf-type", default="reference", help="okf_type for every entry")
|
||||
parser.add_argument(
|
||||
"--path-prefix",
|
||||
default="",
|
||||
help=(
|
||||
"scope every entry's path under this directory. Required for a corpus: "
|
||||
"section numbering is document-local, so two documents propose the same "
|
||||
"path and Door B refuses both. An argument rather than something this "
|
||||
"tool derives -- it sees one document and cannot know what else is in "
|
||||
"the bundle"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--proposed-at",
|
||||
default="1970-01-01T00:00:00Z",
|
||||
|
|
@ -365,7 +404,13 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
return run(args.source, args.out, okf_type=args.okf_type, proposed_at=args.proposed_at)
|
||||
return run(
|
||||
args.source,
|
||||
args.out,
|
||||
okf_type=args.okf_type,
|
||||
proposed_at=args.proposed_at,
|
||||
path_prefix=args.path_prefix,
|
||||
)
|
||||
except ProposerError as exc:
|
||||
print(f"{PROPOSER_ID}: FAILED - {exc}", file=sys.stderr)
|
||||
print(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue