from rapidfuzz import fuzz import re REMOVE = { 'mr', 'mrs', 'ms', 'miss', 'dr', 'shri', 'smt', 'sau', 'sou', 'w/o', 's/o', 'd/o', 'c/o', 'sh', 'shree', 'sri' } # Compound / suffix words which can appear joined with names COMPOUND_SUFFIXES = { 'rao', 'bhai', 'devi', 'kumar', 'lal', 'kant', 'prasad', 'ben', 'banu', 'bai', 'laxmi', 'begam', 'begum' } def normalize_text(name): if not name: return "" name = str(name).lower() # Normalize separators name = name.replace("-", " ").replace(".", " ").replace("/", " ") # Remove relationship prefixes name = re.sub(r'\b(w\s*o|s\s*o|d\s*o|c\s*o)\b', ' ', name) # Keep only alphabets, numbers and spaces name = re.sub(r'[^a-z0-9\s]', ' ', name) # Remove duplicate spaces name = re.sub(r'\s+', ' ', name).strip() return name def clean_name(name): if not name: return [] name = normalize_text(name) # If '&' is present, only consider the name before it name = re.split(r'\s*&\s*', name)[0] tokens = [] for token in name.split(): if not token or token in REMOVE: continue tokens.append(token) return tokens def compound_variants(token): variants = [token] suffixes = sorted(COMPOUND_SUFFIXES, key=len, reverse=True) for suffix in suffixes: if token.endswith(suffix) and token != suffix: root = token[:-len(suffix)] if len(root) >= 3: variants.append(root) variants.append(suffix) break return variants def expand_tokens(tokens): expanded = [] for token in tokens: if token not in expanded: expanded.append(token) for variant in compound_variants(token)[1:]: if variant not in expanded: expanded.append(variant) return expanded def is_initial(token): return len(token) == 1 def token_similarity(a, b): if not a or not b: return 0 if a == b: return 100 if is_initial(a) and len(b) >= 3 and b.startswith(a): return 96 if is_initial(b) and len(a) >= 3 and a.startswith(b): return 96 if a.replace(" ", "") == b.replace(" ", ""): return 98 if len(a) >= 5 and len(b) >= 5: shorter = min(len(a), len(b)) longer = max(len(a), len(b)) if (a in b or b in a) and shorter / longer >= 0.75: return 94 ratio = fuzz.ratio(a, b) if ratio >= 93: return 96 if ratio >= 88: return 92 if ratio >= 82 and len(a) >= 5 and len(b) >= 5: return 85 return 0 def best_token_matches(tokens1, tokens2): candidates = [] for i, a in enumerate(tokens1): for j, b in enumerate(tokens2): score = token_similarity(a, b) if score > 0: candidates.append((score, i, j, a, b)) candidates.sort(key=lambda x: x[0], reverse=True) matches = [] used_i = set() used_j = set() for item in candidates: score, i, j, a, b = item if i in used_i or j in used_j: continue matches.append(item) used_i.add(i) used_j.add(j) return matches def calculate_name_score(tokens1, tokens2): if not tokens1 or not tokens2: return 0, [] matches = best_token_matches(tokens1, tokens2) # Try compound-name expansion if the initial pass is weak if len(matches) < 2 or max([x[0] for x in matches], default=0) < 85: expanded1 = expand_tokens(tokens1) expanded2 = expand_tokens(tokens2) compound_matches = best_token_matches(expanded1, expanded2) if len(compound_matches) > len(matches): matches = compound_matches if not matches: return 0, [] scores = [x[0] for x in matches] average_score = sum(scores) / len(scores) matched_tokens = len(matches) # Coverage is calculated using ORIGINAL token counts so compound # expansion cannot artificially inflate the score. smaller_tokens = min(len(tokens1), len(tokens2)) larger_tokens = max(len(tokens1), len(tokens2)) effective_matches = min(matched_tokens, smaller_tokens) small_coverage = min(effective_matches / smaller_tokens, 1) large_coverage = min(effective_matches / larger_tokens, 1) score = (average_score * 0.65) + (small_coverage * 100 * 0.20) + (large_coverage * 100 * 0.15) return round(min(score, 100), 2), matches def whole_name_score(tokens1, tokens2): name1 = " ".join(tokens1) name2 = " ".join(tokens2) token_sort = fuzz.token_sort_ratio(name1, name2) token_set = fuzz.token_set_ratio(name1, name2) compact_ratio = fuzz.ratio(name1.replace(" ", ""), name2.replace(" ", "")) return round(max(token_sort, token_set, compact_ratio), 2) def exact_overlap(tokens1, tokens2): return len(set(tokens1) & set(tokens2)) def given_name_similarity(tokens1, tokens2): """ Compare the first meaningful token from both names. Protects against cases such as: ANAND NARAYANRAO KHADSE ARYA ANAND KHADSE Both contain ANAND + KHADSE, but ANAND and ARYA are different given names. This does not block legitimate reordered names because it only applies when overall identity evidence is weak. """ if not tokens1 or not tokens2: return 0 return token_similarity(tokens1[0], tokens2[0]) def fuzzy_name_match(name1, name2): tokens1 = clean_name(name1) tokens2 = clean_name(name2) if not tokens1 or not tokens2: return 0 # Exact match if tokens1 == tokens2: return 100 # Same tokens, different order if set(tokens1) == set(tokens2): return 98 # Same name with spaces removed (e.g. JAMELA BEGAM vs JAMELABEGAM) compact1 = "".join(tokens1) compact2 = "".join(tokens2) if compact1 == compact2: return 100 token_score, matches = calculate_name_score(tokens1, tokens2) if not matches: return 0 whole_score = whole_name_score(tokens1, tokens2) matched_count = len(matches) smaller_count = min(len(tokens1), len(tokens2)) larger_count = max(len(tokens1), len(tokens2)) effective_matches = min(matched_count, smaller_count) small_coverage = min(effective_matches / smaller_count, 1) large_coverage = min(effective_matches / larger_count, 1) overlap = exact_overlap(tokens1, tokens2) # V5 given-name protection given_similarity = given_name_similarity(tokens1, tokens2) if len(tokens1) >= 2 and len(tokens2) >= 2 and given_similarity < 82: exact_common = len(set(tokens1) & set(tokens2)) # If the shorter name contains a token that is completely # different, don't let surname overlap create a false MATCH. if exact_common < smaller_count or whole_score < 90: protected_score = whole_score * 0.75 return round(min(protected_score, 69.99), 2) # Strong multi-token match if small_coverage >= 1 and matched_count >= 2 and token_score >= 88 and large_coverage >= 0.66: return round(min(max(token_score, whole_score), 100), 2) # Two or more common tokens if overlap >= 2 and small_coverage >= 0.75 and token_score >= 88: return round(min(max(token_score, whole_score), 100), 2) # General fuzzy score final = (token_score * 0.60) + (whole_score * 0.30) + (large_coverage * 100 * 0.10) # Protect against single-token overlap if overlap == 1 and matched_count <= 1 and len(tokens1) > 1 and len(tokens2) > 1: final = min(final, 60) # Very low coverage if small_coverage < 0.50: final = min(final, 60) # One-token names if len(tokens1) == 1 or len(tokens2) == 1: if tokens1[0] != tokens2[0] and token_score < 93: final = min(final, 75) return round(min(final, 100), 2) def get_match_reason(name1, name2, score): tokens1 = clean_name(name1) tokens2 = clean_name(name2) if tokens1 == tokens2: return "EXACT_MATCH" if set(tokens1) == set(tokens2): return "REORDERED_MATCH" if "".join(tokens1) == "".join(tokens2): return "COMPOUND_NAME_MATCH" given_similarity = given_name_similarity(tokens1, tokens2) if len(tokens1) >= 2 and len(tokens2) >= 2 and given_similarity < 82: return "DIFFERENT_GIVEN_NAME" token_score, matches = calculate_name_score(tokens1, tokens2) if matches: exact_count = sum(1 for x in matches if x[0] == 100) fuzzy_count = sum(1 for x in matches if 0 < x[0] < 100) if exact_count >= 2: return "MULTI_TOKEN_MATCH" if fuzzy_count >= 1: return "SPELLING_VARIATION" if score >= 90: return "FUZZY_MULTI_TOKEN_MATCH" if score >= 70: return "PARTIAL_MATCH" return "LOW_CONFIDENCE" def fuzzy_name_match_with_reason(name1, name2): score = fuzzy_name_match(name1, name2) reason = get_match_reason(name1, name2, score) return {"score": score, "reason": reason} def classify(score): if score >= 90: return "MATCH" if score >= 70: return "REVIEW" return "NO MATCH"