add reversible prospect deduplication

This commit is contained in:
Marco0300
2026-09-03 08:46:22 +02:00
parent 46cc1f6182
commit 25ee7931ab
14 changed files with 290 additions and 13 deletions
+63 -1
View File
@@ -2,9 +2,12 @@
from __future__ import annotations
import re
import unicodedata
from difflib import SequenceMatcher
from urllib.parse import urlparse
SCORE_VERSION = "mvp-1"
MATCH_SCORE_VERSION = "phase6-1"
_SOCIAL = {"facebook.com", "instagram.com", "linkedin.com", "twitter.com", "x.com", "youtube.com", "tiktok.com"}
@@ -20,7 +23,38 @@ def normalize_domain(value: str | None) -> str:
def normalize_phone(value: str | None) -> str:
return re.sub(r"[^0-9+]", "", (value or "").strip())
raw = str(value or "").strip()
if not raw:
return ""
# Keep a leading international plus and digits only; never invent a country
# code for an unknown number. South African local and 00 prefixes are safe
# canonicalization cases because their numbering plan is unambiguous.
compact = re.sub(r"[^0-9+]", "", raw)
if compact.startswith("00"):
compact = "+" + compact[2:]
if compact.startswith("+27"):
rest = compact[3:]
if rest.startswith("0"):
rest = rest[1:]
return "+27" + rest
if compact.startswith("0") and len(compact) == 10:
return "+27" + compact[1:]
if compact.startswith("+"):
return "+" + re.sub(r"\D", "", compact[1:])
return re.sub(r"\D", "", compact)
def _location_part(value: object) -> str:
text = " ".join(str(value or "").split()).strip().lower()
return "".join(c for c in unicodedata.normalize("NFKD", text) if not unicodedata.combining(c))
def normalize_location(value: object = None, *, province=None, city=None, suburb=None) -> dict:
if isinstance(value, dict):
province, city, suburb = value.get("province", province), value.get("city", city), value.get("suburb", suburb)
elif value is not None and not any(x is not None for x in (province, city, suburb)):
province = value
return {"province": _location_part(province), "city": _location_part(city), "suburb": _location_part(suburb)}
def normalize_business(raw: dict) -> dict:
@@ -31,9 +65,37 @@ def normalize_business(raw: dict) -> dict:
phone = normalize_phone(raw.get("phone"))
result = dict(raw)
result.update({"name": name, "email": email, "website": website, "website_domain": domain, "phone": phone})
result.update(normalize_location(raw.get("location", raw)))
return result
def match_businesses(source: dict, candidates: list[dict], threshold: float = 0.72) -> list[dict]:
"""Return deterministic, explainable suggestions; this function never merges."""
left = normalize_business(source)
output = []
for raw in candidates:
right = normalize_business(raw)
signals = []
if left["website_domain"] and left["website_domain"] == right["website_domain"]:
signals.append((1.0, "exact_website_domain"))
if left["email"] and left["email"] == right["email"]:
signals.append((1.0, "exact_email"))
if left["phone"] and left["phone"] == right["phone"]:
signals.append((1.0, "exact_phone"))
if left["name"] and right["name"]:
similarity = SequenceMatcher(None, re.sub(r"[^a-z0-9]", "", left["name"].lower()), re.sub(r"[^a-z0-9]", "", right["name"].lower())).ratio()
if similarity >= 0.65: signals.append((similarity, "similar_name"))
for field, reason in (("province", "same_province"), ("city", "same_city"), ("suburb", "same_suburb")):
if left[field] and left[field] == right[field]: signals.append((0.08, reason))
if not signals: continue
exact = [s for s, r in signals if r.startswith("exact_")]
name = next((s for s, r in signals if r == "similar_name"), 0.0)
confidence = max(exact or [0.0]) if exact else min(0.99, 0.65 * name + sum(s for s, r in signals if r.startswith("same_")))
if confidence >= threshold:
output.append({"id": raw.get("id"), "confidence": round(confidence, 4), "reasons": [r for _, r in signals], "score_version": MATCH_SCORE_VERSION})
return sorted(output, key=lambda x: (-x["confidence"], x["id"] if isinstance(x["id"], int) else str(x["id"])))
def classify_website(website_or_domain: str | None) -> str:
domain = normalize_domain(website_or_domain)
if not domain: