Rifqi Hafizuddin Claude Fable 5 commited on
Commit
72605ff
·
1 Parent(s): 024c30a

[NOTICKET] fix(knowledge_extraction): move runner into the package; fix two ranking defects

Browse files

Runner relocated scripts/run_knowledge_extraction.py -> the package as
src/knowledge_extraction/cli.py. scripts/ is gitignored, and this runner is the
pipeline's operator entry point, so it has to ship with the module:

uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json>

pyproject: narrow the T201 lint exemption to that one CLI file rather than all
of scripts/, so the ignore traces to this change and nothing else.

Two ranking defects found while verifying against the real 9-page BUMA standard
(both recorded as deviations V1/V2 in KNOWLEDGE_PIPELINE_CALIBRATION.md §9):

V1 — a heading that names the term now counts as a mention at offset 0 for
cue-proximity scoring. Indonesian standards are written "2.1.3 Physical of
Availability (PA)" / "Adalah ketersediaan fisik...": the heading names the term
and the body opens with the definitional cue WITHOUT repeating it, so the
definition section earned no cue bonus at all. Measured before the fix, PA's own
definition section scored 7.5 and ranked THIRD, behind a computation section
(8.5) that merely mentions PA often. After it, all five terms rank their own
definition section first: PA->2.1.3, UA->2.1.4, Qty->2.1.2, Pty->2.1.5,
MOHH->2.1.1. Same root cause as the literal-wording defect — the heading is a
separate field from the text, so anything reasoning over "term + its definition"
must be told to read both.

V2 — first_occurrence now follows document order, and ranking ties break on it.
Previously the +1.0 went to whichever chunk a set iteration happened to yield
first, which made the bonus arbitrary and the ranking unreproducible.

Both change which chunks reach the paid stage, so they can move schema-fill
precision when that branch lands; re-score against the frozen baseline then
rather than assuming the improvement carries.

Verification: ruff clean on all touched paths; full suite 470 passed, 7 skipped
(was 467 + 3 new local tests); ranking stable across PYTHONHASHSEED 0/1/7/42/99;
CLI green on the BUMA chunks and on Sofhia's draft artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

KNOWLEDGE_PIPELINE_CALIBRATION.md CHANGED
@@ -185,3 +185,25 @@ clock, 3 fields rejected by span check.
185
  **When quoting E2 externally:** the context doc's **6.2×** is a 1,000-page projection; **2.56×** is
186
  the 9-page measurement. Compression grows as terms repeat across a corpus. Both are true — use the
187
  right one for the audience.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  **When quoting E2 externally:** the context doc's **6.2×** is a 1,000-page projection; **2.56×** is
186
  the 9-page measurement. Compression grows as terms repeat across a corpus. Both are true — use the
187
  right one for the audience.
188
+
189
+ ---
190
+
191
+ ## 9. Deliberate deviations from the prototype (v2)
192
+
193
+ Changes made while rebuilding, with the reason. Each one alters behaviour, so
194
+ each is listed rather than folded silently into the tables above.
195
+
196
+ | # | Change | Why | Measured? |
197
+ |---|---|---|---|
198
+ | **V1** | **A heading that names the term counts as a mention at offset 0** for cue-proximity scoring | Indonesian standards are written *"2.1.3 Physical of Availability (PA)"* / *"Adalah ketersediaan fisik…"* — the heading names the term and the body opens with the definitional cue **without repeating it**. The prototype therefore awarded no cue bonus on the definition section itself. Measured on the BUMA standard: PA's own definition section (§2.1.3) scored 7.5 and ranked **third**, below a computation section (§2.2.4, 8.5) that merely mentions PA often. After the fix, all five terms rank their own definition section first — PA→2.1.3, UA→2.1.4, Qty→2.1.2, Pty→2.1.5, MOHH→2.1.1 | Structurally verified on the 9-page standard. **Not yet scored** — needs the paid stage to show up in E3 |
199
+ | **V2** | **`first_occurrence` follows document order**, and ranking ties break on document order | The prototype awarded the +1.0 to whichever chunk a `set` iteration yielded first, making the bonus arbitrary and the ranking unreproducible across runs. "First occurrence" should mean the term's first appearance in the document | Determinism locked by test; verified stable across `PYTHONHASHSEED` 0/1/7/42/99 |
200
+ | **V3** | **`source_wording` field added** to the glossary entry | The control for the literal-source-wording defect (§7). Not yet populated — needs the seam to carry the heading verbatim and the extraction prompt to fill it | No |
201
+
202
+ **Both V1 and V2 change which chunks reach the paid stage**, so they can move E3
203
+ when the extraction branch lands. Re-score against
204
+ `eval/knowledge/results/baseline_prototype_2026-08-13_145132.json` at that point
205
+ and record the delta rather than assuming the improvement carries.
206
+
207
+ **V1 and the R1 literal-wording defect share a root cause:** the heading is a
208
+ separate field from the chunk text, so anything that reasons over "the term and
209
+ its definition together" has to be told to look at both.
pyproject.toml CHANGED
@@ -126,9 +126,9 @@ ignore = [
126
  "tests/**" = ["S101", "S105", "S106", "S608"]
127
  # T201: eval/ scripts are CLIs — print() is their intended output channel.
128
  "eval/**" = ["T201"]
129
- # Same rule, same reason: scripts/ are operator-run CLIs. (The existing scripts
130
- # already relied on this; it was simply never declared.)
131
- "scripts/**" = ["T201"]
132
 
133
  [tool.mypy]
134
  python_version = "3.12"
 
126
  "tests/**" = ["S101", "S105", "S106", "S608"]
127
  # T201: eval/ scripts are CLIs — print() is their intended output channel.
128
  "eval/**" = ["T201"]
129
+ # Same rule, same reason: this is an operator-run CLI, and its printed output IS
130
+ # the deliverable.
131
+ "src/knowledge_extraction/cli.py" = ["T201"]
132
 
133
  [tool.mypy]
134
  python_version = "3.12"
src/knowledge_extraction/cli.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline runner for the knowledge-extraction pipeline (free stages only).
2
+
3
+ The pipeline runs a few times a year, triggered by an admin — so a script over a
4
+ parsed artifact is the honest entry point, and any HTTP surface is a convenience
5
+ layer over this, never the other way round.
6
+
7
+ Takes a **parsed-document artifact**, never a PDF: extraction does not parse.
8
+ Every stage writes its own JSON so a later stage can be re-run without repeating
9
+ an earlier one — which matters because prompt iteration is the main development
10
+ loop and re-running the span filter is the slow part.
11
+
12
+ Currently covers the free stages: adapter -> cue/legend filters -> cluster ->
13
+ evidence ranking. The paid extraction stage is not built yet.
14
+
15
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json>
16
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --out-dir out/
17
+
18
+ Lives inside the package rather than in `scripts/`, which is gitignored: this
19
+ runner is the pipeline's operator entry point and has to ship with the module.
20
+
21
+ Add --mentions <file.json> to supply span-NER mentions from elsewhere; without
22
+ it the runner clusters only what the legend filter found, which is enough to
23
+ exercise the wiring but is not a recall measurement.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import re
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ from .adapter import parsed_doc_from_artifact
35
+ from .cluster import cluster_mentions
36
+ from .filters import abbrev_pairs, rule_candidates
37
+ from .models import Mention
38
+ from .rank import rank_evidence
39
+ from .settings import EVIDENCE_K
40
+
41
+
42
+ def main(argv: list[str] | None = None) -> int:
43
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
44
+ parser.add_argument("artifact", type=Path, help="parsed-document artifact JSON")
45
+ parser.add_argument("--mentions", type=Path, help="span-NER mentions JSON")
46
+ parser.add_argument("--out-dir", type=Path, default=Path("out/knowledge"))
47
+ parser.add_argument("--doc-id", help="override the artifact's doc_id")
48
+ args = parser.parse_args(argv)
49
+
50
+ if not args.artifact.exists():
51
+ print(f"artifact not found: {args.artifact}", file=sys.stderr)
52
+ return 2
53
+
54
+ raw = json.loads(args.artifact.read_text(encoding="utf-8"))
55
+ doc = parsed_doc_from_artifact(
56
+ raw, doc_id=args.doc_id, source_ref=str(args.artifact)
57
+ )
58
+ print(
59
+ f"[parse ] {doc.doc_id}: {len(doc.chunks)} chunks, {doc.n_pages} pages, "
60
+ f"hash {doc.content_hash}, heading-split={doc.used_heading_split}"
61
+ )
62
+
63
+ pairs = abbrev_pairs(doc.chunks)
64
+ rules = rule_candidates(doc.chunks)
65
+ print(f"[filter] {len(pairs)} abbreviation pairs, {len(rules)} rule candidates")
66
+ for pair in pairs[:8]:
67
+ print(f" {pair.abbrev} -> {pair.expansion}")
68
+
69
+ mentions = _load_mentions(args.mentions) if args.mentions else _from_pairs(doc, pairs)
70
+ print(f"[filter] {len(mentions)} mentions in")
71
+
72
+ clustered = cluster_mentions(mentions, pairs, doc.doc_id)
73
+ print(
74
+ f"[cluster] {clustered.n_mentions} mentions -> {clustered.n_clusters} clusters "
75
+ f"(compression {clustered.compression_ratio}x)"
76
+ )
77
+
78
+ rank_evidence(clustered.clusters, doc.chunks)
79
+ print(f"[rank ] evidence ranked, K={EVIDENCE_K}")
80
+ for cluster in clustered.clusters[:10]:
81
+ top = cluster.evidence_chunk_ids[:EVIDENCE_K]
82
+ print(
83
+ f" {cluster.canonical:<28} mentions={cluster.mention_count:<4} "
84
+ f"evidence={len(cluster.evidence_chunk_ids)} top={top}"
85
+ )
86
+
87
+ args.out_dir.mkdir(parents=True, exist_ok=True)
88
+ _dump(args.out_dir / f"{doc.doc_id}.chunks.json", doc.model_dump(mode="json"))
89
+ _dump(
90
+ args.out_dir / f"{doc.doc_id}.filters.json",
91
+ {
92
+ "doc_id": doc.doc_id,
93
+ "abbrev_pairs": [p.model_dump(mode="json") for p in pairs],
94
+ "rule_candidates": [r.model_dump(mode="json") for r in rules],
95
+ },
96
+ )
97
+ _dump(args.out_dir / f"{doc.doc_id}.clusters.json", clustered.model_dump(mode="json"))
98
+ print(f"[write ] {args.out_dir}")
99
+ return 0
100
+
101
+
102
+ def _load_mentions(path: Path) -> list[Mention]:
103
+ raw = json.loads(path.read_text(encoding="utf-8"))
104
+ items = raw.get("mentions", raw) if isinstance(raw, dict) else raw
105
+ return [Mention.model_validate(m) for m in items]
106
+
107
+
108
+ def _from_pairs(doc, pairs) -> list[Mention]:
109
+ """Stand-in mentions from legend abbreviations, so the wiring is runnable
110
+ before the span filter lands.
111
+
112
+ NOT a recall measurement — it only sees terms a legend block already named.
113
+
114
+ Word-boundary matching, never substring: "PA" occurs inside "parameter",
115
+ "pada", "capacity" and "composite", and substring matching produced 126
116
+ spurious PA mentions on a 9-page document (77x compression instead of the
117
+ measured 2.56x). Same trap the evidence ranker documents for headings.
118
+ """
119
+ surfaces = {p.abbrev for p in pairs} | {p.expansion for p in pairs}
120
+ patterns = [
121
+ (s, re.compile(rf"(?<!\w){re.escape(s)}(?!\w)", re.IGNORECASE))
122
+ for s in surfaces
123
+ ]
124
+ out: list[Mention] = []
125
+ for chunk in doc.chunks:
126
+ for surface, pattern in patterns:
127
+ for match in pattern.finditer(chunk.text):
128
+ out.append(
129
+ Mention(
130
+ surface=surface,
131
+ chunk_id=chunk.chunk_id,
132
+ char_start=match.start(),
133
+ char_end=match.end(),
134
+ label="legend",
135
+ score=1.0,
136
+ )
137
+ )
138
+ return out
139
+
140
+
141
+ def _dump(path: Path, payload) -> None:
142
+ path.write_text(
143
+ json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
144
+ )
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())
src/knowledge_extraction/rank/evidence.py CHANGED
@@ -34,18 +34,26 @@ def rank_evidence(
34
  and `evidence_scores`. Keeps the FULL ranked list, not just the top K."""
35
  w = {**EVIDENCE_WEIGHTS, **(weights or {})}
36
  by_id = {c.chunk_id: c for c in chunks}
 
 
 
 
37
  first_seen: set[str] = set()
38
 
39
  for cluster in clusters:
 
 
 
 
40
  scored: list[tuple[float, str]] = []
41
- for chunk_id in {m.chunk_id for m in cluster.mentions}:
42
  chunk = by_id.get(chunk_id)
43
  if chunk is None:
44
  continue
45
  positions = [m for m in cluster.mentions if m.chunk_id == chunk_id]
46
  scored.append((_score_chunk(chunk, cluster, positions, w, first_seen), chunk_id))
47
- # Tie-break on chunk_id so ranking is stable across runs.
48
- scored.sort(key=lambda t: (-t[0], t[1]))
49
  cluster.evidence_chunk_ids = [cid for _, cid in scored]
50
  cluster.evidence_scores = [round(s, 2) for s, _ in scored]
51
 
@@ -78,24 +86,38 @@ def _score_chunk(
78
  ) -> float:
79
  score = 0.0
80
 
81
- cues = definitional_hits(chunk.text)
82
- for mention in positions:
83
- if any(
84
- abs(cs - mention.char_start) <= CUE_PROXIMITY_CHARS
85
- or abs(ce - mention.char_end) <= CUE_PROXIMITY_CHARS
86
- for cs, ce in cues
87
- ):
88
- score += w["definitional_cue_near"]
89
- break
90
-
91
  # Word-boundary match, NOT substring. "PA" is a substring of "Parameter",
92
  # "pada" and "composite", so substring matching handed this bonus to almost
93
  # every Indonesian heading and pushed real definition sections below
94
  # formula tables.
95
  heading = normalize(chunk.heading or "")
96
- if heading and any(_word_match(normalize(v), heading) for v in cluster.variants):
 
 
 
97
  score += w["term_in_heading"]
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  legend_lines = find_legend_lines(chunk.text)
100
  if legend_lines:
101
  line_starts = _line_starts(chunk.text)
 
34
  and `evidence_scores`. Keeps the FULL ranked list, not just the top K."""
35
  w = {**EVIDENCE_WEIGHTS, **(weights or {})}
36
  by_id = {c.chunk_id: c for c in chunks}
37
+ # Document order, so "first occurrence" means the term's first appearance in
38
+ # the document — not whichever element a set happened to yield first, which
39
+ # would hand the +1.0 to an arbitrary chunk and make ranking unreproducible.
40
+ doc_order = {c.chunk_id: i for i, c in enumerate(chunks)}
41
  first_seen: set[str] = set()
42
 
43
  for cluster in clusters:
44
+ chunk_ids = sorted(
45
+ {m.chunk_id for m in cluster.mentions},
46
+ key=lambda cid: (doc_order.get(cid, len(chunks)), cid),
47
+ )
48
  scored: list[tuple[float, str]] = []
49
+ for chunk_id in chunk_ids:
50
  chunk = by_id.get(chunk_id)
51
  if chunk is None:
52
  continue
53
  positions = [m for m in cluster.mentions if m.chunk_id == chunk_id]
54
  scored.append((_score_chunk(chunk, cluster, positions, w, first_seen), chunk_id))
55
+ # Tie-break on document order so ranking is stable across runs.
56
+ scored.sort(key=lambda t: (-t[0], doc_order.get(t[1], len(chunks)), t[1]))
57
  cluster.evidence_chunk_ids = [cid for _, cid in scored]
58
  cluster.evidence_scores = [round(s, 2) for s, _ in scored]
59
 
 
86
  ) -> float:
87
  score = 0.0
88
 
 
 
 
 
 
 
 
 
 
 
89
  # Word-boundary match, NOT substring. "PA" is a substring of "Parameter",
90
  # "pada" and "composite", so substring matching handed this bonus to almost
91
  # every Indonesian heading and pushed real definition sections below
92
  # formula tables.
93
  heading = normalize(chunk.heading or "")
94
+ term_in_heading = bool(heading) and any(
95
+ _word_match(normalize(v), heading) for v in cluster.variants
96
+ )
97
+ if term_in_heading:
98
  score += w["term_in_heading"]
99
 
100
+ # Cue proximity. When the heading names the term, the heading counts as a
101
+ # mention at offset 0.
102
+ #
103
+ # Indonesian standards are written "2.1.3 Physical of Availability (PA)" /
104
+ # "Adalah ketersediaan fisik..." — the heading names the term and the body
105
+ # opens with the cue WITHOUT repeating it. Measured on the BUMA standard:
106
+ # without this, the PA definition section scored no cue bonus at all and
107
+ # ranked below a computation section that merely mentions PA often. The
108
+ # section's own definition is the single most valuable piece of evidence
109
+ # there is, so losing the signal there defeats the ranking.
110
+ cues = definitional_hits(chunk.text)
111
+ anchors = [(m.char_start, m.char_end) for m in positions]
112
+ if term_in_heading:
113
+ anchors.append((0, 0))
114
+ if any(
115
+ abs(cs - start) <= CUE_PROXIMITY_CHARS or abs(ce - end) <= CUE_PROXIMITY_CHARS
116
+ for cs, ce in cues
117
+ for start, end in anchors
118
+ ):
119
+ score += w["definitional_cue_near"]
120
+
121
  legend_lines = find_legend_lines(chunk.text)
122
  if legend_lines:
123
  line_starts = _line_starts(chunk.text)