[NOTICKET] feat(knowledge_extraction): free stages — models, seam adapter, filters, cluster, ranking
Browse filesFirst 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 +17 -13
- pyproject.toml +4 -0
- src/config/settings.py +7 -0
- src/knowledge_extraction/__init__.py +46 -0
- src/knowledge_extraction/adapter.py +117 -0
- src/knowledge_extraction/cluster/__init__.py +4 -0
- src/knowledge_extraction/cluster/cluster.py +111 -0
- src/knowledge_extraction/cluster/normalize.py +77 -0
- src/knowledge_extraction/config/__init__.py +0 -0
- src/knowledge_extraction/config/cues.yaml +51 -0
- src/knowledge_extraction/config/labels.yaml +44 -0
- src/knowledge_extraction/filters/__init__.py +9 -0
- src/knowledge_extraction/filters/cue_filter.py +76 -0
- src/knowledge_extraction/filters/legend_filter.py +98 -0
- src/knowledge_extraction/models.py +215 -0
- src/knowledge_extraction/rank/__init__.py +3 -0
- src/knowledge_extraction/rank/evidence.py +146 -0
- src/knowledge_extraction/settings.py +93 -0
- uv.lock +22 -1
|
@@ -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 |
-
**
|
| 137 |
-
|
| 138 |
-
`src/
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
| # | Stage | Design | Build | Note |
|
| 142 |
|---|---|---|---|---|
|
| 143 |
-
| **X1** | Candidate-entry schema | ✅ |
|
| 144 |
-
| **X2** | Offline runner over a fixture | ✅ |
|
| 145 |
-
| **X3** | GLiNER span filter | ✅ | ⬜ | Runs on
|
| 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 | ✅ |
|
| 148 |
-
| **X6** | Legend-block filter → formula vars | ✅ |
|
| 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×** |
|
| 151 |
-
| **X9** | Evidence ranking → top-K | ✅ |
|
| 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
|
| 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 |
|
| 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 |
+
OCR→pgvector service and stays untouched). Offline runner: `scripts/run_knowledge_extraction.py`.
|
| 138 |
+
Structure follows `src/query/`: typed models → deterministic stages → thin 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 |
|
|
@@ -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"
|
|
@@ -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="")
|
|
@@ -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 |
+
]
|
|
@@ -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
|
|
@@ -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"]
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
@@ -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 |
+
- '/'
|
|
@@ -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
|
|
@@ -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 |
+
]
|
|
@@ -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()
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .evidence import rank_evidence, rounds_available, top_k
|
| 2 |
+
|
| 3 |
+
__all__ = ["rank_evidence", "rounds_available", "top_k"]
|
|
@@ -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
|
|
@@ -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))
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
version = 1
|
| 2 |
-
revision =
|
| 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"
|