/feat knowledge management

#20
by rhbt6767 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .gitignore +5 -1
  2. DEV_PLAN.md +50 -0
  3. KNOWLEDGE_PIPELINE_CALIBRATION.md +257 -0
  4. KNOWLEDGE_PIPELINE_TODO.md +210 -0
  5. eval/knowledge/README.md +84 -0
  6. eval/knowledge/knowledge_gold.yaml +312 -0
  7. eval/knowledge/results/baseline_prototype_2026-08-13_145132.json +157 -0
  8. eval/knowledge/results/v2_parser_comparison_2026-08-19_152842.json +59 -0
  9. eval/knowledge/results/v2_term_filter_2026-08-19_144318.json +53 -0
  10. eval/knowledge/score.py +227 -0
  11. eval/planner/results/planner_result_2026-07-23_134947.json +662 -0
  12. eval/planner/results/planner_result_2026-07-23_141931.json +1818 -0
  13. eval/planner/results/planner_result_2026-07-23_152650.json +2123 -0
  14. eval/readiness/results/readiness_result_2026-06-23_094622.json +284 -0
  15. eval/readiness/results/readiness_result_2026-07-02_083335.json +250 -0
  16. eval/readiness/results/readiness_result_2026-07-02_093621.json +250 -0
  17. knowledge_pipeline_context.md +299 -0
  18. pyproject.toml +22 -0
  19. src/config/settings.py +7 -0
  20. src/knowledge_extraction/__init__.py +62 -0
  21. src/knowledge_extraction/adapter.py +136 -0
  22. src/knowledge_extraction/cli.py +251 -0
  23. src/knowledge_extraction/cluster/__init__.py +4 -0
  24. src/knowledge_extraction/cluster/cluster.py +111 -0
  25. src/knowledge_extraction/cluster/normalize.py +77 -0
  26. src/knowledge_extraction/config/__init__.py +0 -0
  27. src/knowledge_extraction/config/cues.yaml +51 -0
  28. src/knowledge_extraction/config/labels.yaml +44 -0
  29. src/knowledge_extraction/diff/__init__.py +3 -0
  30. src/knowledge_extraction/diff/glossary_diff.py +33 -0
  31. src/knowledge_extraction/extract/__init__.py +27 -0
  32. src/knowledge_extraction/extract/base.py +67 -0
  33. src/knowledge_extraction/extract/branches.py +235 -0
  34. src/knowledge_extraction/extract/client.py +221 -0
  35. src/knowledge_extraction/extract/prompts/formula.txt +28 -0
  36. src/knowledge_extraction/extract/prompts/glossary.txt +68 -0
  37. src/knowledge_extraction/extract/prompts/rule.txt +39 -0
  38. src/knowledge_extraction/extract/prompts/summary.txt +21 -0
  39. src/knowledge_extraction/extract/schemas.py +85 -0
  40. src/knowledge_extraction/filters/__init__.py +12 -0
  41. src/knowledge_extraction/filters/cue_filter.py +76 -0
  42. src/knowledge_extraction/filters/legend_filter.py +98 -0
  43. src/knowledge_extraction/filters/span_filter.py +169 -0
  44. src/knowledge_extraction/models.py +273 -0
  45. src/knowledge_extraction/queue/__init__.py +3 -0
  46. src/knowledge_extraction/queue/review_queue.py +65 -0
  47. src/knowledge_extraction/rank/__init__.py +3 -0
  48. src/knowledge_extraction/rank/evidence.py +168 -0
  49. src/knowledge_extraction/service.py +231 -0
  50. src/knowledge_extraction/settings.py +93 -0
.gitignore CHANGED
@@ -54,4 +54,8 @@ docs/specs/tabular_parquet_contract.md
54
  docs/specs/tabular_parquet.md
55
 
56
  # Personal / local working docs (not for the shared repo) β€” archived out of root
57
- docs/_archive/
 
 
 
 
 
54
  docs/specs/tabular_parquet.md
55
 
56
  # Personal / local working docs (not for the shared repo) β€” archived out of root
57
+ docs/_archive/
58
+ # Knowledge pipeline output β€” data, not code (parse cache + run artifacts)
59
+ data/knowledge_cache/
60
+ data/knowledge_runs/
61
+ data/knowledge_docs/
DEV_PLAN.md CHANGED
@@ -273,6 +273,51 @@ by design** until #43 (Go-forwarded identity). Not a gap to re-raise β€” it is a
273
  CORS was left at `["*"]` on purpose (tightening it needs the FE origin as config, which we chose
274
  not to set for now).
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  ## 5. Critical path & sequencing
277
 
278
  - **Critical path:** ~~#22 (send Harry the `report_inputs` schema)~~ **βœ… resolved 2026-07-22** β€” now **#32** (`message_traceability` + `message_charts` DDL to Harry) and **#31** (non-convergent migration set). HF deploy (#13) for the playground. (#4 βœ…, #21 βœ…; Harry's #3 no longer blocks us β€” Python is getattr-tolerant.)
@@ -297,3 +342,8 @@ not to set for now).
297
  - **W3 re-open timing** (deferred `analyze_*` activation) β€” Rifqi (deferred at the 2026-07-13 approval).
298
  - **INV-6 relaxation for S1b targeted repair** β€” team, only after S1a `repair_candidate` telemetry
299
  shows a meaningful hit-rate (SPINE_V2_PLAN Β§6).
 
 
 
 
 
 
273
  CORS was left at `["*"]` on purpose (tightening it needs the FE origin as config, which we chose
274
  not to set for now).
275
 
276
+ ## 0.8. Knowledge pipeline β€” test integration (planning, 2026-08-19)
277
+
278
+ **Nothing has landed in this repo.** This section exists so the in-flight work is tracked in the
279
+ usual place; no code, dependency, table or endpoint has been added here yet. Do not read any of it
280
+ into REPO_STATUS.
281
+
282
+ Context and intent: [knowledge_pipeline_context.md](knowledge_pipeline_context.md). Task breakdown,
283
+ owners and verified statuses: [KNOWLEDGE_PIPELINE_TODO.md](KNOWLEDGE_PIPELINE_TODO.md).
284
+
285
+ **Shape.** A six-stage pipeline (parse β†’ free CPU/regex filter β†’ one LLM call per *term cluster* β†’
286
+ deterministic span validation β†’ diff vs. the active glossary β†’ frequency-sorted expert review) that
287
+ turns client documents into reviewable knowledge entries, ultimately served over MCP. It splits into
288
+ two independently runnable halves β€” **parsing (Sofhia, MinerU)** and **knowledge extraction
289
+ (Rifqi)** β€” meeting only at a **persisted, versioned parsed-document artifact**. Extraction never
290
+ takes a file path; that constraint is the whole point of the split.
291
+
292
+ **Where it stands (audited 2026-08-19).** A working **prototype** (`kex`, ~2,763 LOC, 21 passing
293
+ tests, 8-command CLI, four experiments against real Azure calls) exists outside version control at
294
+ `Documents/Dataeyond_catalog/knowledge-pipeline/`. It is **not the product and will not be ported** β€”
295
+ it answered the design questions; extraction v2 gets rebuilt in this repo. What carries forward is
296
+ the *evidence* (gold sets, experiment results) and the *tuned constants*, not the code.
297
+ Measured on the BUMA standard (9 pages): GLiNER Indonesian recall **0.854** (kill line 0.70, PASS β€”
298
+ this closes the context doc's headline open risk), clustering compression **2.56Γ—** (kill 2.0, PASS),
299
+ nano schema-fill precision **0.75** (kill 0.80, **FAIL**), span-validation rejection **1.00** with
300
+ 0.00 false rejections (kill 0.90, PASS). Cost **$0.0069** for the document.
301
+
302
+ | # | Task | Owner | Status | Note |
303
+ |---|---|---|---|---|
304
+ | P1 | Freeze the `kex` prototype (read-only snapshot) | Rifqi | ⬜ | No `.git`. ~2,763 LOC + both gold sets + all four experiment results on one machine. It is an archive, **not** a codebase to develop β€” v2 is a rebuild here, not a port |
305
+ | P2 | Harvest the prototype's tuned constants | Rifqi | ⬜ | Every number that took runs to find, with its reason, **before** v2 code: rapidfuzz 92, fuzzy off below 5 chars (`PA`/`UA` collide), K=3, the evidence weight table, the 1024-token prefix pad (below it, caching does not engage β€” worth the measured 54% hit rate), label + cue sets, the 4 prompts |
306
+ | P3 | Migrate the evidence into `eval/knowledge/` | Rifqi | ⬜ | Gold sets (41 terms + 15 rules) + scorer + experiment runners, following the existing `eval/{intent,help,readiness,planner}/` convention. **Before any v2 pipeline code** β€” without a standing baseline, "improved" is unfalsifiable |
307
+ | S1 | Agree the parsed-artifact schema | Sofhia + Rifqi | πŸ”„ | Sofhia's draft `contracts.py` (2026-08-19) adopted as the base. Open: document-level envelope (no home for `version`), the section-structure claim, `source_wording` |
308
+ | S5 | Keep the seam off the file path | Sofhia + Rifqi | ⬜ | The prototype's boundary was `Parser.parse(path) -> list[Page]` β€” the shape the plan forbids. Settle **before** the MinerU parser is built against it |
309
+ | X* | Build extraction v2 in this repo | Rifqi | ⬜ | 17 stages, `src/query/`-shaped. Design is validated by the prototype; **none of it exists here yet** β€” see the two-column table in the checklist |
310
+ | D2 | Artifact + candidate-entry persistence | Rifqi β†’ Harry | ⬜ | Parsed artifacts, candidate entries, glossary versions + approval audit trail. Go owns dedorch DDL β†’ one consolidated Harry-ready handoff, never DDL from Python (Β§2.2) |
311
+ | R1 | Fix the literal-source-wording violation | Rifqi | ⬜ | A locked decision is violated: the BUMA heading reads "Physical **of** Availability (PA)"; the extracted entry carries "Physical Availability". The discrepancy is meant to reach the expert |
312
+ | D3 | Provision a mini deployment | Rifqi | β›” | Only nano is provisioned, so all four branches route there β€” including `rule` and `summary`, whose failure mode is least detectable. Blocks the nano-vs-mini A/B |
313
+ | D6 | Expert review of the gold set | Rifqi β†’ Mas Beta | β›” | The 41-term / 15-rule gold set is self-bootstrapped and provisional. Now the baseline v2 is measured against |
314
+ | D5 | v2 module name | Rifqi | ⬜ | `src/knowledge/` is taken by the existing OCRβ†’pgvector service. Pick a non-colliding name before the first commit |
315
+
316
+ **Not decided, do not assume:** the v2 module name (D5); whether the admin ingest surface is HTTP
317
+ at all (the offline script path is the honest first milestone); and GLiNER + LangExtract ensembling
318
+ stays ⏸️ flagged as scope creep. **`kex` is not being ported** �� it is a prototype that answered the
319
+ design questions; v2 is built here against this repo's patterns.
320
+
321
  ## 5. Critical path & sequencing
322
 
323
  - **Critical path:** ~~#22 (send Harry the `report_inputs` schema)~~ **βœ… resolved 2026-07-22** β€” now **#32** (`message_traceability` + `message_charts` DDL to Harry) and **#31** (non-convergent migration set). HF deploy (#13) for the playground. (#4 βœ…, #21 βœ…; Harry's #3 no longer blocks us β€” Python is getattr-tolerant.)
 
342
  - **W3 re-open timing** (deferred `analyze_*` activation) β€” Rifqi (deferred at the 2026-07-13 approval).
343
  - **INV-6 relaxation for S1b targeted repair** β€” team, only after S1a `repair_candidate` telemetry
344
  shows a meaningful hit-rate (SPINE_V2_PLAN Β§6).
345
+ - **Knowledge pipeline — v2 module name** (§0.8 D5): `src/knowledge/` is already the OCR→pgvector
346
+ service, so extraction v2 needs its own module name before the first commit.
347
+ - **Knowledge pipeline β€” admin ingest surface** (Β§0.8 D4): whether it is HTTP at all, and if so its
348
+ auth posture. A write surface triggered by an admin is a different risk class from the current
349
+ unauthenticated read-only chat surface (Β§0.7) β€” raise it, do not inherit the posture by default.
KNOWLEDGE_PIPELINE_CALIBRATION.md ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Knowledge Pipeline β€” Calibration Reference
2
+
3
+ **Harvested from the `kex` prototype, 2026-08-19.** Every number below cost a run, a bug, or a
4
+ measurement to find, and **none of it is in the spec** β€” it lived only as constants and comments in
5
+ a codebase that is not being ported.
6
+
7
+ This file exists because clean rewrites drop accidental knowledge and then rediscover it as
8
+ production defects. When building extraction v2, take the value **and** the reason. If you change
9
+ one, change it deliberately and record why.
10
+
11
+ Companions: [KNOWLEDGE_PIPELINE_TODO.md](KNOWLEDGE_PIPELINE_TODO.md) (plan) Β·
12
+ [eval/knowledge/](eval/knowledge/) (gold set, scorer, frozen baseline) Β·
13
+ [knowledge_pipeline_context.md](knowledge_pipeline_context.md) (intent).
14
+
15
+ ---
16
+
17
+ ## 1. Term filter β€” GLiNER
18
+
19
+ | Constant | Value | Why this value |
20
+ |---|---|---|
21
+ | 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 |
22
+ | 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** |
23
+ | 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 |
24
+ | 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 |
25
+ | 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 |
26
+
27
+ **Label sets** (`labels.yaml`) β€” three variants were tried and A/B'd without touching code. Keep
28
+ that property: label phrasing is the main recall lever and GLiNER is very sensitive to it. The
29
+ Indonesian-phrasing variant (`istilah produksi tambang`, …) did **not** win; `broad` did.
30
+
31
+ ---
32
+
33
+ ## 2. Clustering
34
+
35
+ | Constant | Value | Why this value |
36
+ |---|---|---|
37
+ | Match order | exact (normalised) β†’ abbreviation↔expansion β†’ fuzzy | Cheapest and most certain first |
38
+ | Fuzzy threshold | **92** (`rapidfuzz.token_set_ratio`) | Conservative on purpose |
39
+ | **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 |
40
+ | Canonical surface | shortest variant β‰₯ 2 chars | That is how a reader looks a term up β€” `PA`, not `Physical Availability (PA) untuk …` |
41
+
42
+ **Over-merging is worse than under-merging, and the asymmetry is not close.** An under-merge costs
43
+ one extra LLM call and one extra review row. A wrong merge silently destroys a distinct term and
44
+ **the expert never sees it** β€” there is no downstream mechanism that recovers it.
45
+
46
+ **Legend extraction must run before clustering.** Without the abbreviation index, `PA` and
47
+ `Physical Availability` cluster as two unrelated terms.
48
+
49
+ **Stop surfaces** β€” dropped as whole surface forms only (never as substrings, so no term is lost):
50
+ `unit Β· type Β· class Β· equipment Β· equipment unit Β· parameter Β· activity Β· data Β· nilai Β· proses Β·
51
+ hasil Β· total`.
52
+
53
+ **Normalisation** (clustering only, never for span checking): NFKC β†’ casefold β†’ `-`/`_` β†’ space β†’
54
+ strip `.’'` β†’ non-word to space β†’ collapse whitespace β†’ strip ` ()/`.
55
+
56
+ ---
57
+
58
+ ## 3. Evidence ranking
59
+
60
+ Six signals plus one penalty. The full ranked list is retained, not just the top K β€” escalation
61
+ needs the tail.
62
+
63
+ | Signal | Weight |
64
+ |---|---|
65
+ | Definitional cue within 100 chars of the mention | **+5.0** |
66
+ | Term appears in the chunk heading | **+4.0** |
67
+ | Mention sits inside a legend block | **+3.5** |
68
+ | Chunk contains a formula | **+2.0** |
69
+ | Mention is bold/italic | **+1.5** |
70
+ | First occurrence of the cluster | **+1.0** |
71
+ | Chunk is tabular | **βˆ’3.0** |
72
+
73
+ | Constant | Value | Why |
74
+ |---|---|---|
75
+ | `evidence_k` | **3** | Chunks fed per extraction round |
76
+ | Cue proximity | **100 chars** | Distance from mention to a definitional cue |
77
+
78
+ **Heading matching is word-boundary, never substring.** `PA` is a substring of `Parameter`, `pada`
79
+ and `composite` β€” substring matching handed the +4.0 heading bonus to almost every Indonesian
80
+ heading and pushed real definition sections *below* formula tables.
81
+
82
+ **Why this filter is legitimate when the relevance gate was rejected:** no term is dropped (only its
83
+ *evidence* is narrowed), every mention stays in provenance so a bad top-3 is visible, and the
84
+ escalation loop self-corrects. The rejected gate had none of those three properties. Preserve all
85
+ three if ranking is ever revisited.
86
+
87
+ ---
88
+
89
+ ## 4. Chunking
90
+
91
+ | Constant | Value | Why |
92
+ |---|---|---|
93
+ | Max chunk tokens | **1500** | With paragraph-boundary splitting; sentence boundaries as fallback for a single over-budget paragraph |
94
+ | Max heading length | **90 chars** | Longer lines are sentences or formula rows, not headings |
95
+ | Boilerplate frequency | **β‰₯ 0.6 of pages** | A line repeating on 60%+ of pages is a running header/footer. Detected by frequency with **digits normalised to `#`** so page counters collapse β€” never by a hardcoded document-specific string |
96
+ | Tabular detection | short lines > 0.6 **and** numeric lines > 0.4 | Feeds the βˆ’3.0 ranking penalty |
97
+
98
+ **Three things the real document forced, all of which corrupt every downstream number if missed:**
99
+
100
+ 1. **Running headers/footers** β€” every page repeats the title block and "Confidential".
101
+ 2. **Breadcrumb headings** β€” pages re-print `2. PENJELASAN PARAMETER / 2.1. Production Parameter / …`
102
+ at the top. A naive splitter re-opens the section and shatters it. Rule used: *a heading that is
103
+ the current section or an ancestor of it, already seen, is a breadcrumb* β€” the section continues
104
+ and `page_end` extends.
105
+ 3. **Colon-continuation** β€” legend rows arrive as `MOHH` then `: Machine on Hand Hours` on the next
106
+ line. Rejoined before the legend filter sees them.
107
+
108
+ Points 1 and 2 apply to MinerU output too: its `title` blocks repeat the same breadcrumbs on pages
109
+ 2–8 of the standard.
110
+
111
+ ---
112
+
113
+ ## 5. Extraction (the paid stage)
114
+
115
+ | Constant | Value | Why |
116
+ |---|---|---|
117
+ | Temperature | **0.0** | And still not deterministic β€” see below |
118
+ | Seed | 7 | |
119
+ | **Cache minimum** | **1024 prompt tokens** | OpenAI-family prompt caching **does not engage at all** below this. The glossary prefix is padded past it *on purpose*. Measured hit rate **54%** β€” 125,184 of 155,313 prompt tokens |
120
+ | Structured output | `json_schema`, fallback `json_object` + validate-retry | Which mode was used is recorded in the run manifest |
121
+ | API version | `2024-12-01-preview` | `json_schema` needs β‰₯ `2024-08-01-preview` |
122
+
123
+ **Prompts live in files, not code, and the fixed prefix must stay byte-identical across calls** β€”
124
+ any drift and caching stops engaging, silently, at ~10Γ— the input cost.
125
+
126
+ **`temperature=0` is not determinism.** Consecutive runs of the identical pipeline scored 0.75 and
127
+ 0.625 on schema fill. Never report a single run as a measurement on a small base.
128
+
129
+ ---
130
+
131
+ ## 6. Validation
132
+
133
+ | Constant | Value | Why |
134
+ |---|---|---|
135
+ | Span normalisation | **whitespace only** (+ NFKC) | Not case, not punctuation, not diacritics. Every additional normalisation is a hole a fabrication fits through |
136
+ | Escalation rounds | **max 2** | Then keep the entry flagged `no_definition_found` and pass it to review anyway β€” a term we found but could not define is still useful; dropping it hides a known unknown |
137
+ | Conflict overlap threshold | **0.4** token overlap | Token overlap, not embeddings: cheaper, needs no model, and **explainable to the reviewer who has to act on it** |
138
+ | Duplicate overlap threshold | **0.8** | Above it, a differing definition is a duplicate; below, a conflict |
139
+
140
+ **Guarded fields** (span-checked; failure sets the field to `None` and logs it):
141
+
142
+ | Branch | Fields |
143
+ |---|---|
144
+ | glossary | `definition`, `full_name`, `formula_latex`, `interpretation` |
145
+ | rule | `statement`, `condition`, `consequence` |
146
+ | formula | `formula_latex` |
147
+ | summary | *(none β€” generation cannot be span-checked at all)* |
148
+
149
+ **If the provenance span itself is not verbatim, every guarded field on the entry is rejected** β€”
150
+ the entry's only evidence link is broken, so nothing on it can be trusted.
151
+
152
+ **Never repair a failed span.** A repaired span is an unfalsifiable claim, which is exactly what the
153
+ control exists to prevent.
154
+
155
+ **Conflict detection never picks a winner.** The expert decides.
156
+
157
+ ---
158
+
159
+ ## 7. Negative findings β€” do not re-derive these
160
+
161
+ | Finding | Detail |
162
+ |---|---|
163
+ | **nano is not sufficient for schema fill** | 0.75 precision against a 0.80 kill line. Decision 2026-08-19: **stay on nano anyway** for now; the `__54n` env quad is provisioned. Revisit if quality blocks the demo |
164
+ | **Escalation is unreachable on small documents** | 54 of 66 clusters had exactly **1** evidence chunk against K=3, so `rounds_available()` correctly returned 0 and the loop never fired. `escalated = 0` is not a bug. It stays unverified until a larger corpus runs |
165
+ | **Conflict detection had nothing to find** | 0 conflicts on a single internally-consistent standard. Untested against real disagreement |
166
+ | **85% abstention** | 56 of 66 entries returned no definition. Correct behaviour, but it means the review queue is mostly *"term found, no definition in document"*. Whether that is useful or noise is a review-experience question for Mas Beta |
167
+ | **Diff only ever ran the empty-baseline path** | The prototype diffed against the file it then overwrote, so every entry came back `new`. v2 needs a real active-version pointer |
168
+ | **Literal source wording was normalised away** | The standard's heading reads *"Physical **of** Availability (PA)"*; the entry carried `full_name: "Physical Availability"`. The gold set records the literal form correctly, so **gold was right and the pipeline was wrong**. Mechanism: the heading is a separate field from the chunk text, and the chunk text does not contain the phrase, so the literal wording never reached the model. Fix: feed the heading verbatim + a span-checked `source_wording` field |
169
+ | **A `glob('*.pdf')` picked the wrong document once** | Scored the textbook against the standard's gold set β†’ recall 0.05. Document selection must be explicit and error on ambiguity |
170
+ | **torch wheels install corrupt** | `torchgen` missing, version reports `None`. Fix is delete `site-packages/torch*` and reinstall. Needs **torch β‰₯ 2.6**. Documented so nobody debugs it as a GLiNER problem |
171
+
172
+ ---
173
+
174
+ ## 8. What was measured, so v2 can be compared
175
+
176
+ Frozen in `eval/knowledge/results/baseline_prototype_2026-08-13_145132.json`.
177
+
178
+ Funnel on the 9-page standard: **9 pages β†’ 13 chunks β†’ 169 mentions** (195 before noise filtering)
179
+ **β†’ 66 clusters β†’ 83 LLM calls β†’ 66 entries β†’ 66 review-queue items.**
180
+
181
+ E1 recall **0.854** Β· E2 compression **2.56Γ—** Β· E3 schema-fill precision **0.75** (FAIL) Β·
182
+ E4 fabrication rejection **1.00** with **0.00** false rejections. Cost **$0.0069**, 242 s wall
183
+ clock, 3 fields rejected by span check.
184
+
185
+ **When quoting E2 externally:** the context doc's **6.2Γ—** is a 1,000-page projection; **2.56Γ—** is
186
+ the 9-page measurement. Compression grows as terms repeat across a corpus. Both are true β€” use the
187
+ right one for the audience.
188
+
189
+ ---
190
+
191
+ ## 9. Deliberate deviations from the prototype (v2)
192
+
193
+ Changes made while rebuilding, with the reason. Each one alters behaviour, so
194
+ each is listed rather than folded silently into the tables above.
195
+
196
+ | # | Change | Why | Measured? |
197
+ |---|---|---|---|
198
+ | **V1** | **A heading that names the term counts as a mention at offset 0** for cue-proximity scoring | Indonesian standards are written *"2.1.3 Physical of Availability (PA)"* / *"Adalah ketersediaan fisik…"* β€” the heading names the term and the body opens with the definitional cue **without repeating it**. The prototype therefore awarded no cue bonus on the definition section itself. Measured on the BUMA standard: PA's own definition section (Β§2.1.3) scored 7.5 and ranked **third**, below a computation section (Β§2.2.4, 8.5) that merely mentions PA often. After the fix, all five terms rank their own definition section first β€” PAβ†’2.1.3, UAβ†’2.1.4, Qtyβ†’2.1.2, Ptyβ†’2.1.5, MOHHβ†’2.1.1 | Structurally verified on the 9-page standard. **Not yet scored** β€” needs the paid stage to show up in E3 |
199
+ | **V2** | **`first_occurrence` follows document order**, and ranking ties break on document order | The prototype awarded the +1.0 to whichever chunk a `set` iteration yielded first, making the bonus arbitrary and the ranking unreproducible across runs. "First occurrence" should mean the term's first appearance in the document | Determinism locked by test; verified stable across `PYTHONHASHSEED` 0/1/7/42/99 |
200
+ | **V3** | **`source_wording` field added** to the glossary entry | The control for the literal-source-wording defect (Β§7). Not yet populated β€” needs the seam to carry the heading verbatim and the extraction prompt to fill it | No |
201
+
202
+ **Both V1 and V2 change which chunks reach the paid stage**, so they can move E3
203
+ when the extraction branch lands. Re-score against
204
+ `eval/knowledge/results/baseline_prototype_2026-08-13_145132.json` at that point
205
+ and record the delta rather than assuming the improvement carries.
206
+
207
+ **V1 and the R1 literal-wording defect share a root cause:** the heading is a
208
+ separate field from the chunk text, so anything that reasons over "the term and
209
+ its definition together" has to be told to look at both.
210
+
211
+ | **V4** | **`source_wording` is taken from the section heading, not from the model** | Asked to quote the source wording, the model returned "Physical Availability (PA)" β€” a genuine verbatim quote from the page-1 intro, but not the Β§2.1.3 heading "Physical **of** Availability (PA)". Both occur in the document; only the heading form reveals the disagreement. Taking it deterministically means it cannot be normalised away | Live-verified on the pilot: the entry now carries both forms and the queue flags the mismatch |
212
+ | **V5** | **`evidence_text` includes chunk headings** | The heading is part of the source document and is often the only place a term is formally named. Excluding it rejected a correct verbatim quotation of the document's own section title (2 rejections on a 3-entry pilot, both spurious) | Live-verified: 0 rejections after the change |
213
+ | **V6** | **`full_name` and `source_wording` are span-checked against the source themselves** | Both claim to be literal transcriptions. The provenance span passing does not prove the transcription is faithful, and a normalised "full name" is exactly the silent correction this pipeline exists to surface | Unit-tested |
214
+
215
+ **Cache-floor finding (measured on the real prompts):** only the **glossary**
216
+ prefix clears the 1024-token floor at **1,401 tokens**. `rule` (731), `formula`
217
+ (459) and `summary` (288) are **not cacheable** and cache nothing today. Glossary
218
+ is also the branch with by far the most calls (66 of 83 on the reference
219
+ document), so the padding is on the branch that matters β€” but padding the other
220
+ three is free money if their call counts ever grow.
221
+
222
+ ---
223
+
224
+ ## 10. Parser comparison β€” MinerU vs PyMuPDF (2026-08-19)
225
+
226
+ First run of the extraction half on **MinerU-parsed input**, same document, same
227
+ gold set, same pipeline code. Result:
228
+ `eval/knowledge/results/v2_parser_comparison_2026-08-19_152842.json`.
229
+
230
+ | Input | E1 recall | Terms found |
231
+ |---|---|---|
232
+ | PyMuPDF (what the frozen baseline used) | **0.8537** | 35 / 41 |
233
+ | MinerU, raw markup | **0.7561** | 31 / 41 |
234
+ | MinerU, formulas + tables rendered to prose | **0.8293** | 34 / 41 |
235
+
236
+ **The span model reads prose, not markup.** MinerU's structure is an asset for
237
+ the formula branch and a liability for the filter unless it is rendered first:
238
+
239
+ - Terms wrapped in LaTeX are invisible. `\text {Total Hours} - \text {Breakdown}`
240
+ yields no mentions for either term, though both are gold terms and both are
241
+ found in the plain-text parse. Unwrapping `\text{}`/`\mathrm{}`/`\frac{}{}`
242
+ and collapsing MinerU's character-spaced output recovers them.
243
+ - Raw `<table>` HTML in the chunk text is noise the model has to read past. Eight
244
+ of fourteen chunks carried it before rendering.
245
+
246
+ **The residual ~2.4 points are chunking, not content.** MinerU marks non-section
247
+ labels β€” `Keterangan:`, `Keterangan grafik:` β€” as `text_level` titles. Opening a
248
+ new section at each one splits a legend away from the figure it explains, and
249
+ `Other Activity` / `Uncontrollable` are then missed **despite being present as
250
+ prose in a chunk the filter processed**. Only **numbered** headings should open a
251
+ section, which is consistent with Β§9's finding that MinerU flattens every heading
252
+ to `level: 2` β€” numbering is the only reliable section signal it gives us.
253
+
254
+ **Caveat on attribution:** the MinerU artifacts here were built by a scratch
255
+ converter, because section-aware chunking belongs to the parsing half. These are
256
+ findings about what that chunker must handle, not a measurement of MinerU
257
+ itself.
KNOWLEDGE_PIPELINE_TODO.md ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Knowledge Pipeline β€” Task Checklist
2
+
3
+ **Date:** 2026-08-19 Β· **Owner of this doc:** Rifqi Β· **Shared with:** Sofhia
4
+ **Intent + rationale:** [knowledge_pipeline_context.md](knowledge_pipeline_context.md)
5
+ **Tracked in:** [DEV_PLAN.md](DEV_PLAN.md) Β§0.8
6
+ **Target repo:** `Agentic-Service-Data-Eyond-Catalog` (Python agentic service)
7
+
8
+ **Status legend:** ⬜ not started Β· πŸ”„ in progress Β· βœ… done Β· β›” blocked Β· πŸ”Ž built but unverified Β· ⏸️ deferred
9
+
10
+ ---
11
+
12
+ ## How to read this
13
+
14
+ The pipeline splits into two independently runnable halves that meet at **one persisted, versioned
15
+ parsed-document artifact**: **parsing** (Sofhia, MinerU) and **knowledge extraction** (Rifqi).
16
+ Extraction never takes a file path β€” that constraint is the point of the split, because it keeps the
17
+ parser swappable and lets extraction be re-run cheaply on an already-parsed corpus.
18
+
19
+ **The extraction half has a working prototype (`kex`) that is not the product.** It lives outside
20
+ this repo, is not under version control, and will **not** be ported. It answered the design
21
+ questions; v2 gets rebuilt here against this repo's patterns. That distinction drives the whole
22
+ structure below β€” Β§2 disposes of the prototype, Β§5 builds v2, and Β§5 deliberately separates *design
23
+ validated* from *implemented here*, because the two are very different amounts of work.
24
+
25
+ ---
26
+
27
+ ## 1. What the prototype already proved
28
+
29
+ `kex` β€” ~2,763 LOC, 21 passing tests, 8-command CLI, four experiments against real Azure calls.
30
+ Measured on the BUMA standard `STD_2026_006_MNO` (9 pages, majority Bahasa Indonesia), run
31
+ `20260813-145132`.
32
+
33
+ **Funnel:** 9 pages β†’ 13 chunks β†’ 169 mentions (195 raw) β†’ 66 clusters β†’ 66 entries β†’ 66 queue items.
34
+
35
+ | Experiment | Question | Result | Kill line | Verdict |
36
+ |---|---|---|---|---|
37
+ | **E1** | GLiNER recall on Bahasa Indonesia technical prose | **0.854** | 0.70 | **PASS** |
38
+ | **E2** | Does clustering cut expert review burden? | **2.56Γ—** (169 β†’ 66) | 2.0Γ— | **PASS** |
39
+ | **E3** | Is nano sufficient for schema fill? | **0.75** | 0.80 | **FAIL** |
40
+ | **E4** | Does verbatim-span validation catch fabrication? | **1.00** (false-rejection 0.00) | 0.90 | **PASS** |
41
+
42
+ Also measured: **$0.0069** for the document Β· 155,313 prompt tokens of which **125,184 cached**
43
+ (54% hit rate) Β· 242 s wall clock Β· 3 fields rejected by span check Β· 56 of 66 entries returned no
44
+ definition Β· 0 escalations Β· 0 definition conflicts.
45
+
46
+ **Two results that settle open questions:**
47
+
48
+ - **E1 closes the Indonesian risk.** The context doc names GLiNER's unverified Indonesian
49
+ performance as the headline unknown. It is now measured, on the exact document named as the test
50
+ case, comfortably above the kill line.
51
+ - **E3 half-settles the model tier.** nano **fails** at 0.75 against a 0.80 line. Caveats: the
52
+ scoreable base is 8 entries, and nano is not deterministic at `temperature=0` (consecutive
53
+ identical runs gave 0.75 and 0.625). No mini deployment exists, so the intended nano-vs-mini A/B
54
+ has never run and all four branches currently route to nano.
55
+
56
+ **Caveat when quoting E2.** The context doc's **6.2Γ—** is a 1,000-page projection; **2.56Γ—** is the
57
+ 9-page measurement. Compression grows as terms repeat across a corpus. Both are true β€” quote the
58
+ right one to the right audience.
59
+
60
+ ---
61
+
62
+ ## 2. Prototype disposition β€” do this before building v2
63
+
64
+ `kex` is three different assets with three different fates. Treating it as one thing is how you
65
+ either port scaffolding you didn't want or discard measurements you can't recreate.
66
+
67
+ | # | Task | Owner | Status | Description |
68
+ |---|---|---|---|---|
69
+ | **P1** | Freeze the prototype | Rifqi | βœ… | **Resolved 2026-08-19: kept as-is, in place, unversioned β€” Rifqi's call.** The machine-failure risk is accepted knowingly. It is materially reduced anyway now that P2 and P3 have lifted the two things that could not be recreated (the calibration knowledge and the gold set + baseline) into this repo |
70
+ | **P2** | Harvest the tuned constants | Rifqi | βœ… | **Done 2026-08-19 β†’ [KNOWLEDGE_PIPELINE_CALIBRATION.md](KNOWLEDGE_PIPELINE_CALIBRATION.md).** 8 sections, every value with its reason: GLiNER threshold 0.25 (recall 0.854 vs 0.658 @ 0.35) and the 130-word window (Indonesian subword-tokenises ~2.5Γ—, so 220 still tripped GLiNER's 384 cap); rapidfuzz 92 with fuzzy disabled below 5 chars (`PA`/`UA`); the six-signal weight table + word-boundary heading matching; chunking's 1500-token cap, 0.6 boilerplate frequency and the three real-document traps; the 1024-token cache floor; span/escalation/conflict thresholds and the guarded-field table |
71
+ | **P3** | Migrate the evidence into `eval/knowledge/` | Rifqi | βœ… | **Done 2026-08-19.** `eval/knowledge/` now carries `knowledge_gold.yaml` (41 terms + 15 rules, verbatim), `score.py` (ported; only the prototype's `sys.path` bootstrap removed), `README.md`, and the frozen `results/baseline_prototype_2026-08-13_145132.json`. **Migration verified faithful:** the ported scorer re-derives the baseline exactly from the prototype's artifacts β€” E1 recall 0.8537, E3 precision 0.75, scoreable base 8, abstentions 56, all MATCH. `ruff` clean; `import main` OK. No `run_eval.py` yet β€” it lands with the first v2 stage that produces scoreable output |
72
+ | **P4** | Record the negative findings | Rifqi | βœ… | **Done 2026-08-19 β€” calibration doc Β§7.** Eight entries: nano insufficient for schema fill; escalation unreachable on small documents; conflict detection had nothing to find; 85% abstention; diff only ever ran the empty-baseline path; literal source wording normalised away (gold was right, pipeline wrong); a `glob('*.pdf')` once scored the wrong document (recall 0.05); torch wheels install corrupt and need β‰₯ 2.6 |
73
+
74
+ ---
75
+
76
+ ## 3. The seam β€” shared with Sofhia
77
+
78
+ **Status update 2026-08-19 (Sofhia).** The contract has landed in
79
+ `src/knowledge_parsing/contracts.py` and every open item raised against the draft is closed except
80
+ the two that need a joint decision (S6a formatting, S6b page indexing). Rows below updated
81
+ accordingly; the extraction-side rows (S7, S8) are left as Rifqi has them.
82
+
83
+ **Original note β€” under active discussion.** Sofhia circulated a draft `contracts.py` on 2026-08-19,
84
+ derived from real MinerU output rather than designed on paper, and it is adopted as the base. Three
85
+ of its choices improve on the prototype: a `kind` discriminator (text/table/chart/equation),
86
+ `source_items` index back-references into MinerU's `content_list.json`, and an explicit
87
+ verbatim-text warning that independently identifies the same failure mode as the span-check control
88
+ β€” including that it fails *silently to null* and looks like a bad LLM.
89
+
90
+ | # | Task | Owner | Status | Description |
91
+ |---|---|---|---|---|
92
+ | **S1** | Agree the parsed-artifact schema | Sofhia + Rifqi | πŸ”„ | **Adopted as the base and landed** in `src/knowledge_parsing/contracts.py` (pydantic, per S6e). Everything raised against the draft is closed except the two joint decisions in S6 (a: formatting signal, b: page indexing). |
93
+ | **S2** | Add a document-level envelope | Sofhia + Rifqi | βœ… | **Done 2026-08-19.** `ParsedDocument{doc_id, source_path, content_hash, version, n_pages, parser_name, parser_version, chunks}` as proposed, plus three fields worth having: **`parser_backend`** read back from MinerU's `_middle.json` so the artifact records what *actually* ran rather than what was configured; `parser_config` (fingerprint of the settings that affect output); and `raw_output_dir`, pointing at the untouched MinerU output kept in the parse cache. |
94
+ | **S3** | Correct the section-structure claim | Rifqi β†’ Sofhia | βœ… | **Corrected 2026-08-19.** Checked against MinerU's source: `pipeline` and `vlm` run *identical* `text_level` logic and both emit `bbox`, so heading availability is document-dependent, not backend-dependent β€” the original claim came from sampling the handbook, which has no numbered headings. The normalizer now **reads** the hierarchy into a `heading_path` breadcrumb instead of deriving it, with numbering as fallback. Fields stay Optional. |
95
+ | **S4** | Add `source_wording` while the contract is open | Rifqi β†’ Sofhia | βœ… | **Done 2026-08-19.** `source_wording` added to `TermRecord`, and `heading` is carried verbatim β€” so "Physical **of** Availability" survives instead of being silently normalised (R1). |
96
+ | **S5** | Keep the seam off the file path | Sofhia + Rifqi | πŸ”„ | **Satisfied on the parsing side.** `ParsedDocument` never carries a file handle; `source_path` is provenance only, and extraction receives the artifact. Left open for Rifqi to confirm from his side β€” the prototype's `Parser.parse(path)` boundary is the shape this must not reproduce. |
97
+ | **S6** | Minor contract items (deferred to review) | Rifqi β†’ Sofhia | πŸ”„ | **Four of six closed 2026-08-19; two need a joint call.** **(b) page indexing β€” OPEN:** the artifact uses `page_idx`/`page_idxs`, **0-based**, no conversion anywhere, so it always matches the raw MinerU output kept beside it; converting to 1-based is the UI's job, done once at display. Say if you want a 1-based `page_no` added instead, since this number reaches Mas Beta. **(a) formatting signal β€” OPEN, but the premise was wrong:** `text_format` is emitted by *both* backends and is not a formatting signal at all β€” it is the constant `'latex'` on equation blocks. Checked further: MinerU carries **no emphasis information anywhere** (zero `**` in its markdown, no style/font/span keys in `middle.json`). So the signal has no source in MinerU; it would need a separate pass reading the PDF directly (PyMuPDF exposes per-span bold flags). Recommend dropping it from the ranker for now β€” one of six signals, and adding a PDF pass buys nothing measured yet. Your ranker, your call. **(c) chunk size β€” done:** ~1500-token cap, matching calibration Β§4; splits at item boundaries so text stays verbatim. **(d) `chapter` lossy β€” done:** now `chapters: list[str]`, one per page the chunk spans. **(e) pydantic β€” done.** **(f) `TermRecord`** β€” left to you; the declaration in `contracts.py` is a placeholder, not a claim. |
98
+ | **S7** | Commit fixture artifacts | Rifqi | ⬜ | Both source PDFs exist (BUMA standard + Open Pit textbook). Promote a parsed artifact of each into the target repo as committed fixtures, so the extraction half is testable **with MinerU not installed at all** β€” the seam doing its job |
99
+ | **S8** | Write the seam rule into the repo docs | Rifqi | ⬜ | Extraction never takes a file path; neither half imports the other's internals; parsing stays swappable behind the artifact. The prototype drifted from this, so the rule needs to outlive our memory of it |
100
+
101
+ ---
102
+
103
+ ## 4. Parsing pipeline β€” Sofhia
104
+
105
+ MinerU replaces the current text+table-only extraction: it adds formula, image, chart and diagram
106
+ detection with type labels. Presented to the execs 13 Aug as the prerequisite for Scenario 2.
107
+
108
+ | # | Task | Owner | Status | Description |
109
+ |---|---|---|---|---|
110
+ | **B1** | MinerU cost justification (written) | Sofhia | ⬜ | Owed for the budget proposal, from the 13 Aug exec review |
111
+ | **B2** | MinerU install + run on the sample docs | Sofhia | βœ… | Complete 9-page parse of the BUMA standard delivered 2026-08-19: **118 items** (90 text, 14 table, 11 equation, 3 chart), pages 0–8. Includes the rule-of-thumb grounding content (Other Activity, Controllable/Uncontrollable, Standby), so the fixture exercises all four extraction branches. Supersedes an earlier 5-page run |
112
+ | **B3** | Evaluate MinerU quality vs. the current path | Sofhia | ⬜ | Compare on tables, the p.2 legend block, formulas and figures β€” against the PyMuPDF baseline the prototype used, which has real numbers rather than a guess |
113
+ | **B4** | Indonesian check on **parsing** | Sofhia | πŸ”„ | Distinct from E1, which measured GLiNER, not the parser. Partly answered 2026-08-19: MinerU has **no `id` language code** β€” Indonesian documents use `ch`, whose model covers Latin script (`config.periksa()` now rejects a wrong code before models load). `lang` only applies to the `pipeline` backend and only when OCR actually runs, so it is moot under `vlm`. Text/layout **quality** on Bahasa Indonesia still to be judged against B3 |
114
+ | **B5** | Section-aware chunking | Sofhia | πŸ”„ | **Built 2026-08-19** in `normalize.py`. Splits on MinerU's `text_level` hierarchy into a `heading_path` breadcrumb, with numbering as fallback. Three real-document traps handled, two of them from Β§4 of the calibration doc: repeated breadcrumbs no longer reopen a section, **only numbered headings** may open one (so `Keterangan:` keeps a legend with its figure), and a ~1500-token cap guards documents with no detected headings. **Acceptance test still open** β€” needs the Open Pit textbook (S7) to confirm the NSR definition survives pp. 92–94 intact |
115
+ | **B6** | Emit the agreed artifact | Sofhia | βœ… | **Done 2026-08-19** (`5840d201`). `src/knowledge_parsing/` writes `ParsedDocument`, pydantic, not MinerU's native shape. `parse.py` keeps a content-addressed cache of the untouched MinerU output beside it, and `parser_backend` is read back from `_middle.json` so the artifact records what actually ran |
116
+ | **B7** | Fallback path behind the same artifact | Sofhia | ⬜ | Tesseract / Azure Document Intelligence emits the *same* artifact, so extraction never knows which parser ran |
117
+ | **B8** | Keep the parsing subsystem additive + gated | Sofhia | βœ… | **Done 2026-08-19.** `src/knowledge/` is untouched; `src/knowledge_parsing/` sits beside it. MinerU is an **optional extra** (`knowledge-parsing`), not a main dependency, and is imported inside `parse.py` at call time β€” verified that importing the package leaves `mineru` and `torch` out of `sys.modules`, so the deployed Space neither builds nor ships them |
118
+
119
+ **Findings from the real MinerU output that affect B5/B6:**
120
+
121
+ - **Heading depth is flattened** β€” all 29 `title` blocks carry `"level": 2`, including `1. TUJUAN
122
+ PARAMETER` and `2.1.3. Physical of Availability (PA)`. Hierarchy must come from the numbering
123
+ text, never from `level`. The heading *text* is supplied (see S3).
124
+ - **Equation LaTeX is character-spaced** β€” `P r o d u c t i o n = M O H H \times Q t y \times P A
125
+ \times U A \times P t y`. Naive matching will never find `MOHH`. Needs normalising before the
126
+ formula/legend branch, and before span validation can locate anything inside a formula.
127
+ - **Breadcrumb headings repeat per page** β€” `2. PENJELASAN PARAMETER` / `2.1. Production Parameter`
128
+ re-appear as `title` blocks on pages 2–8. They must not be treated as new sections.
129
+ - **Charts carry captions only** β€” all 3 chart items have `content: ""`, with the caption in
130
+ `chart_caption`. A chart chunk contributes a caption plus an image reference and nothing else.
131
+ Chart detection buys Scenario 2, not glossary coverage.
132
+
133
+ ---
134
+
135
+ ## 5. Extraction pipeline v2 β€” Rifqi, built in this repo
136
+
137
+ **Two separate columns, deliberately.** *Design* = validated by the prototype, with a number behind
138
+ it. *Build* = exists in this repo. The gap between them is the remaining engineering effort, and
139
+ collapsing the two is how a rebuild gets under-estimated.
140
+
141
+ **Module: `src/knowledge_extraction/`** (decided 2026-08-19 β€” `src/knowledge/` is the existing
142
+ OCR→pgvector service and stays untouched). Offline runner: `scripts/run_knowledge_extraction.py`.
143
+ Structure follows `src/query/`: typed models β†’ deterministic stages β†’ thin entry point.
144
+
145
+ **The seam is absorbed by one file.** `adapter.py` is the only module that knows the artifact's
146
+ shape; every stage depends on the internal `models.Chunk` subset instead. Verified against **both**
147
+ live shapes β€” Sofhia's bare-list draft (`contoh-chunk.json`, `kind` discriminator) and the
148
+ envelope shape proposed in Β§3 β€” so S1 can settle either way without touching a stage.
149
+
150
+ | # | Stage | Design | Build | Note |
151
+ |---|---|---|---|---|
152
+ | **X1** | Candidate-entry schema | βœ… | βœ… | `models.py`. All three invariants encoded: content fields Optional, `SubdomainEnum`, `Provenance.span` mandatory. Adds `source_wording` for R1. Pydantic v2 |
153
+ | **X2** | Offline runner over a fixture | βœ… | βœ… | `scripts/run_knowledge_extraction.py`. Takes an artifact, **never a PDF**; each stage writes its own JSON. Runs green on the 9-page BUMA chunks and on Sofhia's draft |
154
+ | **X3** | GLiNER span filter | βœ… | βœ… | `filters/span_filter.py`. Runs on all chunks unconditionally; overlapping 130-word windows with character offsets remapped; span-cap hits flagged not truncated; never-throw. **Scored: E1 recall 0.8537 β€” exactly the frozen baseline**, 35/41 gold terms, 66 clusters (baseline 66), compression 2.47Γ— (baseline 2.56Γ—). Result: `eval/knowledge/results/v2_term_filter_2026-08-19_144318.json` |
155
+ | **X4** | Indonesian performance | βœ… **0.854** | βœ… | Re-measured on v2: **0.8537, zero regression** against the prototype |
156
+ | **X5** | Discourse-cue filter β†’ rule-of-thumb | βœ… | βœ… | `filters/cue_filter.py`, cues in `config/cues.yaml`. 9 rule candidates on the BUMA standard. Also supplies `definitional_hits` for the ranker's +5.0 signal |
157
+ | **X6** | Legend-block filter β†’ formula vars | βœ… | βœ… | `filters/legend_filter.py`. Extracts the p.2 legend block **exactly** β€” MOHH Β· Qty Β· PA Β· UA Β· Pty, 5/5 β€” and rejects equation rows. LaTeX normalisation (Β§4) still pending for MinerU input |
158
+ | **X7** | Section pass β†’ summary units | βœ… | ⬜ | The quiet cost risk: few calls but ~ΒΌ of all input tokens, because summarisation cannot be filtered β€” it needs whole documents |
159
+ | **X8** | Normalise + cluster mentions | βœ… **2.56Γ—** | βœ… | `cluster/`. Constants carried from P2 with their reasons. Locked by tests: PA/UA never merge; abbreviation↔expansion merges only with legend pairs; noise surfaces dropped as whole forms only |
160
+ | **X9** | Evidence ranking β†’ top-K | βœ… | βœ… | `rank/evidence.py`, six signals + tabular penalty. **Verified on the real document: every term's top-ranked chunk is its own definition section** β€” PAβ†’2.1.3, UAβ†’2.1.4, Qtyβ†’2.1.2, Ptyβ†’2.1.5. Full ranked list retained; word-boundary heading matching locked by test |
161
+ | **X10** | LLM extraction call | βœ… | βœ… | `extract/` β€” client (nano `__54n`, probed structured output, API-sourced cached tokens), 4 branches, prompts as files. **Live pilot: 3 calls, 10.1s, 7,168 of 8,029 prompt tokens cached (89%)**, correct ID definitions + tags, 0 span rejections. `--dry-run` and `--limit` for cost control; `--mock` runs the whole pipeline with no spend |
162
+ | **X11** | Verbatim-span validation | βœ… **1.00** | βœ… | `validate/span_check.py`. Whitespace-only normalisation; unlocatable provenance rejects every guarded field; never repairs. **Extended:** `full_name`/`source_wording` are checked against the source themselves, so a silent normalisation is caught rather than stored |
163
+ | **X12** | Null-definition escalation | πŸ”Ž | βœ… | `validate/escalate.py` + the glossary loop. Still **unexercised on this document** β€” most clusters have too little evidence to escalate to, which is a property of a 9-page corpus, not a defect |
164
+ | **X13** | Conflict detection | πŸ”Ž | βœ… | `validate/conflict.py`, token overlap (explainable to the reviewer, unlike embeddings); never picks a winner. Unit-tested both ways; still **0 conflicts on real data** β€” one consistent standard gives it nothing to find. Works only because clustering puts all evidence in one call |
165
+ | **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 |
166
+ | **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 |
167
+ | **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 |
168
+ | **X17** | Persistence | ⬜ | πŸ”„ | Stage artifacts are written as JSON by the CLI, which is enough for the offline path. Tables still needed β†’ D2 |
169
+
170
+ ---
171
+
172
+ ## 6. Decisions needed
173
+
174
+ | # | Decision | Owner | Status | Description |
175
+ |---|---|---|---|---|
176
+ | **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 |
177
+ | **D2** | New tables (DDL handoff) | Rifqi β†’ Harry | ⬜ | Parsed artifacts, candidate entries, glossary versions + approval audit trail. Go owns the dedorch schema β€” Python never executes DDL. One consolidated Harry-ready handoff beats three |
178
+ | **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 |
179
+ | **D4** | Admin ingest surface | Rifqi | ⬜ | Whether it is HTTP at all, and if so its auth posture. A write surface triggered by an admin is a different risk class from the current unauthenticated read-only chat surface β€” raise it, don't inherit the posture by default. Only after the offline path works |
180
+ | **D5** | v2 module name | Rifqi | βœ… | **`src/knowledge_extraction/`** (2026-08-19). Offline runner in `scripts/`, per the repo's existing precedent |
181
+ | **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 |
182
+ | **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 |
183
+
184
+ ---
185
+
186
+ ## 7. Defects and risks to carry into v2
187
+
188
+ | # | Finding | Severity | Detail |
189
+ |---|---|---|---|
190
+ | **R1** | ~~Literal source wording is normalised away~~ | **FIXED** | **Resolved 2026-08-19 in v2, live-verified.** Three parts: `source_wording` on the entry, taken **deterministically from the document's own section heading** rather than left to the model (which quoted the normalised page-1 form instead); `evidence_text` now includes headings, so quoting a section title is not wrongly rejected; and the review queue gained a reason for the mismatch. On the live pilot PA carries `full_name="Physical Availability"` **and** `source_wording="Physical of Availability (PA)"`, and ranks as *"source wording differs from the expanded name β€” confirm which is correct"* β€” the discrepancy reaches the expert instead of being silently corrected |
191
+ | **R2** | **Prototype is not under version control** | **High** | Addressed by P1 |
192
+ | **R3** | **85% abstention** | Medium | 56 of 66 entries carry no definition. Correct behaviour β€” for a term the document never defines, null *is* the right answer β€” but it means the review queue is mostly "term found, no definition in document". Whether that is useful to Mas Beta or noise is a **review-experience question to put to him**, and review experience is explicitly where engineering time is worth spending |
193
+ | **R4** | **Everything generalises from one 9-page document** | Medium | Single document, single language, single company. E2's compression, E3's scoreable base and the escalation path are all constrained by it |
194
+ | **R5** | **Rule and summary run on the wrong tier** | Medium | Both route to nano because it is the only provisioned deployment. Same blocker as D3 |
195
+ | **R6** | **Rewrites drop accidental knowledge** | Medium | Addressed by P2. The `PA`/`UA` fuzzy-collision guard and the 1024-token caching threshold are the known examples; there will be others |
196
+
197
+ ---
198
+
199
+ ## 8. Settled β€” do not reopen
200
+
201
+ 1. The LLM call unit is the **term cluster**. Justification is review-burden reduction, **not** tokens (~$0.45 saved is trivial).
202
+ 2. **Conflict detection depends on the clustering** β€” two contradictory definitions can only be spotted because all evidence for a term arrives in one call.
203
+ 3. **No standalone relevance gate.** A pre-filter's false negatives drop content before extraction sees it, and dropped content never reaches review to be caught.
204
+ 4. **Evidence ranking is allowed** even though it is also a filter β€” no term is dropped, all mentions stay in provenance, and the escalation loop self-corrects. Preserve those three properties if it is ever revisited.
205
+ 5. **`provenance.span` is mandatory and verbatim**, never repaired. The primary anti-hallucination control, and what makes expert review finishable: the reviewer checks a quote against a page, not a claim against their memory.
206
+ 6. **All content fields Optional.** Abstention is correct behaviour, not an error.
207
+ 7. **Frequency-sorted review queue** β€” validate the ~40 terms carrying the document first, let the long tail wait.
208
+ 8. **API cost is negligible, latency irrelevant** (measured $0.0069 for 9 pages). This runs a few times a year, admin-triggered, not continuous and not per-user. Optimise the review experience, never tokens. Prefer boring, inspectable stages.
209
+ 9. **The pipeline records the literal source wording** and surfaces discrepancies rather than normalising them (currently violated β€” R1).
210
+ 10. **This is a test integration, not a productionisation.** Both halves are new, gated subsystems alongside the existing document/catalog pipelines β€” not a rewrite of them.
eval/knowledge/README.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # eval/knowledge β€” knowledge-extraction eval harness
2
+
3
+ Scores the knowledge-extraction pipeline against a gold set built from
4
+ **BUMA `STD/2026/006/MNO Rev.0.0` β€” Production Parameter & ECA** (9 pages, majority Bahasa
5
+ Indonesia).
6
+
7
+ Migrated from the `kex` prototype on 2026-08-19. **The prototype itself is not being ported** β€” it
8
+ answered the design questions and stays where it is. This directory carries the parts that survive
9
+ it: the gold set, the scorer, and a frozen baseline. Plan: [KNOWLEDGE_PIPELINE_TODO.md](../../KNOWLEDGE_PIPELINE_TODO.md),
10
+ tuned constants: [KNOWLEDGE_PIPELINE_CALIBRATION.md](../../KNOWLEDGE_PIPELINE_CALIBRATION.md).
11
+
12
+ ## Why this landed before any v2 pipeline code
13
+
14
+ Without a standing baseline, "new and improved" is unfalsifiable β€” and one of the four experiments
15
+ already **failed** (E3, schema fill). Quality is what this work gets judged on, so the yardstick
16
+ ships first and every v2 stage is measured from its first commit rather than retroactively.
17
+
18
+ ## Contents
19
+
20
+ | Path | What it is |
21
+ |---|---|
22
+ | `knowledge_gold.yaml` | 41 terms + 15 rules. **Provisional** β€” bootstrapped by reading the source PDF, *not* expert-reviewed |
23
+ | `score.py` | Precision / recall / F1 per branch. Pipeline-independent: takes plain lists of surfaces and entry dicts, so it scores the prototype's artifacts and v2 alike |
24
+ | `results/baseline_prototype_2026-08-13_145132.json` | **Frozen.** The prototype's measured numbers. Never regenerate β€” add new timestamped files beside it |
25
+
26
+ No `run_eval.py` yet: there is no v2 pipeline to run. It lands with the first stage that produces
27
+ scoreable output, following the house convention (module mode, timestamped results, never
28
+ overwritten) used by `eval/{intent,help,readiness,planner}/`.
29
+
30
+ ## The baseline to beat
31
+
32
+ Measured by the prototype on the document above, run `20260813-145132`, deployment `gpt-5.4-nano`:
33
+
34
+ | Experiment | Question | Result | Kill line | Verdict |
35
+ |---|---|---|---|---|
36
+ | **E1** | GLiNER recall on Bahasa Indonesia technical prose | **0.854** | 0.70 | **PASS** |
37
+ | **E2** | Does clustering cut expert review burden? | **2.56Γ—** (169 β†’ 66) | 2.0Γ— | **PASS** |
38
+ | **E3** | Is nano sufficient for schema fill? | **0.75** | 0.80 | **FAIL** |
39
+ | **E4** | Does verbatim-span validation catch fabrication? | **1.00** (false-rejection 0.00) | 0.90 | **PASS** |
40
+
41
+ Funnel: 9 pages β†’ 13 chunks β†’ 169 mentions β†’ 66 clusters β†’ 83 LLM calls β†’ 66 entries β†’ 66 queue
42
+ items. Cost $0.0069; 125,184 of 155,313 prompt tokens served from cache.
43
+
44
+ ## Two things the scorer refuses to do
45
+
46
+ **It never conflates term-filter recall with extraction precision.** They are different failure
47
+ modes with different fixes β€” recall is fixed at the filter stage (GLiNER labels), precision at the
48
+ extraction stage (model tier, prompt). E1 is the recall number specifically.
49
+
50
+ **It never counts abstention as an error.** For a term the document does not define, `null` is the
51
+ correct answer. Scoring is restricted to the *scoreable* subset: entries that produced a definition,
52
+ whose term is in the gold set, and whose gold record carries `definition_contains` to check against.
53
+ Counting the rest as errors would measure gold coverage while claiming to measure model accuracy.
54
+ Coverage is reported separately in `Score.as_dict()`.
55
+
56
+ ## Reading E3 carefully
57
+
58
+ The scoreable base is **8 entries**, so each adjudication moves the number ~12 points. nano is also
59
+ not deterministic at `temperature=0` β€” consecutive runs of the identical pipeline scored 0.75 and
60
+ 0.625. Treat E3 as a signal, not a measurement, until the gold set is reviewed.
61
+
62
+ Every failure is listed verbatim under `experiments.E3.scoreable_basis.failures` in the baseline
63
+ file, for Mas Beta to adjudicate. Some are genuine extraction errors; others are cases where the
64
+ document carries two valid definitions and the provisional gold names only one. **Gold was not
65
+ edited after seeing output**, so the figure is a lower bound.
66
+
67
+ ## Gold-set conventions (keep these when extending it)
68
+
69
+ - `term` is the string a reader would look up.
70
+ - `full_name` is the **literal source wording**, never normalised. The standard writes *"Physical of
71
+ Availability (PA)"* in the heading and *"Physical Availability"* in the legend β€” both are recorded
72
+ as variants. This is deliberate: the pipeline is required to surface that discrepancy to the
73
+ expert rather than silently correct it.
74
+ - `definition_contains` are substrings that **must** appear in a correct definition. Substring
75
+ matching, not exact, so a correct-but-differently-worded extraction is not scored as a miss.
76
+ - The gold set is **partial by design** β€” the expert is the labelling bottleneck, so scoring reports
77
+ coverage rather than blocking on a complete file.
78
+
79
+ ## Status of the gold set
80
+
81
+ `status: provisional`, `labelled_by: claude-bootstrap`, `labelled_on: 2026-08-13`. It has **not**
82
+ been reviewed by Mas Beta. Every score derived from it carries that caveat, and both E1 and E3 will
83
+ move when it is reviewed. Accepted as good enough for now (2026-08-19, Rifqi) so the rebuild is not
84
+ blocked on expert time.
eval/knowledge/knowledge_gold.yaml ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PROVISIONAL gold set β€” bootstrapped by reading the source PDF, NOT yet expert-reviewed.
2
+ # Reviewer: Mas Beta. Until reviewed, every score derived from this file carries that caveat.
3
+ #
4
+ # Labelling rules used:
5
+ # - `term` is the string a reader would look up.
6
+ # - `full_name` is the LITERAL source wording, not a normalised or corrected form.
7
+ # (e.g. the STD writes "Physical of Availability (PA)" in the heading and
8
+ # "Physical Availability" in the legend β€” both are recorded as variants.)
9
+ # - `definition_contains` are substrings that MUST appear in a correct definition.
10
+ # Substring matching, not exact match, so a correct-but-differently-worded
11
+ # extraction is not scored as a miss (spec Β§5).
12
+
13
+ doc_id: STD_2026_006_MNO
14
+ status: provisional
15
+ labelled_by: claude-bootstrap
16
+ labelled_on: 2026-08-13
17
+ source_pages: 9
18
+
19
+ terms:
20
+ - term: Production
21
+ full_name: Production
22
+ variants: ["Production", "produksi"]
23
+ definition_contains: ["nilai tambah", "sumber daya"]
24
+ formula_present: true
25
+ page: 2
26
+ - term: MOHH
27
+ full_name: Machine on Hand Hours
28
+ variants: ["MOHH"]
29
+ definition_contains: ["Machine on Hand Hours"]
30
+ formula_present: false
31
+ page: 2
32
+ - term: Qty
33
+ full_name: Quantity (Qty)
34
+ variants: ["Qty", "Quantity", "quantity"]
35
+ definition_contains: ["jumlah equipment", "periode"]
36
+ formula_present: true
37
+ page: 2
38
+ - term: PA
39
+ full_name: Physical of Availability (PA)
40
+ variants: ["PA", "Physical Availability", "Physical of Availability"]
41
+ definition_contains: ["ketersediaan fisik", "available"]
42
+ formula_present: true
43
+ page: 4
44
+ - term: UA
45
+ full_name: Utilization of Availability (UA)
46
+ variants: ["UA", "Utilization of Availability"]
47
+ definition_contains: ["efektifitas penggunaan", "working hours"]
48
+ formula_present: true
49
+ page: 4
50
+ - term: Pty
51
+ full_name: Productivity (Pty)
52
+ variants: ["Pty", "PTY", "Productivity", "Produktivitas"]
53
+ definition_contains: ["efektifitas penggunaan sumber daya", "output"]
54
+ formula_present: true
55
+ page: 5
56
+ - term: ECA
57
+ full_name: Equipment Capacity Analysis (ECA)
58
+ variants: ["ECA", "Equipment Capacity Analysis"]
59
+ definition_contains: ["ketidaktercapaian", "kapasitas produksi"]
60
+ formula_present: false
61
+ page: 6
62
+ - term: INPR
63
+ full_name: in operation
64
+ variants: ["INPR", "in operation", "INPR Hours"]
65
+ definition_contains: ["in operation"]
66
+ formula_present: false
67
+ page: 2
68
+ - term: Total Hours
69
+ full_name: Total Hours
70
+ variants: ["Total Hours", "total jam"]
71
+ definition_contains: ["total jam"]
72
+ formula_present: true
73
+ page: 2
74
+ - term: Breakdown
75
+ full_name: Breakdown
76
+ variants: ["Breakdown", "breakdown"]
77
+ definition_contains: []
78
+ formula_present: true
79
+ page: 4
80
+ - term: Working Hours
81
+ full_name: Working Hours
82
+ variants: ["Working Hours", "working hours"]
83
+ definition_contains: ["waktu kerja"]
84
+ formula_present: true
85
+ page: 4
86
+ - term: EWH
87
+ full_name: Effective Working Hours (EWH)
88
+ variants: ["EWH", "Effective Working Hours", "productive working hours"]
89
+ definition_contains: ["waktu kerja efektif"]
90
+ formula_present: false
91
+ page: 5
92
+ - term: Land clearing
93
+ full_name: land clearing
94
+ variants: ["land clearing"]
95
+ definition_contains: []
96
+ formula_present: false
97
+ page: 2
98
+ - term: Overburden removal
99
+ full_name: pengupasan lapisan penutup (overburden removal)
100
+ variants: ["overburden removal", "pengupasan lapisan penutup"]
101
+ definition_contains: []
102
+ formula_present: false
103
+ page: 2
104
+ - term: Coal mining
105
+ full_name: penambangan batubara (coal mining)
106
+ variants: ["coal mining", "Coal Mining", "CM", "penambangan batubara"]
107
+ definition_contains: []
108
+ formula_present: false
109
+ page: 2
110
+ - term: Coal hauling
111
+ full_name: pengangkutan batubara (coal hauling/coal transporting)
112
+ variants: ["coal hauling", "coal transporting", "Coal Transport", "CT"]
113
+ definition_contains: []
114
+ formula_present: false
115
+ page: 2
116
+ - term: Coal barging
117
+ full_name: coal barging atau ship loading
118
+ variants: ["coal barging", "ship loading", "pengapalan"]
119
+ definition_contains: []
120
+ formula_present: false
121
+ page: 2
122
+ - term: Waste Removal
123
+ full_name: Waste Removal (WR)
124
+ variants: ["Waste Removal", "WR", "waste removal"]
125
+ definition_contains: []
126
+ formula_present: false
127
+ page: 3
128
+ - term: General Work
129
+ full_name: General Work
130
+ variants: ["General Work"]
131
+ definition_contains: []
132
+ formula_present: false
133
+ page: 3
134
+ - term: Rental
135
+ full_name: Rental
136
+ variants: ["Rental"]
137
+ definition_contains: []
138
+ formula_present: false
139
+ page: 3
140
+ - term: Grouping (Composite)
141
+ full_name: Grouping (Composite)
142
+ variants: ["Grouping (Composite)", "grouping (composite)", "Composite"]
143
+ definition_contains: ["type", "class"]
144
+ formula_present: true
145
+ page: 3
146
+ - term: EX2500
147
+ full_name: EX2500
148
+ variants: ["EX2500"]
149
+ definition_contains: []
150
+ formula_present: false
151
+ page: 3
152
+ - term: Time Performance MCD Application
153
+ full_name: raw data Time Performance MCD Application
154
+ variants: ["Time Performance MCD Application", "MCD Application", "MCD"]
155
+ definition_contains: []
156
+ formula_present: false
157
+ page: 3
158
+ - term: Joint survey
159
+ full_name: joint survey
160
+ variants: ["joint survey"]
161
+ definition_contains: []
162
+ formula_present: false
163
+ page: 5
164
+ - term: Truck count
165
+ full_name: truck count
166
+ variants: ["truck count"]
167
+ definition_contains: []
168
+ formula_present: false
169
+ page: 5
170
+ - term: Weight average
171
+ full_name: rata-rata tertimbang (weight average)
172
+ variants: ["weight average", "rata-rata tertimbang", "pembobotan"]
173
+ definition_contains: []
174
+ formula_present: false
175
+ page: 4
176
+ - term: Gain/Loss
177
+ full_name: Gain/Loss
178
+ variants: ["Gain/Loss", "Gain / Loss"]
179
+ definition_contains: ["plan", "actual"]
180
+ formula_present: true
181
+ page: 6
182
+ - term: Plan
183
+ full_name: rencana (plan)
184
+ variants: ["plan", "Plan", "rencana", "P(M)", "P(G)"]
185
+ definition_contains: []
186
+ formula_present: false
187
+ page: 6
188
+ - term: Actual
189
+ full_name: realisasi (actual)
190
+ variants: ["actual", "Actual", "realisasi", "A(M)", "A(G)"]
191
+ definition_contains: []
192
+ formula_present: false
193
+ page: 6
194
+ - term: Model Unit
195
+ full_name: Model Unit
196
+ variants: ["Model Unit", "model unit", "(M)", "Model"]
197
+ definition_contains: []
198
+ formula_present: true
199
+ page: 6
200
+ - term: Standby
201
+ full_name: Delay/Standby
202
+ variants: ["Standby", "standby", "Delay/Standby", "Gap Standby"]
203
+ definition_contains: []
204
+ formula_present: true
205
+ page: 6
206
+ - term: BCM
207
+ full_name: BCM
208
+ variants: ["BCM"]
209
+ definition_contains: []
210
+ formula_present: false
211
+ page: 6
212
+ - term: Waterfall Analysis
213
+ full_name: Waterfall Analysis
214
+ variants: ["Waterfall Analysis", "grafik Waterfall"]
215
+ definition_contains: ["visualisasi", "Gain/Loss"]
216
+ formula_present: false
217
+ page: 8
218
+ - term: Other Activity
219
+ full_name: Other Activity
220
+ variants: ["Other Activity"]
221
+ definition_contains: ["tidak sesuai", "direncanakan"]
222
+ formula_present: false
223
+ page: 8
224
+ - term: Loader
225
+ full_name: loader
226
+ variants: ["loader", "PA Loader"]
227
+ definition_contains: []
228
+ formula_present: false
229
+ page: 8
230
+ - term: Hauler
231
+ full_name: hauler
232
+ variants: ["hauler"]
233
+ definition_contains: []
234
+ formula_present: false
235
+ page: 8
236
+ - term: Hierarki Level Analysis
237
+ full_name: Hierarki Level Analyisis
238
+ variants: ["Hierarki Level Analyisis", "Hierarki Level Analysis"]
239
+ definition_contains: ["level"]
240
+ formula_present: false
241
+ page: 9
242
+ - term: Uncontrollable
243
+ full_name: Uncontrollable
244
+ variants: ["Uncontrollable", "uncontrollable"]
245
+ definition_contains: ["level 1"]
246
+ formula_present: false
247
+ page: 9
248
+ - term: Controllable
249
+ full_name: controllable
250
+ variants: ["controllable", "UA Controllable"]
251
+ definition_contains: ["level 1"]
252
+ formula_present: false
253
+ page: 9
254
+ - term: Fleet management
255
+ full_name: fleet management
256
+ variants: ["fleet management"]
257
+ definition_contains: []
258
+ formula_present: false
259
+ page: 9
260
+ - term: Mineplan
261
+ full_name: mineplan
262
+ variants: ["mineplan"]
263
+ definition_contains: []
264
+ formula_present: false
265
+ page: 9
266
+
267
+ rules:
268
+ - rule_id: QTY_PER_ACTIVITY
269
+ statement_contains: ["Qty", "kategori aktivitas", "terpisah"]
270
+ page: 3
271
+ - rule_id: QTY_GROUPING_SUM
272
+ statement_contains: ["Qty Grouping", "menjumlahkan"]
273
+ page: 3
274
+ - rule_id: ACTIVITY_CLASSIFICATION_SOURCE
275
+ statement_contains: ["status equipment", "Time Performance MCD"]
276
+ page: 3
277
+ - rule_id: PA_COMPOSITE_WEIGHTED
278
+ statement_contains: ["weight average", "Quantity", "pembobot"]
279
+ page: 4
280
+ - rule_id: UA_BY_ACTIVITY
281
+ statement_contains: ["UA", "kategori aktivitas"]
282
+ page: 4
283
+ - rule_id: PTY_PRODUCTION_SOURCE
284
+ statement_contains: ["joint survey", "truck count"]
285
+ page: 5
286
+ - rule_id: PTY_WORKING_HOURS_DEFINITION
287
+ statement_contains: ["Effective Working Hours", "waktu kerja efektif"]
288
+ page: 5
289
+ - rule_id: PTY_COMPOSITE_WEIGHTED
290
+ statement_contains: ["Pty", "pembobotan", "Quantity"]
291
+ page: 5
292
+ - rule_id: GAINLOSS_QTY_ZERO_PLAN
293
+ statement_contains: ["QtyP(M) = 0", "QtyA(M) > 0"]
294
+ page: 6
295
+ - rule_id: GAINLOSS_COMPOSITE_SUM
296
+ statement_contains: ["grouping", "penjumlahan", "model unit"]
297
+ page: 6
298
+ - rule_id: GAINLOSS_UNITS
299
+ statement_contains: ["BCM", "ton", "jam"]
300
+ page: 6
301
+ - rule_id: QTY_ACTUAL_ZERO_PLAN_ALT
302
+ statement_contains: ["QtyA", "QtyP", "0"]
303
+ page: 7
304
+ - rule_id: QTY_PHYSICAL_REPORTING
305
+ statement_contains: ["fisik unit", "INPR"]
306
+ page: 7
307
+ - rule_id: ECA_OTHER_ACTIVITY
308
+ statement_contains: ["alokasi", "tidak sesuai", "direncanakan"]
309
+ page: 8
310
+ - rule_id: WATERFALL_TWO_METHODS
311
+ statement_contains: ["Waterfall", "dua metode"]
312
+ page: 8
eval/knowledge/results/baseline_prototype_2026-08-13_145132.json ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_note": "FROZEN BASELINE β€” measured by the `kex` prototype, not by this repo. Extraction v2 must match or beat these numbers on the same document and the same gold set. Copied from the prototype's eval/out/metrics.json on 2026-08-19; never regenerate this file, add a new timestamped one.",
3
+ "_source": {
4
+ "repo": "kex prototype (outside version control)",
5
+ "file": "eval/out/metrics.json",
6
+ "copied_on": "2026-08-19"
7
+ },
8
+ "provenance": {
9
+ "run_id": "20260813-145132",
10
+ "config_hash": "89545a78c702",
11
+ "created_at": "2026-08-13T14:51:32",
12
+ "doc_id": "STD_2026_006_MNO",
13
+ "client_mode": "azure",
14
+ "deployments": {
15
+ "glossary": "gpt-5.4-nano",
16
+ "rule": "gpt-5.4-nano",
17
+ "formula": "gpt-5.4-nano",
18
+ "summary": "gpt-5.4-nano"
19
+ },
20
+ "api_version": "2024-12-01-preview",
21
+ "gold_status": "provisional β€” not yet expert-reviewed"
22
+ },
23
+ "funnel": {
24
+ "status": "measured",
25
+ "stages": [
26
+ {
27
+ "stage": "pages",
28
+ "count": 9
29
+ },
30
+ {
31
+ "stage": "chunks",
32
+ "count": 13
33
+ },
34
+ {
35
+ "stage": "mentions",
36
+ "count": 169
37
+ },
38
+ {
39
+ "stage": "clusters",
40
+ "count": 66
41
+ },
42
+ {
43
+ "stage": "llm_calls",
44
+ "count": 83
45
+ },
46
+ {
47
+ "stage": "glossary_entries",
48
+ "count": 66
49
+ },
50
+ {
51
+ "stage": "review_queue",
52
+ "count": 66
53
+ }
54
+ ],
55
+ "raw_mentions_before_noise_filter": 195
56
+ },
57
+ "experiments": {
58
+ "E1": {
59
+ "question": "GLiNER recall on Bahasa Indonesia technical prose",
60
+ "status": "measured",
61
+ "value": 0.8537,
62
+ "kill_line": 0.7,
63
+ "verdict": "PASS",
64
+ "detail": "broad @ threshold 0.25",
65
+ "n_gold": 41
66
+ },
67
+ "E2": {
68
+ "question": "Does clustering cut expert review burden?",
69
+ "status": "measured",
70
+ "value": 2.561,
71
+ "kill_line": 2.0,
72
+ "verdict": "PASS",
73
+ "detail": "169 mentions -> 66 clusters",
74
+ "llm_calls_avoided": 103
75
+ },
76
+ "E3": {
77
+ "question": "Is nano sufficient for schema fill?",
78
+ "status": "measured_nano_only",
79
+ "value": 0.75,
80
+ "kill_line": 0.8,
81
+ "verdict": "FAIL",
82
+ "detail": "nano only β€” no mini deployment provisioned, so the spec's nano-vs-mini A/B could not be run",
83
+ "glossary_precision": 0.75,
84
+ "glossary_recall": 0.3333,
85
+ "glossary_f1": 0.4615,
86
+ "rule_precision": 0.5714,
87
+ "rule_recall": 0.2667,
88
+ "n_gold_terms": 18,
89
+ "scoreable_basis": {
90
+ "entries_total": 66,
91
+ "entries_with_definition": 10,
92
+ "abstained_null_definition": 56,
93
+ "scoreable": 8,
94
+ "unscoreable_term_not_in_gold": 2,
95
+ "unscoreable_gold_has_no_criteria": 0,
96
+ "gold_terms_with_criteria": 18,
97
+ "failures": [
98
+ {
99
+ "term": "produksi",
100
+ "gold_requires": [
101
+ "nilai tambah",
102
+ "sumber daya"
103
+ ],
104
+ "extracted": "Dalam konteks industri pertambangan, produksi merupakan seluruh rangkaian kegiatan operasional penambangan yang dimulai dari land clearing, pengupasan lapisan penutup (overburden removal), penambangan batubara (coal mining), pengangkutan ba"
105
+ },
106
+ {
107
+ "term": "Production",
108
+ "gold_requires": [
109
+ "nilai tambah",
110
+ "sumber daya"
111
+ ],
112
+ "extracted": "Dalam konteks industri pertambangan, produksi merupakan seluruh rangkaian kegiatan operasional penambangan yang dimulai dari land clearing, pengupasan lapisan penutup (overburden removal), penambangan batubara (coal mining), pengangkutan ba"
113
+ }
114
+ ]
115
+ },
116
+ "abstention_note": "entries where nano returned null are NOT counted as errors β€” for a term the document never defines, null is the correct answer",
117
+ "adjudication_pending": "The scoreable base is small, so each failure moves the number by ~12 points. Every failure is listed verbatim under scoreable_basis.failures for Mas Beta to adjudicate: some are genuine extraction errors, others are cases where the document carries two valid definitions and the provisional gold names only one. Gold was NOT edited after seeing output, so this figure is a lower bound.",
118
+ "stability_warning": "nano is not fully deterministic at temperature=0: consecutive runs of the identical pipeline produced precision 0.75 and 0.625. On a base this small, treat E3 as a signal, not a measurement."
119
+ },
120
+ "E4": {
121
+ "question": "Does verbatim-span validation catch fabrication?",
122
+ "status": "measured",
123
+ "value": 1.0,
124
+ "kill_line": 0.9,
125
+ "verdict": "PASS",
126
+ "false_rejection_rate": 0.0,
127
+ "detail": "span_check is deterministic; measures the control itself"
128
+ }
129
+ },
130
+ "quality": {
131
+ "status": "measured",
132
+ "glossary_entries": 66,
133
+ "rule_entries": 7,
134
+ "formula_entries": 7,
135
+ "fields_rejected_by_span_check": 3,
136
+ "no_definition_found": 56,
137
+ "escalated": 0,
138
+ "definition_conflicts": 0
139
+ },
140
+ "efficiency": {
141
+ "status": "measured + stated assumption",
142
+ "llm_calls_avoided_by_clustering": 103,
143
+ "review_queue_size": 66,
144
+ "assumed_minutes_per_term_review": 3.0,
145
+ "assumed_minutes_per_term_manual": 12.0,
146
+ "est_review_hours": 3.3,
147
+ "est_manual_hours": 13.2,
148
+ "est_hours_saved": 9.9
149
+ },
150
+ "cost_measured": {
151
+ "prompt": 155313,
152
+ "cached": 125184,
153
+ "completion": 11862,
154
+ "usd": 0.006877,
155
+ "latency_s": 242.4
156
+ }
157
+ }
eval/knowledge/results/v2_parser_comparison_2026-08-19_152842.json ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_note": "Parser comparison on the SAME document, gold set and pipeline code. Answers whether MinerU input is interchangeable with the PyMuPDF parse the frozen baseline was measured on.",
3
+ "_caveat": "The MinerU artifacts were built by a SCRATCH converter in the scratchpad, not by production code β€” section-aware chunking is Sofhia's B5. Treat the chunking-related findings as indicative of what her chunker must handle, not as a measurement of MinerU.",
4
+ "run": {
5
+ "created_at": "2026-08-19_152842",
6
+ "doc_id": "STD_2026_006_MNO",
7
+ "implementation": "src/knowledge_extraction (v2)"
8
+ },
9
+ "variants": {
10
+ "pymupdf_prototype_parse": {
11
+ "E1_recall": 0.8537,
12
+ "true_positives": 35,
13
+ "chunks": 13,
14
+ "note": "what the frozen baseline used"
15
+ },
16
+ "mineru_raw_markup": {
17
+ "E1_recall": 0.7561,
18
+ "true_positives": 31,
19
+ "chunks": 14,
20
+ "misses": [
21
+ "Total Hours",
22
+ "Breakdown",
23
+ "Overburden removal",
24
+ "Grouping (Composite)",
25
+ "Weight average",
26
+ "Plan",
27
+ "Other Activity",
28
+ "Uncontrollable",
29
+ "Fleet management",
30
+ "Mineplan"
31
+ ],
32
+ "note": "formulas left as LaTeX, tables as HTML β€” the span model reads prose, not markup"
33
+ },
34
+ "mineru_rendered_to_prose": {
35
+ "E1_recall": 0.8293,
36
+ "true_positives": 34,
37
+ "chunks": 14,
38
+ "misses": [
39
+ "Overburden removal",
40
+ "Grouping (Composite)",
41
+ "Weight average",
42
+ "Other Activity",
43
+ "Uncontrollable",
44
+ "Fleet management",
45
+ "Mineplan"
46
+ ],
47
+ "note": "LaTeX unwrapped, tables flattened to rows; recovers most of the loss"
48
+ }
49
+ },
50
+ "n_gold": 41,
51
+ "kill_line": 0.7,
52
+ "findings": [
53
+ "Raw MinerU markup costs ~9.8 recall points: LaTeX-wrapped terms (Total Hours, Breakdown) are invisible to the span filter.",
54
+ "Rendering formulas and tables to prose recovers +7.3 points, to within ~2.4 points of the PyMuPDF parse.",
55
+ "The residual gap is CHUNKING, not content: MinerU marks non-section labels (Keterangan:, Keterangan grafik:) as text_level titles. Opening a new section at each one splits a legend from the figure it explains, and Other Activity / Uncontrollable are then missed despite being present as prose.",
56
+ "Actionable for the parsing half: only NUMBERED headings should open a section. This matches the earlier finding that MinerU flattens every heading to level 2, so numbering is the only reliable section signal.",
57
+ "The artifact should carry formulas and tables as readable text, not raw LaTeX/HTML, or the filter stage must render them before NER."
58
+ ]
59
+ }
eval/knowledge/results/v2_term_filter_2026-08-19_144318.json ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_note": "First v2 run. Term-filter (E1) only β€” the paid extraction stage is not built, so E3 is not scoreable yet.",
3
+ "run": {
4
+ "created_at": "2026-08-19_144318",
5
+ "implementation": "src/knowledge_extraction (v2)",
6
+ "doc_id": "STD_2026_006_MNO"
7
+ },
8
+ "config": {
9
+ "model": "urchade/gliner_multi-v2.1",
10
+ "labels_variant": "broad",
11
+ "threshold": 0.25,
12
+ "window_words": 130,
13
+ "window_overlap": 30
14
+ },
15
+ "gold": {
16
+ "path": "eval/knowledge/knowledge_gold.yaml",
17
+ "status": "provisional β€” not expert-reviewed",
18
+ "n_terms": 41
19
+ },
20
+ "funnel": {
21
+ "chunks": 13,
22
+ "mentions_raw": 188,
23
+ "mentions_after_noise": 163,
24
+ "clusters": 66,
25
+ "compression_ratio": 2.47
26
+ },
27
+ "E1": {
28
+ "metric": "term_filter_recall",
29
+ "value": 0.8537,
30
+ "kill_line": 0.7,
31
+ "verdict": "PASS",
32
+ "true_positives": 35,
33
+ "precision": 0.4118,
34
+ "precision_note": "reported, NOT the E1 criterion β€” the filter is deliberately over-inclusive",
35
+ "misses": [
36
+ "Overburden removal",
37
+ "Grouping (Composite)",
38
+ "Weight average",
39
+ "Plan",
40
+ "Fleet management",
41
+ "Mineplan"
42
+ ]
43
+ },
44
+ "baseline_comparison": {
45
+ "baseline_file": "results/baseline_prototype_2026-08-13_145132.json",
46
+ "E1_baseline": 0.8537,
47
+ "E1_v2": 0.8537,
48
+ "delta": 0.0,
49
+ "clusters_baseline": 66,
50
+ "clusters_v2": 66,
51
+ "verdict": "MATCH β€” v2 reproduces the prototype recall exactly"
52
+ }
53
+ }
eval/knowledge/score.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Precision / recall / F1 per branch of the knowledge-extraction pipeline.
2
+
3
+ Migrated verbatim (bar the import bootstrap) from the `kex` prototype, 2026-08-19.
4
+ The prototype is not being ported; this scorer is, because it is the only thing
5
+ that can show extraction v2 matches or beats the measured baseline in
6
+ `results/baseline_prototype_2026-08-13_145132.json`.
7
+
8
+ Pipeline-independent by design: it scores plain lists of surfaces / entry dicts,
9
+ so it works against the prototype's artifacts and against v2 alike.
10
+
11
+ The one thing this module refuses to do is conflate **term-filter recall** with
12
+ **extraction precision** (spec Β§5). They are different failure modes with
13
+ different fixes: recall is fixed at stage 2 (GLiNER labels), precision is fixed
14
+ at stage 3 (model tier / prompt). E1 is the recall number specifically.
15
+
16
+ Gold sets are treated as partial by design β€” Mas Beta is the labelling
17
+ bottleneck, so scoring reports coverage rather than blocking on a complete file.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import unicodedata
24
+ from dataclasses import asdict, dataclass
25
+ from pathlib import Path
26
+
27
+ import yaml
28
+
29
+ GOLD_PATH = Path(__file__).resolve().parent / "knowledge_gold.yaml"
30
+
31
+
32
+ def norm(s: str) -> str:
33
+ s = unicodedata.normalize("NFKC", s).casefold()
34
+ s = re.sub(r"[^\w\s]", " ", s)
35
+ return re.sub(r"\s+", " ", s).strip()
36
+
37
+
38
+ @dataclass
39
+ class Score:
40
+ label: str
41
+ n_gold: int
42
+ n_pred: int
43
+ true_positives: int
44
+ precision: float
45
+ recall: float
46
+ f1: float
47
+ misses: list[str]
48
+
49
+ def as_dict(self) -> dict:
50
+ d = asdict(self)
51
+ if hasattr(self, "coverage"):
52
+ d["coverage"] = self.coverage
53
+ return d
54
+
55
+
56
+ def load_gold(path: Path) -> dict:
57
+ with open(path, encoding="utf-8") as fh:
58
+ return yaml.safe_load(fh)
59
+
60
+
61
+ def _prf(tp: int, n_pred: int, n_gold: int) -> tuple[float, float, float]:
62
+ precision = tp / n_pred if n_pred else 0.0
63
+ recall = tp / n_gold if n_gold else 0.0
64
+ f1 = (
65
+ 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
66
+ )
67
+ return round(precision, 4), round(recall, 4), round(f1, 4)
68
+
69
+
70
+ def score_term_filter(gold: dict, surfaces: list[str]) -> Score:
71
+ """E1. A gold term counts as recalled if ANY of its variants appears among
72
+ the filter's mention surfaces (substring match both ways, so 'PA' inside
73
+ 'Physical Availability (PA)' counts).
74
+
75
+ Precision is reported but is NOT E1's criterion: the filter is deliberately
76
+ over-inclusive, and clustering plus evidence ranking absorb the noise.
77
+ """
78
+ normed = {norm(s) for s in surfaces if norm(s)}
79
+ blob = " | ".join(sorted(normed))
80
+ tp, misses = 0, []
81
+ gold_terms = gold.get("terms", [])
82
+ for entry in gold_terms:
83
+ variants = [entry["term"]] + list(entry.get("variants", []))
84
+ if entry.get("full_name"):
85
+ variants.append(entry["full_name"])
86
+ hit = False
87
+ for v in variants:
88
+ nv = norm(v)
89
+ if not nv:
90
+ continue
91
+ if nv in normed or re.search(rf"(?<![\w]){re.escape(nv)}(?![\w])", blob):
92
+ hit = True
93
+ break
94
+ if hit:
95
+ tp += 1
96
+ else:
97
+ misses.append(entry["term"])
98
+ precision, recall, f1 = _prf(tp, len(normed), len(gold_terms))
99
+ return Score(
100
+ label="term_filter_recall(E1)",
101
+ n_gold=len(gold_terms),
102
+ n_pred=len(normed),
103
+ true_positives=tp,
104
+ precision=precision,
105
+ recall=recall,
106
+ f1=f1,
107
+ misses=misses,
108
+ )
109
+
110
+
111
+ def score_glossary(gold: dict, entries: list[dict]) -> Score:
112
+ """E3: when nano fills the schema, is it right?
113
+
114
+ Scoring is restricted to the SCOREABLE subset: entries that produced a
115
+ definition AND whose term is in the gold set AND whose gold record carries
116
+ `definition_contains` to check against.
117
+
118
+ Why not simply tp/len(entries): the term filter is deliberately
119
+ over-inclusive and the gold set is deliberately partial, so most entries are
120
+ for terms gold says nothing about. Counting those as errors would measure
121
+ gold coverage while claiming to measure nano's accuracy β€” precisely the
122
+ conflation spec Β§5 forbids. Coverage is reported separately in as_dict().
123
+
124
+ Substring matching, not exact β€” exact match would under-report
125
+ correct-but-differently-worded extractions (spec Β§5).
126
+ """
127
+ gold_by_term: dict[str, dict] = {}
128
+ for entry in gold.get("terms", []):
129
+ for v in [entry["term"], *entry.get("variants", [])]:
130
+ gold_by_term.setdefault(norm(v), entry)
131
+
132
+ checkable_gold = [
133
+ g for g in gold.get("terms", []) if g.get("definition_contains")
134
+ ]
135
+ n_checkable_gold = len(checkable_gold)
136
+
137
+ correct, incorrect = 0, 0
138
+ unscoreable_no_gold, unscoreable_no_criteria = 0, 0
139
+ matched_gold, wrong, failures = set(), [], []
140
+
141
+ for pred in entries:
142
+ if not (pred.get("definition") or "").strip():
143
+ continue # abstention is scored separately, not as an error
144
+ g = gold_by_term.get(norm(pred.get("term", "")))
145
+ if not g:
146
+ unscoreable_no_gold += 1
147
+ continue
148
+ required = [norm(x) for x in g.get("definition_contains", [])]
149
+ if not required:
150
+ unscoreable_no_criteria += 1
151
+ continue
152
+ definition = norm(pred.get("definition") or "")
153
+ if all(r in definition for r in required):
154
+ correct += 1
155
+ matched_gold.add(g["term"])
156
+ else:
157
+ incorrect += 1
158
+ wrong.append(f"{pred.get('term')} (definition did not match gold)")
159
+ failures.append(
160
+ {
161
+ "term": pred.get("term"),
162
+ "gold_requires": g.get("definition_contains"),
163
+ "extracted": (pred.get("definition") or "")[:240],
164
+ }
165
+ )
166
+
167
+ misses = [g["term"] for g in checkable_gold if g["term"] not in matched_gold]
168
+ n_scoreable = correct + incorrect
169
+ precision, recall, f1 = _prf(correct, n_scoreable, n_checkable_gold)
170
+
171
+ score = Score(
172
+ label="glossary_schema_fill(E3)",
173
+ n_gold=n_checkable_gold,
174
+ n_pred=n_scoreable,
175
+ true_positives=correct,
176
+ precision=precision,
177
+ recall=recall,
178
+ f1=f1,
179
+ misses=misses + wrong,
180
+ )
181
+ score.coverage = { # type: ignore[attr-defined]
182
+ "entries_total": len(entries),
183
+ "entries_with_definition": sum(
184
+ 1 for e in entries if (e.get("definition") or "").strip()
185
+ ),
186
+ "abstained_null_definition": sum(
187
+ 1 for e in entries if not (e.get("definition") or "").strip()
188
+ ),
189
+ "scoreable": n_scoreable,
190
+ "unscoreable_term_not_in_gold": unscoreable_no_gold,
191
+ "unscoreable_gold_has_no_criteria": unscoreable_no_criteria,
192
+ "gold_terms_with_criteria": n_checkable_gold,
193
+ "failures": failures,
194
+ }
195
+ return score
196
+
197
+
198
+ def score_rules(gold: dict, entries: list[dict]) -> Score:
199
+ gold_rules = gold.get("rules", [])
200
+ pred_blobs = [
201
+ norm(
202
+ " ".join(
203
+ str(v)
204
+ for v in (e.get("statement"), e.get("condition"), e.get("consequence"))
205
+ if v
206
+ )
207
+ )
208
+ for e in entries
209
+ ]
210
+ tp, misses = 0, []
211
+ for rule in gold_rules:
212
+ required = [norm(x) for x in rule.get("statement_contains", [])]
213
+ if any(all(r in blob for r in required) for blob in pred_blobs):
214
+ tp += 1
215
+ else:
216
+ misses.append(rule["rule_id"])
217
+ precision, recall, f1 = _prf(tp, len(entries), len(gold_rules))
218
+ return Score(
219
+ label="rule",
220
+ n_gold=len(gold_rules),
221
+ n_pred=len(entries),
222
+ true_positives=tp,
223
+ precision=precision,
224
+ recall=recall,
225
+ f1=f1,
226
+ misses=misses,
227
+ )
eval/planner/results/planner_result_2026-07-23_134947.json ADDED
@@ -0,0 +1,662 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "timestamp": "2026-07-23_134947",
3
+ "total": 27,
4
+ "passed": 20,
5
+ "cases": [
6
+ {
7
+ "id": "count_zero_pa",
8
+ "category": "count",
9
+ "lang": "en",
10
+ "carried_over": true,
11
+ "question": "how many records have PA_Percent = 0?",
12
+ "passed": true,
13
+ "checks": [
14
+ {
15
+ "check": "select_agg=count",
16
+ "ok": true,
17
+ "detail": "aggs=['count']"
18
+ },
19
+ {
20
+ "check": "has_filter",
21
+ "ok": true,
22
+ "detail": "filter_present=True"
23
+ },
24
+ {
25
+ "check": "no_group_by",
26
+ "ok": true,
27
+ "detail": "group_by_present=False"
28
+ }
29
+ ],
30
+ "error": null,
31
+ "latency_ms": 3591,
32
+ "tokens": 14570
33
+ },
34
+ {
35
+ "id": "count_mttr_gt20_id",
36
+ "category": "count",
37
+ "lang": "id",
38
+ "carried_over": true,
39
+ "question": "berapa banyak record dengan MTTR di atas 20?",
40
+ "passed": true,
41
+ "checks": [
42
+ {
43
+ "check": "select_agg=count",
44
+ "ok": true,
45
+ "detail": "aggs=['count']"
46
+ },
47
+ {
48
+ "check": "has_filter",
49
+ "ok": true,
50
+ "detail": "filter_present=True"
51
+ }
52
+ ],
53
+ "error": null,
54
+ "latency_ms": 2154,
55
+ "tokens": 14571
56
+ },
57
+ {
58
+ "id": "count_section_hauler",
59
+ "category": "count",
60
+ "lang": "en",
61
+ "carried_over": true,
62
+ "question": "how many rows are in section OB HAULER?",
63
+ "passed": true,
64
+ "checks": [
65
+ {
66
+ "check": "select_agg=count",
67
+ "ok": true,
68
+ "detail": "aggs=['count']"
69
+ },
70
+ {
71
+ "check": "has_filter",
72
+ "ok": true,
73
+ "detail": "filter_present=True"
74
+ }
75
+ ],
76
+ "error": null,
77
+ "latency_ms": 2063,
78
+ "tokens": 14573
79
+ },
80
+ {
81
+ "id": "rank_units_worst_pa_id",
82
+ "category": "ranking",
83
+ "lang": "id",
84
+ "carried_over": false,
85
+ "question": "5 unit dengan PA terburuk?",
86
+ "passed": true,
87
+ "checks": [
88
+ {
89
+ "check": "group_by",
90
+ "ok": true,
91
+ "detail": "group_by_present=True"
92
+ },
93
+ {
94
+ "check": "group_by_col=Equipment_Number",
95
+ "ok": true,
96
+ "detail": "want_id=c_equipment_number"
97
+ },
98
+ {
99
+ "check": "select_agg=avg",
100
+ "ok": true,
101
+ "detail": "aggs=['avg']"
102
+ },
103
+ {
104
+ "check": "order_dir=asc",
105
+ "ok": true,
106
+ "detail": "dirs=['asc']"
107
+ },
108
+ {
109
+ "check": "limit=5",
110
+ "ok": true,
111
+ "detail": "limits=[5]"
112
+ }
113
+ ],
114
+ "error": null,
115
+ "latency_ms": 2664,
116
+ "tokens": 14784
117
+ },
118
+ {
119
+ "id": "rank_models_top_mttr_id",
120
+ "category": "ranking",
121
+ "lang": "id",
122
+ "carried_over": true,
123
+ "question": "top 3 model dengan MTTR tertinggi?",
124
+ "passed": true,
125
+ "checks": [
126
+ {
127
+ "check": "group_by",
128
+ "ok": true,
129
+ "detail": "group_by_present=True"
130
+ },
131
+ {
132
+ "check": "group_by_col=Model_Unit",
133
+ "ok": true,
134
+ "detail": "want_id=c_model_unit"
135
+ },
136
+ {
137
+ "check": "select_agg=avg",
138
+ "ok": true,
139
+ "detail": "aggs=['avg']"
140
+ },
141
+ {
142
+ "check": "order_dir=desc",
143
+ "ok": true,
144
+ "detail": "dirs=['desc']"
145
+ },
146
+ {
147
+ "check": "limit=3",
148
+ "ok": true,
149
+ "detail": "limits=[3]"
150
+ }
151
+ ],
152
+ "error": null,
153
+ "latency_ms": 2757,
154
+ "tokens": 14719
155
+ },
156
+ {
157
+ "id": "rank_sections_lowest_pa_en",
158
+ "category": "ranking",
159
+ "lang": "en",
160
+ "carried_over": true,
161
+ "question": "which section has the lowest average PA?",
162
+ "passed": false,
163
+ "checks": [
164
+ {
165
+ "check": "group_by",
166
+ "ok": false,
167
+ "detail": "group_by_present=False"
168
+ },
169
+ {
170
+ "check": "group_by_col=Section",
171
+ "ok": false,
172
+ "detail": "want_id=c_section"
173
+ },
174
+ {
175
+ "check": "select_agg=avg",
176
+ "ok": false,
177
+ "detail": "aggs=[]"
178
+ },
179
+ {
180
+ "check": "order_dir=asc",
181
+ "ok": false,
182
+ "detail": "dirs=[]"
183
+ }
184
+ ],
185
+ "error": null,
186
+ "latency_ms": 3366,
187
+ "tokens": 14834
188
+ },
189
+ {
190
+ "id": "rank_units_most_breakdown_id",
191
+ "category": "ranking",
192
+ "lang": "id",
193
+ "carried_over": false,
194
+ "question": "unit mana yang paling sering breakdown?",
195
+ "passed": true,
196
+ "checks": [
197
+ {
198
+ "check": "group_by",
199
+ "ok": true,
200
+ "detail": "group_by_present=True"
201
+ },
202
+ {
203
+ "check": "group_by_col=Equipment_Number",
204
+ "ok": true,
205
+ "detail": "want_id=c_equipment_number"
206
+ },
207
+ {
208
+ "check": "order_dir=desc",
209
+ "ok": true,
210
+ "detail": "dirs=['desc']"
211
+ }
212
+ ],
213
+ "error": null,
214
+ "latency_ms": 2925,
215
+ "tokens": 14736
216
+ },
217
+ {
218
+ "id": "rank_units_worst_pa_en",
219
+ "category": "ranking",
220
+ "lang": "en",
221
+ "carried_over": false,
222
+ "question": "list the 10 worst units by availability",
223
+ "passed": true,
224
+ "checks": [
225
+ {
226
+ "check": "group_by",
227
+ "ok": true,
228
+ "detail": "group_by_present=True"
229
+ },
230
+ {
231
+ "check": "group_by_col=Equipment_Number",
232
+ "ok": true,
233
+ "detail": "want_id=c_equipment_number"
234
+ },
235
+ {
236
+ "check": "select_agg=avg",
237
+ "ok": true,
238
+ "detail": "aggs=['avg']"
239
+ },
240
+ {
241
+ "check": "order_dir=asc",
242
+ "ok": true,
243
+ "detail": "dirs=['asc']"
244
+ },
245
+ {
246
+ "check": "limit=10",
247
+ "ok": true,
248
+ "detail": "limits=[10]"
249
+ }
250
+ ],
251
+ "error": null,
252
+ "latency_ms": 3001,
253
+ "tokens": 14758
254
+ },
255
+ {
256
+ "id": "fuzzy_model_777_id",
257
+ "category": "fuzzy_filter",
258
+ "lang": "id",
259
+ "carried_over": false,
260
+ "question": "berapa banyak model 777?",
261
+ "passed": false,
262
+ "checks": [
263
+ {
264
+ "check": "select_agg=count",
265
+ "ok": true,
266
+ "detail": "aggs=['count']"
267
+ },
268
+ {
269
+ "check": "filter_op=like",
270
+ "ok": false,
271
+ "detail": "ops=['=']"
272
+ },
273
+ {
274
+ "check": "no_filter_op=in",
275
+ "ok": true,
276
+ "detail": "ops=['=']"
277
+ }
278
+ ],
279
+ "error": null,
280
+ "latency_ms": 1831,
281
+ "tokens": 14578
282
+ },
283
+ {
284
+ "id": "fuzzy_model_hd785_id",
285
+ "category": "fuzzy_filter",
286
+ "lang": "id",
287
+ "carried_over": true,
288
+ "question": "berapa banyak unit HD785?",
289
+ "passed": false,
290
+ "checks": [
291
+ {
292
+ "check": "select_agg=count",
293
+ "ok": true,
294
+ "detail": "aggs=['count']"
295
+ },
296
+ {
297
+ "check": "filter_op=like",
298
+ "ok": false,
299
+ "detail": "ops=['=']"
300
+ }
301
+ ],
302
+ "error": null,
303
+ "latency_ms": 2219,
304
+ "tokens": 14578
305
+ },
306
+ {
307
+ "id": "fuzzy_model_ex_en",
308
+ "category": "fuzzy_filter",
309
+ "lang": "en",
310
+ "carried_over": false,
311
+ "question": "how many EX excavator units are there?",
312
+ "passed": true,
313
+ "checks": [
314
+ {
315
+ "check": "filter_op=like",
316
+ "ok": true,
317
+ "detail": "ops=['like']"
318
+ }
319
+ ],
320
+ "error": null,
321
+ "latency_ms": 5243,
322
+ "tokens": 29547
323
+ },
324
+ {
325
+ "id": "agg_pa_per_section_id",
326
+ "category": "aggregate",
327
+ "lang": "id",
328
+ "carried_over": true,
329
+ "question": "berapa rata-rata PA per section?",
330
+ "passed": false,
331
+ "checks": [
332
+ {
333
+ "check": "group_by",
334
+ "ok": false,
335
+ "detail": "group_by_present=False"
336
+ },
337
+ {
338
+ "check": "group_by_col=Section",
339
+ "ok": false,
340
+ "detail": "want_id=c_section"
341
+ },
342
+ {
343
+ "check": "select_agg=avg",
344
+ "ok": false,
345
+ "detail": "aggs=[]"
346
+ }
347
+ ],
348
+ "error": null,
349
+ "latency_ms": 2817,
350
+ "tokens": 14757
351
+ },
352
+ {
353
+ "id": "agg_mttr_per_model_en",
354
+ "category": "aggregate",
355
+ "lang": "en",
356
+ "carried_over": true,
357
+ "question": "what is the average MTTR per model unit?",
358
+ "passed": false,
359
+ "checks": [
360
+ {
361
+ "check": "group_by",
362
+ "ok": false,
363
+ "detail": "group_by_present=False"
364
+ },
365
+ {
366
+ "check": "group_by_col=Model_Unit",
367
+ "ok": false,
368
+ "detail": "want_id=c_model_unit"
369
+ },
370
+ {
371
+ "check": "select_agg=avg",
372
+ "ok": false,
373
+ "detail": "aggs=[]"
374
+ }
375
+ ],
376
+ "error": null,
377
+ "latency_ms": 2859,
378
+ "tokens": 14773
379
+ },
380
+ {
381
+ "id": "agg_downhour_per_group_id",
382
+ "category": "aggregate",
383
+ "lang": "id",
384
+ "carried_over": true,
385
+ "question": "total down hour per equipment group?",
386
+ "passed": false,
387
+ "checks": [
388
+ {
389
+ "check": "group_by",
390
+ "ok": false,
391
+ "detail": "group_by_present=False"
392
+ },
393
+ {
394
+ "check": "group_by_col=Equipment_Group_ID",
395
+ "ok": false,
396
+ "detail": "want_id=c_equipment_group_id"
397
+ },
398
+ {
399
+ "check": "select_agg=sum",
400
+ "ok": false,
401
+ "detail": "aggs=[]"
402
+ }
403
+ ],
404
+ "error": null,
405
+ "latency_ms": 2855,
406
+ "tokens": 14769
407
+ },
408
+ {
409
+ "id": "desc_mttr_stats_id",
410
+ "category": "descriptive",
411
+ "lang": "id",
412
+ "carried_over": true,
413
+ "question": "berikan ringkasan statistik MTTR",
414
+ "passed": true,
415
+ "checks": [
416
+ {
417
+ "check": "has_tool=analyze_descriptive",
418
+ "ok": true,
419
+ "detail": "tools=['analyze_descriptive', 'check_data', 'retrieve_data']"
420
+ }
421
+ ],
422
+ "error": null,
423
+ "latency_ms": 2928,
424
+ "tokens": 14714
425
+ },
426
+ {
427
+ "id": "desc_pa_stats_en",
428
+ "category": "descriptive",
429
+ "lang": "en",
430
+ "carried_over": true,
431
+ "question": "give me the summary statistics for PA_Percent",
432
+ "passed": true,
433
+ "checks": [
434
+ {
435
+ "check": "has_tool=analyze_descriptive",
436
+ "ok": true,
437
+ "detail": "tools=['analyze_descriptive', 'check_data', 'retrieve_data']"
438
+ }
439
+ ],
440
+ "error": null,
441
+ "latency_ms": 2862,
442
+ "tokens": 14704
443
+ },
444
+ {
445
+ "id": "corr_mttr_pa_id",
446
+ "category": "correlation",
447
+ "lang": "id",
448
+ "carried_over": true,
449
+ "question": "apakah ada korelasi antara MTTR dan PA?",
450
+ "passed": true,
451
+ "checks": [
452
+ {
453
+ "check": "has_tool=analyze_correlation",
454
+ "ok": true,
455
+ "detail": "tools=['analyze_correlation', 'check_data', 'retrieve_data']"
456
+ }
457
+ ],
458
+ "error": null,
459
+ "latency_ms": 3171,
460
+ "tokens": 14866
461
+ },
462
+ {
463
+ "id": "corr_freq_pa_en",
464
+ "category": "correlation",
465
+ "lang": "en",
466
+ "carried_over": true,
467
+ "question": "is breakdown frequency correlated with availability?",
468
+ "passed": true,
469
+ "checks": [
470
+ {
471
+ "check": "has_tool=analyze_correlation",
472
+ "ok": true,
473
+ "detail": "tools=['analyze_correlation', 'check_data', 'retrieve_data']"
474
+ }
475
+ ],
476
+ "error": null,
477
+ "latency_ms": 3259,
478
+ "tokens": 14921
479
+ },
480
+ {
481
+ "id": "trend_pa_daily_id",
482
+ "category": "trend",
483
+ "lang": "id",
484
+ "carried_over": true,
485
+ "question": "bagaimana trend PA harian?",
486
+ "passed": false,
487
+ "checks": [],
488
+ "error": "ValidationError: 6 validation errors for TaskList\ntasks.2.id\n Field required [type=missing, input_value={'tool': 'analyze_trend',...: 'day', 'agg': 'mean'}}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing\ntasks.2.stage\n Field required [type=missing, input_value={'tool': 'analyze_trend',...: 'day', 'agg': 'mean'}}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing\ntasks.2.objective\n Field required [type=missing, input_value={'tool': 'analyze_trend',...: 'day', 'agg': 'mean'}}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing\ntasks.2.tool_calls\n Field required [type=missing, input_value={'tool': 'analyze_trend',...: 'day', 'agg': 'mean'}}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing\ntasks.2.expected_output\n Field required [type=missing, input_value={'tool': 'analyze_trend',...: 'day', 'agg': 'mean'}}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing\ntasks.2.success_criteria\n Field required [type=missing, input_value={'tool': 'analyze_trend',...: 'day', 'agg': 'mean'}}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing",
489
+ "latency_ms": 3008,
490
+ "tokens": 14775
491
+ },
492
+ {
493
+ "id": "trend_downhour_en",
494
+ "category": "trend",
495
+ "lang": "en",
496
+ "carried_over": true,
497
+ "question": "show the trend of total down hours over time",
498
+ "passed": true,
499
+ "checks": [
500
+ {
501
+ "check": "has_tool=analyze_trend",
502
+ "ok": true,
503
+ "detail": "tools=['analyze_trend', 'check_data', 'retrieve_data']"
504
+ }
505
+ ],
506
+ "error": null,
507
+ "latency_ms": 3193,
508
+ "tokens": 14842
509
+ },
510
+ {
511
+ "id": "merge_worst_pa_and_mttr_id",
512
+ "category": "merge",
513
+ "lang": "id",
514
+ "carried_over": true,
515
+ "question": "model mana yang PA-nya paling buruk sekaligus MTTR-nya paling tinggi?",
516
+ "passed": true,
517
+ "checks": [
518
+ {
519
+ "check": "group_by",
520
+ "ok": true,
521
+ "detail": "group_by_present=True"
522
+ },
523
+ {
524
+ "check": "group_by_col=Model_Unit",
525
+ "ok": true,
526
+ "detail": "want_id=c_model_unit"
527
+ }
528
+ ],
529
+ "error": null,
530
+ "latency_ms": 4061,
531
+ "tokens": 15116
532
+ },
533
+ {
534
+ "id": "raw_rows_low_pa_id",
535
+ "category": "counter_raw_rows",
536
+ "lang": "id",
537
+ "carried_over": true,
538
+ "question": "tampilkan 10 record dengan PA di bawah 50",
539
+ "passed": true,
540
+ "checks": [
541
+ {
542
+ "check": "no_group_by",
543
+ "ok": true,
544
+ "detail": "group_by_present=False"
545
+ },
546
+ {
547
+ "check": "has_filter",
548
+ "ok": true,
549
+ "detail": "filter_present=True"
550
+ },
551
+ {
552
+ "check": "limit=10",
553
+ "ok": true,
554
+ "detail": "limits=[10]"
555
+ }
556
+ ],
557
+ "error": null,
558
+ "latency_ms": 2944,
559
+ "tokens": 14868
560
+ },
561
+ {
562
+ "id": "raw_rows_head_en",
563
+ "category": "counter_raw_rows",
564
+ "lang": "en",
565
+ "carried_over": true,
566
+ "question": "show me the first 5 rows of the data",
567
+ "passed": true,
568
+ "checks": [
569
+ {
570
+ "check": "no_group_by",
571
+ "ok": true,
572
+ "detail": "group_by_present=False"
573
+ }
574
+ ],
575
+ "error": null,
576
+ "latency_ms": 4427,
577
+ "tokens": 15315
578
+ },
579
+ {
580
+ "id": "exact_model_777d_id",
581
+ "category": "counter_exact_filter",
582
+ "lang": "id",
583
+ "carried_over": true,
584
+ "question": "berapa banyak record untuk model 777D?",
585
+ "passed": true,
586
+ "checks": [
587
+ {
588
+ "check": "select_agg=count",
589
+ "ok": true,
590
+ "detail": "aggs=['count']"
591
+ },
592
+ {
593
+ "check": "has_filter",
594
+ "ok": true,
595
+ "detail": "filter_present=True"
596
+ }
597
+ ],
598
+ "error": null,
599
+ "latency_ms": 2314,
600
+ "tokens": 14568
601
+ },
602
+ {
603
+ "id": "exact_section_loader_en",
604
+ "category": "counter_exact_filter",
605
+ "lang": "en",
606
+ "carried_over": true,
607
+ "question": "how many records are in the OB LOADER section?",
608
+ "passed": true,
609
+ "checks": [
610
+ {
611
+ "check": "select_agg=count",
612
+ "ok": true,
613
+ "detail": "aggs=['count']"
614
+ },
615
+ {
616
+ "check": "has_filter",
617
+ "ok": true,
618
+ "detail": "filter_present=True"
619
+ }
620
+ ],
621
+ "error": null,
622
+ "latency_ms": 2216,
623
+ "tokens": 14595
624
+ },
625
+ {
626
+ "id": "infeasible_churn_id",
627
+ "category": "infeasible",
628
+ "lang": "id",
629
+ "carried_over": true,
630
+ "question": "berapa churn rate pelanggan?",
631
+ "passed": true,
632
+ "checks": [
633
+ {
634
+ "check": "infeasible",
635
+ "ok": true,
636
+ "detail": "infeasible=True"
637
+ }
638
+ ],
639
+ "error": null,
640
+ "latency_ms": 1775,
641
+ "tokens": 14477
642
+ },
643
+ {
644
+ "id": "infeasible_profit_en",
645
+ "category": "infeasible",
646
+ "lang": "en",
647
+ "carried_over": true,
648
+ "question": "what is the monthly profit margin?",
649
+ "passed": true,
650
+ "checks": [
651
+ {
652
+ "check": "infeasible",
653
+ "ok": true,
654
+ "detail": "infeasible=True"
655
+ }
656
+ ],
657
+ "error": null,
658
+ "latency_ms": 2282,
659
+ "tokens": 14551
660
+ }
661
+ ]
662
+ }
eval/planner/results/planner_result_2026-07-23_141931.json ADDED
@@ -0,0 +1,1818 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "timestamp": "2026-07-23_141931",
3
+ "total": 27,
4
+ "passed": 25,
5
+ "cases": [
6
+ {
7
+ "id": "count_zero_pa",
8
+ "category": "count",
9
+ "lang": "en",
10
+ "carried_over": true,
11
+ "question": "how many records have PA_Percent = 0?",
12
+ "passed": true,
13
+ "checks": [
14
+ {
15
+ "check": "select_agg=count",
16
+ "ok": true,
17
+ "detail": "aggs=['count']"
18
+ },
19
+ {
20
+ "check": "has_filter",
21
+ "ok": true,
22
+ "detail": "filter_present=True"
23
+ },
24
+ {
25
+ "check": "no_group_by",
26
+ "ok": true,
27
+ "detail": "grouped=False"
28
+ }
29
+ ],
30
+ "facts": {
31
+ "tools": [
32
+ "retrieve_data"
33
+ ],
34
+ "irs": [
35
+ {
36
+ "source_id": "src_pa",
37
+ "table_id": "t_pa",
38
+ "select": [
39
+ {
40
+ "kind": "agg",
41
+ "fn": "count",
42
+ "alias": "record_count"
43
+ }
44
+ ],
45
+ "filters": [
46
+ {
47
+ "column_id": "c_pa_percent",
48
+ "op": "=",
49
+ "value": 0,
50
+ "value_type": "decimal"
51
+ }
52
+ ]
53
+ }
54
+ ],
55
+ "agg_args": [],
56
+ "infeasible": false
57
+ },
58
+ "error": null,
59
+ "latency_ms": 3652,
60
+ "tokens": 14570
61
+ },
62
+ {
63
+ "id": "count_mttr_gt20_id",
64
+ "category": "count",
65
+ "lang": "id",
66
+ "carried_over": true,
67
+ "question": "berapa banyak record dengan MTTR di atas 20?",
68
+ "passed": true,
69
+ "checks": [
70
+ {
71
+ "check": "select_agg=count",
72
+ "ok": true,
73
+ "detail": "aggs=['count']"
74
+ },
75
+ {
76
+ "check": "has_filter",
77
+ "ok": true,
78
+ "detail": "filter_present=True"
79
+ }
80
+ ],
81
+ "facts": {
82
+ "tools": [
83
+ "retrieve_data"
84
+ ],
85
+ "irs": [
86
+ {
87
+ "source_id": "src_pa",
88
+ "table_id": "t_pa",
89
+ "select": [
90
+ {
91
+ "kind": "agg",
92
+ "fn": "count",
93
+ "alias": "record_count"
94
+ }
95
+ ],
96
+ "filters": [
97
+ {
98
+ "column_id": "c_mttr",
99
+ "op": ">",
100
+ "value": 20,
101
+ "value_type": "decimal"
102
+ }
103
+ ]
104
+ }
105
+ ],
106
+ "agg_args": [],
107
+ "infeasible": false
108
+ },
109
+ "error": null,
110
+ "latency_ms": 2176,
111
+ "tokens": 14571
112
+ },
113
+ {
114
+ "id": "count_section_hauler",
115
+ "category": "count",
116
+ "lang": "en",
117
+ "carried_over": true,
118
+ "question": "how many rows are in section OB HAULER?",
119
+ "passed": true,
120
+ "checks": [
121
+ {
122
+ "check": "select_agg=count",
123
+ "ok": true,
124
+ "detail": "aggs=['count']"
125
+ },
126
+ {
127
+ "check": "has_filter",
128
+ "ok": true,
129
+ "detail": "filter_present=True"
130
+ }
131
+ ],
132
+ "facts": {
133
+ "tools": [
134
+ "retrieve_data"
135
+ ],
136
+ "irs": [
137
+ {
138
+ "source_id": "src_pa",
139
+ "table_id": "t_pa",
140
+ "select": [
141
+ {
142
+ "kind": "agg",
143
+ "fn": "count",
144
+ "alias": "row_count"
145
+ }
146
+ ],
147
+ "filters": [
148
+ {
149
+ "column_id": "c_section",
150
+ "op": "=",
151
+ "value": "OB HAULER",
152
+ "value_type": "string"
153
+ }
154
+ ]
155
+ }
156
+ ],
157
+ "agg_args": [],
158
+ "infeasible": false
159
+ },
160
+ "error": null,
161
+ "latency_ms": 2342,
162
+ "tokens": 14571
163
+ },
164
+ {
165
+ "id": "rank_units_worst_pa_id",
166
+ "category": "ranking",
167
+ "lang": "id",
168
+ "carried_over": false,
169
+ "question": "5 unit dengan PA terburuk?",
170
+ "passed": true,
171
+ "checks": [
172
+ {
173
+ "check": "group_by",
174
+ "ok": true,
175
+ "detail": "grouped=True"
176
+ },
177
+ {
178
+ "check": "group_by_col=Equipment_Number",
179
+ "ok": true,
180
+ "detail": "ids=['c_equipment_number'] aliases=[]"
181
+ },
182
+ {
183
+ "check": "select_agg=avg",
184
+ "ok": true,
185
+ "detail": "aggs=['avg']"
186
+ },
187
+ {
188
+ "check": "order_dir=asc",
189
+ "ok": true,
190
+ "detail": "dirs=['asc']"
191
+ },
192
+ {
193
+ "check": "limit=5",
194
+ "ok": true,
195
+ "detail": "limits=[5]"
196
+ }
197
+ ],
198
+ "facts": {
199
+ "tools": [
200
+ "check_data",
201
+ "retrieve_data"
202
+ ],
203
+ "irs": [
204
+ {
205
+ "source_id": "src_pa",
206
+ "table_id": "t_pa",
207
+ "select": [
208
+ {
209
+ "kind": "column",
210
+ "column_id": "c_equipment_number",
211
+ "alias": "equipment_number"
212
+ },
213
+ {
214
+ "kind": "agg",
215
+ "fn": "avg",
216
+ "column_id": "c_pa_percent",
217
+ "alias": "avg_pa_percent"
218
+ }
219
+ ],
220
+ "group_by": [
221
+ "c_equipment_number"
222
+ ],
223
+ "order_by": [
224
+ {
225
+ "column_id": "avg_pa_percent",
226
+ "dir": "asc"
227
+ }
228
+ ],
229
+ "limit": 5
230
+ }
231
+ ],
232
+ "agg_args": [],
233
+ "infeasible": false
234
+ },
235
+ "error": null,
236
+ "latency_ms": 3519,
237
+ "tokens": 14733
238
+ },
239
+ {
240
+ "id": "rank_models_top_mttr_id",
241
+ "category": "ranking",
242
+ "lang": "id",
243
+ "carried_over": true,
244
+ "question": "top 3 model dengan MTTR tertinggi?",
245
+ "passed": true,
246
+ "checks": [
247
+ {
248
+ "check": "group_by",
249
+ "ok": true,
250
+ "detail": "grouped=True"
251
+ },
252
+ {
253
+ "check": "group_by_col=Model_Unit",
254
+ "ok": true,
255
+ "detail": "ids=['c_model_unit'] aliases=[]"
256
+ },
257
+ {
258
+ "check": "select_agg=avg",
259
+ "ok": true,
260
+ "detail": "aggs=['avg']"
261
+ },
262
+ {
263
+ "check": "order_dir=desc",
264
+ "ok": true,
265
+ "detail": "dirs=['desc']"
266
+ },
267
+ {
268
+ "check": "limit=3",
269
+ "ok": true,
270
+ "detail": "limits=[3]"
271
+ }
272
+ ],
273
+ "facts": {
274
+ "tools": [
275
+ "check_data",
276
+ "retrieve_data"
277
+ ],
278
+ "irs": [
279
+ {
280
+ "source_id": "src_pa",
281
+ "table_id": "t_pa",
282
+ "select": [
283
+ {
284
+ "kind": "column",
285
+ "column_id": "c_model_unit",
286
+ "alias": "model"
287
+ },
288
+ {
289
+ "kind": "agg",
290
+ "fn": "avg",
291
+ "column_id": "c_mttr",
292
+ "alias": "avg_mttr"
293
+ }
294
+ ],
295
+ "group_by": [
296
+ "c_model_unit"
297
+ ],
298
+ "order_by": [
299
+ {
300
+ "column_id": "avg_mttr",
301
+ "dir": "desc"
302
+ }
303
+ ],
304
+ "limit": 3
305
+ }
306
+ ],
307
+ "agg_args": [],
308
+ "infeasible": false
309
+ },
310
+ "error": null,
311
+ "latency_ms": 2928,
312
+ "tokens": 14723
313
+ },
314
+ {
315
+ "id": "rank_sections_lowest_pa_en",
316
+ "category": "ranking",
317
+ "lang": "en",
318
+ "carried_over": true,
319
+ "question": "which section has the lowest average PA?",
320
+ "passed": false,
321
+ "checks": [
322
+ {
323
+ "check": "group_by",
324
+ "ok": true,
325
+ "detail": "grouped=True"
326
+ },
327
+ {
328
+ "check": "group_by_col=Section",
329
+ "ok": true,
330
+ "detail": "ids=[] aliases=['section']"
331
+ },
332
+ {
333
+ "check": "select_agg=avg",
334
+ "ok": true,
335
+ "detail": "aggs=['mean']"
336
+ },
337
+ {
338
+ "check": "order_dir=asc",
339
+ "ok": false,
340
+ "detail": "dirs=[]"
341
+ }
342
+ ],
343
+ "facts": {
344
+ "tools": [
345
+ "analyze_aggregate",
346
+ "check_data",
347
+ "retrieve_data"
348
+ ],
349
+ "irs": [
350
+ {
351
+ "source_id": "src_pa",
352
+ "table_id": "t_pa",
353
+ "select": [
354
+ {
355
+ "kind": "column",
356
+ "column_id": "c_section",
357
+ "alias": "section"
358
+ },
359
+ {
360
+ "kind": "column",
361
+ "column_id": "c_pa_percent",
362
+ "alias": "pa_percent"
363
+ }
364
+ ],
365
+ "filters": [
366
+ {
367
+ "column_id": "c_month_id",
368
+ "op": "=",
369
+ "value": 202604,
370
+ "value_type": "int"
371
+ },
372
+ {
373
+ "column_id": "c_isdeleted",
374
+ "op": "=",
375
+ "value": 0,
376
+ "value_type": "int"
377
+ }
378
+ ],
379
+ "limit": 10000
380
+ }
381
+ ],
382
+ "agg_args": [
383
+ {
384
+ "data": "${t2}",
385
+ "aggregations": {
386
+ "pa_percent": [
387
+ "mean"
388
+ ]
389
+ },
390
+ "group_by": [
391
+ "section"
392
+ ]
393
+ }
394
+ ],
395
+ "infeasible": false
396
+ },
397
+ "error": null,
398
+ "latency_ms": 3445,
399
+ "tokens": 14833
400
+ },
401
+ {
402
+ "id": "rank_units_most_breakdown_id",
403
+ "category": "ranking",
404
+ "lang": "id",
405
+ "carried_over": false,
406
+ "question": "unit mana yang paling sering breakdown?",
407
+ "passed": true,
408
+ "checks": [
409
+ {
410
+ "check": "group_by",
411
+ "ok": true,
412
+ "detail": "grouped=True"
413
+ },
414
+ {
415
+ "check": "group_by_col=Equipment_Number",
416
+ "ok": true,
417
+ "detail": "ids=['c_equipment_number'] aliases=[]"
418
+ },
419
+ {
420
+ "check": "order_dir=desc",
421
+ "ok": true,
422
+ "detail": "dirs=['desc']"
423
+ }
424
+ ],
425
+ "facts": {
426
+ "tools": [
427
+ "check_data",
428
+ "retrieve_data"
429
+ ],
430
+ "irs": [
431
+ {
432
+ "source_id": "src_pa",
433
+ "table_id": "t_pa",
434
+ "select": [
435
+ {
436
+ "kind": "column",
437
+ "column_id": "c_equipment_number",
438
+ "alias": "equipment_number"
439
+ },
440
+ {
441
+ "kind": "agg",
442
+ "fn": "sum",
443
+ "column_id": "c_total_frequency_down",
444
+ "alias": "total_breakdown_frequency"
445
+ }
446
+ ],
447
+ "filters": [
448
+ {
449
+ "column_id": "c_month_id",
450
+ "op": "=",
451
+ "value": 202604,
452
+ "value_type": "int"
453
+ },
454
+ {
455
+ "column_id": "c_isdeleted",
456
+ "op": "=",
457
+ "value": 0,
458
+ "value_type": "int"
459
+ }
460
+ ],
461
+ "group_by": [
462
+ "c_equipment_number"
463
+ ],
464
+ "order_by": [
465
+ {
466
+ "column_id": "total_breakdown_frequency",
467
+ "dir": "desc"
468
+ }
469
+ ],
470
+ "limit": 1
471
+ }
472
+ ],
473
+ "agg_args": [],
474
+ "infeasible": false
475
+ },
476
+ "error": null,
477
+ "latency_ms": 2590,
478
+ "tokens": 14760
479
+ },
480
+ {
481
+ "id": "rank_units_worst_pa_en",
482
+ "category": "ranking",
483
+ "lang": "en",
484
+ "carried_over": false,
485
+ "question": "list the 10 worst units by availability",
486
+ "passed": true,
487
+ "checks": [
488
+ {
489
+ "check": "group_by",
490
+ "ok": true,
491
+ "detail": "grouped=True"
492
+ },
493
+ {
494
+ "check": "group_by_col=Equipment_Number",
495
+ "ok": true,
496
+ "detail": "ids=['c_equipment_number'] aliases=[]"
497
+ },
498
+ {
499
+ "check": "select_agg=avg",
500
+ "ok": true,
501
+ "detail": "aggs=['avg']"
502
+ },
503
+ {
504
+ "check": "order_dir=asc",
505
+ "ok": true,
506
+ "detail": "dirs=['asc']"
507
+ },
508
+ {
509
+ "check": "limit=10",
510
+ "ok": true,
511
+ "detail": "limits=[10]"
512
+ }
513
+ ],
514
+ "facts": {
515
+ "tools": [
516
+ "check_data",
517
+ "retrieve_data"
518
+ ],
519
+ "irs": [
520
+ {
521
+ "source_id": "src_pa",
522
+ "table_id": "t_pa",
523
+ "select": [
524
+ {
525
+ "kind": "column",
526
+ "column_id": "c_equipment_number",
527
+ "alias": "equipment_number"
528
+ },
529
+ {
530
+ "kind": "agg",
531
+ "fn": "avg",
532
+ "column_id": "c_pa_percent",
533
+ "alias": "avg_pa_percent"
534
+ }
535
+ ],
536
+ "filters": [
537
+ {
538
+ "column_id": "c_month_id",
539
+ "op": "=",
540
+ "value": 202604,
541
+ "value_type": "int"
542
+ },
543
+ {
544
+ "column_id": "c_isdeleted",
545
+ "op": "=",
546
+ "value": 0,
547
+ "value_type": "int"
548
+ }
549
+ ],
550
+ "group_by": [
551
+ "c_equipment_number"
552
+ ],
553
+ "order_by": [
554
+ {
555
+ "column_id": "avg_pa_percent",
556
+ "dir": "asc"
557
+ }
558
+ ],
559
+ "limit": 10
560
+ }
561
+ ],
562
+ "agg_args": [],
563
+ "infeasible": false
564
+ },
565
+ "error": null,
566
+ "latency_ms": 3221,
567
+ "tokens": 14791
568
+ },
569
+ {
570
+ "id": "fuzzy_model_777_id",
571
+ "category": "fuzzy_filter",
572
+ "lang": "id",
573
+ "carried_over": false,
574
+ "question": "berapa banyak model 777?",
575
+ "passed": true,
576
+ "checks": [
577
+ {
578
+ "check": "select_agg=count",
579
+ "ok": true,
580
+ "detail": "aggs=['count']"
581
+ },
582
+ {
583
+ "check": "no_filter_op=in",
584
+ "ok": true,
585
+ "detail": "ops=['=']"
586
+ }
587
+ ],
588
+ "facts": {
589
+ "tools": [
590
+ "retrieve_data"
591
+ ],
592
+ "irs": [
593
+ {
594
+ "source_id": "src_pa",
595
+ "table_id": "t_pa",
596
+ "select": [
597
+ {
598
+ "kind": "agg",
599
+ "fn": "count",
600
+ "alias": "record_count"
601
+ }
602
+ ],
603
+ "filters": [
604
+ {
605
+ "column_id": "c_model_unit",
606
+ "op": "=",
607
+ "value": "777",
608
+ "value_type": "string"
609
+ }
610
+ ]
611
+ }
612
+ ],
613
+ "agg_args": [],
614
+ "infeasible": false
615
+ },
616
+ "error": null,
617
+ "latency_ms": 2332,
618
+ "tokens": 14576
619
+ },
620
+ {
621
+ "id": "fuzzy_model_hd785_id",
622
+ "category": "fuzzy_filter",
623
+ "lang": "id",
624
+ "carried_over": true,
625
+ "question": "berapa banyak unit HD785?",
626
+ "passed": true,
627
+ "checks": [
628
+ {
629
+ "check": "select_agg=count",
630
+ "ok": true,
631
+ "detail": "aggs=['count']"
632
+ },
633
+ {
634
+ "check": "no_filter_op=in",
635
+ "ok": true,
636
+ "detail": "ops=['=']"
637
+ }
638
+ ],
639
+ "facts": {
640
+ "tools": [
641
+ "retrieve_data"
642
+ ],
643
+ "irs": [
644
+ {
645
+ "source_id": "src_pa",
646
+ "table_id": "t_pa",
647
+ "select": [
648
+ {
649
+ "kind": "agg",
650
+ "fn": "count",
651
+ "alias": "unit_count"
652
+ }
653
+ ],
654
+ "filters": [
655
+ {
656
+ "column_id": "c_model_unit",
657
+ "op": "=",
658
+ "value": "HD785",
659
+ "value_type": "string"
660
+ }
661
+ ],
662
+ "limit": 10000
663
+ }
664
+ ],
665
+ "agg_args": [],
666
+ "infeasible": false
667
+ },
668
+ "error": null,
669
+ "latency_ms": 4613,
670
+ "tokens": 29329
671
+ },
672
+ {
673
+ "id": "fuzzy_model_ex_en",
674
+ "category": "fuzzy_filter",
675
+ "lang": "en",
676
+ "carried_over": false,
677
+ "question": "how many EX excavator units are there?",
678
+ "passed": true,
679
+ "checks": [
680
+ {
681
+ "check": "no_filter_op=in",
682
+ "ok": true,
683
+ "detail": "ops=['like']"
684
+ }
685
+ ],
686
+ "facts": {
687
+ "tools": [
688
+ "retrieve_data"
689
+ ],
690
+ "irs": [
691
+ {
692
+ "source_id": "src_pa",
693
+ "table_id": "t_pa",
694
+ "select": [
695
+ {
696
+ "kind": "agg",
697
+ "fn": "count",
698
+ "alias": "unit_count"
699
+ }
700
+ ],
701
+ "filters": [
702
+ {
703
+ "column_id": "c_equipment_number",
704
+ "op": "like",
705
+ "value": "EX%",
706
+ "value_type": "string"
707
+ }
708
+ ]
709
+ }
710
+ ],
711
+ "agg_args": [],
712
+ "infeasible": false
713
+ },
714
+ "error": null,
715
+ "latency_ms": 5577,
716
+ "tokens": 29385
717
+ },
718
+ {
719
+ "id": "agg_pa_per_section_id",
720
+ "category": "aggregate",
721
+ "lang": "id",
722
+ "carried_over": true,
723
+ "question": "berapa rata-rata PA per section?",
724
+ "passed": true,
725
+ "checks": [
726
+ {
727
+ "check": "group_by",
728
+ "ok": true,
729
+ "detail": "grouped=True"
730
+ },
731
+ {
732
+ "check": "group_by_col=Section",
733
+ "ok": true,
734
+ "detail": "ids=[] aliases=['section']"
735
+ },
736
+ {
737
+ "check": "select_agg=avg",
738
+ "ok": true,
739
+ "detail": "aggs=['mean']"
740
+ }
741
+ ],
742
+ "facts": {
743
+ "tools": [
744
+ "analyze_aggregate",
745
+ "check_data",
746
+ "retrieve_data"
747
+ ],
748
+ "irs": [
749
+ {
750
+ "source_id": "src_pa",
751
+ "table_id": "t_pa",
752
+ "select": [
753
+ {
754
+ "kind": "column",
755
+ "column_id": "c_section",
756
+ "alias": "section"
757
+ },
758
+ {
759
+ "kind": "column",
760
+ "column_id": "c_pa_percent",
761
+ "alias": "pa_percent"
762
+ }
763
+ ],
764
+ "limit": 10000
765
+ }
766
+ ],
767
+ "agg_args": [
768
+ {
769
+ "data": "${t2}",
770
+ "aggregations": {
771
+ "pa_percent": [
772
+ "mean"
773
+ ]
774
+ },
775
+ "group_by": [
776
+ "section"
777
+ ]
778
+ }
779
+ ],
780
+ "infeasible": false
781
+ },
782
+ "error": null,
783
+ "latency_ms": 3785,
784
+ "tokens": 14754
785
+ },
786
+ {
787
+ "id": "agg_mttr_per_model_en",
788
+ "category": "aggregate",
789
+ "lang": "en",
790
+ "carried_over": true,
791
+ "question": "what is the average MTTR per model unit?",
792
+ "passed": true,
793
+ "checks": [
794
+ {
795
+ "check": "group_by",
796
+ "ok": true,
797
+ "detail": "grouped=True"
798
+ },
799
+ {
800
+ "check": "group_by_col=Model_Unit",
801
+ "ok": true,
802
+ "detail": "ids=[] aliases=['model_unit']"
803
+ },
804
+ {
805
+ "check": "select_agg=avg",
806
+ "ok": true,
807
+ "detail": "aggs=['mean']"
808
+ }
809
+ ],
810
+ "facts": {
811
+ "tools": [
812
+ "analyze_aggregate",
813
+ "check_data",
814
+ "retrieve_data"
815
+ ],
816
+ "irs": [
817
+ {
818
+ "source_id": "src_pa",
819
+ "table_id": "t_pa",
820
+ "select": [
821
+ {
822
+ "kind": "column",
823
+ "column_id": "c_model_unit",
824
+ "alias": "model_unit"
825
+ },
826
+ {
827
+ "kind": "column",
828
+ "column_id": "c_mttr",
829
+ "alias": "mttr"
830
+ }
831
+ ],
832
+ "limit": 10000
833
+ }
834
+ ],
835
+ "agg_args": [
836
+ {
837
+ "data": "${t2}",
838
+ "aggregations": {
839
+ "mttr": [
840
+ "mean"
841
+ ]
842
+ },
843
+ "group_by": [
844
+ "model_unit"
845
+ ]
846
+ }
847
+ ],
848
+ "infeasible": false
849
+ },
850
+ "error": null,
851
+ "latency_ms": 2925,
852
+ "tokens": 14790
853
+ },
854
+ {
855
+ "id": "agg_downhour_per_group_id",
856
+ "category": "aggregate",
857
+ "lang": "id",
858
+ "carried_over": true,
859
+ "question": "total down hour per equipment group?",
860
+ "passed": false,
861
+ "checks": [
862
+ {
863
+ "check": "group_by",
864
+ "ok": true,
865
+ "detail": "grouped=True"
866
+ },
867
+ {
868
+ "check": "group_by_col=Equipment_Group_ID",
869
+ "ok": false,
870
+ "detail": "ids=[] aliases=['equipment_group']"
871
+ },
872
+ {
873
+ "check": "select_agg=sum",
874
+ "ok": true,
875
+ "detail": "aggs=['sum']"
876
+ }
877
+ ],
878
+ "facts": {
879
+ "tools": [
880
+ "analyze_aggregate",
881
+ "check_data",
882
+ "retrieve_data"
883
+ ],
884
+ "irs": [
885
+ {
886
+ "source_id": "src_pa",
887
+ "table_id": "t_pa",
888
+ "select": [
889
+ {
890
+ "kind": "column",
891
+ "column_id": "c_equipment_group_id",
892
+ "alias": "equipment_group"
893
+ },
894
+ {
895
+ "kind": "column",
896
+ "column_id": "c_total_down_hour",
897
+ "alias": "total_down_hour"
898
+ }
899
+ ],
900
+ "limit": 10000
901
+ }
902
+ ],
903
+ "agg_args": [
904
+ {
905
+ "data": "${t2}",
906
+ "aggregations": {
907
+ "total_down_hour": [
908
+ "sum"
909
+ ]
910
+ },
911
+ "group_by": [
912
+ "equipment_group"
913
+ ]
914
+ }
915
+ ],
916
+ "infeasible": false
917
+ },
918
+ "error": null,
919
+ "latency_ms": 3449,
920
+ "tokens": 14785
921
+ },
922
+ {
923
+ "id": "desc_mttr_stats_id",
924
+ "category": "descriptive",
925
+ "lang": "id",
926
+ "carried_over": true,
927
+ "question": "berikan ringkasan statistik MTTR",
928
+ "passed": true,
929
+ "checks": [
930
+ {
931
+ "check": "has_tool=analyze_descriptive",
932
+ "ok": true,
933
+ "detail": "tools=['analyze_descriptive', 'check_data', 'retrieve_data']"
934
+ }
935
+ ],
936
+ "facts": {
937
+ "tools": [
938
+ "analyze_descriptive",
939
+ "check_data",
940
+ "retrieve_data"
941
+ ],
942
+ "irs": [
943
+ {
944
+ "source_id": "src_pa",
945
+ "table_id": "t_pa",
946
+ "select": [
947
+ {
948
+ "kind": "column",
949
+ "column_id": "c_mttr",
950
+ "alias": "mttr"
951
+ }
952
+ ],
953
+ "filters": [
954
+ {
955
+ "column_id": "c_month_id",
956
+ "op": "=",
957
+ "value": 202604,
958
+ "value_type": "int"
959
+ },
960
+ {
961
+ "column_id": "c_isdeleted",
962
+ "op": "=",
963
+ "value": 0,
964
+ "value_type": "int"
965
+ }
966
+ ],
967
+ "limit": 10000
968
+ }
969
+ ],
970
+ "agg_args": [],
971
+ "infeasible": false
972
+ },
973
+ "error": null,
974
+ "latency_ms": 2895,
975
+ "tokens": 14767
976
+ },
977
+ {
978
+ "id": "desc_pa_stats_en",
979
+ "category": "descriptive",
980
+ "lang": "en",
981
+ "carried_over": true,
982
+ "question": "give me the summary statistics for PA_Percent",
983
+ "passed": true,
984
+ "checks": [
985
+ {
986
+ "check": "has_tool=analyze_descriptive",
987
+ "ok": true,
988
+ "detail": "tools=['analyze_descriptive', 'check_data', 'retrieve_data']"
989
+ }
990
+ ],
991
+ "facts": {
992
+ "tools": [
993
+ "analyze_descriptive",
994
+ "check_data",
995
+ "retrieve_data"
996
+ ],
997
+ "irs": [
998
+ {
999
+ "source_id": "src_pa",
1000
+ "table_id": "t_pa",
1001
+ "select": [
1002
+ {
1003
+ "kind": "column",
1004
+ "column_id": "c_pa_percent",
1005
+ "alias": "pa_percent"
1006
+ }
1007
+ ],
1008
+ "limit": 10000
1009
+ }
1010
+ ],
1011
+ "agg_args": [],
1012
+ "infeasible": false
1013
+ },
1014
+ "error": null,
1015
+ "latency_ms": 2850,
1016
+ "tokens": 14703
1017
+ },
1018
+ {
1019
+ "id": "corr_mttr_pa_id",
1020
+ "category": "correlation",
1021
+ "lang": "id",
1022
+ "carried_over": true,
1023
+ "question": "apakah ada korelasi antara MTTR dan PA?",
1024
+ "passed": true,
1025
+ "checks": [
1026
+ {
1027
+ "check": "has_tool=analyze_correlation",
1028
+ "ok": true,
1029
+ "detail": "tools=['analyze_correlation', 'check_data', 'retrieve_data']"
1030
+ }
1031
+ ],
1032
+ "facts": {
1033
+ "tools": [
1034
+ "analyze_correlation",
1035
+ "check_data",
1036
+ "retrieve_data"
1037
+ ],
1038
+ "irs": [
1039
+ {
1040
+ "source_id": "src_pa",
1041
+ "table_id": "t_pa",
1042
+ "select": [
1043
+ {
1044
+ "kind": "column",
1045
+ "column_id": "c_mttr",
1046
+ "alias": "mttr"
1047
+ },
1048
+ {
1049
+ "kind": "column",
1050
+ "column_id": "c_pa_percent",
1051
+ "alias": "pa_percent"
1052
+ }
1053
+ ],
1054
+ "filters": [
1055
+ {
1056
+ "column_id": "c_month_id",
1057
+ "op": "=",
1058
+ "value": 202604,
1059
+ "value_type": "int"
1060
+ },
1061
+ {
1062
+ "column_id": "c_isdeleted",
1063
+ "op": "=",
1064
+ "value": 0,
1065
+ "value_type": "int"
1066
+ }
1067
+ ],
1068
+ "limit": 10000
1069
+ }
1070
+ ],
1071
+ "agg_args": [],
1072
+ "infeasible": false
1073
+ },
1074
+ "error": null,
1075
+ "latency_ms": 3665,
1076
+ "tokens": 14850
1077
+ },
1078
+ {
1079
+ "id": "corr_freq_pa_en",
1080
+ "category": "correlation",
1081
+ "lang": "en",
1082
+ "carried_over": true,
1083
+ "question": "is breakdown frequency correlated with availability?",
1084
+ "passed": true,
1085
+ "checks": [
1086
+ {
1087
+ "check": "has_tool=analyze_correlation",
1088
+ "ok": true,
1089
+ "detail": "tools=['analyze_correlation', 'check_data', 'retrieve_data']"
1090
+ }
1091
+ ],
1092
+ "facts": {
1093
+ "tools": [
1094
+ "analyze_correlation",
1095
+ "check_data",
1096
+ "retrieve_data"
1097
+ ],
1098
+ "irs": [
1099
+ {
1100
+ "source_id": "src_pa",
1101
+ "table_id": "t_pa",
1102
+ "select": [
1103
+ {
1104
+ "kind": "column",
1105
+ "column_id": "c_total_frequency_breakdown_schedule",
1106
+ "alias": "breakdown_freq_schedule"
1107
+ },
1108
+ {
1109
+ "kind": "column",
1110
+ "column_id": "c_total_frequency_breakdown_unschedule",
1111
+ "alias": "breakdown_freq_unschedule"
1112
+ },
1113
+ {
1114
+ "kind": "column",
1115
+ "column_id": "c_pa_percent",
1116
+ "alias": "pa_percent"
1117
+ }
1118
+ ],
1119
+ "filters": [
1120
+ {
1121
+ "column_id": "c_isdeleted",
1122
+ "op": "=",
1123
+ "value": 0,
1124
+ "value_type": "int"
1125
+ }
1126
+ ],
1127
+ "limit": 10000
1128
+ }
1129
+ ],
1130
+ "agg_args": [],
1131
+ "infeasible": false
1132
+ },
1133
+ "error": null,
1134
+ "latency_ms": 3299,
1135
+ "tokens": 14859
1136
+ },
1137
+ {
1138
+ "id": "trend_pa_daily_id",
1139
+ "category": "trend",
1140
+ "lang": "id",
1141
+ "carried_over": true,
1142
+ "question": "bagaimana trend PA harian?",
1143
+ "passed": true,
1144
+ "checks": [
1145
+ {
1146
+ "check": "has_tool=analyze_trend",
1147
+ "ok": true,
1148
+ "detail": "tools=['analyze_trend', 'check_data', 'retrieve_data']"
1149
+ }
1150
+ ],
1151
+ "facts": {
1152
+ "tools": [
1153
+ "analyze_trend",
1154
+ "check_data",
1155
+ "retrieve_data"
1156
+ ],
1157
+ "irs": [
1158
+ {
1159
+ "source_id": "src_pa",
1160
+ "table_id": "t_pa",
1161
+ "select": [
1162
+ {
1163
+ "kind": "column",
1164
+ "column_id": "c_from_date",
1165
+ "alias": "date"
1166
+ },
1167
+ {
1168
+ "kind": "column",
1169
+ "column_id": "c_pa_percent",
1170
+ "alias": "pa_percent"
1171
+ }
1172
+ ],
1173
+ "filters": [
1174
+ {
1175
+ "column_id": "c_month_id",
1176
+ "op": "=",
1177
+ "value": 202604,
1178
+ "value_type": "int"
1179
+ },
1180
+ {
1181
+ "column_id": "c_isdeleted",
1182
+ "op": "=",
1183
+ "value": 0,
1184
+ "value_type": "int"
1185
+ }
1186
+ ],
1187
+ "limit": 10000
1188
+ }
1189
+ ],
1190
+ "agg_args": [],
1191
+ "infeasible": false
1192
+ },
1193
+ "error": null,
1194
+ "latency_ms": 3472,
1195
+ "tokens": 14861
1196
+ },
1197
+ {
1198
+ "id": "trend_downhour_en",
1199
+ "category": "trend",
1200
+ "lang": "en",
1201
+ "carried_over": true,
1202
+ "question": "show the trend of total down hours over time",
1203
+ "passed": true,
1204
+ "checks": [
1205
+ {
1206
+ "check": "has_tool=analyze_trend",
1207
+ "ok": true,
1208
+ "detail": "tools=['analyze_trend', 'check_data', 'retrieve_data']"
1209
+ }
1210
+ ],
1211
+ "facts": {
1212
+ "tools": [
1213
+ "analyze_trend",
1214
+ "check_data",
1215
+ "retrieve_data"
1216
+ ],
1217
+ "irs": [
1218
+ {
1219
+ "source_id": "src_pa",
1220
+ "table_id": "t_pa",
1221
+ "select": [
1222
+ {
1223
+ "kind": "column",
1224
+ "column_id": "c_from_date",
1225
+ "alias": "date"
1226
+ },
1227
+ {
1228
+ "kind": "column",
1229
+ "column_id": "c_total_down_hour",
1230
+ "alias": "total_down_hours"
1231
+ }
1232
+ ],
1233
+ "filters": [
1234
+ {
1235
+ "column_id": "c_month_id",
1236
+ "op": "=",
1237
+ "value": 202604,
1238
+ "value_type": "int"
1239
+ }
1240
+ ],
1241
+ "limit": 10000
1242
+ }
1243
+ ],
1244
+ "agg_args": [],
1245
+ "infeasible": false
1246
+ },
1247
+ "error": null,
1248
+ "latency_ms": 3703,
1249
+ "tokens": 14825
1250
+ },
1251
+ {
1252
+ "id": "merge_worst_pa_and_mttr_id",
1253
+ "category": "merge",
1254
+ "lang": "id",
1255
+ "carried_over": true,
1256
+ "question": "model mana yang PA-nya paling buruk sekaligus MTTR-nya paling tinggi?",
1257
+ "passed": true,
1258
+ "checks": [
1259
+ {
1260
+ "check": "group_by",
1261
+ "ok": true,
1262
+ "detail": "grouped=True"
1263
+ },
1264
+ {
1265
+ "check": "group_by_col=Model_Unit",
1266
+ "ok": true,
1267
+ "detail": "ids=['c_model_unit', 'c_model_unit'] aliases=[]"
1268
+ }
1269
+ ],
1270
+ "facts": {
1271
+ "tools": [
1272
+ "analyze_merge",
1273
+ "check_data",
1274
+ "retrieve_data"
1275
+ ],
1276
+ "irs": [
1277
+ {
1278
+ "source_id": "src_pa",
1279
+ "table_id": "t_pa",
1280
+ "select": [
1281
+ {
1282
+ "kind": "column",
1283
+ "column_id": "c_model_unit",
1284
+ "alias": "model"
1285
+ },
1286
+ {
1287
+ "kind": "agg",
1288
+ "fn": "avg",
1289
+ "column_id": "c_pa_percent",
1290
+ "alias": "avg_pa"
1291
+ }
1292
+ ],
1293
+ "group_by": [
1294
+ "c_model_unit"
1295
+ ],
1296
+ "order_by": [
1297
+ {
1298
+ "column_id": "avg_pa",
1299
+ "dir": "asc"
1300
+ }
1301
+ ],
1302
+ "limit": 1
1303
+ },
1304
+ {
1305
+ "source_id": "src_pa",
1306
+ "table_id": "t_pa",
1307
+ "select": [
1308
+ {
1309
+ "kind": "column",
1310
+ "column_id": "c_model_unit",
1311
+ "alias": "model"
1312
+ },
1313
+ {
1314
+ "kind": "agg",
1315
+ "fn": "avg",
1316
+ "column_id": "c_mttr",
1317
+ "alias": "avg_mttr"
1318
+ }
1319
+ ],
1320
+ "group_by": [
1321
+ "c_model_unit"
1322
+ ],
1323
+ "order_by": [
1324
+ {
1325
+ "column_id": "avg_mttr",
1326
+ "dir": "desc"
1327
+ }
1328
+ ],
1329
+ "limit": 1
1330
+ }
1331
+ ],
1332
+ "agg_args": [],
1333
+ "infeasible": false
1334
+ },
1335
+ "error": null,
1336
+ "latency_ms": 4058,
1337
+ "tokens": 15042
1338
+ },
1339
+ {
1340
+ "id": "raw_rows_low_pa_id",
1341
+ "category": "counter_raw_rows",
1342
+ "lang": "id",
1343
+ "carried_over": true,
1344
+ "question": "tampilkan 10 record dengan PA di bawah 50",
1345
+ "passed": true,
1346
+ "checks": [
1347
+ {
1348
+ "check": "no_group_by",
1349
+ "ok": true,
1350
+ "detail": "grouped=False"
1351
+ },
1352
+ {
1353
+ "check": "has_filter",
1354
+ "ok": true,
1355
+ "detail": "filter_present=True"
1356
+ },
1357
+ {
1358
+ "check": "limit=10",
1359
+ "ok": true,
1360
+ "detail": "limits=[10]"
1361
+ }
1362
+ ],
1363
+ "facts": {
1364
+ "tools": [
1365
+ "retrieve_data"
1366
+ ],
1367
+ "irs": [
1368
+ {
1369
+ "source_id": "src_pa",
1370
+ "table_id": "t_pa",
1371
+ "select": [
1372
+ {
1373
+ "kind": "column",
1374
+ "column_id": "c_keyid",
1375
+ "alias": "key_id"
1376
+ },
1377
+ {
1378
+ "kind": "column",
1379
+ "column_id": "c_month_id",
1380
+ "alias": "month_id"
1381
+ },
1382
+ {
1383
+ "kind": "column",
1384
+ "column_id": "c_site_id",
1385
+ "alias": "site_id"
1386
+ },
1387
+ {
1388
+ "kind": "column",
1389
+ "column_id": "c_from_date",
1390
+ "alias": "from_date"
1391
+ },
1392
+ {
1393
+ "kind": "column",
1394
+ "column_id": "c_to_date",
1395
+ "alias": "to_date"
1396
+ },
1397
+ {
1398
+ "kind": "column",
1399
+ "column_id": "c_time_description",
1400
+ "alias": "time_description"
1401
+ },
1402
+ {
1403
+ "kind": "column",
1404
+ "column_id": "c_model_unit",
1405
+ "alias": "model_unit"
1406
+ },
1407
+ {
1408
+ "kind": "column",
1409
+ "column_id": "c_equipment_number",
1410
+ "alias": "equipment_number"
1411
+ },
1412
+ {
1413
+ "kind": "column",
1414
+ "column_id": "c_equipment_group_id",
1415
+ "alias": "equipment_group_id"
1416
+ },
1417
+ {
1418
+ "kind": "column",
1419
+ "column_id": "c_unit_status",
1420
+ "alias": "unit_status"
1421
+ },
1422
+ {
1423
+ "kind": "column",
1424
+ "column_id": "c_pa_percent",
1425
+ "alias": "pa_percent"
1426
+ }
1427
+ ],
1428
+ "filters": [
1429
+ {
1430
+ "column_id": "c_pa_percent",
1431
+ "op": "<",
1432
+ "value": 50,
1433
+ "value_type": "decimal"
1434
+ }
1435
+ ],
1436
+ "order_by": [
1437
+ {
1438
+ "column_id": "pa_percent",
1439
+ "dir": "asc"
1440
+ }
1441
+ ],
1442
+ "limit": 10
1443
+ }
1444
+ ],
1445
+ "agg_args": [],
1446
+ "infeasible": false
1447
+ },
1448
+ "error": null,
1449
+ "latency_ms": 3493,
1450
+ "tokens": 14790
1451
+ },
1452
+ {
1453
+ "id": "raw_rows_head_en",
1454
+ "category": "counter_raw_rows",
1455
+ "lang": "en",
1456
+ "carried_over": true,
1457
+ "question": "show me the first 5 rows of the data",
1458
+ "passed": true,
1459
+ "checks": [
1460
+ {
1461
+ "check": "no_group_by",
1462
+ "ok": true,
1463
+ "detail": "grouped=False"
1464
+ }
1465
+ ],
1466
+ "facts": {
1467
+ "tools": [
1468
+ "check_data",
1469
+ "retrieve_data"
1470
+ ],
1471
+ "irs": [
1472
+ {
1473
+ "source_id": "src_pa",
1474
+ "table_id": "t_pa",
1475
+ "select": [
1476
+ {
1477
+ "kind": "column",
1478
+ "column_id": "c_keyid",
1479
+ "alias": "KeyId"
1480
+ },
1481
+ {
1482
+ "kind": "column",
1483
+ "column_id": "c_month_id",
1484
+ "alias": "Month_ID"
1485
+ },
1486
+ {
1487
+ "kind": "column",
1488
+ "column_id": "c_site_id",
1489
+ "alias": "Site_ID"
1490
+ },
1491
+ {
1492
+ "kind": "column",
1493
+ "column_id": "c_from_date",
1494
+ "alias": "From_Date"
1495
+ },
1496
+ {
1497
+ "kind": "column",
1498
+ "column_id": "c_to_date",
1499
+ "alias": "To_Date"
1500
+ },
1501
+ {
1502
+ "kind": "column",
1503
+ "column_id": "c_time_description",
1504
+ "alias": "Time_Description"
1505
+ },
1506
+ {
1507
+ "kind": "column",
1508
+ "column_id": "c_model_unit",
1509
+ "alias": "Model_Unit"
1510
+ },
1511
+ {
1512
+ "kind": "column",
1513
+ "column_id": "c_equipment_number",
1514
+ "alias": "Equipment_Number"
1515
+ },
1516
+ {
1517
+ "kind": "column",
1518
+ "column_id": "c_equipment_group_id",
1519
+ "alias": "Equipment_Group_ID"
1520
+ },
1521
+ {
1522
+ "kind": "column",
1523
+ "column_id": "c_unit_status",
1524
+ "alias": "Unit_Status"
1525
+ },
1526
+ {
1527
+ "kind": "column",
1528
+ "column_id": "c_total_breakdown_schedule_hour",
1529
+ "alias": "Total_Breakdown_Schedule_Hour"
1530
+ },
1531
+ {
1532
+ "kind": "column",
1533
+ "column_id": "c_total_breakdown_unschedule_hour",
1534
+ "alias": "Total_Breakdown_Unschedule_Hour"
1535
+ },
1536
+ {
1537
+ "kind": "column",
1538
+ "column_id": "c_total_adj_breakdown_schedule_hour",
1539
+ "alias": "Total_Adj_Breakdown_Schedule_Hour"
1540
+ },
1541
+ {
1542
+ "kind": "column",
1543
+ "column_id": "c_total_adj_breakdown_unschedule_hour",
1544
+ "alias": "Total_Adj_Breakdown_Unschedule_Hour"
1545
+ },
1546
+ {
1547
+ "kind": "column",
1548
+ "column_id": "c_total_mtc_hour",
1549
+ "alias": "Total_MTC_Hour"
1550
+ },
1551
+ {
1552
+ "kind": "column",
1553
+ "column_id": "c_total_down_hour",
1554
+ "alias": "Total_Down_Hour"
1555
+ },
1556
+ {
1557
+ "kind": "column",
1558
+ "column_id": "c_total_frequency_breakdown_schedule",
1559
+ "alias": "Total_Frequency_Breakdown_Schedule"
1560
+ },
1561
+ {
1562
+ "kind": "column",
1563
+ "column_id": "c_total_frequency_breakdown_unschedule",
1564
+ "alias": "Total_Frequency_Breakdown_Unschedule"
1565
+ },
1566
+ {
1567
+ "kind": "column",
1568
+ "column_id": "c_total_frequency_maintenance",
1569
+ "alias": "Total_Frequency_Maintenance"
1570
+ },
1571
+ {
1572
+ "kind": "column",
1573
+ "column_id": "c_total_frequency_tire",
1574
+ "alias": "Total_Frequency_Tire"
1575
+ },
1576
+ {
1577
+ "kind": "column",
1578
+ "column_id": "c_total_frequency_down",
1579
+ "alias": "Total_Frequency_Down"
1580
+ },
1581
+ {
1582
+ "kind": "column",
1583
+ "column_id": "c_total_hours",
1584
+ "alias": "Total_Hours"
1585
+ },
1586
+ {
1587
+ "kind": "column",
1588
+ "column_id": "c_total_inpr_hour",
1589
+ "alias": "Total_INPR_Hour"
1590
+ },
1591
+ {
1592
+ "kind": "column",
1593
+ "column_id": "c_total_record_hm_hour",
1594
+ "alias": "Total_Record_HM_Hour"
1595
+ },
1596
+ {
1597
+ "kind": "column",
1598
+ "column_id": "c_total_hm_mtc_down_hour",
1599
+ "alias": "Total_HM_Mtc_Down_Hour"
1600
+ },
1601
+ {
1602
+ "kind": "column",
1603
+ "column_id": "c_plan_pa_percent",
1604
+ "alias": "Plan_PA_Percent"
1605
+ },
1606
+ {
1607
+ "kind": "column",
1608
+ "column_id": "c_pa_percent",
1609
+ "alias": "PA_Percent"
1610
+ },
1611
+ {
1612
+ "kind": "column",
1613
+ "column_id": "c_mtbs",
1614
+ "alias": "MTBS"
1615
+ },
1616
+ {
1617
+ "kind": "column",
1618
+ "column_id": "c_mttr",
1619
+ "alias": "MTTR"
1620
+ },
1621
+ {
1622
+ "kind": "column",
1623
+ "column_id": "c_sm_percent",
1624
+ "alias": "SM_Percent"
1625
+ },
1626
+ {
1627
+ "kind": "column",
1628
+ "column_id": "c_unschedule_sm_percent",
1629
+ "alias": "Unschedule_SM_Percent"
1630
+ },
1631
+ {
1632
+ "kind": "column",
1633
+ "column_id": "c_isdeleted",
1634
+ "alias": "IsDeleted"
1635
+ },
1636
+ {
1637
+ "kind": "column",
1638
+ "column_id": "c_section",
1639
+ "alias": "Section"
1640
+ },
1641
+ {
1642
+ "kind": "column",
1643
+ "column_id": "c_week_id",
1644
+ "alias": "Week_ID"
1645
+ },
1646
+ {
1647
+ "kind": "column",
1648
+ "column_id": "c_plan_pa_percent_2",
1649
+ "alias": "Plan_PA_Percent_2"
1650
+ },
1651
+ {
1652
+ "kind": "column",
1653
+ "column_id": "c_updated_date",
1654
+ "alias": "Updated_Date"
1655
+ }
1656
+ ],
1657
+ "limit": 5
1658
+ }
1659
+ ],
1660
+ "agg_args": [],
1661
+ "infeasible": false
1662
+ },
1663
+ "error": null,
1664
+ "latency_ms": 4440,
1665
+ "tokens": 15314
1666
+ },
1667
+ {
1668
+ "id": "exact_model_777d_id",
1669
+ "category": "counter_exact_filter",
1670
+ "lang": "id",
1671
+ "carried_over": true,
1672
+ "question": "berapa banyak record untuk model 777D?",
1673
+ "passed": true,
1674
+ "checks": [
1675
+ {
1676
+ "check": "select_agg=count",
1677
+ "ok": true,
1678
+ "detail": "aggs=['count']"
1679
+ },
1680
+ {
1681
+ "check": "has_filter",
1682
+ "ok": true,
1683
+ "detail": "filter_present=True"
1684
+ }
1685
+ ],
1686
+ "facts": {
1687
+ "tools": [
1688
+ "retrieve_data"
1689
+ ],
1690
+ "irs": [
1691
+ {
1692
+ "source_id": "src_pa",
1693
+ "table_id": "t_pa",
1694
+ "select": [
1695
+ {
1696
+ "kind": "agg",
1697
+ "fn": "count",
1698
+ "alias": "record_count"
1699
+ }
1700
+ ],
1701
+ "filters": [
1702
+ {
1703
+ "column_id": "c_model_unit",
1704
+ "op": "=",
1705
+ "value": "777D",
1706
+ "value_type": "string"
1707
+ }
1708
+ ]
1709
+ }
1710
+ ],
1711
+ "agg_args": [],
1712
+ "infeasible": false
1713
+ },
1714
+ "error": null,
1715
+ "latency_ms": 2035,
1716
+ "tokens": 14580
1717
+ },
1718
+ {
1719
+ "id": "exact_section_loader_en",
1720
+ "category": "counter_exact_filter",
1721
+ "lang": "en",
1722
+ "carried_over": true,
1723
+ "question": "how many records are in the OB LOADER section?",
1724
+ "passed": true,
1725
+ "checks": [
1726
+ {
1727
+ "check": "select_agg=count",
1728
+ "ok": true,
1729
+ "detail": "aggs=['count']"
1730
+ },
1731
+ {
1732
+ "check": "has_filter",
1733
+ "ok": true,
1734
+ "detail": "filter_present=True"
1735
+ }
1736
+ ],
1737
+ "facts": {
1738
+ "tools": [
1739
+ "retrieve_data"
1740
+ ],
1741
+ "irs": [
1742
+ {
1743
+ "source_id": "src_pa",
1744
+ "table_id": "t_pa",
1745
+ "select": [
1746
+ {
1747
+ "kind": "agg",
1748
+ "fn": "count",
1749
+ "alias": "record_count"
1750
+ }
1751
+ ],
1752
+ "filters": [
1753
+ {
1754
+ "column_id": "c_section",
1755
+ "op": "=",
1756
+ "value": "OB LOADER",
1757
+ "value_type": "string"
1758
+ }
1759
+ ]
1760
+ }
1761
+ ],
1762
+ "agg_args": [],
1763
+ "infeasible": false
1764
+ },
1765
+ "error": null,
1766
+ "latency_ms": 2133,
1767
+ "tokens": 14570
1768
+ },
1769
+ {
1770
+ "id": "infeasible_churn_id",
1771
+ "category": "infeasible",
1772
+ "lang": "id",
1773
+ "carried_over": true,
1774
+ "question": "berapa churn rate pelanggan?",
1775
+ "passed": true,
1776
+ "checks": [
1777
+ {
1778
+ "check": "infeasible",
1779
+ "ok": true,
1780
+ "detail": "infeasible=True"
1781
+ }
1782
+ ],
1783
+ "facts": {
1784
+ "tools": [],
1785
+ "irs": [],
1786
+ "agg_args": [],
1787
+ "infeasible": true
1788
+ },
1789
+ "error": null,
1790
+ "latency_ms": 2586,
1791
+ "tokens": 14512
1792
+ },
1793
+ {
1794
+ "id": "infeasible_profit_en",
1795
+ "category": "infeasible",
1796
+ "lang": "en",
1797
+ "carried_over": true,
1798
+ "question": "what is the monthly profit margin?",
1799
+ "passed": true,
1800
+ "checks": [
1801
+ {
1802
+ "check": "infeasible",
1803
+ "ok": true,
1804
+ "detail": "infeasible=True"
1805
+ }
1806
+ ],
1807
+ "facts": {
1808
+ "tools": [],
1809
+ "irs": [],
1810
+ "agg_args": [],
1811
+ "infeasible": true
1812
+ },
1813
+ "error": null,
1814
+ "latency_ms": 2206,
1815
+ "tokens": 14551
1816
+ }
1817
+ ]
1818
+ }
eval/planner/results/planner_result_2026-07-23_152650.json ADDED
@@ -0,0 +1,2123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "timestamp": "2026-07-23_152650",
3
+ "total": 31,
4
+ "passed": 30,
5
+ "cases": [
6
+ {
7
+ "id": "count_zero_pa",
8
+ "category": "count",
9
+ "lang": "en",
10
+ "carried_over": true,
11
+ "question": "how many records have PA_Percent = 0?",
12
+ "passed": true,
13
+ "checks": [
14
+ {
15
+ "check": "select_agg=count",
16
+ "ok": true,
17
+ "detail": "aggs=['count']"
18
+ },
19
+ {
20
+ "check": "has_filter",
21
+ "ok": true,
22
+ "detail": "filter_present=True"
23
+ },
24
+ {
25
+ "check": "no_group_by",
26
+ "ok": true,
27
+ "detail": "grouped=False"
28
+ }
29
+ ],
30
+ "facts": {
31
+ "tools": [
32
+ "retrieve_data"
33
+ ],
34
+ "irs": [
35
+ {
36
+ "source_id": "src_pa",
37
+ "table_id": "t_pa",
38
+ "select": [
39
+ {
40
+ "kind": "agg",
41
+ "fn": "count",
42
+ "alias": "record_count"
43
+ }
44
+ ],
45
+ "filters": [
46
+ {
47
+ "column_id": "c_pa_percent",
48
+ "op": "=",
49
+ "value": 0,
50
+ "value_type": "decimal"
51
+ }
52
+ ]
53
+ }
54
+ ],
55
+ "agg_args": [],
56
+ "infeasible": false
57
+ },
58
+ "error": null,
59
+ "latency_ms": 4042,
60
+ "tokens": 14569
61
+ },
62
+ {
63
+ "id": "count_mttr_gt20_id",
64
+ "category": "count",
65
+ "lang": "id",
66
+ "carried_over": true,
67
+ "question": "berapa banyak record dengan MTTR di atas 20?",
68
+ "passed": true,
69
+ "checks": [
70
+ {
71
+ "check": "select_agg=count",
72
+ "ok": true,
73
+ "detail": "aggs=['count']"
74
+ },
75
+ {
76
+ "check": "has_filter",
77
+ "ok": true,
78
+ "detail": "filter_present=True"
79
+ }
80
+ ],
81
+ "facts": {
82
+ "tools": [
83
+ "retrieve_data"
84
+ ],
85
+ "irs": [
86
+ {
87
+ "source_id": "src_pa",
88
+ "table_id": "t_pa",
89
+ "select": [
90
+ {
91
+ "kind": "agg",
92
+ "fn": "count",
93
+ "alias": "record_count"
94
+ }
95
+ ],
96
+ "filters": [
97
+ {
98
+ "column_id": "c_mttr",
99
+ "op": ">",
100
+ "value": 20,
101
+ "value_type": "decimal"
102
+ }
103
+ ]
104
+ }
105
+ ],
106
+ "agg_args": [],
107
+ "infeasible": false
108
+ },
109
+ "error": null,
110
+ "latency_ms": 2495,
111
+ "tokens": 14571
112
+ },
113
+ {
114
+ "id": "count_section_hauler",
115
+ "category": "count",
116
+ "lang": "en",
117
+ "carried_over": true,
118
+ "question": "how many rows are in section OB HAULER?",
119
+ "passed": true,
120
+ "checks": [
121
+ {
122
+ "check": "select_agg=count",
123
+ "ok": true,
124
+ "detail": "aggs=['count']"
125
+ },
126
+ {
127
+ "check": "has_filter",
128
+ "ok": true,
129
+ "detail": "filter_present=True"
130
+ }
131
+ ],
132
+ "facts": {
133
+ "tools": [
134
+ "retrieve_data"
135
+ ],
136
+ "irs": [
137
+ {
138
+ "source_id": "src_pa",
139
+ "table_id": "t_pa",
140
+ "select": [
141
+ {
142
+ "kind": "agg",
143
+ "fn": "count",
144
+ "alias": "row_count"
145
+ }
146
+ ],
147
+ "filters": [
148
+ {
149
+ "column_id": "c_section",
150
+ "op": "=",
151
+ "value": "OB HAULER",
152
+ "value_type": "string"
153
+ }
154
+ ]
155
+ }
156
+ ],
157
+ "agg_args": [],
158
+ "infeasible": false
159
+ },
160
+ "error": null,
161
+ "latency_ms": 2162,
162
+ "tokens": 14574
163
+ },
164
+ {
165
+ "id": "rank_units_worst_pa_id",
166
+ "category": "ranking",
167
+ "lang": "id",
168
+ "carried_over": false,
169
+ "question": "5 unit dengan PA terburuk?",
170
+ "passed": true,
171
+ "checks": [
172
+ {
173
+ "check": "group_by",
174
+ "ok": true,
175
+ "detail": "grouped=True"
176
+ },
177
+ {
178
+ "check": "group_by_col=Equipment_Number",
179
+ "ok": true,
180
+ "detail": "ids=['c_equipment_number'] aliases=[] resolved=['c_equipment_number']"
181
+ },
182
+ {
183
+ "check": "select_agg=avg",
184
+ "ok": true,
185
+ "detail": "aggs=['avg']"
186
+ },
187
+ {
188
+ "check": "order_dir=asc",
189
+ "ok": true,
190
+ "detail": "dirs=['asc']"
191
+ },
192
+ {
193
+ "check": "limit=5",
194
+ "ok": true,
195
+ "detail": "limits=[5]"
196
+ }
197
+ ],
198
+ "facts": {
199
+ "tools": [
200
+ "check_data",
201
+ "retrieve_data"
202
+ ],
203
+ "irs": [
204
+ {
205
+ "source_id": "src_pa",
206
+ "table_id": "t_pa",
207
+ "select": [
208
+ {
209
+ "kind": "column",
210
+ "column_id": "c_equipment_number",
211
+ "alias": "equipment_number"
212
+ },
213
+ {
214
+ "kind": "agg",
215
+ "fn": "avg",
216
+ "column_id": "c_pa_percent",
217
+ "alias": "avg_pa_percent"
218
+ }
219
+ ],
220
+ "group_by": [
221
+ "c_equipment_number"
222
+ ],
223
+ "order_by": [
224
+ {
225
+ "column_id": "avg_pa_percent",
226
+ "dir": "asc"
227
+ }
228
+ ],
229
+ "limit": 5
230
+ }
231
+ ],
232
+ "agg_args": [],
233
+ "infeasible": false
234
+ },
235
+ "error": null,
236
+ "latency_ms": 3918,
237
+ "tokens": 14745
238
+ },
239
+ {
240
+ "id": "rank_models_top_mttr_id",
241
+ "category": "ranking",
242
+ "lang": "id",
243
+ "carried_over": true,
244
+ "question": "top 3 model dengan MTTR tertinggi?",
245
+ "passed": true,
246
+ "checks": [
247
+ {
248
+ "check": "group_by",
249
+ "ok": true,
250
+ "detail": "grouped=True"
251
+ },
252
+ {
253
+ "check": "group_by_col=Model_Unit",
254
+ "ok": true,
255
+ "detail": "ids=['c_model_unit'] aliases=[] resolved=['c_model_unit']"
256
+ },
257
+ {
258
+ "check": "select_agg=avg",
259
+ "ok": true,
260
+ "detail": "aggs=['avg']"
261
+ },
262
+ {
263
+ "check": "order_dir=desc",
264
+ "ok": true,
265
+ "detail": "dirs=['desc']"
266
+ },
267
+ {
268
+ "check": "limit=3",
269
+ "ok": true,
270
+ "detail": "limits=[3]"
271
+ }
272
+ ],
273
+ "facts": {
274
+ "tools": [
275
+ "check_data",
276
+ "retrieve_data"
277
+ ],
278
+ "irs": [
279
+ {
280
+ "source_id": "src_pa",
281
+ "table_id": "t_pa",
282
+ "select": [
283
+ {
284
+ "kind": "column",
285
+ "column_id": "c_model_unit",
286
+ "alias": "model"
287
+ },
288
+ {
289
+ "kind": "agg",
290
+ "fn": "avg",
291
+ "column_id": "c_mttr",
292
+ "alias": "avg_mttr"
293
+ }
294
+ ],
295
+ "group_by": [
296
+ "c_model_unit"
297
+ ],
298
+ "order_by": [
299
+ {
300
+ "column_id": "avg_mttr",
301
+ "dir": "desc"
302
+ }
303
+ ],
304
+ "limit": 3
305
+ }
306
+ ],
307
+ "agg_args": [],
308
+ "infeasible": false
309
+ },
310
+ "error": null,
311
+ "latency_ms": 2820,
312
+ "tokens": 14719
313
+ },
314
+ {
315
+ "id": "rank_sections_lowest_pa_en",
316
+ "category": "ranking",
317
+ "lang": "en",
318
+ "carried_over": true,
319
+ "question": "which section has the lowest average PA?",
320
+ "passed": true,
321
+ "checks": [
322
+ {
323
+ "check": "group_by",
324
+ "ok": true,
325
+ "detail": "grouped=True"
326
+ },
327
+ {
328
+ "check": "group_by_col=Section",
329
+ "ok": true,
330
+ "detail": "ids=[] aliases=['section'] resolved=['c_section']"
331
+ },
332
+ {
333
+ "check": "select_agg=avg",
334
+ "ok": true,
335
+ "detail": "aggs=['mean']"
336
+ }
337
+ ],
338
+ "facts": {
339
+ "tools": [
340
+ "analyze_aggregate",
341
+ "check_data",
342
+ "retrieve_data"
343
+ ],
344
+ "irs": [
345
+ {
346
+ "source_id": "src_pa",
347
+ "table_id": "t_pa",
348
+ "select": [
349
+ {
350
+ "kind": "column",
351
+ "column_id": "c_section",
352
+ "alias": "section"
353
+ },
354
+ {
355
+ "kind": "column",
356
+ "column_id": "c_pa_percent",
357
+ "alias": "pa_percent"
358
+ }
359
+ ],
360
+ "filters": [
361
+ {
362
+ "column_id": "c_month_id",
363
+ "op": "=",
364
+ "value": 202604,
365
+ "value_type": "int"
366
+ },
367
+ {
368
+ "column_id": "c_isdeleted",
369
+ "op": "=",
370
+ "value": 0,
371
+ "value_type": "int"
372
+ }
373
+ ],
374
+ "limit": 10000
375
+ }
376
+ ],
377
+ "agg_args": [
378
+ {
379
+ "data": "${t2}",
380
+ "aggregations": {
381
+ "pa_percent": [
382
+ "mean"
383
+ ]
384
+ },
385
+ "group_by": [
386
+ "section"
387
+ ]
388
+ }
389
+ ],
390
+ "infeasible": false
391
+ },
392
+ "error": null,
393
+ "latency_ms": 3103,
394
+ "tokens": 14818
395
+ },
396
+ {
397
+ "id": "rank_units_most_breakdown_id",
398
+ "category": "ranking",
399
+ "lang": "id",
400
+ "carried_over": false,
401
+ "question": "unit mana yang paling sering breakdown?",
402
+ "passed": true,
403
+ "checks": [
404
+ {
405
+ "check": "group_by",
406
+ "ok": true,
407
+ "detail": "grouped=True"
408
+ },
409
+ {
410
+ "check": "group_by_col=Equipment_Number",
411
+ "ok": true,
412
+ "detail": "ids=['c_equipment_number'] aliases=[] resolved=['c_equipment_number']"
413
+ },
414
+ {
415
+ "check": "order_dir=desc",
416
+ "ok": true,
417
+ "detail": "dirs=['desc']"
418
+ }
419
+ ],
420
+ "facts": {
421
+ "tools": [
422
+ "check_data",
423
+ "retrieve_data"
424
+ ],
425
+ "irs": [
426
+ {
427
+ "source_id": "src_pa",
428
+ "table_id": "t_pa",
429
+ "select": [
430
+ {
431
+ "kind": "column",
432
+ "column_id": "c_equipment_number",
433
+ "alias": "equipment_number"
434
+ },
435
+ {
436
+ "kind": "agg",
437
+ "fn": "sum",
438
+ "column_id": "c_total_frequency_down",
439
+ "alias": "total_breakdown_frequency"
440
+ }
441
+ ],
442
+ "filters": [
443
+ {
444
+ "column_id": "c_month_id",
445
+ "op": "=",
446
+ "value": 202604,
447
+ "value_type": "int"
448
+ },
449
+ {
450
+ "column_id": "c_isdeleted",
451
+ "op": "=",
452
+ "value": 0,
453
+ "value_type": "int"
454
+ }
455
+ ],
456
+ "group_by": [
457
+ "c_equipment_number"
458
+ ],
459
+ "order_by": [
460
+ {
461
+ "column_id": "total_breakdown_frequency",
462
+ "dir": "desc"
463
+ }
464
+ ],
465
+ "limit": 1
466
+ }
467
+ ],
468
+ "agg_args": [],
469
+ "infeasible": false
470
+ },
471
+ "error": null,
472
+ "latency_ms": 2902,
473
+ "tokens": 14761
474
+ },
475
+ {
476
+ "id": "rank_units_worst_pa_en",
477
+ "category": "ranking",
478
+ "lang": "en",
479
+ "carried_over": false,
480
+ "question": "list the 10 worst units by availability",
481
+ "passed": true,
482
+ "checks": [
483
+ {
484
+ "check": "group_by",
485
+ "ok": true,
486
+ "detail": "grouped=True"
487
+ },
488
+ {
489
+ "check": "group_by_col=Equipment_Number",
490
+ "ok": true,
491
+ "detail": "ids=['c_equipment_number'] aliases=[] resolved=['c_equipment_number']"
492
+ },
493
+ {
494
+ "check": "select_agg=avg",
495
+ "ok": true,
496
+ "detail": "aggs=['avg']"
497
+ },
498
+ {
499
+ "check": "order_dir=asc",
500
+ "ok": true,
501
+ "detail": "dirs=['asc']"
502
+ },
503
+ {
504
+ "check": "limit=10",
505
+ "ok": true,
506
+ "detail": "limits=[10]"
507
+ }
508
+ ],
509
+ "facts": {
510
+ "tools": [
511
+ "check_data",
512
+ "retrieve_data"
513
+ ],
514
+ "irs": [
515
+ {
516
+ "source_id": "src_pa",
517
+ "table_id": "t_pa",
518
+ "select": [
519
+ {
520
+ "kind": "column",
521
+ "column_id": "c_equipment_number",
522
+ "alias": "equipment_number"
523
+ },
524
+ {
525
+ "kind": "agg",
526
+ "fn": "avg",
527
+ "column_id": "c_pa_percent",
528
+ "alias": "avg_pa_percent"
529
+ }
530
+ ],
531
+ "filters": [
532
+ {
533
+ "column_id": "c_isdeleted",
534
+ "op": "=",
535
+ "value": 0,
536
+ "value_type": "int"
537
+ },
538
+ {
539
+ "column_id": "c_month_id",
540
+ "op": "=",
541
+ "value": 202604,
542
+ "value_type": "int"
543
+ }
544
+ ],
545
+ "group_by": [
546
+ "c_equipment_number"
547
+ ],
548
+ "order_by": [
549
+ {
550
+ "column_id": "avg_pa_percent",
551
+ "dir": "asc"
552
+ }
553
+ ],
554
+ "limit": 10
555
+ }
556
+ ],
557
+ "agg_args": [],
558
+ "infeasible": false
559
+ },
560
+ "error": null,
561
+ "latency_ms": 3112,
562
+ "tokens": 14762
563
+ },
564
+ {
565
+ "id": "fuzzy_model_777_id",
566
+ "category": "fuzzy_filter",
567
+ "lang": "id",
568
+ "carried_over": false,
569
+ "question": "berapa banyak model 777?",
570
+ "passed": true,
571
+ "checks": [
572
+ {
573
+ "check": "select_agg=count",
574
+ "ok": true,
575
+ "detail": "aggs=['count']"
576
+ },
577
+ {
578
+ "check": "no_filter_op=in",
579
+ "ok": true,
580
+ "detail": "ops=['=']"
581
+ }
582
+ ],
583
+ "facts": {
584
+ "tools": [
585
+ "retrieve_data"
586
+ ],
587
+ "irs": [
588
+ {
589
+ "source_id": "src_pa",
590
+ "table_id": "t_pa",
591
+ "select": [
592
+ {
593
+ "kind": "agg",
594
+ "fn": "count",
595
+ "alias": "record_count"
596
+ }
597
+ ],
598
+ "filters": [
599
+ {
600
+ "column_id": "c_model_unit",
601
+ "op": "=",
602
+ "value": "777",
603
+ "value_type": "string"
604
+ }
605
+ ],
606
+ "limit": 10000
607
+ }
608
+ ],
609
+ "agg_args": [],
610
+ "infeasible": false
611
+ },
612
+ "error": null,
613
+ "latency_ms": 13617,
614
+ "tokens": 14582
615
+ },
616
+ {
617
+ "id": "fuzzy_model_hd785_id",
618
+ "category": "fuzzy_filter",
619
+ "lang": "id",
620
+ "carried_over": true,
621
+ "question": "berapa banyak unit HD785?",
622
+ "passed": true,
623
+ "checks": [
624
+ {
625
+ "check": "select_agg=count",
626
+ "ok": true,
627
+ "detail": "aggs=['count']"
628
+ },
629
+ {
630
+ "check": "no_filter_op=in",
631
+ "ok": true,
632
+ "detail": "ops=['=']"
633
+ }
634
+ ],
635
+ "facts": {
636
+ "tools": [
637
+ "check_data",
638
+ "retrieve_data"
639
+ ],
640
+ "irs": [
641
+ {
642
+ "source_id": "src_pa",
643
+ "table_id": "t_pa",
644
+ "select": [
645
+ {
646
+ "kind": "agg",
647
+ "fn": "count",
648
+ "alias": "unit_count"
649
+ }
650
+ ],
651
+ "filters": [
652
+ {
653
+ "column_id": "c_model_unit",
654
+ "op": "=",
655
+ "value": "HD785",
656
+ "value_type": "string"
657
+ }
658
+ ]
659
+ }
660
+ ],
661
+ "agg_args": [],
662
+ "infeasible": false
663
+ },
664
+ "error": null,
665
+ "latency_ms": 3472,
666
+ "tokens": 14660
667
+ },
668
+ {
669
+ "id": "fuzzy_model_ex_en",
670
+ "category": "fuzzy_filter",
671
+ "lang": "en",
672
+ "carried_over": false,
673
+ "question": "how many EX excavator units are there?",
674
+ "passed": true,
675
+ "checks": [
676
+ {
677
+ "check": "no_filter_op=in",
678
+ "ok": true,
679
+ "detail": "ops=['like']"
680
+ }
681
+ ],
682
+ "facts": {
683
+ "tools": [
684
+ "retrieve_data"
685
+ ],
686
+ "irs": [
687
+ {
688
+ "source_id": "src_pa",
689
+ "table_id": "t_pa",
690
+ "select": [
691
+ {
692
+ "kind": "agg",
693
+ "fn": "count",
694
+ "alias": "unit_count"
695
+ }
696
+ ],
697
+ "filters": [
698
+ {
699
+ "column_id": "c_model_unit",
700
+ "op": "like",
701
+ "value": "EX%",
702
+ "value_type": "string"
703
+ }
704
+ ],
705
+ "limit": 10000
706
+ }
707
+ ],
708
+ "agg_args": [],
709
+ "infeasible": false
710
+ },
711
+ "error": null,
712
+ "latency_ms": 5809,
713
+ "tokens": 29353
714
+ },
715
+ {
716
+ "id": "agg_pa_per_section_id",
717
+ "category": "aggregate",
718
+ "lang": "id",
719
+ "carried_over": true,
720
+ "question": "berapa rata-rata PA per section?",
721
+ "passed": true,
722
+ "checks": [
723
+ {
724
+ "check": "group_by",
725
+ "ok": true,
726
+ "detail": "grouped=True"
727
+ },
728
+ {
729
+ "check": "group_by_col=Section",
730
+ "ok": true,
731
+ "detail": "ids=[] aliases=['section'] resolved=['c_section']"
732
+ },
733
+ {
734
+ "check": "select_agg=avg",
735
+ "ok": true,
736
+ "detail": "aggs=['mean']"
737
+ }
738
+ ],
739
+ "facts": {
740
+ "tools": [
741
+ "analyze_aggregate",
742
+ "check_data",
743
+ "retrieve_data"
744
+ ],
745
+ "irs": [
746
+ {
747
+ "source_id": "src_pa",
748
+ "table_id": "t_pa",
749
+ "select": [
750
+ {
751
+ "kind": "column",
752
+ "column_id": "c_section",
753
+ "alias": "section"
754
+ },
755
+ {
756
+ "kind": "column",
757
+ "column_id": "c_pa_percent",
758
+ "alias": "pa_percent"
759
+ }
760
+ ],
761
+ "filters": [
762
+ {
763
+ "column_id": "c_isdeleted",
764
+ "op": "=",
765
+ "value": 0,
766
+ "value_type": "int"
767
+ }
768
+ ],
769
+ "limit": 10000
770
+ }
771
+ ],
772
+ "agg_args": [
773
+ {
774
+ "data": "${t2}",
775
+ "aggregations": {
776
+ "pa_percent": [
777
+ "mean"
778
+ ]
779
+ },
780
+ "group_by": [
781
+ "section"
782
+ ]
783
+ }
784
+ ],
785
+ "infeasible": false
786
+ },
787
+ "error": null,
788
+ "latency_ms": 3942,
789
+ "tokens": 14785
790
+ },
791
+ {
792
+ "id": "agg_mttr_per_model_en",
793
+ "category": "aggregate",
794
+ "lang": "en",
795
+ "carried_over": true,
796
+ "question": "what is the average MTTR per model unit?",
797
+ "passed": true,
798
+ "checks": [
799
+ {
800
+ "check": "group_by",
801
+ "ok": true,
802
+ "detail": "grouped=True"
803
+ },
804
+ {
805
+ "check": "group_by_col=Model_Unit",
806
+ "ok": true,
807
+ "detail": "ids=[] aliases=['model_unit'] resolved=['c_model_unit']"
808
+ },
809
+ {
810
+ "check": "select_agg=avg",
811
+ "ok": true,
812
+ "detail": "aggs=['mean']"
813
+ }
814
+ ],
815
+ "facts": {
816
+ "tools": [
817
+ "analyze_aggregate",
818
+ "check_data",
819
+ "retrieve_data"
820
+ ],
821
+ "irs": [
822
+ {
823
+ "source_id": "src_pa",
824
+ "table_id": "t_pa",
825
+ "select": [
826
+ {
827
+ "kind": "column",
828
+ "column_id": "c_model_unit",
829
+ "alias": "model_unit"
830
+ },
831
+ {
832
+ "kind": "column",
833
+ "column_id": "c_mttr",
834
+ "alias": "mttr"
835
+ }
836
+ ],
837
+ "limit": 10000
838
+ }
839
+ ],
840
+ "agg_args": [
841
+ {
842
+ "data": "${t2}",
843
+ "aggregations": {
844
+ "mttr": [
845
+ "mean"
846
+ ]
847
+ },
848
+ "group_by": [
849
+ "model_unit"
850
+ ]
851
+ }
852
+ ],
853
+ "infeasible": false
854
+ },
855
+ "error": null,
856
+ "latency_ms": 3648,
857
+ "tokens": 14781
858
+ },
859
+ {
860
+ "id": "agg_downhour_per_group_id",
861
+ "category": "aggregate",
862
+ "lang": "id",
863
+ "carried_over": true,
864
+ "question": "total down hour per equipment group?",
865
+ "passed": true,
866
+ "checks": [
867
+ {
868
+ "check": "group_by",
869
+ "ok": true,
870
+ "detail": "grouped=True"
871
+ },
872
+ {
873
+ "check": "group_by_col=Equipment_Group_ID",
874
+ "ok": true,
875
+ "detail": "ids=[] aliases=['equipment_group'] resolved=['c_equipment_group_id']"
876
+ },
877
+ {
878
+ "check": "select_agg=sum",
879
+ "ok": true,
880
+ "detail": "aggs=['sum']"
881
+ }
882
+ ],
883
+ "facts": {
884
+ "tools": [
885
+ "analyze_aggregate",
886
+ "check_data",
887
+ "retrieve_data"
888
+ ],
889
+ "irs": [
890
+ {
891
+ "source_id": "src_pa",
892
+ "table_id": "t_pa",
893
+ "select": [
894
+ {
895
+ "kind": "column",
896
+ "column_id": "c_equipment_group_id",
897
+ "alias": "equipment_group"
898
+ },
899
+ {
900
+ "kind": "column",
901
+ "column_id": "c_total_down_hour",
902
+ "alias": "down_hour"
903
+ }
904
+ ],
905
+ "filters": [
906
+ {
907
+ "column_id": "c_month_id",
908
+ "op": "=",
909
+ "value": 202604,
910
+ "value_type": "int"
911
+ },
912
+ {
913
+ "column_id": "c_isdeleted",
914
+ "op": "=",
915
+ "value": 0,
916
+ "value_type": "int"
917
+ }
918
+ ],
919
+ "limit": 10000
920
+ }
921
+ ],
922
+ "agg_args": [
923
+ {
924
+ "data": "${t2}",
925
+ "aggregations": {
926
+ "down_hour": [
927
+ "sum"
928
+ ]
929
+ },
930
+ "group_by": [
931
+ "equipment_group"
932
+ ]
933
+ }
934
+ ],
935
+ "infeasible": false
936
+ },
937
+ "error": null,
938
+ "latency_ms": 5057,
939
+ "tokens": 14830
940
+ },
941
+ {
942
+ "id": "desc_mttr_stats_id",
943
+ "category": "descriptive",
944
+ "lang": "id",
945
+ "carried_over": true,
946
+ "question": "berikan ringkasan statistik MTTR",
947
+ "passed": true,
948
+ "checks": [
949
+ {
950
+ "check": "has_tool=analyze_descriptive",
951
+ "ok": true,
952
+ "detail": "tools=['analyze_descriptive', 'check_data', 'retrieve_data']"
953
+ }
954
+ ],
955
+ "facts": {
956
+ "tools": [
957
+ "analyze_descriptive",
958
+ "check_data",
959
+ "retrieve_data"
960
+ ],
961
+ "irs": [
962
+ {
963
+ "source_id": "src_pa",
964
+ "table_id": "t_pa",
965
+ "select": [
966
+ {
967
+ "kind": "column",
968
+ "column_id": "c_mttr",
969
+ "alias": "mttr"
970
+ }
971
+ ],
972
+ "limit": 10000
973
+ }
974
+ ],
975
+ "agg_args": [],
976
+ "infeasible": false
977
+ },
978
+ "error": null,
979
+ "latency_ms": 3173,
980
+ "tokens": 14707
981
+ },
982
+ {
983
+ "id": "desc_pa_stats_en",
984
+ "category": "descriptive",
985
+ "lang": "en",
986
+ "carried_over": true,
987
+ "question": "give me the summary statistics for PA_Percent",
988
+ "passed": true,
989
+ "checks": [
990
+ {
991
+ "check": "has_tool=analyze_descriptive",
992
+ "ok": true,
993
+ "detail": "tools=['analyze_descriptive', 'check_data', 'retrieve_data']"
994
+ }
995
+ ],
996
+ "facts": {
997
+ "tools": [
998
+ "analyze_descriptive",
999
+ "check_data",
1000
+ "retrieve_data"
1001
+ ],
1002
+ "irs": [
1003
+ {
1004
+ "source_id": "src_pa",
1005
+ "table_id": "t_pa",
1006
+ "select": [
1007
+ {
1008
+ "kind": "column",
1009
+ "column_id": "c_pa_percent",
1010
+ "alias": "PA_Percent"
1011
+ }
1012
+ ],
1013
+ "limit": 10000
1014
+ }
1015
+ ],
1016
+ "agg_args": [],
1017
+ "infeasible": false
1018
+ },
1019
+ "error": null,
1020
+ "latency_ms": 3135,
1021
+ "tokens": 14717
1022
+ },
1023
+ {
1024
+ "id": "corr_mttr_pa_id",
1025
+ "category": "correlation",
1026
+ "lang": "id",
1027
+ "carried_over": true,
1028
+ "question": "apakah ada korelasi antara MTTR dan PA?",
1029
+ "passed": true,
1030
+ "checks": [
1031
+ {
1032
+ "check": "has_tool=analyze_correlation",
1033
+ "ok": true,
1034
+ "detail": "tools=['analyze_correlation', 'check_data', 'retrieve_data']"
1035
+ }
1036
+ ],
1037
+ "facts": {
1038
+ "tools": [
1039
+ "analyze_correlation",
1040
+ "check_data",
1041
+ "retrieve_data"
1042
+ ],
1043
+ "irs": [
1044
+ {
1045
+ "source_id": "src_pa",
1046
+ "table_id": "t_pa",
1047
+ "select": [
1048
+ {
1049
+ "kind": "column",
1050
+ "column_id": "c_mttr",
1051
+ "alias": "mttr"
1052
+ },
1053
+ {
1054
+ "kind": "column",
1055
+ "column_id": "c_pa_percent",
1056
+ "alias": "pa_percent"
1057
+ }
1058
+ ],
1059
+ "filters": [
1060
+ {
1061
+ "column_id": "c_month_id",
1062
+ "op": "=",
1063
+ "value": 202604,
1064
+ "value_type": "int"
1065
+ },
1066
+ {
1067
+ "column_id": "c_isdeleted",
1068
+ "op": "=",
1069
+ "value": 0,
1070
+ "value_type": "int"
1071
+ }
1072
+ ],
1073
+ "limit": 10000
1074
+ }
1075
+ ],
1076
+ "agg_args": [],
1077
+ "infeasible": false
1078
+ },
1079
+ "error": null,
1080
+ "latency_ms": 3920,
1081
+ "tokens": 14798
1082
+ },
1083
+ {
1084
+ "id": "corr_freq_pa_en",
1085
+ "category": "correlation",
1086
+ "lang": "en",
1087
+ "carried_over": true,
1088
+ "question": "is breakdown frequency correlated with availability?",
1089
+ "passed": true,
1090
+ "checks": [
1091
+ {
1092
+ "check": "has_tool=analyze_correlation",
1093
+ "ok": true,
1094
+ "detail": "tools=['analyze_correlation', 'check_data', 'retrieve_data']"
1095
+ }
1096
+ ],
1097
+ "facts": {
1098
+ "tools": [
1099
+ "analyze_correlation",
1100
+ "check_data",
1101
+ "retrieve_data"
1102
+ ],
1103
+ "irs": [
1104
+ {
1105
+ "source_id": "src_pa",
1106
+ "table_id": "t_pa",
1107
+ "select": [
1108
+ {
1109
+ "kind": "column",
1110
+ "column_id": "c_total_frequency_down",
1111
+ "alias": "breakdown_frequency"
1112
+ },
1113
+ {
1114
+ "kind": "column",
1115
+ "column_id": "c_pa_percent",
1116
+ "alias": "availability"
1117
+ }
1118
+ ],
1119
+ "filters": [
1120
+ {
1121
+ "column_id": "c_month_id",
1122
+ "op": "=",
1123
+ "value": 202604,
1124
+ "value_type": "int"
1125
+ },
1126
+ {
1127
+ "column_id": "c_isdeleted",
1128
+ "op": "=",
1129
+ "value": 0,
1130
+ "value_type": "int"
1131
+ }
1132
+ ],
1133
+ "limit": 10000
1134
+ }
1135
+ ],
1136
+ "agg_args": [],
1137
+ "infeasible": false
1138
+ },
1139
+ "error": null,
1140
+ "latency_ms": 3774,
1141
+ "tokens": 14845
1142
+ },
1143
+ {
1144
+ "id": "trend_pa_daily_id",
1145
+ "category": "trend",
1146
+ "lang": "id",
1147
+ "carried_over": true,
1148
+ "question": "bagaimana trend PA harian?",
1149
+ "passed": true,
1150
+ "checks": [
1151
+ {
1152
+ "check": "has_tool=analyze_trend",
1153
+ "ok": true,
1154
+ "detail": "tools=['analyze_trend', 'check_data', 'retrieve_data']"
1155
+ }
1156
+ ],
1157
+ "facts": {
1158
+ "tools": [
1159
+ "analyze_trend",
1160
+ "check_data",
1161
+ "retrieve_data"
1162
+ ],
1163
+ "irs": [
1164
+ {
1165
+ "source_id": "src_pa",
1166
+ "table_id": "t_pa",
1167
+ "select": [
1168
+ {
1169
+ "kind": "column",
1170
+ "column_id": "c_from_date",
1171
+ "alias": "date"
1172
+ },
1173
+ {
1174
+ "kind": "column",
1175
+ "column_id": "c_pa_percent",
1176
+ "alias": "pa_percent"
1177
+ }
1178
+ ],
1179
+ "filters": [
1180
+ {
1181
+ "column_id": "c_month_id",
1182
+ "op": "=",
1183
+ "value": 202604,
1184
+ "value_type": "int"
1185
+ },
1186
+ {
1187
+ "column_id": "c_isdeleted",
1188
+ "op": "=",
1189
+ "value": 0,
1190
+ "value_type": "int"
1191
+ }
1192
+ ],
1193
+ "limit": 10000
1194
+ }
1195
+ ],
1196
+ "agg_args": [],
1197
+ "infeasible": false
1198
+ },
1199
+ "error": null,
1200
+ "latency_ms": 4485,
1201
+ "tokens": 14852
1202
+ },
1203
+ {
1204
+ "id": "trend_downhour_en",
1205
+ "category": "trend",
1206
+ "lang": "en",
1207
+ "carried_over": true,
1208
+ "question": "show the trend of total down hours over time",
1209
+ "passed": true,
1210
+ "checks": [
1211
+ {
1212
+ "check": "has_tool=analyze_trend",
1213
+ "ok": true,
1214
+ "detail": "tools=['analyze_trend', 'check_data', 'retrieve_data']"
1215
+ }
1216
+ ],
1217
+ "facts": {
1218
+ "tools": [
1219
+ "analyze_trend",
1220
+ "check_data",
1221
+ "retrieve_data"
1222
+ ],
1223
+ "irs": [
1224
+ {
1225
+ "source_id": "src_pa",
1226
+ "table_id": "t_pa",
1227
+ "select": [
1228
+ {
1229
+ "kind": "column",
1230
+ "column_id": "c_from_date",
1231
+ "alias": "from_date"
1232
+ },
1233
+ {
1234
+ "kind": "column",
1235
+ "column_id": "c_total_down_hour",
1236
+ "alias": "total_down_hour"
1237
+ }
1238
+ ],
1239
+ "filters": [
1240
+ {
1241
+ "column_id": "c_month_id",
1242
+ "op": "=",
1243
+ "value": 202604,
1244
+ "value_type": "int"
1245
+ }
1246
+ ],
1247
+ "limit": 10000
1248
+ }
1249
+ ],
1250
+ "agg_args": [],
1251
+ "infeasible": false
1252
+ },
1253
+ "error": null,
1254
+ "latency_ms": 3686,
1255
+ "tokens": 14832
1256
+ },
1257
+ {
1258
+ "id": "merge_worst_pa_and_mttr_id",
1259
+ "category": "merge",
1260
+ "lang": "id",
1261
+ "carried_over": true,
1262
+ "question": "model mana yang PA-nya paling buruk sekaligus MTTR-nya paling tinggi?",
1263
+ "passed": true,
1264
+ "checks": [
1265
+ {
1266
+ "check": "group_by",
1267
+ "ok": true,
1268
+ "detail": "grouped=True"
1269
+ },
1270
+ {
1271
+ "check": "group_by_col=Model_Unit",
1272
+ "ok": true,
1273
+ "detail": "ids=['c_model_unit', 'c_model_unit'] aliases=[] resolved=['c_model_unit', 'c_model_unit']"
1274
+ }
1275
+ ],
1276
+ "facts": {
1277
+ "tools": [
1278
+ "analyze_merge",
1279
+ "check_data",
1280
+ "retrieve_data"
1281
+ ],
1282
+ "irs": [
1283
+ {
1284
+ "source_id": "src_pa",
1285
+ "table_id": "t_pa",
1286
+ "select": [
1287
+ {
1288
+ "kind": "column",
1289
+ "column_id": "c_model_unit",
1290
+ "alias": "model"
1291
+ },
1292
+ {
1293
+ "kind": "agg",
1294
+ "fn": "avg",
1295
+ "column_id": "c_pa_percent",
1296
+ "alias": "avg_pa"
1297
+ }
1298
+ ],
1299
+ "filters": [
1300
+ {
1301
+ "column_id": "c_from_date",
1302
+ "op": "between",
1303
+ "value": [
1304
+ "2026-04-01",
1305
+ "2026-04-30"
1306
+ ],
1307
+ "value_type": "date"
1308
+ }
1309
+ ],
1310
+ "group_by": [
1311
+ "c_model_unit"
1312
+ ],
1313
+ "order_by": [
1314
+ {
1315
+ "column_id": "avg_pa",
1316
+ "dir": "asc"
1317
+ }
1318
+ ],
1319
+ "limit": 1
1320
+ },
1321
+ {
1322
+ "source_id": "src_pa",
1323
+ "table_id": "t_pa",
1324
+ "select": [
1325
+ {
1326
+ "kind": "column",
1327
+ "column_id": "c_model_unit",
1328
+ "alias": "model"
1329
+ },
1330
+ {
1331
+ "kind": "agg",
1332
+ "fn": "avg",
1333
+ "column_id": "c_mttr",
1334
+ "alias": "avg_mttr"
1335
+ }
1336
+ ],
1337
+ "filters": [
1338
+ {
1339
+ "column_id": "c_from_date",
1340
+ "op": "between",
1341
+ "value": [
1342
+ "2026-04-01",
1343
+ "2026-04-30"
1344
+ ],
1345
+ "value_type": "date"
1346
+ }
1347
+ ],
1348
+ "group_by": [
1349
+ "c_model_unit"
1350
+ ],
1351
+ "order_by": [
1352
+ {
1353
+ "column_id": "avg_mttr",
1354
+ "dir": "desc"
1355
+ }
1356
+ ],
1357
+ "limit": 1
1358
+ }
1359
+ ],
1360
+ "agg_args": [],
1361
+ "infeasible": false
1362
+ },
1363
+ "error": null,
1364
+ "latency_ms": 5546,
1365
+ "tokens": 15165
1366
+ },
1367
+ {
1368
+ "id": "raw_rows_low_pa_id",
1369
+ "category": "counter_raw_rows",
1370
+ "lang": "id",
1371
+ "carried_over": true,
1372
+ "question": "tampilkan 10 record dengan PA di bawah 50",
1373
+ "passed": true,
1374
+ "checks": [
1375
+ {
1376
+ "check": "no_group_by",
1377
+ "ok": true,
1378
+ "detail": "grouped=False"
1379
+ },
1380
+ {
1381
+ "check": "has_filter",
1382
+ "ok": true,
1383
+ "detail": "filter_present=True"
1384
+ },
1385
+ {
1386
+ "check": "limit=10",
1387
+ "ok": true,
1388
+ "detail": "limits=[10]"
1389
+ }
1390
+ ],
1391
+ "facts": {
1392
+ "tools": [
1393
+ "check_data",
1394
+ "retrieve_data"
1395
+ ],
1396
+ "irs": [
1397
+ {
1398
+ "source_id": "src_pa",
1399
+ "table_id": "t_pa",
1400
+ "select": [
1401
+ {
1402
+ "kind": "column",
1403
+ "column_id": "c_keyid",
1404
+ "alias": "key_id"
1405
+ },
1406
+ {
1407
+ "kind": "column",
1408
+ "column_id": "c_month_id",
1409
+ "alias": "month_id"
1410
+ },
1411
+ {
1412
+ "kind": "column",
1413
+ "column_id": "c_site_id",
1414
+ "alias": "site_id"
1415
+ },
1416
+ {
1417
+ "kind": "column",
1418
+ "column_id": "c_from_date",
1419
+ "alias": "from_date"
1420
+ },
1421
+ {
1422
+ "kind": "column",
1423
+ "column_id": "c_to_date",
1424
+ "alias": "to_date"
1425
+ },
1426
+ {
1427
+ "kind": "column",
1428
+ "column_id": "c_time_description",
1429
+ "alias": "time_description"
1430
+ },
1431
+ {
1432
+ "kind": "column",
1433
+ "column_id": "c_model_unit",
1434
+ "alias": "model_unit"
1435
+ },
1436
+ {
1437
+ "kind": "column",
1438
+ "column_id": "c_equipment_number",
1439
+ "alias": "equipment_number"
1440
+ },
1441
+ {
1442
+ "kind": "column",
1443
+ "column_id": "c_equipment_group_id",
1444
+ "alias": "equipment_group_id"
1445
+ },
1446
+ {
1447
+ "kind": "column",
1448
+ "column_id": "c_unit_status",
1449
+ "alias": "unit_status"
1450
+ },
1451
+ {
1452
+ "kind": "column",
1453
+ "column_id": "c_pa_percent",
1454
+ "alias": "pa_percent"
1455
+ }
1456
+ ],
1457
+ "filters": [
1458
+ {
1459
+ "column_id": "c_pa_percent",
1460
+ "op": "<",
1461
+ "value": 50,
1462
+ "value_type": "decimal"
1463
+ }
1464
+ ],
1465
+ "order_by": [
1466
+ {
1467
+ "column_id": "pa_percent",
1468
+ "dir": "asc"
1469
+ }
1470
+ ],
1471
+ "limit": 10
1472
+ }
1473
+ ],
1474
+ "agg_args": [],
1475
+ "infeasible": false
1476
+ },
1477
+ "error": null,
1478
+ "latency_ms": 3525,
1479
+ "tokens": 14840
1480
+ },
1481
+ {
1482
+ "id": "raw_rows_head_en",
1483
+ "category": "counter_raw_rows",
1484
+ "lang": "en",
1485
+ "carried_over": true,
1486
+ "question": "show me the first 5 rows of the data",
1487
+ "passed": true,
1488
+ "checks": [
1489
+ {
1490
+ "check": "no_group_by",
1491
+ "ok": true,
1492
+ "detail": "grouped=False"
1493
+ }
1494
+ ],
1495
+ "facts": {
1496
+ "tools": [
1497
+ "check_data",
1498
+ "retrieve_data"
1499
+ ],
1500
+ "irs": [
1501
+ {
1502
+ "source_id": "src_pa",
1503
+ "table_id": "t_pa",
1504
+ "select": [
1505
+ {
1506
+ "kind": "column",
1507
+ "column_id": "c_keyid",
1508
+ "alias": "KeyId"
1509
+ },
1510
+ {
1511
+ "kind": "column",
1512
+ "column_id": "c_month_id",
1513
+ "alias": "Month_ID"
1514
+ },
1515
+ {
1516
+ "kind": "column",
1517
+ "column_id": "c_site_id",
1518
+ "alias": "Site_ID"
1519
+ },
1520
+ {
1521
+ "kind": "column",
1522
+ "column_id": "c_from_date",
1523
+ "alias": "From_Date"
1524
+ },
1525
+ {
1526
+ "kind": "column",
1527
+ "column_id": "c_to_date",
1528
+ "alias": "To_Date"
1529
+ },
1530
+ {
1531
+ "kind": "column",
1532
+ "column_id": "c_time_description",
1533
+ "alias": "Time_Description"
1534
+ },
1535
+ {
1536
+ "kind": "column",
1537
+ "column_id": "c_model_unit",
1538
+ "alias": "Model_Unit"
1539
+ },
1540
+ {
1541
+ "kind": "column",
1542
+ "column_id": "c_equipment_number",
1543
+ "alias": "Equipment_Number"
1544
+ },
1545
+ {
1546
+ "kind": "column",
1547
+ "column_id": "c_equipment_group_id",
1548
+ "alias": "Equipment_Group_ID"
1549
+ },
1550
+ {
1551
+ "kind": "column",
1552
+ "column_id": "c_unit_status",
1553
+ "alias": "Unit_Status"
1554
+ },
1555
+ {
1556
+ "kind": "column",
1557
+ "column_id": "c_total_breakdown_schedule_hour",
1558
+ "alias": "Total_Breakdown_Schedule_Hour"
1559
+ },
1560
+ {
1561
+ "kind": "column",
1562
+ "column_id": "c_total_breakdown_unschedule_hour",
1563
+ "alias": "Total_Breakdown_Unschedule_Hour"
1564
+ },
1565
+ {
1566
+ "kind": "column",
1567
+ "column_id": "c_total_adj_breakdown_schedule_hour",
1568
+ "alias": "Total_Adj_Breakdown_Schedule_Hour"
1569
+ },
1570
+ {
1571
+ "kind": "column",
1572
+ "column_id": "c_total_adj_breakdown_unschedule_hour",
1573
+ "alias": "Total_Adj_Breakdown_Unschedule_Hour"
1574
+ },
1575
+ {
1576
+ "kind": "column",
1577
+ "column_id": "c_total_mtc_hour",
1578
+ "alias": "Total_MTC_Hour"
1579
+ },
1580
+ {
1581
+ "kind": "column",
1582
+ "column_id": "c_total_down_hour",
1583
+ "alias": "Total_Down_Hour"
1584
+ },
1585
+ {
1586
+ "kind": "column",
1587
+ "column_id": "c_total_frequency_breakdown_schedule",
1588
+ "alias": "Total_Frequency_Breakdown_Schedule"
1589
+ },
1590
+ {
1591
+ "kind": "column",
1592
+ "column_id": "c_total_frequency_breakdown_unschedule",
1593
+ "alias": "Total_Frequency_Breakdown_Unschedule"
1594
+ },
1595
+ {
1596
+ "kind": "column",
1597
+ "column_id": "c_total_frequency_maintenance",
1598
+ "alias": "Total_Frequency_Maintenance"
1599
+ },
1600
+ {
1601
+ "kind": "column",
1602
+ "column_id": "c_total_frequency_tire",
1603
+ "alias": "Total_Frequency_Tire"
1604
+ },
1605
+ {
1606
+ "kind": "column",
1607
+ "column_id": "c_total_frequency_down",
1608
+ "alias": "Total_Frequency_Down"
1609
+ },
1610
+ {
1611
+ "kind": "column",
1612
+ "column_id": "c_total_hours",
1613
+ "alias": "Total_Hours"
1614
+ },
1615
+ {
1616
+ "kind": "column",
1617
+ "column_id": "c_total_inpr_hour",
1618
+ "alias": "Total_INPR_Hour"
1619
+ },
1620
+ {
1621
+ "kind": "column",
1622
+ "column_id": "c_total_record_hm_hour",
1623
+ "alias": "Total_Record_HM_Hour"
1624
+ },
1625
+ {
1626
+ "kind": "column",
1627
+ "column_id": "c_total_hm_mtc_down_hour",
1628
+ "alias": "Total_HM_Mtc_Down_Hour"
1629
+ },
1630
+ {
1631
+ "kind": "column",
1632
+ "column_id": "c_plan_pa_percent",
1633
+ "alias": "Plan_PA_Percent"
1634
+ },
1635
+ {
1636
+ "kind": "column",
1637
+ "column_id": "c_pa_percent",
1638
+ "alias": "PA_Percent"
1639
+ },
1640
+ {
1641
+ "kind": "column",
1642
+ "column_id": "c_mtbs",
1643
+ "alias": "MTBS"
1644
+ },
1645
+ {
1646
+ "kind": "column",
1647
+ "column_id": "c_mttr",
1648
+ "alias": "MTTR"
1649
+ },
1650
+ {
1651
+ "kind": "column",
1652
+ "column_id": "c_sm_percent",
1653
+ "alias": "SM_Percent"
1654
+ },
1655
+ {
1656
+ "kind": "column",
1657
+ "column_id": "c_unschedule_sm_percent",
1658
+ "alias": "Unschedule_SM_Percent"
1659
+ },
1660
+ {
1661
+ "kind": "column",
1662
+ "column_id": "c_isdeleted",
1663
+ "alias": "IsDeleted"
1664
+ },
1665
+ {
1666
+ "kind": "column",
1667
+ "column_id": "c_section",
1668
+ "alias": "Section"
1669
+ },
1670
+ {
1671
+ "kind": "column",
1672
+ "column_id": "c_week_id",
1673
+ "alias": "Week_ID"
1674
+ },
1675
+ {
1676
+ "kind": "column",
1677
+ "column_id": "c_plan_pa_percent_2",
1678
+ "alias": "Plan_PA_Percent_2"
1679
+ },
1680
+ {
1681
+ "kind": "column",
1682
+ "column_id": "c_updated_date",
1683
+ "alias": "Updated_Date"
1684
+ }
1685
+ ],
1686
+ "limit": 5
1687
+ }
1688
+ ],
1689
+ "agg_args": [],
1690
+ "infeasible": false
1691
+ },
1692
+ "error": null,
1693
+ "latency_ms": 6662,
1694
+ "tokens": 15316
1695
+ },
1696
+ {
1697
+ "id": "exact_model_777d_id",
1698
+ "category": "counter_exact_filter",
1699
+ "lang": "id",
1700
+ "carried_over": true,
1701
+ "question": "berapa banyak record untuk model 777D?",
1702
+ "passed": true,
1703
+ "checks": [
1704
+ {
1705
+ "check": "select_agg=count",
1706
+ "ok": true,
1707
+ "detail": "aggs=['count']"
1708
+ },
1709
+ {
1710
+ "check": "has_filter",
1711
+ "ok": true,
1712
+ "detail": "filter_present=True"
1713
+ }
1714
+ ],
1715
+ "facts": {
1716
+ "tools": [
1717
+ "retrieve_data"
1718
+ ],
1719
+ "irs": [
1720
+ {
1721
+ "source_id": "src_pa",
1722
+ "table_id": "t_pa",
1723
+ "select": [
1724
+ {
1725
+ "kind": "agg",
1726
+ "fn": "count",
1727
+ "alias": "record_count"
1728
+ }
1729
+ ],
1730
+ "filters": [
1731
+ {
1732
+ "column_id": "c_model_unit",
1733
+ "op": "=",
1734
+ "value": "777D",
1735
+ "value_type": "string"
1736
+ }
1737
+ ]
1738
+ }
1739
+ ],
1740
+ "agg_args": [],
1741
+ "infeasible": false
1742
+ },
1743
+ "error": null,
1744
+ "latency_ms": 2422,
1745
+ "tokens": 14573
1746
+ },
1747
+ {
1748
+ "id": "exact_section_loader_en",
1749
+ "category": "counter_exact_filter",
1750
+ "lang": "en",
1751
+ "carried_over": true,
1752
+ "question": "how many records are in the OB LOADER section?",
1753
+ "passed": true,
1754
+ "checks": [
1755
+ {
1756
+ "check": "select_agg=count",
1757
+ "ok": true,
1758
+ "detail": "aggs=['count']"
1759
+ },
1760
+ {
1761
+ "check": "has_filter",
1762
+ "ok": true,
1763
+ "detail": "filter_present=True"
1764
+ }
1765
+ ],
1766
+ "facts": {
1767
+ "tools": [
1768
+ "retrieve_data"
1769
+ ],
1770
+ "irs": [
1771
+ {
1772
+ "source_id": "src_pa",
1773
+ "table_id": "t_pa",
1774
+ "select": [
1775
+ {
1776
+ "kind": "agg",
1777
+ "fn": "count",
1778
+ "alias": "record_count"
1779
+ }
1780
+ ],
1781
+ "filters": [
1782
+ {
1783
+ "column_id": "c_section",
1784
+ "op": "=",
1785
+ "value": "OB LOADER",
1786
+ "value_type": "string"
1787
+ }
1788
+ ]
1789
+ }
1790
+ ],
1791
+ "agg_args": [],
1792
+ "infeasible": false
1793
+ },
1794
+ "error": null,
1795
+ "latency_ms": 2378,
1796
+ "tokens": 14570
1797
+ },
1798
+ {
1799
+ "id": "disambig_trend_pa_id",
1800
+ "category": "column_disambiguation",
1801
+ "lang": "id",
1802
+ "carried_over": false,
1803
+ "question": "bagaimana trend PA?",
1804
+ "passed": true,
1805
+ "checks": [
1806
+ {
1807
+ "check": "selects_col=PA_Percent",
1808
+ "ok": true,
1809
+ "detail": "selected=['c_from_date', 'c_pa_percent']"
1810
+ },
1811
+ {
1812
+ "check": "not_selects_col=Plan_PA_Percent",
1813
+ "ok": true,
1814
+ "detail": "selected=['c_from_date', 'c_pa_percent']"
1815
+ }
1816
+ ],
1817
+ "facts": {
1818
+ "tools": [
1819
+ "analyze_trend",
1820
+ "check_data",
1821
+ "retrieve_data"
1822
+ ],
1823
+ "irs": [
1824
+ {
1825
+ "source_id": "src_pa",
1826
+ "table_id": "t_pa",
1827
+ "select": [
1828
+ {
1829
+ "kind": "column",
1830
+ "column_id": "c_from_date",
1831
+ "alias": "date"
1832
+ },
1833
+ {
1834
+ "kind": "column",
1835
+ "column_id": "c_pa_percent",
1836
+ "alias": "pa_percent"
1837
+ }
1838
+ ],
1839
+ "filters": [
1840
+ {
1841
+ "column_id": "c_month_id",
1842
+ "op": "=",
1843
+ "value": 202604,
1844
+ "value_type": "int"
1845
+ },
1846
+ {
1847
+ "column_id": "c_isdeleted",
1848
+ "op": "=",
1849
+ "value": 0,
1850
+ "value_type": "int"
1851
+ }
1852
+ ],
1853
+ "limit": 10000
1854
+ }
1855
+ ],
1856
+ "agg_args": [],
1857
+ "infeasible": false
1858
+ },
1859
+ "error": null,
1860
+ "latency_ms": 3527,
1861
+ "tokens": 14860
1862
+ },
1863
+ {
1864
+ "id": "disambig_avg_pa_en",
1865
+ "category": "column_disambiguation",
1866
+ "lang": "en",
1867
+ "carried_over": false,
1868
+ "question": "what is the average PA per section?",
1869
+ "passed": true,
1870
+ "checks": [
1871
+ {
1872
+ "check": "selects_col=PA_Percent",
1873
+ "ok": true,
1874
+ "detail": "selected=['c_section', 'c_pa_percent']"
1875
+ },
1876
+ {
1877
+ "check": "not_selects_col=Plan_PA_Percent",
1878
+ "ok": true,
1879
+ "detail": "selected=['c_section', 'c_pa_percent']"
1880
+ }
1881
+ ],
1882
+ "facts": {
1883
+ "tools": [
1884
+ "analyze_aggregate",
1885
+ "check_data",
1886
+ "retrieve_data"
1887
+ ],
1888
+ "irs": [
1889
+ {
1890
+ "source_id": "src_pa",
1891
+ "table_id": "t_pa",
1892
+ "select": [
1893
+ {
1894
+ "kind": "column",
1895
+ "column_id": "c_section",
1896
+ "alias": "section"
1897
+ },
1898
+ {
1899
+ "kind": "column",
1900
+ "column_id": "c_pa_percent",
1901
+ "alias": "pa_percent"
1902
+ }
1903
+ ],
1904
+ "filters": [
1905
+ {
1906
+ "column_id": "c_month_id",
1907
+ "op": "=",
1908
+ "value": 202604,
1909
+ "value_type": "int"
1910
+ },
1911
+ {
1912
+ "column_id": "c_isdeleted",
1913
+ "op": "=",
1914
+ "value": 0,
1915
+ "value_type": "int"
1916
+ }
1917
+ ],
1918
+ "limit": 10000
1919
+ }
1920
+ ],
1921
+ "agg_args": [
1922
+ {
1923
+ "data": "${t2}",
1924
+ "aggregations": {
1925
+ "pa_percent": [
1926
+ "mean"
1927
+ ]
1928
+ },
1929
+ "group_by": [
1930
+ "section"
1931
+ ]
1932
+ }
1933
+ ],
1934
+ "infeasible": false
1935
+ },
1936
+ "error": null,
1937
+ "latency_ms": 3972,
1938
+ "tokens": 14810
1939
+ },
1940
+ {
1941
+ "id": "chart_trend_pa_id",
1942
+ "category": "chart",
1943
+ "lang": "id",
1944
+ "carried_over": false,
1945
+ "question": "bagaimana visualisasi trend PA?",
1946
+ "passed": false,
1947
+ "checks": [
1948
+ {
1949
+ "check": "has_tool=render_chart",
1950
+ "ok": true,
1951
+ "detail": "tools=['check_data', 'render_chart', 'retrieve_data']"
1952
+ },
1953
+ {
1954
+ "check": "any_tool=['analyze_aggregate', 'analyze_trend']",
1955
+ "ok": false,
1956
+ "detail": "tools=['check_data', 'render_chart', 'retrieve_data']"
1957
+ },
1958
+ {
1959
+ "check": "selects_col=PA_Percent",
1960
+ "ok": true,
1961
+ "detail": "selected=['c_from_date', 'c_pa_percent']"
1962
+ },
1963
+ {
1964
+ "check": "not_selects_col=Plan_PA_Percent",
1965
+ "ok": true,
1966
+ "detail": "selected=['c_from_date', 'c_pa_percent']"
1967
+ }
1968
+ ],
1969
+ "facts": {
1970
+ "tools": [
1971
+ "check_data",
1972
+ "render_chart",
1973
+ "retrieve_data"
1974
+ ],
1975
+ "irs": [
1976
+ {
1977
+ "source_id": "src_pa",
1978
+ "table_id": "t_pa",
1979
+ "select": [
1980
+ {
1981
+ "kind": "column",
1982
+ "column_id": "c_from_date",
1983
+ "alias": "from_date"
1984
+ },
1985
+ {
1986
+ "kind": "column",
1987
+ "column_id": "c_pa_percent",
1988
+ "alias": "pa_percent"
1989
+ }
1990
+ ],
1991
+ "filters": [
1992
+ {
1993
+ "column_id": "c_month_id",
1994
+ "op": "=",
1995
+ "value": 202604,
1996
+ "value_type": "int"
1997
+ },
1998
+ {
1999
+ "column_id": "c_isdeleted",
2000
+ "op": "=",
2001
+ "value": 0,
2002
+ "value_type": "int"
2003
+ }
2004
+ ],
2005
+ "limit": 10000
2006
+ }
2007
+ ],
2008
+ "agg_args": [],
2009
+ "infeasible": false
2010
+ },
2011
+ "error": null,
2012
+ "latency_ms": 3641,
2013
+ "tokens": 14859
2014
+ },
2015
+ {
2016
+ "id": "chart_avg_pa_by_section_en",
2017
+ "category": "chart",
2018
+ "lang": "en",
2019
+ "carried_over": false,
2020
+ "question": "show me a bar chart of average PA per section",
2021
+ "passed": true,
2022
+ "checks": [
2023
+ {
2024
+ "check": "has_tool=render_chart",
2025
+ "ok": true,
2026
+ "detail": "tools=['check_data', 'render_chart', 'retrieve_data']"
2027
+ },
2028
+ {
2029
+ "check": "group_by",
2030
+ "ok": true,
2031
+ "detail": "grouped=True"
2032
+ },
2033
+ {
2034
+ "check": "selects_col=PA_Percent",
2035
+ "ok": true,
2036
+ "detail": "selected=['c_section', 'c_pa_percent']"
2037
+ }
2038
+ ],
2039
+ "facts": {
2040
+ "tools": [
2041
+ "check_data",
2042
+ "render_chart",
2043
+ "retrieve_data"
2044
+ ],
2045
+ "irs": [
2046
+ {
2047
+ "source_id": "src_pa",
2048
+ "table_id": "t_pa",
2049
+ "select": [
2050
+ {
2051
+ "kind": "column",
2052
+ "column_id": "c_section",
2053
+ "alias": "section"
2054
+ },
2055
+ {
2056
+ "kind": "agg",
2057
+ "fn": "avg",
2058
+ "column_id": "c_pa_percent",
2059
+ "alias": "avg_pa"
2060
+ }
2061
+ ],
2062
+ "group_by": [
2063
+ "c_section"
2064
+ ]
2065
+ }
2066
+ ],
2067
+ "agg_args": [],
2068
+ "infeasible": false
2069
+ },
2070
+ "error": null,
2071
+ "latency_ms": 3166,
2072
+ "tokens": 14769
2073
+ },
2074
+ {
2075
+ "id": "infeasible_churn_id",
2076
+ "category": "infeasible",
2077
+ "lang": "id",
2078
+ "carried_over": true,
2079
+ "question": "berapa churn rate pelanggan?",
2080
+ "passed": true,
2081
+ "checks": [
2082
+ {
2083
+ "check": "infeasible",
2084
+ "ok": true,
2085
+ "detail": "infeasible=True"
2086
+ }
2087
+ ],
2088
+ "facts": {
2089
+ "tools": [],
2090
+ "irs": [],
2091
+ "agg_args": [],
2092
+ "infeasible": true
2093
+ },
2094
+ "error": null,
2095
+ "latency_ms": 2055,
2096
+ "tokens": 14486
2097
+ },
2098
+ {
2099
+ "id": "infeasible_profit_en",
2100
+ "category": "infeasible",
2101
+ "lang": "en",
2102
+ "carried_over": true,
2103
+ "question": "what is the monthly profit margin?",
2104
+ "passed": true,
2105
+ "checks": [
2106
+ {
2107
+ "check": "infeasible",
2108
+ "ok": true,
2109
+ "detail": "infeasible=True"
2110
+ }
2111
+ ],
2112
+ "facts": {
2113
+ "tools": [],
2114
+ "irs": [],
2115
+ "agg_args": [],
2116
+ "infeasible": true
2117
+ },
2118
+ "error": null,
2119
+ "latency_ms": 2072,
2120
+ "tokens": 14501
2121
+ }
2122
+ ]
2123
+ }
eval/readiness/results/readiness_result_2026-06-23_094622.json ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run": {
3
+ "timestamp": "2026-06-23T09:46:22",
4
+ "dataset": "readiness_dataset.json",
5
+ "target": "src/agents/report/readiness.is_report_ready",
6
+ "total": 17,
7
+ "passed": 17,
8
+ "accuracy": 1.0,
9
+ "runtime_avg_ms": 0.0
10
+ },
11
+ "alignment_gap": {
12
+ "count": 2,
13
+ "ids": [
14
+ "align_01",
15
+ "align_02"
16
+ ]
17
+ },
18
+ "by_group": {
19
+ "floor": {
20
+ "n": 8,
21
+ "passed": 8,
22
+ "accuracy": 1.0
23
+ },
24
+ "delta": {
25
+ "n": 5,
26
+ "passed": 5,
27
+ "accuracy": 1.0
28
+ },
29
+ "edge": {
30
+ "n": 1,
31
+ "passed": 1,
32
+ "accuracy": 1.0
33
+ },
34
+ "alignment": {
35
+ "n": 3,
36
+ "passed": 3,
37
+ "accuracy": 1.0
38
+ }
39
+ },
40
+ "cases": [
41
+ {
42
+ "id": "floor_01",
43
+ "group": "floor",
44
+ "expected_ready": false,
45
+ "got_ready": false,
46
+ "expected_missing": [
47
+ "a validated problem statement",
48
+ "at least one completed analysis"
49
+ ],
50
+ "got_missing": [
51
+ "a validated problem statement",
52
+ "at least one completed analysis"
53
+ ],
54
+ "correct": true,
55
+ "aligned": false,
56
+ "gap": false,
57
+ "latency_ms": 0.0
58
+ },
59
+ {
60
+ "id": "floor_02",
61
+ "group": "floor",
62
+ "expected_ready": false,
63
+ "got_ready": false,
64
+ "expected_missing": [
65
+ "a validated problem statement"
66
+ ],
67
+ "got_missing": [
68
+ "a validated problem statement"
69
+ ],
70
+ "correct": true,
71
+ "aligned": true,
72
+ "gap": false,
73
+ "latency_ms": 0.0
74
+ },
75
+ {
76
+ "id": "floor_03",
77
+ "group": "floor",
78
+ "expected_ready": false,
79
+ "got_ready": false,
80
+ "expected_missing": [
81
+ "at least one completed analysis"
82
+ ],
83
+ "got_missing": [
84
+ "at least one completed analysis"
85
+ ],
86
+ "correct": true,
87
+ "aligned": false,
88
+ "gap": false,
89
+ "latency_ms": 0.0
90
+ },
91
+ {
92
+ "id": "floor_04",
93
+ "group": "floor",
94
+ "expected_ready": false,
95
+ "got_ready": false,
96
+ "expected_missing": [
97
+ "at least one completed analysis"
98
+ ],
99
+ "got_missing": [
100
+ "at least one completed analysis"
101
+ ],
102
+ "correct": true,
103
+ "aligned": false,
104
+ "gap": false,
105
+ "latency_ms": 0.0
106
+ },
107
+ {
108
+ "id": "floor_05",
109
+ "group": "floor",
110
+ "expected_ready": false,
111
+ "got_ready": false,
112
+ "expected_missing": [
113
+ "at least one completed analysis"
114
+ ],
115
+ "got_missing": [
116
+ "at least one completed analysis"
117
+ ],
118
+ "correct": true,
119
+ "aligned": false,
120
+ "gap": false,
121
+ "latency_ms": 0.0
122
+ },
123
+ {
124
+ "id": "floor_06",
125
+ "group": "floor",
126
+ "expected_ready": true,
127
+ "got_ready": true,
128
+ "expected_missing": [],
129
+ "got_missing": [],
130
+ "correct": true,
131
+ "aligned": true,
132
+ "gap": false,
133
+ "latency_ms": 0.0
134
+ },
135
+ {
136
+ "id": "floor_07",
137
+ "group": "floor",
138
+ "expected_ready": true,
139
+ "got_ready": true,
140
+ "expected_missing": [],
141
+ "got_missing": [],
142
+ "correct": true,
143
+ "aligned": true,
144
+ "gap": false,
145
+ "latency_ms": 0.0
146
+ },
147
+ {
148
+ "id": "floor_08",
149
+ "group": "floor",
150
+ "expected_ready": true,
151
+ "got_ready": true,
152
+ "expected_missing": [],
153
+ "got_missing": [],
154
+ "correct": true,
155
+ "aligned": true,
156
+ "gap": false,
157
+ "latency_ms": 0.0
158
+ },
159
+ {
160
+ "id": "delta_01",
161
+ "group": "delta",
162
+ "expected_ready": false,
163
+ "got_ready": false,
164
+ "expected_missing": [
165
+ "a new analysis since the last report"
166
+ ],
167
+ "got_missing": [
168
+ "a new analysis since the last report"
169
+ ],
170
+ "correct": true,
171
+ "aligned": true,
172
+ "gap": false,
173
+ "latency_ms": 0.0
174
+ },
175
+ {
176
+ "id": "delta_02",
177
+ "group": "delta",
178
+ "expected_ready": true,
179
+ "got_ready": true,
180
+ "expected_missing": [],
181
+ "got_missing": [],
182
+ "correct": true,
183
+ "aligned": true,
184
+ "gap": false,
185
+ "latency_ms": 0.0
186
+ },
187
+ {
188
+ "id": "delta_03",
189
+ "group": "delta",
190
+ "expected_ready": true,
191
+ "got_ready": true,
192
+ "expected_missing": [],
193
+ "got_missing": [],
194
+ "correct": true,
195
+ "aligned": true,
196
+ "gap": false,
197
+ "latency_ms": 0.0
198
+ },
199
+ {
200
+ "id": "delta_04",
201
+ "group": "delta",
202
+ "expected_ready": false,
203
+ "got_ready": false,
204
+ "expected_missing": [
205
+ "a new analysis since the last report"
206
+ ],
207
+ "got_missing": [
208
+ "a new analysis since the last report"
209
+ ],
210
+ "correct": true,
211
+ "aligned": true,
212
+ "gap": false,
213
+ "latency_ms": 0.0
214
+ },
215
+ {
216
+ "id": "delta_05",
217
+ "group": "delta",
218
+ "expected_ready": false,
219
+ "got_ready": false,
220
+ "expected_missing": [
221
+ "a new analysis since the last report"
222
+ ],
223
+ "got_missing": [
224
+ "a new analysis since the last report"
225
+ ],
226
+ "correct": true,
227
+ "aligned": true,
228
+ "gap": false,
229
+ "latency_ms": 0.0
230
+ },
231
+ {
232
+ "id": "edge_01",
233
+ "group": "edge",
234
+ "expected_ready": false,
235
+ "got_ready": false,
236
+ "expected_missing": [
237
+ "at least one completed analysis"
238
+ ],
239
+ "got_missing": [
240
+ "at least one completed analysis"
241
+ ],
242
+ "correct": true,
243
+ "aligned": false,
244
+ "gap": false,
245
+ "latency_ms": 0.0
246
+ },
247
+ {
248
+ "id": "align_01",
249
+ "group": "alignment",
250
+ "expected_ready": true,
251
+ "got_ready": true,
252
+ "expected_missing": [],
253
+ "got_missing": [],
254
+ "correct": true,
255
+ "aligned": false,
256
+ "gap": true,
257
+ "latency_ms": 0.0
258
+ },
259
+ {
260
+ "id": "align_02",
261
+ "group": "alignment",
262
+ "expected_ready": true,
263
+ "got_ready": true,
264
+ "expected_missing": [],
265
+ "got_missing": [],
266
+ "correct": true,
267
+ "aligned": false,
268
+ "gap": true,
269
+ "latency_ms": 0.0
270
+ },
271
+ {
272
+ "id": "align_03",
273
+ "group": "alignment",
274
+ "expected_ready": true,
275
+ "got_ready": true,
276
+ "expected_missing": [],
277
+ "got_missing": [],
278
+ "correct": true,
279
+ "aligned": true,
280
+ "gap": false,
281
+ "latency_ms": 0.0
282
+ }
283
+ ]
284
+ }
eval/readiness/results/readiness_result_2026-07-02_083335.json ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run": {
3
+ "timestamp": "2026-07-02T08:33:35",
4
+ "dataset": "readiness_dataset.json",
5
+ "target": "src/agents/report/readiness.is_report_ready",
6
+ "total": 15,
7
+ "passed": 15,
8
+ "accuracy": 1.0,
9
+ "runtime_avg_ms": 0.0
10
+ },
11
+ "alignment_gap": {
12
+ "count": 2,
13
+ "ids": [
14
+ "align_01",
15
+ "align_02"
16
+ ]
17
+ },
18
+ "by_group": {
19
+ "floor": {
20
+ "n": 6,
21
+ "passed": 6,
22
+ "accuracy": 1.0
23
+ },
24
+ "delta": {
25
+ "n": 5,
26
+ "passed": 5,
27
+ "accuracy": 1.0
28
+ },
29
+ "edge": {
30
+ "n": 1,
31
+ "passed": 1,
32
+ "accuracy": 1.0
33
+ },
34
+ "alignment": {
35
+ "n": 3,
36
+ "passed": 3,
37
+ "accuracy": 1.0
38
+ }
39
+ },
40
+ "cases": [
41
+ {
42
+ "id": "floor_01",
43
+ "group": "floor",
44
+ "expected_ready": false,
45
+ "got_ready": false,
46
+ "expected_missing": [
47
+ "at least one completed analysis"
48
+ ],
49
+ "got_missing": [
50
+ "at least one completed analysis"
51
+ ],
52
+ "correct": true,
53
+ "aligned": false,
54
+ "gap": false,
55
+ "latency_ms": 0.0
56
+ },
57
+ {
58
+ "id": "floor_02",
59
+ "group": "floor",
60
+ "expected_ready": false,
61
+ "got_ready": false,
62
+ "expected_missing": [
63
+ "at least one completed analysis"
64
+ ],
65
+ "got_missing": [
66
+ "at least one completed analysis"
67
+ ],
68
+ "correct": true,
69
+ "aligned": false,
70
+ "gap": false,
71
+ "latency_ms": 0.0
72
+ },
73
+ {
74
+ "id": "floor_03",
75
+ "group": "floor",
76
+ "expected_ready": false,
77
+ "got_ready": false,
78
+ "expected_missing": [
79
+ "at least one completed analysis"
80
+ ],
81
+ "got_missing": [
82
+ "at least one completed analysis"
83
+ ],
84
+ "correct": true,
85
+ "aligned": false,
86
+ "gap": false,
87
+ "latency_ms": 0.0
88
+ },
89
+ {
90
+ "id": "floor_04",
91
+ "group": "floor",
92
+ "expected_ready": true,
93
+ "got_ready": true,
94
+ "expected_missing": [],
95
+ "got_missing": [],
96
+ "correct": true,
97
+ "aligned": true,
98
+ "gap": false,
99
+ "latency_ms": 0.0
100
+ },
101
+ {
102
+ "id": "floor_05",
103
+ "group": "floor",
104
+ "expected_ready": true,
105
+ "got_ready": true,
106
+ "expected_missing": [],
107
+ "got_missing": [],
108
+ "correct": true,
109
+ "aligned": true,
110
+ "gap": false,
111
+ "latency_ms": 0.0
112
+ },
113
+ {
114
+ "id": "floor_06",
115
+ "group": "floor",
116
+ "expected_ready": true,
117
+ "got_ready": true,
118
+ "expected_missing": [],
119
+ "got_missing": [],
120
+ "correct": true,
121
+ "aligned": true,
122
+ "gap": false,
123
+ "latency_ms": 0.0
124
+ },
125
+ {
126
+ "id": "delta_01",
127
+ "group": "delta",
128
+ "expected_ready": false,
129
+ "got_ready": false,
130
+ "expected_missing": [
131
+ "a new analysis since the last report"
132
+ ],
133
+ "got_missing": [
134
+ "a new analysis since the last report"
135
+ ],
136
+ "correct": true,
137
+ "aligned": true,
138
+ "gap": false,
139
+ "latency_ms": 0.0
140
+ },
141
+ {
142
+ "id": "delta_02",
143
+ "group": "delta",
144
+ "expected_ready": true,
145
+ "got_ready": true,
146
+ "expected_missing": [],
147
+ "got_missing": [],
148
+ "correct": true,
149
+ "aligned": true,
150
+ "gap": false,
151
+ "latency_ms": 0.0
152
+ },
153
+ {
154
+ "id": "delta_03",
155
+ "group": "delta",
156
+ "expected_ready": true,
157
+ "got_ready": true,
158
+ "expected_missing": [],
159
+ "got_missing": [],
160
+ "correct": true,
161
+ "aligned": true,
162
+ "gap": false,
163
+ "latency_ms": 0.0
164
+ },
165
+ {
166
+ "id": "delta_04",
167
+ "group": "delta",
168
+ "expected_ready": false,
169
+ "got_ready": false,
170
+ "expected_missing": [
171
+ "a new analysis since the last report"
172
+ ],
173
+ "got_missing": [
174
+ "a new analysis since the last report"
175
+ ],
176
+ "correct": true,
177
+ "aligned": true,
178
+ "gap": false,
179
+ "latency_ms": 0.0
180
+ },
181
+ {
182
+ "id": "delta_05",
183
+ "group": "delta",
184
+ "expected_ready": false,
185
+ "got_ready": false,
186
+ "expected_missing": [
187
+ "a new analysis since the last report"
188
+ ],
189
+ "got_missing": [
190
+ "a new analysis since the last report"
191
+ ],
192
+ "correct": true,
193
+ "aligned": true,
194
+ "gap": false,
195
+ "latency_ms": 0.0
196
+ },
197
+ {
198
+ "id": "edge_01",
199
+ "group": "edge",
200
+ "expected_ready": false,
201
+ "got_ready": false,
202
+ "expected_missing": [
203
+ "at least one completed analysis"
204
+ ],
205
+ "got_missing": [
206
+ "at least one completed analysis"
207
+ ],
208
+ "correct": true,
209
+ "aligned": false,
210
+ "gap": false,
211
+ "latency_ms": 0.0
212
+ },
213
+ {
214
+ "id": "align_01",
215
+ "group": "alignment",
216
+ "expected_ready": true,
217
+ "got_ready": true,
218
+ "expected_missing": [],
219
+ "got_missing": [],
220
+ "correct": true,
221
+ "aligned": false,
222
+ "gap": true,
223
+ "latency_ms": 0.0
224
+ },
225
+ {
226
+ "id": "align_02",
227
+ "group": "alignment",
228
+ "expected_ready": true,
229
+ "got_ready": true,
230
+ "expected_missing": [],
231
+ "got_missing": [],
232
+ "correct": true,
233
+ "aligned": false,
234
+ "gap": true,
235
+ "latency_ms": 0.0
236
+ },
237
+ {
238
+ "id": "align_03",
239
+ "group": "alignment",
240
+ "expected_ready": true,
241
+ "got_ready": true,
242
+ "expected_missing": [],
243
+ "got_missing": [],
244
+ "correct": true,
245
+ "aligned": true,
246
+ "gap": false,
247
+ "latency_ms": 0.0
248
+ }
249
+ ]
250
+ }
eval/readiness/results/readiness_result_2026-07-02_093621.json ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run": {
3
+ "timestamp": "2026-07-02T09:36:21",
4
+ "dataset": "readiness_dataset.json",
5
+ "target": "src/agents/report/readiness.is_report_ready",
6
+ "total": 15,
7
+ "passed": 15,
8
+ "accuracy": 1.0,
9
+ "runtime_avg_ms": 0.0
10
+ },
11
+ "alignment_gap": {
12
+ "count": 2,
13
+ "ids": [
14
+ "align_01",
15
+ "align_02"
16
+ ]
17
+ },
18
+ "by_group": {
19
+ "floor": {
20
+ "n": 6,
21
+ "passed": 6,
22
+ "accuracy": 1.0
23
+ },
24
+ "delta": {
25
+ "n": 5,
26
+ "passed": 5,
27
+ "accuracy": 1.0
28
+ },
29
+ "edge": {
30
+ "n": 1,
31
+ "passed": 1,
32
+ "accuracy": 1.0
33
+ },
34
+ "alignment": {
35
+ "n": 3,
36
+ "passed": 3,
37
+ "accuracy": 1.0
38
+ }
39
+ },
40
+ "cases": [
41
+ {
42
+ "id": "floor_01",
43
+ "group": "floor",
44
+ "expected_ready": false,
45
+ "got_ready": false,
46
+ "expected_missing": [
47
+ "at least one completed analysis"
48
+ ],
49
+ "got_missing": [
50
+ "at least one completed analysis"
51
+ ],
52
+ "correct": true,
53
+ "aligned": false,
54
+ "gap": false,
55
+ "latency_ms": 0.0
56
+ },
57
+ {
58
+ "id": "floor_02",
59
+ "group": "floor",
60
+ "expected_ready": false,
61
+ "got_ready": false,
62
+ "expected_missing": [
63
+ "at least one completed analysis"
64
+ ],
65
+ "got_missing": [
66
+ "at least one completed analysis"
67
+ ],
68
+ "correct": true,
69
+ "aligned": false,
70
+ "gap": false,
71
+ "latency_ms": 0.0
72
+ },
73
+ {
74
+ "id": "floor_03",
75
+ "group": "floor",
76
+ "expected_ready": false,
77
+ "got_ready": false,
78
+ "expected_missing": [
79
+ "at least one completed analysis"
80
+ ],
81
+ "got_missing": [
82
+ "at least one completed analysis"
83
+ ],
84
+ "correct": true,
85
+ "aligned": false,
86
+ "gap": false,
87
+ "latency_ms": 0.0
88
+ },
89
+ {
90
+ "id": "floor_04",
91
+ "group": "floor",
92
+ "expected_ready": true,
93
+ "got_ready": true,
94
+ "expected_missing": [],
95
+ "got_missing": [],
96
+ "correct": true,
97
+ "aligned": true,
98
+ "gap": false,
99
+ "latency_ms": 0.0
100
+ },
101
+ {
102
+ "id": "floor_05",
103
+ "group": "floor",
104
+ "expected_ready": true,
105
+ "got_ready": true,
106
+ "expected_missing": [],
107
+ "got_missing": [],
108
+ "correct": true,
109
+ "aligned": true,
110
+ "gap": false,
111
+ "latency_ms": 0.0
112
+ },
113
+ {
114
+ "id": "floor_06",
115
+ "group": "floor",
116
+ "expected_ready": true,
117
+ "got_ready": true,
118
+ "expected_missing": [],
119
+ "got_missing": [],
120
+ "correct": true,
121
+ "aligned": true,
122
+ "gap": false,
123
+ "latency_ms": 0.0
124
+ },
125
+ {
126
+ "id": "delta_01",
127
+ "group": "delta",
128
+ "expected_ready": false,
129
+ "got_ready": false,
130
+ "expected_missing": [
131
+ "a new analysis since the last report"
132
+ ],
133
+ "got_missing": [
134
+ "a new analysis since the last report"
135
+ ],
136
+ "correct": true,
137
+ "aligned": true,
138
+ "gap": false,
139
+ "latency_ms": 0.0
140
+ },
141
+ {
142
+ "id": "delta_02",
143
+ "group": "delta",
144
+ "expected_ready": true,
145
+ "got_ready": true,
146
+ "expected_missing": [],
147
+ "got_missing": [],
148
+ "correct": true,
149
+ "aligned": true,
150
+ "gap": false,
151
+ "latency_ms": 0.0
152
+ },
153
+ {
154
+ "id": "delta_03",
155
+ "group": "delta",
156
+ "expected_ready": true,
157
+ "got_ready": true,
158
+ "expected_missing": [],
159
+ "got_missing": [],
160
+ "correct": true,
161
+ "aligned": true,
162
+ "gap": false,
163
+ "latency_ms": 0.0
164
+ },
165
+ {
166
+ "id": "delta_04",
167
+ "group": "delta",
168
+ "expected_ready": false,
169
+ "got_ready": false,
170
+ "expected_missing": [
171
+ "a new analysis since the last report"
172
+ ],
173
+ "got_missing": [
174
+ "a new analysis since the last report"
175
+ ],
176
+ "correct": true,
177
+ "aligned": true,
178
+ "gap": false,
179
+ "latency_ms": 0.0
180
+ },
181
+ {
182
+ "id": "delta_05",
183
+ "group": "delta",
184
+ "expected_ready": false,
185
+ "got_ready": false,
186
+ "expected_missing": [
187
+ "a new analysis since the last report"
188
+ ],
189
+ "got_missing": [
190
+ "a new analysis since the last report"
191
+ ],
192
+ "correct": true,
193
+ "aligned": true,
194
+ "gap": false,
195
+ "latency_ms": 0.0
196
+ },
197
+ {
198
+ "id": "edge_01",
199
+ "group": "edge",
200
+ "expected_ready": false,
201
+ "got_ready": false,
202
+ "expected_missing": [
203
+ "at least one completed analysis"
204
+ ],
205
+ "got_missing": [
206
+ "at least one completed analysis"
207
+ ],
208
+ "correct": true,
209
+ "aligned": false,
210
+ "gap": false,
211
+ "latency_ms": 0.0
212
+ },
213
+ {
214
+ "id": "align_01",
215
+ "group": "alignment",
216
+ "expected_ready": true,
217
+ "got_ready": true,
218
+ "expected_missing": [],
219
+ "got_missing": [],
220
+ "correct": true,
221
+ "aligned": false,
222
+ "gap": true,
223
+ "latency_ms": 0.0
224
+ },
225
+ {
226
+ "id": "align_02",
227
+ "group": "alignment",
228
+ "expected_ready": true,
229
+ "got_ready": true,
230
+ "expected_missing": [],
231
+ "got_missing": [],
232
+ "correct": true,
233
+ "aligned": false,
234
+ "gap": true,
235
+ "latency_ms": 0.0
236
+ },
237
+ {
238
+ "id": "align_03",
239
+ "group": "alignment",
240
+ "expected_ready": true,
241
+ "got_ready": true,
242
+ "expected_missing": [],
243
+ "got_missing": [],
244
+ "correct": true,
245
+ "aligned": true,
246
+ "gap": false,
247
+ "latency_ms": 0.0
248
+ }
249
+ ]
250
+ }
knowledge_pipeline_context.md ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Knowledge Pipeline β€” Context for the Data Eyond Python Repo (19 Aug 2026)
2
+
3
+ > **Who this is for:** a Claude Code session working in `Agentic-Service-Data-Eyond-Catalog`
4
+ > (the Python agentic service). This doc carries *context and intent* only β€” the why, the
5
+ > ownership split, the shape of the pipeline, and the decisions already settled. It does not
6
+ > prescribe folder layout, module names, or endpoint signatures; work those out against
7
+ > `CLAUDE.md`, `REPO_STATUS.md` and the existing subsystem patterns in the repo.
8
+ >
9
+ ---
10
+
11
+ ## 1. What we are doing and why now
12
+
13
+ Data Eyond is an AI data-analyst platform. The strategic thesis from the 30 July exec review
14
+ is that **domain knowledge is the differentiator, not architecture** β€” orchestrators and
15
+ connectors are replicable in a quarter, model capability is rented, only encoded domain
16
+ knowledge compounds. The end goal is an **MCP product** exposing modular domain knowledge
17
+ that EMA and later clients connect to.
18
+
19
+ That knowledge currently comes from experts typing it in. **Mas Beta** (plant & maintenance)
20
+ is the single validation bottleneck β€” nothing becomes an artifact without him. The knowledge
21
+ pipeline exists to change the expert's job from *authoring* to *reviewing*: the pipeline reads
22
+ the client's own documents (standards, SOPs, handbooks) and proposes candidate knowledge
23
+ entries; the expert approves, edits or rejects them.
24
+
25
+ **Why it lands in this repo now.** We want to integrate the pipeline into Data Eyond to test
26
+ it end-to-end on real documents, in the place where the resulting knowledge will actually be
27
+ consumed. This is a **test integration**, not a productionisation β€” treat it as a new, gated
28
+ subsystem alongside the existing document/catalog pipelines, not as a rewrite of them.
29
+
30
+ ---
31
+
32
+ ## 2. Ownership β€” two items, two people, one seam
33
+
34
+ The pipeline splits cleanly in two, and the split is deliberate: **they are separate,
35
+ independently runnable, independently swappable pipelines.**
36
+
37
+ | Half | Owner | Scope |
38
+ |---|---|---|
39
+ | **β‘  Parsing (MinerU)** | **Sofhia Az-Zahra** | PDF/DOCX in β†’ structured, typed extracted content out. MinerU replaces the current text+table-only extraction: it adds formula, image, chart and diagram detection, and emits Markdown or JSON with type labels. Presented to the execs 13 Aug as the **prerequisite for Scenario 2**; a written cost justification for MinerU is owed for the budget proposal. |
40
+ | **β‘‘ Knowledge extraction** | **Rifqi (me)** | Parsed content in β†’ validated candidate knowledge entries out. Term filtering, clustering, evidence ranking, schema-guided LLM attribute fill, span validation, diff against the active glossary version, and the frequency-sorted review queue. |
41
+
42
+ **The seam.** The two halves must meet only at a **persisted, versioned parsed-document
43
+ artifact** β€” extraction consumes that artifact, never a raw file, never the parser's API.
44
+ This is the single most important structural constraint in the whole plan, because:
45
+
46
+ - MinerU can be swapped (or fall back to the existing Tesseract / Azure Document Intelligence
47
+ paths) without extraction knowing;
48
+ - extraction can be re-run cheaply on an already-parsed corpus when a prompt or schema changes
49
+ β€” and it will be, often, because prompt iteration is the main development loop here;
50
+ - Sofhia and I can work and test independently, against a fixture artifact, without blocking
51
+ each other;
52
+ - parsing is the slow/expensive-to-rerun half; extraction is the fast-to-iterate half. Keeping
53
+ the boundary hard means we never re-parse to fix an extraction bug.
54
+
55
+ Anything that makes the two halves import each other's internals, or that makes extraction
56
+ take a file path, breaks the point of the split.
57
+
58
+ ---
59
+
60
+ ## 3. The pipeline
61
+
62
+ Six stages. The cost story matters as much as the flow: almost everything is free CPU/regex
63
+ work, and the LLM appears in exactly one stage.
64
+
65
+ ```mermaid
66
+ flowchart TD
67
+ A["Admin / expert triggers ingest<br/><i>not continuous, not per-user</i>"] --> B
68
+
69
+ subgraph P["β‘  PARSING β€” Sofhia"]
70
+ B["MinerU<br/>text Β· tables Β· formulas Β· figures Β· charts"] --> C["Section-aware chunking<br/>split on numbered headings 2.1.1, 2.1.2 …"]
71
+ end
72
+
73
+ C --> SEAM[("Parsed-document artifact<br/>versioned Β· persisted<br/><b>THE SEAM</b>")]
74
+ SEAM --> D
75
+
76
+ subgraph F["β‘‘ TERM FILTER β€” CPU / regex, free"]
77
+ D["GLiNER span filter on ALL chunks β†’ term mentions"] --> H
78
+ E["Discourse-cue regex β†’ rule-of-thumb candidates"] --> H
79
+ G["Legend-block regex β†’ formula vars + abbreviation dictionary"] --> H
80
+ G2["Section pass β†’ summary units"] --> H
81
+ H["Normalise + cluster mentions<br/>PA Β· P.A. Β· Physical Availability β†’ 1 cluster"] --> I["Evidence ranking<br/>cue Β· heading Β· legend Β· formula Β· formatting Β· first-occurrence"]
82
+ I --> J["Top-3 evidence chunks per cluster"]
83
+ end
84
+
85
+ SEAM --> E
86
+ SEAM --> G
87
+ SEAM --> G2
88
+
89
+ J --> K
90
+
91
+ subgraph X["β‘’ KNOWLEDGE EXTRACTION β€” the only paid stage"]
92
+ K["1 LLM call per TERM CLUSTER<br/>(not per mention, not per chunk)"]
93
+ L["1 call per cue chunk Β· 1 per unique formula Β· 1 per document (summary)"]
94
+ end
95
+
96
+ K --> M
97
+ L --> M
98
+
99
+ subgraph V["β‘£ VALIDATION β€” deterministic"]
100
+ M["Verbatim-span check β†’ reject field if span not locatable"] --> N["Null definition β†’ escalate to next 3 evidence chunks<br/>max 2 rounds β†’ else flag 'term found, no definition located'"]
101
+ N --> O["Conflicting definitions β†’ definition_conflict + variants[]"]
102
+ end
103
+
104
+ O --> Q["β‘€ DIFF vs active glossary version<br/>duplicate (skip) Β· new Β· conflicting"]
105
+ Q --> R
106
+
107
+ subgraph RV["β‘₯ REVIEW β€” human"]
108
+ R["Queue sorted by mention frequency<br/>PA 250 β†’ UA 180 β†’ … β†’ long tail"] --> S["Expert (Mas Beta) approves / edits / rejects"]
109
+ S --> T["Glossary Β· Interpretation Pack Β· Brief Context Β· Formula"]
110
+ T --> U["Versioning + approval audit trail"]
111
+ end
112
+
113
+ U --> W["MCP server β€” the consumption surface"]
114
+
115
+ style P fill:#e8f4ff,stroke:#4a90d9
116
+ style F fill:#e6f7e6,stroke:#4caf50
117
+ style X fill:#dbe9ff,stroke:#2f6fd0
118
+ style V fill:#f0e6ff,stroke:#8e5fd0
119
+ style RV fill:#fff0e0,stroke:#e08b3c
120
+ style SEAM fill:#fffbe0,stroke:#c9a227,stroke-width:3px
121
+ ```
122
+
123
+ Colour convention, carried from the diagrams shown to the team:
124
+ **green = free** (CPU/regex β€” most of the pipeline) Β· **blue = LLM** (the only paid part) Β·
125
+ **purple = deterministic code** Β· **orange = human**.
126
+
127
+ ### The five-box version (how this is explained to execs)
128
+
129
+ ```mermaid
130
+ flowchart LR
131
+ A["PARSE<br/>MinerU"] --> B["FILTER<br/>free, CPU"] --> C["EXTRACT<br/>LLM, per term cluster"] --> D["VALIDATE + DIFF<br/>deterministic"] --> E["EXPERT REVIEW<br/>frequency-sorted queue"]
132
+ style A fill:#e8f4ff,stroke:#4a90d9
133
+ style B fill:#e6f7e6,stroke:#4caf50
134
+ style C fill:#dbe9ff,stroke:#2f6fd0
135
+ style D fill:#f0e6ff,stroke:#8e5fd0
136
+ style E fill:#fff0e0,stroke:#e08b3c
137
+ ```
138
+
139
+ ### Ownership view
140
+
141
+ ```mermaid
142
+ flowchart LR
143
+ subgraph S["Sofhia"]
144
+ P["Parsing pipeline<br/>MinerU + backends"]
145
+ end
146
+ subgraph R["Rifqi"]
147
+ K["Extraction pipeline<br/>filter β†’ LLM β†’ validate β†’ diff β†’ queue"]
148
+ end
149
+ subgraph H["Mas Har / Mas Beta"]
150
+ C["Curation UI + expert review<br/>versioning, approval"]
151
+ end
152
+ P -->|"versioned parsed artifact<br/>(the only contract)"| K
153
+ K -->|"candidate entries + provenance"| C
154
+ C -->|"active versions"| M["MCP server"]
155
+ ```
156
+
157
+ ---
158
+
159
+ ## 4. Decisions already settled β€” do not reopen these
160
+
161
+ 1. **The LLM call unit is the term cluster** β€” not the chunk, not the mention. Per-chunk works
162
+ for a 9-page document and breaks at 1,000+ pages: "PA" mentioned 250Γ— would produce 250
163
+ near-identical candidates. Clustering first cuts expert review burden ~6.2Γ— (3,125 raw
164
+ extractions β†’ ~500 term entries on a 1,000-page document). The token saving is trivial
165
+ (~$0.45); **the review-burden reduction is the justification.**
166
+ 2. **Conflict detection depends on the clustering.** Two contradictory definitions of the same
167
+ term can only be spotted because all evidence for that term arrives in one call. Two
168
+ separate calls would never meet, and the conflict would surface as two silently-accepted
169
+ entries.
170
+ 3. **No standalone relevance gate.** A binary relevance classifier before the term filter is a
171
+ second imperfect classifier whose false negatives drop content *before* extraction sees it β€”
172
+ and dropped content never reaches expert review to be caught. GLiNER is cheap enough (CPU,
173
+ ~400MB) to run unconditionally on every chunk; "zero candidate spans" becomes the relevance
174
+ signal as a byproduct of looking, rather than a judgment made before looking.
175
+ 4. **Evidence ranking is allowed even though it is also a filter** β€” because no term is
176
+ dropped (only which passages feed the call is narrowed), all mentions stay in provenance so
177
+ a bad top-3 is visible, and the escalation loop self-corrects. The rejected relevance gate
178
+ had none of those three properties. This distinction is the reasoning to preserve if the
179
+ ranking is ever revisited.
180
+ 5. **`provenance.span` is mandatory and verbatim.** A field whose span cannot be located in the
181
+ source is rejected. This is the primary anti-hallucination control and the thing that makes
182
+ expert review *finishable* β€” the reviewer checks a quote against a page, not a claim against
183
+ their memory.
184
+ 6. **All content fields are Optional in the schema.** The model must be able to answer null; a
185
+ required field is a fabrication invitation for smaller models. `subdomain_tags` is an enum β€”
186
+ classification, not generation.
187
+ 7. **Frequency-sorted review queue.** Directly targets the Mas Beta bottleneck: validate the
188
+ ~40 terms carrying the document's meaning first, let the long tail wait.
189
+ 8. **Model tier is per branch.** Glossary and Formula are extraction tasks well-guarded by span
190
+ verification β†’ a small/cheap tier is fine. Rule-of-thumb and Summary are reasoning/generation
191
+ tasks where a small model's failure mode is least detectable (a plausible summary cannot be
192
+ span-checked) β†’ larger tier. Test before committing the small tier on the rule-of-thumb
193
+ branch.
194
+ 9. **GLiNER-hybrid stays the glossary-branch default.** Pure NER can only fill 2 of 6 schema
195
+ fields (definition, formula and interpretation are not spans β€” they are assembled from text
196
+ that may be pages apart). Pure small-LLM shows recall 0.93–1.00 at precision 0.15–0.43;
197
+ perfect recall is the failure signature of a model fabricating a value for every field.
198
+ 10. **The pipeline records the literal source wording.** It recorded "Physical of Availability"
199
+ from the BUMA standard rather than the textbook's "Physical Availability" β€” surfacing the
200
+ discrepancy to the expert instead of silently normalising it. Keep that behaviour.
201
+
202
+ ---
203
+
204
+ ## 5. Deployment reality β€” this shapes every engineering trade-off
205
+
206
+ The pipeline runs **once at setup (bulk corpus ingest)** and **occasionally when an admin or
207
+ expert adds a file**. It is **not continuous** and **not per-user** β€” normal engineers cannot
208
+ add data. Consequences, in the order they matter:
209
+
210
+ - **Extraction quality and expert-review efficiency dominate.** A missed term has no
211
+ self-correcting mechanism, because engineers can't add data to fill a gap later.
212
+ - **Maintainability matters more than it looks.** A glue chain invoked every few months is
213
+ exactly the thing nobody remembers how to debug. Prefer boring, inspectable stages over
214
+ clever ones.
215
+ - **API cost is negligible and latency is irrelevant.** ~$8–12 for a 6,000-page corpus;
216
+ an incremental single 9-page file is ~1.5 US cents. Do not spend engineering time optimising
217
+ tokens β€” engineering time on the review experience is worth more than any token saving
218
+ available here.
219
+ - **No GPU required.** GLiNER is a CPU encoder model (~1.6 seq/s Python, ~6.7 seq/s via the
220
+ Rust port on an i9 8-core). ~15,000 chunks β‰ˆ 2.6h Python / ~40min Rust for a job that runs a
221
+ few times a year. The LLM half is an API call. (Runtime as reported to the team on 13 Aug:
222
+ ~11 s/page GLiNER, ~26 s/page LLM β€” with the caveat that **cost scales on term count, not
223
+ page count.**)
224
+ - **Two ingest modes to build:** *bulk setup ingest* (corpus-scale β€” the only place
225
+ corpus-frequency statistics are usable as a candidate-term booster) and *incremental
226
+ single-file add* (no corpus context; diff against the active glossary version).
227
+ - **The summary branch is the quiet cost risk** β€” only ~300 calls but a quarter of all input
228
+ tokens, because summarisation can't be filtered; it needs whole documents.
229
+
230
+ ---
231
+
232
+ ## 6. Where the output goes
233
+
234
+ Approved candidates become the project's **four artifacts** (see the `context` doc for the
235
+ full architecture):
236
+
237
+ 1. **Domain knowledge** β€” system-prompt material, capped ~1500 tokens: operating and equipment
238
+ hierarchy, time convention (WITA), seasonality, most-important glossary terms, high-level
239
+ business process, and agent limitations.
240
+ 2. **Data dictionary** β€” from DB column profiling; used for query generation. Pak Ricky
241
+ connected the extraction work directly to **automating this**.
242
+ 3. **Interpretation pack** β€” expert-curated interpretation logic, action rules, benchmark
243
+ history, tied to a use case ("PA missed" reads differently from "PA missed but MTBS
244
+ achieved"). Revived on 13 Aug after earlier removal for being too normative β€” reintroduce
245
+ carefully.
246
+ 4. **Skill registry** β€” query rules and expert-approved formulas. Approval is mandatory because
247
+ the same term computes differently per company (MTTR at BUMA = breakdown duration Γ·
248
+ breakdown frequency; MTTR in IT = mean time to resolve).
249
+
250
+ All four carry **versioning plus an approval audit trail**, and the expert chooses which
251
+ version is active. Delivery format is **MCP** β€” skill registry β†’ MCP skills; data dictionary β†’
252
+ MCP on demand; interpretation pack β†’ attached to the skill; domain knowledge β†’ system prompt
253
+ but preferably served via MCP so owners can change it without redeploying the engine.
254
+
255
+ ---
256
+
257
+ ## 7. Grounding material
258
+
259
+ Two real sample documents anchor every design claim above, and should anchor test fixtures too:
260
+
261
+ - **BUMA STD/2026/006/MNO Rev.0.0** β€” Production Parameter & ECA. 9 pages, majority Bahasa
262
+ Indonesia. Carries: PA (glossary, p.4), Other Activity + the controllability hierarchy
263
+ (rule-of-thumb, pp.8–9), the MOHH/Qty/PA/UA/Pty legend block (formula branch, p.2).
264
+ - **Open Pit Mine Planning & Design** (textbook excerpt) β€” carries NSR as a **cross-page
265
+ definition**: intro p.92, formula p.93, interpretive remark p.94 (Β§2.3.5). This is the case
266
+ that justifies section-aware chunking over fixed token windows β€” the whole definition must
267
+ stay inside one semantic unit.
268
+
269
+ **Known open risk:** GLiNER multi-v2.1's Indonesian performance is unverified, and the BUMA
270
+ standard is majority Bahasa Indonesia. This is the standing "Indonesian degradation" question,
271
+ now with a concrete test case. Verify before trusting recall numbers.
272
+
273
+ **Flagged as scope creep, proposed not decided:** ensembling GLiNER + LangExtract on every
274
+ ingest. Cheap in API terms, but it adds a reconciliation/dedup step between two candidate sets
275
+ that may disagree β€” new engineering surface for uncertain gain.
276
+
277
+ ---
278
+
279
+ ## 8. Integration guidance for this repo
280
+
281
+ Deliberately shape-agnostic β€” decide the specifics against `CLAUDE.md` and the existing
282
+ subsystem patterns, not against this doc.
283
+
284
+ - **Both halves are new subsystems, not modifications** to the existing document/catalog
285
+ pipelines. The current unstructured path (Tesseract OCR β†’ chunk β†’ pgvector) stays as it is;
286
+ the MinerU path is additive and, at least initially, gated.
287
+ - **Follow the repo's existing multi-stage precedent.** The query subsystem (IR β†’ validator β†’
288
+ compiler β†’ executor, orchestrated from a service facade, triggered by thin entry points) is
289
+ the closest structural analogue to what both halves need.
290
+ - **Respect the hard boundaries.** Go owns the dedorch schema β€” any new table needs a
291
+ Harry-ready DDL handoff, never DDL executed from Python. Any new endpoint on the live surface
292
+ needs a contract-doc entry, and an admin ingest surface is a different risk class than the
293
+ read-only chat surface β€” raise the authentication question rather than assuming.
294
+ - **Build the offline path first.** Because this runs a few times a year and is triggered by an
295
+ admin, a script-driven run over a fixture document is the honest first milestone; HTTP
296
+ endpoints are a convenience layer over it, not the other way round.
297
+ - **Fixtures over live parsing in tests.** A committed parsed-artifact fixture from one of the
298
+ two sample documents lets the extraction half be tested without MinerU installed at all β€”
299
+ which is the seam doing its job.
pyproject.toml CHANGED
@@ -90,6 +90,9 @@ dependencies = [
90
  "pytesseract>=0.3.13",
91
  "pypdf2>=3.0.1",
92
  "pyarrow>=24.0.0",
 
 
 
93
  ]
94
 
95
  [project.optional-dependencies]
@@ -103,6 +106,18 @@ dev = [
103
  "pre-commit==4.0.1",
104
  ]
105
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  [tool.hatch.build.targets.wheel]
107
  packages = ["src/agent_service"]
108
 
@@ -125,6 +140,13 @@ ignore = [
125
  "tests/**" = ["S101", "S105", "S106", "S608"]
126
  # T201: eval/ scripts are CLIs β€” print() is their intended output channel.
127
  "eval/**" = ["T201"]
 
 
 
 
 
 
 
128
 
129
  [tool.mypy]
130
  python_version = "3.12"
 
90
  "pytesseract>=0.3.13",
91
  "pypdf2>=3.0.1",
92
  "pyarrow>=24.0.0",
93
+ "rapidfuzz>=3.14.5",
94
+ "gliner>=0.2.13",
95
+ "torch>=2.6",
96
  ]
97
 
98
  [project.optional-dependencies]
 
106
  "pre-commit==4.0.1",
107
  ]
108
 
109
+ # Document parsing for the knowledge pipeline (src/knowledge_parsing/).
110
+ #
111
+ # Deliberately an EXTRA, not a main dependency: MinerU pulls torch and its model
112
+ # tail (GBs), and the agent service never parses documents at request time β€”
113
+ # parsing is an offline, admin-triggered batch job. Keeping it here means the
114
+ # deployed Space does not build or ship any of it.
115
+ #
116
+ # pip install -e ".[parsing]"
117
+ knowledge-parsing = [
118
+ "mineru==3.4.4",
119
+ ]
120
+
121
  [tool.hatch.build.targets.wheel]
122
  packages = ["src/agent_service"]
123
 
 
140
  "tests/**" = ["S101", "S105", "S106", "S608"]
141
  # T201: eval/ scripts are CLIs β€” print() is their intended output channel.
142
  "eval/**" = ["T201"]
143
+ # Same rule, same reason: this is an operator-run CLI, and its printed output IS
144
+ # the deliverable.
145
+ "src/knowledge_extraction/cli.py" = ["T201"]
146
+ # Same again for the knowledge-parsing CLI entry points. Scoped to the two files
147
+ # rather than the package, so the library modules stay print-free.
148
+ "src/knowledge_parsing/run.py" = ["T201"]
149
+ "src/knowledge_parsing/report.py" = ["T201"]
150
 
151
  [tool.mypy]
152
  python_version = "3.12"
src/config/settings.py CHANGED
@@ -47,6 +47,13 @@ class Settings(BaseSettings):
47
  azureai_deployment_name_54m: str = Field(alias="azureai__deployment__name__54m", default="")
48
  azureai_api_version_54m: str = Field(alias="azureai__api__version__54m", default="")
49
 
 
 
 
 
 
 
 
50
  # Azure OpenAI - Embeddings
51
  azureai_api_key_embedding: str = Field(alias="azureai__api_key__embedding", default="")
52
  azureai_endpoint_url_embedding: str = Field(alias="azureai__endpoint__url__embedding", default="")
 
47
  azureai_deployment_name_54m: str = Field(alias="azureai__deployment__name__54m", default="")
48
  azureai_api_version_54m: str = Field(alias="azureai__api__version__54m", default="")
49
 
50
+ # Azure OpenAI - GPT-5.4-nano (knowledge-extraction pipeline; all four
51
+ # branches route here β€” see KNOWLEDGE_PIPELINE_TODO.md D3)
52
+ azureai_api_key_54n: str = Field(alias="azureai__api_key__54n", default="")
53
+ azureai_endpoint_url_54n: str = Field(alias="azureai__endpoint__url__54n", default="")
54
+ azureai_deployment_name_54n: str = Field(alias="azureai__deployment__name__54n", default="")
55
+ azureai_api_version_54n: str = Field(alias="azureai__api__version__54n", default="")
56
+
57
  # Azure OpenAI - Embeddings
58
  azureai_api_key_embedding: str = Field(alias="azureai__api_key__embedding", default="")
59
  azureai_endpoint_url_embedding: str = Field(alias="azureai__endpoint__url__embedding", default="")
src/knowledge_extraction/__init__.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Knowledge-extraction pipeline: parsed document -> candidate knowledge entries.
2
+
3
+ Distinct from `src/knowledge/`, which is the existing OCR -> chunk -> pgvector
4
+ ingestion path for unstructured RAG. This package does not touch it.
5
+
6
+ Stage order, and which stages cost money:
7
+ adapter seam artifact -> internal Chunk free
8
+ filters cue / legend / span NER -> mentions free (CPU)
9
+ cluster normalise + cluster mentions free
10
+ rank evidence scoring, top-K selection free
11
+ extract one LLM call per TERM CLUSTER PAID
12
+ validate verbatim span check, escalation free
13
+ diff new / duplicate / conflicting free
14
+ queue frequency-sorted review queue free
15
+
16
+ `service.py` is the facade; `cli.py` is the operator entry point.
17
+
18
+ Design rationale: knowledge_pipeline_context.md
19
+ Calibrated constants and why: KNOWLEDGE_PIPELINE_CALIBRATION.md
20
+ """
21
+
22
+ from .adapter import parsed_doc_from_artifact
23
+ from .models import (
24
+ AbbrevPair,
25
+ BriefContext,
26
+ CallUsage,
27
+ Chunk,
28
+ ClusterResult,
29
+ FilterResult,
30
+ FormulaEntry,
31
+ GlossaryEntry,
32
+ Mention,
33
+ ParsedDoc,
34
+ Provenance,
35
+ RuleCandidate,
36
+ RuleEntry,
37
+ TermCluster,
38
+ )
39
+ from .service import ExtractionResult, build_clusters, estimate_cost, extract_all, run_filters
40
+
41
+ __all__ = [
42
+ "AbbrevPair",
43
+ "BriefContext",
44
+ "CallUsage",
45
+ "Chunk",
46
+ "ClusterResult",
47
+ "ExtractionResult",
48
+ "FilterResult",
49
+ "FormulaEntry",
50
+ "GlossaryEntry",
51
+ "Mention",
52
+ "ParsedDoc",
53
+ "Provenance",
54
+ "RuleCandidate",
55
+ "RuleEntry",
56
+ "TermCluster",
57
+ "build_clusters",
58
+ "estimate_cost",
59
+ "extract_all",
60
+ "parsed_doc_from_artifact",
61
+ "run_filters",
62
+ ]
src/knowledge_extraction/adapter.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seam adapter: parsed-document artifact β†’ the pipeline's internal `Chunk`.
2
+
3
+ **This is the only file that knows the seam's shape.** Every stage downstream
4
+ depends on `models.Chunk` alone, so when the artifact contract settles with
5
+ Sofhia the change lands here and nowhere else.
6
+
7
+ The seam is still under discussion (KNOWLEDGE_PIPELINE_TODO.md Β§3), so this
8
+ reads defensively: it accepts either the draft's field names or the prototype's,
9
+ takes plain dicts, and tolerates missing optional structure. It deliberately
10
+ does **not** accept a file path β€” extraction never opens a document. That
11
+ constraint is the point of the split, not an implementation detail.
12
+
13
+ Two things it must never do:
14
+ - reflow, strip or whitespace-normalise `text`. Span validation locates quoted
15
+ spans literally inside it; cleaning the text makes the lookup fail and the
16
+ field go silently null, which presents as a bad model.
17
+ - infer a page number it was not given. A wrong page sends the reviewer to the
18
+ wrong part of the document, which is worse than no page at all.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ from typing import Any
26
+
27
+ from .models import Chunk, ParsedDoc
28
+
29
+ # Field names accepted for the same concept. `page_idx`/`page_idxs` are the
30
+ # parsing half's contract; the rest are earlier drafts and the prototype's shape,
31
+ # kept so old fixtures still load.
32
+ #
33
+ # Page numbers are 0-BASED throughout, exactly as the parser reports them. No
34
+ # conversion happens anywhere in this pipeline: converting to 1-based is the
35
+ # review UI's job, done once at display time. An off-by-one here would be
36
+ # invisible until an expert opened the wrong page.
37
+ _PAGE_KEYS = ("page_idx", "page_start", "page")
38
+ _PAGES_KEYS = ("page_idxs", "pages", "page_list")
39
+ _PAGE_END_KEYS = ("page_idx_end", "page_end")
40
+
41
+
42
+ def chunk_from_dict(raw: dict[str, Any], doc_id: str, ordinal: int = 0) -> Chunk:
43
+ """Map one artifact item onto the internal chunk.
44
+
45
+ `kind`/`is_tabular` are reconciled: the draft carries a `kind` discriminator
46
+ while the prototype carried booleans. Either is accepted.
47
+ """
48
+ kind = raw.get("kind")
49
+ pages = _first(raw, _PAGES_KEYS) or []
50
+ page_start = _first(raw, _PAGE_KEYS)
51
+ if page_start is None:
52
+ page_start = min(pages) if pages else 0
53
+ page_end = _first(raw, _PAGE_END_KEYS)
54
+ if page_end is None:
55
+ page_end = max(pages) if pages else page_start
56
+
57
+ return Chunk(
58
+ chunk_id=raw.get("chunk_id") or f"{doc_id}#{ordinal:04d}",
59
+ doc_id=raw.get("doc_id") or doc_id,
60
+ text=raw["text"], # verbatim, never cleaned
61
+ page_start=int(page_start),
62
+ page_end=int(page_end),
63
+ ordinal=int(raw.get("ordinal", ordinal)),
64
+ section_no=raw.get("section_no"),
65
+ heading=raw.get("heading"),
66
+ has_formula=bool(raw.get("has_formula", kind == "equation")),
67
+ is_tabular=bool(raw.get("is_tabular", kind == "table")),
68
+ bold_spans=list(raw.get("bold_spans") or []),
69
+ )
70
+
71
+
72
+ def parsed_doc_from_artifact(
73
+ artifact: Any,
74
+ doc_id: str | None = None,
75
+ source_ref: str = "",
76
+ parser_name: str = "unknown",
77
+ parser_version: str = "",
78
+ ) -> ParsedDoc:
79
+ """Build a `ParsedDoc` from either shape of the artifact.
80
+
81
+ Accepts a bare `list[chunk]` (the draft's current shape) or a mapping with a
82
+ `chunks` key (the shape proposed for the document-level envelope). When the
83
+ envelope lands, its `content_hash`/`n_pages`/`version` are preferred over
84
+ the values derived here.
85
+ """
86
+ if hasattr(artifact, "model_dump"): # a ParsedDocument from the parsing half
87
+ artifact = artifact.model_dump(mode="json")
88
+
89
+ if isinstance(artifact, dict):
90
+ items = artifact.get("chunks") or []
91
+ doc_id = doc_id or artifact.get("doc_id")
92
+ source_ref = source_ref or artifact.get("source_path") or artifact.get("source_ref") or ""
93
+ parser_name = artifact.get("parser_name") or parser_name
94
+ parser_version = artifact.get("parser_version") or parser_version
95
+ # The backend matters as much as the version: the same MinerU build can
96
+ # emit different text from `pipeline` and `vlm`, so a shift in extraction
97
+ # output has to be attributable to one or the other.
98
+ backend = artifact.get("parser_backend")
99
+ if backend:
100
+ parser_version = f"{parser_version}/{backend}" if parser_version else backend
101
+ declared_hash = artifact.get("content_hash")
102
+ declared_pages = artifact.get("n_pages")
103
+ else:
104
+ items = list(artifact)
105
+ declared_hash, declared_pages = None, None
106
+
107
+ if not doc_id:
108
+ doc_id = (items[0].get("doc_id") if items else None) or "unknown"
109
+
110
+ chunks = [chunk_from_dict(raw, doc_id, i) for i, raw in enumerate(items)]
111
+ pages = {p for c in chunks for p in (c.page_start, c.page_end)}
112
+
113
+ return ParsedDoc(
114
+ doc_id=doc_id,
115
+ source_ref=source_ref,
116
+ content_hash=declared_hash or content_hash(chunks),
117
+ n_pages=int(declared_pages) if declared_pages else (max(pages) + 1 if pages else 0),
118
+ chunks=chunks,
119
+ parser_name=parser_name,
120
+ parser_version=parser_version,
121
+ used_heading_split=any(c.section_no for c in chunks),
122
+ )
123
+
124
+
125
+ def content_hash(chunks: list[Chunk]) -> str:
126
+ """Stable hash of the chunk text, so a re-parse that changed nothing can be
127
+ detected and the expensive stages skipped."""
128
+ blob = json.dumps([c.text for c in chunks], ensure_ascii=False).encode()
129
+ return hashlib.sha256(blob).hexdigest()[:16]
130
+
131
+
132
+ def _first(raw: dict[str, Any], keys: tuple[str, ...]) -> Any:
133
+ for key in keys:
134
+ if raw.get(key) is not None:
135
+ return raw[key]
136
+ return None
src/knowledge_extraction/cli.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline runner for the knowledge-extraction pipeline.
2
+
3
+ The pipeline runs a few times a year, triggered by an admin β€” so a script over a
4
+ parsed artifact is the honest entry point, and any HTTP surface is a convenience
5
+ layer over this, never the other way round.
6
+
7
+ Takes a **parsed-document artifact**, never a PDF: extraction does not parse.
8
+ Every stage writes its own JSON so a later stage can be re-run without repeating
9
+ an earlier one, which matters because prompt iteration is the main development
10
+ loop and the span filter is the slow part.
11
+
12
+ # free stages only (default) β€” no API calls, no spend
13
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json>
14
+
15
+ # cost estimate before spending anything
16
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --dry-run
17
+
18
+ # small pilot, then the full run
19
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --extract --limit 5
20
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --extract
21
+
22
+ # exercise the wiring with no credentials and no spend
23
+ uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --extract --mock
24
+
25
+ Lives inside the package rather than in `scripts/`, which is gitignored: this
26
+ runner is the pipeline's operator entry point and has to ship with the module.
27
+
28
+ **Always --dry-run before a corpus-scale run.** It builds the exact prompts,
29
+ prints the token estimate, and makes zero API calls.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import json
36
+ import re
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ from .adapter import parsed_doc_from_artifact
41
+ from .cluster import cluster_mentions
42
+ from .extract import MockExtractor, cacheable, prefix_tokens
43
+ from .models import Mention
44
+ from .rank import rank_evidence
45
+ from .service import build_clusters, estimate_cost, extract_all, run_filters
46
+ from .settings import EVIDENCE_K
47
+
48
+ BRANCHES = ("glossary", "rule", "formula", "summary")
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
53
+ parser.add_argument("artifact", type=Path, help="parsed-document artifact JSON")
54
+ parser.add_argument("--out-dir", type=Path, default=Path("out/knowledge"))
55
+ parser.add_argument("--doc-id", help="override the artifact's doc_id")
56
+ parser.add_argument("--mentions", type=Path, help="span-NER mentions JSON")
57
+ parser.add_argument(
58
+ "--no-span-filter",
59
+ action="store_true",
60
+ help="skip the span model; legend terms only (wiring check, NOT a recall run)",
61
+ )
62
+ parser.add_argument(
63
+ "--extract", action="store_true", help="run the PAID extraction stage"
64
+ )
65
+ parser.add_argument(
66
+ "--dry-run",
67
+ action="store_true",
68
+ help="build the prompts and print a token estimate; makes no API calls",
69
+ )
70
+ parser.add_argument("--mock", action="store_true", help="mock extractor: no network, no spend")
71
+ parser.add_argument("--limit", type=int, help="cap the number of items per branch (pilot)")
72
+ parser.add_argument(
73
+ "--branches", nargs="+", choices=BRANCHES, default=list(BRANCHES)
74
+ )
75
+ parser.add_argument(
76
+ "--active-glossary", type=Path, help="approved glossary to diff against"
77
+ )
78
+ args = parser.parse_args(argv)
79
+
80
+ if not args.artifact.exists():
81
+ print(f"artifact not found: {args.artifact}", file=sys.stderr)
82
+ return 2
83
+
84
+ raw = json.loads(args.artifact.read_text(encoding="utf-8"))
85
+ doc = parsed_doc_from_artifact(raw, doc_id=args.doc_id, source_ref=str(args.artifact))
86
+ print(
87
+ f"[parse ] {doc.doc_id}: {len(doc.chunks)} chunks, {doc.n_pages} pages, "
88
+ f"hash {doc.content_hash}, heading-split={doc.used_heading_split}"
89
+ )
90
+
91
+ filtered = run_filters(doc, use_span_filter=not (args.no_span_filter or args.mentions))
92
+ if args.mentions:
93
+ filtered.mentions = _load_mentions(args.mentions)
94
+ source = "file"
95
+ elif args.no_span_filter:
96
+ filtered.mentions = _from_pairs(doc, filtered.abbrev_pairs)
97
+ source = "legend stand-in (NOT a recall run)"
98
+ else:
99
+ source = "span filter"
100
+
101
+ capped = sum(m.hit_span_cap for m in filtered.mentions)
102
+ cap_note = f", {capped} hit the span cap" if capped else ""
103
+ print(
104
+ f"[filter] {len(filtered.abbrev_pairs)} abbreviation pairs, "
105
+ f"{len(filtered.rule_candidates)} rule candidates"
106
+ )
107
+ print(f"[filter] {len(filtered.mentions)} mentions from {source}{cap_note}")
108
+
109
+ if args.mentions or args.no_span_filter:
110
+ clustered = cluster_mentions(filtered.mentions, filtered.abbrev_pairs, doc.doc_id)
111
+ rank_evidence(clustered.clusters, doc.chunks)
112
+ else:
113
+ clustered = build_clusters(doc, filtered)
114
+ print(
115
+ f"[cluster] {clustered.n_mentions} mentions -> {clustered.n_clusters} clusters "
116
+ f"(compression {clustered.compression_ratio}x)"
117
+ )
118
+ for cluster in clustered.clusters[:8]:
119
+ print(
120
+ f" {cluster.canonical:<26} mentions={cluster.mention_count:<4} "
121
+ f"evidence={len(cluster.evidence_chunk_ids)} "
122
+ f"top={cluster.evidence_chunk_ids[:EVIDENCE_K]}"
123
+ )
124
+
125
+ args.out_dir.mkdir(parents=True, exist_ok=True)
126
+ _dump(args.out_dir / f"{doc.doc_id}.chunks.json", doc.model_dump(mode="json"))
127
+ _dump(args.out_dir / f"{doc.doc_id}.filters.json", filtered.model_dump(mode="json"))
128
+ _dump(args.out_dir / f"{doc.doc_id}.clusters.json", clustered.model_dump(mode="json"))
129
+
130
+ if args.dry_run:
131
+ est = estimate_cost(doc, clustered, filtered, args.limit)
132
+ print("[dry-run] NO API CALLS MADE")
133
+ for key, value in est.items():
134
+ print(f" {key}: {value}")
135
+ for branch in args.branches:
136
+ print(
137
+ f" prefix[{branch}]: {prefix_tokens(branch)} tokens, "
138
+ f"cacheable={cacheable(branch)}"
139
+ )
140
+ return 0
141
+
142
+ if not args.extract:
143
+ print(f"[write ] {args.out_dir} (free stages only; --extract to run the paid stage)")
144
+ return 0
145
+
146
+ extractor = MockExtractor() if args.mock else _azure_extractor()
147
+ if extractor is None:
148
+ return 3
149
+ active = (
150
+ json.loads(args.active_glossary.read_text(encoding="utf-8"))
151
+ if args.active_glossary
152
+ else []
153
+ )
154
+
155
+ result = extract_all(
156
+ doc,
157
+ clustered,
158
+ filtered,
159
+ extractor,
160
+ limit=args.limit,
161
+ active_glossary=active,
162
+ branches=tuple(args.branches),
163
+ )
164
+
165
+ prompt, cached, completion = result.total_tokens
166
+ simulated = " [SIMULATED β€” not a quality measurement]" if args.mock else ""
167
+ print(f"[extract] {len(result.usages)} calls{simulated}")
168
+ print(
169
+ f" glossary={len(result.glossary)} rules={len(result.rules)} "
170
+ f"formulas={len(result.formulas)}"
171
+ )
172
+ print(f" tokens prompt={prompt} cached={cached} completion={completion}")
173
+ print(f" fields rejected by span check: {len(result.rejected)}")
174
+ no_def = sum(1 for e in result.glossary if e.get("extraction_status") == "no_definition_found")
175
+ print(f" abstained (no definition in document): {no_def}/{len(result.glossary)}")
176
+
177
+ print("[queue ] top of the review queue:")
178
+ for row in result.review_queue[:10]:
179
+ term = str(row.get("term"))[:26]
180
+ print(
181
+ f" {row['rank']:>3}. {term:<26} "
182
+ f"n={row['mention_count']:<4} {row['review_reason']}"
183
+ )
184
+
185
+ _dump(args.out_dir / "glossary.json", result.glossary)
186
+ _dump(args.out_dir / "interpretation_pack.json", result.rules)
187
+ _dump(args.out_dir / "formulas.json", result.formulas)
188
+ _dump(args.out_dir / "review_queue.json", result.review_queue)
189
+ _dump(args.out_dir / "rejected.json", [r.model_dump(mode="json") for r in result.rejected])
190
+ if result.brief:
191
+ _dump(args.out_dir / "brief_context.json", result.brief)
192
+ _dump(args.out_dir / "usage.json", [u.model_dump(mode="json") for u in result.usages])
193
+ print(f"[write ] {args.out_dir}")
194
+ return 0
195
+
196
+
197
+ def _azure_extractor():
198
+ from .extract import AzureExtractor
199
+
200
+ try:
201
+ return AzureExtractor()
202
+ except Exception as exc:
203
+ print(f"cannot build the Azure client: {exc}", file=sys.stderr)
204
+ print("use --mock to exercise the pipeline without credentials", file=sys.stderr)
205
+ return None
206
+
207
+
208
+ def _load_mentions(path: Path) -> list[Mention]:
209
+ raw = json.loads(path.read_text(encoding="utf-8"))
210
+ items = raw.get("mentions", raw) if isinstance(raw, dict) else raw
211
+ return [Mention.model_validate(m) for m in items]
212
+
213
+
214
+ def _from_pairs(doc, pairs) -> list[Mention]:
215
+ """Stand-in mentions from legend abbreviations, so the wiring is runnable
216
+ without the span model.
217
+
218
+ NOT a recall measurement β€” it only sees terms a legend block already named.
219
+
220
+ Word-boundary matching, never substring: "PA" occurs inside "parameter",
221
+ "pada", "capacity" and "composite", and substring matching produced 126
222
+ spurious PA mentions on a 9-page document (77x compression instead of the
223
+ measured 2.56x). Same trap the evidence ranker documents for headings.
224
+ """
225
+ surfaces = {p.abbrev for p in pairs} | {p.expansion for p in pairs}
226
+ patterns = [
227
+ (s, re.compile(rf"(?<!\w){re.escape(s)}(?!\w)", re.IGNORECASE)) for s in surfaces
228
+ ]
229
+ out: list[Mention] = []
230
+ for chunk in doc.chunks:
231
+ for surface, pattern in patterns:
232
+ for match in pattern.finditer(chunk.text):
233
+ out.append(
234
+ Mention(
235
+ surface=surface,
236
+ chunk_id=chunk.chunk_id,
237
+ char_start=match.start(),
238
+ char_end=match.end(),
239
+ label="legend",
240
+ score=1.0,
241
+ )
242
+ )
243
+ return out
244
+
245
+
246
+ def _dump(path: Path, payload) -> None:
247
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
248
+
249
+
250
+ if __name__ == "__main__":
251
+ raise SystemExit(main())
src/knowledge_extraction/cluster/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .cluster import cluster_mentions
2
+ from .normalize import AbbrevIndex, is_noise, normalize
3
+
4
+ __all__ = ["AbbrevIndex", "cluster_mentions", "is_noise", "normalize"]
src/knowledge_extraction/cluster/cluster.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ 3. conservative fuzzy (token_set_ratio >= 92, and only above 5 characters)
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
10
+ distinct term, and no downstream stage recovers it β€” the expert never learns the
11
+ term existed. Every threshold here is set on that asymmetry.
12
+
13
+ Clustering is also what makes conflict detection possible at all: contradictory
14
+ definitions can only be compared if all evidence for a term reaches the same
15
+ call.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from rapidfuzz import fuzz
21
+
22
+ from ..models import AbbrevPair, ClusterResult, Mention, TermCluster
23
+ from ..settings import FUZZY_MIN_LEN, FUZZY_THRESHOLD
24
+ from .normalize import AbbrevIndex, is_noise
25
+
26
+
27
+ def cluster_mentions(
28
+ mentions: list[Mention],
29
+ abbrev_pairs: list[AbbrevPair],
30
+ doc_id: str,
31
+ fuzzy_threshold: int = FUZZY_THRESHOLD,
32
+ ) -> ClusterResult:
33
+ index = AbbrevIndex(abbrev_pairs)
34
+ kept = [m for m in mentions if not is_noise(m.surface)]
35
+
36
+ buckets: dict[str, dict] = {}
37
+
38
+ for mention in kept:
39
+ key = index.canonical_key(mention.surface)
40
+
41
+ if key in buckets:
42
+ _add(buckets[key], mention, "exact")
43
+ continue
44
+
45
+ linked = next((k for k in buckets if index.linked(mention.surface, k)), None)
46
+ if linked:
47
+ _add(buckets[linked], mention, "abbrev")
48
+ continue
49
+
50
+ match = _fuzzy_match(key, buckets.keys(), fuzzy_threshold)
51
+ if match:
52
+ _add(buckets[match], mention, "fuzzy")
53
+ continue
54
+
55
+ buckets[key] = {
56
+ "surfaces": {mention.surface},
57
+ "mentions": [mention],
58
+ "reasons": set(),
59
+ }
60
+
61
+ clusters: list[TermCluster] = []
62
+ ordered = sorted(buckets.items(), key=lambda kv: -len(kv[1]["mentions"]))
63
+ for i, (key, data) in enumerate(ordered):
64
+ variants = sorted(data["surfaces"], key=lambda s: (len(s), s))
65
+ clusters.append(
66
+ TermCluster(
67
+ cluster_id=f"{doc_id}#c{i:03d}",
68
+ canonical=_canonical(variants, key),
69
+ variants=variants,
70
+ mentions=data["mentions"],
71
+ mention_count=len(data["mentions"]),
72
+ merge_reasons=sorted(data["reasons"]),
73
+ )
74
+ )
75
+
76
+ n_mentions, n_clusters = len(kept), len(clusters)
77
+ return ClusterResult(
78
+ doc_id=doc_id,
79
+ clusters=clusters,
80
+ n_mentions=n_mentions,
81
+ n_clusters=n_clusters,
82
+ compression_ratio=round(n_mentions / n_clusters, 3) if n_clusters else 0.0,
83
+ )
84
+
85
+
86
+ def _add(bucket: dict, mention: Mention, reason: str) -> None:
87
+ bucket["surfaces"].add(mention.surface)
88
+ bucket["mentions"].append(mention)
89
+ bucket["reasons"].add(reason)
90
+
91
+
92
+ def _fuzzy_match(key: str, existing, threshold: int) -> str | None:
93
+ best, best_score = None, 0.0
94
+ for other in existing:
95
+ # Short strings fuzzy-match far too easily: "PA" vs "UA" scores high on
96
+ # token_set_ratio. Below FUZZY_MIN_LEN only exact matching is allowed.
97
+ if min(len(key), len(other)) < FUZZY_MIN_LEN:
98
+ continue
99
+ score = fuzz.token_set_ratio(key, other)
100
+ if score >= threshold and score > best_score:
101
+ best, best_score = other, score
102
+ return best
103
+
104
+
105
+ def _canonical(variants: list[str], key: str) -> str:
106
+ """Prefer the shortest non-trivial surface β€” usually the term as a reader
107
+ would look it up ("PA", not "Physical Availability (PA) untuk ...")."""
108
+ for v in variants:
109
+ if len(v) >= 2:
110
+ return v
111
+ return key
src/knowledge_extraction/cluster/normalize.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Surface normalisation and the abbreviation index used by clustering.
2
+
3
+ **This normalisation is for clustering only.** Span validation normalises
4
+ whitespace and nothing else β€” every additional normalisation there is a hole a
5
+ fabrication can fit through. Do not reuse `normalize()` in that path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import unicodedata
12
+
13
+ from ..models import AbbrevPair
14
+
15
+ # Surfaces that carry no discriminating power on their own. A mention of just
16
+ # "unit" or "parameter" is not a term. These are dropped as WHOLE surface forms
17
+ # only, never as substrings β€” so no term containing them is ever lost.
18
+ STOP_SURFACES = {
19
+ "unit",
20
+ "type",
21
+ "class",
22
+ "equipment",
23
+ "equipment unit",
24
+ "parameter",
25
+ "activity",
26
+ "data",
27
+ "nilai",
28
+ "proses",
29
+ "hasil",
30
+ "total",
31
+ }
32
+
33
+
34
+ def normalize(surface: str) -> str:
35
+ s = unicodedata.normalize("NFKC", surface).casefold()
36
+ s = s.replace("-", " ").replace("_", " ")
37
+ s = re.sub(r"[.’']", "", s)
38
+ s = re.sub(r"[^\w\s/()]", " ", s)
39
+ s = re.sub(r"\s+", " ", s)
40
+ return s.strip(" ()/")
41
+
42
+
43
+ def is_noise(surface: str) -> bool:
44
+ n = normalize(surface)
45
+ if len(n) < 2:
46
+ return True
47
+ if n in STOP_SURFACES:
48
+ return True
49
+ return not re.search(r"[a-z]", n) # pure numbers / symbols
50
+
51
+
52
+ class AbbrevIndex:
53
+ """Bidirectional abbreviation ↔ expansion lookup built from legend blocks.
54
+
55
+ This is why the legend filter runs before clustering: without it, `PA` and
56
+ `Physical Availability` never meet.
57
+ """
58
+
59
+ def __init__(self, pairs: list[AbbrevPair]):
60
+ self.to_expansion: dict[str, str] = {}
61
+ self.to_abbrev: dict[str, str] = {}
62
+ for pair in pairs:
63
+ abbrev, expansion = normalize(pair.abbrev), normalize(pair.expansion)
64
+ if not abbrev or not expansion:
65
+ continue
66
+ self.to_expansion[abbrev] = expansion
67
+ self.to_abbrev[expansion] = abbrev
68
+
69
+ def canonical_key(self, surface: str) -> str:
70
+ """Map a surface to a shared key so an abbreviation and its expansion
71
+ collide into the same bucket."""
72
+ n = normalize(surface)
73
+ return self.to_abbrev.get(n, n)
74
+
75
+ def linked(self, a: str, b: str) -> bool:
76
+ na, nb = normalize(a), normalize(b)
77
+ return self.to_expansion.get(na) == nb or self.to_expansion.get(nb) == na
src/knowledge_extraction/config/__init__.py ADDED
File without changes
src/knowledge_extraction/config/cues.yaml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Discourse cues (definitional) and legend-block patterns.
2
+ # Used by cue_filter (rule candidates) and evidence ranking (+5.0 signal).
3
+
4
+ definitional_id:
5
+ - '\badalah kondisi ketika\b'
6
+ - '\badalah\b'
7
+ - '\bmerupakan\b'
8
+ - '\byaitu\b'
9
+ - '\bdidefinisikan sebagai\b'
10
+ - '\bdimaksud dengan\b'
11
+
12
+ definitional_en:
13
+ - '\bis defined as\b'
14
+ - '\brefers to\b'
15
+ - '\bis the\b'
16
+ - '\bmeans\b'
17
+
18
+ conditional_id:
19
+ - '\bapabila\b'
20
+ - '\bjika\b.{0,80}\bmaka\b'
21
+ - '\bbila\b'
22
+ - '\bharus\b'
23
+ - '\btidak boleh\b'
24
+ - '\bwajib\b'
25
+
26
+ conditional_en:
27
+ - '\bif\b.{0,80}\bthen\b'
28
+ - '\bmust\b'
29
+ - '\bshall\b'
30
+ - '\bNote that\b'
31
+
32
+ note_id:
33
+ - '^\s*Catatan\b'
34
+ - '^\s*Sebagai contoh\b'
35
+ - '^\s*Contoh\b'
36
+
37
+ # Legend blocks: 'Keterangan:' / 'Dimana:' / 'where' introduce symbol tables.
38
+ legend_headers:
39
+ - '^\s*Keterangan\s*[::]'
40
+ - '^\s*Dimana\s*[::]'
41
+ - '^\s*Di\s*mana\s*[::]'
42
+ - '^\s*[Ww]here\s*[::]'
43
+
44
+ legend_entry: '^\s*([A-Za-z][A-Za-z0-9_\.]{0,12})\s*[:=]\s*(.+)$'
45
+
46
+ # A chunk is 'formula present' if any of these appear.
47
+ formula_markers:
48
+ - '='
49
+ - 'Γ—'
50
+ - '\bx\s*100\s*%'
51
+ - '/'
src/knowledge_extraction/config/labels.yaml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GLiNER entity labels. E1 tunes these WITHOUT touching code.
2
+ # Two variants so E1 can A/B label phrasing (GLiNER is sensitive to label wording).
3
+
4
+ default:
5
+ - mining production metric
6
+ - equipment availability term
7
+ - operational abbreviation
8
+ - machine or equipment type
9
+ - time category
10
+ - production activity
11
+ - measurement unit
12
+
13
+ # Variant B: Indonesian-language label phrasing. GLiNER multi is multilingual;
14
+ # whether ID label strings improve ID recall is exactly what E1 tests.
15
+ id_phrasing:
16
+ - istilah produksi tambang
17
+ - parameter ketersediaan alat
18
+ - singkatan operasional
19
+ - jenis alat berat
20
+ - kategori waktu kerja
21
+ - aktivitas produksi
22
+ - satuan pengukuran
23
+
24
+ # Variant C: the first two variants both missed the same class of terms β€”
25
+ # mining activities and materials (coal mining, land clearing, waste removal,
26
+ # joint survey). This variant adds labels for that class specifically.
27
+ broad:
28
+ - mining production metric
29
+ - equipment availability term
30
+ - operational abbreviation
31
+ - machine or equipment type
32
+ - time category
33
+ - mining activity
34
+ - material or commodity
35
+ - measurement unit
36
+ - data source or system
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
src/knowledge_extraction/diff/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .glossary_diff import classify, diff_glossary
2
+
3
+ __all__ = ["classify", "diff_glossary"]
src/knowledge_extraction/diff/glossary_diff.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Classify each candidate against the ACTIVE glossary version: new, duplicate
2
+ or conflicting.
3
+
4
+ The prototype diffed against the file it then overwrote, so every entry came
5
+ back `new` and the interesting paths never ran. The baseline must therefore be
6
+ supplied explicitly β€” an approved, versioned set β€” rather than read from
7
+ wherever the last run happened to write.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ..models import DiffStatus
13
+ from ..settings import DUPLICATE_OVERLAP_THRESHOLD
14
+ from ..validate.conflict import overlap
15
+
16
+
17
+ def classify(entry: dict, existing_by_term: dict[str, dict]) -> DiffStatus:
18
+ prior = existing_by_term.get((entry.get("term") or "").casefold())
19
+ if prior is None:
20
+ return "new"
21
+ a = (entry.get("definition") or "").strip()
22
+ b = (prior.get("definition") or "").strip()
23
+ if a and a == b:
24
+ return "duplicate"
25
+ if not a or not b:
26
+ # One side abstained: not a contradiction, just less information.
27
+ return "new"
28
+ return "duplicate" if overlap(a, b) >= DUPLICATE_OVERLAP_THRESHOLD else "conflicting"
29
+
30
+
31
+ def diff_glossary(entries: list[dict], active: list[dict]) -> list[dict]:
32
+ existing_by_term = {(e.get("term") or "").casefold(): e for e in active}
33
+ return [{**entry, "diff_status": classify(entry, existing_by_term)} for entry in entries]
src/knowledge_extraction/extract/__init__.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .base import cacheable, est_tokens, evidence_block, load_prompt, prefix_tokens
2
+ from .branches import (
3
+ build_glossary_prompt,
4
+ extract_formula,
5
+ extract_glossary,
6
+ extract_rule,
7
+ extract_summary,
8
+ )
9
+ from .client import AzureExtractor, LLMResult, MockExtractor
10
+ from .schemas import schema_for
11
+
12
+ __all__ = [
13
+ "AzureExtractor",
14
+ "LLMResult",
15
+ "MockExtractor",
16
+ "build_glossary_prompt",
17
+ "cacheable",
18
+ "est_tokens",
19
+ "evidence_block",
20
+ "extract_formula",
21
+ "extract_glossary",
22
+ "extract_rule",
23
+ "extract_summary",
24
+ "load_prompt",
25
+ "prefix_tokens",
26
+ "schema_for",
27
+ ]
src/knowledge_extraction/extract/base.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared plumbing for the four extraction branches.
2
+
3
+ Prompts live in `prompts/*.txt`, never in code, for two reasons: a prompt change
4
+ is not a code change, and **the fixed prefix must stay byte-identical across
5
+ calls** or prompt caching silently stops engaging at roughly 10x the input cost.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+
13
+ from ..models import Chunk
14
+ from ..settings import CACHE_MIN_TOKENS
15
+
16
+ PROMPT_DIR = Path(__file__).parent / "prompts"
17
+
18
+
19
+ def est_tokens(text: str) -> int:
20
+ """Cheap estimate, for dry-run budgeting only. Real counts come from the
21
+ API's usage object β€” never report a cached price from an estimate."""
22
+ return max(1, int(len(text) / 3.6))
23
+
24
+
25
+ @lru_cache(maxsize=8)
26
+ def load_prompt(branch: str) -> str:
27
+ return (PROMPT_DIR / f"{branch}.txt").read_text(encoding="utf-8")
28
+
29
+
30
+ def prefix_tokens(branch: str) -> int:
31
+ return est_tokens(load_prompt(branch))
32
+
33
+
34
+ def cacheable(branch: str) -> bool:
35
+ """Whether the fixed prefix is long enough to cache at all.
36
+
37
+ Reported, never assumed: caching does not engage below the floor, so a
38
+ shorter prefix caches nothing. Only the API's `cached_tokens` proves a hit.
39
+ """
40
+ return prefix_tokens(branch) >= CACHE_MIN_TOKENS
41
+
42
+
43
+ def evidence_block(chunks: list[Chunk], scores: list[float] | None = None) -> str:
44
+ """Evidence labelled with chunk_id, section and page so the model can cite
45
+ provenance and we can trace which evidence produced which field.
46
+
47
+ **The heading is included, and must stay included.** Two reasons:
48
+
49
+ 1. Indonesian standards name the term in the heading and open the body with
50
+ the definition β€” "2.1.3 Physical of Availability (PA)" / "Adalah
51
+ ketersediaan fisik…" β€” so the body often never repeats the term. Without
52
+ the heading the model is asked to define a term the evidence never names.
53
+ 2. It keeps one invariant true: **what the model reads is exactly what the
54
+ span check searches.** `validate.evidence_text` composes heading + text;
55
+ if this block showed only the text, the model could never quote a
56
+ section title, and any field that did quote one would be rejected as
57
+ unlocatable.
58
+ """
59
+ parts = []
60
+ for i, chunk in enumerate(chunks):
61
+ score = f" score={scores[i]:.1f}" if scores and i < len(scores) else ""
62
+ head = f"{chunk.heading}\n" if chunk.heading else ""
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)
src/knowledge_extraction/extract/branches.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The four extraction branches. This is the only stage that costs money.
2
+
3
+ **One call per TERM CLUSTER** β€” not per mention, not per chunk. That is the
4
+ whole economic argument for clustering: 200 mentions of "PA" cost one call, not
5
+ 200. It is also what makes conflict detection possible, since contradictory
6
+ definitions can only be compared when they arrive together.
7
+
8
+ Each branch returns `(entry, usage)`, with `None` for the entry when the
9
+ response fails schema validation. A failed parse is not an exception: one bad
10
+ response must not abort a corpus-scale run that has already paid for parsing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ...middlewares.logging import get_logger
16
+ from ..models import (
17
+ BriefContext,
18
+ CallUsage,
19
+ Chunk,
20
+ FormulaEntry,
21
+ FormulaVariable,
22
+ GlossaryEntry,
23
+ Provenance,
24
+ RuleCandidate,
25
+ RuleEntry,
26
+ TermCluster,
27
+ )
28
+ from ..rank import top_k
29
+ from ..settings import EVIDENCE_K
30
+ from .base import evidence_block, load_prompt
31
+ from .schemas import FormulaDraft, GlossaryDraft, RuleDraft, SummaryDraft, schema_for
32
+
33
+ logger = get_logger("knowledge_extract")
34
+
35
+
36
+ def _prov(draft_prov, doc_id: str, chunk_id: str | None = None) -> Provenance:
37
+ return Provenance(
38
+ doc_id=doc_id,
39
+ span=draft_prov.span,
40
+ page=draft_prov.page,
41
+ section_no=draft_prov.section_no,
42
+ chunk_id=chunk_id,
43
+ )
44
+
45
+
46
+ def _evidence_for(
47
+ cluster: TermCluster, chunks: list[Chunk], k: int, round_index: int
48
+ ) -> tuple[list[Chunk], list[float]]:
49
+ by_id = {c.chunk_id: c for c in chunks}
50
+ ids = top_k(cluster, k=k, round_index=round_index)
51
+ scores = cluster.evidence_scores[round_index * k : round_index * k + k]
52
+ return [by_id[i] for i in ids if i in by_id], scores
53
+
54
+
55
+ # ── glossary ────────────────────────────────────────────────────────────
56
+
57
+
58
+ def build_glossary_prompt(
59
+ cluster: TermCluster, chunks: list[Chunk], k: int = EVIDENCE_K, round_index: int = 0
60
+ ) -> tuple[str, str]:
61
+ evidence, scores = _evidence_for(cluster, chunks, k, round_index)
62
+ user = (
63
+ f"CANDIDATE TERM: {cluster.canonical}\n"
64
+ f"KNOWN VARIANTS: {', '.join(cluster.variants)}\n"
65
+ f"MENTION COUNT: {cluster.mention_count}\n\n"
66
+ + evidence_block(evidence, scores)
67
+ )
68
+ return load_prompt("glossary"), user
69
+
70
+
71
+ def extract_glossary(
72
+ cluster: TermCluster,
73
+ chunks: list[Chunk],
74
+ extractor,
75
+ doc_id: str,
76
+ k: int = EVIDENCE_K,
77
+ round_index: int = 0,
78
+ ) -> tuple[GlossaryEntry | None, CallUsage]:
79
+ system, user = build_glossary_prompt(cluster, chunks, k, round_index)
80
+ result = extractor.complete(
81
+ "glossary", system, user, schema_for("glossary"), "GlossaryEntry"
82
+ )
83
+ try:
84
+ draft = GlossaryDraft.model_validate(result.data)
85
+ except Exception as exc:
86
+ logger.warning(
87
+ "glossary draft invalid", cluster=cluster.canonical, error=repr(exc)
88
+ )
89
+ return None, result.usage
90
+
91
+ evidence, _ = _evidence_for(cluster, chunks, k, round_index)
92
+ entry = GlossaryEntry(
93
+ term=draft.term,
94
+ full_name=draft.full_name,
95
+ source_wording=_heading_wording(cluster, evidence) or draft.source_wording,
96
+ definition=draft.definition,
97
+ formula_latex=draft.formula_latex,
98
+ interpretation=draft.interpretation,
99
+ subdomain_tags=draft.subdomain_tags,
100
+ domain=draft.domain,
101
+ company=draft.company,
102
+ language=draft.language,
103
+ mention_count=cluster.mention_count,
104
+ provenance=_prov(
105
+ draft.provenance, doc_id, evidence[0].chunk_id if evidence else None
106
+ ),
107
+ )
108
+ return entry, result.usage
109
+
110
+
111
+ def _heading_wording(cluster: TermCluster, evidence: list[Chunk]) -> str | None:
112
+ """The verbatim heading of the evidence chunk whose title names this term.
113
+
114
+ Preferred over whatever the model chose to quote, because the section
115
+ heading is where the document formally names the term. Measured on the
116
+ reference standard: the model quoted "Physical Availability (PA)" from the
117
+ page-1 intro β€” a real verbatim quote β€” while the section itself is headed
118
+ "Physical **of** Availability (PA)". Both occur in the document; only the
119
+ heading form reveals that the two disagree.
120
+
121
+ Recording the literal form is a locked decision: the discrepancy belongs to
122
+ the expert, not to us. Taking it deterministically rather than asking the
123
+ model to volunteer it means it cannot be normalised away.
124
+ """
125
+ from ..cluster.normalize import normalize
126
+ from ..rank.evidence import _word_match
127
+
128
+ variants = [normalize(v) for v in cluster.variants]
129
+ for chunk in evidence:
130
+ heading = chunk.heading
131
+ if heading and any(_word_match(v, normalize(heading)) for v in variants):
132
+ return heading
133
+ return None
134
+
135
+
136
+ # ── rule of thumb ───────────────────────────────────────────────────────
137
+
138
+
139
+ def extract_rule(
140
+ candidate: RuleCandidate, chunk: Chunk, extractor, doc_id: str
141
+ ) -> tuple[RuleEntry | None, CallUsage]:
142
+ user = (
143
+ f"CUE: {candidate.cue}\n\n"
144
+ + evidence_block([chunk])
145
+ + f"\n\nFOCUS PASSAGE:\n{candidate.snippet}"
146
+ )
147
+ result = extractor.complete(
148
+ "rule", load_prompt("rule"), user, schema_for("rule"), "RuleEntry"
149
+ )
150
+ try:
151
+ draft = RuleDraft.model_validate(result.data)
152
+ except Exception as exc:
153
+ logger.warning("rule draft invalid", chunk_id=candidate.chunk_id, error=repr(exc))
154
+ return None, result.usage
155
+
156
+ return (
157
+ RuleEntry(
158
+ rule_id=draft.rule_id,
159
+ statement=draft.statement,
160
+ condition=draft.condition,
161
+ consequence=draft.consequence,
162
+ applies_to=draft.applies_to,
163
+ subdomain_tags=draft.subdomain_tags,
164
+ language=draft.language,
165
+ provenance=_prov(draft.provenance, doc_id, chunk.chunk_id),
166
+ ),
167
+ result.usage,
168
+ )
169
+
170
+
171
+ # ── formula ─────────────────────────────────────────────────────────────
172
+
173
+
174
+ def extract_formula(
175
+ chunk: Chunk, extractor, doc_id: str
176
+ ) -> tuple[FormulaEntry | None, CallUsage]:
177
+ user = evidence_block([chunk])
178
+ result = extractor.complete(
179
+ "formula", load_prompt("formula"), user, schema_for("formula"), "FormulaEntry"
180
+ )
181
+ try:
182
+ draft = FormulaDraft.model_validate(result.data)
183
+ except Exception as exc:
184
+ logger.warning("formula draft invalid", chunk_id=chunk.chunk_id, error=repr(exc))
185
+ return None, result.usage
186
+
187
+ return (
188
+ FormulaEntry(
189
+ name=draft.name,
190
+ formula_latex=draft.formula_latex,
191
+ variables=[
192
+ FormulaVariable(symbol=v.symbol, meaning=v.meaning) for v in draft.variables
193
+ ],
194
+ unit=draft.unit,
195
+ provenance=_prov(draft.provenance, doc_id, chunk.chunk_id),
196
+ ),
197
+ result.usage,
198
+ )
199
+
200
+
201
+ # ── summary ─────────────────────────────────────────────────────────────
202
+
203
+
204
+ def extract_summary(
205
+ chunks: list[Chunk], extractor, doc_id: str
206
+ ) -> tuple[BriefContext | None, CallUsage]:
207
+ """Whole-document summary β€” the quiet cost risk. Few calls, but a large
208
+ share of all input tokens, because summarisation cannot be filtered: it
209
+ needs the whole document.
210
+
211
+ It is also the only branch that cannot be span-checked at all. A plausible
212
+ summary is indistinguishable from a correct one, which is exactly why it
213
+ belongs on a larger tier as soon as one exists.
214
+ """
215
+ user = evidence_block(chunks)
216
+ result = extractor.complete(
217
+ "summary", load_prompt("summary"), user, schema_for("summary"), "BriefContext"
218
+ )
219
+ try:
220
+ draft = SummaryDraft.model_validate(result.data)
221
+ except Exception as exc:
222
+ logger.warning("summary draft invalid", error=repr(exc))
223
+ return None, result.usage
224
+
225
+ return (
226
+ BriefContext(
227
+ title=draft.title,
228
+ purpose=draft.purpose,
229
+ scope=draft.scope,
230
+ key_parameters=draft.key_parameters,
231
+ summary_md=draft.summary_md,
232
+ provenance=_prov(draft.provenance, doc_id),
233
+ ),
234
+ result.usage,
235
+ )
src/knowledge_extraction/extract/client.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM clients for the extraction stage β€” **the only place this pipeline spends
2
+ money.**
3
+
4
+ Two things this module is careful about:
5
+
6
+ - **Structured output is probed, not assumed.** `json_schema` needs a recent
7
+ api_version and we cannot confirm from here what the resource exposes. The
8
+ first call tries it; on rejection it falls back to `json_object` plus
9
+ validate-and-retry, and records which mode actually applied.
10
+ - **Cached tokens are read from the API, never modelled.** Caching does not
11
+ engage below the token floor, so an under-length prefix caches nothing.
12
+ `usage.prompt_tokens_details.cached_tokens` is the only source of truth, and a
13
+ cached price must never be reported without it.
14
+
15
+ All four branches route to the **nano** deployment (`__54n`). That is a recorded
16
+ decision, not an oversight: nano measured 0.75 schema-fill precision against a
17
+ 0.80 line, and `rule`/`summary` β€” whose failure mode is least detectable, since
18
+ a plausible summary cannot be span-checked β€” run there too until a larger
19
+ deployment exists.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import time
26
+ from typing import Any
27
+
28
+ from ...config.settings import settings as app_settings
29
+ from ...middlewares.logging import get_logger
30
+ from ..models import Branch, CallUsage
31
+ from ..settings import TEMPERATURE
32
+
33
+ logger = get_logger("knowledge_extract_client")
34
+
35
+ MAX_RETRIES = 3
36
+
37
+
38
+ class LLMResult:
39
+ def __init__(self, data: dict, usage: CallUsage, raw: str = ""):
40
+ self.data = data
41
+ self.usage = usage
42
+ self.raw = raw
43
+
44
+
45
+ class AzureExtractor:
46
+ """Real calls, real spend. Always dry-run before a corpus-scale run."""
47
+
48
+ def __init__(self, client=None, deployment: str | None = None):
49
+ self.deployment = deployment or app_settings.azureai_deployment_name_54n
50
+ self._client = client or self._build_client()
51
+ self._mode: str | None = None # resolved on the first successful call
52
+
53
+ @staticmethod
54
+ def _build_client():
55
+ from openai import AzureOpenAI
56
+
57
+ endpoint = app_settings.azureai_endpoint_url_54n
58
+ api_key = app_settings.azureai_api_key_54n
59
+ if not endpoint or not api_key:
60
+ raise RuntimeError(
61
+ "azureai__endpoint__url__54n / azureai__api_key__54n are not set. "
62
+ "Use the mock extractor to run without Azure."
63
+ )
64
+ return AzureOpenAI(
65
+ azure_endpoint=endpoint,
66
+ api_key=api_key,
67
+ api_version=app_settings.azureai_api_version_54n,
68
+ )
69
+
70
+ def complete(
71
+ self,
72
+ branch: Branch,
73
+ system_prompt: str,
74
+ user_prompt: str,
75
+ schema: dict,
76
+ schema_name: str,
77
+ ) -> LLMResult:
78
+ messages = [
79
+ {"role": "system", "content": system_prompt},
80
+ {"role": "user", "content": user_prompt},
81
+ ]
82
+ last_error: Exception | None = None
83
+
84
+ for attempt in range(MAX_RETRIES):
85
+ mode = self._mode or "json_schema"
86
+ started = time.time()
87
+ try:
88
+ response = self._client.chat.completions.create(
89
+ model=self.deployment,
90
+ messages=messages,
91
+ temperature=TEMPERATURE,
92
+ response_format=self._response_format(mode, schema, schema_name),
93
+ )
94
+ except Exception as exc:
95
+ if mode == "json_schema" and self._looks_unsupported(exc):
96
+ logger.info(
97
+ "json_schema unsupported β€” falling back to json_object",
98
+ error=repr(exc),
99
+ )
100
+ self._mode = "json_object"
101
+ continue
102
+ last_error = exc
103
+ logger.warning("call failed", branch=branch, attempt=attempt, error=repr(exc))
104
+ time.sleep(2**attempt)
105
+ continue
106
+
107
+ self._mode = mode
108
+ content = response.choices[0].message.content or "{}"
109
+ try:
110
+ data = json.loads(content)
111
+ except json.JSONDecodeError as exc:
112
+ last_error = exc
113
+ logger.warning("unparseable JSON", branch=branch, attempt=attempt)
114
+ continue
115
+
116
+ usage = self._usage(response, branch, time.time() - started, attempt, mode)
117
+ return LLMResult(data, usage, content)
118
+
119
+ raise RuntimeError(f"{branch}: all {MAX_RETRIES} attempts failed: {last_error!r}")
120
+
121
+ @staticmethod
122
+ def _response_format(mode: str, schema: dict, schema_name: str) -> dict:
123
+ if mode == "json_schema":
124
+ return {
125
+ "type": "json_schema",
126
+ "json_schema": {"name": schema_name, "schema": schema, "strict": False},
127
+ }
128
+ return {"type": "json_object"}
129
+
130
+ @staticmethod
131
+ def _looks_unsupported(exc: Exception) -> bool:
132
+ text = str(exc).lower()
133
+ return any(
134
+ s in text
135
+ for s in ("response_format", "json_schema", "unsupported", "invalid_request")
136
+ )
137
+
138
+ def _usage(
139
+ self, response: Any, branch: Branch, latency: float, retries: int, mode: str
140
+ ) -> CallUsage:
141
+ usage = getattr(response, "usage", None)
142
+ details = getattr(usage, "prompt_tokens_details", None)
143
+ # The ONLY source of truth for caching. Absent -> cached stays 0 and the
144
+ # uncached regime is what gets reported.
145
+ cached = int(getattr(details, "cached_tokens", 0) or 0) if details else 0
146
+ return CallUsage(
147
+ branch=branch,
148
+ deployment=self.deployment,
149
+ tier="nano",
150
+ prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
151
+ cached_tokens=cached,
152
+ completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
153
+ latency_s=round(latency, 3),
154
+ retries=retries,
155
+ structured_output_mode=mode,
156
+ simulated=False,
157
+ )
158
+
159
+
160
+ class MockExtractor:
161
+ """No network, no spend. Every record it produces is stamped `simulated`.
162
+
163
+ Exercises the wiring β€” schema validation, span checking, escalation,
164
+ conflicts, diff, queue β€” without credentials. It is **not** a model-quality
165
+ measurement and its output must never be reported as one.
166
+
167
+ It abstains by default (returns null definitions), because abstention is the
168
+ dominant real behaviour: on the reference document 56 of 66 entries had no
169
+ definition. A mock that always answers would make the downstream stages look
170
+ far better exercised than they are.
171
+ """
172
+
173
+ def __init__(self, responses: dict[str, dict] | None = None, deployment: str = "mock"):
174
+ self.responses = responses or {}
175
+ self.deployment = deployment
176
+ self.calls: list[tuple[str, str]] = []
177
+
178
+ def complete(
179
+ self,
180
+ branch: Branch,
181
+ system_prompt: str,
182
+ user_prompt: str,
183
+ schema: dict,
184
+ schema_name: str,
185
+ ) -> LLMResult:
186
+ self.calls.append((branch, user_prompt))
187
+ data = self.responses.get(branch) or self._abstain(branch, user_prompt)
188
+ usage = CallUsage(
189
+ branch=branch,
190
+ deployment=self.deployment,
191
+ prompt_tokens=len(system_prompt) // 4 + len(user_prompt) // 4,
192
+ completion_tokens=40,
193
+ structured_output_mode="mock",
194
+ simulated=True,
195
+ )
196
+ return LLMResult(data, usage, json.dumps(data))
197
+
198
+ @staticmethod
199
+ def _abstain(branch: Branch, user_prompt: str) -> dict:
200
+ # Quote a real fragment so the span check has something locatable and is
201
+ # genuinely exercised rather than trivially passed.
202
+ span = ""
203
+ if "EVIDENCE" in user_prompt:
204
+ body = user_prompt.split("EVIDENCE", 1)[1]
205
+ for line in body.splitlines():
206
+ if line.strip() and not line.startswith("["):
207
+ span = line.strip()[:60]
208
+ break
209
+ prov = {"section_no": None, "page": 1, "span": span}
210
+ if branch == "glossary":
211
+ term = "unknown"
212
+ for line in user_prompt.splitlines():
213
+ if line.startswith("CANDIDATE TERM:"):
214
+ term = line.split(":", 1)[1].strip()
215
+ break
216
+ return {"term": term, "definition": None, "provenance": prov}
217
+ if branch == "rule":
218
+ return {"rule_id": "r_mock", "statement": None, "provenance": prov}
219
+ if branch == "formula":
220
+ return {"name": None, "formula_latex": None, "provenance": prov}
221
+ return {"title": None, "summary_md": None, "provenance": prov}
src/knowledge_extraction/extract/prompts/formula.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You transcribe FORMULAS from Indonesian and English mining-operations standards.
2
+
3
+ This is a TRANSCRIPTION task, not a derivation task. You are converting a formula
4
+ that is already written in the evidence into LaTeX. You must never derive, simplify,
5
+ correct, or complete a formula.
6
+
7
+ RULES β€” correctness requirements:
8
+ 1. If no formula is present in the evidence, set "formula_latex" to null. A null
9
+ answer is a CORRECT answer.
10
+ 2. Transcribe exactly. If the source writes "x 100%", keep the percentage.
11
+ 3. List every variable that appears, with the meaning ONLY if the evidence states it
12
+ (usually in a "Keterangan:" or "Dimana:" legend block). Otherwise meaning is null.
13
+ 4. "provenance.span" must be copied VERBATIM from the evidence and is checked
14
+ automatically against the source.
15
+
16
+ FIELD GUIDE:
17
+ - name: what the formula computes, as named in the source
18
+ - formula_latex: LaTeX transcription
19
+ - variables: [{"symbol": "...", "meaning": "..." or null}, ...]
20
+ - unit: the result unit if stated (BCM, ton, %, hours), else null
21
+
22
+ WORKED EXAMPLE:
23
+ Evidence: "Secara umum, PA dihitung menggunakan rumus berikut:\nPA = Total Hours -
24
+ Breakdown / Total Hours x 100%\nKeterangan:\nPA : Physical Availability"
25
+ Output:
26
+ {"name": "Physical Availability (PA)", "formula_latex": "PA = \\frac{Total\\ Hours - Breakdown}{Total\\ Hours} \\times 100\\%", "variables": [{"symbol": "PA", "meaning": "Physical Availability"}, {"symbol": "Total Hours", "meaning": null}, {"symbol": "Breakdown", "meaning": null}], "unit": "%", "provenance": {"section_no": "2.1.3", "page": 4, "span": "PA = Total Hours - Breakdown"}}
27
+
28
+ Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/extract/prompts/glossary.txt ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You extract glossary entries from Indonesian and English mining-operations standards.
2
+
3
+ RULES β€” these are not style preferences, they are correctness requirements:
4
+ 1. Extract ONLY what the evidence states. Never infer, complete, or generalise.
5
+ 2. If the evidence does not define the term, set "definition" to null. A null answer
6
+ is a CORRECT answer. Guessing is the single worst failure mode here.
7
+ 3. "provenance.span" must be copied VERBATIM from the evidence β€” character for
8
+ character, including Indonesian spelling. It is automatically checked against the
9
+ source and the field is discarded if it does not match exactly.
10
+ 4. "subdomain_tags" must come from the allowed list only. Do not invent tags.
11
+ 5. Preserve the source language. Do not translate an Indonesian definition to English.
12
+
13
+ ALLOWED subdomain_tags:
14
+ production, maintenance, hauling, loading, drilling_blasting, equipment, safety,
15
+ quality, planning, cost, geology, other
16
+
17
+ FIELD GUIDE:
18
+ - term: the term as a reader would look it up (usually the abbreviation, e.g. "PA")
19
+ - full_name: the expanded form exactly as written in the source, else null
20
+ - definition: the definition as stated, else null
21
+ - formula_latex: LaTeX of the formula IF the evidence shows one, else null
22
+ - interpretation: what a high or low value means operationally, ONLY if stated, else null
23
+ - language: "id", "en", or "mixed" β€” the language of the definition you extracted
24
+ - provenance.span: the exact sentence or phrase you took the definition from
25
+
26
+ WORKED EXAMPLE 1 (definition present):
27
+ Evidence: "2.1.3. Physical of Availability (PA)\nAdalah ketersediaan fisik suatu
28
+ equipment/unit yang menunjukkan proporsi waktu equipment/unit tersebut berada pada
29
+ kondisi available (siap pakai) selama suatu periode tertentu."
30
+ Output:
31
+ {"term": "PA", "full_name": "Physical of Availability", "definition": "Adalah ketersediaan fisik suatu equipment/unit yang menunjukkan proporsi waktu equipment/unit tersebut berada pada kondisi available (siap pakai) selama suatu periode tertentu.", "formula_latex": null, "interpretation": null, "subdomain_tags": ["equipment", "production"], "domain": "mining", "company": null, "language": "id", "provenance": {"section_no": "2.1.3", "page": 4, "span": "Adalah ketersediaan fisik suatu equipment/unit"}}
32
+
33
+ WORKED EXAMPLE 2 (term appears but is NOT defined β€” the important case):
34
+ Evidence: "Gain/Loss menggunakan satuan BCM atau ton, Gap Standby menggunakan satuan
35
+ jam (hours)"
36
+ Candidate term: "BCM"
37
+ Output:
38
+ {"term": "BCM", "full_name": null, "definition": null, "formula_latex": null, "interpretation": null, "subdomain_tags": ["production"], "domain": "mining", "company": null, "language": "id", "provenance": {"section_no": "2.2.1", "page": 6, "span": "Gain/Loss menggunakan satuan BCM atau ton"}}
39
+ Note: the term is mentioned but never defined, so definition is null. This is correct.
40
+
41
+ WORKED EXAMPLE 3 (formula present):
42
+ Evidence: "Secara umum, PA dihitung menggunakan rumus berikut:\nPA = Total Hours -
43
+ Breakdown / Total Hours x 100%"
44
+ Output field: "formula_latex": "PA = \\frac{Total\\ Hours - Breakdown}{Total\\ Hours} \\times 100\\%"
45
+
46
+ WORKED EXAMPLE 4 (mixed-language definition, abbreviation and expansion both present):
47
+ Evidence: "2.1.4. Utilization of Availability (UA)\nAdalah tingkat efektifitas
48
+ penggunaan suatu alat yang tersedia secara fisik yang menunjukkan seberapa lama suatu
49
+ unit digunakan secara efektif untuk bekerja - working hours selama berada dalam
50
+ kondisi siap dioperasikan (available)."
51
+ Output:
52
+ {"term": "UA", "full_name": "Utilization of Availability", "definition": "Adalah tingkat efektifitas penggunaan suatu alat yang tersedia secara fisik yang menunjukkan seberapa lama suatu unit digunakan secara efektif untuk bekerja - working hours selama berada dalam kondisi siap dioperasikan (available).", "formula_latex": null, "interpretation": null, "subdomain_tags": ["equipment", "production"], "domain": "mining", "company": null, "language": "mixed", "provenance": {"section_no": "2.1.4", "page": 4, "span": "Adalah tingkat efektifitas penggunaan suatu alat yang tersedia secara fisik"}}
53
+ Note: language is "mixed" because the Indonesian definition embeds English terms.
54
+
55
+ COMMON MISTAKES TO AVOID:
56
+ - Do NOT translate an Indonesian definition into English. Keep the source language.
57
+ - Do NOT expand an abbreviation yourself. If the source never writes the expansion,
58
+ full_name is null.
59
+ - Do NOT copy a definition from your own knowledge of mining. If this document does
60
+ not define the term, the answer is null, even if you know what the term means.
61
+ - Do NOT paraphrase the provenance span to make it shorter or cleaner. It is compared
62
+ character by character against the source and a paraphrase will be rejected.
63
+ - Do NOT merge two different terms into one entry. Extract only the candidate term.
64
+ - Do NOT put units, table captions, or figure labels in the definition field.
65
+ - If the evidence contains only a formula and no prose, set definition to null and
66
+ fill formula_latex only.
67
+
68
+ Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/extract/prompts/rule.txt ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You extract operational RULES from Indonesian and English mining-operations standards.
2
+
3
+ A rule is a statement that constrains or directs behaviour: a condition and what
4
+ follows from it, a requirement, a prohibition, or a calculation policy.
5
+
6
+ RULES β€” correctness requirements, not style preferences:
7
+ 1. Extract ONLY what the evidence states. Never infer or complete a rule.
8
+ 2. If the evidence is descriptive rather than prescriptive, set "statement" to null.
9
+ A null answer is a CORRECT answer.
10
+ 3. "provenance.span" must be copied VERBATIM from the evidence. It is checked
11
+ automatically against the source and discarded if it does not match exactly.
12
+ 4. "subdomain_tags" must come from the allowed list only.
13
+ 5. Preserve the source language. Do not translate.
14
+
15
+ ALLOWED subdomain_tags:
16
+ production, maintenance, hauling, loading, drilling_blasting, equipment, safety,
17
+ quality, planning, cost, geology, other
18
+
19
+ FIELD GUIDE:
20
+ - rule_id: SCREAMING_SNAKE_CASE, descriptive, derived from the rule's subject
21
+ - statement: the rule in one sentence, as stated in the source
22
+ - condition: the triggering condition if the rule is conditional, else null
23
+ - consequence: what must happen when the condition holds, else null
24
+ - applies_to: the parameter, equipment, or activity the rule governs, else null
25
+
26
+ WORKED EXAMPLE 1 (conditional rule):
27
+ Evidence: "Production yang digunakan dalam perhitungan adalah produksi hasil joint
28
+ survey. Apabila data joint survey belum tersedia, maka digunakan data produksi
29
+ berdasarkan truck count sebagai dasar perhitungan."
30
+ Output:
31
+ {"rule_id": "PTY_PRODUCTION_SOURCE", "statement": "Production yang digunakan dalam perhitungan adalah produksi hasil joint survey.", "condition": "Apabila data joint survey belum tersedia", "consequence": "digunakan data produksi berdasarkan truck count sebagai dasar perhitungan", "applies_to": "Productivity (Pty)", "subdomain_tags": ["production", "planning"], "language": "id", "provenance": {"section_no": "2.1.5", "page": 5, "span": "Apabila data joint survey belum tersedia, maka digunakan data produksi berdasarkan truck count"}}
32
+
33
+ WORKED EXAMPLE 2 (descriptive, NOT a rule β€” the important case):
34
+ Evidence: "Waterfall Analysis dapat di-review melalui dua metode."
35
+ Output:
36
+ {"rule_id": "NONE", "statement": null, "condition": null, "consequence": null, "applies_to": null, "subdomain_tags": ["other"], "language": "id", "provenance": {"section_no": "2.2.5", "page": 8, "span": "Waterfall Analysis dapat di-review melalui dua metode"}}
37
+ Note: this describes a capability, it does not constrain behaviour. statement is null.
38
+
39
+ Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/extract/prompts/summary.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You write a short orientation brief for a mining-operations standard document.
2
+
3
+ This is the ONE branch whose output cannot be span-checked, because a summary is by
4
+ nature not verbatim. Treat that as a reason for restraint, not licence: state only
5
+ what the document states, and prefer omission to elaboration.
6
+
7
+ RULES:
8
+ 1. Use only the section headings and text provided. Do not add industry background.
9
+ 2. If the document does not state a purpose or scope, set that field to null.
10
+ 3. "provenance.span" must still be copied VERBATIM from the evidence β€” use the most
11
+ representative sentence from the document's purpose section.
12
+ 4. Keep summary_md under 200 words. It orients a reader; it does not replace the doc.
13
+
14
+ FIELD GUIDE:
15
+ - title: the document's title as written
16
+ - purpose: what the document is for, as stated
17
+ - scope: what it covers, as stated
18
+ - key_parameters: the main parameters or concepts the document defines
19
+ - summary_md: a short markdown orientation, under 200 words
20
+
21
+ Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/extract/schemas.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What the MODEL is asked to return.
2
+
3
+ Deliberately separate from `models.py`. The model never supplies `doc_id` (we
4
+ know it), never sets `extraction_status`, and never sets the conflict or diff
5
+ fields β€” validation owns those. **Asking a model for fields it cannot know is an
6
+ invitation to fabricate**, so the request schema is narrower than the stored one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from ..models import SubdomainEnum
16
+
17
+ Language = Literal["id", "en", "mixed"]
18
+
19
+
20
+ class ProvenanceDraft(BaseModel):
21
+ section_no: str | None = None
22
+ page: int
23
+ span: str
24
+
25
+
26
+ class GlossaryDraft(BaseModel):
27
+ term: str
28
+ full_name: str | None = None
29
+ # The wording exactly as the document writes it, even when "wrong" β€” the
30
+ # standard heads its section "Physical of Availability (PA)". Surfacing the
31
+ # discrepancy is the point; normalising it hides a decision the expert owns.
32
+ source_wording: str | None = None
33
+ definition: str | None = None
34
+ formula_latex: str | None = None
35
+ interpretation: str | None = None
36
+ subdomain_tags: list[SubdomainEnum] = Field(default_factory=list)
37
+ domain: str | None = None
38
+ company: str | None = None
39
+ language: Language | None = None
40
+ provenance: ProvenanceDraft
41
+
42
+
43
+ class RuleDraft(BaseModel):
44
+ rule_id: str
45
+ statement: str | None = None
46
+ condition: str | None = None
47
+ consequence: str | None = None
48
+ applies_to: str | None = None
49
+ subdomain_tags: list[SubdomainEnum] = Field(default_factory=list)
50
+ language: Language | None = None
51
+ provenance: ProvenanceDraft
52
+
53
+
54
+ class VariableDraft(BaseModel):
55
+ symbol: str
56
+ meaning: str | None = None
57
+
58
+
59
+ class FormulaDraft(BaseModel):
60
+ name: str | None = None
61
+ formula_latex: str | None = None
62
+ variables: list[VariableDraft] = Field(default_factory=list)
63
+ unit: str | None = None
64
+ provenance: ProvenanceDraft
65
+
66
+
67
+ class SummaryDraft(BaseModel):
68
+ title: str | None = None
69
+ purpose: str | None = None
70
+ scope: str | None = None
71
+ key_parameters: list[str] = Field(default_factory=list)
72
+ summary_md: str | None = None
73
+ provenance: ProvenanceDraft
74
+
75
+
76
+ DRAFTS = {
77
+ "glossary": GlossaryDraft,
78
+ "rule": RuleDraft,
79
+ "formula": FormulaDraft,
80
+ "summary": SummaryDraft,
81
+ }
82
+
83
+
84
+ def schema_for(branch: str) -> dict:
85
+ return DRAFTS[branch].model_json_schema()
src/knowledge_extraction/filters/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .cue_filter import definitional_hits, rule_candidates
2
+ from .legend_filter import abbrev_pairs, find_legend_lines
3
+ from .span_filter import extract_mentions, load_model
4
+
5
+ __all__ = [
6
+ "abbrev_pairs",
7
+ "definitional_hits",
8
+ "extract_mentions",
9
+ "find_legend_lines",
10
+ "load_model",
11
+ "rule_candidates",
12
+ ]
src/knowledge_extraction/filters/cue_filter.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Discourse-cue regex β†’ rule-of-thumb candidates. Free: zero API calls.
2
+
3
+ Also supplies `definitional_hits`, which evidence ranking uses for its strongest
4
+ signal (+5.0 when a cue sits within 100 characters of a mention). Cue patterns
5
+ live in `config/cues.yaml` so they can be tuned per language without a code
6
+ change.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from functools import lru_cache
13
+
14
+ from ..models import Chunk, RuleCandidate
15
+ from ..settings import load_yaml
16
+
17
+ SNIPPET_CHARS = 240
18
+
19
+
20
+ @lru_cache(maxsize=8)
21
+ def _compiled(group: str) -> tuple[re.Pattern[str], ...]:
22
+ cues = load_yaml("cues.yaml")
23
+ return tuple(
24
+ re.compile(p, re.IGNORECASE | re.MULTILINE) for p in cues.get(group, [])
25
+ )
26
+
27
+
28
+ def _groups(*names: str) -> tuple[re.Pattern[str], ...]:
29
+ out: list[re.Pattern[str]] = []
30
+ for name in names:
31
+ out.extend(_compiled(name))
32
+ return tuple(out)
33
+
34
+
35
+ def definitional_hits(text: str) -> list[tuple[int, int]]:
36
+ """(start, end) of every definitional cue. Consumed by evidence ranking."""
37
+ spans: list[tuple[int, int]] = []
38
+ for pattern in _groups("definitional_id", "definitional_en"):
39
+ spans.extend((m.start(), m.end()) for m in pattern.finditer(text))
40
+ return sorted(spans)
41
+
42
+
43
+ def rule_candidates(chunks: list[Chunk]) -> list[RuleCandidate]:
44
+ """Passages a conditional or note cue marks as possibly stating a rule.
45
+
46
+ Over-inclusive on purpose β€” this is a candidate generator, and the paid
47
+ stage decides whether a rule is actually present. Nothing is dropped here.
48
+ """
49
+ patterns = _groups("conditional_id", "conditional_en", "note_id")
50
+ out: list[RuleCandidate] = []
51
+ for chunk in chunks:
52
+ seen: set[int] = set()
53
+ for pattern in patterns:
54
+ for match in pattern.finditer(chunk.text):
55
+ start = match.start()
56
+ # One candidate per sentence-ish region: several cues in the
57
+ # same clause would otherwise produce near-identical rows.
58
+ bucket = start // 200
59
+ if bucket in seen:
60
+ continue
61
+ seen.add(bucket)
62
+ out.append(
63
+ RuleCandidate(
64
+ chunk_id=chunk.chunk_id,
65
+ cue=match.group(0).strip(),
66
+ char_start=start,
67
+ char_end=match.end(),
68
+ snippet=_snippet(chunk.text, start),
69
+ )
70
+ )
71
+ return out
72
+
73
+
74
+ def _snippet(text: str, start: int) -> str:
75
+ lo = max(0, start - SNIPPET_CHARS // 4)
76
+ return text[lo : lo + SNIPPET_CHARS].strip()
src/knowledge_extraction/filters/legend_filter.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Legend blocks β†’ abbreviation pairs + formula variables. Free: zero API calls.
2
+
3
+ A legend block is the `Keterangan:` / `Dimana:` / `where` table that follows a
4
+ formula and expands its symbols:
5
+
6
+ Keterangan:
7
+ MOHH : Machine on Hand Hours
8
+ Qty : Quantity
9
+
10
+ This runs **before clustering** and that ordering is load-bearing: without the
11
+ abbreviation pairs it produces, `PA` and `Physical Availability` cluster as two
12
+ unrelated terms and the expert sees the same concept twice.
13
+
14
+ `find_legend_lines` additionally feeds evidence ranking (+3.5 when a mention
15
+ sits inside a legend block).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from functools import lru_cache
22
+
23
+ from ..models import AbbrevPair, Chunk
24
+ from ..settings import load_yaml
25
+
26
+ # How far below a legend header the block is assumed to run before a blank line
27
+ # or a non-entry line closes it.
28
+ MAX_BLOCK_LINES = 30
29
+
30
+
31
+ @lru_cache(maxsize=2)
32
+ def _headers() -> tuple[re.Pattern[str], ...]:
33
+ cues = load_yaml("cues.yaml")
34
+ return tuple(re.compile(p) for p in cues.get("legend_headers", []))
35
+
36
+
37
+ @lru_cache(maxsize=2)
38
+ def _entry() -> re.Pattern[str]:
39
+ cues = load_yaml("cues.yaml")
40
+ return re.compile(cues["legend_entry"])
41
+
42
+
43
+ def find_legend_lines(text: str) -> set[int]:
44
+ """Zero-based line numbers that fall inside a legend block."""
45
+ lines = text.split("\n")
46
+ inside: set[int] = set()
47
+ for i, line in enumerate(lines):
48
+ if not any(h.search(line) for h in _headers()):
49
+ continue
50
+ for j in range(i + 1, min(i + 1 + MAX_BLOCK_LINES, len(lines))):
51
+ if not lines[j].strip():
52
+ break
53
+ if not _entry().match(lines[j]):
54
+ break
55
+ inside.add(j)
56
+ return inside
57
+
58
+
59
+ def abbrev_pairs(chunks: list[Chunk]) -> list[AbbrevPair]:
60
+ """Extract `abbrev : expansion` rows from every legend block.
61
+
62
+ Precision matters more than recall here: a wrong pair merges two distinct
63
+ terms into one cluster, which destroys a term silently. So only lines
64
+ inside a detected block are read, never `X : Y` anywhere in the text.
65
+ """
66
+ out: list[AbbrevPair] = []
67
+ seen: set[tuple[str, str]] = set()
68
+ for chunk in chunks:
69
+ lines = chunk.text.split("\n")
70
+ for line_no in sorted(find_legend_lines(chunk.text)):
71
+ match = _entry().match(lines[line_no])
72
+ if not match:
73
+ continue
74
+ abbrev, expansion = match.group(1).strip(), match.group(2).strip()
75
+ if not _plausible(abbrev, expansion):
76
+ continue
77
+ key = (abbrev.casefold(), expansion.casefold())
78
+ if key in seen:
79
+ continue
80
+ seen.add(key)
81
+ out.append(
82
+ AbbrevPair(
83
+ abbrev=abbrev, expansion=expansion, chunk_id=chunk.chunk_id
84
+ )
85
+ )
86
+ return out
87
+
88
+
89
+ def _plausible(abbrev: str, expansion: str) -> bool:
90
+ """Reject rows that are formula lines rather than legend entries."""
91
+ if not abbrev or not expansion:
92
+ return False
93
+ if len(expansion) < 3 or len(expansion) > 120:
94
+ return False
95
+ # An expansion that is mostly digits/operators is the right-hand side of an
96
+ # equation, not a term ("Qty = 360/720").
97
+ letters = sum(c.isalpha() for c in expansion)
98
+ return letters >= max(3, len(expansion) // 3)
src/knowledge_extraction/filters/span_filter.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Span NER over ALL chunks β†’ Mention[]. Free stage: zero API calls, CPU only.
2
+
3
+ **Runs unconditionally on every chunk.** There is deliberately no relevance gate
4
+ in front of it: a binary pre-filter's false negatives drop content *before*
5
+ extraction ever sees it, and dropped content never reaches expert review to be
6
+ caught. The model is cheap enough (CPU encoder, ~400 MB) to just look at
7
+ everything, which turns "zero candidate spans" into the relevance signal as a
8
+ byproduct of looking rather than a judgment made before looking.
9
+
10
+ Two mechanical traps, both of which silently cost recall if reintroduced:
11
+
12
+ 1. **The model truncates long inputs and warns rather than failing.** Feeding a
13
+ whole chunk drops its tail without erroring. Chunks are therefore fed as
14
+ overlapping word windows with offsets remapped back to chunk coordinates.
15
+ 2. **There is a ~12-token span cap.** Compound terms ("UA plan grouping
16
+ (composite) actual") exceed it. Candidates that hit the cap are *flagged*,
17
+ never silently truncated, so a recall shortfall can be attributed to it.
18
+
19
+ The model is loaded lazily and cached: it is heavy, and only this stage needs
20
+ it, so importing the package must not pay for it.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import time
26
+ from functools import lru_cache
27
+
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,
34
+ labels_for,
35
+ )
36
+
37
+ logger = get_logger("knowledge_span_filter")
38
+
39
+ MODEL_NAME = "urchade/gliner_multi-v2.1"
40
+
41
+
42
+ @lru_cache(maxsize=2)
43
+ def load_model(model_name: str = MODEL_NAME):
44
+ """Lazy, cached. Imported inside the function so the package stays importable
45
+ (and the app stays fast to boot) without the model stack."""
46
+ from gliner import GLiNER
47
+
48
+ logger.info("loading span model", model=model_name)
49
+ return GLiNER.from_pretrained(model_name)
50
+
51
+
52
+ def extract_mentions(
53
+ chunks: list[Chunk],
54
+ variant: str | None = None,
55
+ threshold: float | None = None,
56
+ model=None,
57
+ ) -> list[Mention]:
58
+ """Candidate term mentions across every chunk.
59
+
60
+ Never raises: the span filter is the widest net in the pipeline, and a model
61
+ failure must degrade to "no candidates from this chunk" rather than abort a
62
+ corpus-scale ingest that has already paid for parsing. Failures are logged
63
+ with `repr(e)` β€” an empty error string is how a real cause once went missing
64
+ for a day elsewhere in this repo.
65
+ """
66
+ labels, configured_threshold = labels_for(variant) if variant else labels_for()
67
+ score_floor = threshold if threshold is not None else configured_threshold
68
+
69
+ try:
70
+ model = model or load_model()
71
+ except Exception as exc: # pragma: no cover - depends on the model stack
72
+ logger.error("span model unavailable", error=repr(exc))
73
+ return []
74
+
75
+ mentions: list[Mention] = []
76
+ capped = 0
77
+ started = time.time()
78
+
79
+ for chunk in chunks:
80
+ for window_text, offset in _windows(chunk.text):
81
+ try:
82
+ found = model.predict_entities(
83
+ window_text, labels, threshold=score_floor
84
+ )
85
+ except Exception as exc:
86
+ logger.warning(
87
+ "span prediction failed", chunk_id=chunk.chunk_id, error=repr(exc)
88
+ )
89
+ continue
90
+
91
+ for entity in found:
92
+ surface = entity.get("text", "")
93
+ if not surface.strip():
94
+ continue
95
+ hit_cap = len(surface.split()) >= SPAN_TOKEN_CAP
96
+ capped += hit_cap
97
+ mentions.append(
98
+ Mention(
99
+ surface=surface,
100
+ chunk_id=chunk.chunk_id,
101
+ char_start=offset + int(entity.get("start", 0)),
102
+ char_end=offset + int(entity.get("end", 0)),
103
+ label=str(entity.get("label", "")),
104
+ score=float(entity.get("score", 0.0)),
105
+ hit_span_cap=hit_cap,
106
+ )
107
+ )
108
+
109
+ deduped = _dedupe(mentions)
110
+ logger.info(
111
+ "span filter complete",
112
+ chunks=len(chunks),
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
+
124
+ Windowing is on WORDS but offsets must come back in CHARACTERS, so the
125
+ original spacing is walked rather than re-joined β€” re-joining would shift
126
+ every offset in a chunk containing newlines or double spaces, and every
127
+ span check downstream would then fail.
128
+ """
129
+ if not text.strip():
130
+ return []
131
+
132
+ positions: list[tuple[int, int]] = []
133
+ cursor = 0
134
+ for word in text.split():
135
+ start = text.index(word, cursor)
136
+ positions.append((start, start + len(word)))
137
+ cursor = start + len(word)
138
+
139
+ if len(positions) <= WINDOW_WORDS:
140
+ return [(text, 0)]
141
+
142
+ step = max(1, WINDOW_WORDS - WINDOW_OVERLAP)
143
+ windows: list[tuple[str, int]] = []
144
+ for begin in range(0, len(positions), step):
145
+ chunk_words = positions[begin : begin + WINDOW_WORDS]
146
+ if not chunk_words:
147
+ break
148
+ lo, hi = chunk_words[0][0], chunk_words[-1][1]
149
+ windows.append((text[lo:hi], lo))
150
+ if begin + WINDOW_WORDS >= len(positions):
151
+ break
152
+ return windows
153
+
154
+
155
+ def _dedupe(mentions: list[Mention]) -> list[Mention]:
156
+ """Drop duplicates produced by window overlap, keeping the highest score.
157
+
158
+ Overlap is required for recall (a term straddling a window boundary would
159
+ otherwise be missed), and it necessarily re-finds terms in the overlap
160
+ region. Keyed on (chunk, span) so the same surface at a different position
161
+ stays a separate mention β€” mention COUNT drives the review queue ordering.
162
+ """
163
+ best: dict[tuple[str, int, int], Mention] = {}
164
+ for mention in mentions:
165
+ key = (mention.chunk_id, mention.char_start, mention.char_end)
166
+ current = best.get(key)
167
+ if current is None or mention.score > current.score:
168
+ best[key] = mention
169
+ return sorted(best.values(), key=lambda m: (m.chunk_id, m.char_start))
src/knowledge_extraction/models.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic contracts for the knowledge-extraction pipeline.
2
+
3
+ Three invariants are encoded here rather than described in prose, because every
4
+ one of them is a control that a later change could quietly remove:
5
+
6
+ 1. **All content fields are Optional.** A model that cannot answer null will
7
+ fabricate one. Abstention is correct behaviour, never an error.
8
+ 2. **`subdomain_tags` is an enum.** Classification, not generation.
9
+ 3. **`Provenance.span` is mandatory and verbatim-checked.** It is the primary
10
+ anti-hallucination control and the thing that makes expert review
11
+ finishable β€” the reviewer checks a quote against a page, not a claim
12
+ against their memory.
13
+
14
+ `Chunk` here is the pipeline's **internal** unit, deliberately narrower than the
15
+ parsed-document artifact being agreed with Sofhia (the seam). Stages depend only
16
+ on this subset; `adapter.py` maps the seam type onto it, so seam churn lands in
17
+ one file instead of seven. See KNOWLEDGE_PIPELINE_TODO.md Β§3.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from enum import Enum
23
+ from typing import Literal
24
+
25
+ from pydantic import BaseModel, Field
26
+
27
+ Branch = Literal["glossary", "rule", "formula", "summary"]
28
+ ExtractionStatus = Literal["ok", "no_definition_found", "escalated"]
29
+ DiffStatus = Literal["new", "duplicate", "conflicting"]
30
+
31
+
32
+ class SubdomainEnum(str, Enum):
33
+ """Classification target. Extend deliberately β€” a new member changes what
34
+ the model is allowed to answer, which is a prompt change, not a data one."""
35
+
36
+ production = "production"
37
+ maintenance = "maintenance"
38
+ hauling = "hauling"
39
+ loading = "loading"
40
+ drilling_blasting = "drilling_blasting"
41
+ equipment = "equipment"
42
+ safety = "safety"
43
+ quality = "quality"
44
+ planning = "planning"
45
+ cost = "cost"
46
+ geology = "geology"
47
+ other = "other"
48
+
49
+
50
+ # ── Stage 1: the chunk (internal view of the seam artifact) ─────────────
51
+
52
+
53
+ class Chunk(BaseModel):
54
+ """One unit of a parsed document, as the extraction stages need it.
55
+
56
+ `text` must stay **verbatim** from the source document. Span validation
57
+ locates LLM-quoted spans literally inside this text; if it is ever reflowed
58
+ or whitespace-normalised the lookup fails and the field is silently set to
59
+ null. The failure presents as a bad model, but the cause would be here.
60
+ """
61
+
62
+ chunk_id: str
63
+ doc_id: str
64
+ text: str
65
+ page_start: int
66
+ page_end: int
67
+ ordinal: int = 0
68
+
69
+ # Structural context. Both Optional β€” many documents carry no numbering.
70
+ section_no: str | None = None
71
+ heading: str | None = None
72
+
73
+ # Cheap downstream filters / ranking signals
74
+ has_formula: bool = False
75
+ is_tabular: bool = False
76
+ bold_spans: list[str] = Field(default_factory=list)
77
+
78
+
79
+ class ParsedDoc(BaseModel):
80
+ """A document's chunks plus the identity needed to version and cache them."""
81
+
82
+ doc_id: str
83
+ source_ref: str
84
+ content_hash: str
85
+ n_pages: int
86
+ chunks: list[Chunk]
87
+ parser_name: str = "unknown"
88
+ parser_version: str = ""
89
+ used_heading_split: bool = False
90
+
91
+
92
+ # ── Stage 2: filters ────────────────────────────────────────────────────
93
+
94
+
95
+ class Mention(BaseModel):
96
+ """One occurrence of a candidate term inside a chunk."""
97
+
98
+ surface: str
99
+ chunk_id: str
100
+ char_start: int
101
+ char_end: int
102
+ label: str = ""
103
+ score: float = 0.0
104
+ hit_span_cap: bool = False
105
+
106
+
107
+ class RuleCandidate(BaseModel):
108
+ """A passage a discourse cue marks as possibly stating a rule of thumb."""
109
+
110
+ chunk_id: str
111
+ cue: str
112
+ char_start: int
113
+ char_end: int
114
+ snippet: str
115
+
116
+
117
+ class AbbrevPair(BaseModel):
118
+ """`PA` ↔ `Physical Availability`, harvested from a legend block.
119
+
120
+ Legend extraction must run before clustering: without these, an
121
+ abbreviation and its expansion cluster as two unrelated terms.
122
+ """
123
+
124
+ abbrev: str
125
+ expansion: str
126
+ chunk_id: str
127
+
128
+
129
+ class FilterResult(BaseModel):
130
+ doc_id: str
131
+ mentions: list[Mention] = Field(default_factory=list)
132
+ rule_candidates: list[RuleCandidate] = Field(default_factory=list)
133
+ abbrev_pairs: list[AbbrevPair] = Field(default_factory=list)
134
+
135
+
136
+ # ── Stage 3: clusters ───────────────────────────────────────────────────
137
+
138
+
139
+ class TermCluster(BaseModel):
140
+ """All mentions of one term. **The LLM call unit is the cluster**, not the
141
+ chunk and not the mention β€” that is what cuts expert review burden, and it
142
+ is also the only reason conflicting definitions can be detected at all
143
+ (they must arrive in the same call to be compared)."""
144
+
145
+ cluster_id: str
146
+ canonical: str
147
+ variants: list[str] = Field(default_factory=list)
148
+ mentions: list[Mention] = Field(default_factory=list)
149
+ mention_count: int = 0
150
+ merge_reasons: list[str] = Field(default_factory=list)
151
+
152
+ # Ranked best-first. The FULL list is kept, not just the top K β€”
153
+ # escalation consumes the tail.
154
+ evidence_chunk_ids: list[str] = Field(default_factory=list)
155
+ evidence_scores: list[float] = Field(default_factory=list)
156
+
157
+
158
+ class ClusterResult(BaseModel):
159
+ doc_id: str
160
+ clusters: list[TermCluster] = Field(default_factory=list)
161
+ n_mentions: int = 0
162
+ n_clusters: int = 0
163
+ compression_ratio: float = 0.0
164
+
165
+
166
+ # ── Stage 4+: extracted entries ─────────────────────────────────────────
167
+
168
+
169
+ class Provenance(BaseModel):
170
+ """Where a claim came from. `span` is mandatory and must appear verbatim in
171
+ the evidence text; a field whose span cannot be located is rejected, never
172
+ repaired. A repaired span is an unfalsifiable claim."""
173
+
174
+ doc_id: str
175
+ span: str
176
+ page: int | None = None
177
+ section_no: str | None = None
178
+ chunk_id: str | None = None
179
+
180
+
181
+ class GlossaryEntry(BaseModel):
182
+ term: str
183
+ full_name: str | None = None
184
+
185
+ # The literal wording as the document writes it, un-normalised. The BUMA
186
+ # standard heads its section "Physical of Availability (PA)" while the
187
+ # legend says "Physical Availability"; the discrepancy is surfaced to the
188
+ # expert rather than silently corrected.
189
+ source_wording: str | None = None
190
+
191
+ definition: str | None = None
192
+ formula_latex: str | None = None
193
+ interpretation: str | None = None
194
+ subdomain_tags: list[SubdomainEnum] = Field(default_factory=list)
195
+ domain: str | None = None
196
+ company: str | None = None
197
+ language: str | None = None
198
+
199
+ mention_count: int = 0
200
+ provenance: Provenance
201
+ extraction_status: ExtractionStatus = "ok"
202
+ diff_status: DiffStatus | None = None
203
+ definition_conflict: bool = False
204
+ conflict_variants: list[str] = Field(default_factory=list)
205
+
206
+
207
+ class RuleEntry(BaseModel):
208
+ """A rule of thumb / operational convention stated by the document."""
209
+
210
+ rule_id: str
211
+ statement: str | None = None
212
+ condition: str | None = None
213
+ consequence: str | None = None
214
+ applies_to: str | None = None
215
+ subdomain_tags: list[SubdomainEnum] = Field(default_factory=list)
216
+ language: str | None = None
217
+ provenance: Provenance
218
+ extraction_status: ExtractionStatus = "ok"
219
+
220
+
221
+ class FormulaVariable(BaseModel):
222
+ symbol: str
223
+ meaning: str | None = None
224
+
225
+
226
+ class FormulaEntry(BaseModel):
227
+ name: str | None = None
228
+ formula_latex: str | None = None
229
+ variables: list[FormulaVariable] = Field(default_factory=list)
230
+ unit: str | None = None
231
+ provenance: Provenance
232
+ extraction_status: ExtractionStatus = "ok"
233
+
234
+
235
+ class BriefContext(BaseModel):
236
+ """Whole-document summary. The only branch that cannot be span-checked β€”
237
+ a plausible summary is indistinguishable from a correct one, which is why
238
+ it belongs on the larger model tier when one is available."""
239
+
240
+ title: str | None = None
241
+ purpose: str | None = None
242
+ scope: str | None = None
243
+ key_parameters: list[str] = Field(default_factory=list)
244
+ summary_md: str | None = None
245
+ provenance: Provenance
246
+
247
+
248
+ class CallUsage(BaseModel):
249
+ """Per-call accounting. `cached_tokens` comes from the API and is never
250
+ modelled: caching does not engage below 1024 prompt tokens, so assuming it
251
+ would understate cost by ~10x on the input side."""
252
+
253
+ branch: Branch
254
+ deployment: str
255
+ tier: str = "nano"
256
+ prompt_tokens: int = 0
257
+ cached_tokens: int = 0
258
+ completion_tokens: int = 0
259
+ latency_s: float = 0.0
260
+ retries: int = 0
261
+ structured_output_mode: str = ""
262
+ simulated: bool = False
263
+
264
+
265
+ class RejectedField(BaseModel):
266
+ """Audit row for a field the span check refused. Kept so a reviewer can see
267
+ what the control caught rather than only what it let through."""
268
+
269
+ entry_term: str
270
+ field: str
271
+ offending_value: str
272
+ reason: str
273
+ branch: Branch
src/knowledge_extraction/queue/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .review_queue import build_queue
2
+
3
+ __all__ = ["build_queue"]
src/knowledge_extraction/queue/review_queue.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The frequency-sorted review queue β€” the pipeline's actual product.
2
+
3
+ Ordering is the product decision here, and it targets the bottleneck directly:
4
+ the expert is the scarce resource, so they should hit the terms whose definition
5
+ propagates furthest first. Conflicts are promoted above frequency regardless,
6
+ because a contradiction is a decision only they can make.
7
+
8
+ Each row carries page, section and the verbatim span so review is a matter of
9
+ checking a quote against a page, not a claim against memory. That is what makes
10
+ the queue finishable.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+
16
+ def build_queue(entries: list[dict]) -> list[dict]:
17
+ def sort_key(entry: dict):
18
+ conflicting = (
19
+ entry.get("diff_status") == "conflicting"
20
+ or entry.get("definition_conflict") is True
21
+ )
22
+ return (0 if conflicting else 1, -int(entry.get("mention_count", 0) or 0))
23
+
24
+ queue = []
25
+ for rank, entry in enumerate(sorted(entries, key=sort_key), start=1):
26
+ provenance = entry.get("provenance") or {}
27
+ queue.append(
28
+ {
29
+ "rank": rank,
30
+ "term": entry.get("term"),
31
+ "definition": entry.get("definition"),
32
+ "source_wording": entry.get("source_wording"),
33
+ "mention_count": entry.get("mention_count", 0),
34
+ "extraction_status": entry.get("extraction_status"),
35
+ "diff_status": entry.get("diff_status"),
36
+ "definition_conflict": entry.get("definition_conflict", False),
37
+ "conflict_variants": entry.get("conflict_variants", []),
38
+ "page": provenance.get("page"),
39
+ "section_no": provenance.get("section_no"),
40
+ "span": provenance.get("span"),
41
+ "review_reason": _reason(entry),
42
+ }
43
+ )
44
+ return queue
45
+
46
+
47
+ def _reason(entry: dict) -> str:
48
+ if entry.get("definition_conflict") or entry.get("diff_status") == "conflicting":
49
+ return "conflicting definitions β€” expert decision required"
50
+ if _wording_differs(entry):
51
+ return "source wording differs from the expanded name β€” confirm which is correct"
52
+ if entry.get("extraction_status") == "no_definition_found":
53
+ return "term found but no definition in document"
54
+ if not entry.get("definition"):
55
+ return "definition rejected by span check or absent"
56
+ return "routine confirmation"
57
+
58
+
59
+ def _wording_differs(entry: dict) -> bool:
60
+ """The document says "Physical of Availability"; the expansion says
61
+ "Physical Availability". Surfacing that to the expert is a locked
62
+ requirement, so it earns its own review reason."""
63
+ source = (entry.get("source_wording") or "").strip().casefold()
64
+ full = (entry.get("full_name") or "").strip().casefold()
65
+ return bool(source and full) and full not in source
src/knowledge_extraction/rank/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .evidence import rank_evidence, rounds_available, top_k
2
+
3
+ __all__ = ["rank_evidence", "rounds_available", "top_k"]
src/knowledge_extraction/rank/evidence.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score and select the top-K evidence chunks per cluster.
2
+
3
+ This is a filter, and it structurally resembles the relevance gate the team
4
+ rejected. It is defensible only because of three properties, all of which must
5
+ survive any future change here:
6
+
7
+ 1. **No term is ever dropped** β€” only its *evidence* is narrowed.
8
+ 2. **Every mention stays in the cluster**, so a bad top-3 is visible in
9
+ provenance rather than invisible.
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
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+
21
+ 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 CUE_PROXIMITY_CHARS, EVIDENCE_K, EVIDENCE_WEIGHTS
26
+
27
+
28
+ def rank_evidence(
29
+ clusters: list[TermCluster],
30
+ chunks: list[Chunk],
31
+ weights: dict[str, float] | None = None,
32
+ ) -> None:
33
+ """Mutates clusters in place: sets `evidence_chunk_ids` (ranked, best first)
34
+ and `evidence_scores`. Keeps the FULL ranked list, not just the top K."""
35
+ w = {**EVIDENCE_WEIGHTS, **(weights or {})}
36
+ by_id = {c.chunk_id: c for c in chunks}
37
+ # Document order, so "first occurrence" means the term's first appearance in
38
+ # the document β€” not whichever element a set happened to yield first, which
39
+ # would hand the +1.0 to an arbitrary chunk and make ranking unreproducible.
40
+ doc_order = {c.chunk_id: i for i, c in enumerate(chunks)}
41
+ first_seen: set[str] = set()
42
+
43
+ for cluster in clusters:
44
+ chunk_ids = sorted(
45
+ {m.chunk_id for m in cluster.mentions},
46
+ key=lambda cid: (doc_order.get(cid, len(chunks)), cid),
47
+ )
48
+ scored: list[tuple[float, str]] = []
49
+ for chunk_id in chunk_ids:
50
+ chunk = by_id.get(chunk_id)
51
+ if chunk is None:
52
+ continue
53
+ positions = [m for m in cluster.mentions if m.chunk_id == chunk_id]
54
+ scored.append((_score_chunk(chunk, cluster, positions, w, first_seen), chunk_id))
55
+ # Tie-break on document order so ranking is stable across runs.
56
+ scored.sort(key=lambda t: (-t[0], doc_order.get(t[1], len(chunks)), t[1]))
57
+ cluster.evidence_chunk_ids = [cid for _, cid in scored]
58
+ cluster.evidence_scores = [round(s, 2) for s, _ in scored]
59
+
60
+
61
+ def top_k(
62
+ cluster: TermCluster, k: int = EVIDENCE_K, round_index: int = 0
63
+ ) -> list[str]:
64
+ """Evidence for extraction round `round_index` (0 = best K, 1 = next K…)."""
65
+ start = round_index * k
66
+ return cluster.evidence_chunk_ids[start : start + k]
67
+
68
+
69
+ def rounds_available(cluster: TermCluster, k: int, max_rounds: int) -> int:
70
+ """How many escalation rounds this cluster actually has evidence for.
71
+
72
+ Usually 0 on short documents β€” a cluster with a single evidence chunk has
73
+ nowhere to escalate, which is why an escalation count of zero is not by
74
+ itself a sign the loop is broken.
75
+ """
76
+ extra = max(0, len(cluster.evidence_chunk_ids) - k)
77
+ return min(max_rounds, -(-extra // k)) # ceil division
78
+
79
+
80
+ def _score_chunk(
81
+ chunk: Chunk,
82
+ cluster: TermCluster,
83
+ positions: list,
84
+ w: dict[str, float],
85
+ first_seen: set[str],
86
+ ) -> float:
87
+ score = 0.0
88
+
89
+ # Word-boundary match, NOT substring. "PA" is a substring of "Parameter",
90
+ # "pada" and "composite", so substring matching handed this bonus to almost
91
+ # every Indonesian heading and pushed real definition sections below
92
+ # formula tables.
93
+ heading = normalize(chunk.heading or "")
94
+ term_in_heading = bool(heading) and any(
95
+ _word_match(normalize(v), heading) for v in cluster.variants
96
+ )
97
+ if term_in_heading:
98
+ score += w["term_in_heading"]
99
+
100
+ # Cue proximity. When the heading names the term, the heading counts as a
101
+ # mention at offset 0.
102
+ #
103
+ # Indonesian standards are written "2.1.3 Physical of Availability (PA)" /
104
+ # "Adalah ketersediaan fisik..." β€” the heading names the term and the body
105
+ # opens with the cue WITHOUT repeating it. Measured on the BUMA standard:
106
+ # without this, the PA definition section scored no cue bonus at all and
107
+ # ranked below a computation section that merely mentions PA often. The
108
+ # section's own definition is the single most valuable piece of evidence
109
+ # there is, so losing the signal there defeats the ranking.
110
+ cues = definitional_hits(chunk.text)
111
+ anchors = [(m.char_start, m.char_end) for m in positions]
112
+ if term_in_heading:
113
+ anchors.append((0, 0))
114
+ if any(
115
+ abs(cs - start) <= CUE_PROXIMITY_CHARS or abs(ce - end) <= CUE_PROXIMITY_CHARS
116
+ for cs, ce in cues
117
+ for start, end in anchors
118
+ ):
119
+ score += w["definitional_cue_near"]
120
+
121
+ legend_lines = find_legend_lines(chunk.text)
122
+ if legend_lines:
123
+ line_starts = _line_starts(chunk.text)
124
+ for mention in positions:
125
+ if _line_of(mention.char_start, line_starts) in legend_lines:
126
+ score += w["in_legend_block"]
127
+ break
128
+
129
+ if chunk.has_formula:
130
+ score += w["formula_present"]
131
+
132
+ normalised_variants = {normalize(v) for v in cluster.variants}
133
+ if any(normalize(b) in normalised_variants for b in chunk.bold_spans):
134
+ score += w["bold_or_italic"]
135
+
136
+ if cluster.cluster_id not in first_seen:
137
+ score += w["first_occurrence"]
138
+ first_seen.add(cluster.cluster_id)
139
+
140
+ if chunk.is_tabular:
141
+ score += w["tabular_penalty"]
142
+
143
+ return score
144
+
145
+
146
+ def _word_match(needle: str, haystack: str) -> bool:
147
+ if not needle:
148
+ return False
149
+ return re.search(rf"(?<!\w){re.escape(needle)}(?!\w)", haystack) is not None
150
+
151
+
152
+ def _line_starts(text: str) -> list[int]:
153
+ starts, pos = [0], 0
154
+ for line in text.split("\n")[:-1]:
155
+ pos += len(line) + 1
156
+ starts.append(pos)
157
+ return starts
158
+
159
+
160
+ def _line_of(char_pos: int, line_starts: list[int]) -> int:
161
+ lo, hi = 0, len(line_starts) - 1
162
+ while lo < hi:
163
+ mid = (lo + hi + 1) // 2
164
+ if line_starts[mid] <= char_pos:
165
+ lo = mid
166
+ else:
167
+ hi = mid - 1
168
+ return lo
src/knowledge_extraction/service.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pipeline facade: parsed document β†’ candidate entries β†’ review queue.
2
+
3
+ Mirrors the shape of `src/query/service.py` β€” a deterministic orchestrator over
4
+ stages that each do one thing, with the expensive step isolated and every
5
+ failure degrading rather than aborting.
6
+
7
+ Cost discipline, carried from the prototype and worth keeping: **dry-run, then a
8
+ small pilot, then the full run.** A dry run makes zero API calls and prints the
9
+ token estimate, so the bill is knowable before it is incurred.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+
16
+ from ..middlewares.logging import get_logger
17
+ from .cluster import cluster_mentions
18
+ from .diff import diff_glossary
19
+ from .extract import (
20
+ build_glossary_prompt,
21
+ est_tokens,
22
+ extract_formula,
23
+ extract_glossary,
24
+ extract_rule,
25
+ extract_summary,
26
+ )
27
+ from .filters import abbrev_pairs, extract_mentions, rule_candidates
28
+ from .models import (
29
+ CallUsage,
30
+ Chunk,
31
+ ClusterResult,
32
+ FilterResult,
33
+ ParsedDoc,
34
+ RejectedField,
35
+ )
36
+ from .queue import build_queue
37
+ from .rank import rank_evidence, top_k
38
+ from .settings import EVIDENCE_K
39
+ from .validate import evidence_text, find_conflicts, rounds_available, validate_entry
40
+
41
+ logger = get_logger("knowledge_extraction")
42
+
43
+
44
+ class ExtractionResult:
45
+ def __init__(self) -> None:
46
+ self.glossary: list[dict] = []
47
+ self.rules: list[dict] = []
48
+ self.formulas: list[dict] = []
49
+ self.brief: dict | None = None
50
+ self.review_queue: list[dict] = []
51
+ self.rejected: list[RejectedField] = []
52
+ self.usages: list[CallUsage] = []
53
+
54
+ @property
55
+ def total_tokens(self) -> tuple[int, int, int]:
56
+ return (
57
+ sum(u.prompt_tokens for u in self.usages),
58
+ sum(u.cached_tokens for u in self.usages),
59
+ sum(u.completion_tokens for u in self.usages),
60
+ )
61
+
62
+
63
+ def run_filters(doc: ParsedDoc, use_span_filter: bool = True) -> FilterResult:
64
+ """All free stages. Zero API calls."""
65
+ pairs = abbrev_pairs(doc.chunks)
66
+ mentions = extract_mentions(doc.chunks) if use_span_filter else []
67
+ return FilterResult(
68
+ doc_id=doc.doc_id,
69
+ mentions=mentions,
70
+ rule_candidates=rule_candidates(doc.chunks),
71
+ abbrev_pairs=pairs,
72
+ )
73
+
74
+
75
+ def build_clusters(doc: ParsedDoc, filtered: FilterResult) -> ClusterResult:
76
+ clustered = cluster_mentions(filtered.mentions, filtered.abbrev_pairs, doc.doc_id)
77
+ rank_evidence(clustered.clusters, doc.chunks)
78
+ return clustered
79
+
80
+
81
+ def estimate_cost(
82
+ doc: ParsedDoc, clustered: ClusterResult, filtered: FilterResult, limit: int | None = None
83
+ ) -> dict:
84
+ """Dry run: exact prompts are built, nothing is sent."""
85
+ clusters = clustered.clusters[:limit] if limit else clustered.clusters
86
+ prompt_tokens = 0
87
+ for cluster in clusters:
88
+ system, user = build_glossary_prompt(cluster, doc.chunks)
89
+ prompt_tokens += est_tokens(system) + est_tokens(user)
90
+ return {
91
+ "glossary_calls": len(clusters),
92
+ "rule_calls": len(filtered.rule_candidates),
93
+ "formula_calls": sum(1 for c in doc.chunks if c.has_formula),
94
+ "summary_calls": 1,
95
+ "estimated_prompt_tokens": prompt_tokens,
96
+ "note": "estimate only β€” real counts come from the API usage object",
97
+ }
98
+
99
+
100
+ def extract_all(
101
+ doc: ParsedDoc,
102
+ clustered: ClusterResult,
103
+ filtered: FilterResult,
104
+ extractor,
105
+ limit: int | None = None,
106
+ active_glossary: list[dict] | None = None,
107
+ branches: tuple[str, ...] = ("glossary", "rule", "formula", "summary"),
108
+ ) -> ExtractionResult:
109
+ """The paid stage plus validation, diff and queue."""
110
+ out = ExtractionResult()
111
+ started = time.time()
112
+
113
+ if "glossary" in branches:
114
+ _run_glossary(doc, clustered, extractor, out, limit)
115
+ if "rule" in branches:
116
+ _run_rules(doc, filtered, extractor, out, limit)
117
+ if "formula" in branches:
118
+ _run_formulas(doc, extractor, out, limit)
119
+ if "summary" in branches:
120
+ _run_summary(doc, extractor, out)
121
+
122
+ out.glossary = diff_glossary(out.glossary, active_glossary or [])
123
+ out.review_queue = build_queue(out.glossary)
124
+
125
+ prompt, cached, completion = out.total_tokens
126
+ logger.info(
127
+ "extraction complete",
128
+ doc_id=doc.doc_id,
129
+ glossary=len(out.glossary),
130
+ rules=len(out.rules),
131
+ formulas=len(out.formulas),
132
+ rejected_fields=len(out.rejected),
133
+ calls=len(out.usages),
134
+ prompt_tokens=prompt,
135
+ cached_tokens=cached,
136
+ completion_tokens=completion,
137
+ seconds=round(time.time() - started, 1),
138
+ )
139
+ return out
140
+
141
+
142
+ def _run_glossary(doc, clustered, extractor, out, limit) -> None:
143
+ clusters = clustered.clusters[:limit] if limit else clustered.clusters
144
+ for cluster in clusters:
145
+ entry = None
146
+ max_round = rounds_available(cluster, EVIDENCE_K)
147
+
148
+ for round_index in range(max_round + 1):
149
+ entry, usage = extract_glossary(
150
+ cluster, doc.chunks, extractor, doc.doc_id, EVIDENCE_K, round_index
151
+ )
152
+ out.usages.append(usage)
153
+ if entry is None:
154
+ continue
155
+
156
+ source = evidence_text(
157
+ top_k(cluster, EVIDENCE_K, round_index), doc.chunks
158
+ )
159
+ entry, rejections = validate_entry(entry, "glossary", source, cluster.canonical)
160
+ out.rejected.extend(rejections)
161
+
162
+ if entry.definition:
163
+ if round_index > 0:
164
+ entry.extraction_status = "escalated"
165
+ break
166
+ # Null definition -> escalate to the next K chunks.
167
+
168
+ if entry is None:
169
+ continue
170
+ if not entry.definition:
171
+ entry.extraction_status = "no_definition_found"
172
+
173
+ conflicting, variants = find_conflicts(
174
+ [entry.definition] if entry.definition else []
175
+ )
176
+ entry.definition_conflict = conflicting
177
+ entry.conflict_variants = variants
178
+ out.glossary.append(entry.model_dump(mode="json"))
179
+
180
+
181
+ def _run_rules(doc, filtered, extractor, out, limit) -> None:
182
+ by_id: dict[str, Chunk] = {c.chunk_id: c for c in doc.chunks}
183
+ candidates = filtered.rule_candidates[:limit] if limit else filtered.rule_candidates
184
+ seen: set[str] = set()
185
+ for candidate in candidates:
186
+ chunk = by_id.get(candidate.chunk_id)
187
+ if chunk is None:
188
+ continue
189
+ entry, usage = extract_rule(candidate, chunk, extractor, doc.doc_id)
190
+ out.usages.append(usage)
191
+ if entry is None:
192
+ continue
193
+ entry, rejections = validate_entry(entry, "rule", chunk.text, entry.rule_id)
194
+ out.rejected.extend(rejections)
195
+ key = (entry.statement or "").strip().casefold()
196
+ if key and key in seen:
197
+ continue
198
+ if key:
199
+ seen.add(key)
200
+ out.rules.append(entry.model_dump(mode="json"))
201
+
202
+
203
+ def _run_formulas(doc, extractor, out, limit) -> None:
204
+ chunks = [c for c in doc.chunks if c.has_formula]
205
+ chunks = chunks[:limit] if limit else chunks
206
+ seen: set[str] = set()
207
+ for chunk in chunks:
208
+ entry, usage = extract_formula(chunk, extractor, doc.doc_id)
209
+ out.usages.append(usage)
210
+ if entry is None:
211
+ continue
212
+ entry, rejections = validate_entry(
213
+ entry, "formula", chunk.text, entry.name or chunk.chunk_id
214
+ )
215
+ out.rejected.extend(rejections)
216
+ key = (entry.formula_latex or "").strip()
217
+ if key and key in seen:
218
+ continue
219
+ if key:
220
+ seen.add(key)
221
+ out.formulas.append(entry.model_dump(mode="json"))
222
+
223
+
224
+ def _run_summary(doc, extractor, out) -> None:
225
+ entry, usage = extract_summary(doc.chunks, extractor, doc.doc_id)
226
+ out.usages.append(usage)
227
+ if entry is None:
228
+ return
229
+ # Summary prose is NOT span-checked β€” it cannot be. Only its provenance is
230
+ # carried, and the branch belongs on a larger tier for exactly that reason.
231
+ out.brief = entry.model_dump(mode="json")
src/knowledge_extraction/settings.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tunables for the knowledge-extraction pipeline.
2
+
3
+ Every value here was calibrated on real documents and each one has a reason
4
+ recorded in KNOWLEDGE_PIPELINE_CALIBRATION.md. Change them deliberately β€” most
5
+ were arrived at by a measurement, and two of them (`FUZZY_MIN_LEN`,
6
+ `CACHE_MIN_TOKENS`) fix bugs that are silent when reintroduced.
7
+
8
+ Label and cue sets live in `config/*.yaml` so they can be tuned without a code
9
+ change: label phrasing is the main recall lever and the filter is very sensitive
10
+ to it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+ CONFIG_DIR = Path(__file__).resolve().parent / "config"
21
+
22
+ # ── Term filter ─────────────────────────────────────────────────────────
23
+ # Variant C beat both the English-default and Indonesian-phrasing label sets:
24
+ # the other two missed the same class (mining activities and materials).
25
+ LABELS_VARIANT = "broad"
26
+
27
+ # 0.25, not 0.35: measured recall 0.854 @ 0.25 vs 0.658 @ 0.35 on `broad`.
28
+ # Precision falls (0.41 vs 0.50) and that is the intended trade β€” the filter is
29
+ # deliberately over-inclusive, clustering and ranking absorb the noise, and a
30
+ # term the filter never proposes can never be recovered downstream.
31
+ SPAN_SCORE_THRESHOLD = 0.25
32
+
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 does not. Chunks are fed as overlapping windows with offsets remapped.
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
+ EVIDENCE_K = 3
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,
61
+ "tabular_penalty": -3.0,
62
+ }
63
+
64
+ # ── Chunking ────────────────────────────────────────────────────────────
65
+ MAX_CHUNK_TOKENS = 1500
66
+ MAX_HEADING_LEN = 90
67
+ BOILERPLATE_MIN_FRAC = 0.6
68
+
69
+ # ── Extraction ──────────────────────────────────────────────────────────
70
+ TEMPERATURE = 0.0
71
+
72
+ # OpenAI-family prompt caching does not engage AT ALL below this many prompt
73
+ # tokens, so a shorter fixed prefix caches nothing and costs ~10x on input. The
74
+ # measured hit rate at/above it was 54%.
75
+ CACHE_MIN_TOKENS = 1024
76
+
77
+ # ── Validation ──────────────────────────────────────────────────────────
78
+ MAX_ESCALATION_ROUNDS = 2
79
+ CONFLICT_OVERLAP_THRESHOLD = 0.4
80
+ DUPLICATE_OVERLAP_THRESHOLD = 0.8
81
+
82
+
83
+ @lru_cache(maxsize=4)
84
+ def load_yaml(name: str) -> dict:
85
+ with open(CONFIG_DIR / name, encoding="utf-8") as fh:
86
+ return yaml.safe_load(fh)
87
+
88
+
89
+ def labels_for(variant: str = LABELS_VARIANT) -> tuple[list[str], float]:
90
+ """Returns (labels, threshold) for a label variant."""
91
+ cfg = load_yaml("labels.yaml")
92
+ labels = cfg.get(variant) or cfg.get(LABELS_VARIANT) or []
93
+ return list(labels), float(cfg.get("threshold", SPAN_SCORE_THRESHOLD))