/fix parsing and term extract (#21)
Browse files- [NOTICKET] fix: retire the fuzzy clustering pass; add E1c post-cluster recall (2cc21c92688d662c5cfdfeeb6784bb85b98575ca)
- [NOTICKET] feat: parsing schema 0.4.0 — asset pointers for figures and tables (e662ac089b741c90e19b9f51f4933fb97a1225bc)
- [NOTICKET] docs: sync REPO_STATUS / DEV_PLAN / contracts for pipeline v2 (c3c184d83bec4a4d42e9ad3f7265560802817e5b)
- [NOTICKET] docs: close K15 with the paid v1-vs-v2 comparison (62782fed9292f67d7b376783920a07197d6776e9)
- [NOTICKET] fix: test the caption hypothesis; ship the signal at weight 0.0 (36a166e1844d186d6cfb8fff9ea070f3dc69b220)
- [NOTICKET] fix: cap key-parameter pre-emption in the review queue (Step 2) (4e3a6aefef7024db3ea4b7946fd7771a6c269601)
- [NOTICKET] feat: assert on invariants, report a band, classify the genre (Steps 0 + 5) (f1d64b113db0289601806635a9b154201a6127fc)
- [NOTICKET] fix: gate spend in cluster units and surface the estimate (Step 6) (cdb5fe82cc5f2893ccbb5e28282e8dc46a27e061)
- [NOTICKET] fix: retire escalation, budget windows by subword (Finding 6 + the over-cap window) (1faca810ea9c17fd3f86cd388afa942f48dfef78)
- [NOTICKET] docs: sync for the open-queue pass, add the Step 8 and Step 7 proposals (7659cf0333d6d14af923d227ceff139eb359118f)
- [NOTICKET] feat: pull one linked partner chunk into the evidence window (5d5102547e162caa837d7c89839c875b4029f067)
- [NOTICKET] fix: add a fault-code label, measured; re-diagnose the Komatsu tables (366697cb4b2db593a0772bc26b891b5422cc9b31)
- [NOTICKET] feat(knowledge): readable concept keys + key-value lookup (m1, m2, m6, m8) (0ba89b18f02b46549cffbf2e216d51f8e8cc82a8)
- [NOTICKET] feat(knowledge): incremental ingest — versions, reuse, staleness (i1-i7) (72e18a592a8951e8b98d5ce84e4e673986c48b55)
- [NOTICKET] test(knowledge): cross-session dedupe (i6) (0974503b9d280ff6d059898c17c5777a86571014)
- [NOTICKET] feat(knowledge): promoted columns + withdrawal (m3, m4, m5, i7) (e2ae28f347023b01c03208aefe947f1f37b4969d)
- [NOTICKET] feat(knowledge): review diff + the rule/domain boundary (r4, o2) (5cb2e2da72d6002ef8ea96cba8faf3a768faa8dc)
- [NOTICKET] feat(knowledge): two review modes, recorded against a person (r1, r2, r3) (6ef79904de4af18660e493664d47d1c4d1bcf442)
- [NOTICKET] docs(knowledge): bring the DDL handoff current for Harry (e43356130f634eb0078ad709304a143a91b8ad52)
- [NOTICKET] docs: sync REPO_STATUS / DEV_PLAN / contract for the 8 Sep list (K24) (be227a85bb553c5376d85c76cd001f4954ca9829)
- API_CONTRACT_BE_PYTHON.md +54 -1
- DEV_PLAN.md +81 -0
- REPO_STATUS.md +114 -7
- docs/README.md +2 -0
- docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md +109 -14
- docs/knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md +89 -2
- docs/knowledge/KNOWLEDGE_PIPELINE_CALIBRATION.md +43 -6
- docs/knowledge/KNOWLEDGE_PIPELINE_TODO.md +10 -3
- docs/knowledge/TABLE_BRANCH_PROPOSAL.md +196 -0
- docs/knowledge/TERM_HIERARCHY_PROPOSAL.md +122 -0
- docs/knowledge/knowledge_tables.sql +152 -14
- eval/knowledge/invariants.py +203 -0
- eval/knowledge/results/v2_filters_2026-09-10_151557.json +109 -0
- eval/knowledge/results/v2_filters_2026-09-11_092207.json +137 -0
- eval/knowledge/results/v2_filters_2026-09-11_100546.json +131 -0
- eval/knowledge/run_eval.py +291 -14
- eval/knowledge/score.py +54 -0
- src/api/v1/knowledge.py +14 -1
- src/db/postgres/models.py +32 -0
- src/knowledge_extraction/adapter.py +24 -2
- src/knowledge_extraction/cluster/cluster.py +37 -6
- src/knowledge_extraction/cluster/normalize.py +60 -8
- src/knowledge_extraction/config/labels.yaml +103 -0
- src/knowledge_extraction/extract/base.py +36 -0
- src/knowledge_extraction/extract/branches.py +6 -1
- src/knowledge_extraction/extract/prompts/glossary.txt +11 -0
- src/knowledge_extraction/extract/prompts/rule.txt +11 -0
- src/knowledge_extraction/filters/span_filter.py +103 -1
- src/knowledge_extraction/genre.py +213 -0
- src/knowledge_extraction/keys.py +160 -0
- src/knowledge_extraction/models.py +42 -0
- src/knowledge_extraction/mutation.py +113 -0
- src/knowledge_extraction/queue/review_queue.py +93 -15
- src/knowledge_extraction/rank/evidence.py +82 -4
- src/knowledge_extraction/service.py +121 -10
- src/knowledge_extraction/settings.py +225 -8
- src/knowledge_ingest/knowledge_store.py +473 -12
- src/knowledge_ingest/models.py +29 -0
- src/knowledge_ingest/pipeline.py +136 -7
- src/knowledge_ingest/service.py +56 -1
- src/knowledge_ingest/store.py +2 -0
- src/knowledge_parsing/assets.py +156 -0
- src/knowledge_parsing/config.py +5 -0
- src/knowledge_parsing/contracts.py +94 -5
- src/knowledge_parsing/normalize_mistral.py +195 -16
- src/knowledge_parsing/run.py +21 -4
|
@@ -802,7 +802,9 @@ Frontend rendering guidance:
|
|
| 802 |
|
| 803 |
## Knowledge
|
| 804 |
|
| 805 |
-
> Added 2026-09-07 (KNOWLEDGE_SURFACE_PLAN.md). Turns a user's uploaded documents into reviewable knowledge artifacts: glossary terms, rules, formulas and one domain context per document. **Admin-shaped, not part of the chat flow** — no chat endpoint calls any of this
|
|
|
|
|
|
|
| 806 |
|
| 807 |
**Asynchronous by necessity.** Parsing a nine-page document takes ~80 s and extracting from it ~170 s, far past any HTTP timeout, so ingestion admits a batch and returns immediately. **Polling, not SSE** (decided 2026-09-07): a job row survives a page refresh and a dropped connection, a stream does not, and a four-minute stream would hold a worker for its duration.
|
| 808 |
|
|
@@ -821,9 +823,18 @@ Body:
|
|
| 821 |
| `user_id` | Yes | The caller. Must be a UUID (422 otherwise). Scope is derived from it. |
|
| 822 |
| `document_ids` | No | Omit for the one-click case: every parseable document this user owns. Named ids are still filtered by owner and type. |
|
| 823 |
| `reparse` | No | Re-parse even when the content hash matches a stored artifact. Default `false` — on by default it silently re-spends. |
|
|
|
|
| 824 |
|
| 825 |
Only `pdf` and `docx` are parsed; spreadsheets and text files belong to the structured-data path and come back in `skipped` **with a reason**, never silently dropped.
|
| 826 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 827 |
`reused: true` means a job for this company was already running and is being returned instead of a second one starting — a double click is not an error.
|
| 828 |
|
| 829 |
```json
|
|
@@ -846,6 +857,48 @@ Job state and per-document outcomes. **404** when the id is unknown — unlike `
|
|
| 846 |
|
| 847 |
`stage` (`parsing` · `filtering` · `estimating` · `extracting` · `persisting`) is ordered by cost: everything up to and including `estimating` is free, and the ceiling is enforced between `estimating` and `extracting`.
|
| 848 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
### `GET /api/v1/knowledge/jobs`
|
| 850 |
|
| 851 |
Recent jobs for one `user_id`, newest first — lets a caller recover after a refresh without having stashed a `job_id`.
|
|
|
|
| 802 |
|
| 803 |
## Knowledge
|
| 804 |
|
| 805 |
+
> Added 2026-09-07 (KNOWLEDGE_SURFACE_PLAN.md). Turns a user's uploaded documents into reviewable knowledge artifacts: glossary terms, rules, formulas and one domain context per document. **Admin-shaped, not part of the chat flow** — no chat endpoint calls any of this.
|
| 806 |
+
>
|
| 807 |
+
> **Corrected 2026-09-14:** the line that stood here ("the analytics path does not yet read what it produces") is out of date — the consumption path was armed 2026-09-08 (DEV_PLAN §0.8 K5/K9/K11). The planner reads the company's approved terminology on every slow-path turn. **Approved entries only**, so it stays inert until an expert works the queue.
|
| 808 |
|
| 809 |
**Asynchronous by necessity.** Parsing a nine-page document takes ~80 s and extracting from it ~170 s, far past any HTTP timeout, so ingestion admits a batch and returns immediately. **Polling, not SSE** (decided 2026-09-07): a job row survives a page refresh and a dropped connection, a stream does not, and a four-minute stream would hold a worker for its duration.
|
| 810 |
|
|
|
|
| 823 |
| `user_id` | Yes | The caller. Must be a UUID (422 otherwise). Scope is derived from it. |
|
| 824 |
| `document_ids` | No | Omit for the one-click case: every parseable document this user owns. Named ids are still filtered by owner and type. |
|
| 825 |
| `reparse` | No | Re-parse even when the content hash matches a stored artifact. Default `false` — on by default it silently re-spends. |
|
| 826 |
+
| `review_mode` | No | **Added 2026-09-14.** `expert` (default) or `llm`. Who rules on what this batch produces. |
|
| 827 |
|
| 828 |
Only `pdf` and `docx` are parsed; spreadsheets and text files belong to the structured-data path and come back in `skipped` **with a reason**, never silently dropped.
|
| 829 |
|
| 830 |
+
**`review_mode` (2026-09-14).** `expert` leaves every entry waiting for a human. `llm` approves
|
| 831 |
+
them on arrival with **no reviewer step and no second opinion** — the entries were written by a
|
| 832 |
+
model and are accepted as-is; there is no second model call. What the FE must surface when
|
| 833 |
+
offering it: choosing `llm` records `authorised_by` against the caller, so an entry approved this
|
| 834 |
+
way can always answer *who allowed this*. Default is `expert`, so nobody gets LLM approval by
|
| 835 |
+
accident. Entries are written through the ordinary append-only review path, so an expert can
|
| 836 |
+
still disagree with any of them afterwards.
|
| 837 |
+
|
| 838 |
`reused: true` means a job for this company was already running and is being returned instead of a second one starting — a double click is not an error.
|
| 839 |
|
| 840 |
```json
|
|
|
|
| 857 |
|
| 858 |
`stage` (`parsing` · `filtering` · `estimating` · `extracting` · `persisting`) is ordered by cost: everything up to and including `estimating` is free, and the ceiling is enforced between `estimating` and `extracting`.
|
| 859 |
|
| 860 |
+
> **Updated 2026-09-11 — the estimate is now returned, and `blocked` is now reachable.**
|
| 861 |
+
>
|
| 862 |
+
> `estimated_cost` was on the job model from the start and was **never populated**, so the field a
|
| 863 |
+
> caller would poll to answer "what will this cost, and why was that document refused" always came
|
| 864 |
+
> back `null`. It now carries the pre-flight shape of the batch, per document and in total. A
|
| 865 |
+
> wholly-refused batch also settles **`blocked`** rather than `failed` — previously every spend
|
| 866 |
+
> refusal was reported as a generic failure, which hid the one outcome with an action attached.
|
| 867 |
+
>
|
| 868 |
+
> **Spend is driven by CLUSTER COUNT** — one LLM call per term cluster — not by page count, so the
|
| 869 |
+
> per-document breakdown is what tells an operator which document to split. There are now **two
|
| 870 |
+
> ceilings**, and `blocked_by` says which one bound:
|
| 871 |
+
>
|
| 872 |
+
> | `blocked_by` | Meaning | The operator's move |
|
| 873 |
+
> |---|---|---|
|
| 874 |
+
> | `clusters` | Too many term clusters (exact, known free before spending) | Split the document |
|
| 875 |
+
> | `prompt_tokens` | Too many estimated prompt tokens | The document is simply long |
|
| 876 |
+
>
|
| 877 |
+
> ```json
|
| 878 |
+
> {
|
| 879 |
+
> "status": "blocked",
|
| 880 |
+
> "estimated_cost": {
|
| 881 |
+
> "clusters": 2083, "total_calls": 2150, "estimated_prompt_tokens": 5100000,
|
| 882 |
+
> "per_document": {
|
| 883 |
+
> "d1": {"clusters": 83, "total_calls": 106, "estimated_prompt_tokens": 201751},
|
| 884 |
+
> "d2": {"clusters": 2000, "total_calls": 2044, "estimated_prompt_tokens": 4898249}
|
| 885 |
+
> },
|
| 886 |
+
> "blocked": {"d2": "clusters"}
|
| 887 |
+
> },
|
| 888 |
+
> "results": [
|
| 889 |
+
> {"document_id": "d2", "status": "failed", "blocked_by": "clusters",
|
| 890 |
+
> "error": "estimated 2,000 term clusters exceeds the ceiling of 750 (267% of it) — nothing was spent; 2,044 LLM calls were declined"}
|
| 891 |
+
> ]
|
| 892 |
+
> }
|
| 893 |
+
> ```
|
| 894 |
+
>
|
| 895 |
+
> Each `results[]` entry also carries `estimate` — **on success as well as refusal**, because a
|
| 896 |
+
> number a caller only ever sees when it is already too late cannot be planned against.
|
| 897 |
+
>
|
| 898 |
+
> `estimate.genre` is advisory document classification (`standard` · `diagnostic` · `conceptual` ·
|
| 899 |
+
> `unknown`) with a `well_served` flag. **Nothing is routed on it yet**; it exists so a caller can be
|
| 900 |
+
> told *before* spending that a troubleshooting manual will yield almost no definitions.
|
| 901 |
+
|
| 902 |
### `GET /api/v1/knowledge/jobs`
|
| 903 |
|
| 904 |
Recent jobs for one `user_id`, newest first — lets a caller recover after a refresh without having stashed a `job_id`.
|
|
@@ -278,6 +278,74 @@ not to set for now).
|
|
| 278 |
|
| 279 |
## 0.8. Knowledge pipeline — test integration (2026-08-19 → **building, 2026-08-21**)
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
> **Updated 2026-08-21.** The banner here said *"Nothing has landed in this repo"* — that is no
|
| 282 |
> longer true. **Both halves are now in-tree** (`src/knowledge_parsing/` + `src/knowledge_extraction/`
|
| 283 |
> + `eval/knowledge/`, ~5,360 lines across commits `024c30a…a7bdeae`), and three dependencies were
|
|
@@ -382,6 +450,19 @@ scores **1.000** on a committed fixture; see the K9 row for how to read that num
|
|
| 382 |
| K10 | Fixture artifact committed | Rifqi | ✅ | **Done 2026-08-27** — `eval/knowledge/fixtures/parsed_STD_2026_006_MNO.json` (BUMA standard, MinerU 3.4.4 `hybrid`, `schema_version 0.3.0`, 31 chunks). The eval and the extraction CLI both run with MinerU absent. The Open Pit textbook fixture is still open (S7 in the checklist) |
|
| 383 |
| K11 | Output schema v2 (Harry's contract review) | Rifqi | 🔎 | **Landed 2026-08-26, unevaluated.** Deterministic entity ids, glossary references formulas instead of restating them, rules link to formulas and terms, 16 fields cut (50 → 42). Contract: [docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md](docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md); detail in the checklist as X24. Fixed two live defects — neither `cluster_id` (a frequency ordinal) nor `rule_id` (model-written) survived a re-run, which breaks an expert's approvals, not just links. Suite 497 passed / 7 skipped; **no eval run** — the prompt edits join X20's in the unevaluated queue |
|
| 384 |
| K12 | Output schema v3 — `DomainContext` | Rifqi | ✅ | **Done 2026-09-02** (`7f47c61` · `6f5d619` · `e2865ac` · `172d8dc`; checklist **X26**). `BriefContext` → **`DomainContext`**, renamed while nothing is persisted — afterwards an id-prefix change is a migration that loses an expert's approvals. **The branch is no longer the pipeline's one unverifiable output:** it was made guardable by changing the question rather than the model — the prompt asks it to *locate* the document's own statement of purpose (`purpose` → `purpose_verbatim`) instead of composing a summary, so `GUARDED_FIELDS["summary"]` goes `()` → `("title", "purpose_verbatim")`. Adds derived `outline`, `subdomains` and entry counts; `key_parameters` gains a corroborating `term_id`, and an unresolved one earns its own review-queue row. **Contract change for Harry:** queue rows now carry `row_kind`, and a `key_parameter` row has no `term_id`, definition or mention count. Measured on the BUMA fixture (nano, 12 calls): 0 fields rejected, fabricated purpose refused, key-parameter linking 0/5 → 4/5 after a bug the smoke caught. Suite 541 passed / 7 skipped |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
|
| 386 |
**Not decided, do not assume:** whether the admin ingest surface is HTTP at all (the offline CLI is
|
| 387 |
the honest milestone and both halves are there now); and GLiNER + LangExtract ensembling stays ⏸️
|
|
|
|
| 278 |
|
| 279 |
## 0.8. Knowledge pipeline — test integration (2026-08-19 → **building, 2026-08-21**)
|
| 280 |
|
| 281 |
+
> ## Updated 2026-09-11 — the open-queue pass (K17–K22)
|
| 282 |
+
>
|
| 283 |
+
> Six items off `knowledge_pipeline_runs/SESSION_HANDOFF.md` §4, **zero API spend**: every number
|
| 284 |
+
> below comes from replaying the six persisted paid runs or re-running the free stages on the three
|
| 285 |
+
> v2 artifacts. Detail in REPO_STATUS §14; per-item rows K17–K22 in the table below.
|
| 286 |
+
>
|
| 287 |
+
> **The headline is Step 2**, the item everyone agreed on and nobody had done: the review queue put
|
| 288 |
+
> 24 unresolved key parameters in front of Komatsu's `Engine` (108 mentions, rank 25). It is rank 1
|
| 289 |
+
> now, with nothing dropped.
|
| 290 |
+
>
|
| 291 |
+
> **Two findings worth reading as findings, not as changes:**
|
| 292 |
+
> 1. **The spend ceiling was gating on a partial number.** `estimate_cost` summed only the glossary
|
| 293 |
+
> branch under a field named `estimated_prompt_tokens`. Real undercount 8.8%–18.4%; Open Pit is at
|
| 294 |
+
> **78%** of the ceiling, not 71%.
|
| 295 |
+
> 2. **Escalation has never worked.** 48 calls across three documents and two configurations,
|
| 296 |
+
> **0 definitions** recovered. Retired by constant, loop left in place.
|
| 297 |
+
>
|
| 298 |
+
> **What is deliberately NOT switched on:** genre routing. The classifier is right on all three
|
| 299 |
+
> documents, but the label sets are unscoreable without a second gold set and branch-skipping rests
|
| 300 |
+
> on a single document, so `ROUTE_LABELS` / `ROUTE_BRANCHES` are both off and the verdict is recorded
|
| 301 |
+
> rather than acted on. **Flipping either is Rifqi's call and one line.**
|
| 302 |
+
>
|
| 303 |
+
> **Still open and unchanged:** the paid Step 0 band (needs authorised repeated runs), Phase 4's
|
| 304 |
+
> vision call, Step 8, Step 7, R4, and E5 — Sofhia still has not seen the `src/knowledge_parsing/`
|
| 305 |
+
> diff.
|
| 306 |
+
|
| 307 |
+
> ## Updated 2026-09-11 — v2: clustering fix + parsing 0.4.0 (asset pointers)
|
| 308 |
+
>
|
| 309 |
+
> Two changes, measured on three documents (BUMA standard · Komatsu shop manual · Open Pit textbook
|
| 310 |
+
> Ch.1) at **zero API spend** for the free stages — the parse cache fingerprint is unchanged, so
|
| 311 |
+
> re-parses reuse the cached Mistral responses. Full write-up:
|
| 312 |
+
> `knowledge_pipeline_runs/CHANGELOG_v2.md`.
|
| 313 |
+
>
|
| 314 |
+
> **The fuzzy clustering pass is RETIRED (K13).** `token_set_ratio` returns exactly 100 when one token
|
| 315 |
+
> set is a subset of the other, so a head noun always absorbed its own compounds and the threshold of
|
| 316 |
+
> 92 was **inert**. 131 of its 140 merges were subset matches (all wrong); 6 were plurals, now folded
|
| 317 |
+
> deterministically; 3 were distinct metrics wrongly merged. Token-subset merges **15/18/97 → 0**, and
|
| 318 |
+
> post-cluster gold recall **0.8293 → 0.8537** — the gold term `EWH`, which `Working Hours` had
|
| 319 |
+
> absorbed, is recovered. `_fuzzy_match` is parked, not deleted.
|
| 320 |
+
>
|
| 321 |
+
> **Parsing `schema_version` 0.4.0 (K14).** A figure is no longer its own chunk. It was, with an EMPTY
|
| 322 |
+
> `text`, which made it unreachable by construction — **13 figures across three documents, each
|
| 323 |
+
> described by a vision model we pay for, and ZERO reaching any prompt.** Now the ``
|
| 324 |
+
> reference is folded into the prose chunk at its reading-order position and the figure travels as an
|
| 325 |
+
> `Asset` (content-addressed, persisted bytes, verbatim caption, unwrapped description). Image chunks
|
| 326 |
+
> **2/3/8 → 0/0/0**; recall unmoved (E1 and E1c both identical v1→v2). Tables deliberately stay their
|
| 327 |
+
> own chunk. `adapter.py` now carries `assets`, `referenced_by` and **`table_html`**, which parsing had
|
| 328 |
+
> emitted for months while the adapter dropped it.
|
| 329 |
+
>
|
| 330 |
+
> **New metric E1c (post-cluster recall)**, and **E2's compression kill line REMOVED** — over-merging
|
| 331 |
+
> *raises* compression, so the metric was rewarding the defect it should have exposed.
|
| 332 |
+
>
|
| 333 |
+
> **Two settings changes measured and REJECTED:** `WINDOW_WORDS` 256 (truncates 46 of 99 Open Pit
|
| 334 |
+
> windows — the "flat recall" justifying it was measured on BUMA, the one document whose truncation
|
| 335 |
+
> count does not vary with window size) and `SPAN_SCORE_THRESHOLD` 0.15 (+50% clusters against a +20%
|
| 336 |
+
> bar). `EVIDENCE_K` raised 3 → 5 on Rifqi's call; measured cost +1.8% to +15% evidence tokens, and on
|
| 337 |
+
> the one gold-set document top-5 recovers the same 16/18 as top-3.
|
| 338 |
+
>
|
| 339 |
+
> **Export for the team:** `knowledge_pipeline_runs/06_export_knowledge_parsing_v0.4.0/`, verified to
|
| 340 |
+
> import and validate a fixture with no repo on `sys.path`. ⚠️ `src/knowledge_parsing/` is Sofhia's
|
| 341 |
+
> half — she needs the diff before it goes out.
|
| 342 |
+
>
|
| 343 |
+
> **Paid comparison landed 2026-09-11 (K15).** Definitions 7→10 / 0→0 / 25→49; queue junk rows down
|
| 344 |
+
> 37–65%; Komatsu still 0, as predicted. Caveats: the `source_wording` gain is largely tautological,
|
| 345 |
+
> and **no definition cited a figure caption on any document** — assets are reachable but unused, which
|
| 346 |
+
> points at the caption-to-filter path (built, flag off) as the next free experiment. Open Pit now sits
|
| 347 |
+
> at **71% of the 2M spend ceiling** for one chapter.
|
| 348 |
+
|
| 349 |
> **Updated 2026-08-21.** The banner here said *"Nothing has landed in this repo"* — that is no
|
| 350 |
> longer true. **Both halves are now in-tree** (`src/knowledge_parsing/` + `src/knowledge_extraction/`
|
| 351 |
> + `eval/knowledge/`, ~5,360 lines across commits `024c30a…a7bdeae`), and three dependencies were
|
|
|
|
| 450 |
| K10 | Fixture artifact committed | Rifqi | ✅ | **Done 2026-08-27** — `eval/knowledge/fixtures/parsed_STD_2026_006_MNO.json` (BUMA standard, MinerU 3.4.4 `hybrid`, `schema_version 0.3.0`, 31 chunks). The eval and the extraction CLI both run with MinerU absent. The Open Pit textbook fixture is still open (S7 in the checklist) |
|
| 451 |
| K11 | Output schema v2 (Harry's contract review) | Rifqi | 🔎 | **Landed 2026-08-26, unevaluated.** Deterministic entity ids, glossary references formulas instead of restating them, rules link to formulas and terms, 16 fields cut (50 → 42). Contract: [docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md](docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md); detail in the checklist as X24. Fixed two live defects — neither `cluster_id` (a frequency ordinal) nor `rule_id` (model-written) survived a re-run, which breaks an expert's approvals, not just links. Suite 497 passed / 7 skipped; **no eval run** — the prompt edits join X20's in the unevaluated queue |
|
| 452 |
| K12 | Output schema v3 — `DomainContext` | Rifqi | ✅ | **Done 2026-09-02** (`7f47c61` · `6f5d619` · `e2865ac` · `172d8dc`; checklist **X26**). `BriefContext` → **`DomainContext`**, renamed while nothing is persisted — afterwards an id-prefix change is a migration that loses an expert's approvals. **The branch is no longer the pipeline's one unverifiable output:** it was made guardable by changing the question rather than the model — the prompt asks it to *locate* the document's own statement of purpose (`purpose` → `purpose_verbatim`) instead of composing a summary, so `GUARDED_FIELDS["summary"]` goes `()` → `("title", "purpose_verbatim")`. Adds derived `outline`, `subdomains` and entry counts; `key_parameters` gains a corroborating `term_id`, and an unresolved one earns its own review-queue row. **Contract change for Harry:** queue rows now carry `row_kind`, and a `key_parameter` row has no `term_id`, definition or mention count. Measured on the BUMA fixture (nano, 12 calls): 0 fields rejected, fabricated purpose refused, key-parameter linking 0/5 → 4/5 after a bug the smoke caught. Suite 541 passed / 7 skipped |
|
| 453 |
+
| K13 | **Clustering: retire the fuzzy pass** | Rifqi | ✅ | **Done 2026-09-10.** `token_set_ratio` is blind to the subset case (returns 100), so the threshold never applied and head nouns ate their compounds. Measured across three documents: 131 of 140 merges were subset matches. Now `exact (plural-folded) → abbreviation ↔ expansion` only; `depluralise()` handles the 6 legitimate plural merges. Subset merges **15/18/97 → 0**; E1c_strict **0.8293 → 0.8537** (`EWH` recovered). New metric **E1c** added to `score.py`/`run_eval.py`; **E2's kill line removed** (it rises when the defect fires). Suite 676 passed / 7 skipped |
|
| 454 |
+
| K14 | **Parsing 0.4.0 — asset pointers** | Rifqi (Sofhia's half) | ✅ | **Done 2026-09-10.** Figures fold into the prose chunk as `` at reading-order position; `Asset` carries a content-addressed id, persisted bytes, a **verbatim** caption and a **model-written** description that never enters `text`. Image chunks **2/3/8 → 0/0/0**, 0 dangling refs, 0 description leaks, captions 17/17 preserved, **recall unchanged**. Tables keep their own chunk + two-way pointers. Seam: `adapter.py` carries `assets`/`referenced_by`/**`table_html`**; `SUPPORTED_SCHEMA` → (0,4), so parsing and extraction ship together. Found and fixed a Mistral bug: `image_id` mislabelled on one block, collapsing two figures onto one hash. ⚠️ **Sofhia needs the diff before the team export goes out** |
|
| 455 |
+
| K15 | Paid v1-vs-v2 comparison | Rifqi | ✅ | **Done 2026-09-11**, 3 runs, ~9¢. Definitions **7→10 / 0→0 / 25→49** — Open Pit nearly doubled; junk review-queue rows −37% (Komatsu) / −65% (Open Pit); cache rate +7% on all three; 0 retries. **Komatsu stays at 0 definitions**, as predicted: a troubleshooting manual states symptoms, not definitions, and no plumbing change reaches that. Two caveats recorded rather than buried: `source_wording`'s headline +1608% is mostly **tautological** (informative 12→101, not 13→222), and **`definitions_citing_a_caption` = 0 on every document** — the assets are reachable but nothing consumed them, most likely because captions do not feed the term filter (flag off by decision). ⚠️ Cost: Open Pit is now **71% of the 2M pre-flight ceiling** (was 56%) for one chapter. Three variables moved at once, so no single number is attributable. Write-up: `knowledge_pipeline_runs/V1_VS_V2_PAID_COMPARISON.md` |
|
| 456 |
+
| K16 | Caption hypothesis for the 0 caption-citations | Rifqi | ✅ | **Tested 2026-09-11, HYPOTHESIS FALSIFIED.** Both candidate explanations for `definitions_citing_a_caption = 0` are false: captions ALREADY reach the term filter (they are part of `Chunk.text`; 2/2 BUMA and 3/8 Open Pit captions already produce mentions), and figure-bearing chunks are ALREADY selected (a captioned chunk is in the top-5 for 22/83 BUMA and 64/427 Open Pit clusters). Built the missing ranker signal `asset_caption_names_term` to test it properly: at weight 2.5 it changed the top-5 of **1 cluster out of 603** and moved none from no-figure to has-figure. **The real reason: not one caption in the corpus is a definition** — they are figure titles ("Figure 1.3. Relative ability to influence costs"). A model declining to quote one is CORRECT. Signal ships at **weight 0.0** with the measurement beside it. ⚠️ Also corrects a false claim in the earlier docs: the caption-to-filter flag was described as "built, off by decision" — it was never built. Redirects the remaining figure value to Phase 4 (vision call on the bytes), which is now the only untested path |
|
| 457 |
+
|
| 458 |
+
| K17 | **Step 2 — review queue ordering + key-parameter heads** | Rifqi | ✅ | **Done 2026-09-11.** Unresolved key parameters held their own sort tier, so every one outranked every routine term; the branch emits them in bulk on the documents it understands least, so the promotion fired hardest where least deserved. Now spliced at most **1 per 5** term rows. Replayed on the three persisted v2 runs: Komatsu `Engine` (108 mentions) **rank 25 → 1**, Open Pit `Resource` **8 → 1**, first junk row **1 → 6**, **row counts identical** (nothing dropped), BUMA unchanged (0 unresolved). `term` now carries a head (first `.` `:` or newline, only past 60 chars — "No. of units" survives) with the full text in new **`surface_full`**. Contract: OUTPUT_CONTRACT §9 |
|
| 459 |
+
| K18 | **Step 0 — measurement hardening** | Rifqi | 🔄 | **Harness done 2026-09-11, the paid band is NOT run.** `eval/knowledge/invariants.py`: asserts on **fields and invariants, never exact text** — span locatable, entity ids present and unique, a rejected field still null (rejected-never-repaired, checkable only from outside), page in range, queue ranks contiguous, no leaked sort keys. `--runs N` repeats **only the paid stage** (free stages are deterministic) and reports min/median/max with the **median** as the value, plus whether all runs minted identical entity ids — the K11 defect class, unwatched since K11. `EXPECTED_MODEL_SNAPSHOT` turns X25's recording into an **assertion** (exit 6). New exit codes: 5 invariant, 6 snapshot. **Needs Rifqi's go-ahead to produce a real band** |
|
| 460 |
+
| K19 | **Step 6 — spend ceiling in cluster units** | Rifqi | ✅ | **Done 2026-09-11.** Found first: `estimate_cost` summed **only the glossary branch** under a field named `estimated_prompt_tokens`, so the ceiling read a partial number as a total — undercount **BUMA 18.4% · Komatsu 8.8% · Open Pit 15.2%**, moving Open Pit 67.7% → **78.0%** of the 2M ceiling. Now all four branches (glossary exact, the rest approximated from prefix + chunk text, both labelled). Second ceiling **`MAX_CLUSTERS = 750`** — exact, free, and what drives spend; at ~3,200 tokens/cluster the token ceiling still binds first for these documents, so this guards the *other* shape (many cheap clusters, 494 calls ≈ 15 min). Refusals name the ceiling and the declined call count; `IngestJob.estimated_cost` (declared since the job model, **never populated**) now carries the per-document breakdown; a wholly-refused batch settles **`blocked`**, which the contract documented and the code could not produce |
|
| 461 |
+
| K20 | **Step 5 — genre classification** | Rifqi | 🔄 | **Classifier done and correct on all three documents; routing deliberately OFF.** `src/knowledge_extraction/genre.py` → `standard` · `diagnostic` · `conceptual` · `unknown` from four free signals (heading coverage · tabular ratio · abbrev pairs · rule density), which separate in *different directions* — measured BUMA 46%/29%/5/0.57, Komatsu 1%/74%/0/0.01, Open Pit 86%/7%/0/1.48. **`ROUTE_LABELS` and `ROUTE_BRANCHES` are both `False`**: the genre label sets are unscoreable without a second gold set (more mentions ≠ better recall) and branch-skipping rests on one document. Today it is observability — `well_served=False` warns *before* spend that a shop manual yields almost nothing, and heading coverage raises a per-document warning (**P5**, partly: the warning exists, a parsing-side fallback does not). **Flipping either lever is Rifqi's call** |
|
| 462 |
+
| K21 | **Finding 6 — escalation retired** | Rifqi | ✅ | **Done 2026-09-11, with the number Finding 6 asked for.** Across all six persisted paid runs (v1 K=3 and v2 K=5 × three documents): **48 escalation calls, 0 definitions recovered** — `extraction_status == "escalated"` appears zero times in every file. Why it cannot work as built: escalation hands the model the *next* k evidence chunks, ranked **below** ones it already declined, and 16 of 18 gold definitions sit at rank ≤3. `MAX_ESCALATION_ROUNDS = 0`; loop and call site untouched. Reviving it means changing the **question** (different prompt or tier), not the evidence |
|
| 463 |
+
| K22 | **The over-cap window (pre-existing)** | Rifqi | ✅ | **Done 2026-09-11.** `WINDOW_WORDS` caps words; GLiNER truncates on **subwords** and warns rather than failing. Measured with GLiNER's own tokeniser: **1 window of 368** exceeds the 384-token cap — BUMA `STD_2026_006_MNO::0024`, 130 words / 738 chars → **409 tokens** — losing its tail at *every* window size including 130, exactly as the 2026-09-10 sweep suspected and left. Windows are now checked against the real tokeniser and split only when they do not fit (a character budget would have to assume the tightest ratio seen, 1.57, and chop up the 367 that fit). Halves overlap by `SPAN_TOKEN_CAP` words so no term straddles a cut. Fixture: 3 splits, mentions **186 → 208**, clusters 74 → 80 (**+8% spend**), **E1 0.8293 and E1c unchanged** |
|
| 464 |
+
| K23 | Step 8 + Step 7 design proposals | Rifqi | 💡 | **Written 2026-09-11, nothing built, both need sign-off.** [TABLE_BRANCH_PROPOSAL.md](docs/knowledge/TABLE_BRANCH_PROPOSAL.md) — deterministic (row header, column header, cell mark) → assertion, one call per **table** (59 on Komatsu, fewer than the 93 the glossary branch spends to abstain 93 times), provenance at the cell; five decisions listed, **T4 (what `table_html` shapes the parser emits) is free and comes first**. [TERM_HIERARCHY_PROPOSAL.md](docs/knowledge/TERM_HIERARCHY_PROPOSAL.md) — parent/child **with** cross-document identity because SciCo's point is they are one problem; the dominating constraint is that **hierarchy must not enter `entity_id`**, or every correction invalidates expert approvals. Sequenced behind **R4** (still not asked) and Step 0 |
|
| 465 |
+
| K24 | **The 2026-09-08 discussion list — structure, incremental ingest, review** | Rifqi | ✅ | **Done 2026-09-14 (pr/21, 7 commits).** *Struktur data 7/8:* `label` carries a readable `TYPE_SUBJECT` key (`keys.py`) — prefixes from the `kind` vocabulary, rules borrow the term they govern plus a digest since a rule has no canonical surface, and the key is a **label, never an identity** (`entity_id` stays the hash approvals hang on). `content`/`provenance`/`doc_ids` promoted out of `payload`; `get_by_key()` + `glossary_index()` make lookup O(1) with no model call. **m7 parked** — the `kind` CHECK would reject `'summary'`. *Ingest bertahap 7/7:* same `doc_id` + new `content_hash` = a new VERSION (reuse check moved to any-version, or idempotency would become a paid no-op); an approved term skips the paid call; staleness compares **only the ruled-on fields** (`mutation.py`) because comparing the payload re-opens everything on every ingest and comparing document `content_hash` flips untouched entries; `impacted_by_document()`; withdrawal marks and keeps rather than deletes. *Sistem review 4/4:* `review_mode` at ingest, `decided_by` + `authorised_by` so an LLM-mode approval can say whose decision it was, and `review_diff()` naming which fields moved. **7 columns added to dedorch** (§6/§7 of `knowledge_tables.sql`, run by hand **after** the handoff went to Harry, verified read-only against live — Harry still needs both in the migration set). Suite **816 passed / 7 skipped** (was 742), ruff clean on touched paths, `import main` 0. **Inert until the review queue has rows:** reuse and staleness both key on approved entries, and `knowledge_reviews` is at 0 |
|
| 466 |
|
| 467 |
**Not decided, do not assume:** whether the admin ingest surface is HTTP at all (the offline CLI is
|
| 468 |
the honest milestone and both halves are there now); and GLiNER + LangExtract ensembling stays ⏸️
|
|
@@ -5,7 +5,7 @@
|
|
| 5 |
|
| 6 |
**Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
|
| 7 |
**Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
|
| 8 |
-
**Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Agent-quality fixes 2026-07-08 (pr/13):** from the scoped live-test review — the planner gains an explicit **infeasible** outcome (`TaskList.infeasible_reason` → deterministic EN/ID data-gap reply via `refusals.data_gap_message`; no more force-mapping absent measures like `pa` AS "revenue"), the IR validator rejects bare selects under `group_by` (self-corrects via the planner retry), `analyze_trend` handles integer year/month columns (was collapsing every row into one 1970-01 bucket), planner few-shots add top-N (Example G) + infeasible (Example H), numeric catalog `sample_values` are base64-decoded at read (`catalog/sample_decode.py` — stopgap for Go's byte-marshaling; primary fix is Go-side), traceability no longer emits null source rows for failed retrievals, and `check_data` hides `-1` row counts. **Report v2 + analyze_merge planner support 2026-07-09 (pr/13):** Sofia's `analyze_merge` tool (8abf635, KM-703) is now planner-supported (`_validate_data_source` guards `data_right`, two-retrieve→merge few-shot Example I, planner.md "Two measures per entity" rule); the report gains per-business-question answers (`bq_answers` — drafted by the SAME single LLM call, index-based record refs, deterministic fallback unchanged), "Attempted, Unresolved" + "Excluded Analyses" sections (failed runs are no longer silently dropped), evidence tables copied from `results_snapshot` (table-kind outputs, ≤3/record ≤10 rows ≤8 cols, `check_*` skipped), normalized caveat dedupe with caps (12/10), and single-language output via `detect_reply_language`; the report surface adds `GET /tools/report/{analysis_id}/records` (curation list), `GET …/readiness` (FE delta guard), and `exclude_record_ids` on POST — see API_CONTRACT_BE_PYTHON.md. **Report compaction 2026-07-09 (pr/13):** the rendered markdown drops the "Notes & Limitations", "Attempted, Unresolved", and "How This Was Analyzed" sections (team decision — compact report; render blocks commented out in `report/generator.py`, not deleted). The JSON body keeps `caveats`/`open_questions`/`unresolved`/`method_steps` and the curation/records endpoints are unchanged. **Traceability `data_used` layer 2026-07-13 (pr/15):** `GET /api/v1/traceability` gains a resolved, user-facing `data_used[]` block (one per `retrieve_data` call) — real source/table/column names, joins, plain-language filters, and result columns split into read-from-data vs `computed` (with `formula`, e.g. `total_revenue` = `SUM(line_total)`, so an alias is never shown as a real column). Ids are kept but **machine-only (FE must not render)**. Also adds `tool_calls[].summary`, and `sources[]` now carry `source_name` + every table touched. Deterministic catalog resolution (new `src/traceability/resolve.py`), no LLM, never-throw; catalog threaded to the scratchpad at the slow-path composition root. Additive/non-breaking; contract + `TRACEABILITY_FE_HANDOFF.md` updated. **Spine v2 W2+W1 2026-07-13 (pr/16):** §6/§7/§8/§9/§12 — `render_chart` tool lands (first of the `render_*` family: deterministic Plotly-JSON `dataeyond.chart.v1` envelope, hand-built, **no plotly dependency**; planner-selected only on an explicit chart ask, EN/ID) + Python-owned `message_charts` store + `GET /api/v1/charts` (FE fetches on `done`, same pattern as traceability; empty list = valid 200); planner gains a named **recipe table** + viz few-shots (Example J tail, Example K viz-infeasible) + validator Check 10 (`render_chart.data` must reference a table-producing task); and the slow path gains the **S1a quality checkpoint** (`slow_path/checkpoint.py`, 0 LLM, never-throw, between runner and assembler: CK1 all-failed → deterministic honest-failure answer with **no** assembler call, CK2 empty retrieve + downstream, CK3 10k-cap truncation, CK4 single trend bucket, CK5 all-null column, CK6 chart-spec sanity; flags render as an "Execution assessment" block in the assembler input and every flag logs `repair_candidate` — the S1b evidence base). Design + handoff doc: `SPINE_V2_PLAN.md`. **LLM model + env rename 2026-07-14 (pr/17):** §2/§3/§9/§13 — the generation LLM is now **Azure GPT-5.4-mini** (deployment `gpt-5.4-mini`), not GPT-4o, and the settings quad is renamed `azureai__*__4o` → **`azureai__*__54m`** across all 9 LLM call sites. **Hard rename, no `__4o` fallback** — an environment that still sets only `__4o` resolves to empty strings and fails on the first LLM call, by design: the silent-wrong-model drift is exactly how HF stayed on GPT-4o while local ran 5.4-mini (identical question, identical catalog, divergent planner output — HF hallucinated catalog ids, local planned correctly). Deploying requires all four `__54m` vars set in the HF Space secrets. **`reports.user_id` fix 2026-07-22 (pr/18):** §8/§9/§12/§13 — `ReportStore.save` never wrote `reports.user_id`, and on the new **Neon** dedorch instance that column is `text NOT NULL`, so **every `POST /api/v1/tools/report` 500'd** (`NotNullViolationError`; report persistence deliberately re-raises rather than degrading). The ORM gains `user_id` (declared **nullable** so Python tolerates every deployment shape) and the store writes `report.user_id`, which the endpoint already required as a query param and threaded through the generator. Live-verified against `information_schema` + a green 201. **Root cause (verified against the Go source after pulling `dd37b38`):** the column is *not* new — `0001_create_core_schema.sql` has declared it `NOT NULL` all along, but `0002`/`0004` retrofit it onto pre-existing DBs as a **nullable** `ADD COLUMN IF NOT EXISTS`. The old DB therefore accepted the column-less INSERT and the bug stayed latent; a fresh DB enforces it. Generalized as a §13 gotcha — **Go's migration set is not convergent, so fresh and migrated dedorch DBs differ in NOT NULL constraints** and the migrations alone can't tell you which shape you have. A full ORM-vs-live drift scan the same day found all five Python-written tables clean; the remaining drift is on read-only or unwired paths. **Knowledge pipeline 2026-08-21:** new **§14** — both halves of the knowledge pipeline are now in-tree (`src/knowledge_parsing/` · `src/knowledge_extraction/` · `eval/knowledge/`), as **offline CLIs with no HTTP surface, no table and nothing mounted in `main.py`**. They meet at one versioned `ParsedDocument` artifact; extraction never takes a file path. Term-filter recall **0.8537**, matching the prototype baseline. The existing `src/knowledge/` OCR→pgvector RAG path is untouched. Deps: `rapidfuzz`/`gliner`/`torch` main, `mineru` an optional extra. **Superseded 2026-09-07:** `mineru` dropped (commented out) and `torch` pinned to the Linux CPU wheel — 36 `nvidia-*` packages and `triton` gone from `uv.lock`; §14. **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
|
| 9 |
the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
|
| 10 |
own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
|
| 11 |
**`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
|
|
@@ -195,6 +195,7 @@ unless `SKIP_INIT_DB=true`.
|
|
| 195 |
| `analyses_messages` *(dedorch)* | the analysis chat room (`role ∈ user\|ai`); replaces deprecated `rooms`/`chat_messages` | Go `/analyses/{id}/messages` | Python chat path **not yet migrated here** (§12) |
|
| 196 |
| `message_traceability` *(Python-owned)* | one jsonb `TraceabilityPayload` per assistant turn (PK `message_id`); flushed before `done` | chat pipeline (KM-691) | `GET /api/v1/traceability` |
|
| 197 |
| `message_charts` *(Python-owned, added 2026-07-13)* | one row per `render_chart` chart — `spec` jsonb holds the full `dataeyond.chart.v1` envelope; keyed (`analysis_id`, `message_id`), multiple rows per turn allowed; written before `done`, never-throw | slow-path chart persist (`chat_handler._run_slow_path`) | `GET /api/v1/charts` |
|
|
|
|
| 198 |
|
| 199 |
> ✅ **Python ORM ↔ dedorch drift — reconciled 2026-07-01.** `AnalysisStateRow` (`analyses`) dropped
|
| 200 |
> `problem_statement`/`problem_validated` and added `objective`/`business_questions` (Harry's #3);
|
|
@@ -522,6 +523,12 @@ records-based report; floor: ≥1 `analyze_*` success). Wiring Go → Python is
|
|
| 522 |
|
| 523 |
## 14. Knowledge pipeline — offline subsystems (added 2026-08-21)
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
**Updated 2026-09-07 — this is now on the live surface.** The line that stood here
|
| 526 |
("no endpoint, no database table, nothing mounted") is out of date. Eight routes under
|
| 527 |
`/api/v1/knowledge` are mounted in `main.py` (`src/api/v1/knowledge.py`), and five tables exist in
|
|
@@ -532,8 +539,8 @@ RAG.**
|
|
| 532 |
|
| 533 |
| | |
|
| 534 |
|---|---|
|
| 535 |
-
| Surface | `POST /knowledge/ingest` · `GET /knowledge/jobs/{id}` · `GET /knowledge/jobs` · `GET /knowledge/documents` · `POST /knowledge/documents/{id}/extract` · `GET /knowledge/entries` · `GET /knowledge/queue` · `POST /knowledge/entries/{entity_id}/reviews` |
|
| 536 |
-
| Tables | `knowledge_documents` · `knowledge_extraction_runs` · `knowledge_entries` · `knowledge_reviews` · `knowledge_jobs` |
|
| 537 |
| Orchestration | `src/knowledge_ingest/` — job lifecycle, tenant scope, the pipeline port. Owns no parsing and no extraction logic |
|
| 538 |
| Contract | `API_CONTRACT_BE_PYTHON.md` §Knowledge |
|
| 539 |
|
|
@@ -569,8 +576,102 @@ behind them in [KNOWLEDGE_PIPELINE_CALIBRATION.md](docs/knowledge/KNOWLEDGE_PIPE
|
|
| 569 |
| Extraction | `src/knowledge_extraction/` | Rifqi | `python -m src.knowledge_extraction.cli <artifact.json> --extract` |
|
| 570 |
| Eval harness | `eval/knowledge/` | Rifqi | gold set + scorer + frozen baseline |
|
| 571 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
> **Current (2026-09-07):** the parser is **Mistral OCR** (`--backend mistral`, no GPU; normalizer
|
| 573 |
-
> `normalize_mistral.py`, section-level). `schema_version` is **`0.
|
|
|
|
| 574 |
> `normaliser_version`, and a computed 1-based `page_no`).
|
| 575 |
>
|
| 576 |
> **MinerU was dropped 2026-09-07** — commented out of the `knowledge-parsing` extra and installed
|
|
@@ -586,7 +687,7 @@ behind them in [KNOWLEDGE_PIPELINE_CALIBRATION.md](docs/knowledge/KNOWLEDGE_PIPE
|
|
| 586 |
### The seam
|
| 587 |
|
| 588 |
The two halves meet at **one persisted, versioned artifact** — `ParsedDocument` in
|
| 589 |
-
`src/knowledge_parsing/contracts.py` (`schema_version` **`0.
|
| 590 |
never takes a file path and never touches the parser**, which is what keeps MinerU swappable: a
|
| 591 |
Tesseract or Azure Document Intelligence path emits the same artifact and extraction never learns
|
| 592 |
which one ran. `knowledge_extraction/adapter.py` is the only module that knows the shape.
|
|
@@ -666,9 +767,15 @@ silent content drop worth **+0.1220** on the way: `normalize.py` was discarding
|
|
| 666 |
Indonesian definitions, 0 span rejections, 7,168 of 8,029 prompt tokens cached. Full-document cost
|
| 667 |
on the prototype was **$0.0069**.
|
| 668 |
|
| 669 |
-
**Not yet done:** persistence (stage output is JSON on disk; the DDL handoff to Harry is open), any
|
| 670 |
HTTP surface, a full-document run on v2, and **schema-fill precision has never been scored on v2** —
|
| 671 |
-
the one prototype experiment that failed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
|
| 673 |
### Dependencies
|
| 674 |
|
|
|
|
| 5 |
|
| 6 |
**Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
|
| 7 |
**Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
|
| 8 |
+
**Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Agent-quality fixes 2026-07-08 (pr/13):** from the scoped live-test review — the planner gains an explicit **infeasible** outcome (`TaskList.infeasible_reason` → deterministic EN/ID data-gap reply via `refusals.data_gap_message`; no more force-mapping absent measures like `pa` AS "revenue"), the IR validator rejects bare selects under `group_by` (self-corrects via the planner retry), `analyze_trend` handles integer year/month columns (was collapsing every row into one 1970-01 bucket), planner few-shots add top-N (Example G) + infeasible (Example H), numeric catalog `sample_values` are base64-decoded at read (`catalog/sample_decode.py` — stopgap for Go's byte-marshaling; primary fix is Go-side), traceability no longer emits null source rows for failed retrievals, and `check_data` hides `-1` row counts. **Report v2 + analyze_merge planner support 2026-07-09 (pr/13):** Sofia's `analyze_merge` tool (8abf635, KM-703) is now planner-supported (`_validate_data_source` guards `data_right`, two-retrieve→merge few-shot Example I, planner.md "Two measures per entity" rule); the report gains per-business-question answers (`bq_answers` — drafted by the SAME single LLM call, index-based record refs, deterministic fallback unchanged), "Attempted, Unresolved" + "Excluded Analyses" sections (failed runs are no longer silently dropped), evidence tables copied from `results_snapshot` (table-kind outputs, ≤3/record ≤10 rows ≤8 cols, `check_*` skipped), normalized caveat dedupe with caps (12/10), and single-language output via `detect_reply_language`; the report surface adds `GET /tools/report/{analysis_id}/records` (curation list), `GET …/readiness` (FE delta guard), and `exclude_record_ids` on POST — see API_CONTRACT_BE_PYTHON.md. **Report compaction 2026-07-09 (pr/13):** the rendered markdown drops the "Notes & Limitations", "Attempted, Unresolved", and "How This Was Analyzed" sections (team decision — compact report; render blocks commented out in `report/generator.py`, not deleted). The JSON body keeps `caveats`/`open_questions`/`unresolved`/`method_steps` and the curation/records endpoints are unchanged. **Traceability `data_used` layer 2026-07-13 (pr/15):** `GET /api/v1/traceability` gains a resolved, user-facing `data_used[]` block (one per `retrieve_data` call) — real source/table/column names, joins, plain-language filters, and result columns split into read-from-data vs `computed` (with `formula`, e.g. `total_revenue` = `SUM(line_total)`, so an alias is never shown as a real column). Ids are kept but **machine-only (FE must not render)**. Also adds `tool_calls[].summary`, and `sources[]` now carry `source_name` + every table touched. Deterministic catalog resolution (new `src/traceability/resolve.py`), no LLM, never-throw; catalog threaded to the scratchpad at the slow-path composition root. Additive/non-breaking; contract + `TRACEABILITY_FE_HANDOFF.md` updated. **Spine v2 W2+W1 2026-07-13 (pr/16):** §6/§7/§8/§9/§12 — `render_chart` tool lands (first of the `render_*` family: deterministic Plotly-JSON `dataeyond.chart.v1` envelope, hand-built, **no plotly dependency**; planner-selected only on an explicit chart ask, EN/ID) + Python-owned `message_charts` store + `GET /api/v1/charts` (FE fetches on `done`, same pattern as traceability; empty list = valid 200); planner gains a named **recipe table** + viz few-shots (Example J tail, Example K viz-infeasible) + validator Check 10 (`render_chart.data` must reference a table-producing task); and the slow path gains the **S1a quality checkpoint** (`slow_path/checkpoint.py`, 0 LLM, never-throw, between runner and assembler: CK1 all-failed → deterministic honest-failure answer with **no** assembler call, CK2 empty retrieve + downstream, CK3 10k-cap truncation, CK4 single trend bucket, CK5 all-null column, CK6 chart-spec sanity; flags render as an "Execution assessment" block in the assembler input and every flag logs `repair_candidate` — the S1b evidence base). Design + handoff doc: `SPINE_V2_PLAN.md`. **LLM model + env rename 2026-07-14 (pr/17):** §2/§3/§9/§13 — the generation LLM is now **Azure GPT-5.4-mini** (deployment `gpt-5.4-mini`), not GPT-4o, and the settings quad is renamed `azureai__*__4o` → **`azureai__*__54m`** across all 9 LLM call sites. **Hard rename, no `__4o` fallback** — an environment that still sets only `__4o` resolves to empty strings and fails on the first LLM call, by design: the silent-wrong-model drift is exactly how HF stayed on GPT-4o while local ran 5.4-mini (identical question, identical catalog, divergent planner output — HF hallucinated catalog ids, local planned correctly). Deploying requires all four `__54m` vars set in the HF Space secrets. **`reports.user_id` fix 2026-07-22 (pr/18):** §8/§9/§12/§13 — `ReportStore.save` never wrote `reports.user_id`, and on the new **Neon** dedorch instance that column is `text NOT NULL`, so **every `POST /api/v1/tools/report` 500'd** (`NotNullViolationError`; report persistence deliberately re-raises rather than degrading). The ORM gains `user_id` (declared **nullable** so Python tolerates every deployment shape) and the store writes `report.user_id`, which the endpoint already required as a query param and threaded through the generator. Live-verified against `information_schema` + a green 201. **Root cause (verified against the Go source after pulling `dd37b38`):** the column is *not* new — `0001_create_core_schema.sql` has declared it `NOT NULL` all along, but `0002`/`0004` retrofit it onto pre-existing DBs as a **nullable** `ADD COLUMN IF NOT EXISTS`. The old DB therefore accepted the column-less INSERT and the bug stayed latent; a fresh DB enforces it. Generalized as a §13 gotcha — **Go's migration set is not convergent, so fresh and migrated dedorch DBs differ in NOT NULL constraints** and the migrations alone can't tell you which shape you have. A full ORM-vs-live drift scan the same day found all five Python-written tables clean; the remaining drift is on read-only or unwired paths. **Knowledge pipeline 2026-08-21:** new **§14** — both halves of the knowledge pipeline are now in-tree (`src/knowledge_parsing/` · `src/knowledge_extraction/` · `eval/knowledge/`), as **offline CLIs with no HTTP surface, no table and nothing mounted in `main.py`**. They meet at one versioned `ParsedDocument` artifact; extraction never takes a file path. Term-filter recall **0.8537**, matching the prototype baseline. The existing `src/knowledge/` OCR→pgvector RAG path is untouched. Deps: `rapidfuzz`/`gliner`/`torch` main, `mineru` an optional extra. **Superseded 2026-09-07:** `mineru` dropped (commented out) and `torch` pinned to the Linux CPU wheel — 36 `nvidia-*` packages and `triton` gone from `uv.lock`; §14. **Knowledge 8-Sep task list 2026-09-14 (pr/21):** §8/§14 — the discussion's structure-data, incremental-ingest and review items are built. `knowledge_entries.label` now carries a readable `TYPE_SUBJECT` key (`GLOSSARY_PA`) so a lookup is a dictionary hit rather than a scan, with `entity_id` untouched as the thing approvals hang on; a re-uploaded document becomes a **new version** rather than a new document, so its approvals survive; an already-approved term is no longer re-extracted (one LLM call per cluster is the whole cost of that branch); an approval goes stale only when the **content the expert ruled on** changed, never when a count moved; a withdrawn document stops feeding the baseline without anything being deleted; and ingest takes a `review_mode` — `llm` approves on arrival with no reviewer step, recorded against the person who chose it. Seven columns added to dedorch (run by hand 2026-09-14 **after** the handoff went to Harry, and verified read-only against live). **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
|
| 9 |
the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
|
| 10 |
own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
|
| 11 |
**`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
|
|
|
|
| 195 |
| `analyses_messages` *(dedorch)* | the analysis chat room (`role ∈ user\|ai`); replaces deprecated `rooms`/`chat_messages` | Go `/analyses/{id}/messages` | Python chat path **not yet migrated here** (§12) |
|
| 196 |
| `message_traceability` *(Python-owned)* | one jsonb `TraceabilityPayload` per assistant turn (PK `message_id`); flushed before `done` | chat pipeline (KM-691) | `GET /api/v1/traceability` |
|
| 197 |
| `message_charts` *(Python-owned, added 2026-07-13)* | one row per `render_chart` chart — `spec` jsonb holds the full `dataeyond.chart.v1` envelope; keyed (`analysis_id`, `message_id`), multiple rows per turn allowed; written before `done`, never-throw | slow-path chart persist (`chat_handler._run_slow_path`) | `GET /api/v1/charts` |
|
| 198 |
+
| `knowledge_documents` · `knowledge_extraction_runs` · `knowledge_entries` · `knowledge_reviews` · `knowledge_jobs` *(Python-written, Go-owned schema)* | The knowledge pipeline's five tables (§14). **2026-09-14: 7 columns added** — `knowledge_entries.doc_ids`/`.content`/`.provenance` (promoted out of `payload`), `knowledge_documents.withdrawn_at`, `knowledge_jobs.review_mode`, `knowledge_reviews.decided_by`/`.authorised_by`. `label` now carries a readable `TYPE_SUBJECT` key (`GLOSSARY_PA`), not the term. DDL + adoption status: `docs/knowledge/knowledge_tables.sql` | knowledge ingest pipeline | `/api/v1/knowledge/*`, `KnowledgeService` |
|
| 199 |
|
| 200 |
> ✅ **Python ORM ↔ dedorch drift — reconciled 2026-07-01.** `AnalysisStateRow` (`analyses`) dropped
|
| 201 |
> `problem_statement`/`problem_validated` and added `objective`/`business_questions` (Harry's #3);
|
|
|
|
| 523 |
|
| 524 |
## 14. Knowledge pipeline — offline subsystems (added 2026-08-21)
|
| 525 |
|
| 526 |
+
**Updated 2026-09-14 — the 8 Sep discussion landed.** Two claims below were stale and are
|
| 527 |
+
corrected here: the surface is **ten** routes, not eight (`/knowledge/domain/propose` and
|
| 528 |
+
`/knowledge/domain` were added 2026-09-08), and **the analytics path does read what this
|
| 529 |
+
produces** — the consumption path was armed 2026-09-08 (§0.8 K5/K9/K11), approved entries only.
|
| 530 |
+
The 2026-09-08 task list (m*, i*, r*) is built: see **§0.8 K24**.
|
| 531 |
+
|
| 532 |
**Updated 2026-09-07 — this is now on the live surface.** The line that stood here
|
| 533 |
("no endpoint, no database table, nothing mounted") is out of date. Eight routes under
|
| 534 |
`/api/v1/knowledge` are mounted in `main.py` (`src/api/v1/knowledge.py`), and five tables exist in
|
|
|
|
| 539 |
|
| 540 |
| | |
|
| 541 |
|---|---|
|
| 542 |
+
| Surface | **10 routes** (2026-09-14 count): `POST /knowledge/ingest` · `GET /knowledge/jobs/{id}` · `GET /knowledge/jobs` · `GET /knowledge/documents` · `POST /knowledge/documents/{id}/extract` · `GET /knowledge/entries` · `GET /knowledge/queue` · `POST /knowledge/entries/{entity_id}/reviews` · `POST /knowledge/domain/propose` · `GET /knowledge/domain` |
|
| 543 |
+
| Tables | `knowledge_documents` · `knowledge_extraction_runs` · `knowledge_entries` · `knowledge_reviews` · `knowledge_jobs`. **7 columns added 2026-09-14** — see §8 |
|
| 544 |
| Orchestration | `src/knowledge_ingest/` — job lifecycle, tenant scope, the pipeline port. Owns no parsing and no extraction logic |
|
| 545 |
| Contract | `API_CONTRACT_BE_PYTHON.md` §Knowledge |
|
| 546 |
|
|
|
|
| 576 |
| Extraction | `src/knowledge_extraction/` | Rifqi | `python -m src.knowledge_extraction.cli <artifact.json> --extract` |
|
| 577 |
| Eval harness | `eval/knowledge/` | Rifqi | gold set + scorer + frozen baseline |
|
| 578 |
|
| 579 |
+
> ## Updated 2026-09-11 — the open-queue pass (queue ordering · measurement · ceilings · genre)
|
| 580 |
+
>
|
| 581 |
+
> Six items from `knowledge_pipeline_runs/SESSION_HANDOFF.md` §4, **all measured at zero API spend**
|
| 582 |
+
> (replays of the persisted paid runs, plus free stages on the three v2 artifacts).
|
| 583 |
+
>
|
| 584 |
+
> **The review queue no longer buries its own product (Step 2).** Unresolved key parameters held a
|
| 585 |
+
> sort tier beneath conflicts, so *every* one of them outranked *every* routine term. Replaying the
|
| 586 |
+
> three v2 runs: Komatsu's `Engine` (108 mentions) moves **rank 25 → 1**, Open Pit's `Resource`
|
| 587 |
+
> **8 → 1**, the first junk row moves **1 → 6** on both, and row counts are identical — nothing is
|
| 588 |
+
> dropped. They are now spliced in at most 1 per 5 term rows. A `key_parameter`'s `term` also carries
|
| 589 |
+
> a **head** (split at the first `.` `:` or newline, only past 60 chars) with the untruncated text in
|
| 590 |
+
> the new **`surface_full`** — the branch picks the right concepts and returns them at paragraph
|
| 591 |
+
> length (185 chars in one case).
|
| 592 |
+
>
|
| 593 |
+
> **Measurement hardening (Step 0).** `eval/knowledge/invariants.py` asserts on **fields and
|
| 594 |
+
> invariants, not exact text** — a span that cannot be located, a duplicate entity id, a rejected
|
| 595 |
+
> field that still carries a value, a queue whose ranks are not contiguous. `--runs N` repeats only
|
| 596 |
+
> the paid stage and reports **min/median/max** (the value becomes the *median*) plus whether every
|
| 597 |
+
> run minted identical entity ids, which is the K11 defect class and has been unwatched since. The
|
| 598 |
+
> pinned model snapshot is now an **assertion**, not a log line: a run served by a different snapshot
|
| 599 |
+
> exits 6 and must not be compared to the committed results.
|
| 600 |
+
>
|
| 601 |
+
> **Two spend ceilings, and a corrected estimate (Step 6).** `estimate_cost` summed **only the
|
| 602 |
+
> glossary branch** while reporting a field named `estimated_prompt_tokens` — so the ceiling gating
|
| 603 |
+
> on it read a partial number as a whole one. Measured undercount: **8.8%–18.4%**; Open Pit moves
|
| 604 |
+
> from 67.7% to **78.0%** of the token ceiling. A second ceiling now gates on **cluster count**
|
| 605 |
+
> (exact, free, and what actually drives spend — one call per cluster), `MAX_CLUSTERS = 750`. A
|
| 606 |
+
> refusal names which ceiling bound and how many calls were declined; `IngestJob.estimated_cost`,
|
| 607 |
+
> declared since the job model was written and **never populated**, now carries the per-document
|
| 608 |
+
> breakdown; and a wholly-refused batch settles **`blocked`** rather than `failed`, which the
|
| 609 |
+
> contract had documented and the code could not produce.
|
| 610 |
+
>
|
| 611 |
+
> **Genre classification (Step 5).** `src/knowledge_extraction/genre.py` names a document
|
| 612 |
+
> `standard` · `diagnostic` · `conceptual` · `unknown` from four free signals, and gets all three
|
| 613 |
+
> reference documents right. **Routing is OFF** (`ROUTE_LABELS` / `ROUTE_BRANCHES`) — the label sets
|
| 614 |
+
> are unscoreable without a second gold set, and branch-skipping is confirmed on one document — so
|
| 615 |
+
> today this is observability: `well_served=False` says *before* spending that a shop manual will
|
| 616 |
+
> yield almost nothing. Heading coverage now raises a per-document warning (Komatsu: 1 of 80 chunks).
|
| 617 |
+
>
|
| 618 |
+
> **Escalation is retired (Finding 6), with the number that was asked for.** Counted off all six
|
| 619 |
+
> persisted paid runs: **48 escalation calls, 0 definitions recovered**. `extraction_status` is set
|
| 620 |
+
> to `escalated` only when a later round succeeds where round 0 failed, and it appears zero times in
|
| 621 |
+
> every file. `MAX_ESCALATION_ROUNDS = 0`; the loop and its call site are untouched.
|
| 622 |
+
>
|
| 623 |
+
> **The over-cap window is fixed.** `WINDOW_WORDS` caps *words*; GLiNER truncates on *subwords* and
|
| 624 |
+
> warns rather than failing. Measured with GLiNER's own tokeniser: **1 window in 368** is over the
|
| 625 |
+
> 384-token cap — BUMA `::0024`, 130 words / 738 chars → **409 tokens** — and it has been losing its
|
| 626 |
+
> tail at every window size, including 130. Windows are now checked against the model's real
|
| 627 |
+
> tokeniser and split only if they do not fit. On the committed fixture: 3 windows split, mentions
|
| 628 |
+
> **186 → 208**, clusters 74 → 80 (**+8% spend**), **E1 and E1c unchanged**.
|
| 629 |
+
>
|
| 630 |
+
> Proposals written, nothing built: [TABLE_BRANCH_PROPOSAL.md](docs/knowledge/TABLE_BRANCH_PROPOSAL.md)
|
| 631 |
+
> (Step 8) and [TERM_HIERARCHY_PROPOSAL.md](docs/knowledge/TERM_HIERARCHY_PROPOSAL.md) (Step 7 + R7).
|
| 632 |
+
|
| 633 |
+
> **Updated 2026-09-10 — parsing `schema_version` 0.4.0 (asset pointers) + clustering fix.** Two
|
| 634 |
+
> changes, both measured on three documents (BUMA standard · Komatsu shop manual · Open Pit textbook
|
| 635 |
+
> Ch.1), at **zero API spend** — the parse cache fingerprint is unchanged so re-parses reuse the
|
| 636 |
+
> cached Mistral responses.
|
| 637 |
+
>
|
| 638 |
+
> **Clustering: the fuzzy pass is RETIRED.** `token_set_ratio` returns exactly 100 when one token set
|
| 639 |
+
> is a subset of the other, so a head noun always absorbed its own compounds and the threshold of 92
|
| 640 |
+
> was inert. 131 of its 140 merges were subset matches (all wrong); 6 were plurals, now folded
|
| 641 |
+
> deterministically by `normalize(lemma=True)`; 3 were distinct metrics wrongly merged. Matching is now
|
| 642 |
+
> `exact (plural-folded) → abbreviation ↔ expansion`, nothing else. Token-subset merges **15/18/97 → 0
|
| 643 |
+
> on all three documents**, and post-cluster gold recall 0.8293 → **0.8537** (the gold term `EWH`, which
|
| 644 |
+
> `Working Hours` had absorbed, is recovered). `_fuzzy_match` is parked, not deleted.
|
| 645 |
+
>
|
| 646 |
+
> **Parsing 0.4.0: a figure is no longer its own chunk.** It used to be, with an EMPTY `text`, which
|
| 647 |
+
> made it unreachable by construction — no text → no mentions → no cluster → never evidence. Measured:
|
| 648 |
+
> **13 figures, each described by a vision model we pay for, and ZERO reaching any prompt.** Now the
|
| 649 |
+
> `` reference is folded into the prose chunk at its reading-order position and the
|
| 650 |
+
> figure travels as an `Asset` (content-addressed `sha256(bytes)[:16]`, persisted bytes, verbatim
|
| 651 |
+
> caption, unwrapped model description). **Image chunks 2/3/8 → 0/0/0**; 0 dangling references; 0
|
| 652 |
+
> model-written descriptions leaked into `text` (the span-check haystack). **Tables deliberately stay
|
| 653 |
+
> their own chunk** — their linearised text earns its evidence slot (74.6% of Komatsu's top-3 evidence
|
| 654 |
+
> is tabular) — and gain an `Asset` plus two-way pointers. `adapter.py` now carries `assets`,
|
| 655 |
+
> `referenced_by` and, finally, **`table_html`**, which parsing had emitted for months while the
|
| 656 |
+
> adapter dropped it. `SUPPORTED_SCHEMA` → (0,4); parsing and extraction ship together by design.
|
| 657 |
+
>
|
| 658 |
+
> **New metric `E1c` (post-cluster recall)** in `eval/knowledge/score.py`: the gold set scored AFTER
|
| 659 |
+
> clustering, which is the window E1 (measured before) and E3 (only over entries that produced a
|
| 660 |
+
> definition) both miss. **E2's compression kill line was REMOVED** — over-merging *raises* compression,
|
| 661 |
+
> so the metric was rewarding the defect.
|
| 662 |
+
>
|
| 663 |
+
> **Two settings changes were measured and REJECTED:** `WINDOW_WORDS` 256 (truncates 46 of 99 Open Pit
|
| 664 |
+
> windows — the "flat recall" that justified it was BUMA-only, the one document whose truncation count
|
| 665 |
+
> does not vary with window size) and `SPAN_SCORE_THRESHOLD` 0.15 (+50% clusters against a +20% bar,
|
| 666 |
+
> despite E1 0.878 → 0.976). `EVIDENCE_K` was raised 3 → 5. Detail:
|
| 667 |
+
> `knowledge_pipeline_runs/CHANGELOG_v2.md`.
|
| 668 |
+
>
|
| 669 |
+
> ⚠️ **Nothing about definition QUALITY is claimed yet** — the paid comparison has not run. Everything
|
| 670 |
+
> above is recall and structural integrity.
|
| 671 |
+
|
| 672 |
> **Current (2026-09-07):** the parser is **Mistral OCR** (`--backend mistral`, no GPU; normalizer
|
| 673 |
+
> `normalize_mistral.py`, section-level). `schema_version` is **`0.4.0`** as of 2026-09-10 (asset
|
| 674 |
+
> pointers, K14); 0.3.3 added `source_title`,
|
| 675 |
> `normaliser_version`, and a computed 1-based `page_no`).
|
| 676 |
>
|
| 677 |
> **MinerU was dropped 2026-09-07** — commented out of the `knowledge-parsing` extra and installed
|
|
|
|
| 687 |
### The seam
|
| 688 |
|
| 689 |
The two halves meet at **one persisted, versioned artifact** — `ParsedDocument` in
|
| 690 |
+
`src/knowledge_parsing/contracts.py` (`schema_version` **`0.4.0`** as of 2026-09-10; `SUPPORTED_SCHEMA` is `(0, 4)`, so parsing and extraction ship together) — and nowhere else. **Extraction
|
| 691 |
never takes a file path and never touches the parser**, which is what keeps MinerU swappable: a
|
| 692 |
Tesseract or Azure Document Intelligence path emits the same artifact and extraction never learns
|
| 693 |
which one ran. `knowledge_extraction/adapter.py` is the only module that knows the shape.
|
|
|
|
| 767 |
Indonesian definitions, 0 span rejections, 7,168 of 8,029 prompt tokens cached. Full-document cost
|
| 768 |
on the prototype was **$0.0069**.
|
| 769 |
|
| 770 |
+
~~**Not yet done:** persistence (stage output is JSON on disk; the DDL handoff to Harry is open), any
|
| 771 |
HTTP surface, a full-document run on v2, and **schema-fill precision has never been scored on v2** —
|
| 772 |
+
the one prototype experiment that failed.~~
|
| 773 |
+
|
| 774 |
+
**RESOLVED — all four, and the paragraph above is kept only for the record.** Persistence landed
|
| 775 |
+
2026-09-07 (five tables, `knowledge_store.py`) and the HTTP surface with it (8 routes); the
|
| 776 |
+
full-document run is routine (K9, 2026-08-27) and E3 scores **1.000** on the committed fixture —
|
| 777 |
+
read as *precision on a scoreable base of 6*, since 57 of 63 entries abstain. **What is genuinely
|
| 778 |
+
open is in DEV_PLAN §0.8** and, ahead of it, `knowledge_pipeline_runs/SESSION_HANDOFF.md` §4.
|
| 779 |
|
| 780 |
### Dependencies
|
| 781 |
|
|
@@ -39,6 +39,8 @@ Client documents → candidate knowledge entries an expert reviews rather than a
|
|
| 39 |
| [knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md](knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md) | 🔄 | You're working either side of the seam |
|
| 40 |
| [knowledge/KNOWLEDGE_SURFACE_PLAN.md](knowledge/KNOWLEDGE_SURFACE_PLAN.md) | 🔄 | Persistence + endpoints: who owns what, phased tasks, blockers. Approved 2026-09-01 |
|
| 41 |
| [knowledge/KNOWLEDGE_CONSUMPTION_PLAN.md](knowledge/KNOWLEDGE_CONSUMPTION_PLAN.md) | 💡 | How agents READ the entries (N14). Which agent gets what, and why. **Proposal, nothing built** |
|
|
|
|
|
|
|
| 42 |
| [knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md](knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md) | 💡 | What a *real* domain context is. v3's object is a document brief wearing the name. **Proposal, nothing built** |
|
| 43 |
| [knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md](knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md) | 💡 | The four-table DDL sent to Harry 2026-09-01. **Pending — no ORM written against it yet** |
|
| 44 |
| [knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V3.md](knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V3.md) | ✅ | `DomainContext` design reasoning. **Shipped in full 2026-09-02** — the live shape is the output contract §8 |
|
|
|
|
| 39 |
| [knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md](knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md) | 🔄 | You're working either side of the seam |
|
| 40 |
| [knowledge/KNOWLEDGE_SURFACE_PLAN.md](knowledge/KNOWLEDGE_SURFACE_PLAN.md) | 🔄 | Persistence + endpoints: who owns what, phased tasks, blockers. Approved 2026-09-01 |
|
| 41 |
| [knowledge/KNOWLEDGE_CONSUMPTION_PLAN.md](knowledge/KNOWLEDGE_CONSUMPTION_PLAN.md) | 💡 | How agents READ the entries (N14). Which agent gets what, and why. **Proposal, nothing built** |
|
| 42 |
+
| [knowledge/TABLE_BRANCH_PROPOSAL.md](knowledge/TABLE_BRANCH_PROPOSAL.md) | 💡 | Step 8 — the symptom x cause matrix a shop manual actually holds. Deterministic triples, LLM only to phrase. **Proposal, nothing built** |
|
| 43 |
+
| [knowledge/TERM_HIERARCHY_PROPOSAL.md](knowledge/TERM_HIERARCHY_PROPOSAL.md) | 💡 | Step 7 + R7 — parent/child terms and cross-document identity, designed together. **Touches `entity_id`; read before building** |
|
| 44 |
| [knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md](knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md) | 💡 | What a *real* domain context is. v3's object is a document brief wearing the name. **Proposal, nothing built** |
|
| 45 |
| [knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md](knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md) | 💡 | The four-table DDL sent to Harry 2026-09-01. **Pending — no ORM written against it yet** |
|
| 46 |
| [knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V3.md](knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V3.md) | ✅ | `DomainContext` design reasoning. **Shipped in full 2026-09-02** — the live shape is the output contract §8 |
|
|
@@ -24,7 +24,7 @@ nothing consumes.
|
|
| 24 |
|
| 25 |
| Change | Detail |
|
| 26 |
|---|---|
|
| 27 |
-
| **Ids on every entity** | `term_id`, `formula_id`, `
|
| 28 |
| **Glossary no longer restates formulas** | `formula_latex` removed → `defining_formula_id` reference |
|
| 29 |
| **Rules link to formulas and terms** | `formula_ids[]`, `term_ids[]` |
|
| 30 |
| **`rule_type` added** | `interpretation` \| `calculation` — see §2 for why |
|
|
@@ -52,7 +52,8 @@ None is model-generated; none is positional.
|
|
| 52 |
term_id = "t_" + sha256(doc_id + "|" + canonical)[:10]
|
| 53 |
formula_id = "f_" + sha256(doc_id + "|" + name_or_latex_or_chunk_id)[:10]
|
| 54 |
rule_id = "r_" + sha256(doc_id + "|" + chunk_id + "|" + char_start)[:10]
|
| 55 |
-
|
|
|
|
| 56 |
```
|
| 57 |
|
| 58 |
Implemented in `src/knowledge_extraction/ids.py`. `term_id` is keyed on the **cluster canonical**,
|
|
@@ -80,10 +81,42 @@ registry, which belongs with persistence (DEV_PLAN §0.8 D2).
|
|
| 80 |
| `glossary.json` | `GlossaryEntry[]` | Terms and their definitions |
|
| 81 |
| `rules.json` | `RuleEntry[]` | Rules of thumb — how to interpret data or behaviour. **Renamed from `interpretation_pack.json` 2026-09-08** — the file, the entity and the DB `kind` now share one name. *The Interpretation Pack* remains the deliverable's name in prose |
|
| 82 |
| `formulas.json` | `FormulaEntry[]` | Formulas, transcribed from the document |
|
| 83 |
-
| `
|
| 84 |
|
| 85 |
Plus three derived/audit payloads: `review_queue.json`, `rejected.json`, `usage.json`.
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
### Why `RuleEntry` carries a `rule_type`
|
| 88 |
|
| 89 |
The Interpretation Pack exists to **improve analytics insight from expert interpretation rules** —
|
|
@@ -104,6 +137,36 @@ A consumer wanting only interpretation logic filters on `rule_type`.
|
|
| 104 |
|
| 105 |
---
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
## 3 · Transport reality
|
| 108 |
|
| 109 |
Every payload is written by the CLI as a file into `--out-dir` (default `out/knowledge/`), as a
|
|
@@ -343,7 +406,7 @@ consequence can.
|
|
| 343 |
|
| 344 |
---
|
| 345 |
|
| 346 |
-
## 8 · `
|
| 347 |
|
| 348 |
> ✅ **The v3 reshape is complete, corrected 2026-09-08.** This banner previously said the
|
| 349 |
> LLM-facing parts — `purpose` as a span-guarded verbatim quote, and the `subdomains` aggregate —
|
|
@@ -358,7 +421,7 @@ consequence can.
|
|
| 358 |
|
| 359 |
```json
|
| 360 |
{
|
| 361 |
-
"
|
| 362 |
"title": "Standard Parameter Produksi & ECA",
|
| 363 |
"purpose_verbatim": "Standard Parameter ini merupakan pedoman dalam perhitungan parameter produksi yang meliputi Quantity (Qty) Physical Availability (PA), Utilization of Availability (UA), Productivity (PTY), serta Equipment Capacity Analysis (ECA).",
|
| 364 |
"outline": [
|
|
@@ -387,7 +450,7 @@ consequence can.
|
|
| 387 |
|
| 388 |
| Field | Type | Req |
|
| 389 |
|---|---|---|
|
| 390 |
-
| `
|
| 391 |
| `title` | string \| null | — |
|
| 392 |
| `purpose_verbatim` | string \| null | — **Renamed + guarded 2026-09-02** (was `purpose`) |
|
| 393 |
| `outline` | string[] | ✅ Default `[]`. **Added 2026-09-02** |
|
|
@@ -436,7 +499,7 @@ document with no headings.
|
|
| 436 |
|
| 437 |
---
|
| 438 |
|
| 439 |
-
## 9 · `review_queue.json` → `ReviewQueueRow[]` —
|
| 440 |
|
| 441 |
**This is the consumption surface** — the payload a review UI binds to. Deliberately denormalised so
|
| 442 |
a row renders with no joins.
|
|
@@ -465,7 +528,7 @@ a row renders with no joins.
|
|
| 465 |
"rank": 2,
|
| 466 |
"row_kind": "key_parameter",
|
| 467 |
"term_id": null,
|
| 468 |
-
"
|
| 469 |
"term": "Grouping (Composite)",
|
| 470 |
"definition": null,
|
| 471 |
"source_wording": null,
|
|
@@ -483,7 +546,8 @@ a row renders with no joins.
|
|
| 483 |
| `rank` | int | ✅ | 1-based display order — see sorting below |
|
| 484 |
| `row_kind` | `term` \| `key_parameter` | ✅ | **New 2026-09-02.** Switch on this before reading anything else |
|
| 485 |
| `term_id` | string \| null | ✅ | **New in v2.** What an approve / edit / reject decision attaches to, so it survives a re-run. **Null on a `key_parameter` row** — that it resolves to no term is the reason the row exists |
|
| 486 |
-
| `
|
|
|
|
| 487 |
| `term` · `definition` · `source_wording` · `mention_count` | | | Copied from the entry |
|
| 488 |
| `page` · `page_no` · `section_no` · `span` | | | **Lifted out of `provenance`** so a row is self-contained. Render **`page_no`** (1-based), not `page` |
|
| 489 |
| `review_reason` | string | ✅ | Human-readable, one of five |
|
|
@@ -492,11 +556,42 @@ a row renders with no joins.
|
|
| 492 |
`conflict_variants` — all four are already expressed by `review_reason`, and remain available on the
|
| 493 |
entry itself.
|
| 494 |
|
| 495 |
-
**Sort order** — `(conflicts, then
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
|
| 501 |
**`review_reason` values:**
|
| 502 |
|
|
|
|
| 24 |
|
| 25 |
| Change | Detail |
|
| 26 |
|---|---|
|
| 27 |
+
| **Ids on every entity** | `term_id`, `formula_id`, `brief_id`; `rule_id` re-specified. All deterministic and content-derived |
|
| 28 |
| **Glossary no longer restates formulas** | `formula_latex` removed → `defining_formula_id` reference |
|
| 29 |
| **Rules link to formulas and terms** | `formula_ids[]`, `term_ids[]` |
|
| 30 |
| **`rule_type` added** | `interpretation` \| `calculation` — see §2 for why |
|
|
|
|
| 52 |
term_id = "t_" + sha256(doc_id + "|" + canonical)[:10]
|
| 53 |
formula_id = "f_" + sha256(doc_id + "|" + name_or_latex_or_chunk_id)[:10]
|
| 54 |
rule_id = "r_" + sha256(doc_id + "|" + chunk_id + "|" + char_start)[:10]
|
| 55 |
+
brief_id = "b_" + sha256(doc_id)[:10] # per-document card; `d_` belongs to the
|
| 56 |
+
# scope-level DomainContext in src/knowledge_domain/
|
| 57 |
```
|
| 58 |
|
| 59 |
Implemented in `src/knowledge_extraction/ids.py`. `term_id` is keyed on the **cluster canonical**,
|
|
|
|
| 81 |
| `glossary.json` | `GlossaryEntry[]` | Terms and their definitions |
|
| 82 |
| `rules.json` | `RuleEntry[]` | Rules of thumb — how to interpret data or behaviour. **Renamed from `interpretation_pack.json` 2026-09-08** — the file, the entity and the DB `kind` now share one name. *The Interpretation Pack* remains the deliverable's name in prose |
|
| 83 |
| `formulas.json` | `FormulaEntry[]` | Formulas, transcribed from the document |
|
| 84 |
+
| `document_brief.json` | `DocumentBrief` | One per-document card. Prefix history, not churn: `b_` (BriefContext) -> `d_` (DomainContext, 2026-09-01) -> **`b_` again** (DocumentBrief, 2026-09-08), once v4 gave `d_` to the real scope-level `DomainContext` in `src/knowledge_domain/` |
|
| 85 |
|
| 86 |
Plus three derived/audit payloads: `review_queue.json`, `rejected.json`, `usage.json`.
|
| 87 |
|
| 88 |
+
### Rule vs domain knowledge — where the line is
|
| 89 |
+
|
| 90 |
+
*Added 2026-09-14 (o2). The two blurred on screen during the 2026-09-08 review,
|
| 91 |
+
and a boundary nobody has written down is one the prompts cannot hold.*
|
| 92 |
+
|
| 93 |
+
| | **`rule`** | **`domain`** |
|
| 94 |
+
|---|---|---|
|
| 95 |
+
| Grain | One statement, from one document | One per company, composed across all of them |
|
| 96 |
+
| Shape | A condition and a consequence | Identity, boundary, conventions, measures |
|
| 97 |
+
| Evidence | Span-checked against its source | Derived from entries, or expert-declared |
|
| 98 |
+
| Answers | *"…and how do I compute this correctly?"* | *"…what does this company measure, and what does it call it?"* |
|
| 99 |
+
|
| 100 |
+
**The test to apply.** If removing it would change **how a number is computed
|
| 101 |
+
or sourced**, it is a rule — *"if joint-survey data is unavailable, use truck
|
| 102 |
+
count"* changes the figure. If removing it would only leave an agent **less
|
| 103 |
+
oriented** — not knowing that this company works in overburden and BCM — it is
|
| 104 |
+
domain.
|
| 105 |
+
|
| 106 |
+
Two consequences worth stating, because they are what the distinction is *for*:
|
| 107 |
+
|
| 108 |
+
- **A rule is actionable alone; domain knowledge is not.** A planner can apply
|
| 109 |
+
a rule to a specific calculation. It cannot apply "this is a mining company"
|
| 110 |
+
to anything — that shapes which questions make sense, not which column to sum.
|
| 111 |
+
- **Domain is high-level and rules are specific, but "high-level" is not the
|
| 112 |
+
test.** A long, general rule is still a rule. The grain question — *does this
|
| 113 |
+
come from one document's sentence, or from the corpus as a whole?* — settles
|
| 114 |
+
it faster and does not drift.
|
| 115 |
+
|
| 116 |
+
Where it is genuinely both, prefer the **rule**: it carries provenance and a
|
| 117 |
+
span, so an expert can check it. Domain fields are the ones the pipeline can
|
| 118 |
+
least easily prove.
|
| 119 |
+
|
| 120 |
### Why `RuleEntry` carries a `rule_type`
|
| 121 |
|
| 122 |
The Interpretation Pack exists to **improve analytics insight from expert interpretation rules** —
|
|
|
|
| 137 |
|
| 138 |
---
|
| 139 |
|
| 140 |
+
## 2c · Evidence now carries figures and tables *(parsing 0.4.0, 2026-09-10)*
|
| 141 |
+
|
| 142 |
+
The artifact this half consumes changed shape: a figure is no longer its own chunk. It is folded into
|
| 143 |
+
the prose chunk at its reading-order position as ``, and travels as an `Asset` on that
|
| 144 |
+
chunk. `adapter.py` carries `assets`, `referenced_by` and `table_html` across; `evidence_block` renders
|
| 145 |
+
them into the prompt under `ASSETS REFERENCED BY THIS CHUNK`.
|
| 146 |
+
|
| 147 |
+
**What that means for an extraction branch**, and the distinction is enforced in the prompts:
|
| 148 |
+
|
| 149 |
+
| | may be quoted? | may be a provenance span? |
|
| 150 |
+
|---|---|---|
|
| 151 |
+
| `Asset.caption` — printed in the document | **yes** | **yes** |
|
| 152 |
+
| `Asset.description` — written by a vision model | no | **no** — it is not in the document, so the span check rejects it and the field is discarded |
|
| 153 |
+
|
| 154 |
+
Why it mattered: before 0.4.0 a figure chunk had an EMPTY `text`, so it produced no mentions, joined no
|
| 155 |
+
cluster, and was never selected as evidence. 13 figures across three documents, each already described
|
| 156 |
+
by a paid vision call, reached **zero** prompts. Full detail:
|
| 157 |
+
`knowledge_pipeline_runs/CHANGELOG_v2.md` §2.
|
| 158 |
+
|
| 159 |
+
> ✅ **RESOLVED 2026-09-11 — §1, §2 and §8 now match the code.** The note is kept for the record.
|
| 160 |
+
>
|
| 161 |
+
> ⚠️ **Correction to §8 of this document (noted 2026-09-10).** The code writes
|
| 162 |
+
> `document_brief.json` with **`brief_id`** and a `b_` prefix; §8 below documents
|
| 163 |
+
> `domain_context.json` with `domain_id` and `d_`. The v3 rename was partly reverted for the
|
| 164 |
+
> per-document object once the scope-level `DomainContext` became a separate thing in
|
| 165 |
+
> `src/knowledge_domain/`. **§8 does not describe what the code emits.** Worth one edit, and it matters
|
| 166 |
+
> because `entity_id` prefixes are the key an expert's approvals hang on.
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
## 3 · Transport reality
|
| 171 |
|
| 172 |
Every payload is written by the CLI as a file into `--out-dir` (default `out/knowledge/`), as a
|
|
|
|
| 406 |
|
| 407 |
---
|
| 408 |
|
| 409 |
+
## 8 · `document_brief.json` → `DocumentBrief` — 6 fields *(single object)*
|
| 410 |
|
| 411 |
> ✅ **The v3 reshape is complete, corrected 2026-09-08.** This banner previously said the
|
| 412 |
> LLM-facing parts — `purpose` as a span-guarded verbatim quote, and the `subdomains` aggregate —
|
|
|
|
| 421 |
|
| 422 |
```json
|
| 423 |
{
|
| 424 |
+
"brief_id": "d_2ef60a8c19",
|
| 425 |
"title": "Standard Parameter Produksi & ECA",
|
| 426 |
"purpose_verbatim": "Standard Parameter ini merupakan pedoman dalam perhitungan parameter produksi yang meliputi Quantity (Qty) Physical Availability (PA), Utilization of Availability (UA), Productivity (PTY), serta Equipment Capacity Analysis (ECA).",
|
| 427 |
"outline": [
|
|
|
|
| 450 |
|
| 451 |
| Field | Type | Req |
|
| 452 |
|---|---|---|
|
| 453 |
+
| `brief_id` | string | ✅ **New in v2.** `b_` prefix — reconciled with the code 2026-09-11 |
|
| 454 |
| `title` | string \| null | — |
|
| 455 |
| `purpose_verbatim` | string \| null | — **Renamed + guarded 2026-09-02** (was `purpose`) |
|
| 456 |
| `outline` | string[] | ✅ Default `[]`. **Added 2026-09-02** |
|
|
|
|
| 499 |
|
| 500 |
---
|
| 501 |
|
| 502 |
+
## 9 · `review_queue.json` → `ReviewQueueRow[]` — 12 fields
|
| 503 |
|
| 504 |
**This is the consumption surface** — the payload a review UI binds to. Deliberately denormalised so
|
| 505 |
a row renders with no joins.
|
|
|
|
| 528 |
"rank": 2,
|
| 529 |
"row_kind": "key_parameter",
|
| 530 |
"term_id": null,
|
| 531 |
+
"brief_id": "d_2ef60a8c19",
|
| 532 |
"term": "Grouping (Composite)",
|
| 533 |
"definition": null,
|
| 534 |
"source_wording": null,
|
|
|
|
| 546 |
| `rank` | int | ✅ | 1-based display order — see sorting below |
|
| 547 |
| `row_kind` | `term` \| `key_parameter` | ✅ | **New 2026-09-02.** Switch on this before reading anything else |
|
| 548 |
| `term_id` | string \| null | ✅ | **New in v2.** What an approve / edit / reject decision attaches to, so it survives a re-run. **Null on a `key_parameter` row** — that it resolves to no term is the reason the row exists |
|
| 549 |
+
| `brief_id` | string | — | `key_parameter` rows only — what the decision attaches to instead. **Named `brief_id` on the wire**, not `domain_id`; see the correction under Sort order |
|
| 550 |
+
| `surface_full` | string \| null | — | `key_parameter` rows only. **New 2026-09-11.** The untruncated surface when `term` had to be shortened to a head; `null` when it did not |
|
| 551 |
| `term` · `definition` · `source_wording` · `mention_count` | | | Copied from the entry |
|
| 552 |
| `page` · `page_no` · `section_no` · `span` | | | **Lifted out of `provenance`** so a row is self-contained. Render **`page_no`** (1-based), not `page` |
|
| 553 |
| `review_reason` | string | ✅ | Human-readable, one of five |
|
|
|
|
| 556 |
`conflict_variants` — all four are already expressed by `review_reason`, and remain available on the
|
| 557 |
entry itself.
|
| 558 |
|
| 559 |
+
**Sort order** — **changed 2026-09-11.** `(conflicts, then mention_count descending, with unresolved
|
| 560 |
+
key parameters spliced in at most one per five term rows)`.
|
| 561 |
+
|
| 562 |
+
Conflicts still outrank everything, because a contradiction is a decision only the expert can make.
|
| 563 |
+
What changed is the tier beneath them. Unresolved key parameters used to occupy one, which meant
|
| 564 |
+
**every** unresolved parameter outranked **every** routine term regardless of how many there were —
|
| 565 |
+
and the branch produces them in bulk on exactly the documents it understands least. On the Komatsu
|
| 566 |
+
shop manual that was **24 junk rows standing in front of `Engine`, a term with 108 mentions**, which
|
| 567 |
+
sat at rank 25.
|
| 568 |
+
|
| 569 |
+
The original reason for promoting them is preserved and is still sound: that branch has **no span
|
| 570 |
+
check at all**, so an unresolvable surface is the one hallucination signal it can raise, and it must
|
| 571 |
+
not sink beneath sixty routine rows where nobody reaches it. A **cap** expresses that without letting
|
| 572 |
+
the tail take the queue over. Measured by replaying the three persisted v2 runs:
|
| 573 |
+
|
| 574 |
+
| Document | unresolved key parameters | first one at rank | top term, rank |
|
| 575 |
+
|---|---|---|---|
|
| 576 |
+
| BUMA | 0 | — | `Qty` 1 → 1 (unchanged) |
|
| 577 |
+
| Komatsu | 24 | 1 → **6** | `Engine` **25 → 1** |
|
| 578 |
+
| Open Pit | 7 | 1 → **6** | `Resource` **8 → 1** |
|
| 579 |
+
|
| 580 |
+
Row counts are identical before and after on all three — **nothing is dropped**; parameters the
|
| 581 |
+
splice cannot place are appended rather than discarded.
|
| 582 |
+
|
| 583 |
+
**Also new on `key_parameter` rows: `surface_full`.** The branch is supposed to return a parameter
|
| 584 |
+
*name* and on a textbook it returns the right concept wrapped in its whole defining sentence (185
|
| 585 |
+
characters, in one measured case). `term` now carries a head — split at the first `.`, `:` or newline
|
| 586 |
+
— and `surface_full` carries the untruncated text, or `null` when no shortening was needed. The head
|
| 587 |
+
is only taken past 60 characters, which is what keeps a legitimate short name containing a period
|
| 588 |
+
("No. of units") intact.
|
| 589 |
+
|
| 590 |
+
> ⚠️ **Field-name correction, 2026-09-11.** This document described the `key_parameter` row's owning
|
| 591 |
+
> id as **`domain_id`**. The code emits **`brief_id`** and always has: the K12 rename changed the
|
| 592 |
+
> entity from `BriefContext` to `DomainContext` but left the id field's name alone, deliberately —
|
| 593 |
+
> renaming it is a migration that invalidates every expert approval recorded against the old prefix.
|
| 594 |
+
> **The wire field is `brief_id`.** Corrected here rather than in code, per the trust order.
|
| 595 |
|
| 596 |
**`review_reason` values:**
|
| 597 |
|
|
@@ -11,6 +11,40 @@
|
|
| 11 |
Companion to `KNOWLEDGE_OUTPUT_CONTRACT.md` (extraction). This is the half **upstream** of it:
|
| 12 |
parsing emits `ParsedDocument`, extraction consumes it and never touches the parser.
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
> **Current state (2026-09-02) — additive changes since the reference run below.** The default
|
| 15 |
> parser is now **Mistral OCR** (`parser_name: "mistral-ocr"`, `parser_backend: "mistral-ocr"`),
|
| 16 |
> API-based and needing **no GPU**. The artifact shape is **identical whichever parser produced it** —
|
|
@@ -90,7 +124,7 @@ Three conventions hold:
|
|
| 90 |
| `source_title` | string \| null | — | **(0.3.2)** The document's human-readable name. Equals `doc_id` today; a separate field so the two can diverge once `doc_id` becomes a content hash |
|
| 91 |
| `normaliser_version` | string \| null | — | **(0.3.1)** Fingerprint of the normalisation code that produced these chunks. `parser_config` covers the parser's settings; this covers our half |
|
| 92 |
| `version` | int | ✅ | Version of **this artifact for this document**; bumped on re-parse |
|
| 93 |
-
| `schema_version` | string | ✅ | Versions **the contract**. `0.3.0` on this reference run; **current `0.
|
| 94 |
| `created_at` | string \| null | — | ISO 8601, seconds precision |
|
| 95 |
| `raw_output_dir` | string \| null | — | Untouched MinerU output, kept content-addressed and never copied |
|
| 96 |
|
|
@@ -141,11 +175,64 @@ Three conventions hold:
|
|
| 141 |
| `is_tabular` | bool | ✅ | |
|
| 142 |
| `source_items` | int[] | ✅ | Indices into MinerU's `content_list.json` — the trace back |
|
| 143 |
| `bbox` | int[] \| null | — | `[x0, y0, x1, y1]` of the first source item. Carried for a curation UI; **nothing in the pipeline reasons about it** — ordering uses item sequence, never coordinates |
|
| 144 |
-
| `images` | string[] | ✅ |
|
|
|
|
|
|
|
| 145 |
| `latex` | string[] | ✅ | Source markup for formulas, kept beside the rendered prose. ⚠️ **LOAD-BEARING** — extraction's formula guard validates against this field. Reflowing it or leaving it empty weakens the check with no error. See below |
|
| 146 |
| `table_html` | string \| null | — | Source markup for tables |
|
| 147 |
| `generated_description` | string \| null | — | ⚠️ **MODEL-WRITTEN, NOT FROM THE DOCUMENT.** MinerU's description of an image or chart. **Never span-checkable** — treat as a model claim about a picture, never as evidence. See below |
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
### Why `text` is rendered but markup is kept
|
| 150 |
|
| 151 |
The term filter downstream is an NER model reading prose. MinerU writes formulas character-spaced
|
|
|
|
| 11 |
Companion to `KNOWLEDGE_OUTPUT_CONTRACT.md` (extraction). This is the half **upstream** of it:
|
| 12 |
parsing emits `ParsedDocument`, extraction consumes it and never touches the parser.
|
| 13 |
|
| 14 |
+
> ## ⚠️ schema_version **0.4.0** — asset pointers (2026-09-10)
|
| 15 |
+
>
|
| 16 |
+
> **A figure is no longer its own chunk.** Its markdown reference is folded into the prose chunk at
|
| 17 |
+
> its reading-order position, and the figure travels as an `Asset` on that chunk. This is a **MINOR**
|
| 18 |
+
> bump, not a patch: chunk boundaries change, so a 0.3.x consumer would silently read a different
|
| 19 |
+
> document shape. `adapter.check_schema_version` rejects a newer minor by design — parsing and
|
| 20 |
+
> extraction ship together.
|
| 21 |
+
>
|
| 22 |
+
> **Why.** Before 0.4.0 an image became its own chunk with an **empty `text`**, which made it
|
| 23 |
+
> unreachable by construction: no text → no term mentions → joins no cluster → never selected as
|
| 24 |
+
> evidence. Measured across three documents: **13 figures, every one described by a vision model we
|
| 25 |
+
> pay for, and ZERO appearing in any cluster's evidence.** Meanwhile the prose that said
|
| 26 |
+
> "Figure 1.1" had its `` reference stripped, so nothing connected the sentence to the picture.
|
| 27 |
+
>
|
| 28 |
+
> **What changed**
|
| 29 |
+
>
|
| 30 |
+
> | | 0.3.3 | 0.4.0 |
|
| 31 |
+
> |---|---|---|
|
| 32 |
+
> | A figure | its own chunk, `text=""`, unreachable | folded into the prose chunk as ``, carried in `Chunk.assets` |
|
| 33 |
+
> | A table | its own chunk | **unchanged** — its linearised text earns its evidence slot (74.6% of the Komatsu manual's top-3 evidence is tabular). Gains an `Asset` + two-way pointers |
|
| 34 |
+
> | Asset bytes | returned by the API, **written nowhere**; `images[]` pointed at files that did not exist | content-addressed and persisted; `Asset.storage_key` |
|
| 35 |
+
> | Asset identity | the parser's page-local filename | **`sha256(bytes)[:16]`** |
|
| 36 |
+
> | A caption | landed in a *neighbouring* prose chunk, unlinked | attached to the `Asset` **and** left in the prose text (it is verbatim document content) |
|
| 37 |
+
> | A description | stored raw, JSON braces included | unwrapped; `description_source` records what produced it |
|
| 38 |
+
>
|
| 39 |
+
> **Measured effect** (same three documents, no re-parse — the cache fingerprint is unchanged):
|
| 40 |
+
> chunks 32→28, 85→80, 59→44 · **image chunks 2/3/8 → 0/0/0** · empty-text chunks → 0 ·
|
| 41 |
+
> 13 assets persisted · 0 dangling references · **0 descriptions leaked into `text`** ·
|
| 42 |
+
> captions still 17/17 in text · recall unmoved.
|
| 43 |
+
>
|
| 44 |
+
> **The invariant that must not break:** a model-written `description` never enters `Chunk.text`.
|
| 45 |
+
> `text` is the haystack the span check searches, so generated prose in there would let a
|
| 46 |
+
> fabrication pass the control built to catch it.
|
| 47 |
+
|
| 48 |
> **Current state (2026-09-02) — additive changes since the reference run below.** The default
|
| 49 |
> parser is now **Mistral OCR** (`parser_name: "mistral-ocr"`, `parser_backend: "mistral-ocr"`),
|
| 50 |
> API-based and needing **no GPU**. The artifact shape is **identical whichever parser produced it** —
|
|
|
|
| 124 |
| `source_title` | string \| null | — | **(0.3.2)** The document's human-readable name. Equals `doc_id` today; a separate field so the two can diverge once `doc_id` becomes a content hash |
|
| 125 |
| `normaliser_version` | string \| null | — | **(0.3.1)** Fingerprint of the normalisation code that produced these chunks. `parser_config` covers the parser's settings; this covers our half |
|
| 126 |
| `version` | int | ✅ | Version of **this artifact for this document**; bumped on re-parse |
|
| 127 |
+
| `schema_version` | string | ✅ | Versions **the contract**. `0.3.0` on this reference run; **current `0.4.0`** (see the banner above) |
|
| 128 |
| `created_at` | string \| null | — | ISO 8601, seconds precision |
|
| 129 |
| `raw_output_dir` | string \| null | — | Untouched MinerU output, kept content-addressed and never copied |
|
| 130 |
|
|
|
|
| 175 |
| `is_tabular` | bool | ✅ | |
|
| 176 |
| `source_items` | int[] | ✅ | Indices into MinerU's `content_list.json` — the trace back |
|
| 177 |
| `bbox` | int[] \| null | — | `[x0, y0, x1, y1]` of the first source item. Carried for a curation UI; **nothing in the pipeline reasons about it** — ordering uses item sequence, never coordinates |
|
| 178 |
+
| `images` | string[] | ✅ | ⚠️ **DEPRECATED 0.4.0** — superseded by `assets`. Still populated for one version so a 0.3.x reader degrades rather than breaking. Holds the parser's own filenames, which are **not reliable**: Mistral reported `image_id: img-2.jpeg` on a block whose markdown said `img-1.jpeg`, so two figures collided and one image's bytes were lost. Read `assets` |
|
| 179 |
+
| `assets` | Asset[] | ✅ | **New 0.4.0.** Figures and tables this chunk contains or points at. Default `[]` |
|
| 180 |
+
| `referenced_by` | string[] | ✅ | **New 0.4.0.** `chunk_id`s pointing AT this chunk. Populated on table chunks. Default `[]` |
|
| 181 |
| `latex` | string[] | ✅ | Source markup for formulas, kept beside the rendered prose. ⚠️ **LOAD-BEARING** — extraction's formula guard validates against this field. Reflowing it or leaving it empty weakens the check with no error. See below |
|
| 182 |
| `table_html` | string \| null | — | Source markup for tables |
|
| 183 |
| `generated_description` | string \| null | — | ⚠️ **MODEL-WRITTEN, NOT FROM THE DOCUMENT.** MinerU's description of an image or chart. **Never span-checkable** — treat as a model claim about a picture, never as evidence. See below |
|
| 184 |
|
| 185 |
+
## 2b · `Asset` *(new at 0.4.0)*
|
| 186 |
+
|
| 187 |
+
```json
|
| 188 |
+
{
|
| 189 |
+
"asset_id": "d2e7b4ffe31006ec",
|
| 190 |
+
"kind": "figure",
|
| 191 |
+
"storage_key": "assets/d2e7b4ffe31006ec.jpeg",
|
| 192 |
+
"page_idx": 2,
|
| 193 |
+
"page_no": 3,
|
| 194 |
+
"bbox": [93, 562, 434, 907],
|
| 195 |
+
"caption": "Figure 1.1. The relationship between exploration information, resources and reserves (SME, 1991).",
|
| 196 |
+
"description": "The diagram illustrates the classification of mineral resources and reserves based on increasing levels of geological knowledge…",
|
| 197 |
+
"description_source": "mistral-ocr/bbox_annotation"
|
| 198 |
+
}
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
| Field | Type | Req | Notes |
|
| 202 |
+
|---|---|---|---|
|
| 203 |
+
| `asset_id` | string | ✅ | **`sha256(bytes)[:16]`.** Not the parser's id — see the `images` row. Content addressing makes a re-parse idempotent and de-duplicates a figure that appears twice (Open Pit has one such pair) |
|
| 204 |
+
| `kind` | enum | ✅ | `figure` · `chart` · `diagram` · `table` — one vocabulary for both cases |
|
| 205 |
+
| `storage_key` | string \| null | — | Where the bytes live. `null` for a table (no bytes of its own) and whenever persistence was skipped or failed — writing assets is a **never-throw** seam |
|
| 206 |
+
| `page_idx` | int | ✅ | 0-based, as the parser reports |
|
| 207 |
+
| `page_no` | int | ✅ | **Computed** `page_idx + 1`. The number a human reads; derived so it cannot drift |
|
| 208 |
+
| `bbox` | int[] \| null | — | `[x0,y0,x1,y1]` of the **asset**, so a UI can highlight the figure. Distinct from the host chunk's `bbox`, which stays where the text is |
|
| 209 |
+
| `caption` | string \| null | — | **VERBATIM** from the document's own caption block. Quotable as evidence, span-checkable. Also left in the prose `text`, where it already landed — the asset gets a copy, not a move |
|
| 210 |
+
| `description` | string \| null | — | ⚠️ **MODEL-WRITTEN.** Never span-checkable, never evidence, and **never in `text`** |
|
| 211 |
+
| `description_source` | string \| null | — | e.g. `mistral-ocr/bbox_annotation`, so a later model swap is traceable rather than silently changing what the field means |
|
| 212 |
+
|
| 213 |
+
**A figure's reference appears inline in its host chunk's `text`**, at the reading-order position the
|
| 214 |
+
document put it:
|
| 215 |
+
|
| 216 |
+
```text
|
| 217 |
+
…so closely and the geological character is so well defined that size, shape, depth
|
| 218 |
+
and mineral content of the resource are well established.
|
| 219 |
+
|
| 220 |
+

|
| 221 |
+
|
| 222 |
+
Figure 1.1. The relationship between exploration information, resources and reserves (SME, 1991).
|
| 223 |
+
|
| 224 |
+
– Indicated. Quantity and grade…
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
Two consumer obligations:
|
| 228 |
+
|
| 229 |
+
- **Resolve `asset://<id>` against that chunk's own `assets`.** Every reference is guaranteed to
|
| 230 |
+
resolve locally (0 dangling across the three test documents).
|
| 231 |
+
- **Switch on the caption/description distinction.** A caption may be quoted and used as a provenance
|
| 232 |
+
span; a description may not, and a span taken from one is rejected by the validator.
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
### Why `text` is rendered but markup is kept
|
| 237 |
|
| 238 |
The term filter downstream is an NER model reading prose. MinerU writes formulas character-spaced
|
|
@@ -26,7 +26,7 @@ Companions: [KNOWLEDGE_PIPELINE_TODO.md](KNOWLEDGE_PIPELINE_TODO.md) (plan) ·
|
|
| 26 |
| Model | `urchade/gliner_multi-v2.1` | CPU, ~400 MB. Cheap enough to run unconditionally on every chunk, which is what lets us reject a standalone relevance gate |
|
| 27 |
| Score threshold | **0.25** | Not 0.35. The sweep measured recall **0.854 @ 0.25** vs **0.658 @ 0.35** on the `broad` label set. Precision falls (0.41 vs 0.50) and that is accepted: the filter is meant to be over-inclusive, clustering and evidence ranking absorb the noise, and **a term the filter never proposes can never be recovered downstream** |
|
| 28 |
| Label variant | **`broad`** | Two earlier variants both missed the same class — mining activities and materials (coal mining, land clearing, waste removal, joint survey). `broad` adds `mining activity`, `material or commodity`, `data source or system`, `work category`, `analysis method` to cover it |
|
| 29 |
-
| Window size | **130 words**, overlap **30** | Not 220. GLiNER truncates past ~384 of its own tokens and **warns rather than failing** — silently dropping the tail of every long chunk. Indonesian technical prose subword-tokenises at roughly **2.5×**, so a 220-word window still tripped the cap. Chunks are fed as overlapping word windows with offsets remapped |
|
| 30 |
| Span token cap | **12** (GLiNER's own) | Compound terms ("UA plan grouping (composite) actual") exceed it. Candidates that hit the cap are **flagged, not silently truncated**, so a recall shortfall can be attributed to the cap |
|
| 31 |
|
| 32 |
**Label sets** (`labels.yaml`) — three variants were tried and A/B'd without touching code. Keep
|
|
@@ -40,7 +40,7 @@ Indonesian-phrasing variant (`istilah produksi tambang`, …) did **not** win; `
|
|
| 40 |
| Constant | Value | Why this value |
|
| 41 |
|---|---|---|
|
| 42 |
| Match order | exact (normalised) → abbreviation↔expansion → fuzzy | Cheapest and most certain first |
|
| 43 |
-
| Fuzzy threshold | **92**
|
| 44 |
| **Fuzzy disabled below 5 characters** | `min(len(a), len(b)) < 5` → skip | **The one to not lose.** `PA` vs `UA` scores high on `token_set_ratio`. Below 5 chars only exact matching is allowed |
|
| 45 |
| Canonical surface | shortest variant ≥ 2 chars | That is how a reader looks a term up — `PA`, not `Physical Availability (PA) untuk …` |
|
| 46 |
|
|
@@ -77,17 +77,54 @@ needs the tail.
|
|
| 77 |
|
| 78 |
| Constant | Value | Why |
|
| 79 |
|---|---|---|
|
| 80 |
-
| `evidence_k` | **
|
| 81 |
| Cue proximity | **100 chars** | Distance from mention to a definitional cue |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
**Heading matching is word-boundary, never substring.** `PA` is a substring of `Parameter`, `pada`
|
| 84 |
and `composite` — substring matching handed the +4.0 heading bonus to almost every Indonesian
|
| 85 |
heading and pushed real definition sections *below* formula tables.
|
| 86 |
|
| 87 |
**Why this filter is legitimate when the relevance gate was rejected:** no term is dropped (only its
|
| 88 |
-
*evidence* is narrowed), every mention stays in provenance so a bad top-3 is visible, and the
|
| 89 |
-
|
| 90 |
-
three if ranking is ever revisited.
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
---
|
| 93 |
|
|
|
|
| 26 |
| Model | `urchade/gliner_multi-v2.1` | CPU, ~400 MB. Cheap enough to run unconditionally on every chunk, which is what lets us reject a standalone relevance gate |
|
| 27 |
| Score threshold | **0.25** | Not 0.35. The sweep measured recall **0.854 @ 0.25** vs **0.658 @ 0.35** on the `broad` label set. Precision falls (0.41 vs 0.50) and that is accepted: the filter is meant to be over-inclusive, clustering and evidence ranking absorb the noise, and **a term the filter never proposes can never be recovered downstream** |
|
| 28 |
| Label variant | **`broad`** | Two earlier variants both missed the same class — mining activities and materials (coal mining, land clearing, waste removal, joint survey). `broad` adds `mining activity`, `material or commodity`, `data source or system`, `work category`, `analysis method` to cover it |
|
| 29 |
+
| Window size | **130 words**, overlap **30** (re-confirmed 2026-09-10: 256 truncates 46 of 99 Open Pit windows, 180 truncates 4; 130 is the only size measured safe on both English documents. One BUMA window exceeds the cap at EVERY size — pre-existing) | Not 220. GLiNER truncates past ~384 of its own tokens and **warns rather than failing** — silently dropping the tail of every long chunk. Indonesian technical prose subword-tokenises at roughly **2.5×**, so a 220-word window still tripped the cap. Chunks are fed as overlapping word windows with offsets remapped |
|
| 30 |
| Span token cap | **12** (GLiNER's own) | Compound terms ("UA plan grouping (composite) actual") exceed it. Candidates that hit the cap are **flagged, not silently truncated**, so a recall shortfall can be attributed to the cap |
|
| 31 |
|
| 32 |
**Label sets** (`labels.yaml`) — three variants were tried and A/B'd without touching code. Keep
|
|
|
|
| 40 |
| Constant | Value | Why this value |
|
| 41 |
|---|---|---|
|
| 42 |
| Match order | exact (normalised) → abbreviation↔expansion → fuzzy | Cheapest and most certain first |
|
| 43 |
+
| ~~Fuzzy threshold~~ | ~~**92**~~ | ⛔ **RETIRED 2026-09-10.** `token_set_ratio` returns exactly 100 when one token set is a SUBSET of the other, so this threshold was **inert** and a head noun always absorbed its compounds. 131 of 140 merges were subset matches; the 6 correct ones were plurals, now handled by `normalize(lemma=True)`. The pass is commented out at its call site; `_fuzzy_match` is parked. Subset merges went to 0 on all three test documents |
|
| 44 |
| **Fuzzy disabled below 5 characters** | `min(len(a), len(b)) < 5` → skip | **The one to not lose.** `PA` vs `UA` scores high on `token_set_ratio`. Below 5 chars only exact matching is allowed |
|
| 45 |
| Canonical surface | shortest variant ≥ 2 chars | That is how a reader looks a term up — `PA`, not `Physical Availability (PA) untuk …` |
|
| 46 |
|
|
|
|
| 77 |
|
| 78 |
| Constant | Value | Why |
|
| 79 |
|---|---|---|
|
| 80 |
+
| `evidence_k` | **5** (raised from 3, 2026-09-10) | Chunks fed per extraction round |
|
| 81 |
| Cue proximity | **100 chars** | Distance from mention to a definitional cue |
|
| 82 |
+
| `evidence_link_partners` | **1** (added 2026-09-11) | Linked partner chunks pulled into the window on top of the ranked K |
|
| 83 |
+
|
| 84 |
+
### Linked partners (2026-09-11)
|
| 85 |
+
|
| 86 |
+
Schema 0.4.0 pairs a table chunk with the prose that introduces it, via `referenced_by` in both
|
| 87 |
+
directions. `top_k` now pulls **at most one** partner into the evidence window.
|
| 88 |
+
|
| 89 |
+
**Why it is not redundant — the measurement that decided it.** Ranking only, on the three v2
|
| 90 |
+
artifacts and their recorded filter payloads (free; no GLiNER, no spend):
|
| 91 |
+
|
| 92 |
+
| | clusters | top-5 touches a linked pair | **both sides already there** | only one side |
|
| 93 |
+
|---|---|---|---|---|
|
| 94 |
+
| BUMA | 83 | 31 | **0** | 31 |
|
| 95 |
+
| Komatsu | 93 | 55 | **0** | 55 |
|
| 96 |
+
| Open Pit | 427 | 70 | **0** | 70 |
|
| 97 |
+
|
| 98 |
+
**Zero clusters out of 603 saw both sides.** The ranker never co-selects a table and the prose that
|
| 99 |
+
names it — and that prose is frequently the table's only title (*"Troubleshooting of engine
|
| 100 |
+
(S-mode)"* above a bare `S-1 | 6` grid). Cost at the cap: evidence read **+14.5% / +24.5% / +9.9%**,
|
| 101 |
+
which is **+1.6% / +4.1% / +4.2%** of total prompt spend; Open Pit moves 71% → ~74% of the 2M ceiling.
|
| 102 |
+
|
| 103 |
+
**Two placement rules, both load-bearing.** The expansion lives in `top_k`, *not* `rank_evidence`:
|
| 104 |
+
|
| 105 |
+
1. The **ranked list is never mutated**, so everything reading `evidence_chunk_ids` sees the number
|
| 106 |
+
it saw before.
|
| 107 |
+
2. `evidence_block` (the prompt) and `evidence_text` (the span check) both take their ids from the
|
| 108 |
+
same `top_k` call, so *what the model reads is exactly what the span check searches* stays true.
|
| 109 |
+
Expanding in the prompt renderer instead would null every quote from a partner as unlocatable.
|
| 110 |
+
Verified across all 603 clusters: **0 parity failures**.
|
| 111 |
+
|
| 112 |
+
**Scope, stated honestly.** The document this helps most (Komatsu, 55 of 93 clusters) is the one that
|
| 113 |
+
returns 0 definitions for *genre* reasons. This is groundwork for the table branch (Step 8) rather
|
| 114 |
+
than a glossary win, and it has **not** been measured against a paid run — with this corpus's noise a
|
| 115 |
+
single run could not have shown anything either way. Set `EVIDENCE_LINK_PARTNERS = 0` to disable.
|
| 116 |
|
| 117 |
**Heading matching is word-boundary, never substring.** `PA` is a substring of `Parameter`, `pada`
|
| 118 |
and `composite` — substring matching handed the +4.0 heading bonus to almost every Indonesian
|
| 119 |
heading and pushed real definition sections *below* formula tables.
|
| 120 |
|
| 121 |
**Why this filter is legitimate when the relevance gate was rejected:** no term is dropped (only its
|
| 122 |
+
*evidence* is narrowed), every mention stays in provenance so a bad top-3 is visible, and the full
|
| 123 |
+
ranked list is retained so the tail stays reachable. The rejected gate had none of those three
|
| 124 |
+
properties. Preserve all three if ranking is ever revisited. (The third property used to be phrased
|
| 125 |
+
as "the escalation loop self-corrects"; escalation was retired on 2026-09-11 with
|
| 126 |
+
`MAX_ESCALATION_ROUNDS = 0`, but retaining the tail is what made the property true and it still
|
| 127 |
+
holds.)
|
| 128 |
|
| 129 |
---
|
| 130 |
|
|
@@ -10,6 +10,13 @@
|
|
| 10 |
|
| 11 |
**Status legend:** ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked · 🔎 built but unverified · ⏸️ deferred
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
> **Updated 2026-08-26.** Both halves are in-tree and run end to end as offline CLIs; the seam is
|
| 14 |
> settled and consumed from both sides. Tracked in DEV_PLAN §0.8, described in REPO_STATUS §14.
|
| 15 |
> The full-document run landed (X18) and scored E3 = 0.90. **Schema v2 landed 2026-08-26 (X24)** —
|
|
@@ -203,7 +210,7 @@ envelope shape proposed in §3 — so S1 can settle either way without touching
|
|
| 203 |
| **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 |
|
| 204 |
| **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 |
|
| 205 |
| **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 |
|
| 206 |
-
| **X17** | Persistence |
|
| 207 |
| **X24** | Output schema v2 (Harry's contract review) | ✅ | 🔎 | **Landed 2026-08-26, UNEVALUATED.** Four asks, all adopted: deterministic ids (`ids.py` — `term_id`/`formula_id`/`rule_id`/`brief_id`, sha256 over content, never positional and never model-written); the glossary references a formula (`defining_formula_id`) instead of restating it; rules link to formulas and terms (`formula_ids[]`, `term_ids[]`); `brief_id` added. New deterministic `link.py` resolves every reference after the branches run — no LLM, no spend, dangle counts reported not swallowed. **16 fields cut, 8 added (50 → 42).** Two defects fixed on the way: `cluster_id` was a frequency-rank ordinal and `rule_id` was written by the model, so **neither survived a re-run** — which silently breaks approval continuity, not just linking. `rule_type` (`interpretation`\|`calculation`) keeps the Interpretation Pack's name while retaining the calculation conventions the corpus actually yields — **0 of 15 gold rules are interpretation rules**, because a *Standard Parameter* document defines how to compute a parameter, not how to read one. Verified: ruff clean on touched paths, `import main` OK, suite **497 passed / 7 skipped**, and a synthetic end-to-end link check (PA resolves to its own defining formula, not the `Production` formula it merely appears in; `Total Hours` dangles to null as designed). **Not verified: any eval** — see the banner; the prompt edits are in the same unevaluated position as X20 |
|
| 208 |
| **X26** | Output schema v3 — `DomainContext` | Rifqi | ✅ | ✅ | **Done 2026-09-02** (`7f47c61` · `6f5d619` · `e2865ac` · `172d8dc`). `BriefContext` → **`DomainContext`** (`brief_id` → `domain_id`, `b_` → `d_`) — renamed before persistence exists, because after it a prefix change is an id migration that loses approvals. **The branch stopped being the pipeline's one unverifiable output:** `GUARDED_FIELDS["summary"]` went `()` → `("title", "purpose_verbatim")`, achieved by changing the QUESTION rather than the model — the prompt now asks the model to *locate* the document's own statement of purpose instead of composing a summary, and a quote is checkable where a summary never was. `_run_summary` also never called `validate_entry`, so the guard table alone would have been inert. New derived fields: **`outline`** (ordered unique `heading_path`; needed `heading_path` to cross the adapter, which was dropping it exactly as it once dropped `latex`), **`subdomains`** (roll-up of per-term `subdomain_tags`, frequency-ordered with an alphabetical tiebreak — load-bearing, since `production`/`equipment` tie on the reference doc), and **`n_terms`/`n_formulas`/`n_rules`**. `key_parameters` became `KeyParameter{surface, term_id}`, corroborated in `link.py`. **Smoke found a real bug:** exact lookup resolved 0/5 because the document names parameters `Full Name (ABBR)` while the glossary is keyed on the abbreviation — five FALSE hallucination signals sorted above every real term. `_match_parameter` now tries whole → expansion → abbreviation: **0/5 → 4/5**, ECA correctly still dangling. Measured (BUMA, nano, 12 calls): **0 fields rejected**, fabricated purpose refused, 11-entry outline. Contract §8/§9 quote that run literally. Suite 541 passed / 7 skipped |
|
| 209 |
|
|
@@ -214,9 +221,9 @@ envelope shape proposed in §3 — so S1 can settle either way without touching
|
|
| 214 |
| # | Decision | Owner | Status | Description |
|
| 215 |
|---|---|---|---|---|
|
| 216 |
| **D1** | New dependencies | Rifqi | ✅ | **All approved and added 2026-08-19:** `rapidfuzz>=3.14.5`, `gliner>=0.2.13` (resolved 0.2.28), `torch>=2.6` (resolved 2.11.0+cpu). Install verified clean — `import torchgen` OK, so not the corrupt-wheel failure the calibration doc warns about. `PyYAML` was already present transitively |
|
| 217 |
-
| **D2** | New tables (DDL handoff) | Rifqi → Harry | ⬜ |
|
| 218 |
| **D3** | Model tier per branch | Rifqi | ✅ | **Decided 2026-08-19: stay on nano for all four branches.** The `.env` carries the quad `azureai__{api_key,endpoint__url,deployment__name,api__version}__54n`, mirroring the existing `__54m` scheme. Accepted knowingly: nano measured 0.75 against a 0.80 line, and `rule`/`summary` run on the tier whose failure mode is least detectable. Revisit if extraction quality blocks the demo. **`src/config/settings.py` does not expose the `__54n` quad yet** — four `Field(alias=…)` entries, first build step |
|
| 219 |
-
| **D4** | Admin ingest surface | Rifqi |
|
| 220 |
| **D5** | v2 module name | Rifqi | ✅ | **`src/knowledge_extraction/`** (2026-08-19). Offline runner in `scripts/`, per the repo's existing precedent |
|
| 221 |
| **D6** | Expert review of the gold set | Rifqi → Mas Beta | ⏸️ | **Deferred 2026-08-19: provisional gold is good enough for now** so the rebuild is not blocked on expert time. Standing caveat: E1 and E3 both move when it is reviewed, and E3's scoreable base is 8 entries, so each adjudication shifts it ~12 points. Every E3 failure is listed verbatim in the frozen baseline for when he does review it |
|
| 222 |
| **D7** | GLiNER + LangExtract ensembling | — | ⏸️ | Proposed, not decided; flagged as scope creep. Cheap in API terms but adds a reconciliation step between two candidate sets that may disagree. Not in scope for the test integration |
|
|
|
|
| 10 |
|
| 11 |
**Status legend:** ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked · 🔎 built but unverified · ⏸️ deferred
|
| 12 |
|
| 13 |
+
> **Updated 2026-09-11 — this file had drifted, and the drift is worth naming.** Rows **X17**, **D2**
|
| 14 |
+
> and **D4** read ⬜ *"not started"* for four days after persistence and the HTTP surface shipped
|
| 15 |
+
> (2026-09-07), while DEV_PLAN §0.8 recorded both ✅. Anyone reading this file first would have
|
| 16 |
+
> concluded the pipeline was still offline. Corrected below. This file also records **nothing of v2**
|
| 17 |
+
> (K13–K16, 2026-09-10/11) or of the 2026-09-11 open-queue pass (K17–K23) — for those, **DEV_PLAN
|
| 18 |
+
> §0.8 is canonical** and this checklist covers the original build-out only.
|
| 19 |
+
|
| 20 |
> **Updated 2026-08-26.** Both halves are in-tree and run end to end as offline CLIs; the seam is
|
| 21 |
> settled and consumed from both sides. Tracked in DEV_PLAN §0.8, described in REPO_STATUS §14.
|
| 22 |
> The full-document run landed (X18) and scored E3 = 0.90. **Schema v2 landed 2026-08-26 (X24)** —
|
|
|
|
| 210 |
| **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 |
|
| 211 |
| **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 |
|
| 212 |
| **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 |
|
| 213 |
+
| **X17** | Persistence | ✅ | ✅ | **Landed 2026-09-07** — five tables + `knowledge_store.py`; 88 entry rows persisted from the BUMA standard. This row said ⬜ until 2026-09-11 while DEV_PLAN §0.8 recorded it done. See D2 for the part that is still open (Harry's migration) |
|
| 214 |
| **X24** | Output schema v2 (Harry's contract review) | ✅ | 🔎 | **Landed 2026-08-26, UNEVALUATED.** Four asks, all adopted: deterministic ids (`ids.py` — `term_id`/`formula_id`/`rule_id`/`brief_id`, sha256 over content, never positional and never model-written); the glossary references a formula (`defining_formula_id`) instead of restating it; rules link to formulas and terms (`formula_ids[]`, `term_ids[]`); `brief_id` added. New deterministic `link.py` resolves every reference after the branches run — no LLM, no spend, dangle counts reported not swallowed. **16 fields cut, 8 added (50 → 42).** Two defects fixed on the way: `cluster_id` was a frequency-rank ordinal and `rule_id` was written by the model, so **neither survived a re-run** — which silently breaks approval continuity, not just linking. `rule_type` (`interpretation`\|`calculation`) keeps the Interpretation Pack's name while retaining the calculation conventions the corpus actually yields — **0 of 15 gold rules are interpretation rules**, because a *Standard Parameter* document defines how to compute a parameter, not how to read one. Verified: ruff clean on touched paths, `import main` OK, suite **497 passed / 7 skipped**, and a synthetic end-to-end link check (PA resolves to its own defining formula, not the `Production` formula it merely appears in; `Total Hours` dangles to null as designed). **Not verified: any eval** — see the banner; the prompt edits are in the same unevaluated position as X20 |
|
| 215 |
| **X26** | Output schema v3 — `DomainContext` | Rifqi | ✅ | ✅ | **Done 2026-09-02** (`7f47c61` · `6f5d619` · `e2865ac` · `172d8dc`). `BriefContext` → **`DomainContext`** (`brief_id` → `domain_id`, `b_` → `d_`) — renamed before persistence exists, because after it a prefix change is an id migration that loses approvals. **The branch stopped being the pipeline's one unverifiable output:** `GUARDED_FIELDS["summary"]` went `()` → `("title", "purpose_verbatim")`, achieved by changing the QUESTION rather than the model — the prompt now asks the model to *locate* the document's own statement of purpose instead of composing a summary, and a quote is checkable where a summary never was. `_run_summary` also never called `validate_entry`, so the guard table alone would have been inert. New derived fields: **`outline`** (ordered unique `heading_path`; needed `heading_path` to cross the adapter, which was dropping it exactly as it once dropped `latex`), **`subdomains`** (roll-up of per-term `subdomain_tags`, frequency-ordered with an alphabetical tiebreak — load-bearing, since `production`/`equipment` tie on the reference doc), and **`n_terms`/`n_formulas`/`n_rules`**. `key_parameters` became `KeyParameter{surface, term_id}`, corroborated in `link.py`. **Smoke found a real bug:** exact lookup resolved 0/5 because the document names parameters `Full Name (ABBR)` while the glossary is keyed on the abbreviation — five FALSE hallucination signals sorted above every real term. `_match_parameter` now tries whole → expansion → abbreviation: **0/5 → 4/5**, ECA correctly still dangling. Measured (BUMA, nano, 12 calls): **0 fields rejected**, fabricated purpose refused, 11-entry outline. Contract §8/§9 quote that run literally. Suite 541 passed / 7 skipped |
|
| 216 |
|
|
|
|
| 221 |
| # | Decision | Owner | Status | Description |
|
| 222 |
|---|---|---|---|---|
|
| 223 |
| **D1** | New dependencies | Rifqi | ✅ | **All approved and added 2026-08-19:** `rapidfuzz>=3.14.5`, `gliner>=0.2.13` (resolved 0.2.28), `torch>=2.6` (resolved 2.11.0+cpu). Install verified clean — `import torchgen` OK, so not the corrupt-wheel failure the calibration doc warns about. `PyYAML` was already present transitively |
|
| 224 |
+
| **D2** | New tables (DDL handoff) | Rifqi → Harry | ✅ (Python) / ⬜ (Go) | **Python side done 2026-09-07**: five tables exist in dedorch exactly as `docs/knowledge/knowledge_tables.sql`, with ORM models and `PostgresJobStore`. ⚠️ **They were created by a ONE-TIME MANUAL RUN.** Go owns the migration and Harry must still fold the SQL in, or a fresh environment comes up without them — the same gap that left `message_traceability` and `message_charts` out of Go's set (Q5) |
|
| 225 |
| **D3** | Model tier per branch | Rifqi | ✅ | **Decided 2026-08-19: stay on nano for all four branches.** The `.env` carries the quad `azureai__{api_key,endpoint__url,deployment__name,api__version}__54n`, mirroring the existing `__54m` scheme. Accepted knowingly: nano measured 0.75 against a 0.80 line, and `rule`/`summary` run on the tier whose failure mode is least detectable. Revisit if extraction quality blocks the demo. **`src/config/settings.py` does not expose the `__54n` quad yet** — four `Field(alias=…)` entries, first build step |
|
| 226 |
+
| **D4** | Admin ingest surface | Rifqi | ✅ built / ⛔ auth | **Built 2026-09-07** — 8 routes under `/api/v1/knowledge`, async batch (202 + poll), company-scoped, rate-limited, spend-gated, single-flight. **The auth question it was raised to answer is still open and was re-opened 2026-09-07**: the posture was inherited by default, `POST …/reviews` accepts content that reaches a later prompt, and the caller is the browser SPA (#37/#43). Recorded, not solved |
|
| 227 |
| **D5** | v2 module name | Rifqi | ✅ | **`src/knowledge_extraction/`** (2026-08-19). Offline runner in `scripts/`, per the repo's existing precedent |
|
| 228 |
| **D6** | Expert review of the gold set | Rifqi → Mas Beta | ⏸️ | **Deferred 2026-08-19: provisional gold is good enough for now** so the rebuild is not blocked on expert time. Standing caveat: E1 and E3 both move when it is reviewed, and E3's scoreable base is 8 entries, so each adjudication shifts it ~12 points. Every E3 failure is listed verbatim in the frozen baseline for when he does review it |
|
| 229 |
| **D7** | GLiNER + LangExtract ensembling | — | ⏸️ | Proposed, not decided; flagged as scope creep. Cheap in API terms but adds a reconciliation step between two candidate sets that may disagree. Not in scope for the test integration |
|
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Table branch — design proposal (Step 8)
|
| 2 |
+
|
| 3 |
+
<!-- doc-status -->
|
| 4 |
+
> 💡 **PROPOSAL — nothing is built from this.** Written 2026-09-11 against the open queue in
|
| 5 |
+
> `knowledge_pipeline_runs/SESSION_HANDOFF.md` §4.6. Needs Rifqi's sign-off before any code.
|
| 6 |
+
> Roll-up: [DEV_PLAN.md](../../DEV_PLAN.md) §0.8 · built state: [REPO_STATUS.md](../../REPO_STATUS.md) §14.
|
| 7 |
+
|
| 8 |
+
**Owner:** Rifqi · **Size:** weeks · **Depends on:** parsing 0.4.0 (shipped), genre classification
|
| 9 |
+
(shipped 2026-09-11, advisory) · **Blocks:** any useful result on diagnostic documents.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 1 · The problem, stated as a number
|
| 14 |
+
|
| 15 |
+
The Komatsu HD785-7 troubleshooting manual is **74% tabular** — 59 tables across 80 chunks — and the
|
| 16 |
+
pipeline extracts **zero** knowledge entries from it. Two paid runs, v1 and v2, both returned **0
|
| 17 |
+
definitions** from 75 and 93 clusters respectively. That is not a tuning failure and no prompt change
|
| 18 |
+
reaches it: a shop manual does not define its terms, it **asserts relationships between them**, and
|
| 19 |
+
it does so in a matrix.
|
| 20 |
+
|
| 21 |
+
The genre classifier (`src/knowledge_extraction/genre.py`) now names this document `diagnostic` and
|
| 22 |
+
reports `well_served = False` before anything is spent. This proposal is what would make that answer
|
| 23 |
+
change.
|
| 24 |
+
|
| 25 |
+
## 2 · What the document actually contains
|
| 26 |
+
|
| 27 |
+
A troubleshooting table is a **symptom × cause matrix**. Each marked cell is one assertion:
|
| 28 |
+
|
| 29 |
+
| Symptom \ Cause | Fuel filter clogged | Injector worn | Air cleaner blocked |
|
| 30 |
+
|---|---|---|---|
|
| 31 |
+
| Engine does not start | ● | ● | |
|
| 32 |
+
| Black exhaust smoke | | ● | ● |
|
| 33 |
+
| Output drops | ● | | ● |
|
| 34 |
+
|
| 35 |
+
Three assertions come out of the first row alone, and **none of them is a definition**:
|
| 36 |
+
|
| 37 |
+
> *"Engine does not start" may be caused by "Fuel filter clogged".*
|
| 38 |
+
|
| 39 |
+
The unit of knowledge here is the **(row header, column header, cell mark) triple**, and it is
|
| 40 |
+
recoverable **deterministically** — the mark is in the parsed HTML, the headers are in the parsed
|
| 41 |
+
HTML, nothing needs to be inferred.
|
| 42 |
+
|
| 43 |
+
## 3 · The shape of the branch
|
| 44 |
+
|
| 45 |
+
**Deterministic extraction, LLM only to phrase.** This is the inversion of every existing branch, and
|
| 46 |
+
it is the whole point: the assertion's *existence* is read off the table, so the model cannot
|
| 47 |
+
fabricate a relationship that is not in the document — it can only phrase one that is.
|
| 48 |
+
|
| 49 |
+
```
|
| 50 |
+
table chunk ──► parse table_html ──► (row header, col header, cell mark) triples
|
| 51 |
+
│
|
| 52 |
+
├── deterministic: the assertion EXISTS
|
| 53 |
+
│
|
| 54 |
+
▼
|
| 55 |
+
one LLM call per TABLE (not per cell)
|
| 56 |
+
│
|
| 57 |
+
▼
|
| 58 |
+
natural-language phrasing + typing
|
| 59 |
+
│
|
| 60 |
+
▼
|
| 61 |
+
span check against the cell's own headers
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
**Provenance points at the cell**, which is stronger than anything the pipeline produces today: the
|
| 65 |
+
span is the row header plus the column header, both verbatim document text, both locatable by the
|
| 66 |
+
existing `span_present`.
|
| 67 |
+
|
| 68 |
+
### 3.1 · New entity
|
| 69 |
+
|
| 70 |
+
```python
|
| 71 |
+
class RelationEntry(BaseModel):
|
| 72 |
+
relation_id: str # sha256 over (doc_id, table asset_id, row, col) — positional
|
| 73 |
+
# within a table, but the table's asset_id is content-addressed,
|
| 74 |
+
# so a re-parse of identical bytes re-mints identical ids.
|
| 75 |
+
kind: Literal["symptom_cause", "component_action", "condition_effect"]
|
| 76 |
+
subject: str # verbatim row header
|
| 77 |
+
object: str # verbatim column header
|
| 78 |
+
statement: str | None # model-phrased; span-checked against subject + object + caption
|
| 79 |
+
table_asset_id: str
|
| 80 |
+
provenance: Provenance # span = the two headers; page from the chunk
|
| 81 |
+
term_ids: list[str] # links into the glossary where headers match known terms
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
### 3.2 · Why one call per table, not per cell
|
| 85 |
+
|
| 86 |
+
Komatsu's 59 tables carry on the order of 10³ marked cells. One call per cell is a different cost
|
| 87 |
+
class entirely and would blow the pre-flight ceiling by itself. One call per table hands the model
|
| 88 |
+
the linearised table plus the extracted triples and asks it to phrase each one — 59 calls, which is
|
| 89 |
+
**less than the 93 the glossary branch currently spends to abstain 93 times.**
|
| 90 |
+
|
| 91 |
+
## 4 · What has to be decided before coding
|
| 92 |
+
|
| 93 |
+
| # | Question | Why it blocks |
|
| 94 |
+
|---|---|---|
|
| 95 |
+
| T1 | Is a relation a first-class **entry kind** (a fifth `kind` in `knowledge_entries`) or a new table? | Persistence contract + Harry's migration. A fifth kind is additive; a table is a DDL handoff |
|
| 96 |
+
| T2 | What does a reviewer **do** with 1,000 relations? | The review queue is frequency-sorted and finishable by construction. 1,000 relations is not finishable. Likely answer: review per TABLE, not per relation — one approve covers the matrix |
|
| 97 |
+
| T3 | Does a mark type carry meaning (`●` vs `○` vs `▲`)? | Komatsu uses more than one glyph. If they mean "primary/secondary cause" the triple is really a quad, and getting this wrong silently inverts assertions |
|
| 98 |
+
| T4 | Which `table_html` shapes does the parser actually emit — merged cells, multi-row headers? | Determines whether header resolution is 20 lines or a small parser. **Measure before designing** |
|
| 99 |
+
| T5 | Does this ship behind the genre router (`diagnostic` only) or for every document? | BUMA is 25% tabular and its tables are *data*, not relation matrices. Running this on them produces noise |
|
| 100 |
+
|
| 101 |
+
**T4 is the one to do first** and it is free: inspect the 59 Komatsu tables and count the shapes.
|
| 102 |
+
|
| 103 |
+
## 5 · Kill lines, agreed before the first paid run
|
| 104 |
+
|
| 105 |
+
Copying the discipline that made the clustering defect findable — a metric that moves the *wrong* way
|
| 106 |
+
is worse than no metric (E2), so these are stated as recall against a **human-read sample**:
|
| 107 |
+
|
| 108 |
+
| Metric | What it measures | Kill line |
|
| 109 |
+
|---|---|---|
|
| 110 |
+
| **T-E1** | Triples extracted ÷ triples a human reads off the same 5 tables | ≥ 0.90 — it is deterministic; anything less is a parser bug |
|
| 111 |
+
| **T-E2** | Phrasings whose span check passes | ≥ 0.95 |
|
| 112 |
+
| **T-E3** | Phrasings a human judges to *invert or distort* the relation | 0 — this is the one that matters, and it is the reason the mark glyph question (T3) is not cosmetic |
|
| 113 |
+
| **T-cost** | Calls per document | ≤ 1 per table |
|
| 114 |
+
|
| 115 |
+
**T-E3 has no automated proxy.** It needs Mas Beta or an equivalent reader on a sample, which makes
|
| 116 |
+
the R4 conversation (does "found but undefined" help an expert?) worth having in the same sitting.
|
| 117 |
+
|
| 118 |
+
## 6 · What this does NOT solve
|
| 119 |
+
|
| 120 |
+
A relation matrix is not a glossary. After this branch ships, the Komatsu manual still yields **zero
|
| 121 |
+
definitions** — correctly, because it contains none. If the product's promise is "a glossary from
|
| 122 |
+
your documents", a diagnostic document will always disappoint it, and the honest place to say so is
|
| 123 |
+
the ingest response, which the genre classifier now makes possible.
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## 7 · Groundwork already landed (2026-09-11)
|
| 128 |
+
|
| 129 |
+
Two pieces of this branch's plumbing exist before the branch does, and both are measured:
|
| 130 |
+
|
| 131 |
+
**The pointer is populated and durable.** 0.4.0 gives every table chunk an `Asset` whose id comes
|
| 132 |
+
from `synthetic_asset_id(doc_id, chunk_id, item_index)` — stable document coordinates, so it survives
|
| 133 |
+
a re-parse — plus `referenced_by` naming the prose chunk that introduces it, and the reverse pointer
|
| 134 |
+
on that prose chunk's `assets`.
|
| 135 |
+
|
| 136 |
+
**One linked partner now joins the evidence window** (`EVIDENCE_LINK_PARTNERS = 1`, in `rank/evidence.py`).
|
| 137 |
+
The measurement that motivated it: across 603 clusters on the three test documents, **zero** had both
|
| 138 |
+
sides of a linked pair in their top-5, so a table reached the model without the prose naming it, and
|
| 139 |
+
vice versa. 31/83, 55/93 and 70/427 clusters now gain a partner, at +1.6%/+4.1%/+4.2% of prompt spend.
|
| 140 |
+
|
| 141 |
+
**What this does *not* do, and the branch must:** `table_html` crosses the seam into
|
| 142 |
+
`Chunk.table_html` and **nothing reads it** — the prompt shows the linearised `render_table` text
|
| 143 |
+
from `Chunk.text` instead. That is deliberate: the span check validates against `heading + text`
|
| 144 |
+
only, so putting HTML in front of the model would let it quote markup that is then nulled as
|
| 145 |
+
unlocatable. A branch that wants the real cell structure has to extend the span-checked text too, or
|
| 146 |
+
carry provenance that points at a cell rather than a character span (§3.1's `cell_ref` does the
|
| 147 |
+
latter, which is the cleaner of the two).
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## 8 · Correction and re-diagnosis of the Komatsu tables (2026-09-11)
|
| 153 |
+
|
| 154 |
+
> **Correction.** An earlier reading of this corpus (same day, recorded in the session
|
| 155 |
+
> notes) said the troubleshooting charts had **lost their column headers**, on the
|
| 156 |
+
> evidence that only 4 of 59 tabular chunks contain a `<th>`. That count is accurate
|
| 157 |
+
> and the conclusion drawn from it was **wrong**. The headers are present; they are
|
| 158 |
+
> emitted as ordinary `<td>` cells in a **separate chunk**.
|
| 159 |
+
|
| 160 |
+
### What Mistral actually produces
|
| 161 |
+
|
| 162 |
+
Each chart is split into three or four sibling tables, and the first cell of each names
|
| 163 |
+
the fragment. Across the 59 tabular chunks:
|
| 164 |
+
|
| 165 |
+
| first cell | count | what it holds |
|
| 166 |
+
|---|---|---|
|
| 167 |
+
| `Causes` | 17 | **the column headers** — the cause list |
|
| 168 |
+
| `Troubleshooting` | 16 | check rows |
|
| 169 |
+
| `Questions` | 15 | interview/symptom rows |
|
| 170 |
+
| `Remedy` | 10 | the action row |
|
| 171 |
+
|
| 172 |
+
So the (row header, column header, cell mark) triple is recoverable, but its three parts
|
| 173 |
+
live in **different chunks**. The branch's first job is re-assembly, not OCR recovery —
|
| 174 |
+
the fragments are adjacent, same-page, and regularly labelled.
|
| 175 |
+
|
| 176 |
+
### The defect that is real
|
| 177 |
+
|
| 178 |
+
**Marks survive in only 20 of 59 tabular chunks.** Doc-wide there are 278 marks
|
| 179 |
+
(18 ◎, 137 ○, 25 △, 98 ●), and they cluster on the pages where the matrix fragment
|
| 180 |
+
kept them; on pages 9-10 the `Questions` and `Troubleshooting` fragments come through
|
| 181 |
+
with row labels and **zero marks**. Column structure and `rowspan`/`colspan` are
|
| 182 |
+
preserved where the marks are.
|
| 183 |
+
|
| 184 |
+
A chart whose marks were dropped carries no symptom-to-cause link at all, so roughly
|
| 185 |
+
two thirds of the document's assertions are currently unrecoverable from the artifact.
|
| 186 |
+
**That, not the headers, is what gates this branch**, and it has to be fixed at the
|
| 187 |
+
parsing layer.
|
| 188 |
+
|
| 189 |
+
### Free evidence that the content is reachable
|
| 190 |
+
|
| 191 |
+
Adding a `fault code` label to the span filter (see `config/labels.yaml`) surfaced
|
| 192 |
+
**66 cause phrases** as clusters on this document — *"Clogged fuel filter and/or
|
| 193 |
+
strainer"*, *"Improper injection timing"*, *"Defective alternator (regulator section)"* —
|
| 194 |
+
plus 17 service codes. Those are the column headers, read out of the `Causes`
|
| 195 |
+
fragments. The raw material for the branch is already in the artifact and already
|
| 196 |
+
findable; what is missing is the mark matrix that connects it to the symptoms.
|
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Parent/child terms and cross-document identity — design proposal (Step 7 + R7)
|
| 2 |
+
|
| 3 |
+
<!-- doc-status -->
|
| 4 |
+
> 💡 **PROPOSAL — nothing is built from this.** Written 2026-09-11 against `SESSION_HANDOFF.md` §4.7.
|
| 5 |
+
> Needs Rifqi's sign-off, and **must not be built before the reading in §6 is done.**
|
| 6 |
+
> Roll-up: [DEV_PLAN.md](../../DEV_PLAN.md) §0.8 · built state: [REPO_STATUS.md](../../REPO_STATUS.md) §14.
|
| 7 |
+
|
| 8 |
+
**Owner:** Rifqi · **Size:** a quarter or more · **Touches:** `entity_id` derivation, which is what
|
| 9 |
+
every expert approval hangs on. **This is the one change on the queue that cannot be done twice.**
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 1 · Why v2 sharpened this rather than solving it
|
| 14 |
+
|
| 15 |
+
Retiring the fuzzy clustering pass (K13) was correct and is not in question: `token_set_ratio`
|
| 16 |
+
returned 100 on token-subset pairs, so a head noun always absorbed its own compounds — 131 of 140
|
| 17 |
+
merges were wrong, and post-cluster recall rose 0.8293 → 0.8537 when the pass was removed.
|
| 18 |
+
|
| 19 |
+
But look at what the corpus now says about `engine` and `engine oil`:
|
| 20 |
+
|
| 21 |
+
| | before K13 | after K13 | the truth |
|
| 22 |
+
|---|---|---|---|
|
| 23 |
+
| `engine` / `engine oil` | **the same cluster** (wrong) | **unrelated** (also wrong) | `engine oil` is a *child concept* of `engine` |
|
| 24 |
+
|
| 25 |
+
The pipeline has exactly two ways to express a relationship between two terms — same cluster, or no
|
| 26 |
+
relationship — and neither is right. **The defect was never the threshold; it was the vocabulary.**
|
| 27 |
+
Fixing the merge removed the loud wrong answer and left the quiet one.
|
| 28 |
+
|
| 29 |
+
## 2 · The two problems are one problem
|
| 30 |
+
|
| 31 |
+
Step 7 (parent/child within a document) and R7 (the same concept across documents) look separable and
|
| 32 |
+
are not. Both ask *"are these two surfaces the same entity, and if not, how are they related?"* — one
|
| 33 |
+
within a document, one across. SciCo's contribution is precisely that **coreference and hierarchy are
|
| 34 |
+
solved jointly**; solving one and bolting on the other produces a model where `engine oil` in
|
| 35 |
+
document A relates to `engine` in document A but has no defined relationship to `engine oil` in
|
| 36 |
+
document B.
|
| 37 |
+
|
| 38 |
+
They also share the thing that makes this irreversible: **both change `entity_id`.**
|
| 39 |
+
|
| 40 |
+
## 3 · The constraint that dominates the design
|
| 41 |
+
|
| 42 |
+
`entity_id` is `sha256` over content and is what an expert's approval is recorded against
|
| 43 |
+
(`knowledge_reviews.entity_id`). Three consequences, all of them already learned here:
|
| 44 |
+
|
| 45 |
+
1. **K11** — `cluster_id` was a frequency ordinal and `rule_id` was model-written; neither survived a
|
| 46 |
+
re-run, which broke approvals rather than links. Both are now deterministic.
|
| 47 |
+
2. **K12** — `BriefContext` → `DomainContext` was renamed *while nothing was persisted*, explicitly
|
| 48 |
+
because afterwards an id-prefix change is a migration that loses approvals.
|
| 49 |
+
3. **`scope_id` is deliberately excluded from the derivation** (2026-09-07), which is what keeps
|
| 50 |
+
re-pointing a tenant a value migration rather than a re-key.
|
| 51 |
+
|
| 52 |
+
**So: a hierarchy must not be encoded in the id.** `t_<hash>` must stay a hash of the term's own
|
| 53 |
+
content. Parenthood is an edge between two entities, stored beside them — never a path, never a
|
| 54 |
+
compound key. Getting this wrong means every subsequent hierarchy correction invalidates approvals.
|
| 55 |
+
|
| 56 |
+
## 4 · Proposed model
|
| 57 |
+
|
| 58 |
+
```python
|
| 59 |
+
class TermRelation(BaseModel):
|
| 60 |
+
parent_id: str
|
| 61 |
+
child_id: str
|
| 62 |
+
kind: Literal["narrower", "variant", "abbreviation", "related"]
|
| 63 |
+
evidence: Literal["lexical_subset", "abbrev_pair", "shared_definition", "expert"]
|
| 64 |
+
confidence: float # never a hard merge; see §5
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
Four relationship kinds, because collapsing them is what the fuzzy pass did:
|
| 68 |
+
|
| 69 |
+
- **`narrower`** — `engine oil` under `engine`. Lexical subset **plus** type agreement.
|
| 70 |
+
- **`variant`** — `Working Hours` / `working hour`. Already handled deterministically by
|
| 71 |
+
`depluralise()`; this records it rather than changing it.
|
| 72 |
+
- **`abbreviation`** — `PA` / `Physical Availability`. Already a first-class cluster rule.
|
| 73 |
+
- **`related`** — co-defined, no hierarchy. The escape hatch that stops the other three being abused.
|
| 74 |
+
|
| 75 |
+
Clustering is **unchanged**. Relations are computed *after* clustering, between clusters. That is the
|
| 76 |
+
property that makes this safe to ship incrementally: with the relation table empty, behaviour is
|
| 77 |
+
exactly today's.
|
| 78 |
+
|
| 79 |
+
## 5 · GLiNER types are a signal, not a veto
|
| 80 |
+
|
| 81 |
+
Measured 2026-09-10: **48% of token-subset over-merges involved disagreeing GLiNER types.** That is a
|
| 82 |
+
strong enough signal to *type* a proposed relation and far too noisy to *gate* one — the term filter
|
| 83 |
+
runs at threshold 0.25 precisely because it is meant to be over-inclusive, and its labels are
|
| 84 |
+
correspondingly loose. Using a 0.25-threshold label as a hard merge veto would reintroduce the
|
| 85 |
+
clustering defect with the sign flipped: instead of silently merging distinct terms, it would
|
| 86 |
+
silently refuse real relationships.
|
| 87 |
+
|
| 88 |
+
**Rule: type agreement raises confidence; type disagreement lowers it; neither decides.**
|
| 89 |
+
|
| 90 |
+
## 6 · Read before building
|
| 91 |
+
|
| 92 |
+
Non-negotiable, and the reason this is a quarter rather than a sprint:
|
| 93 |
+
|
| 94 |
+
| Source | What it settles |
|
| 95 |
+
|---|---|
|
| 96 |
+
| **SciCo** (hierarchical cross-document coreference) | Why the two problems are one, and what a jointly-solved model looks like |
|
| 97 |
+
| **ILLOD** (abbreviation–expansion detection) | Whether the abbreviation path should change at all — it is the one relation already working |
|
| 98 |
+
| **Nested-NER survey** | Whether nested decoding helps here after all. Note the local measurement said no (2026-09-10: +7–12% mentions, **zero** recall gain, +40–80% over-merges) — the survey is for understanding *why*, not for re-opening the setting |
|
| 99 |
+
|
| 100 |
+
## 7 · Sequencing, and what must come first
|
| 101 |
+
|
| 102 |
+
**This must land before an expert approves anything at volume.** Every approval recorded under the
|
| 103 |
+
current flat model is an approval of a term that may later acquire a parent, and re-keying after the
|
| 104 |
+
fact is the one migration this design exists to avoid.
|
| 105 |
+
|
| 106 |
+
Concretely, in order:
|
| 107 |
+
|
| 108 |
+
1. **R4 — ask Mas Beta** whether "term found, no definition in document" is useful or noise. It is one
|
| 109 |
+
conversation, it is still not had, and it **gates the queue design** this feeds: 48 of 58 BUMA
|
| 110 |
+
entries and 376 of 425 Open Pit entries are exactly that shape, and whether a hierarchy should
|
| 111 |
+
surface them differently depends entirely on the answer.
|
| 112 |
+
2. **Step 0** — measurement hardening. A hierarchy change is a *quality* change, and a single run
|
| 113 |
+
stopped being evidence the moment quality became the subject.
|
| 114 |
+
3. This proposal, reviewed against the reading in §6.
|
| 115 |
+
|
| 116 |
+
## 8 · What it would buy, honestly
|
| 117 |
+
|
| 118 |
+
No recall. Recall is a term-filter and clustering property and this changes neither. What it buys is
|
| 119 |
+
**a reviewable structure**: an expert approving `engine` once, with `engine oil`, `engine coolant`
|
| 120 |
+
and `engine speed` beneath it, rather than meeting four unrelated rows at four different ranks. On
|
| 121 |
+
the Open Pit textbook — 425 entries, 49 of them defined — that is the difference between a queue
|
| 122 |
+
someone finishes and a queue someone abandons.
|
|
@@ -1,26 +1,70 @@
|
|
| 1 |
-- Knowledge pipeline tables — dedorch
|
| 2 |
--
|
| 3 |
-
-- Source of record:
|
| 4 |
-
--
|
| 5 |
-
-- reproducible and reviewable rather than retyped from prose.
|
| 6 |
--
|
| 7 |
-- ⚠️ OWNERSHIP. Go owns every dedorch migration (CLAUDE.md §2.2). Python
|
| 8 |
-- executes NO DDL, ever: `SKIP_INIT_DB` stays true, there is no `create_all`
|
| 9 |
-
-- and no Alembic against this database.
|
| 10 |
-
-- operator to unblock the build, following the 2026-07-13 `message_charts`
|
| 11 |
-
-- precedent — and that precedent's mistake
|
| 12 |
-
-- run by hand and never handed over, so a fresh environment built
|
| 13 |
-
-- migrations comes up WITHOUT those tables and both write
|
|
|
|
| 14 |
--
|
| 15 |
-- Run by hand, then hand over. Never run by hand only.
|
| 16 |
--
|
| 17 |
-
--
|
| 18 |
-
--
|
| 19 |
-
--
|
| 20 |
-
-- position (message_traceability, message_charts).
|
| 21 |
--
|
| 22 |
-
--
|
| 23 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-- 1. Parsed document artifact. Chunks live INSIDE `artifact` (jsonb), deliberately
|
| 26 |
-- not a child table: documents are small (9 pages = 31 chunks) and the artifact
|
|
@@ -168,3 +212,97 @@ CREATE TABLE IF NOT EXISTS knowledge_jobs (
|
|
| 168 |
completed_at timestamptz
|
| 169 |
);
|
| 170 |
CREATE INDEX IF NOT EXISTS idx_knowledge_jobs_user ON knowledge_jobs (user_id, created_at DESC);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
-- Knowledge pipeline tables — dedorch
|
| 2 |
--
|
| 3 |
+
-- HANDOFF TO HARRY. Updated 2026-09-14. Source of record:
|
| 4 |
+
-- docs/knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md §2.
|
|
|
|
| 5 |
--
|
| 6 |
-- ⚠️ OWNERSHIP. Go owns every dedorch migration (CLAUDE.md §2.2). Python
|
| 7 |
-- executes NO DDL, ever: `SKIP_INIT_DB` stays true, there is no `create_all`
|
| 8 |
+
-- and no Alembic against this database. Everything in this file was run ONCE by
|
| 9 |
+
-- an operator to unblock the build, following the 2026-07-13 `message_charts`
|
| 10 |
+
-- precedent — and that precedent's mistake is the one this file exists to avoid:
|
| 11 |
+
-- the schema was run by hand and never handed over, so a fresh environment built
|
| 12 |
+
-- from Go's migrations comes up WITHOUT those tables, and because both write
|
| 13 |
+
-- paths are never-throw seams, nothing errors. They just stop recording.
|
| 14 |
--
|
| 15 |
-- Run by hand, then hand over. Never run by hand only.
|
| 16 |
--
|
| 17 |
+
-- ─────────────────────────────────────────────────────────────────────────────
|
| 18 |
+
-- WHAT IS LIVE IN DEDORCH RIGHT NOW, AND WHAT YOU ALREADY HAVE
|
| 19 |
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
| 20 |
--
|
| 21 |
+
-- §1-§5 five tables run 2026-09-07 sent ✅
|
| 22 |
+
-- §6 4 columns + 2 indexes run 2026-09-14 sent ✅
|
| 23 |
+
-- §7 3 columns (review modes) run 2026-09-14 NOT SENT ⬅ new
|
| 24 |
+
--
|
| 25 |
+
-- **If you adopted the copy sent before 2026-09-14, it is now incomplete in two
|
| 26 |
+
-- ways, and one of them is silent:**
|
| 27 |
+
--
|
| 28 |
+
-- 1. §7 is missing entirely. Without it, `POST /knowledge/ingest` with
|
| 29 |
+
-- `review_mode=llm` fails on insert and no entry is ever auto-approved.
|
| 30 |
+
-- 2. §1-§5 DRIFTED AFTER the 2026-09-07 run, and `CREATE TABLE IF NOT EXISTS`
|
| 31 |
+
-- will not repair an existing table. Two edits landed:
|
| 32 |
+
-- * `knowledge_entries.kind` CHECK gained 'document'
|
| 33 |
+
-- * `knowledge_entries.run_id` / `.knowledge_document_id` became NULLABLE
|
| 34 |
+
-- for kind='domain' only, guarded by knowledge_entries_parent_check
|
| 35 |
+
-- Both are reflected in §3 below. **Take the current file, not the old one.**
|
| 36 |
+
--
|
| 37 |
+
-- Nothing here needs designing. The ask is only that the migration set
|
| 38 |
+
-- reproduce what is already running, so a fresh environment matches.
|
| 39 |
+
--
|
| 40 |
+
-- ─────────────────────────────────────────────────────────────────────────────
|
| 41 |
+
-- STILL OUTSTANDING FROM AN EARLIER ROUND — not in this file
|
| 42 |
+
-- ─────────────────────────────────────────────────────────────────────────────
|
| 43 |
+
--
|
| 44 |
+
-- `message_traceability` (80 rows) and `message_charts` (6 rows) have been live
|
| 45 |
+
-- since July, created the same way, and are still absent from the migration set.
|
| 46 |
+
-- Their DDL is in KNOWLEDGE_PERSISTENCE_CONTRACT.md §appendix rather than here.
|
| 47 |
+
-- Say the word and it gets folded into this file so one artifact covers every
|
| 48 |
+
-- Python-written table you are missing.
|
| 49 |
+
--
|
| 50 |
+
-- ─────────────────────────────────────────────────────────────────────────────
|
| 51 |
+
-- VERIFIED AGAINST LIVE — 2026-09-14, read-only
|
| 52 |
+
-- ─────────────────────────────────────────────────────────────────────────────
|
| 53 |
+
--
|
| 54 |
+
-- Not 'what we believe ran'. Read back out of dedorch after the fact:
|
| 55 |
+
--
|
| 56 |
+
-- 5 tables knowledge_documents 19 cols · knowledge_entries 18 ·
|
| 57 |
+
-- knowledge_extraction_runs 20 · knowledge_jobs 16 ·
|
| 58 |
+
-- knowledge_reviews 13
|
| 59 |
+
-- §6+§7 columns 7 of 7 present (doc_ids, content, provenance,
|
| 60 |
+
-- withdrawn_at, review_mode, decided_by, authorised_by)
|
| 61 |
+
-- indexes 19 on knowledge_* (12 named here + 7 PK/unique)
|
| 62 |
+
--
|
| 63 |
+
-- The ORM also reads every one of them against live, so the Python models
|
| 64 |
+
-- and this file agree with the database rather than only with each other.
|
| 65 |
+
--
|
| 66 |
+
-- Idempotent throughout (IF NOT EXISTS) · Additive only: no DROP, no type
|
| 67 |
+
-- change, nothing that rewrites an existing column.
|
| 68 |
|
| 69 |
-- 1. Parsed document artifact. Chunks live INSIDE `artifact` (jsonb), deliberately
|
| 70 |
-- not a child table: documents are small (9 pages = 31 chunks) and the artifact
|
|
|
|
| 212 |
completed_at timestamptz
|
| 213 |
);
|
| 214 |
CREATE INDEX IF NOT EXISTS idx_knowledge_jobs_user ON knowledge_jobs (user_id, created_at DESC);
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
-- ============================================================================
|
| 218 |
+
-- §6. EXECUTED 2026-09-14 — from the 2026-09-08 discussion (m3, m4, m5, m8, i7).
|
| 219 |
+
--
|
| 220 |
+
-- Verified after the run: 4 new columns and 2 new indexes present, and the
|
| 221 |
+
-- ORM reads all of them against live. Harry still needs this in the
|
| 222 |
+
-- migration set so a fresh environment reproduces it.
|
| 223 |
+
--
|
| 224 |
+
-- Additive only: three columns and one index on `knowledge_entries`. Nothing
|
| 225 |
+
-- is dropped and no column changes type, so a pre-migration reader keeps
|
| 226 |
+
-- working and Python's reads stay tolerant of both shapes while this lands.
|
| 227 |
+
-- ============================================================================
|
| 228 |
+
|
| 229 |
+
-- m3. "doc_id jadi list". One entry fuses meaning from several documents, but
|
| 230 |
+
-- every source stays recorded. `doc_id` REMAINS the primary source — every
|
| 231 |
+
-- existing filter and index uses it, and a single extracted assertion
|
| 232 |
+
-- always has exactly one — while `doc_ids` carries the full list. Additive
|
| 233 |
+
-- rather than a type change: rewriting `doc_id text` into jsonb in place
|
| 234 |
+
-- would break every read at once, for a value that is still needed.
|
| 235 |
+
ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS doc_ids jsonb NOT NULL DEFAULT '[]';
|
| 236 |
+
|
| 237 |
+
-- m4. "kolom content". The definition or rule body, lifted out of `payload` so
|
| 238 |
+
-- a consumer does not have to open jsonb to use it — the objection raised
|
| 239 |
+
-- on 2026-09-08 was that too much JSON makes the data contract hard and
|
| 240 |
+
-- easy to break. `payload` stays the source of truth; this is a promoted
|
| 241 |
+
-- copy written from the same object in the same insert, exactly the
|
| 242 |
+
-- convention `label`, `mention_count` and `diff_status` already follow.
|
| 243 |
+
ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS content text;
|
| 244 |
+
|
| 245 |
+
-- m5. "kolom provenance". Which document, which page — a list of objects:
|
| 246 |
+
-- [{"doc_id": "STD_2026_006_MNO", "page": 3, "page_no": 4,
|
| 247 |
+
-- "section_no": "2.1.3", "chunk_id": "STD_2026_006_MNO::0007"}]
|
| 248 |
+
-- Both page forms travel together on purpose: `page` is 0-based as the
|
| 249 |
+
-- parser reports it, `page_no` is the 1-based number a human reads, and
|
| 250 |
+
-- they are derived from one value so they cannot disagree.
|
| 251 |
+
-- `section_no` is included because the review queue already renders it —
|
| 252 |
+
-- whether it earns a place was left open in the meeting, so drop it from
|
| 253 |
+
-- this shape if the answer is no.
|
| 254 |
+
ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS provenance jsonb NOT NULL DEFAULT '[]';
|
| 255 |
+
|
| 256 |
+
-- m8. Makes the readable-key lookup a hit instead of a scan within the tenant.
|
| 257 |
+
-- `label` now carries the TYPE_SUBJECT key (`GLOSSARY_PA`), and
|
| 258 |
+
-- `KnowledgeStore.get_by_key` filters on exactly this pair. Without the
|
| 259 |
+
-- index the lookup is still correct, just not the O(1) the design intends.
|
| 260 |
+
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_key ON knowledge_entries (scope_id, label);
|
| 261 |
+
|
| 262 |
+
-- i7. "kasus dokumen dihapus" — ruling 2026-09-14: mark withdrawn, keep the
|
| 263 |
+
-- rows, flag what is left. A withdrawn document stops counting towards the
|
| 264 |
+
-- knowledge without anything being destroyed: an approval is an act someone
|
| 265 |
+
-- took, and the record of what it referred to must survive the source going
|
| 266 |
+
-- away. Null = live. Set = withdrawn.
|
| 267 |
+
-- Entries whose last remaining source is withdrawn are surfaced to the
|
| 268 |
+
-- expert to rule on rather than deleted — whether the knowledge leaves with
|
| 269 |
+
-- the document is their call, not the pipeline's.
|
| 270 |
+
ALTER TABLE knowledge_documents ADD COLUMN IF NOT EXISTS withdrawn_at timestamptz;
|
| 271 |
+
CREATE INDEX IF NOT EXISTS idx_knowledge_documents_live
|
| 272 |
+
ON knowledge_documents (scope_id, doc_id) WHERE withdrawn_at IS NULL;
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
-- ============================================================================
|
| 276 |
+
-- §7. EXECUTED 2026-09-14 — the review system (r1, r2, r3) from the
|
| 277 |
+
-- 2026-09-08 discussion. Verified after the run: the ORM reads
|
| 278 |
+
-- review_mode, decided_by and authorised_by against live.
|
| 279 |
+
--
|
| 280 |
+
-- Additive only: three columns, no drops, no type changes.
|
| 281 |
+
--
|
| 282 |
+
-- The point of these, in Mas's words: an entry approved without a human
|
| 283 |
+
-- looking at it must still say WHO allowed that to happen. *"Kalau ada apa-apa
|
| 284 |
+
-- jangan sampai kita yang disalahin… ya udah itu by mereka aja."* It is a
|
| 285 |
+
-- risk-mitigation requirement, not bookkeeping — which is why it belongs in
|
| 286 |
+
-- columns rather than in a free-text note.
|
| 287 |
+
-- ============================================================================
|
| 288 |
+
|
| 289 |
+
-- r1. Which review mode a batch was ingested under. Chosen by the caller at
|
| 290 |
+
-- ingest time: 'expert' leaves entries waiting for a human; 'llm' takes
|
| 291 |
+
-- them straight to approved with no reviewer step at all.
|
| 292 |
+
-- Nullable so every job written before this column existed stays legal.
|
| 293 |
+
ALTER TABLE knowledge_jobs ADD COLUMN IF NOT EXISTS review_mode text
|
| 294 |
+
CHECK (review_mode IS NULL OR review_mode IN ('expert','llm'));
|
| 295 |
+
|
| 296 |
+
-- r2. WHO decided. 'human' is the existing behaviour and the default, so every
|
| 297 |
+
-- review already recorded keeps its meaning without a backfill.
|
| 298 |
+
ALTER TABLE knowledge_reviews ADD COLUMN IF NOT EXISTS decided_by text
|
| 299 |
+
NOT NULL DEFAULT 'human'
|
| 300 |
+
CHECK (decided_by IN ('human','llm'));
|
| 301 |
+
|
| 302 |
+
-- r3. On WHOSE AUTHORITY. The admin who clicked ingest and the expert who
|
| 303 |
+
-- reviews can be different people, and in 'llm' mode there is no reviewer
|
| 304 |
+
-- at all — so the clicker's id has to reach this row or the decision has
|
| 305 |
+
-- no owner. `reviewer_id` stays "who performed the review";
|
| 306 |
+
-- `authorised_by` is "who permitted it to happen this way".
|
| 307 |
+
-- Together they render: approved by LLM with permission of <person>.
|
| 308 |
+
ALTER TABLE knowledge_reviews ADD COLUMN IF NOT EXISTS authorised_by text;
|
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 0 — assert on FIELDS AND INVARIANTS, never on exact text.
|
| 2 |
+
|
| 3 |
+
Why this file exists. `temperature=0` is argmax over logits that a shared
|
| 4 |
+
inference server does not hand back identically twice, and this corpus has a
|
| 5 |
+
recorded case of identical input scoring 6/7 one day and 0/12 the next. So an
|
| 6 |
+
assertion of the form "the definition equals this sentence" is not a test — it
|
| 7 |
+
is a coin flip that fails on a day the wording moves by a comma, and passes on a
|
| 8 |
+
day the pipeline is quietly broken in a way the wording does not show.
|
| 9 |
+
|
| 10 |
+
What survives that noise is structure. "A definition, if present, is locatable
|
| 11 |
+
verbatim in the document" is true of every correct run and false of every broken
|
| 12 |
+
one, no matter which sentence the model picked. These are those claims.
|
| 13 |
+
|
| 14 |
+
Every check here is free: deterministic, local, no API call. They run after the
|
| 15 |
+
paid stage on whatever it produced, so they cost nothing to keep on.
|
| 16 |
+
|
| 17 |
+
**A violation is a defect in the pipeline, not a bad score.** A metric moving is
|
| 18 |
+
information; an invariant breaking means a control leaked, so the runner exits
|
| 19 |
+
non-zero on any violation regardless of how the scores looked.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from dataclasses import dataclass, field
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
from src.knowledge_extraction.validate import span_present
|
| 28 |
+
|
| 29 |
+
# Entry kinds and the field each one calls its identity. Checked by name rather
|
| 30 |
+
# than by isinstance: entries reach here as `model_dump()` dicts, which is the
|
| 31 |
+
# same shape that gets persisted, so this tests what is stored.
|
| 32 |
+
ID_FIELDS = {
|
| 33 |
+
"glossary": "term_id",
|
| 34 |
+
"rule": "rule_id",
|
| 35 |
+
"formula": "formula_id",
|
| 36 |
+
# The domain entry kept `brief_id` through the v3 rename. Recorded here
|
| 37 |
+
# rather than corrected: renaming it is a migration that invalidates every
|
| 38 |
+
# expert approval recorded against the old prefix (K12's own reasoning).
|
| 39 |
+
"document": "brief_id",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class Violation:
|
| 45 |
+
check: str
|
| 46 |
+
kind: str
|
| 47 |
+
detail: str
|
| 48 |
+
|
| 49 |
+
def as_dict(self) -> dict[str, str]:
|
| 50 |
+
return {"check": self.check, "kind": self.kind, "detail": self.detail}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class InvariantReport:
|
| 55 |
+
violations: list[Violation] = field(default_factory=list)
|
| 56 |
+
checked: dict[str, int] = field(default_factory=dict)
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def ok(self) -> bool:
|
| 60 |
+
return not self.violations
|
| 61 |
+
|
| 62 |
+
def as_dict(self) -> dict[str, Any]:
|
| 63 |
+
return {
|
| 64 |
+
"ok": self.ok,
|
| 65 |
+
"n_violations": len(self.violations),
|
| 66 |
+
"checked": self.checked,
|
| 67 |
+
"violations": [v.as_dict() for v in self.violations[:50]],
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def check_invariants(result, doc) -> InvariantReport:
|
| 72 |
+
"""Structural claims that must hold of ANY correct run of ANY document."""
|
| 73 |
+
report = InvariantReport()
|
| 74 |
+
add = report.violations.append
|
| 75 |
+
|
| 76 |
+
source = "\n".join(chunk.text or "" for chunk in doc.chunks)
|
| 77 |
+
n_pages = doc.n_pages or 0
|
| 78 |
+
|
| 79 |
+
by_kind = {
|
| 80 |
+
"glossary": result.glossary,
|
| 81 |
+
"rule": result.rules,
|
| 82 |
+
"formula": result.formulas,
|
| 83 |
+
"document": [result.brief] if result.brief else [],
|
| 84 |
+
}
|
| 85 |
+
report.checked = {kind: len(entries) for kind, entries in by_kind.items()}
|
| 86 |
+
|
| 87 |
+
for kind, entries in by_kind.items():
|
| 88 |
+
id_field = ID_FIELDS[kind]
|
| 89 |
+
seen: set[str] = set()
|
| 90 |
+
for entry in entries:
|
| 91 |
+
label = entry.get("term") or entry.get("name") or entry.get(id_field) or "?"
|
| 92 |
+
|
| 93 |
+
# 1 — identity. Without it a re-run cannot tell an expert which rows
|
| 94 |
+
# they already cleared, which is worse than a wrong definition.
|
| 95 |
+
entity_id = entry.get(id_field)
|
| 96 |
+
if not entity_id:
|
| 97 |
+
add(Violation("entity_id_present", kind, f"{label}: {id_field} missing"))
|
| 98 |
+
elif entity_id in seen:
|
| 99 |
+
add(Violation("entity_id_unique", kind, f"{entity_id} appears twice"))
|
| 100 |
+
else:
|
| 101 |
+
seen.add(entity_id)
|
| 102 |
+
|
| 103 |
+
provenance = entry.get("provenance") or {}
|
| 104 |
+
span = (provenance.get("span") or "").strip()
|
| 105 |
+
|
| 106 |
+
# 2 — a claim that survived carries a span, and that span is real.
|
| 107 |
+
# This is the span check restated from the outside: if it ever
|
| 108 |
+
# fires, the guard leaked rather than the model misbehaved.
|
| 109 |
+
if _has_content(entry, kind):
|
| 110 |
+
if not span:
|
| 111 |
+
add(Violation("span_present", kind, f"{label}: content with no span"))
|
| 112 |
+
elif not span_present(span, source):
|
| 113 |
+
add(
|
| 114 |
+
Violation(
|
| 115 |
+
"span_locatable",
|
| 116 |
+
kind,
|
| 117 |
+
f"{label}: span not found verbatim in the document "
|
| 118 |
+
f"({span[:60]!r})",
|
| 119 |
+
)
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# 3 — provenance points somewhere that exists.
|
| 123 |
+
page = provenance.get("page")
|
| 124 |
+
if page is not None and n_pages and not 0 <= page < n_pages:
|
| 125 |
+
add(
|
| 126 |
+
Violation(
|
| 127 |
+
"page_in_range",
|
| 128 |
+
kind,
|
| 129 |
+
f"{label}: page {page} outside 0..{n_pages - 1}",
|
| 130 |
+
)
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# 4 — a rejected field is NULL on the entry it came from. The contract says
|
| 134 |
+
# rejected, never repaired; this is the only check that can tell the two
|
| 135 |
+
# apart after the fact.
|
| 136 |
+
for rejection in result.rejected:
|
| 137 |
+
entry = _find_entry(by_kind, rejection.entry_term)
|
| 138 |
+
if entry is not None and entry.get(rejection.field) not in (None, "", []):
|
| 139 |
+
add(
|
| 140 |
+
Violation(
|
| 141 |
+
"rejection_is_null",
|
| 142 |
+
rejection.branch,
|
| 143 |
+
f"{rejection.entry_term}.{rejection.field} was rejected but still "
|
| 144 |
+
"carries a value — a repaired span is an unfalsifiable claim",
|
| 145 |
+
)
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
_check_queue(result.review_queue, report)
|
| 149 |
+
return report
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _check_queue(queue: list[dict], report: InvariantReport) -> None:
|
| 153 |
+
"""The consumption surface. A UI binds these, so their shape is a contract."""
|
| 154 |
+
add = report.violations.append
|
| 155 |
+
report.checked["review_queue"] = len(queue)
|
| 156 |
+
|
| 157 |
+
ranks = [row.get("rank") for row in queue]
|
| 158 |
+
if ranks != list(range(1, len(queue) + 1)):
|
| 159 |
+
add(Violation("queue_ranks_contiguous", "review_queue", "ranks are not 1..N"))
|
| 160 |
+
|
| 161 |
+
for row in queue:
|
| 162 |
+
kind = row.get("row_kind")
|
| 163 |
+
if kind not in ("term", "key_parameter"):
|
| 164 |
+
add(Violation("row_kind_known", "review_queue", f"row_kind={kind!r}"))
|
| 165 |
+
if kind == "key_parameter" and row.get("term_id") is not None:
|
| 166 |
+
add(
|
| 167 |
+
Violation(
|
| 168 |
+
"key_parameter_has_no_term",
|
| 169 |
+
"review_queue",
|
| 170 |
+
f"{row.get('term')!r} carries a term_id, which is the one thing "
|
| 171 |
+
"a key_parameter row is defined by NOT having",
|
| 172 |
+
)
|
| 173 |
+
)
|
| 174 |
+
if "_conflict" in row or "_sort" in row:
|
| 175 |
+
add(
|
| 176 |
+
Violation(
|
| 177 |
+
"no_private_keys",
|
| 178 |
+
"review_queue",
|
| 179 |
+
f"{row.get('term')!r} leaks a sorting key into the payload",
|
| 180 |
+
)
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _has_content(entry: dict, kind: str) -> bool:
|
| 185 |
+
"""Did this entry actually assert something? An abstention is correct
|
| 186 |
+
behaviour and carries no span obligation — 48 of 58 entries abstain on the
|
| 187 |
+
reference document, and treating that as a violation would make the whole
|
| 188 |
+
report noise."""
|
| 189 |
+
if kind == "glossary":
|
| 190 |
+
return bool(entry.get("definition"))
|
| 191 |
+
if kind == "rule":
|
| 192 |
+
return bool(entry.get("statement"))
|
| 193 |
+
if kind == "formula":
|
| 194 |
+
return bool(entry.get("formula_latex"))
|
| 195 |
+
return bool(entry.get("purpose_verbatim"))
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _find_entry(by_kind: dict[str, list[dict]], label: str) -> dict | None:
|
| 199 |
+
for entries in by_kind.values():
|
| 200 |
+
for entry in entries:
|
| 201 |
+
if label in (entry.get("term"), entry.get("name")):
|
| 202 |
+
return entry
|
| 203 |
+
return None
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"run": {
|
| 3 |
+
"created_at": "2026-09-10_151557",
|
| 4 |
+
"doc_id": "STD_2026_006_MNO",
|
| 5 |
+
"artifact": "eval\\knowledge\\fixtures\\parsed_STD_2026_006_MNO.json",
|
| 6 |
+
"content_hash": "f10807178b20531e86237a2640ff4125d259aa68e5e9d9f910c0c2365cd92833",
|
| 7 |
+
"parser": "mineru/3.4.4/hybrid",
|
| 8 |
+
"parser_backend": "hybrid",
|
| 9 |
+
"extracted": false,
|
| 10 |
+
"mock": false,
|
| 11 |
+
"limit": null,
|
| 12 |
+
"seconds": 68.7,
|
| 13 |
+
"note": "Free stages only. E3 and rule recall require --extract, which spends."
|
| 14 |
+
},
|
| 15 |
+
"funnel": {
|
| 16 |
+
"pages": 9,
|
| 17 |
+
"chunks": 31,
|
| 18 |
+
"mentions_raw": 186,
|
| 19 |
+
"clusters": 74,
|
| 20 |
+
"compression_ratio": 2.176,
|
| 21 |
+
"abbrev_pairs": 5,
|
| 22 |
+
"rule_candidates": 16
|
| 23 |
+
},
|
| 24 |
+
"scores": {
|
| 25 |
+
"E1": {
|
| 26 |
+
"label": "term-filter recall",
|
| 27 |
+
"value": 0.8293,
|
| 28 |
+
"detail": {
|
| 29 |
+
"label": "term_filter_recall(E1)",
|
| 30 |
+
"n_gold": 41,
|
| 31 |
+
"n_pred": 84,
|
| 32 |
+
"true_positives": 34,
|
| 33 |
+
"precision": 0.4048,
|
| 34 |
+
"recall": 0.8293,
|
| 35 |
+
"f1": 0.544,
|
| 36 |
+
"misses": [
|
| 37 |
+
"Breakdown",
|
| 38 |
+
"Overburden removal",
|
| 39 |
+
"Grouping (Composite)",
|
| 40 |
+
"Weight average",
|
| 41 |
+
"Uncontrollable",
|
| 42 |
+
"Fleet management",
|
| 43 |
+
"Mineplan"
|
| 44 |
+
]
|
| 45 |
+
}
|
| 46 |
+
},
|
| 47 |
+
"E1c_loose": {
|
| 48 |
+
"label": "post-cluster recall (loose)",
|
| 49 |
+
"value": 0.8293,
|
| 50 |
+
"detail": {
|
| 51 |
+
"label": "post_cluster_recall(E1c_loose)",
|
| 52 |
+
"n_gold": 41,
|
| 53 |
+
"n_pred": 74,
|
| 54 |
+
"true_positives": 34,
|
| 55 |
+
"precision": 0.4595,
|
| 56 |
+
"recall": 0.8293,
|
| 57 |
+
"f1": 0.5913,
|
| 58 |
+
"misses": [
|
| 59 |
+
"Breakdown",
|
| 60 |
+
"Overburden removal",
|
| 61 |
+
"Grouping (Composite)",
|
| 62 |
+
"Weight average",
|
| 63 |
+
"Uncontrollable",
|
| 64 |
+
"Fleet management",
|
| 65 |
+
"Mineplan"
|
| 66 |
+
]
|
| 67 |
+
}
|
| 68 |
+
},
|
| 69 |
+
"E1c_strict": {
|
| 70 |
+
"label": "post-cluster recall (owns its own cluster)",
|
| 71 |
+
"value": 0.7805,
|
| 72 |
+
"detail": {
|
| 73 |
+
"label": "post_cluster_recall(E1c_strict)",
|
| 74 |
+
"n_gold": 41,
|
| 75 |
+
"n_pred": 74,
|
| 76 |
+
"true_positives": 32,
|
| 77 |
+
"precision": 0.4324,
|
| 78 |
+
"recall": 0.7805,
|
| 79 |
+
"f1": 0.5565,
|
| 80 |
+
"misses": [
|
| 81 |
+
"Breakdown",
|
| 82 |
+
"Overburden removal",
|
| 83 |
+
"Grouping (Composite)",
|
| 84 |
+
"Joint survey",
|
| 85 |
+
"Weight average",
|
| 86 |
+
"Plan",
|
| 87 |
+
"Uncontrollable",
|
| 88 |
+
"Fleet management",
|
| 89 |
+
"Mineplan"
|
| 90 |
+
]
|
| 91 |
+
}
|
| 92 |
+
},
|
| 93 |
+
"E2": {
|
| 94 |
+
"label": "review-burden compression (descriptive, NOT gated)",
|
| 95 |
+
"value": 2.176,
|
| 96 |
+
"detail": {
|
| 97 |
+
"mentions": 161,
|
| 98 |
+
"clusters": 74
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
},
|
| 102 |
+
"baseline": {
|
| 103 |
+
"file": "v2_full_2026-09-02_160326.json",
|
| 104 |
+
"E1": 0.878,
|
| 105 |
+
"E2": 2.697,
|
| 106 |
+
"E3": 1.0,
|
| 107 |
+
"rule_recall": 0.5
|
| 108 |
+
}
|
| 109 |
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"run": {
|
| 3 |
+
"created_at": "2026-09-11_092207",
|
| 4 |
+
"doc_id": "STD_2026_006_MNO",
|
| 5 |
+
"artifact": "eval\\knowledge\\fixtures\\parsed_STD_2026_006_MNO.json",
|
| 6 |
+
"content_hash": "f10807178b20531e86237a2640ff4125d259aa68e5e9d9f910c0c2365cd92833",
|
| 7 |
+
"parser": "mineru/3.4.4/hybrid",
|
| 8 |
+
"parser_backend": "hybrid",
|
| 9 |
+
"extracted": false,
|
| 10 |
+
"mock": false,
|
| 11 |
+
"limit": null,
|
| 12 |
+
"runs": 1,
|
| 13 |
+
"expected_model": "gpt-5.4-nano-2026-03-17",
|
| 14 |
+
"seconds": 87.1,
|
| 15 |
+
"note": "Free stages only. E3 and rule recall require --extract, which spends."
|
| 16 |
+
},
|
| 17 |
+
"funnel": {
|
| 18 |
+
"pages": 9,
|
| 19 |
+
"chunks": 31,
|
| 20 |
+
"mentions_raw": 208,
|
| 21 |
+
"clusters": 80,
|
| 22 |
+
"compression_ratio": 2.125,
|
| 23 |
+
"abbrev_pairs": 5,
|
| 24 |
+
"rule_candidates": 16
|
| 25 |
+
},
|
| 26 |
+
"genre": {
|
| 27 |
+
"genre": "standard",
|
| 28 |
+
"labels_variant": "broad",
|
| 29 |
+
"branches": [
|
| 30 |
+
"glossary",
|
| 31 |
+
"rule",
|
| 32 |
+
"formula",
|
| 33 |
+
"summary"
|
| 34 |
+
],
|
| 35 |
+
"well_served": true,
|
| 36 |
+
"routing_applied": {
|
| 37 |
+
"labels": false,
|
| 38 |
+
"branches": false
|
| 39 |
+
},
|
| 40 |
+
"signals": {
|
| 41 |
+
"n_chunks": 31,
|
| 42 |
+
"heading_coverage": 0.355,
|
| 43 |
+
"tabular_ratio": 0.452,
|
| 44 |
+
"abbrev_pairs": 5,
|
| 45 |
+
"rule_density": 0.516
|
| 46 |
+
},
|
| 47 |
+
"reasons": [
|
| 48 |
+
"5 abbreviation definitions in headed prose — a document that names its own terms"
|
| 49 |
+
],
|
| 50 |
+
"warnings": []
|
| 51 |
+
},
|
| 52 |
+
"scores": {
|
| 53 |
+
"E1": {
|
| 54 |
+
"label": "term-filter recall",
|
| 55 |
+
"value": 0.8293,
|
| 56 |
+
"detail": {
|
| 57 |
+
"label": "term_filter_recall(E1)",
|
| 58 |
+
"n_gold": 41,
|
| 59 |
+
"n_pred": 92,
|
| 60 |
+
"true_positives": 34,
|
| 61 |
+
"precision": 0.3696,
|
| 62 |
+
"recall": 0.8293,
|
| 63 |
+
"f1": 0.5113,
|
| 64 |
+
"misses": [
|
| 65 |
+
"Breakdown",
|
| 66 |
+
"Overburden removal",
|
| 67 |
+
"Grouping (Composite)",
|
| 68 |
+
"Weight average",
|
| 69 |
+
"Uncontrollable",
|
| 70 |
+
"Fleet management",
|
| 71 |
+
"Mineplan"
|
| 72 |
+
]
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
"E1c_loose": {
|
| 76 |
+
"label": "post-cluster recall (loose)",
|
| 77 |
+
"value": 0.8293,
|
| 78 |
+
"detail": {
|
| 79 |
+
"label": "post_cluster_recall(E1c_loose)",
|
| 80 |
+
"n_gold": 41,
|
| 81 |
+
"n_pred": 80,
|
| 82 |
+
"true_positives": 34,
|
| 83 |
+
"precision": 0.425,
|
| 84 |
+
"recall": 0.8293,
|
| 85 |
+
"f1": 0.562,
|
| 86 |
+
"misses": [
|
| 87 |
+
"Breakdown",
|
| 88 |
+
"Overburden removal",
|
| 89 |
+
"Grouping (Composite)",
|
| 90 |
+
"Weight average",
|
| 91 |
+
"Uncontrollable",
|
| 92 |
+
"Fleet management",
|
| 93 |
+
"Mineplan"
|
| 94 |
+
]
|
| 95 |
+
}
|
| 96 |
+
},
|
| 97 |
+
"E1c_strict": {
|
| 98 |
+
"label": "post-cluster recall (owns its own cluster)",
|
| 99 |
+
"value": 0.7805,
|
| 100 |
+
"detail": {
|
| 101 |
+
"label": "post_cluster_recall(E1c_strict)",
|
| 102 |
+
"n_gold": 41,
|
| 103 |
+
"n_pred": 80,
|
| 104 |
+
"true_positives": 32,
|
| 105 |
+
"precision": 0.4,
|
| 106 |
+
"recall": 0.7805,
|
| 107 |
+
"f1": 0.5289,
|
| 108 |
+
"misses": [
|
| 109 |
+
"Breakdown",
|
| 110 |
+
"Overburden removal",
|
| 111 |
+
"Grouping (Composite)",
|
| 112 |
+
"Joint survey",
|
| 113 |
+
"Weight average",
|
| 114 |
+
"Plan",
|
| 115 |
+
"Uncontrollable",
|
| 116 |
+
"Fleet management",
|
| 117 |
+
"Mineplan"
|
| 118 |
+
]
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
"E2": {
|
| 122 |
+
"label": "review-burden compression (descriptive, NOT gated)",
|
| 123 |
+
"value": 2.125,
|
| 124 |
+
"detail": {
|
| 125 |
+
"mentions": 170,
|
| 126 |
+
"clusters": 80
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
},
|
| 130 |
+
"baseline": {
|
| 131 |
+
"file": "v2_full_2026-09-02_160326.json",
|
| 132 |
+
"E1": 0.878,
|
| 133 |
+
"E2": 2.697,
|
| 134 |
+
"E3": 1.0,
|
| 135 |
+
"rule_recall": 0.5
|
| 136 |
+
}
|
| 137 |
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"run": {
|
| 3 |
+
"created_at": "2026-09-11_100546",
|
| 4 |
+
"doc_id": "STD_2026_006_MNO",
|
| 5 |
+
"artifact": "eval\\knowledge\\fixtures\\parsed_mistral_STD_2026_006_MNO.json",
|
| 6 |
+
"content_hash": "0a95466ea17e67bb0a1a83ca4b7022974cf1171b3450cf2ca92cb8903277920d",
|
| 7 |
+
"parser": "mistral/mistral-ocr-latest/mistral",
|
| 8 |
+
"parser_backend": "mistral",
|
| 9 |
+
"extracted": false,
|
| 10 |
+
"mock": false,
|
| 11 |
+
"limit": null,
|
| 12 |
+
"runs": 1,
|
| 13 |
+
"expected_model": "gpt-5.4-nano-2026-03-17",
|
| 14 |
+
"seconds": 69.7,
|
| 15 |
+
"label": "linked_partner",
|
| 16 |
+
"note": "Free stages only. E3 and rule recall require --extract, which spends."
|
| 17 |
+
},
|
| 18 |
+
"funnel": {
|
| 19 |
+
"pages": 9,
|
| 20 |
+
"chunks": 32,
|
| 21 |
+
"mentions_raw": 249,
|
| 22 |
+
"clusters": 84,
|
| 23 |
+
"compression_ratio": 2.298,
|
| 24 |
+
"abbrev_pairs": 5,
|
| 25 |
+
"rule_candidates": 16
|
| 26 |
+
},
|
| 27 |
+
"genre": {
|
| 28 |
+
"genre": "standard",
|
| 29 |
+
"labels_variant": "broad",
|
| 30 |
+
"branches": [
|
| 31 |
+
"glossary",
|
| 32 |
+
"rule",
|
| 33 |
+
"formula",
|
| 34 |
+
"summary"
|
| 35 |
+
],
|
| 36 |
+
"well_served": true,
|
| 37 |
+
"routing_applied": {
|
| 38 |
+
"labels": false,
|
| 39 |
+
"branches": false
|
| 40 |
+
},
|
| 41 |
+
"signals": {
|
| 42 |
+
"n_chunks": 32,
|
| 43 |
+
"heading_coverage": 0.406,
|
| 44 |
+
"tabular_ratio": 0.25,
|
| 45 |
+
"abbrev_pairs": 5,
|
| 46 |
+
"rule_density": 0.5
|
| 47 |
+
},
|
| 48 |
+
"reasons": [
|
| 49 |
+
"5 abbreviation definitions in headed prose — a document that names its own terms"
|
| 50 |
+
],
|
| 51 |
+
"warnings": []
|
| 52 |
+
},
|
| 53 |
+
"scores": {
|
| 54 |
+
"E1": {
|
| 55 |
+
"label": "term-filter recall",
|
| 56 |
+
"value": 0.878,
|
| 57 |
+
"detail": {
|
| 58 |
+
"label": "term_filter_recall(E1)",
|
| 59 |
+
"n_gold": 41,
|
| 60 |
+
"n_pred": 98,
|
| 61 |
+
"true_positives": 36,
|
| 62 |
+
"precision": 0.3673,
|
| 63 |
+
"recall": 0.878,
|
| 64 |
+
"f1": 0.518,
|
| 65 |
+
"misses": [
|
| 66 |
+
"Breakdown",
|
| 67 |
+
"Overburden removal",
|
| 68 |
+
"Weight average",
|
| 69 |
+
"Fleet management",
|
| 70 |
+
"Mineplan"
|
| 71 |
+
]
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
"E1c_loose": {
|
| 75 |
+
"label": "post-cluster recall (loose)",
|
| 76 |
+
"value": 0.878,
|
| 77 |
+
"detail": {
|
| 78 |
+
"label": "post_cluster_recall(E1c_loose)",
|
| 79 |
+
"n_gold": 41,
|
| 80 |
+
"n_pred": 84,
|
| 81 |
+
"true_positives": 36,
|
| 82 |
+
"precision": 0.4286,
|
| 83 |
+
"recall": 0.878,
|
| 84 |
+
"f1": 0.576,
|
| 85 |
+
"misses": [
|
| 86 |
+
"Breakdown",
|
| 87 |
+
"Overburden removal",
|
| 88 |
+
"Weight average",
|
| 89 |
+
"Fleet management",
|
| 90 |
+
"Mineplan"
|
| 91 |
+
]
|
| 92 |
+
}
|
| 93 |
+
},
|
| 94 |
+
"E1c_strict": {
|
| 95 |
+
"label": "post-cluster recall (owns its own cluster)",
|
| 96 |
+
"value": 0.8537,
|
| 97 |
+
"detail": {
|
| 98 |
+
"label": "post_cluster_recall(E1c_strict)",
|
| 99 |
+
"n_gold": 41,
|
| 100 |
+
"n_pred": 84,
|
| 101 |
+
"true_positives": 35,
|
| 102 |
+
"precision": 0.4167,
|
| 103 |
+
"recall": 0.8537,
|
| 104 |
+
"f1": 0.56,
|
| 105 |
+
"misses": [
|
| 106 |
+
"Breakdown",
|
| 107 |
+
"Overburden removal",
|
| 108 |
+
"Joint survey",
|
| 109 |
+
"Weight average",
|
| 110 |
+
"Fleet management",
|
| 111 |
+
"Mineplan"
|
| 112 |
+
]
|
| 113 |
+
}
|
| 114 |
+
},
|
| 115 |
+
"E2": {
|
| 116 |
+
"label": "review-burden compression (descriptive, NOT gated)",
|
| 117 |
+
"value": 2.298,
|
| 118 |
+
"detail": {
|
| 119 |
+
"mentions": 193,
|
| 120 |
+
"clusters": 84
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
},
|
| 124 |
+
"baseline": {
|
| 125 |
+
"file": "v2_full_2026-09-02_160326.json",
|
| 126 |
+
"E1": 0.878,
|
| 127 |
+
"E2": 2.697,
|
| 128 |
+
"E3": 1.0,
|
| 129 |
+
"rule_recall": 0.5
|
| 130 |
+
}
|
| 131 |
+
}
|
|
@@ -20,7 +20,14 @@ written by `python -m src.knowledge_parsing.run`.
|
|
| 20 |
Module mode only (`-m`). A file-path invocation breaks `sys.path` and the
|
| 21 |
`src.` imports below fail.
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
be compared is worse than no number:
|
| 25 |
|
| 26 |
* **It will not run on an artifact that does not declare `parser_backend`.**
|
|
@@ -30,12 +37,20 @@ be compared is worse than no number:
|
|
| 30 |
overrides, and stamps the result file so the caveat travels with it.
|
| 31 |
* **It never overwrites a result file.** Every run writes a new timestamped
|
| 32 |
file beside the others; the frozen baseline stays frozen.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
"""
|
| 34 |
|
| 35 |
from __future__ import annotations
|
| 36 |
|
| 37 |
import argparse
|
| 38 |
import json
|
|
|
|
| 39 |
import sys
|
| 40 |
from collections import Counter
|
| 41 |
from datetime import datetime
|
|
@@ -44,6 +59,7 @@ from typing import Any
|
|
| 44 |
|
| 45 |
from src.knowledge_extraction.adapter import parsed_doc_from_artifact
|
| 46 |
from src.knowledge_extraction.extract import MockExtractor, cacheable, prefix_tokens
|
|
|
|
| 47 |
from src.knowledge_extraction.service import (
|
| 48 |
build_clusters,
|
| 49 |
estimate_cost,
|
|
@@ -52,14 +68,34 @@ from src.knowledge_extraction.service import (
|
|
| 52 |
)
|
| 53 |
from src.knowledge_extraction.settings import EVIDENCE_K
|
| 54 |
|
| 55 |
-
from .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
_HERE = Path(__file__).resolve().parent
|
| 58 |
RESULTS_DIR = _HERE / "results"
|
| 59 |
|
| 60 |
# The kill lines. Each is a decision already taken, recorded in
|
| 61 |
# KNOWLEDGE_PIPELINE_CALIBRATION.md — not a target invented per run.
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
# What the last committed run measured, for the delta column. Rule recall has
|
| 65 |
# no kill line: it is tracked, not gated, and 3/15 is the post-X22 scoring of
|
|
@@ -80,6 +116,29 @@ BASELINE = {
|
|
| 80 |
"rule_recall": 0.5,
|
| 81 |
}
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
def _azure_extractor():
|
| 85 |
"""Built lazily: importing it requires credentials to be configured."""
|
|
@@ -117,6 +176,60 @@ def _delta(value: float, previous: float | None) -> str:
|
|
| 117 |
return f"{diff:+.4f}"
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
def build_report(
|
| 121 |
doc,
|
| 122 |
filtered,
|
|
@@ -126,6 +239,8 @@ def build_report(
|
|
| 126 |
args,
|
| 127 |
started: datetime,
|
| 128 |
seconds: float,
|
|
|
|
|
|
|
| 129 |
) -> dict:
|
| 130 |
funnel = {
|
| 131 |
"pages": doc.n_pages,
|
|
@@ -147,12 +262,19 @@ def build_report(
|
|
| 147 |
"extracted": bool(args.extract),
|
| 148 |
"mock": bool(args.mock),
|
| 149 |
"limit": args.limit,
|
|
|
|
|
|
|
| 150 |
"seconds": round(seconds, 1),
|
| 151 |
},
|
| 152 |
"funnel": funnel,
|
|
|
|
| 153 |
"scores": scores,
|
| 154 |
"baseline": BASELINE,
|
| 155 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
if args.allow_unlabelled_artifact and not doc.parser_backend:
|
| 158 |
report["run"]["comparability"] = (
|
|
@@ -170,12 +292,25 @@ def build_report(
|
|
| 170 |
# measurement of an unknown: on 2026-08-27 identical input scored 6/7
|
| 171 |
# one day and 0/12 the next under an unchanged deployment name, and
|
| 172 |
# nothing recorded said whether the model had moved. See X25.
|
|
|
|
| 173 |
report["run"]["served_by"] = {
|
| 174 |
-
"model_version":
|
| 175 |
"system_fingerprint": sorted(
|
| 176 |
{u.system_fingerprint for u in result.usages if u.system_fingerprint}
|
| 177 |
),
|
| 178 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
prompt, cached, completion = result.total_tokens
|
| 180 |
report["cost"] = {
|
| 181 |
"prompt_tokens": prompt,
|
|
@@ -204,10 +339,17 @@ def format_summary(report: dict) -> str:
|
|
| 204 |
for key, score in report["scores"].items():
|
| 205 |
value = score["value"]
|
| 206 |
kill = KILL_LINES.get(key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
rows.append(
|
| 208 |
f" {key:<12} {value:>8.4f} kill {kill if kill else ' — '!s:>5} "
|
| 209 |
f"{_verdict(value, kill):<5} vs baseline {_delta(value, BASELINE.get(key))}"
|
| 210 |
-
f" {score['label']}"
|
| 211 |
)
|
| 212 |
funnel = report["funnel"]
|
| 213 |
out = [
|
|
@@ -229,6 +371,39 @@ def format_summary(report: dict) -> str:
|
|
| 229 |
" by the span check",
|
| 230 |
f" latex_verification: {report['quality']['latex_verification']}",
|
| 231 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
if "comparability" in report["run"]:
|
| 233 |
out += ["", f" !! {report['run']['comparability']}"]
|
| 234 |
return "\n".join(out)
|
|
@@ -258,8 +433,39 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 258 |
action="store_true",
|
| 259 |
help="run on an artifact with no parser_backend (results NOT comparable)",
|
| 260 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
args = parser.parse_args(argv)
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
if not args.artifact.exists():
|
| 264 |
print(f"artifact not found: {args.artifact}", file=sys.stderr)
|
| 265 |
return 2
|
|
@@ -297,14 +503,30 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 297 |
return 0
|
| 298 |
|
| 299 |
e1 = score_term_filter(gold, [m.surface for m in filtered.mentions])
|
|
|
|
|
|
|
|
|
|
| 300 |
scores: dict[str, Any] = {
|
| 301 |
"E1": {
|
| 302 |
"label": "term-filter recall",
|
| 303 |
"value": e1.recall,
|
| 304 |
"detail": e1.as_dict(),
|
| 305 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
"E2": {
|
| 307 |
-
"label": "review-burden compression",
|
| 308 |
"value": clustered.compression_ratio,
|
| 309 |
"detail": {
|
| 310 |
"mentions": clustered.n_mentions,
|
|
@@ -314,6 +536,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 314 |
}
|
| 315 |
|
| 316 |
result = None
|
|
|
|
|
|
|
| 317 |
if args.extract:
|
| 318 |
extractor = MockExtractor() if args.mock else _azure_extractor()
|
| 319 |
if extractor is None:
|
|
@@ -323,24 +547,58 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 323 |
if args.active_glossary
|
| 324 |
else None
|
| 325 |
)
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
scores["E3"] = {
|
| 332 |
"label": "glossary schema fill (precision)",
|
| 333 |
-
"value":
|
| 334 |
"detail": e3.as_dict(),
|
| 335 |
}
|
| 336 |
scores["rule_recall"] = {
|
| 337 |
"label": "rule-branch recall",
|
| 338 |
-
"value":
|
| 339 |
"detail": rules.as_dict(),
|
| 340 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
|
| 342 |
seconds = (datetime.now() - started).total_seconds()
|
| 343 |
-
report = build_report(
|
|
|
|
|
|
|
|
|
|
| 344 |
if args.label:
|
| 345 |
report["run"]["label"] = args.label
|
| 346 |
if not args.extract:
|
|
@@ -361,6 +619,25 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 361 |
)
|
| 362 |
print(f"\n-> saved: {out_path.relative_to(_HERE.parent.parent)}")
|
| 363 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
if EVIDENCE_K and args.extract and not args.mock:
|
| 365 |
failed = [
|
| 366 |
k for k, s in scores.items() if k in KILL_LINES and s["value"] < KILL_LINES[k]
|
|
|
|
| 20 |
Module mode only (`-m`). A file-path invocation breaks `sys.path` and the
|
| 21 |
`src.` imports below fail.
|
| 22 |
|
| 23 |
+
# a BAND instead of a number — repeats the paid stage only. SPENDS N TIMES.
|
| 24 |
+
./.venv/Scripts/python.exe -m eval.knowledge.run_eval <artifact.json> --extract --runs 5
|
| 25 |
+
|
| 26 |
+
Exit codes: `0` clean · `1` below a kill line · `2` bad arguments or artifact ·
|
| 27 |
+
`3` no extractor · `4` would overwrite a result · `5` an invariant broke ·
|
| 28 |
+
`6` a different model snapshot served the run.
|
| 29 |
+
|
| 30 |
+
Four things this refuses to do, all for the same reason — a number that cannot
|
| 31 |
be compared is worse than no number:
|
| 32 |
|
| 33 |
* **It will not run on an artifact that does not declare `parser_backend`.**
|
|
|
|
| 37 |
overrides, and stamps the result file so the caveat travels with it.
|
| 38 |
* **It never overwrites a result file.** Every run writes a new timestamped
|
| 39 |
file beside the others; the frozen baseline stays frozen.
|
| 40 |
+
* **It refuses a run served by a different model snapshot** than the committed
|
| 41 |
+
results were measured on (`EXPECTED_MODEL_SNAPSHOT`). X25 recorded what served
|
| 42 |
+
a run; this asserts it, so a silent tier move stops the comparison instead of
|
| 43 |
+
quietly poisoning the delta column.
|
| 44 |
+
* **It refuses a run that broke a structural invariant** (`invariants.py`) —
|
| 45 |
+
a span that is not locatable, a duplicate entity id, a repaired rejection.
|
| 46 |
+
Those are defects rather than scores, so they fail the run outright.
|
| 47 |
"""
|
| 48 |
|
| 49 |
from __future__ import annotations
|
| 50 |
|
| 51 |
import argparse
|
| 52 |
import json
|
| 53 |
+
import statistics
|
| 54 |
import sys
|
| 55 |
from collections import Counter
|
| 56 |
from datetime import datetime
|
|
|
|
| 59 |
|
| 60 |
from src.knowledge_extraction.adapter import parsed_doc_from_artifact
|
| 61 |
from src.knowledge_extraction.extract import MockExtractor, cacheable, prefix_tokens
|
| 62 |
+
from src.knowledge_extraction.genre import classify as classify_genre
|
| 63 |
from src.knowledge_extraction.service import (
|
| 64 |
build_clusters,
|
| 65 |
estimate_cost,
|
|
|
|
| 68 |
)
|
| 69 |
from src.knowledge_extraction.settings import EVIDENCE_K
|
| 70 |
|
| 71 |
+
from .invariants import check_invariants
|
| 72 |
+
from .score import (
|
| 73 |
+
GOLD_PATH,
|
| 74 |
+
load_gold,
|
| 75 |
+
score_glossary,
|
| 76 |
+
score_post_cluster,
|
| 77 |
+
score_rules,
|
| 78 |
+
score_term_filter,
|
| 79 |
+
)
|
| 80 |
|
| 81 |
_HERE = Path(__file__).resolve().parent
|
| 82 |
RESULTS_DIR = _HERE / "results"
|
| 83 |
|
| 84 |
# The kill lines. Each is a decision already taken, recorded in
|
| 85 |
# KNOWLEDGE_PIPELINE_CALIBRATION.md — not a target invented per run.
|
| 86 |
+
# E2's kill line was REMOVED 2026-09-10. Compression is now reported as a
|
| 87 |
+
# descriptive statistic only, never as pass/fail.
|
| 88 |
+
#
|
| 89 |
+
# The reason is not that 2.0 was the wrong number — it is that the metric moves
|
| 90 |
+
# the WRONG WAY. Over-merging distinct terms into one cluster RAISES compression,
|
| 91 |
+
# so the defect that cost the gold term EWH its own entry was pushing this score
|
| 92 |
+
# up and through its own pass line. A metric whose pass condition is partly met
|
| 93 |
+
# by the defect it should expose is worse than no metric. Retiring the fuzzy pass
|
| 94 |
+
# correctly LOWERED compression (BUMA 2.712 -> 2.183), which under a kill line
|
| 95 |
+
# would have read as a regression.
|
| 96 |
+
#
|
| 97 |
+
# E1c (post-cluster recall) is the metric that actually watches this stage.
|
| 98 |
+
KILL_LINES = {"E1": 0.70, "E3": 0.80}
|
| 99 |
|
| 100 |
# What the last committed run measured, for the delta column. Rule recall has
|
| 101 |
# no kill line: it is tracked, not gated, and 3/15 is the post-X22 scoring of
|
|
|
|
| 116 |
"rule_recall": 0.5,
|
| 117 |
}
|
| 118 |
|
| 119 |
+
# ── Step 0: measurement hardening ───────────────────────────────────────
|
| 120 |
+
# The snapshot every committed result on this branch was measured against. X25
|
| 121 |
+
# started RECORDING what served a run; this makes it an ASSERTION, which is the
|
| 122 |
+
# difference between a fact discoverable by diffing two files afterwards and one
|
| 123 |
+
# that stops a run at the moment it stops being true.
|
| 124 |
+
#
|
| 125 |
+
# A deployment name is OUR label and never changes; the snapshot behind it can
|
| 126 |
+
# move without notice. When it does, every number in `results/` becomes a
|
| 127 |
+
# measurement of a different system and the delta column starts lying. Comparing
|
| 128 |
+
# against a moved model is the failure this catches — not the move itself, which
|
| 129 |
+
# is Azure's business.
|
| 130 |
+
#
|
| 131 |
+
# `--expect-model` overrides for a deliberate tier change; `--allow-model-drift`
|
| 132 |
+
# downgrades the refusal to a stamped warning when you need the number anyway.
|
| 133 |
+
EXPECTED_MODEL_SNAPSHOT = "gpt-5.4-nano-2026-03-17"
|
| 134 |
+
|
| 135 |
+
# Below this spread, two runs of one document are telling the same story. Above
|
| 136 |
+
# it, a single run is not evidence and the band must be quoted instead of the
|
| 137 |
+
# point. 0.10 is the handoff's own read of this corpus (identical input scored
|
| 138 |
+
# 6/7 one day and 0/12 the next), not a measured tolerance — raise or lower it
|
| 139 |
+
# once `--runs` has produced a real band on a real document.
|
| 140 |
+
NOISE_BAND = 0.10
|
| 141 |
+
|
| 142 |
|
| 143 |
def _azure_extractor():
|
| 144 |
"""Built lazily: importing it requires credentials to be configured."""
|
|
|
|
| 176 |
return f"{diff:+.4f}"
|
| 177 |
|
| 178 |
|
| 179 |
+
def _stability(values: list[float]) -> dict[str, Any]:
|
| 180 |
+
"""A band, not a number.
|
| 181 |
+
|
| 182 |
+
The reported value becomes the MEDIAN rather than the mean: one run that
|
| 183 |
+
fails schema validation drags a mean into a region no run actually
|
| 184 |
+
occupied, and a metric nobody measured is worse than a noisy one.
|
| 185 |
+
"""
|
| 186 |
+
ordered = sorted(values)
|
| 187 |
+
return {
|
| 188 |
+
"n": len(ordered),
|
| 189 |
+
"min": round(ordered[0], 4),
|
| 190 |
+
"median": round(statistics.median(ordered), 4),
|
| 191 |
+
"max": round(ordered[-1], 4),
|
| 192 |
+
"spread": round(ordered[-1] - ordered[0], 4),
|
| 193 |
+
"runs": [round(v, 4) for v in values],
|
| 194 |
+
"noisy": bool(ordered[-1] - ordered[0] > NOISE_BAND),
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _entity_ids(result) -> dict[str, list[str]]:
|
| 199 |
+
"""Every id a run minted, by kind — the thing an expert's approval hangs on.
|
| 200 |
+
|
| 201 |
+
K11 fixed two ids that did not survive a re-run. Nothing has watched them
|
| 202 |
+
since, and a re-run that re-keys its entries silently orphans every approval
|
| 203 |
+
recorded against the old ones. Cheap to check, catastrophic to miss.
|
| 204 |
+
"""
|
| 205 |
+
return {
|
| 206 |
+
"glossary": sorted(e.get("term_id") or "" for e in result.glossary),
|
| 207 |
+
"rule": sorted(e.get("rule_id") or "" for e in result.rules),
|
| 208 |
+
"formula": sorted(e.get("formula_id") or "" for e in result.formulas),
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _id_stability(per_run: list[dict[str, list[str]]]) -> dict[str, Any]:
|
| 213 |
+
"""Did every run mint the same ids? Anything but `True` is a defect."""
|
| 214 |
+
if len(per_run) < 2:
|
| 215 |
+
return {"comparable": False, "note": "needs --runs 2 or more"}
|
| 216 |
+
first = per_run[0]
|
| 217 |
+
drift = {
|
| 218 |
+
kind: {
|
| 219 |
+
"only_in_first": sorted(set(first[kind]) - set(other[kind]))[:10],
|
| 220 |
+
"only_in_later": sorted(set(other[kind]) - set(first[kind]))[:10],
|
| 221 |
+
}
|
| 222 |
+
for other in per_run[1:]
|
| 223 |
+
for kind in first
|
| 224 |
+
if set(first[kind]) != set(other[kind])
|
| 225 |
+
}
|
| 226 |
+
return {"comparable": True, "identical_across_runs": not drift, "drift": drift}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _served_snapshots(result) -> list[str]:
|
| 230 |
+
return sorted({u.model_version for u in result.usages if u.model_version})
|
| 231 |
+
|
| 232 |
+
|
| 233 |
def build_report(
|
| 234 |
doc,
|
| 235 |
filtered,
|
|
|
|
| 239 |
args,
|
| 240 |
started: datetime,
|
| 241 |
seconds: float,
|
| 242 |
+
invariants=None,
|
| 243 |
+
stability: dict[str, Any] | None = None,
|
| 244 |
) -> dict:
|
| 245 |
funnel = {
|
| 246 |
"pages": doc.n_pages,
|
|
|
|
| 262 |
"extracted": bool(args.extract),
|
| 263 |
"mock": bool(args.mock),
|
| 264 |
"limit": args.limit,
|
| 265 |
+
"runs": args.runs,
|
| 266 |
+
"expected_model": args.expect_model,
|
| 267 |
"seconds": round(seconds, 1),
|
| 268 |
},
|
| 269 |
"funnel": funnel,
|
| 270 |
+
"genre": classify_genre(doc, filtered).as_dict(),
|
| 271 |
"scores": scores,
|
| 272 |
"baseline": BASELINE,
|
| 273 |
}
|
| 274 |
+
if invariants is not None:
|
| 275 |
+
report["invariants"] = invariants.as_dict()
|
| 276 |
+
if stability:
|
| 277 |
+
report["stability"] = stability
|
| 278 |
|
| 279 |
if args.allow_unlabelled_artifact and not doc.parser_backend:
|
| 280 |
report["run"]["comparability"] = (
|
|
|
|
| 292 |
# measurement of an unknown: on 2026-08-27 identical input scored 6/7
|
| 293 |
# one day and 0/12 the next under an unchanged deployment name, and
|
| 294 |
# nothing recorded said whether the model had moved. See X25.
|
| 295 |
+
served = _served_snapshots(result)
|
| 296 |
report["run"]["served_by"] = {
|
| 297 |
+
"model_version": served,
|
| 298 |
"system_fingerprint": sorted(
|
| 299 |
{u.system_fingerprint for u in result.usages if u.system_fingerprint}
|
| 300 |
),
|
| 301 |
}
|
| 302 |
+
# Step 0(a): the recorded snapshot becomes an assertion. A mock run
|
| 303 |
+
# reports no snapshot and is exempt — it measures plumbing, not a model.
|
| 304 |
+
if not args.mock and served and served != [args.expect_model]:
|
| 305 |
+
report["run"]["model_drift"] = {
|
| 306 |
+
"expected": args.expect_model,
|
| 307 |
+
"served": served,
|
| 308 |
+
"forced": bool(args.allow_model_drift),
|
| 309 |
+
"note": (
|
| 310 |
+
"every committed result was measured on the expected snapshot; "
|
| 311 |
+
"a delta against this run compares two different systems"
|
| 312 |
+
),
|
| 313 |
+
}
|
| 314 |
prompt, cached, completion = result.total_tokens
|
| 315 |
report["cost"] = {
|
| 316 |
"prompt_tokens": prompt,
|
|
|
|
| 339 |
for key, score in report["scores"].items():
|
| 340 |
value = score["value"]
|
| 341 |
kill = KILL_LINES.get(key)
|
| 342 |
+
band = score.get("stability")
|
| 343 |
+
suffix = ""
|
| 344 |
+
if band:
|
| 345 |
+
suffix = (
|
| 346 |
+
f" [{band['min']:.4f}..{band['max']:.4f}] n={band['n']}"
|
| 347 |
+
+ (" NOISY" if band["noisy"] else "")
|
| 348 |
+
)
|
| 349 |
rows.append(
|
| 350 |
f" {key:<12} {value:>8.4f} kill {kill if kill else ' — '!s:>5} "
|
| 351 |
f"{_verdict(value, kill):<5} vs baseline {_delta(value, BASELINE.get(key))}"
|
| 352 |
+
f" {score['label']}{suffix}"
|
| 353 |
)
|
| 354 |
funnel = report["funnel"]
|
| 355 |
out = [
|
|
|
|
| 371 |
" by the span check",
|
| 372 |
f" latex_verification: {report['quality']['latex_verification']}",
|
| 373 |
]
|
| 374 |
+
genre = report.get("genre")
|
| 375 |
+
if genre:
|
| 376 |
+
out += [
|
| 377 |
+
"",
|
| 378 |
+
f" genre: {genre['genre']} (labels {genre['labels_variant']}, "
|
| 379 |
+
f"well-served {genre['well_served']}) {genre['signals']}",
|
| 380 |
+
]
|
| 381 |
+
for reason in genre["reasons"]:
|
| 382 |
+
out += [f" · {reason}"]
|
| 383 |
+
for warning in genre["warnings"]:
|
| 384 |
+
out += [f" !! {warning}"]
|
| 385 |
+
if "invariants" in report:
|
| 386 |
+
inv = report["invariants"]
|
| 387 |
+
out += [
|
| 388 |
+
"",
|
| 389 |
+
f" invariants: {'OK' if inv['ok'] else str(inv['n_violations']) + ' VIOLATED'}"
|
| 390 |
+
f" checked {inv['checked']}",
|
| 391 |
+
]
|
| 392 |
+
for violation in inv["violations"][:10]:
|
| 393 |
+
out += [f" !! {violation['check']} [{violation['kind']}] {violation['detail']}"]
|
| 394 |
+
if "stability" in report:
|
| 395 |
+
ids = report["stability"]["entity_ids"]
|
| 396 |
+
if ids.get("comparable"):
|
| 397 |
+
out += [
|
| 398 |
+
f" entity ids identical across runs: {ids['identical_across_runs']}"
|
| 399 |
+
]
|
| 400 |
+
if "model_drift" in report["run"]:
|
| 401 |
+
drift = report["run"]["model_drift"]
|
| 402 |
+
out += [
|
| 403 |
+
"",
|
| 404 |
+
f" !! MODEL DRIFT — expected {drift['expected']}, served "
|
| 405 |
+
f"{', '.join(drift['served'])}",
|
| 406 |
+
]
|
| 407 |
if "comparability" in report["run"]:
|
| 408 |
out += ["", f" !! {report['run']['comparability']}"]
|
| 409 |
return "\n".join(out)
|
|
|
|
| 433 |
action="store_true",
|
| 434 |
help="run on an artifact with no parser_backend (results NOT comparable)",
|
| 435 |
)
|
| 436 |
+
parser.add_argument(
|
| 437 |
+
"--runs",
|
| 438 |
+
type=int,
|
| 439 |
+
default=1,
|
| 440 |
+
metavar="N",
|
| 441 |
+
help=(
|
| 442 |
+
"repeat the PAID stage N times and report min/median/max instead of a "
|
| 443 |
+
"single number. MULTIPLIES SPEND BY N — the free stages run once"
|
| 444 |
+
),
|
| 445 |
+
)
|
| 446 |
+
parser.add_argument(
|
| 447 |
+
"--expect-model",
|
| 448 |
+
default=EXPECTED_MODEL_SNAPSHOT,
|
| 449 |
+
help=f"model snapshot this run must be served by (default {EXPECTED_MODEL_SNAPSHOT})",
|
| 450 |
+
)
|
| 451 |
+
parser.add_argument(
|
| 452 |
+
"--allow-model-drift",
|
| 453 |
+
action="store_true",
|
| 454 |
+
help="record a snapshot mismatch as a warning instead of refusing the run",
|
| 455 |
+
)
|
| 456 |
args = parser.parse_args(argv)
|
| 457 |
|
| 458 |
+
if args.runs < 1:
|
| 459 |
+
print("--runs must be 1 or more", file=sys.stderr)
|
| 460 |
+
return 2
|
| 461 |
+
if args.runs > 1 and not args.extract:
|
| 462 |
+
print(
|
| 463 |
+
"--runs only varies the PAID stage; the free stages are deterministic.\n"
|
| 464 |
+
" Add --extract, or drop --runs.",
|
| 465 |
+
file=sys.stderr,
|
| 466 |
+
)
|
| 467 |
+
return 2
|
| 468 |
+
|
| 469 |
if not args.artifact.exists():
|
| 470 |
print(f"artifact not found: {args.artifact}", file=sys.stderr)
|
| 471 |
return 2
|
|
|
|
| 503 |
return 0
|
| 504 |
|
| 505 |
e1 = score_term_filter(gold, [m.surface for m in filtered.mentions])
|
| 506 |
+
canonicals = [c.canonical for c in clustered.clusters]
|
| 507 |
+
e1c_loose = score_post_cluster(gold, canonicals)
|
| 508 |
+
e1c_strict = score_post_cluster(gold, canonicals, cluster_variants=True)
|
| 509 |
scores: dict[str, Any] = {
|
| 510 |
"E1": {
|
| 511 |
"label": "term-filter recall",
|
| 512 |
"value": e1.recall,
|
| 513 |
"detail": e1.as_dict(),
|
| 514 |
},
|
| 515 |
+
# E1c watches the merge stage, which E1 (measured before clustering) and
|
| 516 |
+
# E3 (measured only over entries that produced a definition) both miss.
|
| 517 |
+
# `E1 - E1c_loose` is the recall clustering destroyed; 0 is the target.
|
| 518 |
+
"E1c_loose": {
|
| 519 |
+
"label": "post-cluster recall (loose)",
|
| 520 |
+
"value": e1c_loose.recall,
|
| 521 |
+
"detail": e1c_loose.as_dict(),
|
| 522 |
+
},
|
| 523 |
+
"E1c_strict": {
|
| 524 |
+
"label": "post-cluster recall (owns its own cluster)",
|
| 525 |
+
"value": e1c_strict.recall,
|
| 526 |
+
"detail": e1c_strict.as_dict(),
|
| 527 |
+
},
|
| 528 |
"E2": {
|
| 529 |
+
"label": "review-burden compression (descriptive, NOT gated)",
|
| 530 |
"value": clustered.compression_ratio,
|
| 531 |
"detail": {
|
| 532 |
"mentions": clustered.n_mentions,
|
|
|
|
| 536 |
}
|
| 537 |
|
| 538 |
result = None
|
| 539 |
+
invariants = None
|
| 540 |
+
stability: dict[str, Any] = {}
|
| 541 |
if args.extract:
|
| 542 |
extractor = MockExtractor() if args.mock else _azure_extractor()
|
| 543 |
if extractor is None:
|
|
|
|
| 547 |
if args.active_glossary
|
| 548 |
else None
|
| 549 |
)
|
| 550 |
+
|
| 551 |
+
# The free stages are deterministic and already done — only the paid
|
| 552 |
+
# stage is repeated. Re-running GLiNER N times would cost minutes to
|
| 553 |
+
# re-derive an identical clustering.
|
| 554 |
+
e3_values: list[float] = []
|
| 555 |
+
rule_values: list[float] = []
|
| 556 |
+
ids_per_run: list[dict[str, list[str]]] = []
|
| 557 |
+
violations_per_run: list[int] = []
|
| 558 |
+
|
| 559 |
+
for run_index in range(args.runs):
|
| 560 |
+
if args.runs > 1:
|
| 561 |
+
print(f" run {run_index + 1}/{args.runs} ...", file=sys.stderr)
|
| 562 |
+
result = extract_all(doc, clustered, filtered, extractor, args.limit, active)
|
| 563 |
+
e3 = score_glossary(gold, result.glossary)
|
| 564 |
+
rules = score_rules(gold, result.rules)
|
| 565 |
+
e3_values.append(e3.precision)
|
| 566 |
+
rule_values.append(rules.recall)
|
| 567 |
+
ids_per_run.append(_entity_ids(result))
|
| 568 |
+
invariants = check_invariants(result, doc)
|
| 569 |
+
violations_per_run.append(len(invariants.violations))
|
| 570 |
+
|
| 571 |
+
# The LAST run's detail is reported beside the band. Detail from one run
|
| 572 |
+
# of several is an example, not a summary — labelled as such below.
|
| 573 |
scores["E3"] = {
|
| 574 |
"label": "glossary schema fill (precision)",
|
| 575 |
+
"value": round(statistics.median(e3_values), 4),
|
| 576 |
"detail": e3.as_dict(),
|
| 577 |
}
|
| 578 |
scores["rule_recall"] = {
|
| 579 |
"label": "rule-branch recall",
|
| 580 |
+
"value": round(statistics.median(rule_values), 4),
|
| 581 |
"detail": rules.as_dict(),
|
| 582 |
}
|
| 583 |
+
if args.runs > 1:
|
| 584 |
+
scores["E3"]["stability"] = _stability(e3_values)
|
| 585 |
+
scores["rule_recall"]["stability"] = _stability(rule_values)
|
| 586 |
+
for key in ("E3", "rule_recall"):
|
| 587 |
+
scores[key]["detail"]["note"] = (
|
| 588 |
+
f"detail is from run {args.runs} of {args.runs}; the value is "
|
| 589 |
+
"the median across runs"
|
| 590 |
+
)
|
| 591 |
+
stability = {
|
| 592 |
+
"runs": args.runs,
|
| 593 |
+
"entity_ids": _id_stability(ids_per_run),
|
| 594 |
+
"violations_per_run": violations_per_run,
|
| 595 |
+
}
|
| 596 |
|
| 597 |
seconds = (datetime.now() - started).total_seconds()
|
| 598 |
+
report = build_report(
|
| 599 |
+
doc, filtered, clustered, scores, result, args, started, seconds,
|
| 600 |
+
invariants=invariants, stability=stability,
|
| 601 |
+
)
|
| 602 |
if args.label:
|
| 603 |
report["run"]["label"] = args.label
|
| 604 |
if not args.extract:
|
|
|
|
| 619 |
)
|
| 620 |
print(f"\n-> saved: {out_path.relative_to(_HERE.parent.parent)}")
|
| 621 |
|
| 622 |
+
# Invariants first, and regardless of the tier: a broken structural claim
|
| 623 |
+
# means a control leaked, which invalidates the scores rather than joining
|
| 624 |
+
# them. Mock runs included — the plumbing is exactly what they test.
|
| 625 |
+
if invariants is not None and not invariants.ok:
|
| 626 |
+
print(
|
| 627 |
+
f"\nINVARIANTS VIOLATED: {len(invariants.violations)} — see the result file",
|
| 628 |
+
file=sys.stderr,
|
| 629 |
+
)
|
| 630 |
+
return 5
|
| 631 |
+
|
| 632 |
+
if "model_drift" in report["run"] and not args.allow_model_drift:
|
| 633 |
+
print(
|
| 634 |
+
"\nMODEL SNAPSHOT MISMATCH — the run is recorded but must not be compared\n"
|
| 635 |
+
" to the committed results. Re-pin with --expect-model, or pass\n"
|
| 636 |
+
" --allow-model-drift to accept the caveat.",
|
| 637 |
+
file=sys.stderr,
|
| 638 |
+
)
|
| 639 |
+
return 6
|
| 640 |
+
|
| 641 |
if EVIDENCE_K and args.extract and not args.mock:
|
| 642 |
failed = [
|
| 643 |
k for k, s in scores.items() if k in KILL_LINES and s["value"] < KILL_LINES[k]
|
|
@@ -125,6 +125,60 @@ def score_term_filter(gold: dict, surfaces: list[str]) -> Score:
|
|
| 125 |
)
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
def score_glossary(gold: dict, entries: list[dict]) -> Score:
|
| 129 |
"""E3: when nano fills the schema, is it right?
|
| 130 |
|
|
|
|
| 125 |
)
|
| 126 |
|
| 127 |
|
| 128 |
+
def score_post_cluster(gold: dict, canonicals: list[str], cluster_variants=None) -> Score:
|
| 129 |
+
"""**E1c — added 2026-09-10.** The same gold set as E1, scored AFTER clustering.
|
| 130 |
+
|
| 131 |
+
E1 is measured on mention surfaces, which is *before* clustering. That left a
|
| 132 |
+
window nothing observed: a term the filter found correctly, which clustering
|
| 133 |
+
then absorbed into another term's cluster, and which therefore never gets an
|
| 134 |
+
entry an expert could approve. On the reference document that window cost the
|
| 135 |
+
gold term **EWH** — absorbed into `Working Hours`, which then abstained, so
|
| 136 |
+
two gold terms became one null entry.
|
| 137 |
+
|
| 138 |
+
Nothing in the harness could see it. E1 scores it as found; **E2 compression
|
| 139 |
+
is INFLATED by the merge** (which is why compression is no longer gated); and
|
| 140 |
+
E3 only scores entries that produced a definition, so a merged-then-null
|
| 141 |
+
entry is excluded from the scoreable base by construction.
|
| 142 |
+
|
| 143 |
+
Two variants, because the difference between them is informative:
|
| 144 |
+
|
| 145 |
+
- **loose** (`cluster_variants=None`) — identical matching to E1, just fed
|
| 146 |
+
cluster canonicals instead of mention surfaces. Directly comparable to E1,
|
| 147 |
+
so `E1 - E1c_loose` is the recall the merge stage destroyed.
|
| 148 |
+
- **strict** (`cluster_variants` supplied) — a gold term counts only if it is
|
| 149 |
+
some cluster's OWN canonical. Surviving as another term's variant does not
|
| 150 |
+
count, because that is exactly the loss. Always ≤ loose.
|
| 151 |
+
"""
|
| 152 |
+
score = score_term_filter(gold, canonicals)
|
| 153 |
+
score.label = "post_cluster_recall(E1c_loose)"
|
| 154 |
+
if cluster_variants is None:
|
| 155 |
+
return score
|
| 156 |
+
|
| 157 |
+
canon = {norm(c) for c in canonicals if norm(c)}
|
| 158 |
+
tp, misses = 0, []
|
| 159 |
+
for entry in gold.get("terms", []):
|
| 160 |
+
variants = [entry["term"]] + list(entry.get("variants", []))
|
| 161 |
+
if entry.get("full_name"):
|
| 162 |
+
variants.append(entry["full_name"])
|
| 163 |
+
normed = {norm(v) for v in variants if norm(v)}
|
| 164 |
+
if normed & canon:
|
| 165 |
+
tp += 1
|
| 166 |
+
else:
|
| 167 |
+
misses.append(entry["term"])
|
| 168 |
+
n_gold = len(gold.get("terms", []))
|
| 169 |
+
precision, recall, f1 = _prf(tp, len(canon), n_gold)
|
| 170 |
+
return Score(
|
| 171 |
+
label="post_cluster_recall(E1c_strict)",
|
| 172 |
+
n_gold=n_gold,
|
| 173 |
+
n_pred=len(canon),
|
| 174 |
+
true_positives=tp,
|
| 175 |
+
precision=precision,
|
| 176 |
+
recall=recall,
|
| 177 |
+
f1=f1,
|
| 178 |
+
misses=misses,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
def score_glossary(gold: dict, entries: list[dict]) -> Score:
|
| 183 |
"""E3: when nano fills the schema, is it right?
|
| 184 |
|
|
@@ -102,6 +102,17 @@ class IngestRequest(BaseModel):
|
|
| 102 |
"artifact. Off by default: on, it silently re-spends on every call."
|
| 103 |
),
|
| 104 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
@field_validator("user_id")
|
| 107 |
@classmethod
|
|
@@ -145,7 +156,9 @@ class IngestAccepted(BaseModel):
|
|
| 145 |
async def ingest(request: Request, payload: IngestRequest) -> IngestAccepted:
|
| 146 |
"""Admit a batch and return immediately. Poll `GET /jobs/{job_id}`."""
|
| 147 |
job, reused = await _service.start(
|
| 148 |
-
user_id=payload.user_id,
|
|
|
|
|
|
|
| 149 |
)
|
| 150 |
if reused:
|
| 151 |
message = "A job for this scope is already running; returning it."
|
|
|
|
| 102 |
"artifact. Off by default: on, it silently re-spends on every call."
|
| 103 |
),
|
| 104 |
)
|
| 105 |
+
review_mode: Literal["expert", "llm"] = Field(
|
| 106 |
+
default="expert",
|
| 107 |
+
description=(
|
| 108 |
+
"Who rules on what this batch produces. 'expert' leaves every "
|
| 109 |
+
"entry waiting for a human. 'llm' approves them on arrival with "
|
| 110 |
+
"NO reviewer step and no second opinion — the entries were "
|
| 111 |
+
"written by a model and are accepted as-is. Choosing it records "
|
| 112 |
+
"`authorised_by` against the caller, so an entry approved this "
|
| 113 |
+
"way can always say who allowed it."
|
| 114 |
+
),
|
| 115 |
+
)
|
| 116 |
|
| 117 |
@field_validator("user_id")
|
| 118 |
@classmethod
|
|
|
|
| 156 |
async def ingest(request: Request, payload: IngestRequest) -> IngestAccepted:
|
| 157 |
"""Admit a batch and return immediately. Poll `GET /jobs/{job_id}`."""
|
| 158 |
job, reused = await _service.start(
|
| 159 |
+
user_id=payload.user_id,
|
| 160 |
+
document_ids=payload.document_ids,
|
| 161 |
+
review_mode=payload.review_mode,
|
| 162 |
)
|
| 163 |
if reused:
|
| 164 |
message = "A job for this scope is already running; returning it."
|
|
@@ -379,6 +379,11 @@ class KnowledgeDocumentRow(Base):
|
|
| 379 |
raw_output_dir = Column(Text)
|
| 380 |
artifact = Column(JSONB, nullable=False)
|
| 381 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
|
| 384 |
class KnowledgeExtractionRunRow(Base):
|
|
@@ -467,6 +472,20 @@ class KnowledgeEntryRow(Base):
|
|
| 467 |
diff_status = Column(Text)
|
| 468 |
definition_conflict = Column(Boolean, nullable=False, default=False)
|
| 469 |
payload = Column(JSONB, nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 471 |
|
| 472 |
|
|
@@ -501,6 +520,14 @@ class KnowledgeReviewRow(Base):
|
|
| 501 |
edited_payload = Column(JSONB)
|
| 502 |
note = Column(Text)
|
| 503 |
reviewer_id = Column(Text, nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 505 |
|
| 506 |
|
|
@@ -520,6 +547,11 @@ class KnowledgeJobRow(Base):
|
|
| 520 |
user_id = Column(Text, nullable=False)
|
| 521 |
status = Column(Text, nullable=False, default="queued")
|
| 522 |
stage = Column(Text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
document_ids = Column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
| 524 |
n_requested = Column(Integer, nullable=False, default=0)
|
| 525 |
n_succeeded = Column(Integer, nullable=False, default=0)
|
|
|
|
| 379 |
raw_output_dir = Column(Text)
|
| 380 |
artifact = Column(JSONB, nullable=False)
|
| 381 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 382 |
+
# i7 (2026-09-14): the source document has been removed. Null = live.
|
| 383 |
+
# A withdrawn document stops counting towards the knowledge WITHOUT
|
| 384 |
+
# anything being destroyed -- an approval is an act someone took, and the
|
| 385 |
+
# record of what it referred to has to survive the source going away.
|
| 386 |
+
withdrawn_at = Column(DateTime(timezone=True), nullable=True)
|
| 387 |
|
| 388 |
|
| 389 |
class KnowledgeExtractionRunRow(Base):
|
|
|
|
| 472 |
diff_status = Column(Text)
|
| 473 |
definition_conflict = Column(Boolean, nullable=False, default=False)
|
| 474 |
payload = Column(JSONB, nullable=False)
|
| 475 |
+
# m3/m4/m5 (2026-09-08 discussion, migrated 2026-09-14). Promoted out of
|
| 476 |
+
# `payload` so a consumer does not have to open jsonb to use them -- the
|
| 477 |
+
# objection raised in that discussion was that too much JSON makes the data
|
| 478 |
+
# contract hard and easy to break. `payload` REMAINS the source of truth;
|
| 479 |
+
# these are written from the same object in the same insert, exactly as
|
| 480 |
+
# `label`, `mention_count` and `diff_status` already are.
|
| 481 |
+
#
|
| 482 |
+
# `doc_ids` is the list of documents behind the entry. `doc_id` above stays
|
| 483 |
+
# the primary source -- every existing filter and index uses it -- and the
|
| 484 |
+
# two agree for an ordinary entry. They differ only when a definition is
|
| 485 |
+
# carried forward onto a later document that re-mentions the term.
|
| 486 |
+
doc_ids = Column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
| 487 |
+
content = Column(Text)
|
| 488 |
+
provenance = Column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
| 489 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 490 |
|
| 491 |
|
|
|
|
| 520 |
edited_payload = Column(JSONB)
|
| 521 |
note = Column(Text)
|
| 522 |
reviewer_id = Column(Text, nullable=False)
|
| 523 |
+
# r2/r3. `decided_by` says WHETHER A HUMAN LOOKED; `authorised_by` says
|
| 524 |
+
# on whose authority it happened. They are separate because the admin who
|
| 525 |
+
# clicks ingest and the expert who reviews can be different people, and in
|
| 526 |
+
# "llm" mode there is no reviewer at all -- so without the clicker's id
|
| 527 |
+
# reaching this row the decision would have no owner. Together they read:
|
| 528 |
+
# approved by LLM with permission of <person>.
|
| 529 |
+
decided_by = Column(Text, nullable=False, server_default=text("'human'"))
|
| 530 |
+
authorised_by = Column(Text)
|
| 531 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 532 |
|
| 533 |
|
|
|
|
| 547 |
user_id = Column(Text, nullable=False)
|
| 548 |
status = Column(Text, nullable=False, default="queued")
|
| 549 |
stage = Column(Text)
|
| 550 |
+
# r1 (2026-09-08 discussion): which review mode this batch was ingested
|
| 551 |
+
# under. "expert" leaves entries waiting for a human; "llm" takes them
|
| 552 |
+
# straight to approved with no reviewer step at all. Nullable so jobs
|
| 553 |
+
# written before the column existed stay legal.
|
| 554 |
+
review_mode = Column(Text)
|
| 555 |
document_ids = Column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
| 556 |
n_requested = Column(Integer, nullable=False, default=0)
|
| 557 |
n_succeeded = Column(Integer, nullable=False, default=0)
|
|
@@ -26,7 +26,7 @@ from typing import Any
|
|
| 26 |
|
| 27 |
from src.middlewares.logging import get_logger
|
| 28 |
|
| 29 |
-
from .models import Chunk, ParsedDoc
|
| 30 |
|
| 31 |
logger = get_logger("knowledge_adapter")
|
| 32 |
|
|
@@ -54,7 +54,7 @@ _PAGE_END_KEYS = ("page_idx_end", "page_end")
|
|
| 54 |
# seam. Drift is caught instead by a local seam test that imports
|
| 55 |
# `knowledge_parsing.contracts` directly and asserts the two agree, so a bump on
|
| 56 |
# either side fails loudly in tests rather than silently at read time.
|
| 57 |
-
SUPPORTED_SCHEMA = (0,
|
| 58 |
|
| 59 |
|
| 60 |
class IncompatibleArtifactError(ValueError):
|
|
@@ -156,6 +156,28 @@ def chunk_from_dict(raw: dict[str, Any], doc_id: str, ordinal: int = 0) -> Chunk
|
|
| 156 |
# the floor here, which left `formula_latex` unprovable — `text` carries
|
| 157 |
# rendered prose, so a LaTeX claim had nothing to be checked against.
|
| 158 |
latex=list(raw.get("latex") or []),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
)
|
| 160 |
|
| 161 |
|
|
|
|
| 26 |
|
| 27 |
from src.middlewares.logging import get_logger
|
| 28 |
|
| 29 |
+
from .models import Chunk, ChunkAsset, ParsedDoc
|
| 30 |
|
| 31 |
logger = get_logger("knowledge_adapter")
|
| 32 |
|
|
|
|
| 54 |
# seam. Drift is caught instead by a local seam test that imports
|
| 55 |
# `knowledge_parsing.contracts` directly and asserts the two agree, so a bump on
|
| 56 |
# either side fails loudly in tests rather than silently at read time.
|
| 57 |
+
SUPPORTED_SCHEMA = (0, 4)
|
| 58 |
|
| 59 |
|
| 60 |
class IncompatibleArtifactError(ValueError):
|
|
|
|
| 156 |
# the floor here, which left `formula_latex` unprovable — `text` carries
|
| 157 |
# rendered prose, so a LaTeX claim had nothing to be checked against.
|
| 158 |
latex=list(raw.get("latex") or []),
|
| 159 |
+
# Same story a third time: the parsing half emits `table_html` and
|
| 160 |
+
# extraction dropped it here, so a table reached the model ONLY as
|
| 161 |
+
# pipe-linearised text with its row/column structure destroyed. It is the
|
| 162 |
+
# haystack the formula guard needs for tables and the input a future
|
| 163 |
+
# table branch needs; carrying it costs nothing.
|
| 164 |
+
table_html=raw.get("table_html") or None,
|
| 165 |
+
# New at 0.4.0 — see ChunkAsset. A pointer the adapter drops is a pointer
|
| 166 |
+
# that does not exist, which is exactly what happened to `table_html`.
|
| 167 |
+
assets=[_asset(a) for a in (raw.get("assets") or [])],
|
| 168 |
+
referenced_by=list(raw.get("referenced_by") or []),
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _asset(raw: dict[str, Any]) -> ChunkAsset:
|
| 173 |
+
"""Map one artifact asset onto the internal shape, tolerating absences."""
|
| 174 |
+
return ChunkAsset(
|
| 175 |
+
asset_id=str(raw.get("asset_id") or ""),
|
| 176 |
+
kind=str(raw.get("kind") or "figure"),
|
| 177 |
+
caption=raw.get("caption") or None,
|
| 178 |
+
description=raw.get("description") or None,
|
| 179 |
+
page_no=raw.get("page_no"),
|
| 180 |
+
storage_key=raw.get("storage_key") or None,
|
| 181 |
)
|
| 182 |
|
| 183 |
|
|
@@ -1,9 +1,13 @@
|
|
| 1 |
"""Mention[] → TermCluster[].
|
| 2 |
|
| 3 |
Matching order, cheapest and most certain first:
|
| 4 |
-
1. exact match after normalisation
|
| 5 |
2. abbreviation ↔ expansion (from legend blocks)
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
**Over-merging is much worse than under-merging.** An under-merge costs one
|
| 9 |
extra LLM call and one extra review-queue row. A wrong merge silently destroys a
|
|
@@ -58,10 +62,37 @@ def cluster_mentions(
|
|
| 58 |
_add(buckets[linked], mention, "abbrev")
|
| 59 |
continue
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
buckets[key] = {
|
| 67 |
"surfaces": {mention.surface},
|
|
|
|
| 1 |
"""Mention[] → TermCluster[].
|
| 2 |
|
| 3 |
Matching order, cheapest and most certain first:
|
| 4 |
+
1. exact match after normalisation (plural-folded — `normalize(lemma=True)`)
|
| 5 |
2. abbreviation ↔ expansion (from legend blocks)
|
| 6 |
+
|
| 7 |
+
**There is deliberately no fuzzy pass** (retired 2026-09-10; `_fuzzy_match` is
|
| 8 |
+
parked below with the measurement that retired it). Every merge it made that was
|
| 9 |
+
correct is now done deterministically by plural folding; 94% of what it made was
|
| 10 |
+
a token-subset match, which is always wrong.
|
| 11 |
|
| 12 |
**Over-merging is much worse than under-merging.** An under-merge costs one
|
| 13 |
extra LLM call and one extra review-queue row. A wrong merge silently destroys a
|
|
|
|
| 62 |
_add(buckets[linked], mention, "abbrev")
|
| 63 |
continue
|
| 64 |
|
| 65 |
+
# --- FUZZY PASS RETIRED 2026-09-10 (measured, not a style call) ---------
|
| 66 |
+
# `_fuzzy_match` is kept below for the record; this call site is not.
|
| 67 |
+
#
|
| 68 |
+
# `token_set_ratio` returns EXACTLY 100 whenever one string's token set is
|
| 69 |
+
# a subset of the other's, so a head noun always absorbed its own
|
| 70 |
+
# compounds and the threshold of 92 was inert. Measured across the BUMA
|
| 71 |
+
# standard, a Komatsu shop manual and an Open Pit textbook chapter:
|
| 72 |
+
#
|
| 73 |
+
# 131 of the 140 merges this pass made were token-subset matches — ALL
|
| 74 |
+
# wrong (`Radiator` ate `radiator cap`; `level` ate `oil level sensor`;
|
| 75 |
+
# `Working Hours` ate `effective working hours`, which cost the gold
|
| 76 |
+
# term EWH its own entry entirely).
|
| 77 |
+
#
|
| 78 |
+
# Of the 9 genuine near-misses, 6 were plain singular/plural and 3 were
|
| 79 |
+
# `gain/loss pty|qty|ua grouping` — three DISTINCT metrics, also wrong.
|
| 80 |
+
#
|
| 81 |
+
# So the pass produced no correct merge that plural folding does not now
|
| 82 |
+
# do deterministically (`normalize(lemma=True)`). Retiring it drove
|
| 83 |
+
# subset-merges to 0 on all three documents and lifted post-cluster gold
|
| 84 |
+
# recall 0.8293 -> 0.8537, while also giving the FEWEST clusters of any
|
| 85 |
+
# correct configuration (Open Pit 431, against 434 for the best guarded
|
| 86 |
+
# fuzzy variant and 445 for no-fuzzy-without-lemma).
|
| 87 |
+
#
|
| 88 |
+
# Matching is now: exact (normalised, plural-folded) -> abbreviation
|
| 89 |
+
# expansion. Nothing else. If you are tempted to restore this, the thing
|
| 90 |
+
# to beat is subset_pairs = 0; see FREE_STAGE_RESULTS.md.
|
| 91 |
+
#
|
| 92 |
+
# match = _fuzzy_match(key, buckets.keys(), fuzzy_threshold)
|
| 93 |
+
# if match:
|
| 94 |
+
# _add(buckets[match], mention, "fuzzy")
|
| 95 |
+
# continue
|
| 96 |
|
| 97 |
buckets[key] = {
|
| 98 |
"surfaces": {mention.surface},
|
|
@@ -31,7 +31,54 @@ STOP_SURFACES = {
|
|
| 31 |
}
|
| 32 |
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
s = unicodedata.normalize("NFKC", surface).casefold()
|
| 36 |
s = s.replace("-", " ").replace("_", " ")
|
| 37 |
s = re.sub(r"[.’']", "", s)
|
|
@@ -45,11 +92,15 @@ def normalize(surface: str) -> str:
|
|
| 45 |
# and gave one concept two clusters. Checked after the strip, not before.
|
| 46 |
if s.count("(") != s.count(")"):
|
| 47 |
s = re.sub(r"\s+", " ", s.replace("(", " ").replace(")", " ")).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
return s
|
| 49 |
|
| 50 |
|
| 51 |
def is_noise(surface: str) -> bool:
|
| 52 |
-
n = normalize(surface)
|
| 53 |
if len(n) < 2:
|
| 54 |
return True
|
| 55 |
if n in STOP_SURFACES:
|
|
@@ -122,7 +173,8 @@ class AbbrevIndex:
|
|
| 122 |
self.to_expansion: dict[str, str] = {}
|
| 123 |
self.to_abbrev: dict[str, str] = {}
|
| 124 |
for pair in pairs:
|
| 125 |
-
abbrev
|
|
|
|
| 126 |
if not abbrev or not expansion:
|
| 127 |
continue
|
| 128 |
self.to_expansion[abbrev] = expansion
|
|
@@ -152,9 +204,9 @@ class AbbrevIndex:
|
|
| 152 |
continue
|
| 153 |
outer, inner = parts
|
| 154 |
if looks_like_abbreviation(inner) and is_initialism(inner, outer):
|
| 155 |
-
abbrev, expansion = normalize(inner), normalize(outer)
|
| 156 |
elif looks_like_abbreviation(outer) and is_initialism(outer, inner):
|
| 157 |
-
abbrev, expansion = normalize(outer), normalize(inner)
|
| 158 |
else:
|
| 159 |
continue
|
| 160 |
if abbrev and expansion and abbrev not in self.to_expansion:
|
|
@@ -166,7 +218,7 @@ class AbbrevIndex:
|
|
| 166 |
def canonical_key(self, surface: str) -> str:
|
| 167 |
"""Map a surface to a shared key so an abbreviation and its expansion
|
| 168 |
collide into the same bucket."""
|
| 169 |
-
n = normalize(surface)
|
| 170 |
if n in self.to_abbrev:
|
| 171 |
return self.to_abbrev[n]
|
| 172 |
if n in self.to_expansion:
|
|
@@ -179,7 +231,7 @@ class AbbrevIndex:
|
|
| 179 |
parts = split_parenthetical(surface)
|
| 180 |
if parts:
|
| 181 |
outer, inner = parts
|
| 182 |
-
outer_n, inner_n = normalize(outer), normalize(inner)
|
| 183 |
if inner_n in self.to_expansion: # the legend knows the bracket
|
| 184 |
return inner_n
|
| 185 |
if outer_n in self.to_abbrev: # the legend knows the outer
|
|
@@ -193,7 +245,7 @@ class AbbrevIndex:
|
|
| 193 |
return n
|
| 194 |
|
| 195 |
def linked(self, a: str, b: str) -> bool:
|
| 196 |
-
na, nb = normalize(a), normalize(b)
|
| 197 |
if self.to_expansion.get(na) == nb or self.to_expansion.get(nb) == na:
|
| 198 |
return True
|
| 199 |
# Same reasoning as `canonical_key`: two surfaces are linked when one is
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
|
| 34 |
+
# Plural folding for the CLUSTERING KEY ONLY (see `normalize(lemma=...)` below).
|
| 35 |
+
# Measured 2026-09-10: of the 140 merges the retired fuzzy pass made across three
|
| 36 |
+
# documents, 131 were token-subset matches (all wrong) and of the 9 genuine
|
| 37 |
+
# near-misses SIX were plain singular/plural pairs — `resource`/`resources`,
|
| 38 |
+
# `ore type`/`ore types`, `deposit`/`deposits`, `measurement`/`measurements`,
|
| 39 |
+
# `project manager`/`project managers`, `reserve`/`reserves`. Folding those
|
| 40 |
+
# deterministically is what let the fuzzy pass be retired instead of guarded: on
|
| 41 |
+
# the Open Pit textbook it gives 431 clusters against 434 for the best guarded
|
| 42 |
+
# fuzzy config and 445 for no-fuzzy-without-lemma.
|
| 43 |
+
#
|
| 44 |
+
# Deliberately hand-rolled rather than a real lemmatiser: a proper one means a
|
| 45 |
+
# new dependency (nltk/spacy) for a measured six-pair gain, and this runs on a
|
| 46 |
+
# normalised key where the only job is collapsing an English plural suffix.
|
| 47 |
+
_PLURAL_EXCEPTIONS = ("ss", "us", "is", "as", "os")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def depluralise(token: str) -> str:
|
| 51 |
+
"""Collapse an English plural suffix on a single normalised token.
|
| 52 |
+
|
| 53 |
+
Conservative by construction — it only ever SHORTENS a token, and only on
|
| 54 |
+
suffixes that are plural in this corpus. `status`, `analysis`, `gas` and
|
| 55 |
+
`bias` are protected by `_PLURAL_EXCEPTIONS`; anything under 4 characters is
|
| 56 |
+
left alone so an abbreviation (`UAS`, `BCM`) is never touched.
|
| 57 |
+
"""
|
| 58 |
+
for suffix, replacement in (("ies", "y"), ("ses", "s"), ("xes", "x"), ("hes", "h")):
|
| 59 |
+
if token.endswith(suffix) and len(token) > len(suffix) + 1:
|
| 60 |
+
return token[: -len(suffix)] + replacement
|
| 61 |
+
if (
|
| 62 |
+
token.endswith("s")
|
| 63 |
+
and not token.endswith(_PLURAL_EXCEPTIONS)
|
| 64 |
+
and len(token) > 3
|
| 65 |
+
):
|
| 66 |
+
return token[:-1]
|
| 67 |
+
return token
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def normalize(surface: str, lemma: bool = False) -> str:
|
| 71 |
+
"""Normalise a surface for comparison.
|
| 72 |
+
|
| 73 |
+
`lemma=True` additionally folds English plurals, and is passed ONLY by the
|
| 74 |
+
clustering key path (`is_noise` and `AbbrevIndex`). It is deliberately NOT
|
| 75 |
+
the default: `rank/evidence.py` imports this function directly to match a
|
| 76 |
+
term against a chunk heading, and the 2026-09-10 measurement that justified
|
| 77 |
+
plural folding patched this module's global — which that direct import
|
| 78 |
+
bypasses. Making it the default would therefore change evidence ranking in a
|
| 79 |
+
way nothing has measured. Keep the ranker on the unfolded form until there
|
| 80 |
+
is a number for it.
|
| 81 |
+
"""
|
| 82 |
s = unicodedata.normalize("NFKC", surface).casefold()
|
| 83 |
s = s.replace("-", " ").replace("_", " ")
|
| 84 |
s = re.sub(r"[.’']", "", s)
|
|
|
|
| 92 |
# and gave one concept two clusters. Checked after the strip, not before.
|
| 93 |
if s.count("(") != s.count(")"):
|
| 94 |
s = re.sub(r"\s+", " ", s.replace("(", " ").replace(")", " ")).strip()
|
| 95 |
+
# Applied LAST, per token, so it never interferes with the bracket and
|
| 96 |
+
# punctuation handling above.
|
| 97 |
+
if lemma:
|
| 98 |
+
s = " ".join(depluralise(t) for t in s.split())
|
| 99 |
return s
|
| 100 |
|
| 101 |
|
| 102 |
def is_noise(surface: str) -> bool:
|
| 103 |
+
n = normalize(surface, lemma=True)
|
| 104 |
if len(n) < 2:
|
| 105 |
return True
|
| 106 |
if n in STOP_SURFACES:
|
|
|
|
| 173 |
self.to_expansion: dict[str, str] = {}
|
| 174 |
self.to_abbrev: dict[str, str] = {}
|
| 175 |
for pair in pairs:
|
| 176 |
+
abbrev = normalize(pair.abbrev, lemma=True)
|
| 177 |
+
expansion = normalize(pair.expansion, lemma=True)
|
| 178 |
if not abbrev or not expansion:
|
| 179 |
continue
|
| 180 |
self.to_expansion[abbrev] = expansion
|
|
|
|
| 204 |
continue
|
| 205 |
outer, inner = parts
|
| 206 |
if looks_like_abbreviation(inner) and is_initialism(inner, outer):
|
| 207 |
+
abbrev, expansion = normalize(inner, lemma=True), normalize(outer, lemma=True)
|
| 208 |
elif looks_like_abbreviation(outer) and is_initialism(outer, inner):
|
| 209 |
+
abbrev, expansion = normalize(outer, lemma=True), normalize(inner, lemma=True)
|
| 210 |
else:
|
| 211 |
continue
|
| 212 |
if abbrev and expansion and abbrev not in self.to_expansion:
|
|
|
|
| 218 |
def canonical_key(self, surface: str) -> str:
|
| 219 |
"""Map a surface to a shared key so an abbreviation and its expansion
|
| 220 |
collide into the same bucket."""
|
| 221 |
+
n = normalize(surface, lemma=True)
|
| 222 |
if n in self.to_abbrev:
|
| 223 |
return self.to_abbrev[n]
|
| 224 |
if n in self.to_expansion:
|
|
|
|
| 231 |
parts = split_parenthetical(surface)
|
| 232 |
if parts:
|
| 233 |
outer, inner = parts
|
| 234 |
+
outer_n, inner_n = normalize(outer, lemma=True), normalize(inner, lemma=True)
|
| 235 |
if inner_n in self.to_expansion: # the legend knows the bracket
|
| 236 |
return inner_n
|
| 237 |
if outer_n in self.to_abbrev: # the legend knows the outer
|
|
|
|
| 245 |
return n
|
| 246 |
|
| 247 |
def linked(self, a: str, b: str) -> bool:
|
| 248 |
+
na, nb = normalize(a, lemma=True), normalize(b, lemma=True)
|
| 249 |
if self.to_expansion.get(na) == nb or self.to_expansion.get(nb) == na:
|
| 250 |
return True
|
| 251 |
# Same reasoning as `canonical_key`: two surfaces are linked when one is
|
|
@@ -37,8 +37,111 @@ broad:
|
|
| 37 |
- work category
|
| 38 |
- analysis method
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# 0.25, not 0.35: the E1 sweep measured recall 0.854 @ 0.25 vs 0.658 @ 0.35 on the
|
| 41 |
# `broad` variant. Precision falls (0.41 vs 0.50) but the term filter is meant to be
|
| 42 |
# over-inclusive — clustering and evidence ranking absorb the noise, and a term the
|
| 43 |
# filter never proposes can never be recovered downstream.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
threshold: 0.25
|
|
|
|
| 37 |
- work category
|
| 38 |
- analysis method
|
| 39 |
|
| 40 |
+
# ── Genre variants, added 2026-09-10. NOT WIRED: `broad` stays the default. ──────
|
| 41 |
+
#
|
| 42 |
+
# Measured on three documents. `broad` wins where we can score it (BUMA E1 0.8780
|
| 43 |
+
# vs 0.8537 for `conceptual` and 0.4146 for `diagnostic`), which confirms the
|
| 44 |
+
# original calibration. What the other two showed is that label fit is
|
| 45 |
+
# GENRE-dependent, not that either is better:
|
| 46 |
+
#
|
| 47 |
+
# diagnostic -> 1108 mentions on the Komatsu shop manual against `broad`'s 449
|
| 48 |
+
# (2.5x), and 133 on the textbook. Collapses on the standard.
|
| 49 |
+
# conceptual -> 940 mentions on the Open Pit textbook against `broad`'s 792.
|
| 50 |
+
#
|
| 51 |
+
# Those gains are UNSCOREABLE — neither document has a gold set, so "more
|
| 52 |
+
# mentions" is not "better recall", and `diagnostic` on Komatsu also brought 128
|
| 53 |
+
# token-subset merges. They are parked here for the genre router to select once
|
| 54 |
+
# there is a gold set to justify the switch. Selecting one today would be shipping
|
| 55 |
+
# on a mention count.
|
| 56 |
+
|
| 57 |
+
# Shop manuals / troubleshooting: components, symptoms, remedies. The other sets
|
| 58 |
+
# have no notion of a fault or a corrective action, which is most of a manual.
|
| 59 |
+
#
|
| 60 |
+
# ⚠️ `fault code` added 2026-09-11 after a measured miss. The Komatsu manual prints
|
| 61 |
+
# 27 service codes (`[CA559]`, `[CB2249]`) in plain prose, and BOTH `broad` and the
|
| 62 |
+
# original `diagnostic` set returned **zero** mentions for them — a code is not a
|
| 63 |
+
# component, a symptom, a check or an action, so no label was asking. GLiNER finds
|
| 64 |
+
# them readily once asked: 6 of 6 on the probe chunk at 0.54-0.69, well over the
|
| 65 |
+
# 0.25 floor. Worth recording because it corrects an assumption in the Step 5 plan:
|
| 66 |
+
# routing this document to `diagnostic` would NOT have recovered the codes.
|
| 67 |
+
diagnostic:
|
| 68 |
+
- machine component
|
| 69 |
+
- fault or symptom
|
| 70 |
+
- fault code
|
| 71 |
+
- inspection or check
|
| 72 |
+
- corrective action
|
| 73 |
+
- consumable or fluid
|
| 74 |
+
- measurement unit
|
| 75 |
+
- operating condition
|
| 76 |
+
- warning or indicator
|
| 77 |
+
|
| 78 |
+
# `broad` plus the one label it was measurably missing. Exists ONLY as the A/B arm;
|
| 79 |
+
# **do not make it the default.** Measured 2026-09-11, `broad` vs `broad_codes`,
|
| 80 |
+
# both arms on current code (so both include the subword window split):
|
| 81 |
+
#
|
| 82 |
+
# mentions clusters code clusters E1c_strict
|
| 83 |
+
# BUMA 251 -> 200 86 -> 67 0 -> 0 0.8537 -> 0.7805
|
| 84 |
+
# Komatsu 449 -> 548 93 -> 161 0 -> 17 —
|
| 85 |
+
# Open Pit 787 -> 711 427 -> 377 0 -> 0 —
|
| 86 |
+
#
|
| 87 |
+
# **A label is not additive — it perturbs the whole decode.** GLiNER runs flat NER,
|
| 88 |
+
# so a new label competes for spans: BUMA lost 51 mentions, 19 clusters and THREE
|
| 89 |
+
# gold terms (`Coal barging`, `Grouping (Composite)`, `Other Activity`), and
|
| 90 |
+
# `dropped_overlapping` moved 10 -> 6. Open Pit lost 76 mentions. Adding this to
|
| 91 |
+
# `broad` would buy 17 code clusters on one document by damaging the other two.
|
| 92 |
+
#
|
| 93 |
+
# On Komatsu it also costs +73% clusters (93 -> 161) = one LLM call each, against
|
| 94 |
+
# the +20% bar that rejected threshold 0.15. 17 of the 68 new clusters are codes;
|
| 95 |
+
# **66 are cause phrases** ("Clogged fuel filter and/or strainer", "Improper
|
| 96 |
+
# injection timing") — the diagnostic content the other label sets never reached.
|
| 97 |
+
# That is the case for routing, not for widening the default.
|
| 98 |
+
broad_codes:
|
| 99 |
+
- mining production metric
|
| 100 |
+
- equipment availability term
|
| 101 |
+
- operational abbreviation
|
| 102 |
+
- machine or equipment type
|
| 103 |
+
- time category
|
| 104 |
+
- mining activity
|
| 105 |
+
- material or commodity
|
| 106 |
+
- measurement unit
|
| 107 |
+
- data source or system
|
| 108 |
+
- work category
|
| 109 |
+
- analysis method
|
| 110 |
+
- fault code
|
| 111 |
+
|
| 112 |
+
# Textbooks / standards-of-practice: concepts and categories rather than equipment.
|
| 113 |
+
conceptual:
|
| 114 |
+
- technical concept
|
| 115 |
+
- classification category
|
| 116 |
+
- economic measure
|
| 117 |
+
- mining activity
|
| 118 |
+
- material or commodity
|
| 119 |
+
- analysis method
|
| 120 |
+
- regulatory or reporting term
|
| 121 |
+
- measurement unit
|
| 122 |
+
|
| 123 |
# 0.25, not 0.35: the E1 sweep measured recall 0.854 @ 0.25 vs 0.658 @ 0.35 on the
|
| 124 |
# `broad` variant. Precision falls (0.41 vs 0.50) but the term filter is meant to be
|
| 125 |
# over-inclusive — clustering and evidence ranking absorb the noise, and a term the
|
| 126 |
# filter never proposes can never be recovered downstream.
|
| 127 |
+
#
|
| 128 |
+
# ⚠️ RE-SWEPT 2026-09-10. 0.15 had never been tested and measures E1 0.9756
|
| 129 |
+
# (40/41) against 0.8780 at 0.25 — four more gold terms, and post-cluster recall
|
| 130 |
+
# 0.8537 -> 0.9512. It is NOT shipped, and the reason is cost:
|
| 131 |
+
#
|
| 132 |
+
# clusters vs the 0.25 baseline: BUMA +50% Komatsu +29% Open Pit +10%
|
| 133 |
+
#
|
| 134 |
+
# Clusters drive LLM spend one call each, and a single 46-page chapter already
|
| 135 |
+
# consumes 56% of the 2M pre-flight ceiling. The agreed rule was "ship 0.15 if it
|
| 136 |
+
# lands within +20% of today's cluster count"; the worst case is +50%, so the rule
|
| 137 |
+
# says keep 0.25.
|
| 138 |
+
#
|
| 139 |
+
# The hoped-for escape was pairing it with a larger WINDOW_WORDS to cancel the
|
| 140 |
+
# cost. That is now closed too: 256 truncates 46 of 99 windows on the Open Pit
|
| 141 |
+
# textbook (see settings.WINDOW_WORDS), so the cluster reduction it offered was
|
| 142 |
+
# partly content loss, not efficiency.
|
| 143 |
+
#
|
| 144 |
+
# What would reopen this: a cheaper way to spend a cluster (batching several terms
|
| 145 |
+
# per call), or a gold set for a second document so the recall gain can be
|
| 146 |
+
# confirmed as general rather than BUMA-specific.
|
| 147 |
threshold: 0.25
|
|
@@ -63,5 +63,41 @@ def evidence_block(chunks: list[Chunk], scores: list[float] | None = None) -> st
|
|
| 63 |
parts.append(
|
| 64 |
f"[chunk_id={chunk.chunk_id} section={chunk.section_no or '-'} "
|
| 65 |
f"page={chunk.page_start}{score}]\n{head}{chunk.text}"
|
|
|
|
| 66 |
)
|
| 67 |
return "EVIDENCE\n" + "\n\n---\n\n".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
parts.append(
|
| 64 |
f"[chunk_id={chunk.chunk_id} section={chunk.section_no or '-'} "
|
| 65 |
f"page={chunk.page_start}{score}]\n{head}{chunk.text}"
|
| 66 |
+
+ _asset_block(chunk)
|
| 67 |
)
|
| 68 |
return "EVIDENCE\n" + "\n\n---\n\n".join(parts)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _asset_block(chunk: Chunk) -> str:
|
| 72 |
+
"""Figures and tables this chunk references, appended to its evidence. 0.4.0.
|
| 73 |
+
|
| 74 |
+
The chunk's own text already carries each figure's `` reference
|
| 75 |
+
inline, at the position the document put it. This resolves those references so
|
| 76 |
+
the model can actually use them — before 0.4.0 a figure was an unreachable
|
| 77 |
+
chunk with empty text, and its vision-model description never reached a prompt
|
| 78 |
+
at all.
|
| 79 |
+
|
| 80 |
+
**The verbatim/generated split is labelled, not merely recorded.** A caption is
|
| 81 |
+
printed in the document, so it is quotable as evidence and the span check can
|
| 82 |
+
locate it. A description is a model's claim about a picture: it cannot be
|
| 83 |
+
span-checked against anything, so quoting it would produce a field that the
|
| 84 |
+
validator then nulls — wasting the call and looking like a model failure. The
|
| 85 |
+
label is what stops that.
|
| 86 |
+
"""
|
| 87 |
+
if not chunk.assets:
|
| 88 |
+
return ""
|
| 89 |
+
lines = ["", "ASSETS REFERENCED BY THIS CHUNK"]
|
| 90 |
+
for asset in chunk.assets:
|
| 91 |
+
where = f" · page {asset.page_no}" if asset.page_no else ""
|
| 92 |
+
lines.append(f" [asset://{asset.asset_id} · {asset.kind}{where}]")
|
| 93 |
+
if asset.caption:
|
| 94 |
+
lines.append(f" caption (VERBATIM, quotable as evidence): {asset.caption}")
|
| 95 |
+
if asset.description:
|
| 96 |
+
lines.append(
|
| 97 |
+
" description (MODEL-GENERATED, not from the document — "
|
| 98 |
+
"do NOT quote as evidence):"
|
| 99 |
+
)
|
| 100 |
+
lines.append(f" {asset.description}")
|
| 101 |
+
if not asset.caption and not asset.description:
|
| 102 |
+
lines.append(" (no caption or description available)")
|
| 103 |
+
return "\n".join(lines)
|
|
@@ -49,7 +49,12 @@ def _evidence_for(
|
|
| 49 |
cluster: TermCluster, chunks: list[Chunk], k: int, round_index: int
|
| 50 |
) -> tuple[list[Chunk], list[float]]:
|
| 51 |
by_id = {c.chunk_id: c for c in chunks}
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
scores = cluster.evidence_scores[round_index * k : round_index * k + k]
|
| 54 |
return [by_id[i] for i in ids if i in by_id], scores
|
| 55 |
|
|
|
|
| 49 |
cluster: TermCluster, chunks: list[Chunk], k: int, round_index: int
|
| 50 |
) -> tuple[list[Chunk], list[float]]:
|
| 51 |
by_id = {c.chunk_id: c for c in chunks}
|
| 52 |
+
# `chunks` is passed so a linked partner (0.4.0 table pointers) joins the
|
| 53 |
+
# window. `service` passes it to `top_k` too, so the prompt and the span-check
|
| 54 |
+
# source stay identical — that equality is load-bearing, not incidental.
|
| 55 |
+
ids = top_k(cluster, k=k, round_index=round_index, chunks=chunks)
|
| 56 |
+
# Ranked scores only. A partner was LINKED in, not ranked in, so it has no
|
| 57 |
+
# score — `evidence_block` shows none for it, which is the honest rendering.
|
| 58 |
scores = cluster.evidence_scores[round_index * k : round_index * k + k]
|
| 59 |
return [by_id[i] for i in ids if i in by_id], scores
|
| 60 |
|
|
@@ -70,4 +70,15 @@ COMMON MISTAKES TO AVOID:
|
|
| 70 |
- Do NOT put units, table captions, or figure labels in the definition field.
|
| 71 |
- If the evidence contains only a formula and no prose, set definition to null.
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
Return a single JSON object. No prose, no markdown fence.
|
|
|
|
| 70 |
- Do NOT put units, table captions, or figure labels in the definition field.
|
| 71 |
- If the evidence contains only a formula and no prose, set definition to null.
|
| 72 |
|
| 73 |
+
FIGURES AND TABLES (asset references):
|
| 74 |
+
- Evidence may contain `` markers and an "ASSETS REFERENCED BY THIS
|
| 75 |
+
CHUNK" section. The marker is where the document placed a figure.
|
| 76 |
+
- A caption marked VERBATIM is printed in the document. You MAY quote it, and you may
|
| 77 |
+
use it as a provenance span.
|
| 78 |
+
- A description marked MODEL-GENERATED is another model's claim about a picture. You
|
| 79 |
+
may read it for orientation, but you must NOT quote it and must NOT use it as a
|
| 80 |
+
provenance span — it does not appear in the document, so any span taken from it is
|
| 81 |
+
rejected and the field is discarded.
|
| 82 |
+
- Never treat the `asset://` marker itself as part of a definition or a span.
|
| 83 |
+
|
| 84 |
Return a single JSON object. No prose, no markdown fence.
|
|
@@ -54,4 +54,15 @@ corpus, so neither an answer nor a NON-answer for a real document leaks into thi
|
|
| 54 |
prompt. Keep it that way — an example lifted from the corpus either hands the
|
| 55 |
model a gold answer or forbids one.
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
Return a single JSON object. No prose, no markdown fence.
|
|
|
|
| 54 |
prompt. Keep it that way — an example lifted from the corpus either hands the
|
| 55 |
model a gold answer or forbids one.
|
| 56 |
|
| 57 |
+
FIGURES AND TABLES (asset references):
|
| 58 |
+
- Evidence may contain `` markers and an "ASSETS REFERENCED BY THIS
|
| 59 |
+
CHUNK" section. The marker is where the document placed a figure.
|
| 60 |
+
- A caption marked VERBATIM is printed in the document. You MAY quote it, and you may
|
| 61 |
+
use it as a provenance span.
|
| 62 |
+
- A description marked MODEL-GENERATED is another model's claim about a picture. You
|
| 63 |
+
may read it for orientation, but you must NOT quote it and must NOT use it as a
|
| 64 |
+
provenance span — it does not appear in the document, so any span taken from it is
|
| 65 |
+
rejected and the field is discarded.
|
| 66 |
+
- Never treat the `asset://` marker itself as part of a definition or a span.
|
| 67 |
+
|
| 68 |
Return a single JSON object. No prose, no markdown fence.
|
|
@@ -28,6 +28,10 @@ from functools import lru_cache
|
|
| 28 |
from ...middlewares.logging import get_logger
|
| 29 |
from ..models import Chunk, Mention
|
| 30 |
from ..settings import (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
SPAN_TOKEN_CAP,
|
| 32 |
WINDOW_OVERLAP,
|
| 33 |
WINDOW_WORDS,
|
|
@@ -74,10 +78,19 @@ def extract_mentions(
|
|
| 74 |
|
| 75 |
mentions: list[Mention] = []
|
| 76 |
capped = 0
|
|
|
|
| 77 |
started = time.time()
|
|
|
|
| 78 |
|
| 79 |
for chunk in chunks:
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
try:
|
| 82 |
found = model.predict_entities(
|
| 83 |
window_text, labels, threshold=score_floor
|
|
@@ -113,11 +126,100 @@ def extract_mentions(
|
|
| 113 |
mentions=len(deduped),
|
| 114 |
dropped_overlapping=len(mentions) - len(deduped),
|
| 115 |
hit_span_cap=capped,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
seconds=round(time.time() - started, 1),
|
| 117 |
)
|
| 118 |
return deduped
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def _windows(text: str) -> list[tuple[str, int]]:
|
| 122 |
"""Overlapping word windows plus each window's character offset.
|
| 123 |
|
|
|
|
| 28 |
from ...middlewares.logging import get_logger
|
| 29 |
from ..models import Chunk, Mention
|
| 30 |
from ..settings import (
|
| 31 |
+
SPAN_FALLBACK_CHARS_PER_TOKEN,
|
| 32 |
+
SPAN_MAX_SPLIT_DEPTH,
|
| 33 |
+
SPAN_SUBWORD_CAP,
|
| 34 |
+
SPAN_SUBWORD_MARGIN,
|
| 35 |
SPAN_TOKEN_CAP,
|
| 36 |
WINDOW_OVERLAP,
|
| 37 |
WINDOW_WORDS,
|
|
|
|
| 78 |
|
| 79 |
mentions: list[Mention] = []
|
| 80 |
capped = 0
|
| 81 |
+
split = 0
|
| 82 |
started = time.time()
|
| 83 |
+
fits = _token_fitter(model)
|
| 84 |
|
| 85 |
for chunk in chunks:
|
| 86 |
+
windows = _windows(chunk.text)
|
| 87 |
+
budgeted: list[tuple[str, int]] = []
|
| 88 |
+
for text, offset in windows:
|
| 89 |
+
pieces = _fit_to_budget(text, offset, fits)
|
| 90 |
+
split += len(pieces) - 1
|
| 91 |
+
budgeted.extend(pieces)
|
| 92 |
+
|
| 93 |
+
for window_text, offset in budgeted:
|
| 94 |
try:
|
| 95 |
found = model.predict_entities(
|
| 96 |
window_text, labels, threshold=score_floor
|
|
|
|
| 126 |
mentions=len(deduped),
|
| 127 |
dropped_overlapping=len(mentions) - len(deduped),
|
| 128 |
hit_span_cap=capped,
|
| 129 |
+
# Windows that exceeded the model's subword budget and were split rather
|
| 130 |
+
# than silently truncated. Expected to be small and non-zero: 1 of 368
|
| 131 |
+
# on the three reference documents. A sudden rise means the corpus got
|
| 132 |
+
# denser, not that something broke.
|
| 133 |
+
split_over_budget=split,
|
| 134 |
seconds=round(time.time() - started, 1),
|
| 135 |
)
|
| 136 |
return deduped
|
| 137 |
|
| 138 |
|
| 139 |
+
def _token_fitter(model):
|
| 140 |
+
"""A `fits(text) -> bool` using the model's OWN tokeniser where possible.
|
| 141 |
+
|
| 142 |
+
The word window is a proxy for the thing that actually matters — the
|
| 143 |
+
subword count the transformer truncates at — and the two diverge by text
|
| 144 |
+
density. Asking the tokeniser removes the proxy.
|
| 145 |
+
|
| 146 |
+
Falls back to a character budget if the attribute has moved (a GLiNER
|
| 147 |
+
version change), erring toward over-splitting: a split window costs a little
|
| 148 |
+
context at one boundary, while a truncated one loses its tail entirely and
|
| 149 |
+
says nothing about it.
|
| 150 |
+
"""
|
| 151 |
+
tokenizer = None
|
| 152 |
+
for path in ("data_processor.transformer_tokenizer", "data_processor.tokenizer"):
|
| 153 |
+
probe = model
|
| 154 |
+
try:
|
| 155 |
+
for part in path.split("."):
|
| 156 |
+
probe = getattr(probe, part)
|
| 157 |
+
except AttributeError:
|
| 158 |
+
continue
|
| 159 |
+
if hasattr(probe, "tokenize"):
|
| 160 |
+
tokenizer = probe
|
| 161 |
+
break
|
| 162 |
+
|
| 163 |
+
budget_chars = int(SPAN_SUBWORD_CAP * SPAN_FALLBACK_CHARS_PER_TOKEN)
|
| 164 |
+
if tokenizer is None:
|
| 165 |
+
logger.warning(
|
| 166 |
+
"span tokeniser not found; falling back to a character budget",
|
| 167 |
+
budget_chars=budget_chars,
|
| 168 |
+
)
|
| 169 |
+
return lambda text: len(text) <= budget_chars
|
| 170 |
+
|
| 171 |
+
limit = int(SPAN_SUBWORD_CAP * SPAN_SUBWORD_MARGIN)
|
| 172 |
+
|
| 173 |
+
def fits(text: str) -> bool:
|
| 174 |
+
try:
|
| 175 |
+
return len(tokenizer.tokenize(text)) <= limit
|
| 176 |
+
except Exception as exc: # noqa: BLE001 - never break the widest net
|
| 177 |
+
logger.warning("span tokenise failed", error=repr(exc))
|
| 178 |
+
return len(text) <= budget_chars
|
| 179 |
+
|
| 180 |
+
return fits
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _fit_to_budget(
|
| 184 |
+
text: str, offset: int, fits, depth: int = 0
|
| 185 |
+
) -> list[tuple[str, int]]:
|
| 186 |
+
"""Halve a window until every piece fits the model's subword budget.
|
| 187 |
+
|
| 188 |
+
Slices the ORIGINAL text rather than re-joining words, so character offsets
|
| 189 |
+
stay exact — the same reason `_windows` walks positions. An off-by-one here
|
| 190 |
+
would fail every span check downstream, and it would look like a bad model.
|
| 191 |
+
|
| 192 |
+
The halves overlap by `SPAN_TOKEN_CAP` words — the longest span the filter
|
| 193 |
+
can emit — which is exactly enough that no term can straddle the cut and be
|
| 194 |
+
lost, and no more. (`WINDOW_OVERLAP`, the top-level overlap, is 30 words and
|
| 195 |
+
would make the right half LARGER than the left on a 130-word window, so the
|
| 196 |
+
split would not converge.) The overlap re-finds terms, and `_dedupe` removes
|
| 197 |
+
them exactly, keyed on position.
|
| 198 |
+
"""
|
| 199 |
+
if fits(text) or depth >= SPAN_MAX_SPLIT_DEPTH:
|
| 200 |
+
return [(text, offset)]
|
| 201 |
+
|
| 202 |
+
cut = text.rfind(" ", 0, len(text) // 2 + 1)
|
| 203 |
+
if cut <= 0:
|
| 204 |
+
# One enormous unbroken token. Nothing to split on; the model will
|
| 205 |
+
# truncate it, and there is no representation that avoids that.
|
| 206 |
+
return [(text, offset)]
|
| 207 |
+
|
| 208 |
+
left = text[:cut]
|
| 209 |
+
# Start the right half SPAN_TOKEN_CAP words back, so the boundary is covered.
|
| 210 |
+
back = cut
|
| 211 |
+
for _ in range(SPAN_TOKEN_CAP):
|
| 212 |
+
previous = text.rfind(" ", 0, back)
|
| 213 |
+
if previous <= 0:
|
| 214 |
+
break
|
| 215 |
+
back = previous
|
| 216 |
+
right_start = back + 1 if back > 0 else cut + 1
|
| 217 |
+
|
| 218 |
+
return _fit_to_budget(left, offset, fits, depth + 1) + _fit_to_budget(
|
| 219 |
+
text[right_start:], offset + right_start, fits, depth + 1
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
def _windows(text: str) -> list[tuple[str, int]]:
|
| 224 |
"""Overlapping word windows plus each window's character offset.
|
| 225 |
|
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 5 — what KIND of document is this, decided before anything is spent.
|
| 2 |
+
|
| 3 |
+
The pipeline was calibrated on one genre (an Indonesian mining standard) and is
|
| 4 |
+
now handed three. The failure is not subtle: on the Komatsu shop manual the
|
| 5 |
+
glossary branch burns ~104 calls to abstain 93 times, because a troubleshooting
|
| 6 |
+
manual states symptoms and remedies, not definitions. No amount of prompt work
|
| 7 |
+
reaches that — it is the wrong question asked of the wrong document.
|
| 8 |
+
|
| 9 |
+
Four signals separate the three genres cleanly, and all four are already
|
| 10 |
+
computed for free before the paid stage:
|
| 11 |
+
|
| 12 |
+
heading coverage tabular ratio abbrev pairs rule density
|
| 13 |
+
BUMA standard 41% 25% 5 0.50
|
| 14 |
+
Komatsu shop manual 1% 69% 0 0.01
|
| 15 |
+
Open Pit textbook 63% 5% 0 1.10
|
| 16 |
+
|
| 17 |
+
They do not merely differ, they differ in DIFFERENT DIRECTIONS, which is what
|
| 18 |
+
makes a rule-based classifier honest here rather than a model call: a shop
|
| 19 |
+
manual is mostly tables with no headings, a textbook is mostly headed prose, a
|
| 20 |
+
standard sits between them and is the only one that defines its abbreviations.
|
| 21 |
+
|
| 22 |
+
**What this module does NOT do yet, and why.** Classification is on; acting on
|
| 23 |
+
it is not. Both levers below default to off because neither can be justified by
|
| 24 |
+
a measurement today, and shipping an unmeasured recall change is how the
|
| 25 |
+
clustering defect survived for months:
|
| 26 |
+
|
| 27 |
+
* `ROUTE_LABELS` — switching the GLiNER label set per genre. The `diagnostic`
|
| 28 |
+
and `conceptual` sets produce far more mentions on their genres (2.5x on
|
| 29 |
+
Komatsu), but only BUMA has a gold set, so "more mentions" cannot be shown to
|
| 30 |
+
be "better recall". Unlocked by: a gold set for a second document.
|
| 31 |
+
* `ROUTE_BRANCHES` — skipping branches that do not fit. Measured on exactly one
|
| 32 |
+
document (Komatsu: 0 definitions across two paid runs, as predicted), which
|
| 33 |
+
is a prediction confirmed once, not a rule. Unlocked by: a second diagnostic
|
| 34 |
+
document behaving the same way, or Rifqi accepting one document as enough.
|
| 35 |
+
|
| 36 |
+
Until then this is observability: the verdict and its warnings are recorded, an
|
| 37 |
+
operator can see why a document is expensive and unproductive, and turning
|
| 38 |
+
either lever on is a one-line change with the reasoning already written down.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
from __future__ import annotations
|
| 42 |
+
|
| 43 |
+
from dataclasses import dataclass, field
|
| 44 |
+
|
| 45 |
+
# Both default OFF. See the module docstring for what unlocks each.
|
| 46 |
+
ROUTE_LABELS = False
|
| 47 |
+
ROUTE_BRANCHES = False
|
| 48 |
+
|
| 49 |
+
# Thresholds sit between the measured clusters rather than on top of any one of
|
| 50 |
+
# them, so a fourth document of a known genre is likely to land correctly.
|
| 51 |
+
# Widely separated by construction — see the table above.
|
| 52 |
+
HEADING_COVERAGE_LOW = 0.10
|
| 53 |
+
HEADING_COVERAGE_HIGH = 0.50
|
| 54 |
+
TABULAR_RATIO_HIGH = 0.50
|
| 55 |
+
TABULAR_RATIO_LOW = 0.20
|
| 56 |
+
RULE_DENSITY_LOW = 0.10
|
| 57 |
+
|
| 58 |
+
# Below this, the document's own structure is missing and every heading-derived
|
| 59 |
+
# signal downstream degrades: `source_wording` (read off headings), the
|
| 60 |
+
# `term_in_heading` ranking weight, and `DocumentContext.outline`. Komatsu has
|
| 61 |
+
# exactly ONE headed chunk of 85, and nothing warned about it.
|
| 62 |
+
HEADING_COVERAGE_WARN = 0.10
|
| 63 |
+
|
| 64 |
+
GENRE_LABELS = {
|
| 65 |
+
"standard": "broad",
|
| 66 |
+
"diagnostic": "diagnostic",
|
| 67 |
+
"conceptual": "conceptual",
|
| 68 |
+
"unknown": "broad",
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# Which branches earn their spend on each genre. `unknown` runs everything —
|
| 72 |
+
# not knowing the genre is not a reason to extract less.
|
| 73 |
+
GENRE_BRANCHES: dict[str, tuple[str, ...]] = {
|
| 74 |
+
"standard": ("glossary", "rule", "formula", "summary"),
|
| 75 |
+
# A shop manual defines nothing. What it holds is a symptom x cause matrix
|
| 76 |
+
# across 59 tables, and the branch that reads one does not exist yet
|
| 77 |
+
# (Step 8) — so today the honest answer is "almost nothing here fits".
|
| 78 |
+
"diagnostic": ("summary",),
|
| 79 |
+
"conceptual": ("glossary", "rule", "summary"),
|
| 80 |
+
"unknown": ("glossary", "rule", "formula", "summary"),
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class GenreSignals:
|
| 86 |
+
n_chunks: int
|
| 87 |
+
heading_coverage: float
|
| 88 |
+
tabular_ratio: float
|
| 89 |
+
abbrev_pairs: int
|
| 90 |
+
rule_density: float
|
| 91 |
+
|
| 92 |
+
def as_dict(self) -> dict:
|
| 93 |
+
return {
|
| 94 |
+
"n_chunks": self.n_chunks,
|
| 95 |
+
"heading_coverage": round(self.heading_coverage, 3),
|
| 96 |
+
"tabular_ratio": round(self.tabular_ratio, 3),
|
| 97 |
+
"abbrev_pairs": self.abbrev_pairs,
|
| 98 |
+
"rule_density": round(self.rule_density, 3),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@dataclass
|
| 103 |
+
class GenreVerdict:
|
| 104 |
+
genre: str
|
| 105 |
+
signals: GenreSignals
|
| 106 |
+
reasons: list[str] = field(default_factory=list)
|
| 107 |
+
warnings: list[str] = field(default_factory=list)
|
| 108 |
+
|
| 109 |
+
@property
|
| 110 |
+
def labels_variant(self) -> str:
|
| 111 |
+
"""The label set this genre wants — advisory unless `ROUTE_LABELS`."""
|
| 112 |
+
return GENRE_LABELS.get(self.genre, "broad")
|
| 113 |
+
|
| 114 |
+
@property
|
| 115 |
+
def branches(self) -> tuple[str, ...]:
|
| 116 |
+
"""The branches that fit — advisory unless `ROUTE_BRANCHES`."""
|
| 117 |
+
return GENRE_BRANCHES.get(self.genre, GENRE_BRANCHES["unknown"])
|
| 118 |
+
|
| 119 |
+
@property
|
| 120 |
+
def well_served(self) -> bool:
|
| 121 |
+
"""Is this a document the pipeline can actually do something with?
|
| 122 |
+
|
| 123 |
+
False is not a failure — it is the honest answer for a shop manual
|
| 124 |
+
today, and it is worth saying BEFORE spending ~104 calls to find out.
|
| 125 |
+
"""
|
| 126 |
+
return self.genre != "diagnostic"
|
| 127 |
+
|
| 128 |
+
def as_dict(self) -> dict:
|
| 129 |
+
return {
|
| 130 |
+
"genre": self.genre,
|
| 131 |
+
"labels_variant": self.labels_variant,
|
| 132 |
+
"branches": list(self.branches),
|
| 133 |
+
"well_served": self.well_served,
|
| 134 |
+
"routing_applied": {"labels": ROUTE_LABELS, "branches": ROUTE_BRANCHES},
|
| 135 |
+
"signals": self.signals.as_dict(),
|
| 136 |
+
"reasons": self.reasons,
|
| 137 |
+
"warnings": self.warnings,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def measure(doc, filtered) -> GenreSignals:
|
| 142 |
+
"""Four free signals off the parsed artifact and the free filter stage."""
|
| 143 |
+
chunks = doc.chunks or []
|
| 144 |
+
n = len(chunks) or 1
|
| 145 |
+
return GenreSignals(
|
| 146 |
+
n_chunks=len(chunks),
|
| 147 |
+
heading_coverage=sum(1 for c in chunks if (c.heading or "").strip()) / n,
|
| 148 |
+
tabular_ratio=sum(1 for c in chunks if c.is_tabular) / n,
|
| 149 |
+
abbrev_pairs=len(filtered.abbrev_pairs),
|
| 150 |
+
rule_density=len(filtered.rule_candidates) / n,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def classify(doc, filtered) -> GenreVerdict:
|
| 155 |
+
"""Name the genre, or say `unknown` rather than guess.
|
| 156 |
+
|
| 157 |
+
Deliberately rule-based and deliberately conservative. A misclassification
|
| 158 |
+
that routes a document to the wrong label set costs recall silently, which
|
| 159 |
+
is the one failure mode this pipeline has repeatedly proven bad at noticing
|
| 160 |
+
— so an unclear document gets `unknown`, which routes to exactly today's
|
| 161 |
+
behaviour and spends nothing extra.
|
| 162 |
+
"""
|
| 163 |
+
signals = measure(doc, filtered)
|
| 164 |
+
reasons: list[str] = []
|
| 165 |
+
|
| 166 |
+
if (
|
| 167 |
+
signals.tabular_ratio >= TABULAR_RATIO_HIGH
|
| 168 |
+
and signals.heading_coverage <= HEADING_COVERAGE_LOW
|
| 169 |
+
and signals.rule_density <= RULE_DENSITY_LOW
|
| 170 |
+
):
|
| 171 |
+
genre = "diagnostic"
|
| 172 |
+
reasons.append(
|
| 173 |
+
f"{signals.tabular_ratio:.0%} tabular with {signals.heading_coverage:.0%} "
|
| 174 |
+
"headed and almost no rule language — a symptom/remedy matrix, not prose"
|
| 175 |
+
)
|
| 176 |
+
elif (
|
| 177 |
+
signals.heading_coverage >= HEADING_COVERAGE_HIGH
|
| 178 |
+
and signals.tabular_ratio <= TABULAR_RATIO_LOW
|
| 179 |
+
):
|
| 180 |
+
genre = "conceptual"
|
| 181 |
+
reasons.append(
|
| 182 |
+
f"{signals.heading_coverage:.0%} of chunks are headed and only "
|
| 183 |
+
f"{signals.tabular_ratio:.0%} tabular — structured expository prose"
|
| 184 |
+
)
|
| 185 |
+
elif signals.abbrev_pairs >= 3 and signals.heading_coverage > HEADING_COVERAGE_LOW:
|
| 186 |
+
genre = "standard"
|
| 187 |
+
reasons.append(
|
| 188 |
+
f"{signals.abbrev_pairs} abbreviation definitions in headed prose — "
|
| 189 |
+
"a document that names its own terms"
|
| 190 |
+
)
|
| 191 |
+
else:
|
| 192 |
+
genre = "unknown"
|
| 193 |
+
reasons.append(
|
| 194 |
+
"no genre signature matched; routing is unchanged and nothing is skipped"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
warnings: list[str] = []
|
| 198 |
+
if signals.heading_coverage < HEADING_COVERAGE_WARN:
|
| 199 |
+
warnings.append(
|
| 200 |
+
f"heading coverage is {signals.heading_coverage:.0%} "
|
| 201 |
+
f"({round(signals.heading_coverage * signals.n_chunks)} of "
|
| 202 |
+
f"{signals.n_chunks} chunks): `source_wording`, the term_in_heading "
|
| 203 |
+
"ranking weight and the document outline all degrade together, and "
|
| 204 |
+
"nothing downstream can tell that from a document with no terms"
|
| 205 |
+
)
|
| 206 |
+
if genre == "diagnostic":
|
| 207 |
+
warnings.append(
|
| 208 |
+
"diagnostic documents define almost nothing — the glossary branch is "
|
| 209 |
+
"expected to abstain on nearly every cluster. The content is in the "
|
| 210 |
+
"tables, and the branch that reads them is not built (Step 8)"
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
return GenreVerdict(genre=genre, signals=signals, reasons=reasons, warnings=warnings)
|
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Readable concept keys — `TYPE_SUBJECT`, the label a person or an agent searches by.
|
| 2 |
+
|
| 3 |
+
Asked for in the 2026-09-08 discussion (m1). The shape is the one agreed there:
|
| 4 |
+
the type first, then what the entry is *about*, in capitals — `GLOSSARY_PA`,
|
| 5 |
+
never the entry's full text. Two properties it exists to provide:
|
| 6 |
+
|
| 7 |
+
- **One concept, one key.** `PA` drawn from five documents still reads
|
| 8 |
+
`GLOSSARY_PA`, so an agent that needs a term's definition writes the key and
|
| 9 |
+
looks it up directly — no scan, no embedding, no model call.
|
| 10 |
+
- **Readable at a glance.** Capitals were a deliberate call (lowercase reads
|
| 11 |
+
worse in a list), and lookups are capitals too.
|
| 12 |
+
|
| 13 |
+
**A key is a label, never an identity — this is m2.** `entity_id` in `ids.py`
|
| 14 |
+
stays the thing an expert's approval hangs on, because it is a hash of content
|
| 15 |
+
and survives a re-run untouched. A key is built from a *surface*, so it moves
|
| 16 |
+
the moment clustering canonicalises that surface differently. Both travel
|
| 17 |
+
together and neither substitutes for the other: the hash identifies, the key
|
| 18 |
+
reads. Collapsing the two is exactly how approvals get orphaned, which is the
|
| 19 |
+
failure `ids.py` was written to prevent.
|
| 20 |
+
|
| 21 |
+
Prefixes use the `kind` column's own vocabulary (English, singular) so the
|
| 22 |
+
system holds one spelling of each branch rather than two to keep in sync
|
| 23 |
+
(decided 2026-09-14).
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import hashlib
|
| 29 |
+
import re
|
| 30 |
+
import unicodedata
|
| 31 |
+
from collections.abc import Iterable, Mapping
|
| 32 |
+
from typing import Any
|
| 33 |
+
|
| 34 |
+
# Keyed by the `kind` column's values, so a new kind that forgets to register
|
| 35 |
+
# here returns None rather than silently inventing a prefix.
|
| 36 |
+
_PREFIX: dict[str, str] = {
|
| 37 |
+
"glossary": "GLOSSARY",
|
| 38 |
+
"rule": "RULE",
|
| 39 |
+
"formula": "FORMULA",
|
| 40 |
+
"document": "DOCUMENT",
|
| 41 |
+
"domain": "DOMAIN",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# Past this a subject stops being a label and becomes the content it was meant
|
| 45 |
+
# to stand in for. A rule's `statement` can be a whole paragraph.
|
| 46 |
+
MAX_SUBJECT = 40
|
| 47 |
+
|
| 48 |
+
# Short, and deliberately NOT the full `entity_id`: this digest separates two
|
| 49 |
+
# rules that govern the same term. It does not identify the row — `entity_id`
|
| 50 |
+
# does, and shortening a hash for readability is only safe because of that.
|
| 51 |
+
DIGEST_LEN = 6
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def slugify(surface: str) -> str:
|
| 55 |
+
"""`Physical of Availability (PA)` -> `PHYSICAL_OF_AVAILABILITY_PA`.
|
| 56 |
+
|
| 57 |
+
Accents are folded rather than dropped so an Indonesian or a borrowed term
|
| 58 |
+
keeps its letters. Everything that is not a letter or a digit becomes a
|
| 59 |
+
single underscore, because a key that carries brackets or slashes cannot be
|
| 60 |
+
typed from memory — which is the whole point of having one.
|
| 61 |
+
"""
|
| 62 |
+
folded = unicodedata.normalize("NFKD", surface or "")
|
| 63 |
+
folded = "".join(c for c in folded if not unicodedata.combining(c))
|
| 64 |
+
slug = re.sub(r"[^A-Za-z0-9]+", "_", folded).upper()
|
| 65 |
+
slug = re.sub(r"_+", "_", slug).strip("_")
|
| 66 |
+
if len(slug) <= MAX_SUBJECT:
|
| 67 |
+
return slug
|
| 68 |
+
# Cut on a word boundary so the tail is not half a word — but a single long
|
| 69 |
+
# token has no boundary to cut on, and an empty subject is worse than a
|
| 70 |
+
# blunt one.
|
| 71 |
+
cut = slug[:MAX_SUBJECT]
|
| 72 |
+
head, _, _tail = cut.rpartition("_")
|
| 73 |
+
return (head or cut).strip("_")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _digest(value: str) -> str:
|
| 77 |
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:DIGEST_LEN].upper()
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def term_surfaces(glossary_payloads: Iterable[Mapping[str, Any]] | None) -> dict[str, str]:
|
| 81 |
+
"""`term_id` -> the term's surface, for keying the rules that govern it.
|
| 82 |
+
|
| 83 |
+
Built by the caller from the same run's glossary payloads, because a rule
|
| 84 |
+
payload carries `term_ids` and no surfaces — the words live on the glossary
|
| 85 |
+
entry, not on the rule.
|
| 86 |
+
"""
|
| 87 |
+
out: dict[str, str] = {}
|
| 88 |
+
for payload in glossary_payloads or []:
|
| 89 |
+
term_id = payload.get("term_id")
|
| 90 |
+
term = payload.get("term")
|
| 91 |
+
if term_id and isinstance(term, str) and term.strip():
|
| 92 |
+
out[str(term_id)] = term.strip()
|
| 93 |
+
return out
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _rule_subject(
|
| 97 |
+
payload: Mapping[str, Any], entity_id: str, surfaces: Mapping[str, str]
|
| 98 |
+
) -> str:
|
| 99 |
+
"""A rule has no canonical surface of its own — it is a sentence.
|
| 100 |
+
|
| 101 |
+
So it borrows the term it governs and adds a short digest, because one term
|
| 102 |
+
usually governs several rules: `RULE_PA_C07BE4`. Asking the model to name
|
| 103 |
+
the rule was the obvious alternative and is the one `ids.py` already rules
|
| 104 |
+
out — a model cannot produce a stable identifier, and a model-written
|
| 105 |
+
`rule_id` was a real defect here, returning a different slug for the same
|
| 106 |
+
rule on consecutive runs at temperature 0. A rule that governs no term
|
| 107 |
+
keeps the digest alone; that it is not searchable by word was accepted when
|
| 108 |
+
this was agreed.
|
| 109 |
+
"""
|
| 110 |
+
for term_id in payload.get("term_ids") or []:
|
| 111 |
+
surface = slugify(surfaces.get(str(term_id), ""))
|
| 112 |
+
if surface:
|
| 113 |
+
return f"{surface}_{_digest(entity_id)}"
|
| 114 |
+
return _digest(entity_id)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _subject(
|
| 118 |
+
kind: str,
|
| 119 |
+
payload: Mapping[str, Any],
|
| 120 |
+
entity_id: str,
|
| 121 |
+
doc_id: str,
|
| 122 |
+
surfaces: Mapping[str, str],
|
| 123 |
+
) -> str:
|
| 124 |
+
if kind == "glossary":
|
| 125 |
+
return slugify(payload.get("term") or "")
|
| 126 |
+
if kind == "formula":
|
| 127 |
+
# A formula may legitimately have no name; `entity_id` already falls
|
| 128 |
+
# back name -> latex -> chunk_id, so the digest inherits that work.
|
| 129 |
+
return slugify(payload.get("name") or "") or _digest(entity_id)
|
| 130 |
+
if kind == "document":
|
| 131 |
+
# The document's own id reads better than its title. A title is a
|
| 132 |
+
# verbatim transcription and routinely carries the PDF's broken
|
| 133 |
+
# spacing — correct to store, useless to type.
|
| 134 |
+
return slugify(doc_id) or _digest(entity_id)
|
| 135 |
+
if kind == "domain":
|
| 136 |
+
return slugify(payload.get("domain_name") or "")
|
| 137 |
+
if kind == "rule":
|
| 138 |
+
return _rule_subject(payload, entity_id, surfaces)
|
| 139 |
+
return ""
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def concept_key(
|
| 143 |
+
kind: str,
|
| 144 |
+
payload: Mapping[str, Any],
|
| 145 |
+
*,
|
| 146 |
+
entity_id: str,
|
| 147 |
+
doc_id: str = "",
|
| 148 |
+
surfaces: Mapping[str, str] | None = None,
|
| 149 |
+
) -> str | None:
|
| 150 |
+
"""The `TYPE_SUBJECT` key for one entry; None when the kind is unregistered.
|
| 151 |
+
|
| 152 |
+
Never raises: the caller writes this into a row alongside content it has
|
| 153 |
+
already validated, and a key that cannot be built is a degraded label, not
|
| 154 |
+
a reason to lose the entry.
|
| 155 |
+
"""
|
| 156 |
+
prefix = _PREFIX.get(kind)
|
| 157 |
+
if prefix is None:
|
| 158 |
+
return None
|
| 159 |
+
subject = _subject(kind, payload, entity_id, doc_id, surfaces or {})
|
| 160 |
+
return f"{prefix}_{subject}" if subject else prefix
|
|
@@ -67,6 +67,30 @@ class SubdomainEnum(str, Enum):
|
|
| 67 |
# ── Stage 1: the chunk (internal view of the seam artifact) ─────────────
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
class Chunk(BaseModel):
|
| 71 |
"""One unit of a parsed document, as the extraction stages need it.
|
| 72 |
|
|
@@ -98,6 +122,16 @@ class Chunk(BaseModel):
|
|
| 98 |
# it on the floor at the adapter — the same way it once dropped `latex`.
|
| 99 |
heading_path: list[str] = Field(default_factory=list)
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
# Cheap downstream filters / ranking signals
|
| 102 |
has_formula: bool = False
|
| 103 |
is_tabular: bool = False
|
|
@@ -115,6 +149,14 @@ class Chunk(BaseModel):
|
|
| 115 |
# "fails verification" — see `validate/span_check.py`.
|
| 116 |
latex: list[str] = Field(default_factory=list)
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
class ParsedDoc(BaseModel):
|
| 120 |
"""A document's chunks plus the identity needed to version and cache them."""
|
|
|
|
| 67 |
# ── Stage 1: the chunk (internal view of the seam artifact) ─────────────
|
| 68 |
|
| 69 |
|
| 70 |
+
class ChunkAsset(BaseModel):
|
| 71 |
+
"""A figure or table referenced by a chunk. Crosses the seam at 0.4.0.
|
| 72 |
+
|
| 73 |
+
The subset of the parsing half's `Asset` that extraction actually uses. The
|
| 74 |
+
split between the two content fields is the whole point and must be preserved
|
| 75 |
+
into the prompt:
|
| 76 |
+
|
| 77 |
+
- `caption` is **verbatim document text**, so it is quotable as evidence and
|
| 78 |
+
the span check can locate it.
|
| 79 |
+
- `description` is **model-written** (a vision model's claim about the
|
| 80 |
+
picture). It is never span-checkable, never evidence, and must never be
|
| 81 |
+
copied into `Chunk.text` — `text` is the haystack the span check searches,
|
| 82 |
+
so generated prose in there would let a fabrication pass the control built
|
| 83 |
+
to catch it.
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
asset_id: str
|
| 87 |
+
kind: str
|
| 88 |
+
caption: str | None = None
|
| 89 |
+
description: str | None = None
|
| 90 |
+
page_no: int | None = None
|
| 91 |
+
storage_key: str | None = None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
class Chunk(BaseModel):
|
| 95 |
"""One unit of a parsed document, as the extraction stages need it.
|
| 96 |
|
|
|
|
| 122 |
# it on the floor at the adapter — the same way it once dropped `latex`.
|
| 123 |
heading_path: list[str] = Field(default_factory=list)
|
| 124 |
|
| 125 |
+
# Figures and tables this chunk carries or points at. New at schema 0.4.0.
|
| 126 |
+
# Before it, a figure was its own chunk with EMPTY text, which made it
|
| 127 |
+
# unreachable: no text -> no mentions -> no cluster -> never evidence. 13
|
| 128 |
+
# figures across three documents, each described by a vision model we paid
|
| 129 |
+
# for, and none ever reached a prompt.
|
| 130 |
+
assets: list[ChunkAsset] = Field(default_factory=list)
|
| 131 |
+
# chunk_ids pointing AT this chunk (set on table chunks, which stay
|
| 132 |
+
# standalone because their linearised text earns its evidence slot).
|
| 133 |
+
referenced_by: list[str] = Field(default_factory=list)
|
| 134 |
+
|
| 135 |
# Cheap downstream filters / ranking signals
|
| 136 |
has_formula: bool = False
|
| 137 |
is_tabular: bool = False
|
|
|
|
| 149 |
# "fails verification" — see `validate/span_check.py`.
|
| 150 |
latex: list[str] = Field(default_factory=list)
|
| 151 |
|
| 152 |
+
# Source table markup, kept beside the pipe-linearised `text`. Crosses the
|
| 153 |
+
# seam at 0.4.0; before that the parsing half emitted it and the adapter
|
| 154 |
+
# dropped it, so a symptom x cause matrix reached the model as a wall of
|
| 155 |
+
# pipe-delimited text with the row/column correspondence gone. `colspan` and
|
| 156 |
+
# `rowspan` live here, which is what a table branch needs to recover
|
| 157 |
+
# (row header, column header, cell) triples.
|
| 158 |
+
table_html: str | None = None
|
| 159 |
+
|
| 160 |
|
| 161 |
class ParsedDoc(BaseModel):
|
| 162 |
"""A document's chunks plus the identity needed to version and cache them."""
|
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Has an entry changed in a way that invalidates an expert's ruling? (i3)
|
| 2 |
+
|
| 3 |
+
From the 2026-09-08 discussion: *"kalau dia sudah termutate harusnya dia perlu
|
| 4 |
+
review lagi… kalau tidak berubah, tetap approved."* The whole difficulty is in
|
| 5 |
+
the second half. Flipping everything a new document touches is easy and useless
|
| 6 |
+
— a document that merely mentions `PA` again moves `mention_count`, and if that
|
| 7 |
+
counts as a mutation the expert comes back to forty rows where nothing has
|
| 8 |
+
actually changed, finds that out one row at a time, and stops trusting the queue.
|
| 9 |
+
|
| 10 |
+
So the comparison is deliberately narrow: **only the fields an expert ruled on**
|
| 11 |
+
(decided 2026-09-14). Everything the pipeline computes about an entry — how
|
| 12 |
+
often it was mentioned, how it was classified, where it was found — can move
|
| 13 |
+
freely without disturbing an approval, because none of it is what the expert was
|
| 14 |
+
looking at when they approved.
|
| 15 |
+
|
| 16 |
+
Whitespace is collapsed before comparing, so re-extraction that reflows a
|
| 17 |
+
definition does not re-open it. Any other difference counts: this stays
|
| 18 |
+
conservative, because missing a real change means serving knowledge an expert
|
| 19 |
+
never agreed to, and that is the failure mode this exists to prevent.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from collections.abc import Mapping
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
# What a reviewer is actually judging, per kind — the fields the review queue
|
| 28 |
+
# puts in front of them. Everything else on the entry is machinery.
|
| 29 |
+
RULED_FIELDS: dict[str, tuple[str, ...]] = {
|
| 30 |
+
"glossary": ("term", "full_name", "definition", "source_wording"),
|
| 31 |
+
"rule": ("statement", "condition", "consequence"),
|
| 32 |
+
"formula": ("name", "formula_latex", "unit"),
|
| 33 |
+
"document": ("title", "purpose_verbatim"),
|
| 34 |
+
"domain": ("domain_name", "purpose_verbatim"),
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# Pipeline bookkeeping. Named explicitly so the fallback below cannot mistake a
|
| 38 |
+
# recomputed count for a changed claim.
|
| 39 |
+
VOLATILE_FIELDS: frozenset[str] = frozenset(
|
| 40 |
+
{
|
| 41 |
+
"mention_count",
|
| 42 |
+
"diff_status",
|
| 43 |
+
"extraction_status",
|
| 44 |
+
"definition_conflict",
|
| 45 |
+
"conflict_variants",
|
| 46 |
+
"provenance",
|
| 47 |
+
"doc_id",
|
| 48 |
+
"doc_ids",
|
| 49 |
+
}
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _norm(value: Any) -> Any:
|
| 54 |
+
"""Collapse whitespace in strings; recurse into lists and dicts.
|
| 55 |
+
|
| 56 |
+
Reflowed prose is not a changed claim. Nothing else is normalised — each
|
| 57 |
+
further normalisation is a difference this would stop noticing.
|
| 58 |
+
"""
|
| 59 |
+
if isinstance(value, str):
|
| 60 |
+
return " ".join(value.split())
|
| 61 |
+
if isinstance(value, list | tuple):
|
| 62 |
+
return [_norm(v) for v in value]
|
| 63 |
+
if isinstance(value, Mapping):
|
| 64 |
+
return {k: _norm(v) for k, v in sorted(value.items())}
|
| 65 |
+
return value
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def ruled_content(kind: str, payload: Mapping[str, Any] | None) -> dict[str, Any]:
|
| 69 |
+
"""The subset of an entry that an approval actually covers.
|
| 70 |
+
|
| 71 |
+
An unregistered kind falls back to the whole payload minus the volatile
|
| 72 |
+
fields — conservative on purpose: a new kind should over-report changes
|
| 73 |
+
until someone decides what its reviewer is looking at, never under-report.
|
| 74 |
+
"""
|
| 75 |
+
payload = payload or {}
|
| 76 |
+
fields = RULED_FIELDS.get(kind)
|
| 77 |
+
if fields is not None:
|
| 78 |
+
return {field: _norm(payload.get(field)) for field in fields}
|
| 79 |
+
return {
|
| 80 |
+
key: _norm(value)
|
| 81 |
+
for key, value in sorted(payload.items())
|
| 82 |
+
if key not in VOLATILE_FIELDS
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def changed_fields(
|
| 87 |
+
kind: str,
|
| 88 |
+
reviewed: Mapping[str, Any] | None,
|
| 89 |
+
current: Mapping[str, Any] | None,
|
| 90 |
+
) -> list[str]:
|
| 91 |
+
"""Which ruled-on fields moved — the before/after a reviewer is shown (r4).
|
| 92 |
+
|
| 93 |
+
Same comparison as `has_mutated`, reported field by field so the queue can
|
| 94 |
+
say *what* changed instead of making the expert re-read the whole entry to
|
| 95 |
+
find out. That was the ask: show before / after, do not send them back to
|
| 96 |
+
the start.
|
| 97 |
+
"""
|
| 98 |
+
before = ruled_content(kind, reviewed)
|
| 99 |
+
after = ruled_content(kind, current)
|
| 100 |
+
return [field for field in before if before[field] != after.get(field)]
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def has_mutated(
|
| 104 |
+
kind: str,
|
| 105 |
+
reviewed: Mapping[str, Any] | None,
|
| 106 |
+
current: Mapping[str, Any] | None,
|
| 107 |
+
) -> bool:
|
| 108 |
+
"""True when `current` differs from what was approved, in ways that matter.
|
| 109 |
+
|
| 110 |
+
`reviewed` is what the expert had in front of them — for an `edited`
|
| 111 |
+
decision that is their corrected payload, not the model's original claim.
|
| 112 |
+
"""
|
| 113 |
+
return ruled_content(kind, reviewed) != ruled_content(kind, current)
|
|
@@ -22,6 +22,22 @@ on the entry for a consumer that wants them.
|
|
| 22 |
|
| 23 |
from __future__ import annotations
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def build_queue(entries: list[dict], domain: dict | None = None) -> list[dict]:
|
| 27 |
"""Rows an expert works through, most-decision-worthy first.
|
|
@@ -36,12 +52,12 @@ def build_queue(entries: list[dict], domain: dict | None = None) -> list[dict]:
|
|
| 36 |
entry.get("diff_status") == "conflicting"
|
| 37 |
or entry.get("definition_conflict") is True
|
| 38 |
)
|
| 39 |
-
return (0 if conflicting else
|
| 40 |
|
| 41 |
-
|
| 42 |
for entry in sorted(entries, key=sort_key):
|
| 43 |
provenance = entry.get("provenance") or {}
|
| 44 |
-
|
| 45 |
{
|
| 46 |
"row_kind": "term",
|
| 47 |
# What an approve/edit/reject decision attaches to. Without it a
|
|
@@ -57,21 +73,51 @@ def build_queue(entries: list[dict], domain: dict | None = None) -> list[dict]:
|
|
| 57 |
"section_no": provenance.get("section_no"),
|
| 58 |
"span": provenance.get("span"),
|
| 59 |
"review_reason": _reason(entry),
|
| 60 |
-
"
|
| 61 |
}
|
| 62 |
)
|
| 63 |
|
| 64 |
-
queue
|
| 65 |
-
|
| 66 |
-
# Ranked once, over both kinds. Unresolved parameters sit between conflicts
|
| 67 |
-
# and routine confirmations: a contradiction is still the more urgent call,
|
| 68 |
-
# but "the model named a parameter nothing in the document corroborates" is
|
| 69 |
-
# a judgement only the expert can make, and it should not sink beneath 60
|
| 70 |
-
# routine rows where nobody reaches it.
|
| 71 |
-
queue.sort(key=lambda row: row["_sort"])
|
| 72 |
for rank, row in enumerate(queue, start=1):
|
| 73 |
row["rank"] = rank
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
return queue
|
| 76 |
|
| 77 |
|
|
@@ -88,6 +134,7 @@ def _key_parameter_rows(domain: dict | None) -> list[dict]:
|
|
| 88 |
for parameter in (domain or {}).get("key_parameters") or []:
|
| 89 |
if parameter.get("term_id"):
|
| 90 |
continue
|
|
|
|
| 91 |
rows.append(
|
| 92 |
{
|
| 93 |
"row_kind": "key_parameter",
|
|
@@ -95,7 +142,12 @@ def _key_parameter_rows(domain: dict | None) -> list[dict]:
|
|
| 95 |
# there is no term_id, which is the entire point of the row.
|
| 96 |
"term_id": None,
|
| 97 |
"brief_id": (domain or {}).get("brief_id"),
|
| 98 |
-
"term":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
"definition": None,
|
| 100 |
"source_wording": None,
|
| 101 |
"mention_count": 0,
|
|
@@ -107,12 +159,38 @@ def _key_parameter_rows(domain: dict | None) -> list[dict]:
|
|
| 107 |
"named as a key parameter but no extracted term corroborates it "
|
| 108 |
"— confirm it is real"
|
| 109 |
),
|
| 110 |
-
"_sort": (1, 0),
|
| 111 |
}
|
| 112 |
)
|
| 113 |
return rows
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def _reason(entry: dict) -> str:
|
| 117 |
if entry.get("definition_conflict") or entry.get("diff_status") == "conflicting":
|
| 118 |
return "conflicting definitions — expert decision required"
|
|
|
|
| 22 |
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
+
import re
|
| 26 |
+
import textwrap
|
| 27 |
+
|
| 28 |
+
# At most one unresolved key parameter per this many term rows. See
|
| 29 |
+
# `_interleave` for why a cap replaced the promotion tier it used to have.
|
| 30 |
+
KEY_PARAMETER_INTERLEAVE = 5
|
| 31 |
+
|
| 32 |
+
# A key parameter NAME longer than this is not a name. The branch that produces
|
| 33 |
+
# them has no span check and no length guard, and on a textbook it returns the
|
| 34 |
+
# whole defining paragraph as the surface — see `_parameter_name`.
|
| 35 |
+
KEY_PARAMETER_NAME_MAX = 60
|
| 36 |
+
|
| 37 |
+
# The first sentence/label boundary in a returned surface. Applied only past
|
| 38 |
+
# `KEY_PARAMETER_NAME_MAX`, which is what keeps "No. of units" intact.
|
| 39 |
+
_HEAD_SPLIT = re.compile(r"[.:\n]")
|
| 40 |
+
|
| 41 |
|
| 42 |
def build_queue(entries: list[dict], domain: dict | None = None) -> list[dict]:
|
| 43 |
"""Rows an expert works through, most-decision-worthy first.
|
|
|
|
| 52 |
entry.get("diff_status") == "conflicting"
|
| 53 |
or entry.get("definition_conflict") is True
|
| 54 |
)
|
| 55 |
+
return (0 if conflicting else 1, -int(entry.get("mention_count", 0) or 0))
|
| 56 |
|
| 57 |
+
term_rows = []
|
| 58 |
for entry in sorted(entries, key=sort_key):
|
| 59 |
provenance = entry.get("provenance") or {}
|
| 60 |
+
term_rows.append(
|
| 61 |
{
|
| 62 |
"row_kind": "term",
|
| 63 |
# What an approve/edit/reject decision attaches to. Without it a
|
|
|
|
| 73 |
"section_no": provenance.get("section_no"),
|
| 74 |
"span": provenance.get("span"),
|
| 75 |
"review_reason": _reason(entry),
|
| 76 |
+
"_conflict": sort_key(entry)[0] == 0,
|
| 77 |
}
|
| 78 |
)
|
| 79 |
|
| 80 |
+
queue = _interleave(term_rows, _key_parameter_rows(domain))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
for rank, row in enumerate(queue, start=1):
|
| 82 |
row["rank"] = rank
|
| 83 |
+
row.pop("_conflict", None)
|
| 84 |
+
return queue
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _interleave(term_rows: list[dict], parameter_rows: list[dict]) -> list[dict]:
|
| 88 |
+
"""Conflicts, then terms by frequency with key parameters spliced in.
|
| 89 |
+
|
| 90 |
+
Unresolved key parameters used to occupy their own tier directly beneath
|
| 91 |
+
conflicts, which meant EVERY one of them outranked EVERY routine term no
|
| 92 |
+
matter how many there were. On the Komatsu shop manual that is 24 junk rows
|
| 93 |
+
standing in front of `Engine` (108 mentions) — the branch produces them in
|
| 94 |
+
bulk on exactly the documents where it understands least, so the promotion
|
| 95 |
+
fired hardest where it was least deserved and the expert met a wall of
|
| 96 |
+
noise before reaching anything real.
|
| 97 |
+
|
| 98 |
+
The original reason for promoting them is still sound and is preserved: the
|
| 99 |
+
key-parameter branch has no span check, so an unresolvable surface is the
|
| 100 |
+
one hallucination signal it can raise, and it must not sink beneath sixty
|
| 101 |
+
routine rows where nobody reaches it. A CAP expresses that without letting
|
| 102 |
+
the tail take the queue over — one parameter per five terms, so they are
|
| 103 |
+
always visible early and never crowd out frequency.
|
| 104 |
+
|
| 105 |
+
Conflicts stay strictly on top: a contradiction is a decision only the
|
| 106 |
+
expert can make, and nothing interleaves above it.
|
| 107 |
+
"""
|
| 108 |
+
conflicts = [row for row in term_rows if row.get("_conflict")]
|
| 109 |
+
routine = [row for row in term_rows if not row.get("_conflict")]
|
| 110 |
+
|
| 111 |
+
queue = list(conflicts)
|
| 112 |
+
pending = list(parameter_rows)
|
| 113 |
+
for index, row in enumerate(routine, start=1):
|
| 114 |
+
queue.append(row)
|
| 115 |
+
if pending and index % KEY_PARAMETER_INTERLEAVE == 0:
|
| 116 |
+
queue.append(pending.pop(0))
|
| 117 |
+
|
| 118 |
+
# Whatever the interleave could not place — a document with more unresolved
|
| 119 |
+
# parameters than terms, which is itself a signal — rather than dropped.
|
| 120 |
+
queue.extend(pending)
|
| 121 |
return queue
|
| 122 |
|
| 123 |
|
|
|
|
| 134 |
for parameter in (domain or {}).get("key_parameters") or []:
|
| 135 |
if parameter.get("term_id"):
|
| 136 |
continue
|
| 137 |
+
name, verbatim = _parameter_name(parameter.get("surface"))
|
| 138 |
rows.append(
|
| 139 |
{
|
| 140 |
"row_kind": "key_parameter",
|
|
|
|
| 142 |
# there is no term_id, which is the entire point of the row.
|
| 143 |
"term_id": None,
|
| 144 |
"brief_id": (domain or {}).get("brief_id"),
|
| 145 |
+
"term": name,
|
| 146 |
+
# Null unless the surface had to be shortened. The full text is
|
| 147 |
+
# never discarded: it is what the expert judges "is this real?"
|
| 148 |
+
# against, and the branch picks the right CONCEPTS even when it
|
| 149 |
+
# returns them at paragraph length.
|
| 150 |
+
"surface_full": verbatim,
|
| 151 |
"definition": None,
|
| 152 |
"source_wording": None,
|
| 153 |
"mention_count": 0,
|
|
|
|
| 159 |
"named as a key parameter but no extracted term corroborates it "
|
| 160 |
"— confirm it is real"
|
| 161 |
),
|
|
|
|
| 162 |
}
|
| 163 |
)
|
| 164 |
return rows
|
| 165 |
|
| 166 |
|
| 167 |
+
def _parameter_name(surface: str | None) -> tuple[str, str | None]:
|
| 168 |
+
"""A key parameter's displayable name, and its full text when shortened.
|
| 169 |
+
|
| 170 |
+
The branch returns a `surface` that is supposed to be a parameter name, and
|
| 171 |
+
on the Open Pit textbook it returns the right concept wrapped in its whole
|
| 172 |
+
defining sentence. Nothing downstream caps it, so the queue renders a
|
| 173 |
+
paragraph where a name belongs and the row is unreadable at a glance.
|
| 174 |
+
|
| 175 |
+
Shortening is applied ONLY past `KEY_PARAMETER_NAME_MAX`, which is what
|
| 176 |
+
keeps a legitimate short name containing a period ("No. of units") intact —
|
| 177 |
+
splitting unconditionally on the first `.` is the obvious implementation and
|
| 178 |
+
it silently truncates that class of name.
|
| 179 |
+
"""
|
| 180 |
+
text = (surface or "").strip()
|
| 181 |
+
if not text:
|
| 182 |
+
return "", None
|
| 183 |
+
if len(text) <= KEY_PARAMETER_NAME_MAX and "\n" not in text:
|
| 184 |
+
return text, None
|
| 185 |
+
|
| 186 |
+
head = _HEAD_SPLIT.split(text, 1)[0].strip()
|
| 187 |
+
if not head or len(head) > KEY_PARAMETER_NAME_MAX:
|
| 188 |
+
# No usable boundary — a run-on clause. Cut on a word boundary rather
|
| 189 |
+
# than mid-token so the row still reads as language.
|
| 190 |
+
head = textwrap.shorten(text, width=KEY_PARAMETER_NAME_MAX, placeholder="…")
|
| 191 |
+
return head, text
|
| 192 |
+
|
| 193 |
+
|
| 194 |
def _reason(entry: dict) -> str:
|
| 195 |
if entry.get("definition_conflict") or entry.get("diff_status") == "conflicting":
|
| 196 |
return "conflicting definitions — expert decision required"
|
|
@@ -10,6 +10,13 @@ survive any future change here:
|
|
| 10 |
3. **The full ranked list is retained**, so escalation can take the next K and
|
| 11 |
self-correct.
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
The rejected relevance gate had none of the three. If ranking is ever revisited,
|
| 14 |
that is the test to apply.
|
| 15 |
"""
|
|
@@ -22,7 +29,12 @@ from ..cluster.normalize import normalize
|
|
| 22 |
from ..filters.cue_filter import definitional_hits
|
| 23 |
from ..filters.legend_filter import find_legend_lines
|
| 24 |
from ..models import Chunk, TermCluster
|
| 25 |
-
from ..settings import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def rank_evidence(
|
|
@@ -59,11 +71,62 @@ def rank_evidence(
|
|
| 59 |
|
| 60 |
|
| 61 |
def top_k(
|
| 62 |
-
cluster: TermCluster,
|
|
|
|
|
|
|
|
|
|
| 63 |
) -> list[str]:
|
| 64 |
-
"""Evidence for extraction round `round_index` (0 = best K, 1 = next K…).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
start = round_index * k
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
def rounds_available(cluster: TermCluster, k: int, max_rounds: int) -> int:
|
|
@@ -126,6 +189,21 @@ def _score_chunk(
|
|
| 126 |
score += w["in_legend_block"]
|
| 127 |
break
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
if chunk.has_formula:
|
| 130 |
score += w["formula_present"]
|
| 131 |
|
|
|
|
| 10 |
3. **The full ranked list is retained**, so escalation can take the next K and
|
| 11 |
self-correct.
|
| 12 |
|
| 13 |
+
One signal ranks on ASSET metadata (`asset_caption_names_term`, added 2026-09-11),
|
| 14 |
+
and it is deliberately limited to the **caption**. A caption is printed in the
|
| 15 |
+
document and is span-checkable; the `description` beside it is model-written and
|
| 16 |
+
cannot be, so ranking on a description would let a hallucinated sentence pull a
|
| 17 |
+
figure into evidence — the retrieval-side version of the failure the span check
|
| 18 |
+
exists to prevent.
|
| 19 |
+
|
| 20 |
The rejected relevance gate had none of the three. If ranking is ever revisited,
|
| 21 |
that is the test to apply.
|
| 22 |
"""
|
|
|
|
| 29 |
from ..filters.cue_filter import definitional_hits
|
| 30 |
from ..filters.legend_filter import find_legend_lines
|
| 31 |
from ..models import Chunk, TermCluster
|
| 32 |
+
from ..settings import (
|
| 33 |
+
CUE_PROXIMITY_CHARS,
|
| 34 |
+
EVIDENCE_K,
|
| 35 |
+
EVIDENCE_LINK_PARTNERS,
|
| 36 |
+
EVIDENCE_WEIGHTS,
|
| 37 |
+
)
|
| 38 |
|
| 39 |
|
| 40 |
def rank_evidence(
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
def top_k(
|
| 74 |
+
cluster: TermCluster,
|
| 75 |
+
k: int = EVIDENCE_K,
|
| 76 |
+
round_index: int = 0,
|
| 77 |
+
chunks: list[Chunk] | None = None,
|
| 78 |
) -> list[str]:
|
| 79 |
+
"""Evidence for extraction round `round_index` (0 = best K, 1 = next K…).
|
| 80 |
+
|
| 81 |
+
When `chunks` is supplied, up to `EVIDENCE_LINK_PARTNERS` **linked partner**
|
| 82 |
+
chunks are appended to the window: 0.4.0 records a table and the prose that
|
| 83 |
+
introduces it as a pair (`referenced_by`, both directions), and the ranker
|
| 84 |
+
measured as never co-selecting them — 0 of 603 clusters across the three test
|
| 85 |
+
documents had both sides in their top-5. The partner is therefore unseen
|
| 86 |
+
content, and for a bare table it is usually the only thing that names it.
|
| 87 |
+
|
| 88 |
+
Both consumers of this function pass `chunks` — `_evidence_for` (the prompt)
|
| 89 |
+
and `service` (the span-check source) — so the model reads exactly what the
|
| 90 |
+
span check searches. The full ranked list is NOT modified, so anything reading
|
| 91 |
+
`evidence_chunk_ids` (`rounds_available` included) is unaffected. See
|
| 92 |
+
`EVIDENCE_LINK_PARTNERS` for the measurement and why the expansion lives here
|
| 93 |
+
rather than in `rank_evidence`.
|
| 94 |
+
|
| 95 |
+
Omitting `chunks` keeps the pre-0.4.0 behaviour exactly.
|
| 96 |
+
"""
|
| 97 |
start = round_index * k
|
| 98 |
+
window = cluster.evidence_chunk_ids[start : start + k]
|
| 99 |
+
if not chunks or EVIDENCE_LINK_PARTNERS <= 0 or not window:
|
| 100 |
+
return window
|
| 101 |
+
return window + _linked_partners(window, chunks, EVIDENCE_LINK_PARTNERS)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _linked_partners(
|
| 105 |
+
window: list[str], chunks: list[Chunk], cap: int
|
| 106 |
+
) -> list[str]:
|
| 107 |
+
"""Partners of chunks in `window`, best-ranked side first, capped.
|
| 108 |
+
|
| 109 |
+
Walks the window in rank order so the partner chosen belongs to the
|
| 110 |
+
highest-ranked linked chunk, and sorts each chunk's own partners by id so a
|
| 111 |
+
chunk with several links resolves identically on every run.
|
| 112 |
+
"""
|
| 113 |
+
links: dict[str, set[str]] = {}
|
| 114 |
+
for chunk in chunks:
|
| 115 |
+
for other in chunk.referenced_by:
|
| 116 |
+
links.setdefault(chunk.chunk_id, set()).add(other)
|
| 117 |
+
links.setdefault(other, set()).add(chunk.chunk_id)
|
| 118 |
+
|
| 119 |
+
seen = set(window)
|
| 120 |
+
picked: list[str] = []
|
| 121 |
+
for chunk_id in window:
|
| 122 |
+
for partner in sorted(links.get(chunk_id, ())):
|
| 123 |
+
if partner in seen:
|
| 124 |
+
continue
|
| 125 |
+
picked.append(partner)
|
| 126 |
+
seen.add(partner)
|
| 127 |
+
if len(picked) >= cap:
|
| 128 |
+
return picked
|
| 129 |
+
return picked
|
| 130 |
|
| 131 |
|
| 132 |
def rounds_available(cluster: TermCluster, k: int, max_rounds: int) -> int:
|
|
|
|
| 189 |
score += w["in_legend_block"]
|
| 190 |
break
|
| 191 |
|
| 192 |
+
# A figure in this chunk whose CAPTION names the term (0.4.0 assets).
|
| 193 |
+
#
|
| 194 |
+
# Word-boundary matched against the caption only — never the model-written
|
| 195 |
+
# `description`. A description is a claim about a picture that cannot be
|
| 196 |
+
# span-checked, so letting it influence retrieval would let a hallucinated
|
| 197 |
+
# sentence pull a figure into evidence. The caption is printed in the
|
| 198 |
+
# document, so it is safe to rank on.
|
| 199 |
+
if any(
|
| 200 |
+
asset.caption
|
| 201 |
+
and any(_word_match(normalize(v), normalize(asset.caption))
|
| 202 |
+
for v in cluster.variants)
|
| 203 |
+
for asset in getattr(chunk, "assets", []) or []
|
| 204 |
+
):
|
| 205 |
+
score += w.get("asset_caption_names_term", 0.0)
|
| 206 |
+
|
| 207 |
if chunk.has_formula:
|
| 208 |
score += w["formula_present"]
|
| 209 |
|
|
@@ -15,6 +15,7 @@ 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,
|
|
@@ -23,8 +24,10 @@ from .extract import (
|
|
| 23 |
extract_glossary,
|
| 24 |
extract_rule,
|
| 25 |
extract_summary,
|
|
|
|
| 26 |
)
|
| 27 |
from .filters import abbrev_pairs, extract_mentions, rule_candidates
|
|
|
|
| 28 |
from .link import link_all
|
| 29 |
from .models import (
|
| 30 |
CallUsage,
|
|
@@ -56,6 +59,10 @@ class ExtractionResult:
|
|
| 56 |
self.brief: dict | None = None
|
| 57 |
self.review_queue: list[dict] = []
|
| 58 |
self.rejected: list[RejectedField] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
self.usages: list[CallUsage] = []
|
| 60 |
# Dangle counts from the linking stage. Reported rather than swallowed:
|
| 61 |
# a link stage that resolves nothing looks identical to one that works.
|
|
@@ -91,19 +98,67 @@ def build_clusters(doc: ParsedDoc, filtered: FilterResult) -> ClusterResult:
|
|
| 91 |
def estimate_cost(
|
| 92 |
doc: ParsedDoc, clustered: ClusterResult, filtered: FilterResult, limit: int | None = None
|
| 93 |
) -> dict:
|
| 94 |
-
"""Dry run: exact prompts are built, nothing is sent.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
clusters = clustered.clusters[:limit] if limit else clustered.clusters
|
| 96 |
-
|
| 97 |
for cluster in clusters:
|
| 98 |
system, user = build_glossary_prompt(cluster, doc.chunks)
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
return {
|
|
|
|
| 101 |
"glossary_calls": len(clusters),
|
| 102 |
-
"rule_calls": len(
|
| 103 |
-
"formula_calls":
|
| 104 |
"summary_calls": 1,
|
| 105 |
-
"
|
| 106 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
|
|
@@ -121,7 +176,9 @@ def extract_all(
|
|
| 121 |
started = time.time()
|
| 122 |
|
| 123 |
if "glossary" in branches:
|
| 124 |
-
_run_glossary(
|
|
|
|
|
|
|
| 125 |
if "rule" in branches:
|
| 126 |
_run_rules(doc, filtered, extractor, out, limit)
|
| 127 |
if "formula" in branches:
|
|
@@ -162,9 +219,59 @@ def extract_all(
|
|
| 162 |
return out
|
| 163 |
|
| 164 |
|
| 165 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
clusters = clustered.clusters[:limit] if limit else clustered.clusters
|
|
|
|
| 167 |
for cluster in clusters:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
entry = None
|
| 169 |
max_round = rounds_available(cluster, EVIDENCE_K)
|
| 170 |
|
|
@@ -177,7 +284,11 @@ def _run_glossary(doc, clustered, extractor, out, limit) -> None:
|
|
| 177 |
continue
|
| 178 |
|
| 179 |
source = evidence_text(
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
)
|
| 182 |
entry, rejections = validate_entry(entry, "glossary", source, cluster.canonical)
|
| 183 |
out.rejected.extend(rejections)
|
|
|
|
| 15 |
|
| 16 |
from ..middlewares.logging import get_logger
|
| 17 |
from .cluster import cluster_mentions
|
| 18 |
+
from .cluster.normalize import normalize
|
| 19 |
from .diff import diff_glossary
|
| 20 |
from .extract import (
|
| 21 |
build_glossary_prompt,
|
|
|
|
| 24 |
extract_glossary,
|
| 25 |
extract_rule,
|
| 26 |
extract_summary,
|
| 27 |
+
prefix_tokens,
|
| 28 |
)
|
| 29 |
from .filters import abbrev_pairs, extract_mentions, rule_candidates
|
| 30 |
+
from .ids import term_id
|
| 31 |
from .link import link_all
|
| 32 |
from .models import (
|
| 33 |
CallUsage,
|
|
|
|
| 59 |
self.brief: dict | None = None
|
| 60 |
self.review_queue: list[dict] = []
|
| 61 |
self.rejected: list[RejectedField] = []
|
| 62 |
+
# i2: clusters answered from an approved entry instead of a paid
|
| 63 |
+
# call. Reported rather than inferred - a branch that skips work
|
| 64 |
+
# silently looks identical to one that found nothing.
|
| 65 |
+
self.reused: int = 0
|
| 66 |
self.usages: list[CallUsage] = []
|
| 67 |
# Dangle counts from the linking stage. Reported rather than swallowed:
|
| 68 |
# a link stage that resolves nothing looks identical to one that works.
|
|
|
|
| 98 |
def estimate_cost(
|
| 99 |
doc: ParsedDoc, clustered: ClusterResult, filtered: FilterResult, limit: int | None = None
|
| 100 |
) -> dict:
|
| 101 |
+
"""Dry run: exact prompts are built, nothing is sent.
|
| 102 |
+
|
| 103 |
+
**`estimated_prompt_tokens` covers all four branches as of 2026-09-11.** It
|
| 104 |
+
used to sum the glossary branch alone while carrying a name that reads as a
|
| 105 |
+
total, so every consumer — including the spend ceiling that gates on it —
|
| 106 |
+
was reading a partial number as a whole one. The glossary figure stays exact
|
| 107 |
+
(its prompts are really built); the other three are approximated from the
|
| 108 |
+
cached prefix plus the chunk text they are handed, which undercounts the
|
| 109 |
+
evidence block's own framing by a few percent. Both are labelled, because a
|
| 110 |
+
ceiling that does not know which half of its input is a guess is not a
|
| 111 |
+
ceiling.
|
| 112 |
+
|
| 113 |
+
`clusters` is reported alongside because **cluster count, not page count, is
|
| 114 |
+
what drives spend** — one call per cluster — and it is known here for free.
|
| 115 |
+
"""
|
| 116 |
clusters = clustered.clusters[:limit] if limit else clustered.clusters
|
| 117 |
+
glossary_tokens = 0
|
| 118 |
for cluster in clusters:
|
| 119 |
system, user = build_glossary_prompt(cluster, doc.chunks)
|
| 120 |
+
glossary_tokens += est_tokens(system) + est_tokens(user)
|
| 121 |
+
|
| 122 |
+
by_id = {c.chunk_id: c for c in doc.chunks}
|
| 123 |
+
rule_chunks = [
|
| 124 |
+
by_id[c.chunk_id]
|
| 125 |
+
for c in (filtered.rule_candidates[:limit] if limit else filtered.rule_candidates)
|
| 126 |
+
if c.chunk_id in by_id
|
| 127 |
+
]
|
| 128 |
+
formula_chunks = [c for c in doc.chunks if c.has_formula]
|
| 129 |
+
formula_chunks = formula_chunks[:limit] if limit else formula_chunks
|
| 130 |
+
|
| 131 |
+
rule_tokens = sum(prefix_tokens("rule") + est_tokens(c.text or "") for c in rule_chunks)
|
| 132 |
+
formula_tokens = sum(
|
| 133 |
+
prefix_tokens("formula") + est_tokens(c.text or "") for c in formula_chunks
|
| 134 |
+
)
|
| 135 |
+
# The summary branch is handed the WHOLE document — few calls, but roughly a
|
| 136 |
+
# quarter of all input tokens, and it cannot be filtered (X7).
|
| 137 |
+
summary_tokens = prefix_tokens("summary") + sum(
|
| 138 |
+
est_tokens(c.text or "") for c in doc.chunks
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
by_branch = {
|
| 142 |
+
"glossary": glossary_tokens,
|
| 143 |
+
"rule": rule_tokens,
|
| 144 |
+
"formula": formula_tokens,
|
| 145 |
+
"summary": summary_tokens,
|
| 146 |
+
}
|
| 147 |
return {
|
| 148 |
+
"clusters": len(clusters),
|
| 149 |
"glossary_calls": len(clusters),
|
| 150 |
+
"rule_calls": len(rule_chunks),
|
| 151 |
+
"formula_calls": len(formula_chunks),
|
| 152 |
"summary_calls": 1,
|
| 153 |
+
"total_calls": len(clusters) + len(rule_chunks) + len(formula_chunks) + 1,
|
| 154 |
+
"estimated_prompt_tokens": sum(by_branch.values()),
|
| 155 |
+
"estimated_prompt_tokens_by_branch": by_branch,
|
| 156 |
+
"glossary_tokens_are_exact": True,
|
| 157 |
+
"note": (
|
| 158 |
+
"estimate only — real counts come from the API usage object. Glossary "
|
| 159 |
+
"prompts are built for real; rule/formula/summary are approximated from "
|
| 160 |
+
"prefix + chunk text and run slightly low."
|
| 161 |
+
),
|
| 162 |
}
|
| 163 |
|
| 164 |
|
|
|
|
| 176 |
started = time.time()
|
| 177 |
|
| 178 |
if "glossary" in branches:
|
| 179 |
+
_run_glossary(
|
| 180 |
+
doc, clustered, extractor, out, limit, _approved_index(active_glossary)
|
| 181 |
+
)
|
| 182 |
if "rule" in branches:
|
| 183 |
_run_rules(doc, filtered, extractor, out, limit)
|
| 184 |
if "formula" in branches:
|
|
|
|
| 219 |
return out
|
| 220 |
|
| 221 |
|
| 222 |
+
def _approved_index(active_glossary: list[dict] | None) -> dict[str, dict]:
|
| 223 |
+
"""Canonical key -> the approved payload, for recognising a known term (i2).
|
| 224 |
+
|
| 225 |
+
Keyed with the SAME normaliser that buckets mentions inside a document, so
|
| 226 |
+
one rule decides sameness at both scales rather than two that can drift.
|
| 227 |
+
Both `term` and `full_name` are indexed because a later document may write
|
| 228 |
+
the expansion where the first wrote the abbreviation.
|
| 229 |
+
"""
|
| 230 |
+
index: dict[str, dict] = {}
|
| 231 |
+
for payload in active_glossary or []:
|
| 232 |
+
for surface in (payload.get("term"), payload.get("full_name")):
|
| 233 |
+
key = normalize(surface or "")
|
| 234 |
+
if key:
|
| 235 |
+
index.setdefault(key, payload)
|
| 236 |
+
return index
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _reuse_approved(approved: dict, doc: ParsedDoc, cluster) -> dict:
|
| 240 |
+
"""Carry an approved definition onto this document without paying for it.
|
| 241 |
+
|
| 242 |
+
The entry is this document's — `term_id` is re-minted from *its* `doc_id`,
|
| 243 |
+
because ids stay document-scoped (decision (a), 2026-09-14) and sharing one
|
| 244 |
+
id across documents would move every existing `entity_id` and orphan the
|
| 245 |
+
approvals this whole scheme protects.
|
| 246 |
+
|
| 247 |
+
**Provenance is deliberately left pointing at the document the definition
|
| 248 |
+
actually came from.** It would be easy to rewrite it to this document and
|
| 249 |
+
wrong: nothing was extracted here, so there is no span in this document to
|
| 250 |
+
cite, and a fabricated one is exactly the unfalsifiable claim the span check
|
| 251 |
+
exists to refuse. Which documents re-mention the term is what `doc_ids`
|
| 252 |
+
(m3) will record once it lands.
|
| 253 |
+
"""
|
| 254 |
+
entry = dict(approved)
|
| 255 |
+
entry["term_id"] = term_id(doc.doc_id, cluster.canonical)
|
| 256 |
+
entry["mention_count"] = getattr(cluster, "mention_count", 0)
|
| 257 |
+
entry["diff_status"] = "duplicate"
|
| 258 |
+
return entry
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _run_glossary(doc, clustered, extractor, out, limit, approved=None) -> None:
|
| 262 |
clusters = clustered.clusters[:limit] if limit else clustered.clusters
|
| 263 |
+
approved = approved or {}
|
| 264 |
for cluster in clusters:
|
| 265 |
+
# i2: a term this company has already approved is not re-extracted. One
|
| 266 |
+
# LLM call per cluster is the whole cost of this branch, so every term
|
| 267 |
+
# recognised here is a call not made — and it is also what stops the
|
| 268 |
+
# same concept arriving as `term 1.1` and `term 1.2`.
|
| 269 |
+
reused = approved.get(normalize(cluster.canonical))
|
| 270 |
+
if reused is not None:
|
| 271 |
+
out.glossary.append(_reuse_approved(reused, doc, cluster))
|
| 272 |
+
out.reused += 1
|
| 273 |
+
continue
|
| 274 |
+
|
| 275 |
entry = None
|
| 276 |
max_round = rounds_available(cluster, EVIDENCE_K)
|
| 277 |
|
|
|
|
| 284 |
continue
|
| 285 |
|
| 286 |
source = evidence_text(
|
| 287 |
+
# `doc.chunks` passed so a linked partner joins the window here
|
| 288 |
+
# exactly as it does in the prompt (`_evidence_for`). If these two
|
| 289 |
+
# ever diverge, a quote from a partner chunk is nulled as
|
| 290 |
+
# unlocatable and the call is wasted.
|
| 291 |
+
top_k(cluster, EVIDENCE_K, round_index, doc.chunks), doc.chunks
|
| 292 |
)
|
| 293 |
entry, rejections = validate_entry(entry, "glossary", source, cluster.canonical)
|
| 294 |
out.rejected.extend(rejections)
|
|
@@ -33,28 +33,215 @@ SPAN_SCORE_THRESHOLD = 0.25
|
|
| 33 |
# The span NER model truncates past ~384 of its own tokens and *warns rather
|
| 34 |
# than failing*, so a long chunk silently loses its tail. Indonesian technical
|
| 35 |
# prose subword-tokenises at roughly 2.5x, so 220-word windows still tripped the
|
| 36 |
-
# cap; 130
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
WINDOW_WORDS = 130
|
| 38 |
WINDOW_OVERLAP = 30
|
| 39 |
SPAN_TOKEN_CAP = 12
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
# ── Clustering ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
FUZZY_THRESHOLD = 92
|
| 43 |
-
|
| 44 |
-
# Below this length only exact matching is allowed: "PA" and "UA" score highly
|
| 45 |
-
# against each other on token_set_ratio. Over-merging is far worse than
|
| 46 |
-
# under-merging — an under-merge costs one extra call and one extra review row,
|
| 47 |
-
# a wrong merge destroys a distinct term and the expert never sees it.
|
| 48 |
FUZZY_MIN_LEN = 5
|
| 49 |
|
| 50 |
# ── Evidence ranking ────────────────────────────────────────────────────
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
CUE_PROXIMITY_CHARS = 100
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
EVIDENCE_WEIGHTS: dict[str, float] = {
|
| 55 |
"definitional_cue_near": 5.0,
|
| 56 |
"term_in_heading": 4.0,
|
| 57 |
"in_legend_block": 3.5,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
"formula_present": 2.0,
|
| 59 |
"bold_or_italic": 1.5,
|
| 60 |
"first_occurrence": 1.0,
|
|
@@ -75,7 +262,37 @@ TEMPERATURE = 0.0
|
|
| 75 |
CACHE_MIN_TOKENS = 1024
|
| 76 |
|
| 77 |
# ── Validation ──────────────────────────────────────────────────────────
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
CONFLICT_OVERLAP_THRESHOLD = 0.4
|
| 80 |
DUPLICATE_OVERLAP_THRESHOLD = 0.8
|
| 81 |
|
|
|
|
| 33 |
# The span NER model truncates past ~384 of its own tokens and *warns rather
|
| 34 |
# than failing*, so a long chunk silently loses its tail. Indonesian technical
|
| 35 |
# prose subword-tokenises at roughly 2.5x, so 220-word windows still tripped the
|
| 36 |
+
# cap; 130 did not. Chunks are fed as overlapping windows with offsets remapped.
|
| 37 |
+
#
|
| 38 |
+
# ⚠️ STAYS AT 130. A 2026-09-10 sweep appeared to show 256 was free — recall flat
|
| 39 |
+
# across 130/180/256/320 (E1 0.878 at every size) while clusters fell 6%/23%/34%
|
| 40 |
+
# across the three test documents. It was raised, then REVERTED the same day after
|
| 41 |
+
# counting the truncation directly with GLiNER's own tokeniser:
|
| 42 |
+
#
|
| 43 |
+
# windows exceeding the 384-token cap, by WINDOW_WORDS
|
| 44 |
+
# 130 180 200 256 320
|
| 45 |
+
# BUMA (ID) 1 1 1 1 1 <- pre-existing, see below
|
| 46 |
+
# Komatsu (EN) 0 0 0 1 10
|
| 47 |
+
# Open Pit (EN) 0 4 11 46 49 <- 46 of 99 windows!
|
| 48 |
+
#
|
| 49 |
+
# At 256 nearly HALF of the Open Pit textbook's windows lose their tail. The
|
| 50 |
+
# "flat recall" that justified the raise was measured on BUMA, the one document
|
| 51 |
+
# whose truncation count does not change with window size — so the metric was
|
| 52 |
+
# structurally blind to the cost, in exactly the way E1 was blind to the
|
| 53 |
+
# clustering over-merge. The cluster reduction was not better grouping; it was
|
| 54 |
+
# content disappearing.
|
| 55 |
+
#
|
| 56 |
+
# This vindicates the original reasoning rather than overturning it: the ~2.5x
|
| 57 |
+
# Indonesian subword ratio was the motivating case, but dense ENGLISH technical
|
| 58 |
+
# prose also trips the cap by 180. 130 is the only size measured safe on both
|
| 59 |
+
# English documents.
|
| 60 |
+
#
|
| 61 |
+
# ⚠️ Separate, PRE-EXISTING finding from the same measurement: BUMA has one window
|
| 62 |
+
# that exceeds the cap at EVERY size including 130 (409 tokens from 130 words — a
|
| 63 |
+
# dense Indonesian chunk). One chunk in that document has always been losing its
|
| 64 |
+
# tail. Not caused by this work and not fixed by it; a smaller window or a
|
| 65 |
+
# per-chunk token budget would be the fix, and neither is measured yet.
|
| 66 |
WINDOW_WORDS = 130
|
| 67 |
WINDOW_OVERLAP = 30
|
| 68 |
SPAN_TOKEN_CAP = 12
|
| 69 |
|
| 70 |
+
# ── The per-window SUBWORD budget (fixes the pre-existing over-cap window) ──
|
| 71 |
+
#
|
| 72 |
+
# `WINDOW_WORDS` counts WORDS; the model truncates on SUBWORDS, and the ratio
|
| 73 |
+
# between them is a property of the text, not of the setting. Measured with
|
| 74 |
+
# GLiNER's own tokeniser across all three documents, 2026-09-11:
|
| 75 |
+
#
|
| 76 |
+
# windows over the 384-token cap chars/subword (min / mean)
|
| 77 |
+
# BUMA 31 1 1.57 / 3.43
|
| 78 |
+
# Komatsu 144 0 2.53 / 3.58
|
| 79 |
+
# Open Pit 193 0 2.79 / 4.10
|
| 80 |
+
#
|
| 81 |
+
# One window in 368 is over, and it is the one the 2026-09-10 sweep found and
|
| 82 |
+
# left unfixed: `STD_2026_006_MNO::0024`, 130 words and 738 characters, which
|
| 83 |
+
# tokenise to 409 subwords. That chunk has ALWAYS been losing its tail, at every
|
| 84 |
+
# window size including 130, and the model warns rather than failing so nothing
|
| 85 |
+
# ever surfaced it.
|
| 86 |
+
#
|
| 87 |
+
# The fix measures instead of guessing: each window is checked against the
|
| 88 |
+
# model's own tokeniser and split only if it does not fit. A character budget
|
| 89 |
+
# would have to assume the worst ratio seen anywhere (1.57) and would therefore
|
| 90 |
+
# split at ~600 characters — chopping up the other 367 windows, which fit
|
| 91 |
+
# comfortably, to fix one.
|
| 92 |
+
SPAN_SUBWORD_CAP = 384
|
| 93 |
+
|
| 94 |
+
# Split at 90% of the cap. The tokeniser count excludes the special tokens and
|
| 95 |
+
# the label prompt GLiNER prepends, so measuring exactly to 384 still truncates.
|
| 96 |
+
SPAN_SUBWORD_MARGIN = 0.9
|
| 97 |
+
|
| 98 |
+
# Used only when the tokeniser cannot be located on the model (a GLiNER version
|
| 99 |
+
# change). 1.57 is the tightest ratio measured anywhere in the corpus, so this
|
| 100 |
+
# over-splits rather than silently truncating — the safe direction.
|
| 101 |
+
SPAN_FALLBACK_CHARS_PER_TOKEN = 1.57
|
| 102 |
+
|
| 103 |
+
# A split halves and recurses. 4 takes a 130-word window down to ~8 words, which
|
| 104 |
+
# is past any plausible cap; the bound exists so a pathological input cannot
|
| 105 |
+
# recurse indefinitely.
|
| 106 |
+
SPAN_MAX_SPLIT_DEPTH = 4
|
| 107 |
+
|
| 108 |
+
# Flat (non-overlapping) span decoding. MEASURED AND KEPT 2026-09-10: nested
|
| 109 |
+
# decoding (`flat_ner=False`) was tested on all three documents on the hypothesis
|
| 110 |
+
# that flat mode was discarding the parent/child spans compound terms need. It
|
| 111 |
+
# was not. Nested added 7-12% more mentions, delivered EXACTLY ZERO recall gain
|
| 112 |
+
# (E1 0.878 either way, E1c identical too), and increased token-subset
|
| 113 |
+
# over-merges by 40-80% (BUMA 15->24, Komatsu 18->32, Open Pit 97->134).
|
| 114 |
+
# `span_filter` leaves this at the library default rather than passing it; this
|
| 115 |
+
# constant documents the decision. Do not re-open without a new measurement.
|
| 116 |
+
SPAN_FLAT_NER = True
|
| 117 |
+
|
| 118 |
# ── Clustering ──────────────────────────────────────────────────────────
|
| 119 |
+
# ⚠️ BOTH CONSTANTS BELOW ARE RETIRED as of 2026-09-10. The fuzzy matching pass
|
| 120 |
+
# they configured is commented out at its call site in `cluster/cluster.py`; they
|
| 121 |
+
# are still imported there because the `_fuzzy_match` function itself is parked
|
| 122 |
+
# (comment-out-don't-delete), and it reads them. Changing them now affects
|
| 123 |
+
# NOTHING — the pass does not run.
|
| 124 |
+
#
|
| 125 |
+
# Why it was retired, measured across three documents: `token_set_ratio` returns
|
| 126 |
+
# exactly 100 when one token set is a subset of the other, so `FUZZY_THRESHOLD`
|
| 127 |
+
# was inert and head nouns always absorbed their compounds. 131 of 140 merges
|
| 128 |
+
# were subset matches (all wrong); of the 9 remaining, 6 were plain plurals —
|
| 129 |
+
# now handled deterministically by `normalize(lemma=True)` — and 3 were distinct
|
| 130 |
+
# metrics wrongly merged. Retiring the pass took subset-merges to 0 on all three
|
| 131 |
+
# documents and lifted post-cluster gold recall 0.8293 -> 0.8537.
|
| 132 |
+
#
|
| 133 |
+
# `FUZZY_MIN_LEN` did its own job correctly to the end: it stopped "PA" and "UA"
|
| 134 |
+
# colliding. It simply never gated multi-word English compounds, which is the
|
| 135 |
+
# case that did the damage.
|
| 136 |
FUZZY_THRESHOLD = 92
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
FUZZY_MIN_LEN = 5
|
| 138 |
|
| 139 |
# ── Evidence ranking ────────────────────────────────────────────────────
|
| 140 |
+
# 3 -> 5 on 2026-09-10 (Rifqi's call). How many top-ranked chunks each extraction
|
| 141 |
+
# call sees. Three things this changes, recorded because none is obvious:
|
| 142 |
+
#
|
| 143 |
+
# 1. **Cost.** Evidence is the variable part of the prompt, so this is the single
|
| 144 |
+
# biggest lever on spend per call after cluster count. Measured effect on the
|
| 145 |
+
# three test documents: see CHANGELOG_v2.md. It also lowers the CACHE HIT RATE,
|
| 146 |
+
# because the fixed cached prefix becomes a smaller share of each prompt.
|
| 147 |
+
#
|
| 148 |
+
# 2. **Escalation gets rarer but deeper.** `rounds_available` needs more than K
|
| 149 |
+
# evidence chunks to fire at all, so at 5 a cluster needs 6+ (not 4+) to
|
| 150 |
+
# escalate; when it does, a round covers ranks 6-10 instead of 4-6. On the three
|
| 151 |
+
# test documents most clusters have 1-2 evidence chunks, so escalation fires
|
| 152 |
+
# less often than before.
|
| 153 |
+
#
|
| 154 |
+
# 3. **It is NOT supported by the ranker measurement, and that is fine.** The
|
| 155 |
+
# 2026-09-10 ceiling check found 16 of 16 gold definitions already at rank <=3
|
| 156 |
+
# (14 at rank 1), so on the BUMA standard ranks 4-5 add cost and no recall. The
|
| 157 |
+
# case for 5 is the two ENGLISH documents, where definitions are dispersed
|
| 158 |
+
# across pages and there is no gold set to measure with — a judgement that more
|
| 159 |
+
# context beats a cheaper call on that document class. Revisit with a number if
|
| 160 |
+
# a second gold set ever exists.
|
| 161 |
+
EVIDENCE_K = 5
|
| 162 |
CUE_PROXIMITY_CHARS = 100
|
| 163 |
|
| 164 |
+
# How many LINKED partner chunks may be pulled into an evidence window on top of
|
| 165 |
+
# the ranked K (0.4.0 two-way table pointers; added 2026-09-11). 1, not more.
|
| 166 |
+
#
|
| 167 |
+
# A table keeps its own chunk, and the prose that introduces it is a separate
|
| 168 |
+
# chunk; `referenced_by` records the pair in both directions. Measured on the
|
| 169 |
+
# three v2 artifacts, ranking only (free — no GLiNER, no spend):
|
| 170 |
+
#
|
| 171 |
+
# clusters whose top-5 touches a linked pair, by side
|
| 172 |
+
# clusters touches a pair BOTH sides only ONE side
|
| 173 |
+
# BUMA 83 31 0 31
|
| 174 |
+
# Komatsu 93 55 0 55
|
| 175 |
+
# Open Pit 427 70 0 70
|
| 176 |
+
#
|
| 177 |
+
# **Zero clusters see both sides.** The ranker never co-selects a table and the
|
| 178 |
+
# prose that names it, so the partner is genuinely unseen content rather than a
|
| 179 |
+
# duplicate — and that prose is frequently the table's only title ("Troubleshooting
|
| 180 |
+
# of engine (S-mode)" above a bare `S-1 | 6` grid).
|
| 181 |
+
#
|
| 182 |
+
# Cost AT THE CAP OF 1, measured on the same artifacts: evidence the model reads
|
| 183 |
+
# +14.5% / +24.5% / +9.9%, which is +3.4k / +11.2k / +59.5k tokens, i.e.
|
| 184 |
+
# **+1.6% / +4.1% / +4.2%** of each document's total v2 prompt spend (the fixed
|
| 185 |
+
# cached prefix dominates). Open Pit moves from 71% to ~74% of the 2M ingest
|
| 186 |
+
# ceiling. Clusters that actually gain a partner: 31/83, 55/93, 70/427.
|
| 187 |
+
#
|
| 188 |
+
# **Expansion happens in `top_k`, NOT in `rank_evidence`.** Two invariants depend
|
| 189 |
+
# on that placement:
|
| 190 |
+
#
|
| 191 |
+
# 1. The full ranked list stays untouched, so nothing that reads
|
| 192 |
+
# `evidence_chunk_ids` — `rounds_available` included — sees a different number.
|
| 193 |
+
# Appending partners to the RANKED list would inflate the evidence count and
|
| 194 |
+
# change escalation's arithmetic as a side effect. That is currently moot
|
| 195 |
+
# (`MAX_ESCALATION_ROUNDS = 0` since 2026-09-11; escalation produced 0
|
| 196 |
+
# definitions in 48 calls) but the separation is what keeps this change
|
| 197 |
+
# orthogonal to that one, and to whatever replaces it.
|
| 198 |
+
# 2. `evidence_text` (the span check) and `evidence_block` (the prompt) both take
|
| 199 |
+
# their ids from `top_k`, so expanding there keeps "what the model reads is
|
| 200 |
+
# exactly what the span check searches" true for free. Expanding in the prompt
|
| 201 |
+
# renderer instead would break it, and every quote from a partner chunk would
|
| 202 |
+
# be nulled as unlocatable. Verified: 0 parity failures across all 603 clusters.
|
| 203 |
+
#
|
| 204 |
+
# Set to 0 to disable. Two honest scope notes:
|
| 205 |
+
#
|
| 206 |
+
# - The document this helps most (Komatsu, 59% of clusters gain a partner) is the
|
| 207 |
+
# one that returns 0 definitions for GENRE reasons. This is groundwork for the
|
| 208 |
+
# table branch more than a glossary win.
|
| 209 |
+
# - The counts above were measured on the v2 artifacts and the RECORDED v2 filter
|
| 210 |
+
# payloads, so they predate the subword window-splitting change. Mention and
|
| 211 |
+
# cluster counts move with that change; the pairing structure does not.
|
| 212 |
+
EVIDENCE_LINK_PARTNERS = 1
|
| 213 |
+
|
| 214 |
EVIDENCE_WEIGHTS: dict[str, float] = {
|
| 215 |
"definitional_cue_near": 5.0,
|
| 216 |
"term_in_heading": 4.0,
|
| 217 |
"in_legend_block": 3.5,
|
| 218 |
+
# A figure whose CAPTION names this term sits in this chunk. Built and
|
| 219 |
+
# measured 2026-09-11, then set to **0.0 — OFF, because it measured as a
|
| 220 |
+
# no-op.** The code stays because the A/B harness and the reasoning are worth
|
| 221 |
+
# more than the three lines, and a diagram-heavy document could revive it.
|
| 222 |
+
#
|
| 223 |
+
# It was built to explain `definitions_citing_a_caption = 0` on all three
|
| 224 |
+
# documents in the first paid v2 run. Two hypotheses, both now falsified:
|
| 225 |
+
#
|
| 226 |
+
# 1. "Captions don't reach the term filter." FALSE — a caption is part of
|
| 227 |
+
# `Chunk.text`, so GLiNER already scans it and already produces mentions
|
| 228 |
+
# from it (2 of 2 captions on BUMA, 3 of 8 on Open Pit).
|
| 229 |
+
# 2. "Figure-bearing chunks don't get selected." FALSE — a captioned chunk is
|
| 230 |
+
# already inside the top-5 for 22 of 83 BUMA clusters and 64 of 427 Open
|
| 231 |
+
# Pit clusters. Turning this weight up to 2.5 changed the top-5 of **1
|
| 232 |
+
# cluster out of 603** across all three documents, and moved zero clusters
|
| 233 |
+
# from "no figure" to "has a figure".
|
| 234 |
+
#
|
| 235 |
+
# The actual reason is simpler, and visible by reading them: **not one caption
|
| 236 |
+
# in the corpus is a definition.** They are figure titles — "Figure 1.3.
|
| 237 |
+
# Relative ability to influence costs (Lee, 1984)", "Gambar 2.1 Analisis
|
| 238 |
+
# Gain/Loss Berdasarkan Setiap Parameter Produksi". A model declining to quote
|
| 239 |
+
# one as a definition is CORRECT, not a retrieval failure.
|
| 240 |
+
#
|
| 241 |
+
# If revived, weight it BELOW `term_in_heading`: a heading naming a term means
|
| 242 |
+
# "this section is about X"; a caption naming it means only "a figure about X
|
| 243 |
+
# is here".
|
| 244 |
+
"asset_caption_names_term": 0.0,
|
| 245 |
"formula_present": 2.0,
|
| 246 |
"bold_or_italic": 1.5,
|
| 247 |
"first_occurrence": 1.0,
|
|
|
|
| 262 |
CACHE_MIN_TOKENS = 1024
|
| 263 |
|
| 264 |
# ── Validation ──────────────────────────────────────────────────────────
|
| 265 |
+
# ⚠️ RETIRED 2026-09-11 by setting it to 0 — the loop and its call site are
|
| 266 |
+
# untouched (comment-out-don't-delete), and restoring it is this one character.
|
| 267 |
+
#
|
| 268 |
+
# Finding 6 asked for a number either way. Here it is, counted off the six
|
| 269 |
+
# persisted paid runs (v1 K=3 and v2 K=5, three documents):
|
| 270 |
+
#
|
| 271 |
+
# glossary calls clusters extra calls definitions FROM escalation
|
| 272 |
+
# BUMA v1 69 66 3 0
|
| 273 |
+
# BUMA v2 83 83 0 0
|
| 274 |
+
# Komatsu v1 94 75 19 0
|
| 275 |
+
# Komatsu v2 102 93 9 0
|
| 276 |
+
# Open Pit v1 361 349 12 0
|
| 277 |
+
# Open Pit v2 430 425 5 0
|
| 278 |
+
#
|
| 279 |
+
# 48 escalation calls across three documents and two configurations, and NOT ONE
|
| 280 |
+
# of them produced a definition the first round had missed. `extraction_status`
|
| 281 |
+
# is set to "escalated" only when a later round succeeds where round 0 failed,
|
| 282 |
+
# and it appears zero times in every one of those six files.
|
| 283 |
+
#
|
| 284 |
+
# Why it fails is worth keeping, because it argues against reviving it naively:
|
| 285 |
+
# escalation hands the model the NEXT k evidence chunks, ranked below the ones it
|
| 286 |
+
# already declined. The 2026-09-10 ranker ceiling check found 16 of 18 gold
|
| 287 |
+
# definitions at rank <=3 — so if the definition is anywhere, it was already in
|
| 288 |
+
# round 0, and ranks 6-10 are where it is least likely to be. Raising EVIDENCE_K
|
| 289 |
+
# to 5 made this worse in both directions at once: escalation needs 6+ evidence
|
| 290 |
+
# chunks to fire at all, and the rounds it does reach are even further down.
|
| 291 |
+
#
|
| 292 |
+
# What would justify reviving it: a retry that changes the QUESTION (a different
|
| 293 |
+
# prompt, a larger tier) rather than one that changes the evidence to lower-ranked
|
| 294 |
+
# evidence. That is a different feature, not this constant.
|
| 295 |
+
MAX_ESCALATION_ROUNDS = 0
|
| 296 |
CONFLICT_OVERLAP_THRESHOLD = 0.4
|
| 297 |
DUPLICATE_OVERLAP_THRESHOLD = 0.8
|
| 298 |
|
|
@@ -34,7 +34,7 @@ import uuid
|
|
| 34 |
from datetime import UTC, datetime
|
| 35 |
from typing import Any
|
| 36 |
|
| 37 |
-
from sqlalchemy import select
|
| 38 |
|
| 39 |
from src.db.postgres.connection import AsyncSessionLocal
|
| 40 |
from src.db.postgres.models import (
|
|
@@ -43,6 +43,7 @@ from src.db.postgres.models import (
|
|
| 43 |
KnowledgeExtractionRunRow,
|
| 44 |
KnowledgeReviewRow,
|
| 45 |
)
|
|
|
|
| 46 |
from src.middlewares.logging import get_logger
|
| 47 |
|
| 48 |
logger = get_logger("knowledge_store")
|
|
@@ -62,6 +63,84 @@ _ENTRY_KINDS: dict[str, tuple[str, str]] = {
|
|
| 62 |
}
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
def _label(payload: dict[str, Any], key: str) -> str | None:
|
| 66 |
"""The listing label, trimmed. A rule's `statement` can be a paragraph, and
|
| 67 |
a column meant for scanning a queue should not carry one."""
|
|
@@ -95,6 +174,43 @@ class KnowledgeStore:
|
|
| 95 |
)
|
| 96 |
return result.scalars().first()
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
async def save_document(
|
| 99 |
self,
|
| 100 |
artifact: Any,
|
|
@@ -120,9 +236,8 @@ class KnowledgeStore:
|
|
| 120 |
)
|
| 121 |
doc_id = payload["doc_id"]
|
| 122 |
content_hash = payload["content_hash"]
|
| 123 |
-
version = int(payload.get("version", 1))
|
| 124 |
|
| 125 |
-
existing = await self.
|
| 126 |
if existing is not None:
|
| 127 |
logger.info(
|
| 128 |
"knowledge_document_reused",
|
|
@@ -130,6 +245,20 @@ class KnowledgeStore:
|
|
| 130 |
)
|
| 131 |
return existing
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
row = KnowledgeDocumentRow(
|
| 134 |
id=str(uuid.uuid4()),
|
| 135 |
doc_id=doc_id,
|
|
@@ -262,6 +391,10 @@ class KnowledgeStore:
|
|
| 262 |
# so, hence the warning and the count on the run row.
|
| 263 |
seen: set[tuple[str, str]] = set()
|
| 264 |
collisions: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
for kind, payloads in entries_by_kind.items():
|
| 266 |
if kind not in _ENTRY_KINDS:
|
| 267 |
raise ValueError(f"unknown entry kind {kind!r}")
|
|
@@ -277,6 +410,7 @@ class KnowledgeStore:
|
|
| 277 |
collisions.append(f"{kind}:{entity_id}")
|
| 278 |
continue
|
| 279 |
seen.add((kind, entity_id))
|
|
|
|
| 280 |
rows.append(
|
| 281 |
KnowledgeEntryRow(
|
| 282 |
id=str(uuid.uuid4()),
|
|
@@ -287,7 +421,19 @@ class KnowledgeStore:
|
|
| 287 |
user_id=user_id,
|
| 288 |
kind=kind,
|
| 289 |
entity_id=entity_id,
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
mention_count=int(payload.get("mention_count") or 0),
|
| 292 |
extraction_status=payload.get("extraction_status"),
|
| 293 |
diff_status=payload.get("diff_status"),
|
|
@@ -295,6 +441,12 @@ class KnowledgeStore:
|
|
| 295 |
payload.get("definition_conflict", False)
|
| 296 |
),
|
| 297 |
payload=payload,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
)
|
| 299 |
)
|
| 300 |
|
|
@@ -349,6 +501,188 @@ class KnowledgeStore:
|
|
| 349 |
result = await session.execute(stmt)
|
| 350 |
return list(result.scalars().all())
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
async def latest_run_for(
|
| 353 |
self, scope_id: str, doc_id: str
|
| 354 |
) -> KnowledgeExtractionRunRow | None:
|
|
@@ -435,6 +769,8 @@ class KnowledgeStore:
|
|
| 435 |
reviewer_id: str,
|
| 436 |
edited_payload: dict | None = None,
|
| 437 |
note: str | None = None,
|
|
|
|
|
|
|
| 438 |
) -> KnowledgeReviewRow:
|
| 439 |
"""Record one expert decision. APPEND-ONLY.
|
| 440 |
|
|
@@ -468,6 +804,8 @@ class KnowledgeStore:
|
|
| 468 |
edited_payload=edited_payload,
|
| 469 |
note=note,
|
| 470 |
reviewer_id=reviewer_id,
|
|
|
|
|
|
|
| 471 |
)
|
| 472 |
session.add(row)
|
| 473 |
await session.commit()
|
|
@@ -478,11 +816,65 @@ class KnowledgeStore:
|
|
| 478 |
extra={
|
| 479 |
"entity_id": entity_id,
|
| 480 |
"decision": decision,
|
|
|
|
| 481 |
"content_hash": content_hash[:12],
|
| 482 |
},
|
| 483 |
)
|
| 484 |
return row
|
| 485 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
async def latest_reviews(
|
| 487 |
self, scope_id: str, entity_ids: list[str] | None = None
|
| 488 |
) -> dict[str, KnowledgeReviewRow]:
|
|
@@ -513,6 +905,33 @@ class KnowledgeStore:
|
|
| 513 |
is worked. An `edited` decision contributes the expert's corrected
|
| 514 |
payload, not the model's original claim.
|
| 515 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
reviews = await self.latest_reviews(scope_id)
|
| 517 |
approved = {
|
| 518 |
entity_id: review
|
|
@@ -520,31 +939,69 @@ class KnowledgeStore:
|
|
| 520 |
if review.decision in ("approved", "edited")
|
| 521 |
}
|
| 522 |
if not approved:
|
| 523 |
-
return []
|
| 524 |
|
|
|
|
| 525 |
async with AsyncSessionLocal() as session:
|
| 526 |
result = await session.execute(
|
| 527 |
select(KnowledgeEntryRow).where(
|
| 528 |
KnowledgeEntryRow.scope_id == scope_id,
|
| 529 |
-
KnowledgeEntryRow.kind ==
|
| 530 |
KnowledgeEntryRow.entity_id.in_(list(approved)),
|
| 531 |
)
|
| 532 |
)
|
| 533 |
rows = list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
|
| 535 |
out: list[dict] = []
|
|
|
|
| 536 |
seen: set[str] = set()
|
| 537 |
for row in sorted(rows, key=lambda r: r.created_at or 0, reverse=True):
|
| 538 |
if row.entity_id in seen:
|
| 539 |
continue
|
| 540 |
seen.add(row.entity_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
review = approved[row.entity_id]
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
)
|
| 547 |
-
return out
|
| 548 |
|
| 549 |
async def save_declaration(
|
| 550 |
self, *, scope_id: str, user_id: str, payload: dict
|
|
@@ -588,7 +1045,11 @@ class KnowledgeStore:
|
|
| 588 |
entity_id=entity_id,
|
| 589 |
)
|
| 590 |
session.add(row)
|
| 591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
row.payload = payload
|
| 593 |
row.extraction_status = "ok"
|
| 594 |
await session.commit()
|
|
|
|
| 34 |
from datetime import UTC, datetime
|
| 35 |
from typing import Any
|
| 36 |
|
| 37 |
+
from sqlalchemy import select, update
|
| 38 |
|
| 39 |
from src.db.postgres.connection import AsyncSessionLocal
|
| 40 |
from src.db.postgres.models import (
|
|
|
|
| 43 |
KnowledgeExtractionRunRow,
|
| 44 |
KnowledgeReviewRow,
|
| 45 |
)
|
| 46 |
+
from src.knowledge_extraction import keys, mutation
|
| 47 |
from src.middlewares.logging import get_logger
|
| 48 |
|
| 49 |
logger = get_logger("knowledge_store")
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
|
| 66 |
+
# m4: the body a reviewer or a consumer actually wants, per kind. First
|
| 67 |
+
# non-empty wins, so a kind whose primary field abstained still promotes
|
| 68 |
+
# something rather than silently leaving the column null.
|
| 69 |
+
_CONTENT_FIELDS: dict[str, tuple[str, ...]] = {
|
| 70 |
+
"glossary": ("definition",),
|
| 71 |
+
"rule": ("statement", "consequence"),
|
| 72 |
+
"formula": ("formula_latex", "name"),
|
| 73 |
+
"document": ("purpose_verbatim", "title"),
|
| 74 |
+
"domain": ("purpose_verbatim", "domain_name"),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
# m5: the provenance keys worth lifting out. `page` is 0-based as the parser
|
| 78 |
+
# reports it and `page_no` is the 1-based number a human reads; both travel
|
| 79 |
+
# because they are derived from one value and cannot disagree.
|
| 80 |
+
_PROVENANCE_KEYS = ("doc_id", "page", "page_no", "section_no", "chunk_id")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _content(kind: str, payload: dict[str, Any]) -> str | None:
|
| 84 |
+
"""m4: the entry's body, promoted out of `payload`."""
|
| 85 |
+
for field in _CONTENT_FIELDS.get(kind, ()):
|
| 86 |
+
value = payload.get(field)
|
| 87 |
+
if isinstance(value, str) and value.strip():
|
| 88 |
+
return value.strip()
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _provenance(payload: dict[str, Any]) -> list[dict]:
|
| 93 |
+
"""m5: provenance as a LIST, so a fused entry can carry several sources.
|
| 94 |
+
|
| 95 |
+
The extraction contract emits a single object per entry today; it is
|
| 96 |
+
wrapped rather than reshaped, because the list is what an entry drawn from
|
| 97 |
+
more than one document needs and normalising the shape here means every
|
| 98 |
+
consumer reads one thing.
|
| 99 |
+
"""
|
| 100 |
+
raw = payload.get("provenance")
|
| 101 |
+
if isinstance(raw, dict):
|
| 102 |
+
raw = [raw]
|
| 103 |
+
if not isinstance(raw, list):
|
| 104 |
+
return []
|
| 105 |
+
out: list[dict] = []
|
| 106 |
+
for item in raw:
|
| 107 |
+
if not isinstance(item, dict):
|
| 108 |
+
continue
|
| 109 |
+
kept = {k: item[k] for k in _PROVENANCE_KEYS if item.get(k) is not None}
|
| 110 |
+
if kept:
|
| 111 |
+
out.append(kept)
|
| 112 |
+
return out
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _doc_ids(doc_id: str, provenance: list[dict]) -> list[str]:
|
| 116 |
+
"""m3: every document behind this entry, this one first.
|
| 117 |
+
|
| 118 |
+
Derived rather than declared, so it cannot drift from the provenance it
|
| 119 |
+
summarises. For an ordinary entry it is `[doc_id]`; it grows only when a
|
| 120 |
+
definition was carried forward from an earlier document that a later one
|
| 121 |
+
re-mentions (i2), which is the fusion case m3 was asked for.
|
| 122 |
+
"""
|
| 123 |
+
ids = [doc_id]
|
| 124 |
+
for item in provenance:
|
| 125 |
+
source = item.get("doc_id")
|
| 126 |
+
if isinstance(source, str) and source and source not in ids:
|
| 127 |
+
ids.append(source)
|
| 128 |
+
return ids
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _has_live_source(row: Any, withdrawn: set[str]) -> bool:
|
| 132 |
+
"""Does this entry still rest on a document that has not been withdrawn? (i7)
|
| 133 |
+
|
| 134 |
+
Reads `doc_ids` when it is populated and falls back to `doc_id` when it is
|
| 135 |
+
not, so rows written before m3 landed keep answering correctly instead of
|
| 136 |
+
all looking orphaned at once.
|
| 137 |
+
"""
|
| 138 |
+
sources = [d for d in (getattr(row, "doc_ids", None) or []) if isinstance(d, str)]
|
| 139 |
+
if not sources:
|
| 140 |
+
sources = [row.doc_id]
|
| 141 |
+
return any(source not in withdrawn for source in sources)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
def _label(payload: dict[str, Any], key: str) -> str | None:
|
| 145 |
"""The listing label, trimmed. A rule's `statement` can be a paragraph, and
|
| 146 |
a column meant for scanning a queue should not carry one."""
|
|
|
|
| 174 |
)
|
| 175 |
return result.scalars().first()
|
| 176 |
|
| 177 |
+
async def find_document_by_hash(
|
| 178 |
+
self, doc_id: str, content_hash: str
|
| 179 |
+
) -> KnowledgeDocumentRow | None:
|
| 180 |
+
"""The same content-addressing lookup, across every version (i5).
|
| 181 |
+
|
| 182 |
+
Version is assigned by us now, not read off the artifact, so the reuse
|
| 183 |
+
check cannot key on it: computing the next version and *then* looking
|
| 184 |
+
it up would miss the row that already holds these exact bytes and
|
| 185 |
+
re-parse a document we already have — turning the idempotency guarantee
|
| 186 |
+
into a paid no-op.
|
| 187 |
+
"""
|
| 188 |
+
async with AsyncSessionLocal() as session:
|
| 189 |
+
result = await session.execute(
|
| 190 |
+
select(KnowledgeDocumentRow)
|
| 191 |
+
.where(
|
| 192 |
+
KnowledgeDocumentRow.doc_id == doc_id,
|
| 193 |
+
KnowledgeDocumentRow.content_hash == content_hash,
|
| 194 |
+
)
|
| 195 |
+
.order_by(KnowledgeDocumentRow.version.desc())
|
| 196 |
+
)
|
| 197 |
+
return result.scalars().first()
|
| 198 |
+
|
| 199 |
+
async def latest_document(self, doc_id: str) -> KnowledgeDocumentRow | None:
|
| 200 |
+
"""The current version of a document — the highest `version` (i5).
|
| 201 |
+
|
| 202 |
+
What "current" means for a document that has been amended: the newest
|
| 203 |
+
version supersedes, and the older rows stay for the audit trail rather
|
| 204 |
+
than being rewritten.
|
| 205 |
+
"""
|
| 206 |
+
async with AsyncSessionLocal() as session:
|
| 207 |
+
result = await session.execute(
|
| 208 |
+
select(KnowledgeDocumentRow)
|
| 209 |
+
.where(KnowledgeDocumentRow.doc_id == doc_id)
|
| 210 |
+
.order_by(KnowledgeDocumentRow.version.desc())
|
| 211 |
+
)
|
| 212 |
+
return result.scalars().first()
|
| 213 |
+
|
| 214 |
async def save_document(
|
| 215 |
self,
|
| 216 |
artifact: Any,
|
|
|
|
| 236 |
)
|
| 237 |
doc_id = payload["doc_id"]
|
| 238 |
content_hash = payload["content_hash"]
|
|
|
|
| 239 |
|
| 240 |
+
existing = await self.find_document_by_hash(doc_id, content_hash)
|
| 241 |
if existing is not None:
|
| 242 |
logger.info(
|
| 243 |
"knowledge_document_reused",
|
|
|
|
| 245 |
)
|
| 246 |
return existing
|
| 247 |
|
| 248 |
+
# i5 (ruling 2026-09-14): identity is `doc_id`, so the same document
|
| 249 |
+
# arriving with different bytes is a NEW VERSION of it, never a new
|
| 250 |
+
# document. That is what keeps an amended file's approvals attached —
|
| 251 |
+
# `entity_id` derives from `doc_id`, which does not move. The artifact's
|
| 252 |
+
# own `version` is ignored: the parsing half stamps 1 on every parse and
|
| 253 |
+
# has no way to know what this tenant already holds.
|
| 254 |
+
previous = await self.latest_document(doc_id)
|
| 255 |
+
version = (previous.version + 1) if previous else 1
|
| 256 |
+
if previous is not None:
|
| 257 |
+
logger.info(
|
| 258 |
+
"knowledge_document_versioned",
|
| 259 |
+
extra={"doc_id": doc_id, "version": version, "supersedes": previous.id},
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
row = KnowledgeDocumentRow(
|
| 263 |
id=str(uuid.uuid4()),
|
| 264 |
doc_id=doc_id,
|
|
|
|
| 391 |
# so, hence the warning and the count on the run row.
|
| 392 |
seen: set[tuple[str, str]] = set()
|
| 393 |
collisions: list[str] = []
|
| 394 |
+
# m1/m6: `label` carries the readable `TYPE_SUBJECT` key. Rules key off
|
| 395 |
+
# the term they govern, and a rule payload holds `term_ids` rather than
|
| 396 |
+
# words, so the surfaces are collected from this run's glossary first.
|
| 397 |
+
surfaces = keys.term_surfaces(entries_by_kind.get("glossary"))
|
| 398 |
for kind, payloads in entries_by_kind.items():
|
| 399 |
if kind not in _ENTRY_KINDS:
|
| 400 |
raise ValueError(f"unknown entry kind {kind!r}")
|
|
|
|
| 410 |
collisions.append(f"{kind}:{entity_id}")
|
| 411 |
continue
|
| 412 |
seen.add((kind, entity_id))
|
| 413 |
+
prov = _provenance(payload)
|
| 414 |
rows.append(
|
| 415 |
KnowledgeEntryRow(
|
| 416 |
id=str(uuid.uuid4()),
|
|
|
|
| 421 |
user_id=user_id,
|
| 422 |
kind=kind,
|
| 423 |
entity_id=entity_id,
|
| 424 |
+
# m6 (2026-09-08): the key, not the term. The readable
|
| 425 |
+
# term is still in `payload`, so nothing is lost to a
|
| 426 |
+
# reader — but the column an agent searches now holds
|
| 427 |
+
# something it can type from memory. `_label` remains
|
| 428 |
+
# the fallback for a kind with no registered prefix.
|
| 429 |
+
label=keys.concept_key(
|
| 430 |
+
kind,
|
| 431 |
+
payload,
|
| 432 |
+
entity_id=entity_id,
|
| 433 |
+
doc_id=doc_id,
|
| 434 |
+
surfaces=surfaces,
|
| 435 |
+
)
|
| 436 |
+
or _label(payload, label_field),
|
| 437 |
mention_count=int(payload.get("mention_count") or 0),
|
| 438 |
extraction_status=payload.get("extraction_status"),
|
| 439 |
diff_status=payload.get("diff_status"),
|
|
|
|
| 441 |
payload.get("definition_conflict", False)
|
| 442 |
),
|
| 443 |
payload=payload,
|
| 444 |
+
# m3/m4/m5: promoted from the SAME object in the SAME
|
| 445 |
+
# insert, so the column and the jsonb can never
|
| 446 |
+
# disagree about what this entry says.
|
| 447 |
+
content=_content(kind, payload),
|
| 448 |
+
provenance=prov,
|
| 449 |
+
doc_ids=_doc_ids(doc_id, prov),
|
| 450 |
)
|
| 451 |
)
|
| 452 |
|
|
|
|
| 501 |
result = await session.execute(stmt)
|
| 502 |
return list(result.scalars().all())
|
| 503 |
|
| 504 |
+
async def get_by_key(
|
| 505 |
+
self, scope_id: str, key: str
|
| 506 |
+
) -> KnowledgeEntryRow | None:
|
| 507 |
+
"""One entry by its readable key (m8) — `GLOSSARY_PA`, not a search.
|
| 508 |
+
|
| 509 |
+
This is the lookup the 2026-09-08 discussion asked for: an agent that
|
| 510 |
+
meets a term it does not know writes the key and hits the row. **No
|
| 511 |
+
model, no embedding, no scan of payloads** — the whole reason the key
|
| 512 |
+
exists is that finding a glossary term should not cost a round trip to
|
| 513 |
+
a language model.
|
| 514 |
+
|
| 515 |
+
Keys are uppercased on the way in so a caller that types one from
|
| 516 |
+
memory in lower case still lands, matching how they are minted.
|
| 517 |
+
|
| 518 |
+
Note for whoever adds the index: this filters on `label`, which has no
|
| 519 |
+
index today, so Postgres scans within the tenant. Correct, and fine at
|
| 520 |
+
the current corpus size — but it is an index away from being the O(1)
|
| 521 |
+
the design intends, and that index rides along with the m3/m4/m5 DDL.
|
| 522 |
+
"""
|
| 523 |
+
stmt = (
|
| 524 |
+
select(KnowledgeEntryRow)
|
| 525 |
+
.where(KnowledgeEntryRow.scope_id == scope_id)
|
| 526 |
+
.where(KnowledgeEntryRow.label == (key or "").strip().upper())
|
| 527 |
+
.order_by(KnowledgeEntryRow.created_at.desc())
|
| 528 |
+
.limit(1)
|
| 529 |
+
)
|
| 530 |
+
async with AsyncSessionLocal() as session:
|
| 531 |
+
result = await session.execute(stmt)
|
| 532 |
+
return result.scalars().first()
|
| 533 |
+
|
| 534 |
+
async def glossary_index(
|
| 535 |
+
self, scope_id: str, *, limit: int = 1000
|
| 536 |
+
) -> dict[str, dict]:
|
| 537 |
+
"""The company's glossary as a dictionary keyed by concept key (m8).
|
| 538 |
+
|
| 539 |
+
`{"GLOSSARY_PA": {...payload...}, "GLOSSARY_QTY": {...}}` — the shape
|
| 540 |
+
Mas asked for when he said the glossary should be key-value rather than
|
| 541 |
+
a list. Searching a list is O(N) and searching this is O(1); it is also
|
| 542 |
+
the shape the JSON export writes, so the file on disk and the lookup in
|
| 543 |
+
memory agree with each other rather than drifting.
|
| 544 |
+
|
| 545 |
+
A row whose key could not be built is skipped rather than given a
|
| 546 |
+
made-up one: a glossary you cannot trust the keys of is worse than a
|
| 547 |
+
smaller glossary.
|
| 548 |
+
"""
|
| 549 |
+
rows = await self.list_entries(
|
| 550 |
+
scope_id=scope_id, kind="glossary", limit=limit
|
| 551 |
+
)
|
| 552 |
+
index: dict[str, dict] = {}
|
| 553 |
+
for row in rows:
|
| 554 |
+
if row.label and isinstance(row.payload, dict):
|
| 555 |
+
index.setdefault(row.label, row.payload)
|
| 556 |
+
return index
|
| 557 |
+
|
| 558 |
+
async def review_diff(
|
| 559 |
+
self, scope_id: str, entity_id: str, kind: str = "glossary"
|
| 560 |
+
) -> dict | None:
|
| 561 |
+
"""Before / after for one entry, for the review screen (r4).
|
| 562 |
+
|
| 563 |
+
Returns None when there is nothing to compare — no decision on record,
|
| 564 |
+
or the entry is gone. `changed` is empty when the approval still
|
| 565 |
+
stands, which is the common case and the one worth making cheap: the
|
| 566 |
+
screen can say "nothing to re-read" instead of showing two identical
|
| 567 |
+
panes.
|
| 568 |
+
|
| 569 |
+
`before` is what the expert had in front of them; for an `edited`
|
| 570 |
+
decision that is their own corrected text, not the model's claim.
|
| 571 |
+
"""
|
| 572 |
+
reviews = await self.latest_reviews(scope_id, [entity_id])
|
| 573 |
+
review = reviews.get(entity_id)
|
| 574 |
+
if review is None:
|
| 575 |
+
return None
|
| 576 |
+
|
| 577 |
+
rows = await self.list_entries(scope_id=scope_id, kind=kind, limit=1000)
|
| 578 |
+
current = next(
|
| 579 |
+
(r for r in sorted(rows, key=lambda r: r.created_at or 0, reverse=True)
|
| 580 |
+
if r.entity_id == entity_id),
|
| 581 |
+
None,
|
| 582 |
+
)
|
| 583 |
+
if current is None:
|
| 584 |
+
return None
|
| 585 |
+
|
| 586 |
+
if review.decision == "edited" and review.edited_payload:
|
| 587 |
+
before = review.edited_payload
|
| 588 |
+
else:
|
| 589 |
+
async with AsyncSessionLocal() as session:
|
| 590 |
+
result = await session.execute(
|
| 591 |
+
select(KnowledgeEntryRow).where(
|
| 592 |
+
KnowledgeEntryRow.id == review.entry_id
|
| 593 |
+
)
|
| 594 |
+
)
|
| 595 |
+
reviewed_row = result.scalars().first()
|
| 596 |
+
before = reviewed_row.payload if reviewed_row else None
|
| 597 |
+
|
| 598 |
+
changed = mutation.changed_fields(kind, before, current.payload)
|
| 599 |
+
return {
|
| 600 |
+
"entity_id": entity_id,
|
| 601 |
+
"kind": kind,
|
| 602 |
+
"decision": review.decision,
|
| 603 |
+
"decided_at": review.created_at,
|
| 604 |
+
"changed": changed,
|
| 605 |
+
"status": "waiting_approval" if changed else review.decision,
|
| 606 |
+
"before": mutation.ruled_content(kind, before),
|
| 607 |
+
"after": mutation.ruled_content(kind, current.payload),
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
async def withdraw_document(self, scope_id: str, doc_id: str) -> int:
|
| 611 |
+
"""Mark every version of a document withdrawn (i7). Returns how many.
|
| 612 |
+
|
| 613 |
+
**Nothing is deleted**, here or downstream. The entries the document
|
| 614 |
+
produced stay exactly where they are, and so do the reviews attached to
|
| 615 |
+
them — an approval is an act someone took, and destroying the record of
|
| 616 |
+
what it referred to would destroy the audit trail that makes the
|
| 617 |
+
approval mean anything.
|
| 618 |
+
|
| 619 |
+
What withdrawal *does* change is what the knowledge base will serve: a
|
| 620 |
+
withdrawn document stops feeding the approved baseline, so the pipeline
|
| 621 |
+
cannot keep answering from a standard the client has taken back. That
|
| 622 |
+
was the concern behind the whole discussion — never serve knowledge the
|
| 623 |
+
source no longer supports.
|
| 624 |
+
"""
|
| 625 |
+
async with AsyncSessionLocal() as session:
|
| 626 |
+
result = await session.execute(
|
| 627 |
+
update(KnowledgeDocumentRow)
|
| 628 |
+
.where(
|
| 629 |
+
KnowledgeDocumentRow.scope_id == scope_id,
|
| 630 |
+
KnowledgeDocumentRow.doc_id == doc_id,
|
| 631 |
+
KnowledgeDocumentRow.withdrawn_at.is_(None),
|
| 632 |
+
)
|
| 633 |
+
.values(withdrawn_at=datetime.now(UTC))
|
| 634 |
+
)
|
| 635 |
+
await session.commit()
|
| 636 |
+
count = result.rowcount or 0
|
| 637 |
+
logger.info(
|
| 638 |
+
"knowledge_document_withdrawn",
|
| 639 |
+
extra={"scope_id": scope_id, "doc_id": doc_id, "versions": count},
|
| 640 |
+
)
|
| 641 |
+
return count
|
| 642 |
+
|
| 643 |
+
async def withdrawn_doc_ids(self, scope_id: str) -> set[str]:
|
| 644 |
+
"""Documents this company has taken back."""
|
| 645 |
+
async with AsyncSessionLocal() as session:
|
| 646 |
+
result = await session.execute(
|
| 647 |
+
select(KnowledgeDocumentRow.doc_id).where(
|
| 648 |
+
KnowledgeDocumentRow.scope_id == scope_id,
|
| 649 |
+
KnowledgeDocumentRow.withdrawn_at.is_not(None),
|
| 650 |
+
)
|
| 651 |
+
)
|
| 652 |
+
return {r for (r,) in result.all() if r}
|
| 653 |
+
|
| 654 |
+
async def orphaned_entities(self, scope_id: str, kind: str = "glossary") -> set[str]:
|
| 655 |
+
"""Entries whose every source document has been withdrawn (i7).
|
| 656 |
+
|
| 657 |
+
Surfaced for the expert to rule on rather than deleted: whether the
|
| 658 |
+
knowledge leaves with the document is their judgement, not the
|
| 659 |
+
pipeline's. A term defined in a withdrawn standard may well still be
|
| 660 |
+
the company's term.
|
| 661 |
+
"""
|
| 662 |
+
withdrawn = await self.withdrawn_doc_ids(scope_id)
|
| 663 |
+
if not withdrawn:
|
| 664 |
+
return set()
|
| 665 |
+
rows = await self.list_entries(scope_id=scope_id, kind=kind, limit=1000)
|
| 666 |
+
return {r.entity_id for r in rows if not _has_live_source(r, withdrawn)}
|
| 667 |
+
|
| 668 |
+
async def impacted_by_document(
|
| 669 |
+
self, scope_id: str, doc_id: str, limit: int = 1000
|
| 670 |
+
) -> list[KnowledgeEntryRow]:
|
| 671 |
+
"""Which entries a document contributed to — the impact map (i4).
|
| 672 |
+
|
| 673 |
+
This is what makes a re-ingest proportionate: a document is amended,
|
| 674 |
+
these are the entries that can possibly have moved, and **only these**
|
| 675 |
+
go back to the reviewer. Everything else the company has approved is
|
| 676 |
+
untouched and stays untouched.
|
| 677 |
+
|
| 678 |
+
No separate lineage structure is needed for it — the contribution is
|
| 679 |
+
already recorded on the entry. Once `doc_ids` lands (m3) this also has
|
| 680 |
+
to match membership in that list, for entries fused from several
|
| 681 |
+
documents; today every entry has exactly one source, so the single
|
| 682 |
+
column is the whole answer.
|
| 683 |
+
"""
|
| 684 |
+
return await self.list_entries(scope_id=scope_id, doc_id=doc_id, limit=limit)
|
| 685 |
+
|
| 686 |
async def latest_run_for(
|
| 687 |
self, scope_id: str, doc_id: str
|
| 688 |
) -> KnowledgeExtractionRunRow | None:
|
|
|
|
| 769 |
reviewer_id: str,
|
| 770 |
edited_payload: dict | None = None,
|
| 771 |
note: str | None = None,
|
| 772 |
+
decided_by: str = "human",
|
| 773 |
+
authorised_by: str | None = None,
|
| 774 |
) -> KnowledgeReviewRow:
|
| 775 |
"""Record one expert decision. APPEND-ONLY.
|
| 776 |
|
|
|
|
| 804 |
edited_payload=edited_payload,
|
| 805 |
note=note,
|
| 806 |
reviewer_id=reviewer_id,
|
| 807 |
+
decided_by=decided_by,
|
| 808 |
+
authorised_by=authorised_by,
|
| 809 |
)
|
| 810 |
session.add(row)
|
| 811 |
await session.commit()
|
|
|
|
| 816 |
extra={
|
| 817 |
"entity_id": entity_id,
|
| 818 |
"decision": decision,
|
| 819 |
+
"decided_by": decided_by,
|
| 820 |
"content_hash": content_hash[:12],
|
| 821 |
},
|
| 822 |
)
|
| 823 |
return row
|
| 824 |
|
| 825 |
+
async def auto_approve_run(
|
| 826 |
+
self, *, run_id: str, scope_id: str, authorised_by: str
|
| 827 |
+
) -> int:
|
| 828 |
+
"""Approve every entry a run produced, on a named person's authority (r1).
|
| 829 |
+
|
| 830 |
+
This is what `review_mode="llm"` does, and it is worth being plain about
|
| 831 |
+
what it is: **no model re-examines anything.** The entries were written
|
| 832 |
+
by a model already; choosing this mode means accepting them without a
|
| 833 |
+
human looking. There is no second opinion in here.
|
| 834 |
+
|
| 835 |
+
What makes it offerable rather than reckless is the record. Every row
|
| 836 |
+
carries `decided_by="llm"` and `authorised_by=<the person who chose
|
| 837 |
+
it>`, so an entry approved this way can always answer "who allowed
|
| 838 |
+
this?" — which was the entire point when it was agreed: if something
|
| 839 |
+
goes wrong it was their choice, made knowingly, and the system can show
|
| 840 |
+
whose.
|
| 841 |
+
|
| 842 |
+
Written through the ordinary append-only review path, so an expert can
|
| 843 |
+
later disagree with any of it exactly as they would with each other.
|
| 844 |
+
"""
|
| 845 |
+
rows = await self.list_entries(scope_id=scope_id, run_id=run_id, limit=1000)
|
| 846 |
+
approved = 0
|
| 847 |
+
for row in rows:
|
| 848 |
+
try:
|
| 849 |
+
await self.save_review(
|
| 850 |
+
entity_id=row.entity_id,
|
| 851 |
+
scope_id=scope_id,
|
| 852 |
+
decision="approved",
|
| 853 |
+
reviewer_id="llm",
|
| 854 |
+
decided_by="llm",
|
| 855 |
+
authorised_by=authorised_by,
|
| 856 |
+
note=f"auto-approved (review_mode=llm) on behalf of {authorised_by}",
|
| 857 |
+
)
|
| 858 |
+
approved += 1
|
| 859 |
+
except LookupError:
|
| 860 |
+
# The entry vanished between listing and reviewing. Not fatal:
|
| 861 |
+
# an un-approved entry is a reviewable one, which is the safe
|
| 862 |
+
# direction to fail in.
|
| 863 |
+
logger.warning(
|
| 864 |
+
"knowledge_auto_approve_skipped",
|
| 865 |
+
extra={"entity_id": row.entity_id, "run_id": run_id},
|
| 866 |
+
)
|
| 867 |
+
logger.info(
|
| 868 |
+
"knowledge_run_auto_approved",
|
| 869 |
+
extra={
|
| 870 |
+
"run_id": run_id,
|
| 871 |
+
"scope_id": scope_id,
|
| 872 |
+
"approved": approved,
|
| 873 |
+
"authorised_by": authorised_by,
|
| 874 |
+
},
|
| 875 |
+
)
|
| 876 |
+
return approved
|
| 877 |
+
|
| 878 |
async def latest_reviews(
|
| 879 |
self, scope_id: str, entity_ids: list[str] | None = None
|
| 880 |
) -> dict[str, KnowledgeReviewRow]:
|
|
|
|
| 905 |
is worked. An `edited` decision contributes the expert's corrected
|
| 906 |
payload, not the model's original claim.
|
| 907 |
"""
|
| 908 |
+
fresh, _stale = await self._approval_state(scope_id, "glossary")
|
| 909 |
+
return fresh
|
| 910 |
+
|
| 911 |
+
async def stale_approvals(
|
| 912 |
+
self, scope_id: str, kind: str = "glossary"
|
| 913 |
+
) -> set[str]:
|
| 914 |
+
"""Entity ids whose approval no longer stands — 'termutasi' (i3).
|
| 915 |
+
|
| 916 |
+
A later document changed the content the expert ruled on, so the entry
|
| 917 |
+
is back to waiting approval and must not feed the baseline in the
|
| 918 |
+
meantime. What counts as changed is `mutation.RULED_FIELDS`, and it is
|
| 919 |
+
deliberately narrow — see that module for why a re-mention must not
|
| 920 |
+
re-open anything.
|
| 921 |
+
"""
|
| 922 |
+
_fresh, stale = await self._approval_state(scope_id, kind)
|
| 923 |
+
return stale
|
| 924 |
+
|
| 925 |
+
async def _approval_state(
|
| 926 |
+
self, scope_id: str, kind: str
|
| 927 |
+
) -> tuple[list[dict], set[str]]:
|
| 928 |
+
"""Split approved entries into those that still stand and those that do not.
|
| 929 |
+
|
| 930 |
+
One pass, because the baseline and the status display must never
|
| 931 |
+
disagree about whether an approval is current — the same defect as
|
| 932 |
+
having two notions of readiness, which is why `readiness.py` shares one
|
| 933 |
+
predicate between the report API and Help.
|
| 934 |
+
"""
|
| 935 |
reviews = await self.latest_reviews(scope_id)
|
| 936 |
approved = {
|
| 937 |
entity_id: review
|
|
|
|
| 939 |
if review.decision in ("approved", "edited")
|
| 940 |
}
|
| 941 |
if not approved:
|
| 942 |
+
return [], set()
|
| 943 |
|
| 944 |
+
reviewed_ids = [r.entry_id for r in approved.values() if r.entry_id]
|
| 945 |
async with AsyncSessionLocal() as session:
|
| 946 |
result = await session.execute(
|
| 947 |
select(KnowledgeEntryRow).where(
|
| 948 |
KnowledgeEntryRow.scope_id == scope_id,
|
| 949 |
+
KnowledgeEntryRow.kind == kind,
|
| 950 |
KnowledgeEntryRow.entity_id.in_(list(approved)),
|
| 951 |
)
|
| 952 |
)
|
| 953 |
rows = list(result.scalars().all())
|
| 954 |
+
# The exact rows the decisions were made against. Without these the
|
| 955 |
+
# only available comparison is document-level `content_hash`, which
|
| 956 |
+
# flips every entry in an edited document including the untouched
|
| 957 |
+
# ones — the outcome the i3 ruling rejected.
|
| 958 |
+
reviewed_rows: dict[str, KnowledgeEntryRow] = {}
|
| 959 |
+
if reviewed_ids:
|
| 960 |
+
reviewed = await session.execute(
|
| 961 |
+
select(KnowledgeEntryRow).where(
|
| 962 |
+
KnowledgeEntryRow.id.in_(reviewed_ids)
|
| 963 |
+
)
|
| 964 |
+
)
|
| 965 |
+
reviewed_rows = {r.id: r for r in reviewed.scalars().all()}
|
| 966 |
+
|
| 967 |
+
withdrawn = await self.withdrawn_doc_ids(scope_id)
|
| 968 |
|
| 969 |
out: list[dict] = []
|
| 970 |
+
stale: set[str] = set()
|
| 971 |
seen: set[str] = set()
|
| 972 |
for row in sorted(rows, key=lambda r: r.created_at or 0, reverse=True):
|
| 973 |
if row.entity_id in seen:
|
| 974 |
continue
|
| 975 |
seen.add(row.entity_id)
|
| 976 |
+
# i7: a withdrawn source stops feeding the baseline, approval or
|
| 977 |
+
# not. The approval itself is untouched and the rows stay — this
|
| 978 |
+
# only stops the pipeline answering from a document the client has
|
| 979 |
+
# taken back.
|
| 980 |
+
if withdrawn and not _has_live_source(row, withdrawn):
|
| 981 |
+
continue
|
| 982 |
review = approved[row.entity_id]
|
| 983 |
+
|
| 984 |
+
if review.decision == "edited" and review.edited_payload:
|
| 985 |
+
# An edit is a stronger statement than an approval: the expert
|
| 986 |
+
# wrote the text themselves, so a later run producing different
|
| 987 |
+
# model output does not unsettle it. Their payload IS the entry.
|
| 988 |
+
out.append(review.edited_payload)
|
| 989 |
+
continue
|
| 990 |
+
|
| 991 |
+
ruled = reviewed_rows.get(review.entry_id)
|
| 992 |
+
if ruled is not None and mutation.has_mutated(
|
| 993 |
+
kind, ruled.payload, row.payload
|
| 994 |
+
):
|
| 995 |
+
stale.add(row.entity_id)
|
| 996 |
+
continue
|
| 997 |
+
out.append(row.payload)
|
| 998 |
+
|
| 999 |
+
if stale:
|
| 1000 |
+
logger.info(
|
| 1001 |
+
"knowledge_approvals_stale",
|
| 1002 |
+
extra={"scope_id": scope_id, "kind": kind, "count": len(stale)},
|
| 1003 |
)
|
| 1004 |
+
return out, stale
|
| 1005 |
|
| 1006 |
async def save_declaration(
|
| 1007 |
self, *, scope_id: str, user_id: str, payload: dict
|
|
|
|
| 1045 |
entity_id=entity_id,
|
| 1046 |
)
|
| 1047 |
session.add(row)
|
| 1048 |
+
# m6: the declaration is keyed like every other entry, so one
|
| 1049 |
+
# lookup path covers all five kinds.
|
| 1050 |
+
row.label = keys.concept_key(
|
| 1051 |
+
"domain", payload, entity_id=entity_id
|
| 1052 |
+
) or _label(payload, "domain_name")
|
| 1053 |
row.payload = payload
|
| 1054 |
row.extraction_status = "ok"
|
| 1055 |
await session.commit()
|
|
@@ -35,6 +35,15 @@ JobStage = Literal["parsing", "filtering", "estimating", "extracting", "persisti
|
|
| 35 |
|
| 36 |
DocumentStatus = Literal["pending", "succeeded", "failed", "skipped"]
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
def _now() -> datetime:
|
| 40 |
return datetime.now(UTC)
|
|
@@ -55,6 +64,18 @@ class DocumentResult(BaseModel):
|
|
| 55 |
n_entries: int = 0
|
| 56 |
error: str | None = None
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
class SkippedDocument(BaseModel):
|
| 60 |
"""A document the batch declined to take, and why.
|
|
@@ -77,6 +98,7 @@ class IngestJob(BaseModel):
|
|
| 77 |
user_id: str
|
| 78 |
status: JobStatus = "queued"
|
| 79 |
stage: JobStage | None = None
|
|
|
|
| 80 |
document_ids: list[str] = Field(default_factory=list)
|
| 81 |
n_requested: int = 0
|
| 82 |
n_succeeded: int = 0
|
|
@@ -102,10 +124,17 @@ class IngestJob(BaseModel):
|
|
| 102 |
"""
|
| 103 |
self.n_succeeded = sum(1 for r in self.results if r.status == "succeeded")
|
| 104 |
self.n_failed = sum(1 for r in self.results if r.status == "failed")
|
|
|
|
| 105 |
if self.n_succeeded and self.n_failed:
|
| 106 |
self.status = "partial"
|
| 107 |
elif self.n_succeeded:
|
| 108 |
self.status = "succeeded"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
else:
|
| 110 |
self.status = "failed"
|
| 111 |
self.stage = None
|
|
|
|
| 35 |
|
| 36 |
DocumentStatus = Literal["pending", "succeeded", "failed", "skipped"]
|
| 37 |
|
| 38 |
+
# r1 (2026-09-08 discussion). Chosen by the caller at ingest time.
|
| 39 |
+
# "expert" - entries wait for a human to rule on them.
|
| 40 |
+
# "llm" - entries go straight to approved, with NO reviewer step at all.
|
| 41 |
+
# The second is not a second model call: the entries were produced by a model
|
| 42 |
+
# already, so "review by LLM" means accepting them without human review. What
|
| 43 |
+
# makes it safe to offer is that the decision is RECORDED against the person
|
| 44 |
+
# who chose it (r2/r3), not that the pipeline checks itself.
|
| 45 |
+
ReviewMode = Literal["expert", "llm"]
|
| 46 |
+
|
| 47 |
|
| 48 |
def _now() -> datetime:
|
| 49 |
return datetime.now(UTC)
|
|
|
|
| 64 |
n_entries: int = 0
|
| 65 |
error: str | None = None
|
| 66 |
|
| 67 |
+
# The pre-flight estimate, recorded whether or not the document was
|
| 68 |
+
# refused. An operator cannot plan a batch from a number they only ever see
|
| 69 |
+
# when it is already too late, and "why was this one refused when that one
|
| 70 |
+
# ran" is not answerable without the shape of a run that succeeded.
|
| 71 |
+
estimate: dict | None = None
|
| 72 |
+
|
| 73 |
+
# Which ceiling refused this document (`clusters` / `prompt_tokens`), or
|
| 74 |
+
# None if it was not refused. Distinguishes "declined before spending" from
|
| 75 |
+
# every other failure, which is what lets a wholly-refused batch settle
|
| 76 |
+
# `blocked` rather than `failed`.
|
| 77 |
+
blocked_by: str | None = None
|
| 78 |
+
|
| 79 |
|
| 80 |
class SkippedDocument(BaseModel):
|
| 81 |
"""A document the batch declined to take, and why.
|
|
|
|
| 98 |
user_id: str
|
| 99 |
status: JobStatus = "queued"
|
| 100 |
stage: JobStage | None = None
|
| 101 |
+
review_mode: ReviewMode = "expert"
|
| 102 |
document_ids: list[str] = Field(default_factory=list)
|
| 103 |
n_requested: int = 0
|
| 104 |
n_succeeded: int = 0
|
|
|
|
| 124 |
"""
|
| 125 |
self.n_succeeded = sum(1 for r in self.results if r.status == "succeeded")
|
| 126 |
self.n_failed = sum(1 for r in self.results if r.status == "failed")
|
| 127 |
+
failures = [r for r in self.results if r.status == "failed"]
|
| 128 |
if self.n_succeeded and self.n_failed:
|
| 129 |
self.status = "partial"
|
| 130 |
elif self.n_succeeded:
|
| 131 |
self.status = "succeeded"
|
| 132 |
+
elif failures and all(r.blocked_by for r in failures):
|
| 133 |
+
# `blocked` was documented from the start and was unreachable until
|
| 134 |
+
# 2026-09-11: a spend refusal settled as `failed`, which is the one
|
| 135 |
+
# outcome an operator can act on (raise the ceiling, or split the
|
| 136 |
+
# document) hidden inside the one they cannot.
|
| 137 |
+
self.status = "blocked"
|
| 138 |
else:
|
| 139 |
self.status = "failed"
|
| 140 |
self.stage = None
|
|
@@ -36,6 +36,7 @@ from functools import partial
|
|
| 36 |
from pathlib import Path
|
| 37 |
|
| 38 |
from src.db.postgres.models import Document
|
|
|
|
| 39 |
from src.knowledge_extraction.service import (
|
| 40 |
build_clusters,
|
| 41 |
estimate_cost,
|
|
@@ -54,6 +55,45 @@ logger = get_logger("knowledge_pipeline")
|
|
| 54 |
# accident without getting in the way of anything real.
|
| 55 |
MAX_PROMPT_TOKENS = 2_000_000
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
# Extraction is synchronous and blocks for ~170 s on nine pages, so it cannot run
|
| 58 |
# on the event loop. Its own pool, following the #49 `dbexec` precedent: sharing
|
| 59 |
# the default `to_thread` pool means a long parse competes with every other
|
|
@@ -75,16 +115,42 @@ BRANCHES = ("glossary", "rule", "formula", "summary")
|
|
| 75 |
|
| 76 |
|
| 77 |
class SpendCeilingExceededError(RuntimeError):
|
| 78 |
-
"""The pre-flight estimate exceeded
|
| 79 |
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
self.estimate = estimate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
super().__init__(
|
| 83 |
-
f"estimated {
|
| 84 |
-
f"
|
|
|
|
| 85 |
)
|
| 86 |
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
def _extractor():
|
| 89 |
"""The Azure client the extraction branches call.
|
| 90 |
|
|
@@ -121,6 +187,12 @@ class KnowledgePipeline:
|
|
| 121 |
|
| 122 |
def __init__(self, store: KnowledgeStore | None = None) -> None:
|
| 123 |
self._store = store or KnowledgeStore()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
async def run_document(
|
| 126 |
self, document: Document, scope_id: str, user_id: str
|
|
@@ -151,10 +223,18 @@ class KnowledgePipeline:
|
|
| 151 |
except SpendCeilingExceededError as e:
|
| 152 |
logger.warning(
|
| 153 |
"knowledge_spend_ceiling",
|
| 154 |
-
extra={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
)
|
| 156 |
result.status = "failed"
|
| 157 |
result.error = str(e)
|
|
|
|
|
|
|
| 158 |
return result
|
| 159 |
except Exception as e:
|
| 160 |
logger.error(
|
|
@@ -167,6 +247,7 @@ class KnowledgePipeline:
|
|
| 167 |
|
| 168 |
result.run_id = run_id
|
| 169 |
result.n_entries = n_entries
|
|
|
|
| 170 |
result.status = "succeeded"
|
| 171 |
return result
|
| 172 |
|
|
@@ -229,8 +310,56 @@ class KnowledgePipeline:
|
|
| 229 |
clustered = await loop.run_in_executor(_EXECUTOR, build_clusters, doc, filtered)
|
| 230 |
|
| 231 |
estimate = estimate_cost(doc, clustered, filtered)
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
# Built before the run row so a missing Azure deployment fails without
|
| 236 |
# leaving an orphan `running` row behind.
|
|
|
|
| 36 |
from pathlib import Path
|
| 37 |
|
| 38 |
from src.db.postgres.models import Document
|
| 39 |
+
from src.knowledge_extraction.genre import classify as classify_genre
|
| 40 |
from src.knowledge_extraction.service import (
|
| 41 |
build_clusters,
|
| 42 |
estimate_cost,
|
|
|
|
| 55 |
# accident without getting in the way of anything real.
|
| 56 |
MAX_PROMPT_TOKENS = 2_000_000
|
| 57 |
|
| 58 |
+
# The SECOND ceiling, and the one that tracks what actually scales.
|
| 59 |
+
#
|
| 60 |
+
# Spend is driven by CLUSTER COUNT, not pages: the pipeline makes one call per
|
| 61 |
+
# term cluster, so a dense chapter costs more than a long simple one. Two things
|
| 62 |
+
# the token ceiling alone cannot see:
|
| 63 |
+
#
|
| 64 |
+
# 1. **Per-call cost is not linear in prompt tokens.** Each call carries its
|
| 65 |
+
# own completion tokens and its own latency, and the fixed cached prefix
|
| 66 |
+
# becomes a smaller share of each prompt as evidence grows - so the CACHE
|
| 67 |
+
# HIT RATE degrades as a run gets bigger, and the real bill rises faster
|
| 68 |
+
# than the token estimate does.
|
| 69 |
+
# 2. **The estimate is partly an approximation** (see `estimate_cost`), while
|
| 70 |
+
# cluster count is exact and known for free before anything is spent.
|
| 71 |
+
#
|
| 72 |
+
# This became urgent rather than theoretical on 2026-09-10: retiring the fuzzy
|
| 73 |
+
# clustering pass was correct, and it produces MORE, SMALLER clusters - the Open
|
| 74 |
+
# Pit textbook's single 46-page chapter went from 56% to 71% of the token
|
| 75 |
+
# ceiling on that change alone. A ceiling that only counts tokens would have let
|
| 76 |
+
# the next such improvement through unnoticed.
|
| 77 |
+
#
|
| 78 |
+
# Measured free on the three v2 artifacts, 2026-09-11:
|
| 79 |
+
#
|
| 80 |
+
# clusters calls est. prompt tokens % of the token ceiling
|
| 81 |
+
# BUMA 83 106 201,751 10.1%
|
| 82 |
+
# Komatsu 93 95 227,329 11.4%
|
| 83 |
+
# Open Pit 427 494 1,560,548 78.0%
|
| 84 |
+
#
|
| 85 |
+
# Read those honestly: at this corpus's density (~3,200 prompt tokens per
|
| 86 |
+
# cluster) the TOKEN ceiling binds first, at roughly 630 clusters. So this
|
| 87 |
+
# ceiling is not the tighter of the two for documents that look like these - it
|
| 88 |
+
# is the guard for the other shape, a document with many cheap clusters, where
|
| 89 |
+
# the cost is call COUNT rather than prompt size: 494 calls already runs about a
|
| 90 |
+
# quarter of an hour, and a batch is twenty documents.
|
| 91 |
+
#
|
| 92 |
+
# 750 is ~1.75x the largest real document measured. Generous enough not to
|
| 93 |
+
# obstruct anything observed, tight enough that one document cannot occupy the
|
| 94 |
+
# single-flight worker for an hour. Raise it with a measurement, not a hunch.
|
| 95 |
+
MAX_CLUSTERS = 750
|
| 96 |
+
|
| 97 |
# Extraction is synchronous and blocks for ~170 s on nine pages, so it cannot run
|
| 98 |
# on the event loop. Its own pool, following the #49 `dbexec` precedent: sharing
|
| 99 |
# the default `to_thread` pool means a long parse competes with every other
|
|
|
|
| 115 |
|
| 116 |
|
| 117 |
class SpendCeilingExceededError(RuntimeError):
|
| 118 |
+
"""The pre-flight estimate exceeded a ceiling; nothing was spent.
|
| 119 |
|
| 120 |
+
Names WHICH ceiling bound and by how much. An operator's next move differs
|
| 121 |
+
completely between the two - a cluster refusal means the document is dense
|
| 122 |
+
and wants splitting, a token refusal means it is simply long - and "over
|
| 123 |
+
budget" tells them neither.
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
def __init__(self, estimate: dict, ceiling: str) -> None:
|
| 127 |
self.estimate = estimate
|
| 128 |
+
self.ceiling = ceiling
|
| 129 |
+
if ceiling == "clusters":
|
| 130 |
+
measured, limit, unit = estimate.get("clusters", 0), MAX_CLUSTERS, "term clusters"
|
| 131 |
+
else:
|
| 132 |
+
measured, limit, unit = (
|
| 133 |
+
estimate.get("estimated_prompt_tokens", 0),
|
| 134 |
+
MAX_PROMPT_TOKENS,
|
| 135 |
+
"prompt tokens",
|
| 136 |
+
)
|
| 137 |
+
self.measured, self.limit = measured, limit
|
| 138 |
super().__init__(
|
| 139 |
+
f"estimated {measured:,} {unit} exceeds the ceiling of {limit:,} "
|
| 140 |
+
f"({measured / limit:.0%} of it) — nothing was spent; "
|
| 141 |
+
f"{estimate.get('total_calls', 0):,} LLM calls were declined"
|
| 142 |
)
|
| 143 |
|
| 144 |
|
| 145 |
+
def check_ceilings(estimate: dict) -> None:
|
| 146 |
+
"""Raise if either ceiling binds. Clusters are checked FIRST because they
|
| 147 |
+
are exact where the token figure is partly approximate."""
|
| 148 |
+
if (estimate.get("clusters") or 0) > MAX_CLUSTERS:
|
| 149 |
+
raise SpendCeilingExceededError(estimate, "clusters")
|
| 150 |
+
if (estimate.get("estimated_prompt_tokens") or 0) > MAX_PROMPT_TOKENS:
|
| 151 |
+
raise SpendCeilingExceededError(estimate, "prompt_tokens")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
def _extractor():
|
| 155 |
"""The Azure client the extraction branches call.
|
| 156 |
|
|
|
|
| 187 |
|
| 188 |
def __init__(self, store: KnowledgeStore | None = None) -> None:
|
| 189 |
self._store = store or KnowledgeStore()
|
| 190 |
+
# The most recent pre-flight estimate, so `run_document` can report it
|
| 191 |
+
# on the SUCCESS path too (a refusal carries its own on the exception).
|
| 192 |
+
# Safe as instance state only because documents are run one at a time —
|
| 193 |
+
# `IngestService` holds a single-flight lock and `_EXECUTOR` has one
|
| 194 |
+
# worker. If either ever changes, pass it back instead of stashing it.
|
| 195 |
+
self.last_estimate: dict | None = None
|
| 196 |
|
| 197 |
async def run_document(
|
| 198 |
self, document: Document, scope_id: str, user_id: str
|
|
|
|
| 223 |
except SpendCeilingExceededError as e:
|
| 224 |
logger.warning(
|
| 225 |
"knowledge_spend_ceiling",
|
| 226 |
+
extra={
|
| 227 |
+
"document_id": document.id,
|
| 228 |
+
"ceiling": e.ceiling,
|
| 229 |
+
"measured": e.measured,
|
| 230 |
+
"limit": e.limit,
|
| 231 |
+
"estimate": e.estimate,
|
| 232 |
+
},
|
| 233 |
)
|
| 234 |
result.status = "failed"
|
| 235 |
result.error = str(e)
|
| 236 |
+
result.estimate = e.estimate
|
| 237 |
+
result.blocked_by = e.ceiling
|
| 238 |
return result
|
| 239 |
except Exception as e:
|
| 240 |
logger.error(
|
|
|
|
| 247 |
|
| 248 |
result.run_id = run_id
|
| 249 |
result.n_entries = n_entries
|
| 250 |
+
result.estimate = self.last_estimate
|
| 251 |
result.status = "succeeded"
|
| 252 |
return result
|
| 253 |
|
|
|
|
| 310 |
clustered = await loop.run_in_executor(_EXECUTOR, build_clusters, doc, filtered)
|
| 311 |
|
| 312 |
estimate = estimate_cost(doc, clustered, filtered)
|
| 313 |
+
|
| 314 |
+
# What KIND of document this is, decided before anything is spent.
|
| 315 |
+
# Advisory today — see `genre.ROUTE_LABELS` / `ROUTE_BRANCHES`, both
|
| 316 |
+
# off — but recorded, because "104 calls, 93 abstentions" is a fact an
|
| 317 |
+
# operator should be able to see the reason for.
|
| 318 |
+
#
|
| 319 |
+
# A never-throw seam, like every other observability path here: this
|
| 320 |
+
# describes the run, it does not decide it, and a classifier defect must
|
| 321 |
+
# not take down an extraction that was about to work. `repr(e)` because
|
| 322 |
+
# an empty `str(e)` is how the Fernet mismatch hid for a day.
|
| 323 |
+
try:
|
| 324 |
+
verdict = classify_genre(doc, filtered)
|
| 325 |
+
estimate["genre"] = verdict.as_dict()
|
| 326 |
+
for warning in verdict.warnings:
|
| 327 |
+
logger.warning(
|
| 328 |
+
"knowledge_genre_warning",
|
| 329 |
+
extra={
|
| 330 |
+
"doc_id": doc.doc_id,
|
| 331 |
+
"genre": verdict.genre,
|
| 332 |
+
"warning": warning,
|
| 333 |
+
},
|
| 334 |
+
)
|
| 335 |
+
except Exception as e: # noqa: BLE001 - described above
|
| 336 |
+
logger.warning(
|
| 337 |
+
"knowledge_genre_failed",
|
| 338 |
+
extra={"doc_id": doc.doc_id, "error": repr(e)},
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
self.last_estimate = estimate
|
| 342 |
+
# Logged on EVERY run, not only on a refusal: an operator who can only
|
| 343 |
+
# see the number when it is already too late cannot plan a batch, and
|
| 344 |
+
# "why was this refused" is answerable only against what a run that
|
| 345 |
+
# succeeded looked like.
|
| 346 |
+
logger.info(
|
| 347 |
+
"knowledge_pre_flight_estimate",
|
| 348 |
+
extra={
|
| 349 |
+
"doc_id": doc.doc_id,
|
| 350 |
+
"clusters": estimate.get("clusters"),
|
| 351 |
+
"total_calls": estimate.get("total_calls"),
|
| 352 |
+
"estimated_prompt_tokens": estimate.get("estimated_prompt_tokens"),
|
| 353 |
+
"cluster_ceiling_pct": round(
|
| 354 |
+
100 * (estimate.get("clusters") or 0) / MAX_CLUSTERS, 1
|
| 355 |
+
),
|
| 356 |
+
"token_ceiling_pct": round(
|
| 357 |
+
100 * (estimate.get("estimated_prompt_tokens") or 0) / MAX_PROMPT_TOKENS,
|
| 358 |
+
1,
|
| 359 |
+
),
|
| 360 |
+
},
|
| 361 |
+
)
|
| 362 |
+
check_ceilings(estimate)
|
| 363 |
|
| 364 |
# Built before the run row so a missing Azure deployment fails without
|
| 365 |
# leaving an orphan `running` row behind.
|
|
@@ -38,6 +38,39 @@ from src.middlewares.logging import get_logger
|
|
| 38 |
logger = get_logger("knowledge_ingest")
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
@runtime_checkable
|
| 42 |
class PipelinePort(Protocol):
|
| 43 |
"""Run the whole pipeline over ONE document and report its outcome.
|
|
@@ -102,7 +135,10 @@ class IngestService:
|
|
| 102 |
return await self._store.list_for_user(user_id, limit)
|
| 103 |
|
| 104 |
async def start(
|
| 105 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 106 |
) -> tuple[IngestJob, bool]:
|
| 107 |
"""Admit a batch. Returns (job, reused).
|
| 108 |
|
|
@@ -127,6 +163,7 @@ class IngestService:
|
|
| 127 |
job = IngestJob(
|
| 128 |
scope_id=scope_id,
|
| 129 |
user_id=user_id,
|
|
|
|
| 130 |
document_ids=[d.id for d in selected],
|
| 131 |
n_requested=len(selected),
|
| 132 |
results=[
|
|
@@ -195,8 +232,26 @@ class IngestService:
|
|
| 195 |
status="failed",
|
| 196 |
error=repr(e),
|
| 197 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
by_id[result.document_id] = result
|
| 199 |
job.results = list(by_id.values())
|
|
|
|
| 200 |
await self._store.save(job)
|
| 201 |
|
| 202 |
job.settle()
|
|
|
|
| 38 |
logger = get_logger("knowledge_ingest")
|
| 39 |
|
| 40 |
|
| 41 |
+
def _roll_up_estimates(results: list[DocumentResult]) -> dict | None:
|
| 42 |
+
"""The batch's pre-flight shape, per document and in total.
|
| 43 |
+
|
| 44 |
+
`IngestJob.estimated_cost` has existed since the job model was written and
|
| 45 |
+
was never populated, so the field a caller would poll to answer "what is
|
| 46 |
+
this going to cost, and why was that document refused" always came back
|
| 47 |
+
null. Spend is driven by CLUSTER COUNT — one LLM call per term cluster — so
|
| 48 |
+
the per-document breakdown is what an operator needs to see; a total alone
|
| 49 |
+
cannot tell them which document to split.
|
| 50 |
+
"""
|
| 51 |
+
estimates = [(r.document_id, r.estimate) for r in results if r.estimate]
|
| 52 |
+
if not estimates:
|
| 53 |
+
return None
|
| 54 |
+
return {
|
| 55 |
+
"clusters": sum(e.get("clusters") or 0 for _, e in estimates),
|
| 56 |
+
"total_calls": sum(e.get("total_calls") or 0 for _, e in estimates),
|
| 57 |
+
"estimated_prompt_tokens": sum(
|
| 58 |
+
e.get("estimated_prompt_tokens") or 0 for _, e in estimates
|
| 59 |
+
),
|
| 60 |
+
"per_document": {
|
| 61 |
+
doc_id: {
|
| 62 |
+
"clusters": e.get("clusters"),
|
| 63 |
+
"total_calls": e.get("total_calls"),
|
| 64 |
+
"estimated_prompt_tokens": e.get("estimated_prompt_tokens"),
|
| 65 |
+
}
|
| 66 |
+
for doc_id, e in estimates
|
| 67 |
+
},
|
| 68 |
+
"blocked": {
|
| 69 |
+
r.document_id: r.blocked_by for r in results if r.blocked_by
|
| 70 |
+
},
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
@runtime_checkable
|
| 75 |
class PipelinePort(Protocol):
|
| 76 |
"""Run the whole pipeline over ONE document and report its outcome.
|
|
|
|
| 135 |
return await self._store.list_for_user(user_id, limit)
|
| 136 |
|
| 137 |
async def start(
|
| 138 |
+
self,
|
| 139 |
+
user_id: str,
|
| 140 |
+
document_ids: list[str] | None = None,
|
| 141 |
+
review_mode: str = "expert",
|
| 142 |
) -> tuple[IngestJob, bool]:
|
| 143 |
"""Admit a batch. Returns (job, reused).
|
| 144 |
|
|
|
|
| 163 |
job = IngestJob(
|
| 164 |
scope_id=scope_id,
|
| 165 |
user_id=user_id,
|
| 166 |
+
review_mode=review_mode,
|
| 167 |
document_ids=[d.id for d in selected],
|
| 168 |
n_requested=len(selected),
|
| 169 |
results=[
|
|
|
|
| 232 |
status="failed",
|
| 233 |
error=repr(e),
|
| 234 |
)
|
| 235 |
+
# r1: in `llm` mode there is no reviewer step - the run's
|
| 236 |
+
# entries are approved as soon as they land, recorded against
|
| 237 |
+
# the person who chose the mode (r2/r3). Never-throw: an
|
| 238 |
+
# auto-approval that fails leaves the entries REVIEWABLE, which
|
| 239 |
+
# is the safe direction, and must not fail the document.
|
| 240 |
+
if job.review_mode == "llm" and result.status == "succeeded" and result.run_id:
|
| 241 |
+
try:
|
| 242 |
+
await self._knowledge.auto_approve_run(
|
| 243 |
+
run_id=result.run_id,
|
| 244 |
+
scope_id=job.scope_id,
|
| 245 |
+
authorised_by=job.user_id,
|
| 246 |
+
)
|
| 247 |
+
except Exception as e: # noqa: BLE001
|
| 248 |
+
logger.error(
|
| 249 |
+
"ingest_auto_approve_failed",
|
| 250 |
+
extra={"job_id": job.id, "run_id": result.run_id, "error": repr(e)},
|
| 251 |
+
)
|
| 252 |
by_id[result.document_id] = result
|
| 253 |
job.results = list(by_id.values())
|
| 254 |
+
job.estimated_cost = _roll_up_estimates(job.results)
|
| 255 |
await self._store.save(job)
|
| 256 |
|
| 257 |
job.settle()
|
|
@@ -123,6 +123,7 @@ class PostgresJobStore:
|
|
| 123 |
user_id=row.user_id,
|
| 124 |
status=row.status,
|
| 125 |
stage=row.stage,
|
|
|
|
| 126 |
document_ids=list(row.document_ids or []),
|
| 127 |
n_requested=row.n_requested or 0,
|
| 128 |
n_succeeded=row.n_succeeded or 0,
|
|
@@ -161,6 +162,7 @@ class PostgresJobStore:
|
|
| 161 |
user_id=job.user_id,
|
| 162 |
status=job.status,
|
| 163 |
stage=job.stage,
|
|
|
|
| 164 |
document_ids=list(job.document_ids),
|
| 165 |
n_requested=job.n_requested,
|
| 166 |
n_succeeded=job.n_succeeded,
|
|
|
|
| 123 |
user_id=row.user_id,
|
| 124 |
status=row.status,
|
| 125 |
stage=row.stage,
|
| 126 |
+
review_mode=row.review_mode or "expert",
|
| 127 |
document_ids=list(row.document_ids or []),
|
| 128 |
n_requested=row.n_requested or 0,
|
| 129 |
n_succeeded=row.n_succeeded or 0,
|
|
|
|
| 162 |
user_id=job.user_id,
|
| 163 |
status=job.status,
|
| 164 |
stage=job.stage,
|
| 165 |
+
review_mode=job.review_mode,
|
| 166 |
document_ids=list(job.document_ids),
|
| 167 |
n_requested=job.n_requested,
|
| 168 |
n_succeeded=job.n_succeeded,
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Asset identity and persistence for figures pulled out of a parse. Added 0.4.0.
|
| 2 |
+
|
| 3 |
+
Two jobs, deliberately small:
|
| 4 |
+
|
| 5 |
+
1. **Identity.** `asset_id()` content-addresses the bytes. The parser's own id is
|
| 6 |
+
not usable: Mistral names images per PAGE (`img-0.jpeg`), so the same name
|
| 7 |
+
recurs across pages — the Open Pit textbook produces **7 distinct names for 8
|
| 8 |
+
images**, and a flat store keyed on the parser's name loses one. Content
|
| 9 |
+
addressing also makes a re-parse idempotent and de-duplicates a figure that
|
| 10 |
+
appears in two documents.
|
| 11 |
+
|
| 12 |
+
2. **Persistence.** An `AssetSink` protocol plus a local-filesystem default.
|
| 13 |
+
|
| 14 |
+
**Why a protocol and not a blob client.** `src/knowledge_parsing/` imports nothing
|
| 15 |
+
from the rest of `src` and that is load-bearing — the package is handed to the
|
| 16 |
+
team as a standalone unit, so reaching into `src.storage` (which pulls app
|
| 17 |
+
settings, which needs `.env`) would break the export and make the parser
|
| 18 |
+
un-runnable outside this repo. The app injects a blob-backed sink instead; see
|
| 19 |
+
`knowledge_ingest.asset_sink`.
|
| 20 |
+
|
| 21 |
+
Persistence is a **never-throw seam**. A figure whose bytes cannot be stored
|
| 22 |
+
yields `storage_key=None` and a logged warning, never a failed parse: the parse is
|
| 23 |
+
the expensive, cached, hard-to-repeat half, and losing a whole document because
|
| 24 |
+
one PNG could not be written is the wrong trade.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import base64
|
| 30 |
+
import binascii
|
| 31 |
+
import hashlib
|
| 32 |
+
import logging
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import Protocol, runtime_checkable
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger("knowledge_parsing.assets")
|
| 37 |
+
|
| 38 |
+
ASSET_ID_CHARS = 16
|
| 39 |
+
|
| 40 |
+
# Mistral reports ids like "img-0.jpeg"; the suffix is the only extension signal
|
| 41 |
+
# available. Anything unrecognised falls back to .bin rather than guessing, so a
|
| 42 |
+
# stored object is never mislabelled.
|
| 43 |
+
_KNOWN_EXT = {"jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff"}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def asset_id(data: bytes) -> str:
|
| 47 |
+
"""Content address for asset bytes: sha256, first 16 hex chars.
|
| 48 |
+
|
| 49 |
+
16 chars is ~64 bits. At corpus scale (thousands of figures) the collision
|
| 50 |
+
probability is negligible, and it keeps the id short enough to read in a
|
| 51 |
+
`` reference inside a prose chunk.
|
| 52 |
+
"""
|
| 53 |
+
return hashlib.sha256(data).hexdigest()[:ASSET_ID_CHARS]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def extension_for(parser_id: str | None) -> str:
|
| 57 |
+
"""File extension from the parser's own id, defaulting to `bin`."""
|
| 58 |
+
if not parser_id or "." not in parser_id:
|
| 59 |
+
return "bin"
|
| 60 |
+
ext = parser_id.rsplit(".", 1)[-1].strip().lower()
|
| 61 |
+
return ext if ext in _KNOWN_EXT else "bin"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def decode_base64(payload: str | None) -> bytes | None:
|
| 65 |
+
"""Decode a base64 image payload, tolerating a `data:` URI prefix.
|
| 66 |
+
|
| 67 |
+
Returns None rather than raising on anything malformed — the caller treats a
|
| 68 |
+
missing payload as "no bytes to store", which is the same degraded path as a
|
| 69 |
+
failed write.
|
| 70 |
+
"""
|
| 71 |
+
if not payload:
|
| 72 |
+
return None
|
| 73 |
+
text = payload.strip()
|
| 74 |
+
if text.startswith("data:"):
|
| 75 |
+
_, _, text = text.partition(",")
|
| 76 |
+
try:
|
| 77 |
+
return base64.b64decode(text, validate=False)
|
| 78 |
+
except (binascii.Error, ValueError) as exc:
|
| 79 |
+
logger.warning("asset base64 decode failed: %r", exc)
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@runtime_checkable
|
| 84 |
+
class AssetSink(Protocol):
|
| 85 |
+
"""Somewhere to put asset bytes. Returns a storage key, or None on failure."""
|
| 86 |
+
|
| 87 |
+
def put(self, identifier: str, extension: str, data: bytes) -> str | None:
|
| 88 |
+
...
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class LocalAssetSink:
|
| 92 |
+
"""Write assets to a local directory, named by content address.
|
| 93 |
+
|
| 94 |
+
The default, and what the CLI uses. Idempotent by construction: an asset whose
|
| 95 |
+
id already exists on disk is not rewritten, because the id IS the content.
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
def __init__(self, root: Path, prefix: str = "assets") -> None:
|
| 99 |
+
self.root = Path(root)
|
| 100 |
+
self.prefix = prefix
|
| 101 |
+
|
| 102 |
+
def put(self, identifier: str, extension: str, data: bytes) -> str | None:
|
| 103 |
+
try:
|
| 104 |
+
target_dir = self.root / self.prefix
|
| 105 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 106 |
+
target = target_dir / f"{identifier}.{extension}"
|
| 107 |
+
if not target.exists():
|
| 108 |
+
target.write_bytes(data)
|
| 109 |
+
return f"{self.prefix}/{identifier}.{extension}"
|
| 110 |
+
except OSError as exc:
|
| 111 |
+
logger.warning("asset write failed for %s: %r", identifier, exc)
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class NullAssetSink:
|
| 116 |
+
"""Discard asset bytes, keeping the asset metadata. For `--no-assets`.
|
| 117 |
+
|
| 118 |
+
Not the same as having no assets: the figure is still folded into its prose
|
| 119 |
+
chunk with its caption and description, and still carries an `asset_id`. Only
|
| 120 |
+
the bytes are not retained, so `storage_key` is None.
|
| 121 |
+
"""
|
| 122 |
+
|
| 123 |
+
def put(self, identifier: str, extension: str, data: bytes) -> str | None:
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def store_asset(
|
| 128 |
+
sink: AssetSink | None,
|
| 129 |
+
parser_id: str | None,
|
| 130 |
+
base64_payload: str | None,
|
| 131 |
+
) -> tuple[str | None, str | None]:
|
| 132 |
+
"""Content-address and persist one asset. Returns `(asset_id, storage_key)`.
|
| 133 |
+
|
| 134 |
+
`asset_id` is None only when there are no bytes to address — a table, or a
|
| 135 |
+
figure whose payload the parser did not return. `storage_key` is None whenever
|
| 136 |
+
the bytes were not retained, which is a normal outcome (`NullAssetSink`) as
|
| 137 |
+
well as a failure one.
|
| 138 |
+
"""
|
| 139 |
+
data = decode_base64(base64_payload)
|
| 140 |
+
if data is None:
|
| 141 |
+
return None, None
|
| 142 |
+
identifier = asset_id(data)
|
| 143 |
+
if sink is None:
|
| 144 |
+
return identifier, None
|
| 145 |
+
return identifier, sink.put(identifier, extension_for(parser_id), data)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def synthetic_asset_id(*parts: str) -> str:
|
| 149 |
+
"""Id for an asset with no bytes of its own — a table.
|
| 150 |
+
|
| 151 |
+
Derived from the document and chunk coordinates rather than content, because
|
| 152 |
+
there is no content to hash. Deterministic for the same document, which is
|
| 153 |
+
what matters: a re-parse produces the same id, so a pointer to a table
|
| 154 |
+
survives re-extraction the way a figure's does.
|
| 155 |
+
"""
|
| 156 |
+
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:ASSET_ID_CHARS]
|
|
@@ -88,6 +88,11 @@ class PipelineConfig:
|
|
| 88 |
|
| 89 |
# --- behaviour ---
|
| 90 |
resume: bool = True # skip documents already finished
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
write_debug_pdf: bool = True # _layout.pdf & _span.pdf: fastest quality check there is
|
| 92 |
|
| 93 |
# --- quality-check thresholds (see checks.py) ---
|
|
|
|
| 88 |
|
| 89 |
# --- behaviour ---
|
| 90 |
resume: bool = True # skip documents already finished
|
| 91 |
+
# Keep asset METADATA but discard the bytes. The figure is still folded into
|
| 92 |
+
# its prose chunk with its caption, description and content-addressed id —
|
| 93 |
+
# only `storage_key` comes back null. For a run where the images are not
|
| 94 |
+
# wanted on disk, or where storage is unavailable.
|
| 95 |
+
no_assets: bool = False
|
| 96 |
write_debug_pdf: bool = True # _layout.pdf & _span.pdf: fastest quality check there is
|
| 97 |
|
| 98 |
# --- quality-check thresholds (see checks.py) ---
|
|
@@ -42,13 +42,85 @@ from typing import Literal
|
|
| 42 |
from pydantic import BaseModel, Field, computed_field
|
| 43 |
|
| 44 |
ChunkKind = Literal["text", "table", "chart", "equation", "image"]
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
# 0.3.3 — adds the computed 1-based `page_no` (S6b). Derived from `page_idx`.
|
| 47 |
# 0.3.2 — adds the optional `source_title`.
|
| 48 |
# 0.3.1 — adds the optional `normaliser_version`.
|
| 49 |
-
#
|
| 50 |
-
# and the new fields are null on anything written earlier.
|
| 51 |
-
SCHEMA_VERSION = "0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
class Chunk(BaseModel):
|
|
@@ -124,7 +196,24 @@ class Chunk(BaseModel):
|
|
| 124 |
# uses item sequence, never coordinates.
|
| 125 |
bbox: list[int] | None = None
|
| 126 |
|
| 127 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
images: list[str] = Field(default_factory=list)
|
| 129 |
|
| 130 |
# Source markup, kept verbatim beside the rendered prose in `text`.
|
|
|
|
| 42 |
from pydantic import BaseModel, Field, computed_field
|
| 43 |
|
| 44 |
ChunkKind = Literal["text", "table", "chart", "equation", "image"]
|
| 45 |
+
AssetKind = Literal["figure", "chart", "diagram", "table"]
|
| 46 |
+
|
| 47 |
+
# 0.4.0 — ASSET POINTERS. A figure is no longer its own chunk: its markdown
|
| 48 |
+
# reference is folded into the prose chunk at its reading-order position
|
| 49 |
+
# and the figure travels as an `Asset` on that chunk. Tables stay their
|
| 50 |
+
# own chunk (their linearised text earns its place as evidence) but gain
|
| 51 |
+
# an `Asset` and a two-way pointer. Adds `Chunk.assets` and
|
| 52 |
+
# `Chunk.referenced_by`; `Chunk.images` is kept as a DEPRECATED alias.
|
| 53 |
+
#
|
| 54 |
+
# A MINOR bump, not a patch: chunk boundaries change, so a 0.3.x consumer
|
| 55 |
+
# reading a 0.4.0 artifact would silently see a different document shape.
|
| 56 |
+
# `adapter.check_schema_version` rejects a newer minor, which is the
|
| 57 |
+
# designed behaviour — parsing and extraction ship together here.
|
| 58 |
# 0.3.3 — adds the computed 1-based `page_no` (S6b). Derived from `page_idx`.
|
| 59 |
# 0.3.2 — adds the optional `source_title`.
|
| 60 |
# 0.3.1 — adds the optional `normaliser_version`.
|
| 61 |
+
# 0.3.x were all patch bumps: purely additive, so every 0.3.0 consumer reads them
|
| 62 |
+
# unchanged and the new fields are null on anything written earlier.
|
| 63 |
+
SCHEMA_VERSION = "0.4.0"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class Asset(BaseModel):
|
| 67 |
+
"""A figure, chart, diagram or table referenced by a chunk. Added 0.4.0.
|
| 68 |
+
|
| 69 |
+
**Why this exists.** Before 0.4.0 an image became its own chunk with an EMPTY
|
| 70 |
+
`text`, which made it unreachable by construction: no text means no term
|
| 71 |
+
mentions, no mentions means the chunk joins no cluster, and a chunk in no
|
| 72 |
+
cluster is never selected as evidence. Measured across three documents: 13
|
| 73 |
+
figures, every one described by a vision model we paid for, and **zero of
|
| 74 |
+
them appearing in any cluster's evidence.** Meanwhile the prose that said
|
| 75 |
+
"Figure 1.1" had its `` reference stripped, so nothing connected the
|
| 76 |
+
sentence to the picture.
|
| 77 |
+
|
| 78 |
+
The asset carries both halves of the provenance split, and the distinction is
|
| 79 |
+
load-bearing:
|
| 80 |
+
|
| 81 |
+
- `caption` is **verbatim document text** — printed on the page, therefore
|
| 82 |
+
quotable as evidence and span-checkable like any other transcription.
|
| 83 |
+
- `description` is **model-written** — a claim *about* the picture. It is
|
| 84 |
+
never span-checkable and must never be quoted as evidence, which is why it
|
| 85 |
+
lives here and not in `Chunk.text`.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
# Content-addressed: sha256 of the asset bytes, first 16 hex chars.
|
| 89 |
+
#
|
| 90 |
+
# NOT the parser's own id. Mistral names images per PAGE (`img-0.jpeg`), which
|
| 91 |
+
# already collides in practice — the Open Pit textbook yields 7 distinct names
|
| 92 |
+
# for 8 images, so a flat store keyed on the parser's name silently loses one.
|
| 93 |
+
# Content addressing also makes a re-parse idempotent and de-duplicates a
|
| 94 |
+
# figure that appears in two documents.
|
| 95 |
+
asset_id: str
|
| 96 |
+
kind: AssetKind
|
| 97 |
+
|
| 98 |
+
# Where the bytes live. `null` for a table (it has no bytes of its own) and
|
| 99 |
+
# for a figure whose persistence failed — writing assets is a never-throw
|
| 100 |
+
# seam, so a storage outage degrades to "no key" rather than failing a parse.
|
| 101 |
+
storage_key: str | None = None
|
| 102 |
+
|
| 103 |
+
page_idx: int = 0
|
| 104 |
+
|
| 105 |
+
@computed_field # type: ignore[prop-decorator]
|
| 106 |
+
@property
|
| 107 |
+
def page_no(self) -> int:
|
| 108 |
+
"""1-based page number, for a human and for a review UI. Derived from
|
| 109 |
+
`page_idx` so the two cannot drift."""
|
| 110 |
+
return self.page_idx + 1
|
| 111 |
+
|
| 112 |
+
# `[x0, y0, x1, y1]` of the asset itself, so a UI can highlight the figure.
|
| 113 |
+
# Distinct from the host chunk's own `bbox`, which stays where the TEXT is.
|
| 114 |
+
bbox: list[int] | None = None
|
| 115 |
+
|
| 116 |
+
# Verbatim, from the document's own caption block. Quotable as evidence.
|
| 117 |
+
caption: str | None = None
|
| 118 |
+
|
| 119 |
+
# ⚠️ MODEL-WRITTEN. See the class docstring. Never evidence.
|
| 120 |
+
description: str | None = None
|
| 121 |
+
# Which model/feature produced `description`, so a future swap is traceable
|
| 122 |
+
# rather than silently changing what the field means.
|
| 123 |
+
description_source: str | None = None
|
| 124 |
|
| 125 |
|
| 126 |
class Chunk(BaseModel):
|
|
|
|
| 196 |
# uses item sequence, never coordinates.
|
| 197 |
bbox: list[int] | None = None
|
| 198 |
|
| 199 |
+
# Figures, charts, diagrams and tables this chunk contains or points at.
|
| 200 |
+
# Added 0.4.0 — see the `Asset` docstring for why images stopped being chunks.
|
| 201 |
+
#
|
| 202 |
+
# A prose chunk that holds a figure carries it here AND carries the figure's
|
| 203 |
+
# `` reference inline in `text` at its reading-order
|
| 204 |
+
# position, so the model reads the reference exactly where the document put it.
|
| 205 |
+
assets: list[Asset] = Field(default_factory=list)
|
| 206 |
+
|
| 207 |
+
# chunk_ids of OTHER chunks that point at this one. Populated on table chunks,
|
| 208 |
+
# which stay standalone (their linearised text earns its place as evidence —
|
| 209 |
+
# 74.6% of the Komatsu manual's top-3 evidence is tabular) while still being
|
| 210 |
+
# reachable from the prose that introduces them.
|
| 211 |
+
referenced_by: list[str] = Field(default_factory=list)
|
| 212 |
+
|
| 213 |
+
# ⚠️ DEPRECATED 0.4.0 — superseded by `assets`. Still populated for one
|
| 214 |
+
# version so a 0.3.x reader degrades rather than breaking; it holds the bare
|
| 215 |
+
# parser-local filenames, which are NOT unique across a document (see
|
| 216 |
+
# `Asset.asset_id`). Read `assets` instead.
|
| 217 |
images: list[str] = Field(default_factory=list)
|
| 218 |
|
| 219 |
# Source markup, kept verbatim beside the rendered prose in `text`.
|
|
@@ -30,13 +30,15 @@ whitespace normalisation. See contracts.py for why.
|
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
import hashlib
|
|
|
|
| 33 |
import re
|
| 34 |
from collections.abc import Iterator
|
| 35 |
from functools import lru_cache
|
| 36 |
from pathlib import Path
|
| 37 |
from typing import Any
|
| 38 |
|
| 39 |
-
from .
|
|
|
|
| 40 |
from .normalize import MAX_CHUNK_CHARS, _heading # identical section-boundary logic
|
| 41 |
from .render import render_latex, render_table
|
| 42 |
|
|
@@ -62,8 +64,15 @@ def normaliser_version() -> str:
|
|
| 62 |
# the source anyway.
|
| 63 |
_DISCARDED = {"page_number", "footer"}
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
_LEADING_HASHES = re.compile(r"^\s*(#{1,6})\s*")
|
| 66 |
_MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
|
|
|
|
|
|
|
|
|
|
| 67 |
_MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
| 68 |
_MD_BOLD = re.compile(r"\*\*(.+?)\*\*", re.S)
|
| 69 |
_MD_ITALIC = re.compile(r"\*(.+?)\*", re.S)
|
|
@@ -127,7 +136,75 @@ def _image_descriptions(response: dict[str, Any]) -> dict[str, str]:
|
|
| 127 |
ann = im.get("image_annotation")
|
| 128 |
iid = im.get("id")
|
| 129 |
if iid and ann:
|
| 130 |
-
out[iid] =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
return out
|
| 132 |
|
| 133 |
|
|
@@ -221,7 +298,10 @@ def _chunk_by_page(
|
|
| 221 |
|
| 222 |
|
| 223 |
def normalise_mistral(
|
| 224 |
-
response: dict[str, Any],
|
|
|
|
|
|
|
|
|
|
| 225 |
) -> list[Chunk]:
|
| 226 |
"""Mistral OCR response (include_blocks=True) → chunks.
|
| 227 |
|
|
@@ -268,13 +348,38 @@ def normalise_mistral(
|
|
| 268 |
|
| 269 |
# --- page mode: recall-only; a page chunk has no single heading ---
|
| 270 |
if granularity == "page":
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
# --- section mode (default): a numbered heading opens a new chunk (mirrors normalize.py) ---
|
| 274 |
chunks: list[Chunk] = []
|
| 275 |
running: Chunk | None = None
|
| 276 |
pieces: list[str] = []
|
| 277 |
stack: list[tuple[int, str]] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
def push_heading(level: int, text: str) -> None:
|
| 280 |
while stack and stack[-1][0] >= level:
|
|
@@ -285,7 +390,7 @@ def normalise_mistral(
|
|
| 285 |
nonlocal running, pieces
|
| 286 |
if running is not None:
|
| 287 |
running.text = "\n\n".join(pieces).strip()
|
| 288 |
-
if running.text or running.images:
|
| 289 |
running.page_idxs = sorted(set(running.page_idxs))
|
| 290 |
chunks.append(running)
|
| 291 |
running, pieces = None, []
|
|
@@ -308,6 +413,12 @@ def normalise_mistral(
|
|
| 308 |
continue
|
| 309 |
|
| 310 |
if kind == "table":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
close()
|
| 312 |
c = open_chunk("table", page)
|
| 313 |
c.text = render_table(block.get("content") or "")
|
|
@@ -315,22 +426,67 @@ def normalise_mistral(
|
|
| 315 |
c.table_html = block.get("content") or None
|
| 316 |
c.source_items = [i]
|
| 317 |
c.bbox = _bbox(block)
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
chunks.append(c)
|
| 320 |
continue
|
| 321 |
|
| 322 |
if kind == "image":
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
if iid:
|
| 330 |
-
|
| 331 |
-
#
|
| 332 |
-
#
|
| 333 |
-
|
|
|
|
| 334 |
continue
|
| 335 |
|
| 336 |
if kind == "equation":
|
|
@@ -362,6 +518,14 @@ def normalise_mistral(
|
|
| 362 |
running.source_items.append(i)
|
| 363 |
running.page_idxs.append(page)
|
| 364 |
pieces.append(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
continue
|
| 366 |
|
| 367 |
# `text` / `title` / `signature`: a NUMBERED line may open a section.
|
|
@@ -413,4 +577,19 @@ def normalise_mistral(
|
|
| 413 |
heading=continued_from.heading)
|
| 414 |
|
| 415 |
close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
return chunks
|
|
|
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
import hashlib
|
| 33 |
+
import json
|
| 34 |
import re
|
| 35 |
from collections.abc import Iterator
|
| 36 |
from functools import lru_cache
|
| 37 |
from pathlib import Path
|
| 38 |
from typing import Any
|
| 39 |
|
| 40 |
+
from .assets import AssetSink, store_asset, synthetic_asset_id
|
| 41 |
+
from .contracts import Asset, Chunk
|
| 42 |
from .normalize import MAX_CHUNK_CHARS, _heading # identical section-boundary logic
|
| 43 |
from .render import render_latex, render_table
|
| 44 |
|
|
|
|
| 64 |
# the source anyway.
|
| 65 |
_DISCARDED = {"page_number", "footer"}
|
| 66 |
|
| 67 |
+
# Recorded on every Asset so a later swap of the description model is traceable
|
| 68 |
+
# rather than silently changing what the field means.
|
| 69 |
+
_DESCRIPTION_SOURCE = "mistral-ocr/bbox_annotation"
|
| 70 |
+
|
| 71 |
_LEADING_HASHES = re.compile(r"^\s*(#{1,6})\s*")
|
| 72 |
_MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
|
| 73 |
+
# Same shape, but capturing the target so a block's own markdown can be read
|
| 74 |
+
# for the image id it refers to (see _block_image_id).
|
| 75 |
+
_MD_IMAGE_TARGET = re.compile(r"!\[[^\]]*\]\(([^)]*)\)")
|
| 76 |
_MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
| 77 |
_MD_BOLD = re.compile(r"\*\*(.+?)\*\*", re.S)
|
| 78 |
_MD_ITALIC = re.compile(r"\*(.+?)\*", re.S)
|
|
|
|
| 136 |
ann = im.get("image_annotation")
|
| 137 |
iid = im.get("id")
|
| 138 |
if iid and ann:
|
| 139 |
+
out[iid] = _unwrap_description(ann)
|
| 140 |
+
return out
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _unwrap_description(annotation: Any) -> str:
|
| 144 |
+
"""Pull the prose out of an annotation payload.
|
| 145 |
+
|
| 146 |
+
Mistral returns bbox annotations JSON-WRAPPED — literally
|
| 147 |
+
`{"description": "The diagram illustrates…"}` — and 0.3.x stored that string
|
| 148 |
+
raw, braces and all, so every consumer got JSON where it expected a sentence.
|
| 149 |
+
Unwrapped here, at the one place that reads the field.
|
| 150 |
+
|
| 151 |
+
Never raises: anything unparseable falls back to the string form, because a
|
| 152 |
+
slightly ugly description is worth more than a failed parse.
|
| 153 |
+
"""
|
| 154 |
+
if isinstance(annotation, dict):
|
| 155 |
+
value = annotation.get("description") or annotation.get("text")
|
| 156 |
+
return str(value) if value else json.dumps(annotation, ensure_ascii=False)
|
| 157 |
+
if isinstance(annotation, str):
|
| 158 |
+
text = annotation.strip()
|
| 159 |
+
if text.startswith("{"):
|
| 160 |
+
try:
|
| 161 |
+
parsed = json.loads(text)
|
| 162 |
+
except (ValueError, TypeError):
|
| 163 |
+
return text
|
| 164 |
+
if isinstance(parsed, dict):
|
| 165 |
+
value = parsed.get("description") or parsed.get("text")
|
| 166 |
+
if value:
|
| 167 |
+
return str(value)
|
| 168 |
+
return text
|
| 169 |
+
return text
|
| 170 |
+
return str(annotation)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _block_image_id(block: dict[str, Any]) -> str | None:
|
| 174 |
+
"""The image this block refers to, preferring the id in its own markdown.
|
| 175 |
+
|
| 176 |
+
⚠️ **`block["image_id"]` is not trustworthy.** Measured on the Open Pit
|
| 177 |
+
textbook: page 6 carries two image blocks, and the FIRST one's `content` reads
|
| 178 |
+
`` while its `image_id` field says **`img-2.jpeg`** —
|
| 179 |
+
so both blocks claim the same image. Keying on `image_id` therefore resolved
|
| 180 |
+
both to img-2's bytes, gave two different figures one content hash, and lost
|
| 181 |
+
img-1 entirely (7 files written for 8 figures).
|
| 182 |
+
|
| 183 |
+
The `content` markdown is the document's own reference and was correct in
|
| 184 |
+
every case, so it wins; `image_id` is the fallback for a block whose content
|
| 185 |
+
carries no reference. This also corrects an earlier diagnosis: the ids are
|
| 186 |
+
globally unique across the response (`img-0` … `img-7`), so the duplication
|
| 187 |
+
was never page-local naming — it was this mislabelled field.
|
| 188 |
+
"""
|
| 189 |
+
match = _MD_IMAGE_TARGET.search(block.get("content") or "")
|
| 190 |
+
if match and match.group(1).strip():
|
| 191 |
+
return match.group(1).strip()
|
| 192 |
+
return block.get("image_id")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _image_payloads(response: dict[str, Any]) -> dict[str, str]:
|
| 196 |
+
"""image_id -> base64 payload. Empty when `include_image_base64` was off.
|
| 197 |
+
|
| 198 |
+
0.3.x never read these: the bytes were returned by the API, recorded nowhere,
|
| 199 |
+
and `Chunk.images` pointed at filenames that did not exist on disk. 0.4.0
|
| 200 |
+
content-addresses and persists them.
|
| 201 |
+
"""
|
| 202 |
+
out: dict[str, str] = {}
|
| 203 |
+
for page in response.get("pages", []):
|
| 204 |
+
for im in page.get("images") or []:
|
| 205 |
+
iid, payload = im.get("id"), im.get("image_base64")
|
| 206 |
+
if iid and payload:
|
| 207 |
+
out[iid] = payload
|
| 208 |
return out
|
| 209 |
|
| 210 |
|
|
|
|
| 298 |
|
| 299 |
|
| 300 |
def normalise_mistral(
|
| 301 |
+
response: dict[str, Any],
|
| 302 |
+
doc_id: str,
|
| 303 |
+
granularity: str = "section",
|
| 304 |
+
asset_sink: AssetSink | None = None,
|
| 305 |
) -> list[Chunk]:
|
| 306 |
"""Mistral OCR response (include_blocks=True) → chunks.
|
| 307 |
|
|
|
|
| 348 |
|
| 349 |
# --- page mode: recall-only; a page chunk has no single heading ---
|
| 350 |
if granularity == "page":
|
| 351 |
+
# ⚠️ FENCED OFF AT 0.4.0, deliberately, rather than given the asset
|
| 352 |
+
# treatment. Page mode emits 0.3.x-shaped chunks — images standalone with
|
| 353 |
+
# empty text, no `assets` — so letting it run would quietly produce an
|
| 354 |
+
# artifact that CLAIMS schema_version 0.4.0 while having the old shape.
|
| 355 |
+
# Two normalisers silently diverging is exactly how the `type: "list"`
|
| 356 |
+
# content drop survived unnoticed and cost five gold terms.
|
| 357 |
+
#
|
| 358 |
+
# Page mode exists only for a recall-only measurement (it nulls
|
| 359 |
+
# `heading_path`, which the outline and `source_wording` both need), so it
|
| 360 |
+
# is not worth duplicating the asset logic into. Restore by porting the
|
| 361 |
+
# image/table branches from the section path below.
|
| 362 |
+
raise NotImplementedError(
|
| 363 |
+
"granularity='page' is not supported at schema 0.4.0: it would emit "
|
| 364 |
+
"0.3.x-shaped chunks (standalone image chunks, no assets) under a "
|
| 365 |
+
"0.4.0 version stamp. Use granularity='section', or port the asset "
|
| 366 |
+
"handling into _chunk_by_page first."
|
| 367 |
+
)
|
| 368 |
|
| 369 |
# --- section mode (default): a numbered heading opens a new chunk (mirrors normalize.py) ---
|
| 370 |
chunks: list[Chunk] = []
|
| 371 |
running: Chunk | None = None
|
| 372 |
pieces: list[str] = []
|
| 373 |
stack: list[tuple[int, str]] = []
|
| 374 |
+
payloads = _image_payloads(response)
|
| 375 |
+
# A table's asset has to be added to the PROSE chunk that precedes it, but
|
| 376 |
+
# that chunk is already closed and appended by then — so the back-reference is
|
| 377 |
+
# queued and resolved once, after the loop.
|
| 378 |
+
_pending_back_refs: list[tuple[str, Asset]] = []
|
| 379 |
+
# A printed caption arrives as its own `caption` block AFTER its image block.
|
| 380 |
+
# The figure waiting for one is held here; the caption is attached to the asset
|
| 381 |
+
# AND left in the prose text, because it is verbatim document content.
|
| 382 |
+
_pending_caption_for: list[Asset] = []
|
| 383 |
|
| 384 |
def push_heading(level: int, text: str) -> None:
|
| 385 |
while stack and stack[-1][0] >= level:
|
|
|
|
| 390 |
nonlocal running, pieces
|
| 391 |
if running is not None:
|
| 392 |
running.text = "\n\n".join(pieces).strip()
|
| 393 |
+
if running.text or running.assets or running.images:
|
| 394 |
running.page_idxs = sorted(set(running.page_idxs))
|
| 395 |
chunks.append(running)
|
| 396 |
running, pieces = None, []
|
|
|
|
| 413 |
continue
|
| 414 |
|
| 415 |
if kind == "table":
|
| 416 |
+
# Tables stay their OWN chunk (0.4.0 kept this deliberately): unlike a
|
| 417 |
+
# figure, a table's linearised text carries real terms the filter
|
| 418 |
+
# finds — 74.6% of the Komatsu manual's top-3 evidence is tabular. What
|
| 419 |
+
# 0.4.0 adds is reachability: an `Asset` plus a two-way pointer, so the
|
| 420 |
+
# prose that introduces a table can be followed to it and back.
|
| 421 |
+
prose_before = running.chunk_id if running is not None else None
|
| 422 |
close()
|
| 423 |
c = open_chunk("table", page)
|
| 424 |
c.text = render_table(block.get("content") or "")
|
|
|
|
| 426 |
c.table_html = block.get("content") or None
|
| 427 |
c.source_items = [i]
|
| 428 |
c.bbox = _bbox(block)
|
| 429 |
+
table_asset = Asset(
|
| 430 |
+
# No bytes to hash, so the id is derived from stable document
|
| 431 |
+
# coordinates instead — deterministic across re-parses, which is
|
| 432 |
+
# what a durable pointer needs.
|
| 433 |
+
asset_id=synthetic_asset_id(doc_id, c.chunk_id, str(i)),
|
| 434 |
+
kind="table",
|
| 435 |
+
page_idx=page,
|
| 436 |
+
bbox=_bbox(block),
|
| 437 |
+
)
|
| 438 |
+
c.assets = [table_asset]
|
| 439 |
+
if prose_before is not None:
|
| 440 |
+
c.referenced_by = [prose_before]
|
| 441 |
+
_pending_back_refs.append((prose_before, table_asset))
|
| 442 |
+
if c.text or c.assets:
|
| 443 |
chunks.append(c)
|
| 444 |
continue
|
| 445 |
|
| 446 |
if kind == "image":
|
| 447 |
+
# ── 0.4.0: a figure is NO LONGER ITS OWN CHUNK. ────────────────────
|
| 448 |
+
# It used to be, with an EMPTY `text`, which made it unreachable by
|
| 449 |
+
# construction: no text -> no term mentions -> joins no cluster ->
|
| 450 |
+
# never selected as evidence. Measured across three documents: 13
|
| 451 |
+
# figures, every one described by a vision model we pay for, and ZERO
|
| 452 |
+
# appearing in any cluster's evidence. Meanwhile the prose saying
|
| 453 |
+
# "Figure 1.1" had its `` reference stripped, so nothing
|
| 454 |
+
# connected the sentence to the picture.
|
| 455 |
+
#
|
| 456 |
+
# Now the reference is folded into the RUNNING prose chunk at its
|
| 457 |
+
# reading-order position — exactly where the document put it — and the
|
| 458 |
+
# figure travels as an `Asset` on that chunk.
|
| 459 |
+
iid = _block_image_id(block)
|
| 460 |
+
identifier, storage_key = store_asset(
|
| 461 |
+
asset_sink, iid, payloads.get(iid) if iid else None
|
| 462 |
+
)
|
| 463 |
+
# Fall back to document coordinates when the parser returned no bytes,
|
| 464 |
+
# so a figure is still referenced rather than silently vanishing.
|
| 465 |
+
if identifier is None:
|
| 466 |
+
identifier = synthetic_asset_id(doc_id, str(page), str(i))
|
| 467 |
+
asset = Asset(
|
| 468 |
+
asset_id=identifier,
|
| 469 |
+
kind="figure",
|
| 470 |
+
storage_key=storage_key,
|
| 471 |
+
page_idx=page,
|
| 472 |
+
bbox=_bbox(block),
|
| 473 |
+
description=descriptions.get(iid) if iid else None,
|
| 474 |
+
description_source=_DESCRIPTION_SOURCE if iid else None,
|
| 475 |
+
)
|
| 476 |
+
# P5 edge case: a figure can precede any prose on its page (Open Pit
|
| 477 |
+
# p.3 is close to this shape). Open a chunk rather than drop the asset.
|
| 478 |
+
if running is None:
|
| 479 |
+
running = open_chunk("text", page)
|
| 480 |
+
running.source_items.append(i)
|
| 481 |
+
running.page_idxs.append(page)
|
| 482 |
+
running.assets.append(asset)
|
| 483 |
+
# Deprecated alias, one version only.
|
| 484 |
if iid:
|
| 485 |
+
running.images.append(iid)
|
| 486 |
+
# The reference itself, inline in the prose. `asset://` rather than the
|
| 487 |
+
# parser's filename because the filename is page-local and collides.
|
| 488 |
+
pieces.append(f"")
|
| 489 |
+
_pending_caption_for.append(asset)
|
| 490 |
continue
|
| 491 |
|
| 492 |
if kind == "equation":
|
|
|
|
| 518 |
running.source_items.append(i)
|
| 519 |
running.page_idxs.append(page)
|
| 520 |
pieces.append(text)
|
| 521 |
+
# A caption directly after a figure names that figure. Attach it to
|
| 522 |
+
# the asset so a consumer can render "Figure 1.1 …" beside the
|
| 523 |
+
# image — and LEAVE it in `pieces` too, because it is printed in
|
| 524 |
+
# the document: verbatim, quotable as evidence, span-checkable.
|
| 525 |
+
# Copying it is the point; moving it would silently delete
|
| 526 |
+
# evidence that survived 17/17 times on the Open Pit textbook.
|
| 527 |
+
if kind == "caption" and _pending_caption_for:
|
| 528 |
+
_pending_caption_for.pop(0).caption = text.strip()
|
| 529 |
continue
|
| 530 |
|
| 531 |
# `text` / `title` / `signature`: a NUMBERED line may open a section.
|
|
|
|
| 577 |
heading=continued_from.heading)
|
| 578 |
|
| 579 |
close()
|
| 580 |
+
|
| 581 |
+
# Two-way table pointers, resolved once the loop is done. The table chunk
|
| 582 |
+
# already names the prose chunk in `referenced_by`; this adds the table's asset
|
| 583 |
+
# to that prose chunk so the link is navigable from the prose side too — the
|
| 584 |
+
# direction a reader, and a model reading evidence, actually travels. Deferred
|
| 585 |
+
# rather than done inline because the prose chunk is already closed and
|
| 586 |
+
# appended by the time its table is reached.
|
| 587 |
+
by_id = {c.chunk_id: c for c in chunks}
|
| 588 |
+
for prose_id, table_asset in _pending_back_refs:
|
| 589 |
+
host = by_id.get(prose_id)
|
| 590 |
+
if host is not None and not any(
|
| 591 |
+
a.asset_id == table_asset.asset_id for a in host.assets
|
| 592 |
+
):
|
| 593 |
+
host.assets.append(table_asset)
|
| 594 |
+
|
| 595 |
return chunks
|
|
@@ -21,6 +21,7 @@ from datetime import datetime
|
|
| 21 |
from pathlib import Path
|
| 22 |
from typing import Any
|
| 23 |
|
|
|
|
| 24 |
from .checks import check_items, vocabulary_per_page
|
| 25 |
from .config import PipelineConfig
|
| 26 |
from .contracts import ParsedDocument
|
|
@@ -114,7 +115,12 @@ class _ParseOutcome:
|
|
| 114 |
normalise_seconds: float
|
| 115 |
|
| 116 |
|
| 117 |
-
def _parse_one(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
"""The per-document core: parse, normalise, assemble the `ParsedDocument`.
|
| 119 |
|
| 120 |
The single place a `ParsedDocument` is built. Both `parse_one` and `run()` go
|
|
@@ -130,7 +136,7 @@ def _parse_one(source: Path, cfg: PipelineConfig, doc_id: str) -> _ParseOutcome:
|
|
| 130 |
# character-spaced formulas, so neither applies.
|
| 131 |
checks: dict[str, Any] = {"backend": "mistral-ocr"}
|
| 132 |
t0 = time.perf_counter()
|
| 133 |
-
chunks = normalise_mistral(response, doc_id)
|
| 134 |
normalise_seconds = time.perf_counter() - t0
|
| 135 |
else:
|
| 136 |
items = json.loads(result.content_list.read_text(encoding="utf-8"))
|
|
@@ -190,6 +196,7 @@ def parse_one(
|
|
| 190 |
source: Path,
|
| 191 |
cfg: PipelineConfig,
|
| 192 |
doc_id: str | None = None,
|
|
|
|
| 193 |
) -> ParsedDocument:
|
| 194 |
"""Parse one document and return the artifact in memory. Raises on failure.
|
| 195 |
|
|
@@ -206,7 +213,7 @@ def parse_one(
|
|
| 206 |
"""
|
| 207 |
if doc_id is None:
|
| 208 |
doc_id = source.stem
|
| 209 |
-
return _parse_one(source, cfg, doc_id).artifact
|
| 210 |
|
| 211 |
|
| 212 |
def run(cfg: PipelineConfig) -> Manifest:
|
|
@@ -245,7 +252,13 @@ def run(cfg: PipelineConfig) -> Manifest:
|
|
| 245 |
# Same core the ingestion service calls — the artifact is built once,
|
| 246 |
# here and there. run() adds only the folder-batch concerns around it:
|
| 247 |
# the manifest record and writing the result to disk.
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
artifact = outcome.artifact
|
| 250 |
result = outcome.parse_result
|
| 251 |
|
|
@@ -323,6 +336,8 @@ def main() -> None:
|
|
| 323 |
# on `vlm`.
|
| 324 |
p.add_argument("--effort", choices=["medium", "high"], default=None)
|
| 325 |
p.add_argument("--no-resume", action="store_true", help="reprocess everything")
|
|
|
|
|
|
|
| 326 |
p.add_argument("--start-page", type=int, default=None)
|
| 327 |
p.add_argument("--end-page", type=int, default=None)
|
| 328 |
a = p.parse_args()
|
|
@@ -338,6 +353,8 @@ def main() -> None:
|
|
| 338 |
cfg.effort = a.effort
|
| 339 |
if a.no_resume:
|
| 340 |
cfg.resume = False
|
|
|
|
|
|
|
| 341 |
if a.start_page is not None:
|
| 342 |
cfg.start_page = a.start_page
|
| 343 |
if a.end_page is not None:
|
|
|
|
| 21 |
from pathlib import Path
|
| 22 |
from typing import Any
|
| 23 |
|
| 24 |
+
from .assets import AssetSink, LocalAssetSink, NullAssetSink
|
| 25 |
from .checks import check_items, vocabulary_per_page
|
| 26 |
from .config import PipelineConfig
|
| 27 |
from .contracts import ParsedDocument
|
|
|
|
| 115 |
normalise_seconds: float
|
| 116 |
|
| 117 |
|
| 118 |
+
def _parse_one(
|
| 119 |
+
source: Path,
|
| 120 |
+
cfg: PipelineConfig,
|
| 121 |
+
doc_id: str,
|
| 122 |
+
asset_sink: AssetSink | None = None,
|
| 123 |
+
) -> _ParseOutcome:
|
| 124 |
"""The per-document core: parse, normalise, assemble the `ParsedDocument`.
|
| 125 |
|
| 126 |
The single place a `ParsedDocument` is built. Both `parse_one` and `run()` go
|
|
|
|
| 136 |
# character-spaced formulas, so neither applies.
|
| 137 |
checks: dict[str, Any] = {"backend": "mistral-ocr"}
|
| 138 |
t0 = time.perf_counter()
|
| 139 |
+
chunks = normalise_mistral(response, doc_id, asset_sink=asset_sink)
|
| 140 |
normalise_seconds = time.perf_counter() - t0
|
| 141 |
else:
|
| 142 |
items = json.loads(result.content_list.read_text(encoding="utf-8"))
|
|
|
|
| 196 |
source: Path,
|
| 197 |
cfg: PipelineConfig,
|
| 198 |
doc_id: str | None = None,
|
| 199 |
+
asset_sink: AssetSink | None = None,
|
| 200 |
) -> ParsedDocument:
|
| 201 |
"""Parse one document and return the artifact in memory. Raises on failure.
|
| 202 |
|
|
|
|
| 213 |
"""
|
| 214 |
if doc_id is None:
|
| 215 |
doc_id = source.stem
|
| 216 |
+
return _parse_one(source, cfg, doc_id, asset_sink).artifact
|
| 217 |
|
| 218 |
|
| 219 |
def run(cfg: PipelineConfig) -> Manifest:
|
|
|
|
| 252 |
# Same core the ingestion service calls — the artifact is built once,
|
| 253 |
# here and there. run() adds only the folder-batch concerns around it:
|
| 254 |
# the manifest record and writing the result to disk.
|
| 255 |
+
# Assets land beside the artifact they belong to, so a run folder is
|
| 256 |
+
# self-describing and movable. `--no-assets` keeps the metadata and
|
| 257 |
+
# drops only the bytes (see assets.NullAssetSink).
|
| 258 |
+
sink: AssetSink = (
|
| 259 |
+
NullAssetSink() if cfg.no_assets else LocalAssetSink(target)
|
| 260 |
+
)
|
| 261 |
+
outcome = _parse_one(source, cfg, doc_id, asset_sink=sink)
|
| 262 |
artifact = outcome.artifact
|
| 263 |
result = outcome.parse_result
|
| 264 |
|
|
|
|
| 336 |
# on `vlm`.
|
| 337 |
p.add_argument("--effort", choices=["medium", "high"], default=None)
|
| 338 |
p.add_argument("--no-resume", action="store_true", help="reprocess everything")
|
| 339 |
+
p.add_argument("--no-assets", action="store_true",
|
| 340 |
+
help="keep asset metadata but do not write image bytes to disk")
|
| 341 |
p.add_argument("--start-page", type=int, default=None)
|
| 342 |
p.add_argument("--end-page", type=int, default=None)
|
| 343 |
a = p.parse_args()
|
|
|
|
| 353 |
cfg.effort = a.effort
|
| 354 |
if a.no_resume:
|
| 355 |
cfg.resume = False
|
| 356 |
+
if a.no_assets:
|
| 357 |
+
cfg.no_assets = True
|
| 358 |
if a.start_page is not None:
|
| 359 |
cfg.start_page = a.start_page
|
| 360 |
if a.end_page is not None:
|