Files
0pt/services/api/workers/entity_resolution.py
T

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)]