169 lines
5.7 KiB
Python
169 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from difflib import SequenceMatcher
|
|
from urllib.parse import urlsplit
|
|
|
|
from apps.jobs.models import JobPosting
|
|
from apps.sources.models import Source
|
|
|
|
from .normalization import CanonicalJobDraft, normalize_token
|
|
|
|
|
|
MERGE_THRESHOLD = 0.96
|
|
TITLE_MIN_THRESHOLD = 0.92
|
|
CONFLICT_TITLE_THRESHOLD = 0.70
|
|
CONFLICT_EMPLOYER_THRESHOLD = 0.50
|
|
CONFLICT_LOCATION_THRESHOLD = 0.45
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EmployerResolutionDecision:
|
|
job: JobPosting | None
|
|
reason: str
|
|
confidence: float
|
|
canonical_url: str | None = None
|
|
conflict: bool = False
|
|
evidence: list[str] = field(default_factory=list)
|
|
|
|
|
|
def _normalize_similarity(value: str) -> str:
|
|
return normalize_token(value or "")
|
|
|
|
|
|
def _token_similarity(left: str, right: str) -> float:
|
|
if not left or not right:
|
|
return 0.0
|
|
return SequenceMatcher(
|
|
None, _normalize_similarity(left)[:12000], _normalize_similarity(right)[:12000]
|
|
).ratio()
|
|
|
|
|
|
def _weighted_similarity(
|
|
title_score: float,
|
|
location_score: float | None,
|
|
employer_score: float | None,
|
|
employer_domain_match: bool,
|
|
canonical_host_match: bool,
|
|
) -> float:
|
|
weights: list[tuple[float, float]] = [(title_score, 0.68), (employer_domain_match and 1.0 or 0.0, 0.12)]
|
|
if location_score is not None:
|
|
weights.append((location_score, 0.12))
|
|
if employer_score is not None:
|
|
weights.append((employer_score, 0.06))
|
|
if canonical_host_match:
|
|
weights.append((1.0, 0.05))
|
|
total_weight = sum(weight for _, weight in weights)
|
|
if total_weight == 0:
|
|
return 0.0
|
|
return sum(value * weight for value, weight in weights) / total_weight
|
|
|
|
|
|
def _domain_match(value: str, candidate: str) -> bool:
|
|
if not value or not candidate:
|
|
return False
|
|
return _normalize_similarity(value).strip(".").lower() == _normalize_similarity(candidate).strip(".").lower()
|
|
|
|
|
|
def _host(value: str) -> str:
|
|
return (urlsplit((value or "").lower()).hostname or "").strip(".")
|
|
|
|
|
|
def resolve_direct_employer_match(
|
|
draft: CanonicalJobDraft, *, source: Source | None
|
|
) -> EmployerResolutionDecision:
|
|
if source is None or source.source_type == Source.Type.EMPLOYER or not draft.normalized_title:
|
|
return EmployerResolutionDecision(None, "no_direct_resolution", 0.0, canonical_url=None, conflict=False)
|
|
|
|
candidates = JobPosting.objects.filter(
|
|
status__in=[JobPosting.Status.ACTIVE, JobPosting.Status.NEW],
|
|
direct_employer=True,
|
|
).select_related("employer").order_by("id")
|
|
|
|
best: JobPosting | None = None
|
|
best_score = 0.0
|
|
best_conflict = False
|
|
best_evidence: list[str] = []
|
|
draft_host = _host(draft.canonical_url)
|
|
|
|
for candidate in candidates:
|
|
title_score = _token_similarity(draft.normalized_title, candidate.normalized_title)
|
|
if title_score < TITLE_MIN_THRESHOLD:
|
|
continue
|
|
|
|
location_score: float | None = None
|
|
if draft.location_text and candidate.raw_location:
|
|
location_score = _token_similarity(draft.location_text, candidate.raw_location)
|
|
|
|
employer_score: float | None = None
|
|
if draft.employer_name and candidate.employer_name:
|
|
employer_score = _token_similarity(draft.employer_name, candidate.employer_name)
|
|
|
|
employer_domain_match = _domain_match(
|
|
draft.employer_domain, candidate.employer.domain if candidate.employer else ""
|
|
)
|
|
canonical_host_match = _host(candidate.canonical_url) == draft_host
|
|
|
|
score = _weighted_similarity(
|
|
title_score=title_score,
|
|
location_score=location_score,
|
|
employer_score=employer_score,
|
|
employer_domain_match=employer_domain_match,
|
|
canonical_host_match=canonical_host_match,
|
|
)
|
|
|
|
has_conflict = False
|
|
if draft.location_text and candidate.raw_location:
|
|
if location_score is not None and location_score < CONFLICT_LOCATION_THRESHOLD:
|
|
has_conflict = True
|
|
if draft.employer_name and candidate.employer_name and (
|
|
employer_score is not None and employer_score < CONFLICT_EMPLOYER_THRESHOLD
|
|
):
|
|
has_conflict = True
|
|
if draft.employer_name and not candidate.employer_name and title_score < CONFLICT_TITLE_THRESHOLD:
|
|
has_conflict = True
|
|
|
|
if score > best_score:
|
|
best = candidate
|
|
best_score = score
|
|
best_conflict = has_conflict
|
|
best_evidence = [
|
|
f"title:{round(title_score, 3)}",
|
|
f"location:{'none' if location_score is None else round(location_score, 3)}",
|
|
f"employer:{'none' if employer_score is None else round(employer_score, 3)}",
|
|
f"employer_domain:{int(employer_domain_match)}",
|
|
f"canonical_host_match:{int(canonical_host_match)}",
|
|
]
|
|
|
|
if best is None:
|
|
return EmployerResolutionDecision(None, "no_direct_resolution", 0.0, conflict=False)
|
|
|
|
if best_conflict:
|
|
return EmployerResolutionDecision(
|
|
None,
|
|
"review_direct_conflict",
|
|
best_score,
|
|
canonical_url=best.canonical_url,
|
|
conflict=True,
|
|
evidence=best_evidence,
|
|
)
|
|
|
|
if best_score < MERGE_THRESHOLD:
|
|
return EmployerResolutionDecision(
|
|
None,
|
|
"no_direct_resolution",
|
|
best_score,
|
|
canonical_url=None,
|
|
conflict=False,
|
|
evidence=best_evidence,
|
|
)
|
|
|
|
return EmployerResolutionDecision(
|
|
best,
|
|
"resolved_direct_match",
|
|
best_score,
|
|
canonical_url=best.canonical_url,
|
|
conflict=False,
|
|
evidence=best_evidence,
|
|
)
|