Files
VacatureRadar/apps/jobs/services/employer_resolution.py
T
Jens b8091e59bd
deploy / deploy (push) Canceled after 0s
Initial deploy setup
2026-07-21 14:00:00 +02:00

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