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