ishaq101's picture
/fix parsing and term extract (#21)
f07443e
Raw History Blame Contribute Delete
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