"""Pure-Python string metrics used when the optional ``jellyfish`` wheel is absent. The production project may still use jellyfish as an accelerator. These implementations keep the business matcher deterministic and testable in restricted/offline environments (including CPython 3.13 builders without access to PyPI). """ from __future__ import annotations import re import unicodedata def _norm(value: str) -> str: value = unicodedata.normalize("NFKD", str(value).lower()) value = "".join(ch for ch in value if not unicodedata.combining(ch)) return re.sub(r"[^a-z0-9]+", "", value) def levenshtein_distance(a: str, b: str) -> int: a, b = str(a), str(b) if a == b: return 0 if not a: return len(b) if not b: return len(a) if len(a) > len(b): a, b = b, a previous = list(range(len(a) + 1)) for i, cb in enumerate(b, 1): current = [i] for j, ca in enumerate(a, 1): current.append(min( current[-1] + 1, previous[j] + 1, previous[j - 1] + (ca != cb), )) previous = current return previous[-1] def jaro_similarity(a: str, b: str) -> float: a, b = str(a), str(b) if a == b: return 1.0 if not a or not b: return 0.0 match_distance = max(len(a), len(b)) // 2 - 1 match_distance = max(match_distance, 0) a_match = [False] * len(a) b_match = [False] * len(b) matches = 0 for i, ca in enumerate(a): lo = max(0, i - match_distance) hi = min(i + match_distance + 1, len(b)) for j in range(lo, hi): if b_match[j] or ca != b[j]: continue a_match[i] = True b_match[j] = True matches += 1 break if matches == 0: return 0.0 a_chars = [a[i] for i in range(len(a)) if a_match[i]] b_chars = [b[j] for j in range(len(b)) if b_match[j]] transpositions = sum(x != y for x, y in zip(a_chars, b_chars)) / 2.0 m = float(matches) return (m / len(a) + m / len(b) + (m - transpositions) / m) / 3.0 def jaro_winkler_similarity(a: str, b: str, *, scaling: float = 0.1) -> float: jaro = jaro_similarity(a, b) prefix = 0 for ca, cb in zip(str(a), str(b)): if ca != cb or prefix == 4: break prefix += 1 return jaro + prefix * scaling * (1.0 - jaro) def metaphone(value: str) -> str: """Small deterministic phonetic key tuned for Portuguese ASR item names. It intentionally mirrors the role (not the implementation) of Jellyfish's Metaphone: collapse spelling variants so the matcher can rank transcript errors. The key is conservative and is only one signal alongside Jaro-Winkler/token similarity. """ s = _norm(value) if not s: return "" replacements = ( ("sch", "x"), ("sh", "x"), ("ch", "x"), ("ph", "f"), ("th", "t"), ("nh", "n"), ("lh", "l"), ("qu", "k"), ("gu", "g"), ("ck", "k"), ) for old, new in replacements: s = s.replace(old, new) trans = str.maketrans({ "c": "k", "q": "k", "k": "k", "g": "j", "j": "j", "v": "f", "f": "f", "z": "s", "s": "s", "x": "x", "d": "t", "t": "t", "b": "p", "p": "p", "y": "i", "w": "u", }) s = s.translate(trans) first = s[0] tail = "".join(ch for ch in s[1:] if ch not in "aeiou") code = first + tail code = re.sub(r"(.)\1+", r"\1", code) return code.upper() __all__ = ["jaro_winkler_similarity", "levenshtein_distance", "metaphone"]