[NOTICKET] feat(knowledge_extraction): paid extraction stage + validate, diff, queue
Browse filesCompletes 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 +11 -0
- KNOWLEDGE_PIPELINE_TODO.md +8 -8
- src/knowledge_extraction/__init__.py +20 -4
- src/knowledge_extraction/cli.py +137 -53
- src/knowledge_extraction/diff/__init__.py +3 -0
- src/knowledge_extraction/diff/glossary_diff.py +33 -0
- src/knowledge_extraction/extract/__init__.py +27 -0
- src/knowledge_extraction/extract/base.py +53 -0
- src/knowledge_extraction/extract/branches.py +235 -0
- src/knowledge_extraction/extract/client.py +221 -0
- src/knowledge_extraction/extract/prompts/formula.txt +28 -0
- src/knowledge_extraction/extract/prompts/glossary.txt +68 -0
- src/knowledge_extraction/extract/prompts/rule.txt +39 -0
- src/knowledge_extraction/extract/prompts/summary.txt +21 -0
- src/knowledge_extraction/extract/schemas.py +85 -0
- src/knowledge_extraction/models.py +58 -0
- src/knowledge_extraction/queue/__init__.py +3 -0
- src/knowledge_extraction/queue/review_queue.py +65 -0
- src/knowledge_extraction/service.py +231 -0
- src/knowledge_extraction/validate/__init__.py +20 -0
- src/knowledge_extraction/validate/conflict.py +44 -0
- src/knowledge_extraction/validate/escalate.py +23 -0
- src/knowledge_extraction/validate/span_check.py +109 -0
|
@@ -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.
|
|
@@ -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 | ✅ |
|
| 157 |
-
| **X11** | Verbatim-span validation | ✅ **1.00** |
|
| 158 |
-
| **X12** | Null-definition escalation | 🔎 |
|
| 159 |
-
| **X13** | Conflict detection | 🔎 |
|
| 160 |
-
| **X14** | Diff vs. active glossary version | 🔎 |
|
| 161 |
-
| **X15** | Frequency-sorted review queue | ✅ |
|
| 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 | ⬜ |
|
| 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** |
|
| 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 |
|
|
@@ -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
|
| 12 |
-
validate verbatim span check, escalation free
|
| 13 |
-
diff new / duplicate / conflicting free
|
| 14 |
-
queue frequency-sorted review queue free
|
|
|
|
|
|
|
| 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 |
]
|
|
@@ -1,4 +1,4 @@
|
|
| 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,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
|
| 10 |
-
loop and
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 22 |
-
|
| 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 .
|
| 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;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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,
|
| 79 |
source = "legend stand-in (NOT a recall run)"
|
| 80 |
else:
|
| 81 |
-
mentions = extract_mentions(doc.chunks)
|
| 82 |
source = "span filter"
|
| 83 |
-
|
|
|
|
| 84 |
cap_note = f", {capped} hit the span cap" if capped else ""
|
| 85 |
-
print(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:<
|
| 99 |
-
f"evidence={len(cluster.evidence_chunk_ids)}
|
|
|
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
)
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
)
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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__":
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .glossary_diff import classify, diff_glossary
|
| 2 |
+
|
| 3 |
+
__all__ = ["classify", "diff_glossary"]
|
|
@@ -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]
|
|
@@ -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 |
+
]
|
|
@@ -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)
|
|
@@ -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 |
+
)
|
|
@@ -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}
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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()
|
|
@@ -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."""
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .review_queue import build_queue
|
| 2 |
+
|
| 3 |
+
__all__ = ["build_queue"]
|
|
@@ -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
|
|
@@ -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")
|
|
@@ -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 |
+
]
|
|
@@ -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 []
|
|
@@ -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
|
|
@@ -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"})
|