Download src/knowledge_extraction/cluster/cluster.py from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 6.29 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/main/src/knowledge_extraction/cluster/cluster.py
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/src/knowledge_extraction/cluster/cluster.py
-
curl -L -o cluster.py https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/main/src/knowledge_extraction/cluster/cluster.py
6.29 kB
| """Mention[] → TermCluster[]. | |
| Matching order, cheapest and most certain first: | |
| 1. exact match after normalisation (plural-folded — `normalize(lemma=True)`) | |
| 2. abbreviation ↔ expansion (from legend blocks) | |
| **There is deliberately no fuzzy pass** (retired 2026-09-10; `_fuzzy_match` is | |
| parked below with the measurement that retired it). Every merge it made that was | |
| correct is now done deterministically by plural folding; 94% of what it made was | |
| a token-subset match, which is always wrong. | |
| **Over-merging is much worse than under-merging.** An under-merge costs one | |
| extra LLM call and one extra review-queue row. A wrong merge silently destroys a | |
| distinct term, and no downstream stage recovers it — the expert never learns the | |
| term existed. Every threshold here is set on that asymmetry. | |
| Clustering is also what makes conflict detection possible at all: contradictory | |
| definitions can only be compared if all evidence for a term reaches the same | |
| call. | |
| """ | |
| from __future__ import annotations | |
| from rapidfuzz import fuzz | |
| from src.middlewares.logging import get_logger | |
| from ..models import AbbrevPair, ClusterResult, Mention, TermCluster | |
| from ..settings import FUZZY_MIN_LEN, FUZZY_THRESHOLD | |
| from .normalize import AbbrevIndex, is_noise | |
| logger = get_logger("knowledge_cluster") | |
| def cluster_mentions( | |
| mentions: list[Mention], | |
| abbrev_pairs: list[AbbrevPair], | |
| doc_id: str, | |
| fuzzy_threshold: int = FUZZY_THRESHOLD, | |
| ) -> ClusterResult: | |
| index = AbbrevIndex(abbrev_pairs) | |
| kept = [m for m in mentions if not is_noise(m.surface)] | |
| # Pairs the document declares inline - "Waste Removal (WR)" - are as good as | |
| # a legend entry and this corpus uses them constantly. Learned in one pass | |
| # BEFORE bucketing, so the bracketed form, the bare expansion and the bare | |
| # abbreviation all reach the same key instead of splitting three ways. | |
| inline = index.learn_inline(m.surface for m in kept) | |
| if inline: | |
| logger.info("cluster_inline_pairs_learned", extra={"learned": inline}) | |
| buckets: dict[str, dict] = {} | |
| for mention in kept: | |
| key = index.canonical_key(mention.surface) | |
| if key in buckets: | |
| _add(buckets[key], mention, "exact") | |
| continue | |
| linked = next((k for k in buckets if index.linked(mention.surface, k)), None) | |
| if linked: | |
| _add(buckets[linked], mention, "abbrev") | |
| continue | |
| # --- FUZZY PASS RETIRED 2026-09-10 (measured, not a style call) --------- | |
| # `_fuzzy_match` is kept below for the record; this call site is not. | |
| # | |
| # `token_set_ratio` returns EXACTLY 100 whenever one string's token set is | |
| # a subset of the other's, so a head noun always absorbed its own | |
| # compounds and the threshold of 92 was inert. Measured across the BUMA | |
| # standard, a Komatsu shop manual and an Open Pit textbook chapter: | |
| # | |
| # 131 of the 140 merges this pass made were token-subset matches — ALL | |
| # wrong (`Radiator` ate `radiator cap`; `level` ate `oil level sensor`; | |
| # `Working Hours` ate `effective working hours`, which cost the gold | |
| # term EWH its own entry entirely). | |
| # | |
| # Of the 9 genuine near-misses, 6 were plain singular/plural and 3 were | |
| # `gain/loss pty|qty|ua grouping` — three DISTINCT metrics, also wrong. | |
| # | |
| # So the pass produced no correct merge that plural folding does not now | |
| # do deterministically (`normalize(lemma=True)`). Retiring it drove | |
| # subset-merges to 0 on all three documents and lifted post-cluster gold | |
| # recall 0.8293 -> 0.8537, while also giving the FEWEST clusters of any | |
| # correct configuration (Open Pit 431, against 434 for the best guarded | |
| # fuzzy variant and 445 for no-fuzzy-without-lemma). | |
| # | |
| # Matching is now: exact (normalised, plural-folded) -> abbreviation | |
| # expansion. Nothing else. If you are tempted to restore this, the thing | |
| # to beat is subset_pairs = 0; see FREE_STAGE_RESULTS.md. | |
| # | |
| # match = _fuzzy_match(key, buckets.keys(), fuzzy_threshold) | |
| # if match: | |
| # _add(buckets[match], mention, "fuzzy") | |
| # continue | |
| buckets[key] = { | |
| "surfaces": {mention.surface}, | |
| "mentions": [mention], | |
| "reasons": set(), | |
| } | |
| clusters: list[TermCluster] = [] | |
| ordered = sorted(buckets.items(), key=lambda kv: -len(kv[1]["mentions"])) | |
| for i, (key, data) in enumerate(ordered): | |
| variants = sorted(data["surfaces"], key=lambda s: (len(s), s)) | |
| clusters.append( | |
| TermCluster( | |
| cluster_id=f"{doc_id}#c{i:03d}", | |
| canonical=_canonical(variants, key), | |
| variants=variants, | |
| mentions=data["mentions"], | |
| mention_count=len(data["mentions"]), | |
| merge_reasons=sorted(data["reasons"]), | |
| ) | |
| ) | |
| n_mentions, n_clusters = len(kept), len(clusters) | |
| return ClusterResult( | |
| doc_id=doc_id, | |
| clusters=clusters, | |
| n_mentions=n_mentions, | |
| n_clusters=n_clusters, | |
| compression_ratio=round(n_mentions / n_clusters, 3) if n_clusters else 0.0, | |
| ) | |
| def _add(bucket: dict, mention: Mention, reason: str) -> None: | |
| bucket["surfaces"].add(mention.surface) | |
| bucket["mentions"].append(mention) | |
| bucket["reasons"].add(reason) | |
| def _fuzzy_match(key: str, existing, threshold: int) -> str | None: | |
| best, best_score = None, 0.0 | |
| for other in existing: | |
| # Short strings fuzzy-match far too easily: "PA" vs "UA" scores high on | |
| # token_set_ratio. Below FUZZY_MIN_LEN only exact matching is allowed. | |
| if min(len(key), len(other)) < FUZZY_MIN_LEN: | |
| continue | |
| score = fuzz.token_set_ratio(key, other) | |
| if score >= threshold and score > best_score: | |
| best, best_score = other, score | |
| return best | |
| def _canonical(variants: list[str], key: str) -> str: | |
| """Prefer the shortest non-trivial surface — usually the term as a reader | |
| would look it up ("PA", not "Physical Availability (PA) untuk ...").""" | |
| for v in variants: | |
| if len(v) >= 2: | |
| return v | |
| return key | |