218 lines
6.9 KiB
Python
218 lines
6.9 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Literal
|
|
from urllib.parse import urlparse
|
|
import re
|
|
import unicodedata
|
|
|
|
from rapidfuzz.fuzz import ratio
|
|
|
|
EntityType = Literal["organization", "person"]
|
|
|
|
|
|
class ResolutionDecision(str, Enum):
|
|
EXACT_LINK = "exact_link"
|
|
REVIEW = "review"
|
|
DISTINCT = "distinct"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EntityCandidate:
|
|
entity_id: str
|
|
entity_type: EntityType
|
|
name: str
|
|
source_ref: str
|
|
registration_country: str | None = None
|
|
registration_number: str | None = None
|
|
email: str | None = None
|
|
website: str | None = None
|
|
organization_id: str | None = None
|
|
city: str | None = None
|
|
metadata: dict[str, object] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResolutionResult:
|
|
left_id: str
|
|
right_id: str
|
|
decision: ResolutionDecision
|
|
score: float
|
|
reasons: tuple[str, ...]
|
|
requires_human_review: bool
|
|
algorithm_version: str = "entity-resolution-v1"
|
|
|
|
|
|
def _norm(value: str | None) -> str:
|
|
if not value:
|
|
return ""
|
|
normalized = unicodedata.normalize("NFKD", value)
|
|
ascii_text = "".join(char for char in normalized if not unicodedata.combining(char))
|
|
return re.sub(r"[^a-z0-9]+", " ", ascii_text.lower()).strip()
|
|
|
|
|
|
def _norm_registration_number(value: str | None) -> str:
|
|
return re.sub(r"[^0-9a-z]+", "", (value or "").lower())
|
|
|
|
|
|
def _norm_email(value: str | None) -> str:
|
|
return (value or "").strip().lower()
|
|
|
|
|
|
def _domain(value: str | None) -> str:
|
|
if not value:
|
|
return ""
|
|
raw = value.strip().lower()
|
|
if "@" in raw and "://" not in raw:
|
|
raw = raw.rsplit("@", 1)[-1]
|
|
if "://" not in raw:
|
|
raw = f"https://{raw}"
|
|
host = (urlparse(raw).hostname or "").lower()
|
|
return host.removeprefix("www.")
|
|
|
|
|
|
def _exact_organization(left: EntityCandidate, right: EntityCandidate) -> ResolutionResult | None:
|
|
left_number = _norm_registration_number(left.registration_number)
|
|
right_number = _norm_registration_number(right.registration_number)
|
|
left_country = _norm(left.registration_country)
|
|
right_country = _norm(right.registration_country)
|
|
if left_number and right_number and left_country and right_country:
|
|
if left_country == right_country and left_number == right_number:
|
|
return ResolutionResult(
|
|
left.entity_id,
|
|
right.entity_id,
|
|
ResolutionDecision.EXACT_LINK,
|
|
1.0,
|
|
("same registration country and registration number",),
|
|
False,
|
|
)
|
|
if left_country == right_country and left_number != right_number:
|
|
return ResolutionResult(
|
|
left.entity_id,
|
|
right.entity_id,
|
|
ResolutionDecision.DISTINCT,
|
|
0.0,
|
|
("different registration numbers in the same country",),
|
|
False,
|
|
)
|
|
return None
|
|
|
|
|
|
def _exact_person(left: EntityCandidate, right: EntityCandidate) -> ResolutionResult | None:
|
|
left_email = _norm_email(left.email)
|
|
right_email = _norm_email(right.email)
|
|
if left_email and right_email:
|
|
if left_email == right_email:
|
|
return ResolutionResult(
|
|
left.entity_id,
|
|
right.entity_id,
|
|
ResolutionDecision.EXACT_LINK,
|
|
1.0,
|
|
("same normalized email address",),
|
|
False,
|
|
)
|
|
return None
|
|
|
|
|
|
def resolve_entity_pair(left: EntityCandidate, right: EntityCandidate) -> ResolutionResult:
|
|
if left.entity_type != right.entity_type:
|
|
return ResolutionResult(
|
|
left.entity_id,
|
|
right.entity_id,
|
|
ResolutionDecision.DISTINCT,
|
|
0.0,
|
|
("different entity types",),
|
|
False,
|
|
)
|
|
|
|
exact = _exact_organization(left, right) if left.entity_type == "organization" else _exact_person(left, right)
|
|
if exact is not None:
|
|
return exact
|
|
|
|
left_name = _norm(left.name)
|
|
right_name = _norm(right.name)
|
|
if not left_name or not right_name:
|
|
return ResolutionResult(
|
|
left.entity_id,
|
|
right.entity_id,
|
|
ResolutionDecision.REVIEW,
|
|
0.0,
|
|
("missing normalized name prevents deterministic comparison",),
|
|
True,
|
|
)
|
|
|
|
name_similarity = ratio(left_name, right_name) / 100.0
|
|
reasons = [f"name similarity {name_similarity:.3f}"]
|
|
score = name_similarity * 0.65
|
|
|
|
if left.entity_type == "organization":
|
|
left_domain = _domain(left.website or left.email)
|
|
right_domain = _domain(right.website or right.email)
|
|
if left_domain and right_domain:
|
|
if left_domain == right_domain:
|
|
score += 0.25
|
|
reasons.append("same normalized domain")
|
|
else:
|
|
score -= 0.1
|
|
reasons.append("different normalized domains")
|
|
if _norm(left.registration_country) and _norm(left.registration_country) == _norm(right.registration_country):
|
|
score += 0.05
|
|
reasons.append("same registration country")
|
|
if _norm(left.city) and _norm(left.city) == _norm(right.city):
|
|
score += 0.05
|
|
reasons.append("same city")
|
|
else:
|
|
if left.organization_id and right.organization_id:
|
|
if left.organization_id == right.organization_id:
|
|
score += 0.25
|
|
reasons.append("same known organization")
|
|
else:
|
|
score -= 0.1
|
|
reasons.append("different known organizations")
|
|
left_domain = _domain(left.email)
|
|
right_domain = _domain(right.email)
|
|
if left_domain and right_domain and left_domain == right_domain:
|
|
score += 0.1
|
|
reasons.append("same email domain")
|
|
|
|
score = max(0.0, min(1.0, score))
|
|
|
|
# Only strong identifiers auto-link. Similarity-based matches remain operator-review candidates.
|
|
if score >= 0.72:
|
|
decision = ResolutionDecision.REVIEW
|
|
review = True
|
|
elif score <= 0.3:
|
|
decision = ResolutionDecision.DISTINCT
|
|
review = False
|
|
else:
|
|
decision = ResolutionDecision.REVIEW
|
|
review = True
|
|
|
|
return ResolutionResult(
|
|
left.entity_id,
|
|
right.entity_id,
|
|
decision,
|
|
round(score, 6),
|
|
tuple(reasons),
|
|
review,
|
|
)
|
|
|
|
|
|
def rank_resolution_candidates(
|
|
query: EntityCandidate,
|
|
candidates: list[EntityCandidate],
|
|
*,
|
|
limit: int = 20,
|
|
) -> list[ResolutionResult]:
|
|
results = [resolve_entity_pair(query, candidate) for candidate in candidates if candidate.entity_id != query.entity_id]
|
|
decision_order = {
|
|
ResolutionDecision.EXACT_LINK: 0,
|
|
ResolutionDecision.REVIEW: 1,
|
|
ResolutionDecision.DISTINCT: 2,
|
|
}
|
|
return sorted(
|
|
results,
|
|
key=lambda result: (decision_order[result.decision], -result.score, result.right_id),
|
|
)[: max(0, limit)]
|