Rifqi Hafizuddin Claude Fable 5 commited on
Commit
ab5ea78
·
1 Parent(s): 3a01634

[NOTICKET] feat(knowledge_extraction): paid extraction stage + validate, diff, queue

Browse files

Completes every stage that does not depend on the seam. The pipeline now runs
end to end: artifact -> filters -> cluster -> rank -> LLM -> validate -> diff ->
frequency-sorted review queue.

extract/ client on the nano __54n quad; structured output is PROBED not assumed
(json_schema, falling back to json_object + validate-retry, recording
which applied); cached tokens read from the API, never modelled. Four
branches, one call per TERM CLUSTER. Prompts stay as files so the
fixed prefix is byte-identical and caching keeps engaging.
validate/ span check (whitespace-only normalisation, never repairs, unlocatable
provenance rejects every guarded field), escalation, conflict
detection by token overlap — explainable to the reviewer, and it
never picks a winner.
diff/ new / duplicate / conflicting against an explicitly supplied active
glossary, so the duplicate and conflicting paths are reachable. The
prototype diffed against the file it then overwrote and only ever
exercised the empty baseline.
queue/ frequency-sorted, conflicts promoted; every row carries page, section
and the verbatim span so review is quote-vs-page.
service.py facade, cli.py gains --dry-run, --limit, --mock, --active-glossary.

R1 FIXED — the literal-source-wording decision now holds, live-verified:
- source_wording is taken DETERMINISTICALLY from the document's own section
heading. Asked to supply it, the model returned "Physical Availability (PA)"
— a real verbatim quote from the page-1 intro, but not the section heading
"Physical OF Availability (PA)". Both occur; only the heading form reveals
they disagree.
- evidence_text now includes headings, so quoting a section title is no longer
wrongly rejected (2 spurious rejections on a 3-entry pilot -> 0).
- full_name and source_wording are span-checked against the source themselves;
a normalised "full name" is the silent correction we exist to surface.
- the queue flags the mismatch: PA now ranks as "source wording differs from
the expanded name — confirm which is correct".

Verification: ruff clean on touched paths; import main OK; full suite 488
passed, 7 skipped (was 473 + 15 new local tests). Mock run exercises all 89
calls with no spend. Live pilot 3 calls / 10.1s / 7,168 of 8,029 prompt tokens
cached / 0 span rejections, on the nano deployment.

Measured cache-floor finding recorded in the calibration doc: only the glossary
prefix (1,401 tokens) clears the 1024 floor; rule/formula/summary cache nothing.
Glossary carries 66 of 83 calls, so the padding is on the branch that matters.

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

KNOWLEDGE_PIPELINE_CALIBRATION.md CHANGED
@@ -207,3 +207,14 @@ and record the delta rather than assuming the improvement carries.
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.
 
 
 
 
 
 
 
 
 
 
 
 
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.
210
+
211
+ | **V4** | **`source_wording` is taken from the section heading, not from the model** | Asked to quote the source wording, the model returned "Physical Availability (PA)" — a genuine verbatim quote from the page-1 intro, but not the §2.1.3 heading "Physical **of** Availability (PA)". Both occur in the document; only the heading form reveals the disagreement. Taking it deterministically means it cannot be normalised away | Live-verified on the pilot: the entry now carries both forms and the queue flags the mismatch |
212
+ | **V5** | **`evidence_text` includes chunk headings** | The heading is part of the source document and is often the only place a term is formally named. Excluding it rejected a correct verbatim quotation of the document's own section title (2 rejections on a 3-entry pilot, both spurious) | Live-verified: 0 rejections after the change |
213
+ | **V6** | **`full_name` and `source_wording` are span-checked against the source themselves** | Both claim to be literal transcriptions. The provenance span passing does not prove the transcription is faithful, and a normalised "full name" is exactly the silent correction this pipeline exists to surface | Unit-tested |
214
+
215
+ **Cache-floor finding (measured on the real prompts):** only the **glossary**
216
+ prefix clears the 1024-token floor at **1,401 tokens**. `rule` (731), `formula`
217
+ (459) and `summary` (288) are **not cacheable** and cache nothing today. Glossary
218
+ is also the branch with by far the most calls (66 of 83 on the reference
219
+ document), so the padding is on the branch that matters — but padding the other
220
+ three is free money if their call counts ever grow.
KNOWLEDGE_PIPELINE_TODO.md CHANGED
@@ -153,14 +153,14 @@ envelope shape proposed in §3 — so S1 can settle either way without touching
153
  | **X7** | Section pass → summary units | ✅ | ⬜ | The quiet cost risk: few calls but ~¼ of all input tokens, because summarisation cannot be filtered — it needs whole documents |
154
  | **X8** | Normalise + cluster mentions | ✅ **2.56×** | ✅ | `cluster/`. Constants carried from P2 with their reasons. Locked by tests: PA/UA never merge; abbreviation↔expansion merges only with legend pairs; noise surfaces dropped as whole forms only |
155
  | **X9** | Evidence ranking → top-K | ✅ | ✅ | `rank/evidence.py`, six signals + tabular penalty. **Verified on the real document: every term's top-ranked chunk is its own definition section** — PA→2.1.3, UA→2.1.4, Qty→2.1.2, Pty→2.1.5. Full ranked list retained; word-boundary heading matching locked by test |
156
- | **X10** | LLM extraction call | ✅ | | One call per term cluster, plus one per cue chunk, per unique formula, per document. Keep prompts as files with a byte-identical cached prefix |
157
- | **X11** | Verbatim-span validation | ✅ **1.00** | | A field whose span cannot be located is rejected, **never repaired** a repaired span is an unfalsifiable claim, which is what the control exists to prevent |
158
- | **X12** | Null-definition escalation | 🔎 | | Built in the prototype but **never fired**: 54 of 66 clusters had exactly 1 evidence chunk against K=3, so the loop correctly had nowhere to escalate. Unverified until a larger corpus runs |
159
- | **X13** | Conflict detection | 🔎 | | Built but **0 conflicts found** — one internally-consistent standard gives it nothing. Untested against real disagreement. Note it only works *because* all evidence for a term arrives in one call |
160
- | **X14** | Diff vs. active glossary version | 🔎 | | The prototype diffed against the file it then overwrote, so only the empty-baseline path ever ran and every entry came back `new`. v2 needs a real active-version pointer |
161
- | **X15** | Frequency-sorted review queue | ✅ | | Sorted by mention count, conflicts promoted above frequency. Prototype queue topped out Qty(20) · PA(16) · PTY(12) · UA(11) exactly the terms carrying the document |
162
  | **X16** | Bulk vs. incremental ingest | ⬜ | ⬜ | Bulk setup ingest (corpus-frequency statistics as a candidate booster) and incremental single-file add (no corpus context; diff against the active version). Neither exists |
163
- | **X17** | Persistence | ⬜ | | Prototype wrote JSON files. v2 needs tablessee D2 |
164
 
165
  ---
166
 
@@ -182,7 +182,7 @@ envelope shape proposed in §3 — so S1 can settle either way without touching
182
 
183
  | # | Finding | Severity | Detail |
184
  |---|---|---|---|
185
- | **R1** | **Literal source wording is normalised away** | **High** | A locked decision says the pipeline records what the document actually says and surfaces the discrepancy to the expert. It does not. The BUMA heading reads **"2.1.3. Physical of Availability (PA)"**; the extracted entry carries `full_name: "Physical Availability"` silently corrected. Mechanism: the heading is a separate field from the chunk text, and the chunk text does not contain the phrase, so the literal wording never reaches the model. Fix shape: feed the heading verbatim + add the span-checked `source_wording` field (S4) |
186
  | **R2** | **Prototype is not under version control** | **High** | Addressed by P1 |
187
  | **R3** | **85% abstention** | Medium | 56 of 66 entries carry no definition. Correct behaviour — for a term the document never defines, null *is* the right answer — but it means the review queue is mostly "term found, no definition in document". Whether that is useful to Mas Beta or noise is a **review-experience question to put to him**, and review experience is explicitly where engineering time is worth spending |
188
  | **R4** | **Everything generalises from one 9-page document** | Medium | Single document, single language, single company. E2's compression, E3's scoreable base and the escalation path are all constrained by it |
 
153
  | **X7** | Section pass → summary units | ✅ | ⬜ | The quiet cost risk: few calls but ~¼ of all input tokens, because summarisation cannot be filtered — it needs whole documents |
154
  | **X8** | Normalise + cluster mentions | ✅ **2.56×** | ✅ | `cluster/`. Constants carried from P2 with their reasons. Locked by tests: PA/UA never merge; abbreviation↔expansion merges only with legend pairs; noise surfaces dropped as whole forms only |
155
  | **X9** | Evidence ranking → top-K | ✅ | ✅ | `rank/evidence.py`, six signals + tabular penalty. **Verified on the real document: every term's top-ranked chunk is its own definition section** — PA→2.1.3, UA→2.1.4, Qty→2.1.2, Pty→2.1.5. Full ranked list retained; word-boundary heading matching locked by test |
156
+ | **X10** | LLM extraction call | ✅ | | `extract/` client (nano `__54n`, probed structured output, API-sourced cached tokens), 4 branches, prompts as files. **Live pilot: 3 calls, 10.1s, 7,168 of 8,029 prompt tokens cached (89%)**, correct ID definitions + tags, 0 span rejections. `--dry-run` and `--limit` for cost control; `--mock` runs the whole pipeline with no spend |
157
+ | **X11** | Verbatim-span validation | ✅ **1.00** | | `validate/span_check.py`. Whitespace-only normalisation; unlocatable provenance rejects every guarded field; never repairs. **Extended:** `full_name`/`source_wording` are checked against the source themselves, so a silent normalisation is caught rather than stored |
158
+ | **X12** | Null-definition escalation | 🔎 | | `validate/escalate.py` + the glossary loop. Still **unexercised on this document** most clusters have too little evidence to escalate to, which is a property of a 9-page corpus, not a defect |
159
+ | **X13** | Conflict detection | 🔎 | | `validate/conflict.py`, token overlap (explainable to the reviewer, unlike embeddings); never picks a winner. Unit-tested both ways; still **0 conflicts on real data** — one consistent standard gives it nothing to find. Works only because clustering puts all evidence in one call |
160
+ | **X14** | Diff vs. active glossary version | 🔎 | | `diff/glossary_diff.py`. The baseline is passed in explicitly (`--active-glossary`) rather than read from wherever the last run wrote, so the duplicate and conflicting paths are reachable all three verified by test |
161
+ | **X15** | Frequency-sorted review queue | ✅ | | `queue/review_queue.py`. Each row carries page, section and the verbatim span so review is quote-vs-page. Gained a review reason for **wording discrepancies**, which is how the literal-wording decision reaches the expert |
162
  | **X16** | Bulk vs. incremental ingest | ⬜ | ⬜ | Bulk setup ingest (corpus-frequency statistics as a candidate booster) and incremental single-file add (no corpus context; diff against the active version). Neither exists |
163
+ | **X17** | Persistence | ⬜ | 🔄 | Stage artifacts are written as JSON by the CLI, which is enough for the offline path. Tables still needed → D2 |
164
 
165
  ---
166
 
 
182
 
183
  | # | Finding | Severity | Detail |
184
  |---|---|---|---|
185
+ | **R1** | ~~Literal source wording is normalised away~~ | **FIXED** | **Resolved 2026-08-19 in v2, live-verified.** Three parts: `source_wording` on the entry, taken **deterministically from the document's own section heading** rather than left to the model (which quoted the normalised page-1 form instead); `evidence_text` now includes headings, so quoting a section title is not wrongly rejected; and the review queue gained a reason for the mismatch. On the live pilot PA carries `full_name="Physical Availability"` **and** `source_wording="Physical of Availability (PA)"`, and ranks as *"source wording differs from the expanded name confirm which is correct"* the discrepancy reaches the expert instead of being silently corrected |
186
  | **R2** | **Prototype is not under version control** | **High** | Addressed by P1 |
187
  | **R3** | **85% abstention** | Medium | 56 of 66 entries carry no definition. Correct behaviour — for a term the document never defines, null *is* the right answer — but it means the review queue is mostly "term found, no definition in document". Whether that is useful to Mas Beta or noise is a **review-experience question to put to him**, and review experience is explicitly where engineering time is worth spending |
188
  | **R4** | **Everything generalises from one 9-page document** | Medium | Single document, single language, single company. E2's compression, E3's scoreable base and the escalation path are all constrained by it |
src/knowledge_extraction/__init__.py CHANGED
@@ -8,10 +8,12 @@ Stage order, and which stages cost money:
8
  filters cue / legend / span NER -> mentions free (CPU)
9
  cluster normalise + cluster mentions free
10
  rank evidence scoring, top-K selection free
11
- extract one LLM call per TERM CLUSTER PAID (not yet built)
12
- validate verbatim span check, escalation free (not yet built)
13
- diff new / duplicate / conflicting free (not yet built)
14
- queue frequency-sorted review queue free (not yet built)
 
 
15
 
16
  Design rationale: knowledge_pipeline_context.md
17
  Calibrated constants and why: KNOWLEDGE_PIPELINE_CALIBRATION.md
@@ -20,27 +22,41 @@ Calibrated constants and why: KNOWLEDGE_PIPELINE_CALIBRATION.md
20
  from .adapter import parsed_doc_from_artifact
21
  from .models import (
22
  AbbrevPair,
 
 
23
  Chunk,
24
  ClusterResult,
25
  FilterResult,
 
26
  GlossaryEntry,
27
  Mention,
28
  ParsedDoc,
29
  Provenance,
30
  RuleCandidate,
 
31
  TermCluster,
32
  )
 
33
 
34
  __all__ = [
35
  "AbbrevPair",
 
 
36
  "Chunk",
37
  "ClusterResult",
 
38
  "FilterResult",
 
39
  "GlossaryEntry",
40
  "Mention",
41
  "ParsedDoc",
42
  "Provenance",
43
  "RuleCandidate",
 
44
  "TermCluster",
 
 
 
45
  "parsed_doc_from_artifact",
 
46
  ]
 
8
  filters cue / legend / span NER -> mentions free (CPU)
9
  cluster normalise + cluster mentions free
10
  rank evidence scoring, top-K selection free
11
+ extract one LLM call per TERM CLUSTER PAID
12
+ validate verbatim span check, escalation free
13
+ diff new / duplicate / conflicting free
14
+ queue frequency-sorted review queue free
15
+
16
+ `service.py` is the facade; `cli.py` is the operator entry point.
17
 
18
  Design rationale: knowledge_pipeline_context.md
19
  Calibrated constants and why: KNOWLEDGE_PIPELINE_CALIBRATION.md
 
22
  from .adapter import parsed_doc_from_artifact
23
  from .models import (
24
  AbbrevPair,
25
+ BriefContext,
26
+ CallUsage,
27
  Chunk,
28
  ClusterResult,
29
  FilterResult,
30
+ FormulaEntry,
31
  GlossaryEntry,
32
  Mention,
33
  ParsedDoc,
34
  Provenance,
35
  RuleCandidate,
36
+ RuleEntry,
37
  TermCluster,
38
  )
39
+ from .service import ExtractionResult, build_clusters, estimate_cost, extract_all, run_filters
40
 
41
  __all__ = [
42
  "AbbrevPair",
43
+ "BriefContext",
44
+ "CallUsage",
45
  "Chunk",
46
  "ClusterResult",
47
+ "ExtractionResult",
48
  "FilterResult",
49
+ "FormulaEntry",
50
  "GlossaryEntry",
51
  "Mention",
52
  "ParsedDoc",
53
  "Provenance",
54
  "RuleCandidate",
55
+ "RuleEntry",
56
  "TermCluster",
57
+ "build_clusters",
58
+ "estimate_cost",
59
+ "extract_all",
60
  "parsed_doc_from_artifact",
61
+ "run_filters",
62
  ]
src/knowledge_extraction/cli.py CHANGED
@@ -1,4 +1,4 @@
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
@@ -6,21 +6,27 @@ 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
@@ -33,23 +39,42 @@ 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, extract_mentions, 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(
47
  "--no-span-filter",
48
  action="store_true",
49
- help="skip the span model; use legend terms only (fast wiring check, NOT a recall run)",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  )
51
- parser.add_argument("--out-dir", type=Path, default=Path("out/knowledge"))
52
- parser.add_argument("--doc-id", help="override the artifact's doc_id")
53
  args = parser.parse_args(argv)
54
 
55
  if not args.artifact.exists():
@@ -57,67 +82,129 @@ def main(argv: list[str] | None = None) -> int:
57
  return 2
58
 
59
  raw = json.loads(args.artifact.read_text(encoding="utf-8"))
60
- doc = parsed_doc_from_artifact(
61
- raw, doc_id=args.doc_id, source_ref=str(args.artifact)
62
- )
63
  print(
64
  f"[parse ] {doc.doc_id}: {len(doc.chunks)} chunks, {doc.n_pages} pages, "
65
  f"hash {doc.content_hash}, heading-split={doc.used_heading_split}"
66
  )
67
 
68
- pairs = abbrev_pairs(doc.chunks)
69
- rules = rule_candidates(doc.chunks)
70
- print(f"[filter] {len(pairs)} abbreviation pairs, {len(rules)} rule candidates")
71
- for pair in pairs[:8]:
72
- print(f" {pair.abbrev} -> {pair.expansion}")
73
-
74
  if args.mentions:
75
- mentions = _load_mentions(args.mentions)
76
  source = "file"
77
  elif args.no_span_filter:
78
- mentions = _from_pairs(doc, pairs)
79
  source = "legend stand-in (NOT a recall run)"
80
  else:
81
- mentions = extract_mentions(doc.chunks)
82
  source = "span filter"
83
- capped = sum(m.hit_span_cap for m in mentions)
 
84
  cap_note = f", {capped} hit the span cap" if capped else ""
85
- print(f"[filter] {len(mentions)} mentions from {source}{cap_note}")
 
 
 
 
86
 
87
- clustered = cluster_mentions(mentions, pairs, doc.doc_id)
 
 
 
 
88
  print(
89
  f"[cluster] {clustered.n_mentions} mentions -> {clustered.n_clusters} clusters "
90
  f"(compression {clustered.compression_ratio}x)"
91
  )
92
-
93
- rank_evidence(clustered.clusters, doc.chunks)
94
- print(f"[rank ] evidence ranked, K={EVIDENCE_K}")
95
- for cluster in clustered.clusters[:10]:
96
- top = cluster.evidence_chunk_ids[:EVIDENCE_K]
97
  print(
98
- f" {cluster.canonical:<28} mentions={cluster.mention_count:<4} "
99
- f"evidence={len(cluster.evidence_chunk_ids)} top={top}"
 
100
  )
101
 
102
  args.out_dir.mkdir(parents=True, exist_ok=True)
103
  _dump(args.out_dir / f"{doc.doc_id}.chunks.json", doc.model_dump(mode="json"))
104
- _dump(
105
- args.out_dir / f"{doc.doc_id}.filters.json",
106
- {
107
- "doc_id": doc.doc_id,
108
- "abbrev_pairs": [p.model_dump(mode="json") for p in pairs],
109
- "rule_candidates": [r.model_dump(mode="json") for r in rules],
110
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  )
112
- _dump(
113
- args.out_dir / f"{doc.doc_id}.mentions.json",
114
- {"doc_id": doc.doc_id, "mentions": [m.model_dump(mode="json") for m in mentions]},
 
 
 
 
 
 
115
  )
116
- _dump(args.out_dir / f"{doc.doc_id}.clusters.json", clustered.model_dump(mode="json"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  print(f"[write ] {args.out_dir}")
118
  return 0
119
 
120
 
 
 
 
 
 
 
 
 
 
 
 
121
  def _load_mentions(path: Path) -> list[Mention]:
122
  raw = json.loads(path.read_text(encoding="utf-8"))
123
  items = raw.get("mentions", raw) if isinstance(raw, dict) else raw
@@ -126,7 +213,7 @@ def _load_mentions(path: Path) -> list[Mention]:
126
 
127
  def _from_pairs(doc, pairs) -> list[Mention]:
128
  """Stand-in mentions from legend abbreviations, so the wiring is runnable
129
- before the span filter lands.
130
 
131
  NOT a recall measurement — it only sees terms a legend block already named.
132
 
@@ -137,8 +224,7 @@ def _from_pairs(doc, pairs) -> list[Mention]:
137
  """
138
  surfaces = {p.abbrev for p in pairs} | {p.expansion for p in pairs}
139
  patterns = [
140
- (s, re.compile(rf"(?<!\w){re.escape(s)}(?!\w)", re.IGNORECASE))
141
- for s in surfaces
142
  ]
143
  out: list[Mention] = []
144
  for chunk in doc.chunks:
@@ -158,9 +244,7 @@ def _from_pairs(doc, pairs) -> list[Mention]:
158
 
159
 
160
  def _dump(path: Path, payload) -> None:
161
- path.write_text(
162
- json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
163
- )
164
 
165
 
166
  if __name__ == "__main__":
 
1
+ """Offline runner for the knowledge-extraction pipeline.
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
 
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 the span filter is the slow part.
 
 
 
11
 
12
+ # free stages only (default) — no API calls, no spend
13
  uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json>
14
+
15
+ # cost estimate before spending anything
16
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --dry-run
17
+
18
+ # small pilot, then the full run
19
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --extract --limit 5
20
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --extract
21
+
22
+ # exercise the wiring with no credentials and no spend
23
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --extract --mock
24
 
25
  Lives inside the package rather than in `scripts/`, which is gitignored: this
26
  runner is the pipeline's operator entry point and has to ship with the module.
27
 
28
+ **Always --dry-run before a corpus-scale run.** It builds the exact prompts,
29
+ prints the token estimate, and makes zero API calls.
 
30
  """
31
 
32
  from __future__ import annotations
 
39
 
40
  from .adapter import parsed_doc_from_artifact
41
  from .cluster import cluster_mentions
42
+ from .extract import MockExtractor, cacheable, prefix_tokens
43
  from .models import Mention
44
  from .rank import rank_evidence
45
+ from .service import build_clusters, estimate_cost, extract_all, run_filters
46
  from .settings import EVIDENCE_K
47
 
48
+ BRANCHES = ("glossary", "rule", "formula", "summary")
49
+
50
 
51
  def main(argv: list[str] | None = None) -> int:
52
  parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
53
  parser.add_argument("artifact", type=Path, help="parsed-document artifact JSON")
54
+ parser.add_argument("--out-dir", type=Path, default=Path("out/knowledge"))
55
+ parser.add_argument("--doc-id", help="override the artifact's doc_id")
56
  parser.add_argument("--mentions", type=Path, help="span-NER mentions JSON")
57
  parser.add_argument(
58
  "--no-span-filter",
59
  action="store_true",
60
+ help="skip the span model; legend terms only (wiring check, NOT a recall run)",
61
+ )
62
+ parser.add_argument(
63
+ "--extract", action="store_true", help="run the PAID extraction stage"
64
+ )
65
+ parser.add_argument(
66
+ "--dry-run",
67
+ action="store_true",
68
+ help="build the prompts and print a token estimate; makes no API calls",
69
+ )
70
+ parser.add_argument("--mock", action="store_true", help="mock extractor: no network, no spend")
71
+ parser.add_argument("--limit", type=int, help="cap the number of items per branch (pilot)")
72
+ parser.add_argument(
73
+ "--branches", nargs="+", choices=BRANCHES, default=list(BRANCHES)
74
+ )
75
+ parser.add_argument(
76
+ "--active-glossary", type=Path, help="approved glossary to diff against"
77
  )
 
 
78
  args = parser.parse_args(argv)
79
 
80
  if not args.artifact.exists():
 
82
  return 2
83
 
84
  raw = json.loads(args.artifact.read_text(encoding="utf-8"))
85
+ doc = parsed_doc_from_artifact(raw, doc_id=args.doc_id, source_ref=str(args.artifact))
 
 
86
  print(
87
  f"[parse ] {doc.doc_id}: {len(doc.chunks)} chunks, {doc.n_pages} pages, "
88
  f"hash {doc.content_hash}, heading-split={doc.used_heading_split}"
89
  )
90
 
91
+ filtered = run_filters(doc, use_span_filter=not (args.no_span_filter or args.mentions))
 
 
 
 
 
92
  if args.mentions:
93
+ filtered.mentions = _load_mentions(args.mentions)
94
  source = "file"
95
  elif args.no_span_filter:
96
+ filtered.mentions = _from_pairs(doc, filtered.abbrev_pairs)
97
  source = "legend stand-in (NOT a recall run)"
98
  else:
 
99
  source = "span filter"
100
+
101
+ capped = sum(m.hit_span_cap for m in filtered.mentions)
102
  cap_note = f", {capped} hit the span cap" if capped else ""
103
+ print(
104
+ f"[filter] {len(filtered.abbrev_pairs)} abbreviation pairs, "
105
+ f"{len(filtered.rule_candidates)} rule candidates"
106
+ )
107
+ print(f"[filter] {len(filtered.mentions)} mentions from {source}{cap_note}")
108
 
109
+ if args.mentions or args.no_span_filter:
110
+ clustered = cluster_mentions(filtered.mentions, filtered.abbrev_pairs, doc.doc_id)
111
+ rank_evidence(clustered.clusters, doc.chunks)
112
+ else:
113
+ clustered = build_clusters(doc, filtered)
114
  print(
115
  f"[cluster] {clustered.n_mentions} mentions -> {clustered.n_clusters} clusters "
116
  f"(compression {clustered.compression_ratio}x)"
117
  )
118
+ for cluster in clustered.clusters[:8]:
 
 
 
 
119
  print(
120
+ f" {cluster.canonical:<26} mentions={cluster.mention_count:<4} "
121
+ f"evidence={len(cluster.evidence_chunk_ids)} "
122
+ f"top={cluster.evidence_chunk_ids[:EVIDENCE_K]}"
123
  )
124
 
125
  args.out_dir.mkdir(parents=True, exist_ok=True)
126
  _dump(args.out_dir / f"{doc.doc_id}.chunks.json", doc.model_dump(mode="json"))
127
+ _dump(args.out_dir / f"{doc.doc_id}.filters.json", filtered.model_dump(mode="json"))
128
+ _dump(args.out_dir / f"{doc.doc_id}.clusters.json", clustered.model_dump(mode="json"))
129
+
130
+ if args.dry_run:
131
+ est = estimate_cost(doc, clustered, filtered, args.limit)
132
+ print("[dry-run] NO API CALLS MADE")
133
+ for key, value in est.items():
134
+ print(f" {key}: {value}")
135
+ for branch in args.branches:
136
+ print(
137
+ f" prefix[{branch}]: {prefix_tokens(branch)} tokens, "
138
+ f"cacheable={cacheable(branch)}"
139
+ )
140
+ return 0
141
+
142
+ if not args.extract:
143
+ print(f"[write ] {args.out_dir} (free stages only; --extract to run the paid stage)")
144
+ return 0
145
+
146
+ extractor = MockExtractor() if args.mock else _azure_extractor()
147
+ if extractor is None:
148
+ return 3
149
+ active = (
150
+ json.loads(args.active_glossary.read_text(encoding="utf-8"))
151
+ if args.active_glossary
152
+ else []
153
  )
154
+
155
+ result = extract_all(
156
+ doc,
157
+ clustered,
158
+ filtered,
159
+ extractor,
160
+ limit=args.limit,
161
+ active_glossary=active,
162
+ branches=tuple(args.branches),
163
  )
164
+
165
+ prompt, cached, completion = result.total_tokens
166
+ simulated = " [SIMULATED — not a quality measurement]" if args.mock else ""
167
+ print(f"[extract] {len(result.usages)} calls{simulated}")
168
+ print(
169
+ f" glossary={len(result.glossary)} rules={len(result.rules)} "
170
+ f"formulas={len(result.formulas)}"
171
+ )
172
+ print(f" tokens prompt={prompt} cached={cached} completion={completion}")
173
+ print(f" fields rejected by span check: {len(result.rejected)}")
174
+ no_def = sum(1 for e in result.glossary if e.get("extraction_status") == "no_definition_found")
175
+ print(f" abstained (no definition in document): {no_def}/{len(result.glossary)}")
176
+
177
+ print("[queue ] top of the review queue:")
178
+ for row in result.review_queue[:10]:
179
+ term = str(row.get("term"))[:26]
180
+ print(
181
+ f" {row['rank']:>3}. {term:<26} "
182
+ f"n={row['mention_count']:<4} {row['review_reason']}"
183
+ )
184
+
185
+ _dump(args.out_dir / "glossary.json", result.glossary)
186
+ _dump(args.out_dir / "interpretation_pack.json", result.rules)
187
+ _dump(args.out_dir / "formulas.json", result.formulas)
188
+ _dump(args.out_dir / "review_queue.json", result.review_queue)
189
+ _dump(args.out_dir / "rejected.json", [r.model_dump(mode="json") for r in result.rejected])
190
+ if result.brief:
191
+ _dump(args.out_dir / "brief_context.json", result.brief)
192
+ _dump(args.out_dir / "usage.json", [u.model_dump(mode="json") for u in result.usages])
193
  print(f"[write ] {args.out_dir}")
194
  return 0
195
 
196
 
197
+ def _azure_extractor():
198
+ from .extract import AzureExtractor
199
+
200
+ try:
201
+ return AzureExtractor()
202
+ except Exception as exc:
203
+ print(f"cannot build the Azure client: {exc}", file=sys.stderr)
204
+ print("use --mock to exercise the pipeline without credentials", file=sys.stderr)
205
+ return None
206
+
207
+
208
  def _load_mentions(path: Path) -> list[Mention]:
209
  raw = json.loads(path.read_text(encoding="utf-8"))
210
  items = raw.get("mentions", raw) if isinstance(raw, dict) else raw
 
213
 
214
  def _from_pairs(doc, pairs) -> list[Mention]:
215
  """Stand-in mentions from legend abbreviations, so the wiring is runnable
216
+ without the span model.
217
 
218
  NOT a recall measurement — it only sees terms a legend block already named.
219
 
 
224
  """
225
  surfaces = {p.abbrev for p in pairs} | {p.expansion for p in pairs}
226
  patterns = [
227
+ (s, re.compile(rf"(?<!\w){re.escape(s)}(?!\w)", re.IGNORECASE)) for s in surfaces
 
228
  ]
229
  out: list[Mention] = []
230
  for chunk in doc.chunks:
 
244
 
245
 
246
  def _dump(path: Path, payload) -> None:
247
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
 
 
248
 
249
 
250
  if __name__ == "__main__":
src/knowledge_extraction/diff/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .glossary_diff import classify, diff_glossary
2
+
3
+ __all__ = ["classify", "diff_glossary"]
src/knowledge_extraction/diff/glossary_diff.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Classify each candidate against the ACTIVE glossary version: new, duplicate
2
+ or conflicting.
3
+
4
+ The prototype diffed against the file it then overwrote, so every entry came
5
+ back `new` and the interesting paths never ran. The baseline must therefore be
6
+ supplied explicitly — an approved, versioned set — rather than read from
7
+ wherever the last run happened to write.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ..models import DiffStatus
13
+ from ..settings import DUPLICATE_OVERLAP_THRESHOLD
14
+ from ..validate.conflict import overlap
15
+
16
+
17
+ def classify(entry: dict, existing_by_term: dict[str, dict]) -> DiffStatus:
18
+ prior = existing_by_term.get((entry.get("term") or "").casefold())
19
+ if prior is None:
20
+ return "new"
21
+ a = (entry.get("definition") or "").strip()
22
+ b = (prior.get("definition") or "").strip()
23
+ if a and a == b:
24
+ return "duplicate"
25
+ if not a or not b:
26
+ # One side abstained: not a contradiction, just less information.
27
+ return "new"
28
+ return "duplicate" if overlap(a, b) >= DUPLICATE_OVERLAP_THRESHOLD else "conflicting"
29
+
30
+
31
+ def diff_glossary(entries: list[dict], active: list[dict]) -> list[dict]:
32
+ existing_by_term = {(e.get("term") or "").casefold(): e for e in active}
33
+ return [{**entry, "diff_status": classify(entry, existing_by_term)} for entry in entries]
src/knowledge_extraction/extract/__init__.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .base import cacheable, est_tokens, evidence_block, load_prompt, prefix_tokens
2
+ from .branches import (
3
+ build_glossary_prompt,
4
+ extract_formula,
5
+ extract_glossary,
6
+ extract_rule,
7
+ extract_summary,
8
+ )
9
+ from .client import AzureExtractor, LLMResult, MockExtractor
10
+ from .schemas import schema_for
11
+
12
+ __all__ = [
13
+ "AzureExtractor",
14
+ "LLMResult",
15
+ "MockExtractor",
16
+ "build_glossary_prompt",
17
+ "cacheable",
18
+ "est_tokens",
19
+ "evidence_block",
20
+ "extract_formula",
21
+ "extract_glossary",
22
+ "extract_rule",
23
+ "extract_summary",
24
+ "load_prompt",
25
+ "prefix_tokens",
26
+ "schema_for",
27
+ ]
src/knowledge_extraction/extract/base.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared plumbing for the four extraction branches.
2
+
3
+ Prompts live in `prompts/*.txt`, never in code, for two reasons: a prompt change
4
+ is not a code change, and **the fixed prefix must stay byte-identical across
5
+ calls** or prompt caching silently stops engaging at roughly 10x the input cost.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+
13
+ from ..models import Chunk
14
+ from ..settings import CACHE_MIN_TOKENS
15
+
16
+ PROMPT_DIR = Path(__file__).parent / "prompts"
17
+
18
+
19
+ def est_tokens(text: str) -> int:
20
+ """Cheap estimate, for dry-run budgeting only. Real counts come from the
21
+ API's usage object — never report a cached price from an estimate."""
22
+ return max(1, int(len(text) / 3.6))
23
+
24
+
25
+ @lru_cache(maxsize=8)
26
+ def load_prompt(branch: str) -> str:
27
+ return (PROMPT_DIR / f"{branch}.txt").read_text(encoding="utf-8")
28
+
29
+
30
+ def prefix_tokens(branch: str) -> int:
31
+ return est_tokens(load_prompt(branch))
32
+
33
+
34
+ def cacheable(branch: str) -> bool:
35
+ """Whether the fixed prefix is long enough to cache at all.
36
+
37
+ Reported, never assumed: caching does not engage below the floor, so a
38
+ shorter prefix caches nothing. Only the API's `cached_tokens` proves a hit.
39
+ """
40
+ return prefix_tokens(branch) >= CACHE_MIN_TOKENS
41
+
42
+
43
+ def evidence_block(chunks: list[Chunk], scores: list[float] | None = None) -> str:
44
+ """Evidence labelled with chunk_id, section and page so the model can cite
45
+ provenance and we can trace which evidence produced which field."""
46
+ parts = []
47
+ for i, chunk in enumerate(chunks):
48
+ score = f" score={scores[i]:.1f}" if scores and i < len(scores) else ""
49
+ parts.append(
50
+ f"[chunk_id={chunk.chunk_id} section={chunk.section_no or '-'} "
51
+ f"page={chunk.page_start}{score}]\n{chunk.text}"
52
+ )
53
+ return "EVIDENCE\n" + "\n\n---\n\n".join(parts)
src/knowledge_extraction/extract/branches.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The four extraction branches. This is the only stage that costs money.
2
+
3
+ **One call per TERM CLUSTER** — not per mention, not per chunk. That is the
4
+ whole economic argument for clustering: 200 mentions of "PA" cost one call, not
5
+ 200. It is also what makes conflict detection possible, since contradictory
6
+ definitions can only be compared when they arrive together.
7
+
8
+ Each branch returns `(entry, usage)`, with `None` for the entry when the
9
+ response fails schema validation. A failed parse is not an exception: one bad
10
+ response must not abort a corpus-scale run that has already paid for parsing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ...middlewares.logging import get_logger
16
+ from ..models import (
17
+ BriefContext,
18
+ CallUsage,
19
+ Chunk,
20
+ FormulaEntry,
21
+ FormulaVariable,
22
+ GlossaryEntry,
23
+ Provenance,
24
+ RuleCandidate,
25
+ RuleEntry,
26
+ TermCluster,
27
+ )
28
+ from ..rank import top_k
29
+ from ..settings import EVIDENCE_K
30
+ from .base import evidence_block, load_prompt
31
+ from .schemas import FormulaDraft, GlossaryDraft, RuleDraft, SummaryDraft, schema_for
32
+
33
+ logger = get_logger("knowledge_extract")
34
+
35
+
36
+ def _prov(draft_prov, doc_id: str, chunk_id: str | None = None) -> Provenance:
37
+ return Provenance(
38
+ doc_id=doc_id,
39
+ span=draft_prov.span,
40
+ page=draft_prov.page,
41
+ section_no=draft_prov.section_no,
42
+ chunk_id=chunk_id,
43
+ )
44
+
45
+
46
+ def _evidence_for(
47
+ cluster: TermCluster, chunks: list[Chunk], k: int, round_index: int
48
+ ) -> tuple[list[Chunk], list[float]]:
49
+ by_id = {c.chunk_id: c for c in chunks}
50
+ ids = top_k(cluster, k=k, round_index=round_index)
51
+ scores = cluster.evidence_scores[round_index * k : round_index * k + k]
52
+ return [by_id[i] for i in ids if i in by_id], scores
53
+
54
+
55
+ # ── glossary ────────────────────────────────────────────────────────────
56
+
57
+
58
+ def build_glossary_prompt(
59
+ cluster: TermCluster, chunks: list[Chunk], k: int = EVIDENCE_K, round_index: int = 0
60
+ ) -> tuple[str, str]:
61
+ evidence, scores = _evidence_for(cluster, chunks, k, round_index)
62
+ user = (
63
+ f"CANDIDATE TERM: {cluster.canonical}\n"
64
+ f"KNOWN VARIANTS: {', '.join(cluster.variants)}\n"
65
+ f"MENTION COUNT: {cluster.mention_count}\n\n"
66
+ + evidence_block(evidence, scores)
67
+ )
68
+ return load_prompt("glossary"), user
69
+
70
+
71
+ def extract_glossary(
72
+ cluster: TermCluster,
73
+ chunks: list[Chunk],
74
+ extractor,
75
+ doc_id: str,
76
+ k: int = EVIDENCE_K,
77
+ round_index: int = 0,
78
+ ) -> tuple[GlossaryEntry | None, CallUsage]:
79
+ system, user = build_glossary_prompt(cluster, chunks, k, round_index)
80
+ result = extractor.complete(
81
+ "glossary", system, user, schema_for("glossary"), "GlossaryEntry"
82
+ )
83
+ try:
84
+ draft = GlossaryDraft.model_validate(result.data)
85
+ except Exception as exc:
86
+ logger.warning(
87
+ "glossary draft invalid", cluster=cluster.canonical, error=repr(exc)
88
+ )
89
+ return None, result.usage
90
+
91
+ evidence, _ = _evidence_for(cluster, chunks, k, round_index)
92
+ entry = GlossaryEntry(
93
+ term=draft.term,
94
+ full_name=draft.full_name,
95
+ source_wording=_heading_wording(cluster, evidence) or draft.source_wording,
96
+ definition=draft.definition,
97
+ formula_latex=draft.formula_latex,
98
+ interpretation=draft.interpretation,
99
+ subdomain_tags=draft.subdomain_tags,
100
+ domain=draft.domain,
101
+ company=draft.company,
102
+ language=draft.language,
103
+ mention_count=cluster.mention_count,
104
+ provenance=_prov(
105
+ draft.provenance, doc_id, evidence[0].chunk_id if evidence else None
106
+ ),
107
+ )
108
+ return entry, result.usage
109
+
110
+
111
+ def _heading_wording(cluster: TermCluster, evidence: list[Chunk]) -> str | None:
112
+ """The verbatim heading of the evidence chunk whose title names this term.
113
+
114
+ Preferred over whatever the model chose to quote, because the section
115
+ heading is where the document formally names the term. Measured on the
116
+ reference standard: the model quoted "Physical Availability (PA)" from the
117
+ page-1 intro — a real verbatim quote — while the section itself is headed
118
+ "Physical **of** Availability (PA)". Both occur in the document; only the
119
+ heading form reveals that the two disagree.
120
+
121
+ Recording the literal form is a locked decision: the discrepancy belongs to
122
+ the expert, not to us. Taking it deterministically rather than asking the
123
+ model to volunteer it means it cannot be normalised away.
124
+ """
125
+ from ..cluster.normalize import normalize
126
+ from ..rank.evidence import _word_match
127
+
128
+ variants = [normalize(v) for v in cluster.variants]
129
+ for chunk in evidence:
130
+ heading = chunk.heading
131
+ if heading and any(_word_match(v, normalize(heading)) for v in variants):
132
+ return heading
133
+ return None
134
+
135
+
136
+ # ── rule of thumb ───────────────────────────────────────────────────────
137
+
138
+
139
+ def extract_rule(
140
+ candidate: RuleCandidate, chunk: Chunk, extractor, doc_id: str
141
+ ) -> tuple[RuleEntry | None, CallUsage]:
142
+ user = (
143
+ f"CUE: {candidate.cue}\n\n"
144
+ + evidence_block([chunk])
145
+ + f"\n\nFOCUS PASSAGE:\n{candidate.snippet}"
146
+ )
147
+ result = extractor.complete(
148
+ "rule", load_prompt("rule"), user, schema_for("rule"), "RuleEntry"
149
+ )
150
+ try:
151
+ draft = RuleDraft.model_validate(result.data)
152
+ except Exception as exc:
153
+ logger.warning("rule draft invalid", chunk_id=candidate.chunk_id, error=repr(exc))
154
+ return None, result.usage
155
+
156
+ return (
157
+ RuleEntry(
158
+ rule_id=draft.rule_id,
159
+ statement=draft.statement,
160
+ condition=draft.condition,
161
+ consequence=draft.consequence,
162
+ applies_to=draft.applies_to,
163
+ subdomain_tags=draft.subdomain_tags,
164
+ language=draft.language,
165
+ provenance=_prov(draft.provenance, doc_id, chunk.chunk_id),
166
+ ),
167
+ result.usage,
168
+ )
169
+
170
+
171
+ # ── formula ─────────────────────────────────────────────────────────────
172
+
173
+
174
+ def extract_formula(
175
+ chunk: Chunk, extractor, doc_id: str
176
+ ) -> tuple[FormulaEntry | None, CallUsage]:
177
+ user = evidence_block([chunk])
178
+ result = extractor.complete(
179
+ "formula", load_prompt("formula"), user, schema_for("formula"), "FormulaEntry"
180
+ )
181
+ try:
182
+ draft = FormulaDraft.model_validate(result.data)
183
+ except Exception as exc:
184
+ logger.warning("formula draft invalid", chunk_id=chunk.chunk_id, error=repr(exc))
185
+ return None, result.usage
186
+
187
+ return (
188
+ FormulaEntry(
189
+ name=draft.name,
190
+ formula_latex=draft.formula_latex,
191
+ variables=[
192
+ FormulaVariable(symbol=v.symbol, meaning=v.meaning) for v in draft.variables
193
+ ],
194
+ unit=draft.unit,
195
+ provenance=_prov(draft.provenance, doc_id, chunk.chunk_id),
196
+ ),
197
+ result.usage,
198
+ )
199
+
200
+
201
+ # ── summary ─────────────────────────────────────────────────────────────
202
+
203
+
204
+ def extract_summary(
205
+ chunks: list[Chunk], extractor, doc_id: str
206
+ ) -> tuple[BriefContext | None, CallUsage]:
207
+ """Whole-document summary — the quiet cost risk. Few calls, but a large
208
+ share of all input tokens, because summarisation cannot be filtered: it
209
+ needs the whole document.
210
+
211
+ It is also the only branch that cannot be span-checked at all. A plausible
212
+ summary is indistinguishable from a correct one, which is exactly why it
213
+ belongs on a larger tier as soon as one exists.
214
+ """
215
+ user = evidence_block(chunks)
216
+ result = extractor.complete(
217
+ "summary", load_prompt("summary"), user, schema_for("summary"), "BriefContext"
218
+ )
219
+ try:
220
+ draft = SummaryDraft.model_validate(result.data)
221
+ except Exception as exc:
222
+ logger.warning("summary draft invalid", error=repr(exc))
223
+ return None, result.usage
224
+
225
+ return (
226
+ BriefContext(
227
+ title=draft.title,
228
+ purpose=draft.purpose,
229
+ scope=draft.scope,
230
+ key_parameters=draft.key_parameters,
231
+ summary_md=draft.summary_md,
232
+ provenance=_prov(draft.provenance, doc_id),
233
+ ),
234
+ result.usage,
235
+ )
src/knowledge_extraction/extract/client.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM clients for the extraction stage — **the only place this pipeline spends
2
+ money.**
3
+
4
+ Two things this module is careful about:
5
+
6
+ - **Structured output is probed, not assumed.** `json_schema` needs a recent
7
+ api_version and we cannot confirm from here what the resource exposes. The
8
+ first call tries it; on rejection it falls back to `json_object` plus
9
+ validate-and-retry, and records which mode actually applied.
10
+ - **Cached tokens are read from the API, never modelled.** Caching does not
11
+ engage below the token floor, so an under-length prefix caches nothing.
12
+ `usage.prompt_tokens_details.cached_tokens` is the only source of truth, and a
13
+ cached price must never be reported without it.
14
+
15
+ All four branches route to the **nano** deployment (`__54n`). That is a recorded
16
+ decision, not an oversight: nano measured 0.75 schema-fill precision against a
17
+ 0.80 line, and `rule`/`summary` — whose failure mode is least detectable, since
18
+ a plausible summary cannot be span-checked — run there too until a larger
19
+ deployment exists.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import time
26
+ from typing import Any
27
+
28
+ from ...config.settings import settings as app_settings
29
+ from ...middlewares.logging import get_logger
30
+ from ..models import Branch, CallUsage
31
+ from ..settings import TEMPERATURE
32
+
33
+ logger = get_logger("knowledge_extract_client")
34
+
35
+ MAX_RETRIES = 3
36
+
37
+
38
+ class LLMResult:
39
+ def __init__(self, data: dict, usage: CallUsage, raw: str = ""):
40
+ self.data = data
41
+ self.usage = usage
42
+ self.raw = raw
43
+
44
+
45
+ class AzureExtractor:
46
+ """Real calls, real spend. Always dry-run before a corpus-scale run."""
47
+
48
+ def __init__(self, client=None, deployment: str | None = None):
49
+ self.deployment = deployment or app_settings.azureai_deployment_name_54n
50
+ self._client = client or self._build_client()
51
+ self._mode: str | None = None # resolved on the first successful call
52
+
53
+ @staticmethod
54
+ def _build_client():
55
+ from openai import AzureOpenAI
56
+
57
+ endpoint = app_settings.azureai_endpoint_url_54n
58
+ api_key = app_settings.azureai_api_key_54n
59
+ if not endpoint or not api_key:
60
+ raise RuntimeError(
61
+ "azureai__endpoint__url__54n / azureai__api_key__54n are not set. "
62
+ "Use the mock extractor to run without Azure."
63
+ )
64
+ return AzureOpenAI(
65
+ azure_endpoint=endpoint,
66
+ api_key=api_key,
67
+ api_version=app_settings.azureai_api_version_54n,
68
+ )
69
+
70
+ def complete(
71
+ self,
72
+ branch: Branch,
73
+ system_prompt: str,
74
+ user_prompt: str,
75
+ schema: dict,
76
+ schema_name: str,
77
+ ) -> LLMResult:
78
+ messages = [
79
+ {"role": "system", "content": system_prompt},
80
+ {"role": "user", "content": user_prompt},
81
+ ]
82
+ last_error: Exception | None = None
83
+
84
+ for attempt in range(MAX_RETRIES):
85
+ mode = self._mode or "json_schema"
86
+ started = time.time()
87
+ try:
88
+ response = self._client.chat.completions.create(
89
+ model=self.deployment,
90
+ messages=messages,
91
+ temperature=TEMPERATURE,
92
+ response_format=self._response_format(mode, schema, schema_name),
93
+ )
94
+ except Exception as exc:
95
+ if mode == "json_schema" and self._looks_unsupported(exc):
96
+ logger.info(
97
+ "json_schema unsupported — falling back to json_object",
98
+ error=repr(exc),
99
+ )
100
+ self._mode = "json_object"
101
+ continue
102
+ last_error = exc
103
+ logger.warning("call failed", branch=branch, attempt=attempt, error=repr(exc))
104
+ time.sleep(2**attempt)
105
+ continue
106
+
107
+ self._mode = mode
108
+ content = response.choices[0].message.content or "{}"
109
+ try:
110
+ data = json.loads(content)
111
+ except json.JSONDecodeError as exc:
112
+ last_error = exc
113
+ logger.warning("unparseable JSON", branch=branch, attempt=attempt)
114
+ continue
115
+
116
+ usage = self._usage(response, branch, time.time() - started, attempt, mode)
117
+ return LLMResult(data, usage, content)
118
+
119
+ raise RuntimeError(f"{branch}: all {MAX_RETRIES} attempts failed: {last_error!r}")
120
+
121
+ @staticmethod
122
+ def _response_format(mode: str, schema: dict, schema_name: str) -> dict:
123
+ if mode == "json_schema":
124
+ return {
125
+ "type": "json_schema",
126
+ "json_schema": {"name": schema_name, "schema": schema, "strict": False},
127
+ }
128
+ return {"type": "json_object"}
129
+
130
+ @staticmethod
131
+ def _looks_unsupported(exc: Exception) -> bool:
132
+ text = str(exc).lower()
133
+ return any(
134
+ s in text
135
+ for s in ("response_format", "json_schema", "unsupported", "invalid_request")
136
+ )
137
+
138
+ def _usage(
139
+ self, response: Any, branch: Branch, latency: float, retries: int, mode: str
140
+ ) -> CallUsage:
141
+ usage = getattr(response, "usage", None)
142
+ details = getattr(usage, "prompt_tokens_details", None)
143
+ # The ONLY source of truth for caching. Absent -> cached stays 0 and the
144
+ # uncached regime is what gets reported.
145
+ cached = int(getattr(details, "cached_tokens", 0) or 0) if details else 0
146
+ return CallUsage(
147
+ branch=branch,
148
+ deployment=self.deployment,
149
+ tier="nano",
150
+ prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
151
+ cached_tokens=cached,
152
+ completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
153
+ latency_s=round(latency, 3),
154
+ retries=retries,
155
+ structured_output_mode=mode,
156
+ simulated=False,
157
+ )
158
+
159
+
160
+ class MockExtractor:
161
+ """No network, no spend. Every record it produces is stamped `simulated`.
162
+
163
+ Exercises the wiring — schema validation, span checking, escalation,
164
+ conflicts, diff, queue — without credentials. It is **not** a model-quality
165
+ measurement and its output must never be reported as one.
166
+
167
+ It abstains by default (returns null definitions), because abstention is the
168
+ dominant real behaviour: on the reference document 56 of 66 entries had no
169
+ definition. A mock that always answers would make the downstream stages look
170
+ far better exercised than they are.
171
+ """
172
+
173
+ def __init__(self, responses: dict[str, dict] | None = None, deployment: str = "mock"):
174
+ self.responses = responses or {}
175
+ self.deployment = deployment
176
+ self.calls: list[tuple[str, str]] = []
177
+
178
+ def complete(
179
+ self,
180
+ branch: Branch,
181
+ system_prompt: str,
182
+ user_prompt: str,
183
+ schema: dict,
184
+ schema_name: str,
185
+ ) -> LLMResult:
186
+ self.calls.append((branch, user_prompt))
187
+ data = self.responses.get(branch) or self._abstain(branch, user_prompt)
188
+ usage = CallUsage(
189
+ branch=branch,
190
+ deployment=self.deployment,
191
+ prompt_tokens=len(system_prompt) // 4 + len(user_prompt) // 4,
192
+ completion_tokens=40,
193
+ structured_output_mode="mock",
194
+ simulated=True,
195
+ )
196
+ return LLMResult(data, usage, json.dumps(data))
197
+
198
+ @staticmethod
199
+ def _abstain(branch: Branch, user_prompt: str) -> dict:
200
+ # Quote a real fragment so the span check has something locatable and is
201
+ # genuinely exercised rather than trivially passed.
202
+ span = ""
203
+ if "EVIDENCE" in user_prompt:
204
+ body = user_prompt.split("EVIDENCE", 1)[1]
205
+ for line in body.splitlines():
206
+ if line.strip() and not line.startswith("["):
207
+ span = line.strip()[:60]
208
+ break
209
+ prov = {"section_no": None, "page": 1, "span": span}
210
+ if branch == "glossary":
211
+ term = "unknown"
212
+ for line in user_prompt.splitlines():
213
+ if line.startswith("CANDIDATE TERM:"):
214
+ term = line.split(":", 1)[1].strip()
215
+ break
216
+ return {"term": term, "definition": None, "provenance": prov}
217
+ if branch == "rule":
218
+ return {"rule_id": "r_mock", "statement": None, "provenance": prov}
219
+ if branch == "formula":
220
+ return {"name": None, "formula_latex": None, "provenance": prov}
221
+ return {"title": None, "summary_md": None, "provenance": prov}
src/knowledge_extraction/extract/prompts/formula.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You transcribe FORMULAS from Indonesian and English mining-operations standards.
2
+
3
+ This is a TRANSCRIPTION task, not a derivation task. You are converting a formula
4
+ that is already written in the evidence into LaTeX. You must never derive, simplify,
5
+ correct, or complete a formula.
6
+
7
+ RULES — correctness requirements:
8
+ 1. If no formula is present in the evidence, set "formula_latex" to null. A null
9
+ answer is a CORRECT answer.
10
+ 2. Transcribe exactly. If the source writes "x 100%", keep the percentage.
11
+ 3. List every variable that appears, with the meaning ONLY if the evidence states it
12
+ (usually in a "Keterangan:" or "Dimana:" legend block). Otherwise meaning is null.
13
+ 4. "provenance.span" must be copied VERBATIM from the evidence and is checked
14
+ automatically against the source.
15
+
16
+ FIELD GUIDE:
17
+ - name: what the formula computes, as named in the source
18
+ - formula_latex: LaTeX transcription
19
+ - variables: [{"symbol": "...", "meaning": "..." or null}, ...]
20
+ - unit: the result unit if stated (BCM, ton, %, hours), else null
21
+
22
+ WORKED EXAMPLE:
23
+ Evidence: "Secara umum, PA dihitung menggunakan rumus berikut:\nPA = Total Hours -
24
+ Breakdown / Total Hours x 100%\nKeterangan:\nPA : Physical Availability"
25
+ Output:
26
+ {"name": "Physical Availability (PA)", "formula_latex": "PA = \\frac{Total\\ Hours - Breakdown}{Total\\ Hours} \\times 100\\%", "variables": [{"symbol": "PA", "meaning": "Physical Availability"}, {"symbol": "Total Hours", "meaning": null}, {"symbol": "Breakdown", "meaning": null}], "unit": "%", "provenance": {"section_no": "2.1.3", "page": 4, "span": "PA = Total Hours - Breakdown"}}
27
+
28
+ Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/extract/prompts/glossary.txt ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You extract glossary entries from Indonesian and English mining-operations standards.
2
+
3
+ RULES — these are not style preferences, they are correctness requirements:
4
+ 1. Extract ONLY what the evidence states. Never infer, complete, or generalise.
5
+ 2. If the evidence does not define the term, set "definition" to null. A null answer
6
+ is a CORRECT answer. Guessing is the single worst failure mode here.
7
+ 3. "provenance.span" must be copied VERBATIM from the evidence — character for
8
+ character, including Indonesian spelling. It is automatically checked against the
9
+ source and the field is discarded if it does not match exactly.
10
+ 4. "subdomain_tags" must come from the allowed list only. Do not invent tags.
11
+ 5. Preserve the source language. Do not translate an Indonesian definition to English.
12
+
13
+ ALLOWED subdomain_tags:
14
+ production, maintenance, hauling, loading, drilling_blasting, equipment, safety,
15
+ quality, planning, cost, geology, other
16
+
17
+ FIELD GUIDE:
18
+ - term: the term as a reader would look it up (usually the abbreviation, e.g. "PA")
19
+ - full_name: the expanded form exactly as written in the source, else null
20
+ - definition: the definition as stated, else null
21
+ - formula_latex: LaTeX of the formula IF the evidence shows one, else null
22
+ - interpretation: what a high or low value means operationally, ONLY if stated, else null
23
+ - language: "id", "en", or "mixed" — the language of the definition you extracted
24
+ - provenance.span: the exact sentence or phrase you took the definition from
25
+
26
+ WORKED EXAMPLE 1 (definition present):
27
+ Evidence: "2.1.3. Physical of Availability (PA)\nAdalah ketersediaan fisik suatu
28
+ equipment/unit yang menunjukkan proporsi waktu equipment/unit tersebut berada pada
29
+ kondisi available (siap pakai) selama suatu periode tertentu."
30
+ Output:
31
+ {"term": "PA", "full_name": "Physical of Availability", "definition": "Adalah ketersediaan fisik suatu equipment/unit yang menunjukkan proporsi waktu equipment/unit tersebut berada pada kondisi available (siap pakai) selama suatu periode tertentu.", "formula_latex": null, "interpretation": null, "subdomain_tags": ["equipment", "production"], "domain": "mining", "company": null, "language": "id", "provenance": {"section_no": "2.1.3", "page": 4, "span": "Adalah ketersediaan fisik suatu equipment/unit"}}
32
+
33
+ WORKED EXAMPLE 2 (term appears but is NOT defined — the important case):
34
+ Evidence: "Gain/Loss menggunakan satuan BCM atau ton, Gap Standby menggunakan satuan
35
+ jam (hours)"
36
+ Candidate term: "BCM"
37
+ Output:
38
+ {"term": "BCM", "full_name": null, "definition": null, "formula_latex": null, "interpretation": null, "subdomain_tags": ["production"], "domain": "mining", "company": null, "language": "id", "provenance": {"section_no": "2.2.1", "page": 6, "span": "Gain/Loss menggunakan satuan BCM atau ton"}}
39
+ Note: the term is mentioned but never defined, so definition is null. This is correct.
40
+
41
+ WORKED EXAMPLE 3 (formula present):
42
+ Evidence: "Secara umum, PA dihitung menggunakan rumus berikut:\nPA = Total Hours -
43
+ Breakdown / Total Hours x 100%"
44
+ Output field: "formula_latex": "PA = \\frac{Total\\ Hours - Breakdown}{Total\\ Hours} \\times 100\\%"
45
+
46
+ WORKED EXAMPLE 4 (mixed-language definition, abbreviation and expansion both present):
47
+ Evidence: "2.1.4. Utilization of Availability (UA)\nAdalah tingkat efektifitas
48
+ penggunaan suatu alat yang tersedia secara fisik yang menunjukkan seberapa lama suatu
49
+ unit digunakan secara efektif untuk bekerja - working hours selama berada dalam
50
+ kondisi siap dioperasikan (available)."
51
+ Output:
52
+ {"term": "UA", "full_name": "Utilization of Availability", "definition": "Adalah tingkat efektifitas penggunaan suatu alat yang tersedia secara fisik yang menunjukkan seberapa lama suatu unit digunakan secara efektif untuk bekerja - working hours selama berada dalam kondisi siap dioperasikan (available).", "formula_latex": null, "interpretation": null, "subdomain_tags": ["equipment", "production"], "domain": "mining", "company": null, "language": "mixed", "provenance": {"section_no": "2.1.4", "page": 4, "span": "Adalah tingkat efektifitas penggunaan suatu alat yang tersedia secara fisik"}}
53
+ Note: language is "mixed" because the Indonesian definition embeds English terms.
54
+
55
+ COMMON MISTAKES TO AVOID:
56
+ - Do NOT translate an Indonesian definition into English. Keep the source language.
57
+ - Do NOT expand an abbreviation yourself. If the source never writes the expansion,
58
+ full_name is null.
59
+ - Do NOT copy a definition from your own knowledge of mining. If this document does
60
+ not define the term, the answer is null, even if you know what the term means.
61
+ - Do NOT paraphrase the provenance span to make it shorter or cleaner. It is compared
62
+ character by character against the source and a paraphrase will be rejected.
63
+ - Do NOT merge two different terms into one entry. Extract only the candidate term.
64
+ - Do NOT put units, table captions, or figure labels in the definition field.
65
+ - If the evidence contains only a formula and no prose, set definition to null and
66
+ fill formula_latex only.
67
+
68
+ Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/extract/prompts/rule.txt ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You extract operational RULES from Indonesian and English mining-operations standards.
2
+
3
+ A rule is a statement that constrains or directs behaviour: a condition and what
4
+ follows from it, a requirement, a prohibition, or a calculation policy.
5
+
6
+ RULES — correctness requirements, not style preferences:
7
+ 1. Extract ONLY what the evidence states. Never infer or complete a rule.
8
+ 2. If the evidence is descriptive rather than prescriptive, set "statement" to null.
9
+ A null answer is a CORRECT answer.
10
+ 3. "provenance.span" must be copied VERBATIM from the evidence. It is checked
11
+ automatically against the source and discarded if it does not match exactly.
12
+ 4. "subdomain_tags" must come from the allowed list only.
13
+ 5. Preserve the source language. Do not translate.
14
+
15
+ ALLOWED subdomain_tags:
16
+ production, maintenance, hauling, loading, drilling_blasting, equipment, safety,
17
+ quality, planning, cost, geology, other
18
+
19
+ FIELD GUIDE:
20
+ - rule_id: SCREAMING_SNAKE_CASE, descriptive, derived from the rule's subject
21
+ - statement: the rule in one sentence, as stated in the source
22
+ - condition: the triggering condition if the rule is conditional, else null
23
+ - consequence: what must happen when the condition holds, else null
24
+ - applies_to: the parameter, equipment, or activity the rule governs, else null
25
+
26
+ WORKED EXAMPLE 1 (conditional rule):
27
+ Evidence: "Production yang digunakan dalam perhitungan adalah produksi hasil joint
28
+ survey. Apabila data joint survey belum tersedia, maka digunakan data produksi
29
+ berdasarkan truck count sebagai dasar perhitungan."
30
+ Output:
31
+ {"rule_id": "PTY_PRODUCTION_SOURCE", "statement": "Production yang digunakan dalam perhitungan adalah produksi hasil joint survey.", "condition": "Apabila data joint survey belum tersedia", "consequence": "digunakan data produksi berdasarkan truck count sebagai dasar perhitungan", "applies_to": "Productivity (Pty)", "subdomain_tags": ["production", "planning"], "language": "id", "provenance": {"section_no": "2.1.5", "page": 5, "span": "Apabila data joint survey belum tersedia, maka digunakan data produksi berdasarkan truck count"}}
32
+
33
+ WORKED EXAMPLE 2 (descriptive, NOT a rule — the important case):
34
+ Evidence: "Waterfall Analysis dapat di-review melalui dua metode."
35
+ Output:
36
+ {"rule_id": "NONE", "statement": null, "condition": null, "consequence": null, "applies_to": null, "subdomain_tags": ["other"], "language": "id", "provenance": {"section_no": "2.2.5", "page": 8, "span": "Waterfall Analysis dapat di-review melalui dua metode"}}
37
+ Note: this describes a capability, it does not constrain behaviour. statement is null.
38
+
39
+ Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/extract/prompts/summary.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You write a short orientation brief for a mining-operations standard document.
2
+
3
+ This is the ONE branch whose output cannot be span-checked, because a summary is by
4
+ nature not verbatim. Treat that as a reason for restraint, not licence: state only
5
+ what the document states, and prefer omission to elaboration.
6
+
7
+ RULES:
8
+ 1. Use only the section headings and text provided. Do not add industry background.
9
+ 2. If the document does not state a purpose or scope, set that field to null.
10
+ 3. "provenance.span" must still be copied VERBATIM from the evidence — use the most
11
+ representative sentence from the document's purpose section.
12
+ 4. Keep summary_md under 200 words. It orients a reader; it does not replace the doc.
13
+
14
+ FIELD GUIDE:
15
+ - title: the document's title as written
16
+ - purpose: what the document is for, as stated
17
+ - scope: what it covers, as stated
18
+ - key_parameters: the main parameters or concepts the document defines
19
+ - summary_md: a short markdown orientation, under 200 words
20
+
21
+ Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/extract/schemas.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What the MODEL is asked to return.
2
+
3
+ Deliberately separate from `models.py`. The model never supplies `doc_id` (we
4
+ know it), never sets `extraction_status`, and never sets the conflict or diff
5
+ fields — validation owns those. **Asking a model for fields it cannot know is an
6
+ invitation to fabricate**, so the request schema is narrower than the stored one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from ..models import SubdomainEnum
16
+
17
+ Language = Literal["id", "en", "mixed"]
18
+
19
+
20
+ class ProvenanceDraft(BaseModel):
21
+ section_no: str | None = None
22
+ page: int
23
+ span: str
24
+
25
+
26
+ class GlossaryDraft(BaseModel):
27
+ term: str
28
+ full_name: str | None = None
29
+ # The wording exactly as the document writes it, even when "wrong" — the
30
+ # standard heads its section "Physical of Availability (PA)". Surfacing the
31
+ # discrepancy is the point; normalising it hides a decision the expert owns.
32
+ source_wording: str | None = None
33
+ definition: str | None = None
34
+ formula_latex: str | None = None
35
+ interpretation: str | None = None
36
+ subdomain_tags: list[SubdomainEnum] = Field(default_factory=list)
37
+ domain: str | None = None
38
+ company: str | None = None
39
+ language: Language | None = None
40
+ provenance: ProvenanceDraft
41
+
42
+
43
+ class RuleDraft(BaseModel):
44
+ rule_id: str
45
+ statement: str | None = None
46
+ condition: str | None = None
47
+ consequence: str | None = None
48
+ applies_to: str | None = None
49
+ subdomain_tags: list[SubdomainEnum] = Field(default_factory=list)
50
+ language: Language | None = None
51
+ provenance: ProvenanceDraft
52
+
53
+
54
+ class VariableDraft(BaseModel):
55
+ symbol: str
56
+ meaning: str | None = None
57
+
58
+
59
+ class FormulaDraft(BaseModel):
60
+ name: str | None = None
61
+ formula_latex: str | None = None
62
+ variables: list[VariableDraft] = Field(default_factory=list)
63
+ unit: str | None = None
64
+ provenance: ProvenanceDraft
65
+
66
+
67
+ class SummaryDraft(BaseModel):
68
+ title: str | None = None
69
+ purpose: str | None = None
70
+ scope: str | None = None
71
+ key_parameters: list[str] = Field(default_factory=list)
72
+ summary_md: str | None = None
73
+ provenance: ProvenanceDraft
74
+
75
+
76
+ DRAFTS = {
77
+ "glossary": GlossaryDraft,
78
+ "rule": RuleDraft,
79
+ "formula": FormulaDraft,
80
+ "summary": SummaryDraft,
81
+ }
82
+
83
+
84
+ def schema_for(branch: str) -> dict:
85
+ return DRAFTS[branch].model_json_schema()
src/knowledge_extraction/models.py CHANGED
@@ -204,6 +204,64 @@ class GlossaryEntry(BaseModel):
204
  conflict_variants: list[str] = Field(default_factory=list)
205
 
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  class RejectedField(BaseModel):
208
  """Audit row for a field the span check refused. Kept so a reviewer can see
209
  what the control caught rather than only what it let through."""
 
204
  conflict_variants: list[str] = Field(default_factory=list)
205
 
206
 
207
+ class RuleEntry(BaseModel):
208
+ """A rule of thumb / operational convention stated by the document."""
209
+
210
+ rule_id: str
211
+ statement: str | None = None
212
+ condition: str | None = None
213
+ consequence: str | None = None
214
+ applies_to: str | None = None
215
+ subdomain_tags: list[SubdomainEnum] = Field(default_factory=list)
216
+ language: str | None = None
217
+ provenance: Provenance
218
+ extraction_status: ExtractionStatus = "ok"
219
+
220
+
221
+ class FormulaVariable(BaseModel):
222
+ symbol: str
223
+ meaning: str | None = None
224
+
225
+
226
+ class FormulaEntry(BaseModel):
227
+ name: str | None = None
228
+ formula_latex: str | None = None
229
+ variables: list[FormulaVariable] = Field(default_factory=list)
230
+ unit: str | None = None
231
+ provenance: Provenance
232
+ extraction_status: ExtractionStatus = "ok"
233
+
234
+
235
+ class BriefContext(BaseModel):
236
+ """Whole-document summary. The only branch that cannot be span-checked —
237
+ a plausible summary is indistinguishable from a correct one, which is why
238
+ it belongs on the larger model tier when one is available."""
239
+
240
+ title: str | None = None
241
+ purpose: str | None = None
242
+ scope: str | None = None
243
+ key_parameters: list[str] = Field(default_factory=list)
244
+ summary_md: str | None = None
245
+ provenance: Provenance
246
+
247
+
248
+ class CallUsage(BaseModel):
249
+ """Per-call accounting. `cached_tokens` comes from the API and is never
250
+ modelled: caching does not engage below 1024 prompt tokens, so assuming it
251
+ would understate cost by ~10x on the input side."""
252
+
253
+ branch: Branch
254
+ deployment: str
255
+ tier: str = "nano"
256
+ prompt_tokens: int = 0
257
+ cached_tokens: int = 0
258
+ completion_tokens: int = 0
259
+ latency_s: float = 0.0
260
+ retries: int = 0
261
+ structured_output_mode: str = ""
262
+ simulated: bool = False
263
+
264
+
265
  class RejectedField(BaseModel):
266
  """Audit row for a field the span check refused. Kept so a reviewer can see
267
  what the control caught rather than only what it let through."""
src/knowledge_extraction/queue/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .review_queue import build_queue
2
+
3
+ __all__ = ["build_queue"]
src/knowledge_extraction/queue/review_queue.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The frequency-sorted review queue — the pipeline's actual product.
2
+
3
+ Ordering is the product decision here, and it targets the bottleneck directly:
4
+ the expert is the scarce resource, so they should hit the terms whose definition
5
+ propagates furthest first. Conflicts are promoted above frequency regardless,
6
+ because a contradiction is a decision only they can make.
7
+
8
+ Each row carries page, section and the verbatim span so review is a matter of
9
+ checking a quote against a page, not a claim against memory. That is what makes
10
+ the queue finishable.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+
16
+ def build_queue(entries: list[dict]) -> list[dict]:
17
+ def sort_key(entry: dict):
18
+ conflicting = (
19
+ entry.get("diff_status") == "conflicting"
20
+ or entry.get("definition_conflict") is True
21
+ )
22
+ return (0 if conflicting else 1, -int(entry.get("mention_count", 0) or 0))
23
+
24
+ queue = []
25
+ for rank, entry in enumerate(sorted(entries, key=sort_key), start=1):
26
+ provenance = entry.get("provenance") or {}
27
+ queue.append(
28
+ {
29
+ "rank": rank,
30
+ "term": entry.get("term"),
31
+ "definition": entry.get("definition"),
32
+ "source_wording": entry.get("source_wording"),
33
+ "mention_count": entry.get("mention_count", 0),
34
+ "extraction_status": entry.get("extraction_status"),
35
+ "diff_status": entry.get("diff_status"),
36
+ "definition_conflict": entry.get("definition_conflict", False),
37
+ "conflict_variants": entry.get("conflict_variants", []),
38
+ "page": provenance.get("page"),
39
+ "section_no": provenance.get("section_no"),
40
+ "span": provenance.get("span"),
41
+ "review_reason": _reason(entry),
42
+ }
43
+ )
44
+ return queue
45
+
46
+
47
+ def _reason(entry: dict) -> str:
48
+ if entry.get("definition_conflict") or entry.get("diff_status") == "conflicting":
49
+ return "conflicting definitions — expert decision required"
50
+ if _wording_differs(entry):
51
+ return "source wording differs from the expanded name — confirm which is correct"
52
+ if entry.get("extraction_status") == "no_definition_found":
53
+ return "term found but no definition in document"
54
+ if not entry.get("definition"):
55
+ return "definition rejected by span check or absent"
56
+ return "routine confirmation"
57
+
58
+
59
+ def _wording_differs(entry: dict) -> bool:
60
+ """The document says "Physical of Availability"; the expansion says
61
+ "Physical Availability". Surfacing that to the expert is a locked
62
+ requirement, so it earns its own review reason."""
63
+ source = (entry.get("source_wording") or "").strip().casefold()
64
+ full = (entry.get("full_name") or "").strip().casefold()
65
+ return bool(source and full) and full not in source
src/knowledge_extraction/service.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pipeline facade: parsed document → candidate entries → review queue.
2
+
3
+ Mirrors the shape of `src/query/service.py` — a deterministic orchestrator over
4
+ stages that each do one thing, with the expensive step isolated and every
5
+ failure degrading rather than aborting.
6
+
7
+ Cost discipline, carried from the prototype and worth keeping: **dry-run, then a
8
+ small pilot, then the full run.** A dry run makes zero API calls and prints the
9
+ token estimate, so the bill is knowable before it is incurred.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+
16
+ from ..middlewares.logging import get_logger
17
+ from .cluster import cluster_mentions
18
+ from .diff import diff_glossary
19
+ from .extract import (
20
+ build_glossary_prompt,
21
+ est_tokens,
22
+ extract_formula,
23
+ extract_glossary,
24
+ extract_rule,
25
+ extract_summary,
26
+ )
27
+ from .filters import abbrev_pairs, extract_mentions, rule_candidates
28
+ from .models import (
29
+ CallUsage,
30
+ Chunk,
31
+ ClusterResult,
32
+ FilterResult,
33
+ ParsedDoc,
34
+ RejectedField,
35
+ )
36
+ from .queue import build_queue
37
+ from .rank import rank_evidence, top_k
38
+ from .settings import EVIDENCE_K
39
+ from .validate import evidence_text, find_conflicts, rounds_available, validate_entry
40
+
41
+ logger = get_logger("knowledge_extraction")
42
+
43
+
44
+ class ExtractionResult:
45
+ def __init__(self) -> None:
46
+ self.glossary: list[dict] = []
47
+ self.rules: list[dict] = []
48
+ self.formulas: list[dict] = []
49
+ self.brief: dict | None = None
50
+ self.review_queue: list[dict] = []
51
+ self.rejected: list[RejectedField] = []
52
+ self.usages: list[CallUsage] = []
53
+
54
+ @property
55
+ def total_tokens(self) -> tuple[int, int, int]:
56
+ return (
57
+ sum(u.prompt_tokens for u in self.usages),
58
+ sum(u.cached_tokens for u in self.usages),
59
+ sum(u.completion_tokens for u in self.usages),
60
+ )
61
+
62
+
63
+ def run_filters(doc: ParsedDoc, use_span_filter: bool = True) -> FilterResult:
64
+ """All free stages. Zero API calls."""
65
+ pairs = abbrev_pairs(doc.chunks)
66
+ mentions = extract_mentions(doc.chunks) if use_span_filter else []
67
+ return FilterResult(
68
+ doc_id=doc.doc_id,
69
+ mentions=mentions,
70
+ rule_candidates=rule_candidates(doc.chunks),
71
+ abbrev_pairs=pairs,
72
+ )
73
+
74
+
75
+ def build_clusters(doc: ParsedDoc, filtered: FilterResult) -> ClusterResult:
76
+ clustered = cluster_mentions(filtered.mentions, filtered.abbrev_pairs, doc.doc_id)
77
+ rank_evidence(clustered.clusters, doc.chunks)
78
+ return clustered
79
+
80
+
81
+ def estimate_cost(
82
+ doc: ParsedDoc, clustered: ClusterResult, filtered: FilterResult, limit: int | None = None
83
+ ) -> dict:
84
+ """Dry run: exact prompts are built, nothing is sent."""
85
+ clusters = clustered.clusters[:limit] if limit else clustered.clusters
86
+ prompt_tokens = 0
87
+ for cluster in clusters:
88
+ system, user = build_glossary_prompt(cluster, doc.chunks)
89
+ prompt_tokens += est_tokens(system) + est_tokens(user)
90
+ return {
91
+ "glossary_calls": len(clusters),
92
+ "rule_calls": len(filtered.rule_candidates),
93
+ "formula_calls": sum(1 for c in doc.chunks if c.has_formula),
94
+ "summary_calls": 1,
95
+ "estimated_prompt_tokens": prompt_tokens,
96
+ "note": "estimate only — real counts come from the API usage object",
97
+ }
98
+
99
+
100
+ def extract_all(
101
+ doc: ParsedDoc,
102
+ clustered: ClusterResult,
103
+ filtered: FilterResult,
104
+ extractor,
105
+ limit: int | None = None,
106
+ active_glossary: list[dict] | None = None,
107
+ branches: tuple[str, ...] = ("glossary", "rule", "formula", "summary"),
108
+ ) -> ExtractionResult:
109
+ """The paid stage plus validation, diff and queue."""
110
+ out = ExtractionResult()
111
+ started = time.time()
112
+
113
+ if "glossary" in branches:
114
+ _run_glossary(doc, clustered, extractor, out, limit)
115
+ if "rule" in branches:
116
+ _run_rules(doc, filtered, extractor, out, limit)
117
+ if "formula" in branches:
118
+ _run_formulas(doc, extractor, out, limit)
119
+ if "summary" in branches:
120
+ _run_summary(doc, extractor, out)
121
+
122
+ out.glossary = diff_glossary(out.glossary, active_glossary or [])
123
+ out.review_queue = build_queue(out.glossary)
124
+
125
+ prompt, cached, completion = out.total_tokens
126
+ logger.info(
127
+ "extraction complete",
128
+ doc_id=doc.doc_id,
129
+ glossary=len(out.glossary),
130
+ rules=len(out.rules),
131
+ formulas=len(out.formulas),
132
+ rejected_fields=len(out.rejected),
133
+ calls=len(out.usages),
134
+ prompt_tokens=prompt,
135
+ cached_tokens=cached,
136
+ completion_tokens=completion,
137
+ seconds=round(time.time() - started, 1),
138
+ )
139
+ return out
140
+
141
+
142
+ def _run_glossary(doc, clustered, extractor, out, limit) -> None:
143
+ clusters = clustered.clusters[:limit] if limit else clustered.clusters
144
+ for cluster in clusters:
145
+ entry = None
146
+ max_round = rounds_available(cluster, EVIDENCE_K)
147
+
148
+ for round_index in range(max_round + 1):
149
+ entry, usage = extract_glossary(
150
+ cluster, doc.chunks, extractor, doc.doc_id, EVIDENCE_K, round_index
151
+ )
152
+ out.usages.append(usage)
153
+ if entry is None:
154
+ continue
155
+
156
+ source = evidence_text(
157
+ top_k(cluster, EVIDENCE_K, round_index), doc.chunks
158
+ )
159
+ entry, rejections = validate_entry(entry, "glossary", source, cluster.canonical)
160
+ out.rejected.extend(rejections)
161
+
162
+ if entry.definition:
163
+ if round_index > 0:
164
+ entry.extraction_status = "escalated"
165
+ break
166
+ # Null definition -> escalate to the next K chunks.
167
+
168
+ if entry is None:
169
+ continue
170
+ if not entry.definition:
171
+ entry.extraction_status = "no_definition_found"
172
+
173
+ conflicting, variants = find_conflicts(
174
+ [entry.definition] if entry.definition else []
175
+ )
176
+ entry.definition_conflict = conflicting
177
+ entry.conflict_variants = variants
178
+ out.glossary.append(entry.model_dump(mode="json"))
179
+
180
+
181
+ def _run_rules(doc, filtered, extractor, out, limit) -> None:
182
+ by_id: dict[str, Chunk] = {c.chunk_id: c for c in doc.chunks}
183
+ candidates = filtered.rule_candidates[:limit] if limit else filtered.rule_candidates
184
+ seen: set[str] = set()
185
+ for candidate in candidates:
186
+ chunk = by_id.get(candidate.chunk_id)
187
+ if chunk is None:
188
+ continue
189
+ entry, usage = extract_rule(candidate, chunk, extractor, doc.doc_id)
190
+ out.usages.append(usage)
191
+ if entry is None:
192
+ continue
193
+ entry, rejections = validate_entry(entry, "rule", chunk.text, entry.rule_id)
194
+ out.rejected.extend(rejections)
195
+ key = (entry.statement or "").strip().casefold()
196
+ if key and key in seen:
197
+ continue
198
+ if key:
199
+ seen.add(key)
200
+ out.rules.append(entry.model_dump(mode="json"))
201
+
202
+
203
+ def _run_formulas(doc, extractor, out, limit) -> None:
204
+ chunks = [c for c in doc.chunks if c.has_formula]
205
+ chunks = chunks[:limit] if limit else chunks
206
+ seen: set[str] = set()
207
+ for chunk in chunks:
208
+ entry, usage = extract_formula(chunk, extractor, doc.doc_id)
209
+ out.usages.append(usage)
210
+ if entry is None:
211
+ continue
212
+ entry, rejections = validate_entry(
213
+ entry, "formula", chunk.text, entry.name or chunk.chunk_id
214
+ )
215
+ out.rejected.extend(rejections)
216
+ key = (entry.formula_latex or "").strip()
217
+ if key and key in seen:
218
+ continue
219
+ if key:
220
+ seen.add(key)
221
+ out.formulas.append(entry.model_dump(mode="json"))
222
+
223
+
224
+ def _run_summary(doc, extractor, out) -> None:
225
+ entry, usage = extract_summary(doc.chunks, extractor, doc.doc_id)
226
+ out.usages.append(usage)
227
+ if entry is None:
228
+ return
229
+ # Summary prose is NOT span-checked — it cannot be. Only its provenance is
230
+ # carried, and the branch belongs on a larger tier for exactly that reason.
231
+ out.brief = entry.model_dump(mode="json")
src/knowledge_extraction/validate/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .conflict import find_conflicts, overlap
2
+ from .escalate import rounds_available
3
+ from .span_check import (
4
+ GUARDED_FIELDS,
5
+ evidence_text,
6
+ normalise_ws,
7
+ span_present,
8
+ validate_entry,
9
+ )
10
+
11
+ __all__ = [
12
+ "GUARDED_FIELDS",
13
+ "evidence_text",
14
+ "find_conflicts",
15
+ "normalise_ws",
16
+ "overlap",
17
+ "rounds_available",
18
+ "span_present",
19
+ "validate_entry",
20
+ ]
src/knowledge_extraction/validate/conflict.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Detect contradictory definitions within one cluster.
2
+
3
+ Token overlap, not embeddings: cheaper, needs no model, and — the reason that
4
+ matters — **explainable to the reviewer who has to act on it**.
5
+
6
+ This module deliberately does NOT pick a winner. Two contradictory definitions
7
+ of the same term is a decision only the expert can make, and it is only
8
+ detectable at all because clustering puts all the evidence in one call.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from ..settings import CONFLICT_OVERLAP_THRESHOLD
16
+
17
+
18
+ def tokens(text: str) -> set[str]:
19
+ return {t for t in re.findall(r"\w+", (text or "").casefold()) if len(t) > 2}
20
+
21
+
22
+ def overlap(a: str, b: str) -> float:
23
+ ta, tb = tokens(a), tokens(b)
24
+ if not ta or not tb:
25
+ return 0.0
26
+ return len(ta & tb) / min(len(ta), len(tb))
27
+
28
+
29
+ def find_conflicts(definitions: list[str]) -> tuple[bool, list[str]]:
30
+ """Returns (conflicting, variants). Definitions that share little vocabulary
31
+ are treated as competing rather than as rewordings of each other."""
32
+ present = [d.strip() for d in definitions if d and d.strip()]
33
+ unique: list[str] = []
34
+ for definition in present:
35
+ if not any(overlap(definition, seen) >= 0.9 for seen in unique):
36
+ unique.append(definition)
37
+ if len(unique) < 2:
38
+ return False, []
39
+ conflicting = any(
40
+ overlap(unique[i], unique[j]) < CONFLICT_OVERLAP_THRESHOLD
41
+ for i in range(len(unique))
42
+ for j in range(i + 1, len(unique))
43
+ )
44
+ return conflicting, unique if conflicting else []
src/knowledge_extraction/validate/escalate.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Null definition -> retry with the next K evidence chunks.
2
+
3
+ After the last round the entry is KEPT, flagged `no_definition_found`, and
4
+ passed to review anyway. A term we found but could not define is still useful
5
+ information for the expert — dropping it would hide a known unknown, and no
6
+ downstream stage would ever recover it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ..models import TermCluster
12
+ from ..settings import MAX_ESCALATION_ROUNDS
13
+
14
+
15
+ def rounds_available(cluster: TermCluster, k: int) -> int:
16
+ """How many escalation rounds this cluster actually has evidence for.
17
+
18
+ Frequently 0 on short documents: a cluster with a single evidence chunk has
19
+ nowhere to escalate. An escalation count of zero is therefore not by itself
20
+ evidence that the loop is broken.
21
+ """
22
+ extra = max(0, len(cluster.evidence_chunk_ids) - k)
23
+ return min(MAX_ESCALATION_ROUNDS, -(-extra // k)) # ceil division
src/knowledge_extraction/validate/span_check.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verbatim span validation — the primary anti-hallucination control.
2
+
3
+ Three rules that must not be relaxed:
4
+
5
+ 1. **Normalise whitespace only.** Not case, not punctuation, not diacritics.
6
+ Every additional normalisation is a hole a fabrication can fit through.
7
+ 2. **On failure the FIELD becomes `None`** and the rejection is recorded, so a
8
+ reviewer can see what the control caught rather than only what it let past.
9
+ 3. **Never repair a failed span** by rewriting it to something that does match.
10
+ A repaired span is an unfalsifiable claim, which is precisely what this
11
+ control exists to prevent.
12
+
13
+ If the provenance span itself is not verbatim, *every* guarded field on the
14
+ entry is rejected: the entry's only link to evidence is broken, so nothing on it
15
+ can be trusted.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import unicodedata
22
+
23
+ from ..models import Branch, Chunk, RejectedField
24
+
25
+ # Fields carrying a factual claim, and therefore span-guarded.
26
+ GUARDED_FIELDS: dict[str, tuple[str, ...]] = {
27
+ "glossary": ("definition", "full_name", "source_wording", "formula_latex", "interpretation"),
28
+ "rule": ("statement", "condition", "consequence"),
29
+ "formula": ("formula_latex",),
30
+ # Generation, not extraction — it cannot be span-checked at all.
31
+ "summary": (),
32
+ }
33
+
34
+
35
+ def normalise_ws(text: str) -> str:
36
+ return re.sub(r"\s+", " ", unicodedata.normalize("NFKC", text)).strip()
37
+
38
+
39
+ def span_present(span: str, source: str) -> bool:
40
+ if not span or not span.strip():
41
+ return False
42
+ return normalise_ws(span) in normalise_ws(source)
43
+
44
+
45
+ def evidence_text(chunk_ids: list[str], chunks: list[Chunk]) -> str:
46
+ """The text a span is validated against.
47
+
48
+ Includes each chunk's HEADING as well as its body. The heading is part of
49
+ the source document and is often where a term is formally named — the
50
+ reference standard heads a section "Physical of Availability (PA)" while
51
+ the body never repeats the phrase. Excluding it would reject a correct,
52
+ verbatim quotation of the document's own section title, which is precisely
53
+ the wording we are required to preserve.
54
+ """
55
+ by_id = {c.chunk_id: c for c in chunks}
56
+ parts: list[str] = []
57
+ for chunk_id in chunk_ids:
58
+ chunk = by_id.get(chunk_id)
59
+ if chunk is None:
60
+ continue
61
+ if chunk.heading:
62
+ parts.append(chunk.heading)
63
+ parts.append(chunk.text)
64
+ return "\n".join(parts)
65
+
66
+
67
+ def validate_entry(
68
+ entry, branch: Branch, source_text: str, label: str
69
+ ) -> tuple[object, list[RejectedField]]:
70
+ """Returns `(entry, rejections)`; the entry is mutated in place.
71
+
72
+ Also checks each guarded field's own value against the source where the
73
+ field is expected to be quoted: `source_wording` and `full_name` are
74
+ literal transcriptions, so a value that cannot be located is a silent
75
+ normalisation — exactly the failure this pipeline is required to surface.
76
+ """
77
+ rejections: list[RejectedField] = []
78
+ guarded = GUARDED_FIELDS.get(branch, ())
79
+ span_ok = span_present(entry.provenance.span, source_text)
80
+
81
+ for field in guarded:
82
+ value = getattr(entry, field, None)
83
+ if value is None:
84
+ continue
85
+
86
+ if not span_ok:
87
+ reason = "provenance.span not found verbatim in evidence"
88
+ elif field in _TRANSCRIBED and not span_present(str(value), source_text):
89
+ reason = f"{field} is not a verbatim transcription of the source"
90
+ else:
91
+ continue
92
+
93
+ rejections.append(
94
+ RejectedField(
95
+ entry_term=label,
96
+ field=field,
97
+ offending_value=str(value)[:300],
98
+ reason=reason,
99
+ branch=branch,
100
+ )
101
+ )
102
+ setattr(entry, field, None)
103
+
104
+ return entry, rejections
105
+
106
+
107
+ # Fields that claim to be copied from the document word for word. A definition
108
+ # may legitimately be assembled across sentences; a "full name" may not.
109
+ _TRANSCRIBED = frozenset({"full_name", "source_wording"})