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

[NOTICKET] feat(knowledge_extraction): free stages — models, seam adapter, filters, cluster, ranking

Browse files

First slice of extraction v2, rebuilt in this repo (the kex prototype is not
being ported). All stages here are free: zero API calls, CPU only.

New module src/knowledge_extraction/ (named to avoid colliding with
src/knowledge/, the existing OCR->pgvector service, which is untouched):

- models.py pydantic v2 contracts. Encodes the three invariants: content
fields Optional, subdomain_tags as an enum, provenance.span
mandatory. Adds source_wording so the literal source wording
survives instead of being normalised away.
- adapter.py THE ONLY file that knows the seam's shape. Stages depend on an
internal Chunk subset, so the artifact contract can settle
either way without touching a stage. Verified against both
live shapes: Sofhia's bare-list draft with its `kind`
discriminator, and the document-level envelope proposed in the
checklist. Never accepts a file path; never reflows text.
- settings.py every tuned constant, each with the reason it has that value.
- filters/ cue_filter (rule candidates + the ranker's cue signal),
legend_filter (abbreviation pairs; runs BEFORE clustering, or
PA and Physical Availability never meet).
- cluster/ normalise + cluster. Fuzzy 92, disabled below 5 chars because
PA and UA score highly against each other.
- rank/ six-signal evidence ranking; full ranked list retained because
escalation consumes the tail.

Config: labels.yaml + cues.yaml carried over verbatim, so label phrasing stays
tunable without a code change.

src/config/settings.py: expose the __54n Azure quad (all four branches route to
nano per the model-tier decision). Mirrors the existing __54m block.

Dependency: rapidfuzz>=3.14.5 (approved). gliner/torch deliberately deferred —
not needed until the span filter, and the heavy tail is worth delaying.

pyproject: declare the T201 per-file-ignore for scripts/ that eval/ already has,
same reason (operator CLIs; print is the output channel). The existing scripts
already relied on it.

Verification: ruff clean on all new paths; PYTHONPATH=. python -c "import main"
exits 0; full suite 467 passed, 7 skipped (was 456 + 11 new local tests).
Ranking verified against the real 9-page BUMA document — every term's top-ranked
evidence chunk is its own definition section (PA->2.1.3, UA->2.1.4, Qty->2.1.2,
Pty->2.1.5), and the legend filter extracts the p.2 block exactly, 5 of 5.

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

KNOWLEDGE_PIPELINE_TODO.md CHANGED
@@ -133,22 +133,26 @@ detection with type labels. Presented to the execs 13 Aug as the prerequisite fo
133
  it. *Build* = exists in this repo. The gap between them is the remaining engineering effort, and
134
  collapsing the two is how a rebuild gets under-estimated.
135
 
136
- **Structural target:** follow `src/query/` (typed models validator deterministic stages →
137
- service facade thin entry points), with the offline runner in `scripts/`. **Naming collision:**
138
- `src/knowledge/` is already taken by the existing OCRpgvector service v2 needs its own module
139
- name (`src/kex/`, `src/knowledge_extraction/`, TBD in §6).
 
 
 
 
140
 
141
  | # | Stage | Design | Build | Note |
142
  |---|---|---|---|---|
143
- | **X1** | Candidate-entry schema | ✅ | | Three invariants to re-encode: all content fields Optional (a required field is a fabrication invitation), `subdomain_tags` as an enum, `provenance.span` mandatory. Pydantic v2 |
144
- | **X2** | Offline runner over a fixture | ✅ | | Script-driven run is the honest first milestone; HTTP is a convenience layer over it, never the reverse. Every stage reads/writes its own artifact so extraction re-runs without re-running GLiNER |
145
- | **X3** | GLiNER span filter | ✅ | ⬜ | Runs on **all** chunks unconditionally. No standalone relevance gate — "zero candidate spans" is the relevance signal, produced as a byproduct of looking rather than a judgment made before looking |
146
  | **X4** | Indonesian performance | ✅ **0.854** | n/a | Answered by E1. Re-measure once on v2 to confirm no regression |
147
- | **X5** | Discourse-cue filter → rule-of-thumb | ✅ | | Free CPU pass. Grounding case: Other Activity + the controllability hierarchy |
148
- | **X6** | Legend-block filter → formula vars | ✅ | | Grounding case: the MOHH/Qty/PA/UA/Pty legend block. Needs the LaTeX normalisation noted in §4 |
149
  | **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 |
150
- | **X8** | Normalise + cluster mentions | ✅ **2.56×** | | The LLM call unit is the **term cluster**. Carry the tuned constants from P2 |
151
- | **X9** | Evidence ranking → top-K | ✅ | | Six signals: cue · heading · legend · formula · formatting · first-occurrence. No term is ever dropped ranking narrows *evidence*, not terms, and the full ranked list is retained for escalation |
152
  | **X10** | LLM extraction call | ✅ | ⬜ | One call per term cluster, plus one per cue chunk, per unique formula, per document. Keep prompts as files with a byte-identical cached prefix |
153
  | **X11** | Verbatim-span validation | ✅ **1.00** | ⬜ | A field whose span cannot be located is rejected, **never repaired** — a repaired span is an unfalsifiable claim, which is what the control exists to prevent |
154
  | **X12** | Null-definition escalation | 🔎 | ⬜ | Built in the prototype but **never fired**: 54 of 66 clusters had exactly 1 evidence chunk against K=3, so the loop correctly had nowhere to escalate. Unverified until a larger corpus runs |
@@ -164,11 +168,11 @@ name (`src/kex/`, `src/knowledge_extraction/`, TBD in §6).
164
 
165
  | # | Decision | Owner | Status | Description |
166
  |---|---|---|---|---|
167
- | **D1** | New dependencies | Rifqi (asks) | | GLiNER + torch (heavy) for extraction; MinerU for parsing. `pyproject.toml` changes need sign-off. Prototype needed **torch ≥ 2.6** and hit a corrupt-wheel failure worth not re-debugging |
168
  | **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 |
169
  | **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 |
170
  | **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 |
171
- | **D5** | v2 module name | Rifqi | | `src/knowledge/` is taken by the existing OCR→pgvector service. Pick a non-colliding name before the first commit |
172
  | **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 |
173
  | **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 |
174
 
 
133
  it. *Build* = exists in this repo. The gap between them is the remaining engineering effort, and
134
  collapsing the two is how a rebuild gets under-estimated.
135
 
136
+ **Module: `src/knowledge_extraction/`** (decided 2026-08-19 `src/knowledge/` is the existing
137
+ OCRpgvector service and stays untouched). Offline runner: `scripts/run_knowledge_extraction.py`.
138
+ Structure follows `src/query/`: typed models deterministic stagesthin entry point.
139
+
140
+ **The seam is absorbed by one file.** `adapter.py` is the only module that knows the artifact's
141
+ shape; every stage depends on the internal `models.Chunk` subset instead. Verified against **both**
142
+ live shapes — Sofhia's bare-list draft (`contoh-chunk.json`, `kind` discriminator) and the
143
+ envelope shape proposed in §3 — so S1 can settle either way without touching a stage.
144
 
145
  | # | Stage | Design | Build | Note |
146
  |---|---|---|---|---|
147
+ | **X1** | Candidate-entry schema | ✅ | | `models.py`. All three invariants encoded: content fields Optional, `SubdomainEnum`, `Provenance.span` mandatory. Adds `source_wording` for R1. Pydantic v2 |
148
+ | **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 |
149
+ | **X3** | GLiNER span filter | ✅ | ⬜ | **Next.** Runs on all chunks unconditionally; "zero candidate spans" is the relevance signal. Deferred with the `gliner`+`torch` dependency (D1); the runner has a legend-only stand-in meanwhile, explicitly not a recall measurement |
150
  | **X4** | Indonesian performance | ✅ **0.854** | n/a | Answered by E1. Re-measure once on v2 to confirm no regression |
151
+ | **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 |
152
+ | **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 |
153
  | **X7** | Section pass → summary units | ✅ | ⬜ | The quiet cost risk: few calls but ~¼ of all input tokens, because summarisation cannot be filtered — it needs whole documents |
154
+ | **X8** | Normalise + cluster mentions | ✅ **2.56×** | | `cluster/`. Constants carried from P2 with their reasons. Locked by tests: PA/UA never merge; abbreviation↔expansion merges only with legend pairs; noise surfaces dropped as whole forms only |
155
+ | **X9** | Evidence ranking → top-K | ✅ | | `rank/evidence.py`, six signals + tabular penalty. **Verified on the real document: every term's top-ranked chunk is its own definition section** PA→2.1.3, UA→2.1.4, Qty→2.1.2, Pty→2.1.5. Full ranked list retained; word-boundary heading matching locked by test |
156
  | **X10** | LLM extraction call | ✅ | ⬜ | One call per term cluster, plus one per cue chunk, per unique formula, per document. Keep prompts as files with a byte-identical cached prefix |
157
  | **X11** | Verbatim-span validation | ✅ **1.00** | ⬜ | A field whose span cannot be located is rejected, **never repaired** — a repaired span is an unfalsifiable claim, which is what the control exists to prevent |
158
  | **X12** | Null-definition escalation | 🔎 | ⬜ | Built in the prototype but **never fired**: 54 of 66 clusters had exactly 1 evidence chunk against K=3, so the loop correctly had nowhere to escalate. Unverified until a larger corpus runs |
 
168
 
169
  | # | Decision | Owner | Status | Description |
170
  |---|---|---|---|---|
171
+ | **D1** | New dependencies | Rifqi | 🔄 | **`rapidfuzz>=3.14.5` added 2026-08-19** (approved). `gliner` + `torch` still **deferred** — not needed until X3, and the heavy tail is worth delaying. `PyYAML` was already present transitively. Prototype needed **torch ≥ 2.6** and hit a corrupt-wheel failure worth not re-debugging |
172
  | **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 |
173
  | **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 |
174
  | **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 |
175
+ | **D5** | v2 module name | Rifqi | | **`src/knowledge_extraction/`** (2026-08-19). Offline runner in `scripts/`, per the repo's existing precedent |
176
  | **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 |
177
  | **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 |
178
 
pyproject.toml CHANGED
@@ -90,6 +90,7 @@ dependencies = [
90
  "pytesseract>=0.3.13",
91
  "pypdf2>=3.0.1",
92
  "pyarrow>=24.0.0",
 
93
  ]
94
 
95
  [project.optional-dependencies]
@@ -125,6 +126,9 @@ 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
  ]
95
 
96
  [project.optional-dependencies]
 
126
  "tests/**" = ["S101", "S105", "S106", "S608"]
127
  # T201: eval/ scripts are CLIs — print() is their intended output channel.
128
  "eval/**" = ["T201"]
129
+ # Same rule, same reason: scripts/ are operator-run CLIs. (The existing scripts
130
+ # already relied on this; it was simply never declared.)
131
+ "scripts/**" = ["T201"]
132
 
133
  [tool.mypy]
134
  python_version = "3.12"
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,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (not yet built)
12
+ validate verbatim span check, escalation free (not yet built)
13
+ diff new / duplicate / conflicting free (not yet built)
14
+ queue frequency-sorted review queue free (not yet built)
15
+
16
+ Design rationale: knowledge_pipeline_context.md
17
+ Calibrated constants and why: KNOWLEDGE_PIPELINE_CALIBRATION.md
18
+ """
19
+
20
+ from .adapter import parsed_doc_from_artifact
21
+ from .models import (
22
+ AbbrevPair,
23
+ Chunk,
24
+ ClusterResult,
25
+ FilterResult,
26
+ GlossaryEntry,
27
+ Mention,
28
+ ParsedDoc,
29
+ Provenance,
30
+ RuleCandidate,
31
+ TermCluster,
32
+ )
33
+
34
+ __all__ = [
35
+ "AbbrevPair",
36
+ "Chunk",
37
+ "ClusterResult",
38
+ "FilterResult",
39
+ "GlossaryEntry",
40
+ "Mention",
41
+ "ParsedDoc",
42
+ "Provenance",
43
+ "RuleCandidate",
44
+ "TermCluster",
45
+ "parsed_doc_from_artifact",
46
+ ]
src/knowledge_extraction/adapter.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Draft field name → internal field name, where they differ.
30
+ _PAGE_KEYS = ("page_start", "page", "page_idx")
31
+ _PAGES_KEYS = ("pages", "page_list")
32
+
33
+
34
+ def chunk_from_dict(raw: dict[str, Any], doc_id: str, ordinal: int = 0) -> Chunk:
35
+ """Map one artifact item onto the internal chunk.
36
+
37
+ `kind`/`is_tabular` are reconciled: the draft carries a `kind` discriminator
38
+ while the prototype carried booleans. Either is accepted.
39
+ """
40
+ kind = raw.get("kind")
41
+ pages = _first(raw, _PAGES_KEYS) or []
42
+ page_start = _first(raw, _PAGE_KEYS)
43
+ if page_start is None:
44
+ page_start = min(pages) if pages else 0
45
+ page_end = max(pages) if pages else raw.get("page_end", page_start)
46
+
47
+ return Chunk(
48
+ chunk_id=raw.get("chunk_id") or f"{doc_id}#{ordinal:04d}",
49
+ doc_id=raw.get("doc_id") or doc_id,
50
+ text=raw["text"], # verbatim, never cleaned
51
+ page_start=int(page_start),
52
+ page_end=int(page_end),
53
+ ordinal=int(raw.get("ordinal", ordinal)),
54
+ section_no=raw.get("section_no"),
55
+ heading=raw.get("heading"),
56
+ has_formula=bool(raw.get("has_formula", kind == "equation")),
57
+ is_tabular=bool(raw.get("is_tabular", kind == "table")),
58
+ bold_spans=list(raw.get("bold_spans") or []),
59
+ )
60
+
61
+
62
+ def parsed_doc_from_artifact(
63
+ artifact: Any,
64
+ doc_id: str | None = None,
65
+ source_ref: str = "",
66
+ parser_name: str = "unknown",
67
+ parser_version: str = "",
68
+ ) -> ParsedDoc:
69
+ """Build a `ParsedDoc` from either shape of the artifact.
70
+
71
+ Accepts a bare `list[chunk]` (the draft's current shape) or a mapping with a
72
+ `chunks` key (the shape proposed for the document-level envelope). When the
73
+ envelope lands, its `content_hash`/`n_pages`/`version` are preferred over
74
+ the values derived here.
75
+ """
76
+ if isinstance(artifact, dict):
77
+ items = artifact.get("chunks") or []
78
+ doc_id = doc_id or artifact.get("doc_id")
79
+ source_ref = source_ref or artifact.get("source_path") or artifact.get("source_ref") or ""
80
+ parser_name = artifact.get("parser_name") or parser_name
81
+ parser_version = artifact.get("parser_version") or parser_version
82
+ declared_hash = artifact.get("content_hash")
83
+ declared_pages = artifact.get("n_pages")
84
+ else:
85
+ items = list(artifact)
86
+ declared_hash, declared_pages = None, None
87
+
88
+ if not doc_id:
89
+ doc_id = (items[0].get("doc_id") if items else None) or "unknown"
90
+
91
+ chunks = [chunk_from_dict(raw, doc_id, i) for i, raw in enumerate(items)]
92
+ pages = {p for c in chunks for p in (c.page_start, c.page_end)}
93
+
94
+ return ParsedDoc(
95
+ doc_id=doc_id,
96
+ source_ref=source_ref,
97
+ content_hash=declared_hash or content_hash(chunks),
98
+ n_pages=int(declared_pages) if declared_pages else (max(pages) + 1 if pages else 0),
99
+ chunks=chunks,
100
+ parser_name=parser_name,
101
+ parser_version=parser_version,
102
+ used_heading_split=any(c.section_no for c in chunks),
103
+ )
104
+
105
+
106
+ def content_hash(chunks: list[Chunk]) -> str:
107
+ """Stable hash of the chunk text, so a re-parse that changed nothing can be
108
+ detected and the expensive stages skipped."""
109
+ blob = json.dumps([c.text for c in chunks], ensure_ascii=False).encode()
110
+ return hashlib.sha256(blob).hexdigest()[:16]
111
+
112
+
113
+ def _first(raw: dict[str, Any], keys: tuple[str, ...]) -> Any:
114
+ for key in keys:
115
+ if raw.get(key) is not None:
116
+ return raw[key]
117
+ return None
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/filters/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from .cue_filter import definitional_hits, rule_candidates
2
+ from .legend_filter import abbrev_pairs, find_legend_lines
3
+
4
+ __all__ = [
5
+ "abbrev_pairs",
6
+ "definitional_hits",
7
+ "find_legend_lines",
8
+ "rule_candidates",
9
+ ]
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/models.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 RejectedField(BaseModel):
208
+ """Audit row for a field the span check refused. Kept so a reviewer can see
209
+ what the control caught rather than only what it let through."""
210
+
211
+ entry_term: str
212
+ field: str
213
+ offending_value: str
214
+ reason: str
215
+ branch: Branch
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,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ first_seen: set[str] = set()
38
+
39
+ for cluster in clusters:
40
+ scored: list[tuple[float, str]] = []
41
+ for chunk_id in {m.chunk_id for m in cluster.mentions}:
42
+ chunk = by_id.get(chunk_id)
43
+ if chunk is None:
44
+ continue
45
+ positions = [m for m in cluster.mentions if m.chunk_id == chunk_id]
46
+ scored.append((_score_chunk(chunk, cluster, positions, w, first_seen), chunk_id))
47
+ # Tie-break on chunk_id so ranking is stable across runs.
48
+ scored.sort(key=lambda t: (-t[0], t[1]))
49
+ cluster.evidence_chunk_ids = [cid for _, cid in scored]
50
+ cluster.evidence_scores = [round(s, 2) for s, _ in scored]
51
+
52
+
53
+ def top_k(
54
+ cluster: TermCluster, k: int = EVIDENCE_K, round_index: int = 0
55
+ ) -> list[str]:
56
+ """Evidence for extraction round `round_index` (0 = best K, 1 = next K…)."""
57
+ start = round_index * k
58
+ return cluster.evidence_chunk_ids[start : start + k]
59
+
60
+
61
+ def rounds_available(cluster: TermCluster, k: int, max_rounds: int) -> int:
62
+ """How many escalation rounds this cluster actually has evidence for.
63
+
64
+ Usually 0 on short documents — a cluster with a single evidence chunk has
65
+ nowhere to escalate, which is why an escalation count of zero is not by
66
+ itself a sign the loop is broken.
67
+ """
68
+ extra = max(0, len(cluster.evidence_chunk_ids) - k)
69
+ return min(max_rounds, -(-extra // k)) # ceil division
70
+
71
+
72
+ def _score_chunk(
73
+ chunk: Chunk,
74
+ cluster: TermCluster,
75
+ positions: list,
76
+ w: dict[str, float],
77
+ first_seen: set[str],
78
+ ) -> float:
79
+ score = 0.0
80
+
81
+ cues = definitional_hits(chunk.text)
82
+ for mention in positions:
83
+ if any(
84
+ abs(cs - mention.char_start) <= CUE_PROXIMITY_CHARS
85
+ or abs(ce - mention.char_end) <= CUE_PROXIMITY_CHARS
86
+ for cs, ce in cues
87
+ ):
88
+ score += w["definitional_cue_near"]
89
+ break
90
+
91
+ # Word-boundary match, NOT substring. "PA" is a substring of "Parameter",
92
+ # "pada" and "composite", so substring matching handed this bonus to almost
93
+ # every Indonesian heading and pushed real definition sections below
94
+ # formula tables.
95
+ heading = normalize(chunk.heading or "")
96
+ if heading and any(_word_match(normalize(v), heading) for v in cluster.variants):
97
+ score += w["term_in_heading"]
98
+
99
+ legend_lines = find_legend_lines(chunk.text)
100
+ if legend_lines:
101
+ line_starts = _line_starts(chunk.text)
102
+ for mention in positions:
103
+ if _line_of(mention.char_start, line_starts) in legend_lines:
104
+ score += w["in_legend_block"]
105
+ break
106
+
107
+ if chunk.has_formula:
108
+ score += w["formula_present"]
109
+
110
+ normalised_variants = {normalize(v) for v in cluster.variants}
111
+ if any(normalize(b) in normalised_variants for b in chunk.bold_spans):
112
+ score += w["bold_or_italic"]
113
+
114
+ if cluster.cluster_id not in first_seen:
115
+ score += w["first_occurrence"]
116
+ first_seen.add(cluster.cluster_id)
117
+
118
+ if chunk.is_tabular:
119
+ score += w["tabular_penalty"]
120
+
121
+ return score
122
+
123
+
124
+ def _word_match(needle: str, haystack: str) -> bool:
125
+ if not needle:
126
+ return False
127
+ return re.search(rf"(?<!\w){re.escape(needle)}(?!\w)", haystack) is not None
128
+
129
+
130
+ def _line_starts(text: str) -> list[int]:
131
+ starts, pos = [0], 0
132
+ for line in text.split("\n")[:-1]:
133
+ pos += len(line) + 1
134
+ starts.append(pos)
135
+ return starts
136
+
137
+
138
+ def _line_of(char_pos: int, line_starts: list[int]) -> int:
139
+ lo, hi = 0, len(line_starts) - 1
140
+ while lo < hi:
141
+ mid = (lo + hi + 1) // 2
142
+ if line_starts[mid] <= char_pos:
143
+ lo = mid
144
+ else:
145
+ hi = mid - 1
146
+ return lo
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))
uv.lock CHANGED
@@ -1,5 +1,5 @@
1
  version = 1
2
- revision = 2
3
  requires-python = "==3.12.*"
4
  resolution-markers = [
5
  "python_full_version >= '3.12.4'",
@@ -59,6 +59,7 @@ dependencies = [
59
  { name = "python-dotenv" },
60
  { name = "python-multipart" },
61
  { name = "rank-bm25" },
 
62
  { name = "redis" },
63
  { name = "sentence-transformers" },
64
  { name = "slowapi" },
@@ -149,6 +150,7 @@ requires-dist = [
149
  { name = "python-dotenv", specifier = "==1.0.1" },
150
  { name = "python-multipart", specifier = "==0.0.12" },
151
  { name = "rank-bm25", specifier = "==0.2.2" },
 
152
  { name = "redis", specifier = "==5.2.1" },
153
  { name = "ruff", marker = "extra == 'dev'", specifier = "==0.8.4" },
154
  { name = "sentence-transformers", specifier = "==3.3.1" },
@@ -2770,6 +2772,25 @@ wheels = [
2770
  { url = "https://files.pythonhosted.org/packages/2a/21/f691fb2613100a62b3fa91e9988c991e9ca5b89ea31c0d3152a3210344f9/rank_bm25-0.2.2-py3-none-any.whl", hash = "sha256:7bd4a95571adadfc271746fa146a4bcfd89c0cf731e49c3d1ad863290adbe8ae", size = 8584, upload-time = "2022-02-16T12:10:50.626Z" },
2771
  ]
2772
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2773
  [[package]]
2774
  name = "redis"
2775
  version = "5.2.1"
 
1
  version = 1
2
+ revision = 3
3
  requires-python = "==3.12.*"
4
  resolution-markers = [
5
  "python_full_version >= '3.12.4'",
 
59
  { name = "python-dotenv" },
60
  { name = "python-multipart" },
61
  { name = "rank-bm25" },
62
+ { name = "rapidfuzz" },
63
  { name = "redis" },
64
  { name = "sentence-transformers" },
65
  { name = "slowapi" },
 
150
  { name = "python-dotenv", specifier = "==1.0.1" },
151
  { name = "python-multipart", specifier = "==0.0.12" },
152
  { name = "rank-bm25", specifier = "==0.2.2" },
153
+ { name = "rapidfuzz", specifier = ">=3.14.5" },
154
  { name = "redis", specifier = "==5.2.1" },
155
  { name = "ruff", marker = "extra == 'dev'", specifier = "==0.8.4" },
156
  { name = "sentence-transformers", specifier = "==3.3.1" },
 
2772
  { url = "https://files.pythonhosted.org/packages/2a/21/f691fb2613100a62b3fa91e9988c991e9ca5b89ea31c0d3152a3210344f9/rank_bm25-0.2.2-py3-none-any.whl", hash = "sha256:7bd4a95571adadfc271746fa146a4bcfd89c0cf731e49c3d1ad863290adbe8ae", size = 8584, upload-time = "2022-02-16T12:10:50.626Z" },
2773
  ]
2774
 
2775
+ [[package]]
2776
+ name = "rapidfuzz"
2777
+ version = "3.14.5"
2778
+ source = { registry = "https://pypi.org/simple" }
2779
+ sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" }
2780
+ wheels = [
2781
+ { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" },
2782
+ { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" },
2783
+ { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" },
2784
+ { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" },
2785
+ { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" },
2786
+ { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" },
2787
+ { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" },
2788
+ { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" },
2789
+ { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" },
2790
+ { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" },
2791
+ { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" },
2792
+ ]
2793
+
2794
  [[package]]
2795
  name = "redis"
2796
  version = "5.2.1"