from collections import defaultdict from typing import NamedTuple, Optional import pandas as pd from backend.utils.addressMatch import AddressMatch class GroupDecision(NamedTuple): """One row's outcome after cross-row ambiguity resolution (ADR-0057).""" uprn: Optional[str] status: str # "matched" | "ambiguous_duplicate" | "unmatched" def resolve_group_ambiguity( matches: list[tuple[Optional[str], str]], ) -> list[GroupDecision]: """Resolve cross-row UPRN ambiguity within one postcode group (ADR-0057). ``matches`` is ``(uprn, normalised_address)`` per row. A UPRN that is the best match for two rows with *different* normalised addresses is withheld on both (a coarse EPC record absorbing several real addresses, e.g. flats in a block); a UPRN shared only by identical addresses is a genuine re-listing and kept. Returns a ``GroupDecision`` per row, in input order. """ distinct_addresses: dict[str, set[str]] = defaultdict(set) for uprn, norm_address in matches: if uprn: distinct_addresses[uprn].add(norm_address) resolved: list[GroupDecision] = [] for uprn, _norm_address in matches: if not uprn: resolved.append(GroupDecision(None, "unmatched")) elif len(distinct_addresses[uprn]) > 1: resolved.append(GroupDecision(None, "ambiguous_duplicate")) else: resolved.append(GroupDecision(uprn, "matched")) return resolved def all_uprns_match( df: pd.DataFrame, target_uprn: str, column: str = "uprn", ) -> bool: if column not in df.columns: return False uprns = df[column].dropna().astype(str).str.strip().unique() if len(uprns) == 0: return False return len(uprns) == 1 and uprns[0] == str(target_uprn) def rank_address_similarity( address_list_df: pd.DataFrame, user_address: str, address_column: str = "address", uprn_column: str = "uprn", ) -> pd.DataFrame: """ Annotate EPC results with lexicographical similarity scores and ranks. Returns a DataFrame sorted by descending lexiscore. DOES NOT choose or return a UPRN. """ if address_column not in address_list_df.columns: raise ValueError(f"Missing column: {address_column}") if uprn_column not in address_list_df.columns: raise ValueError(f"Missing column: {uprn_column}") out = address_list_df.copy() user_norm = AddressMatch.normalise_address(user_address) out["lexiscore"] = out[address_column].apply( lambda x: AddressMatch.levenshtein(user_norm, x) ) out[uprn_column] = out[uprn_column].astype(str).str.replace(r"\.0$", "", regex=True) out["lexirank"] = out["lexiscore"].rank(method="dense", ascending=False).astype(int) return out.sort_values( ["lexirank", "lexiscore"], ascending=[True, False], )