Files
VacatureRadar/apps/jobs/services/scoring.py
T

516 lines
19 KiB
Python

from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Any
from django.db import transaction
from apps.jobs.models import JobPosting, ScoreRun
from apps.jobs.services.ai import AiAnalysis, AiAnalysisCache, analyze_job_text
from apps.jobs.services.distance import estimate_commute, haversine_km
from apps.jobs.services.geocoding import resolve_cached_location
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
from .relevance import assess_it_relevance, profile_requires_it_focus
from .skill_terms import contains_term, skill_is_present
@dataclass(frozen=True)
class ScoreResult:
score: float
confidence: float
recommendation: str
components: dict[str, float]
positives: list[str]
concerns: list[str]
hard_exclusions: list[str]
evidence: dict[str, Any]
model_version: str = ""
prompt_version: str = ""
@dataclass(frozen=True)
class _GeoReference:
latitude: float
longitude: float
confidence: float
@dataclass(frozen=True)
class DistanceAssessment:
exact_distance_km: float | None
distance_confidence: float | None
commute_minutes: int | None
commute_estimate: bool
commute_confidence: float | None
commute_source: str | None
commute_source_version: str | None
has_distance_data: bool
EXACT_DISTANCE_CONF_THRESHOLD = 0.80
AI_MAX_WEIGHT = 20.0
SKILL_MATCH_TARGET = 4
def _similarity(left: str, right: str) -> float:
if not left or not right:
return 0.0
return SequenceMatcher(None, normalize_token(left), normalize_token(right)).ratio()
def _resolve_cached_geopoint(value: str) -> _GeoReference | None:
for query in (value or "").split(","):
query = query.strip()
if not query:
continue
match = resolve_cached_location(query)
if match.location and match.location.point:
return _GeoReference(
latitude=match.location.point.latitude,
longitude=match.location.point.longitude,
confidence=float(match.location.confidence),
)
if match.ambiguous:
return None
return None
def _job_reference(job: JobPosting) -> _GeoReference | None:
if None not in (job.latitude, job.longitude):
return _GeoReference(
latitude=float(job.latitude),
longitude=float(job.longitude),
confidence=1.0,
)
for candidate in (job.postal_code, job.municipality, job.raw_location):
reference = _resolve_cached_geopoint(candidate)
if reference is not None:
return reference
return None
def _profile_reference(profile: SearchProfile) -> _GeoReference | None:
if None not in (profile.home_latitude, profile.home_longitude):
return _GeoReference(
latitude=float(profile.home_latitude),
longitude=float(profile.home_longitude),
confidence=1.0,
)
for candidate in (profile.home_postal_code, profile.home_municipality):
reference = _resolve_cached_geopoint(candidate)
if reference is not None:
return reference
return None
def _title_fit(job: JobPosting, profile: SearchProfile) -> float:
if not profile.desired_titles:
return 0.65
return max(
_similarity(job.normalized_title, desired_title) for desired_title in profile.desired_titles
)
def _contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
return contains_term(text, term, allow_plural=allow_plural)
def _skill_is_present(skill: str, text: str) -> bool:
return skill_is_present(skill, text)
def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str], list[str]]:
desired = {normalize_token(skill) for skill in profile.desired_skills if skill}
if not desired:
return 0.65, [], []
text = normalize_token(
" ".join(job.skills_required + job.skills_preferred) + " " + job.description_text
)
present = sorted(skill for skill in desired if _skill_is_present(skill, text))
missing = sorted(desired - set(present))
evidence_target = min(SKILL_MATCH_TARGET, len(desired))
return min(1.0, len(present) / evidence_target), present, missing
def _distance(job: JobPosting, profile: SearchProfile) -> DistanceAssessment:
profile_reference = _profile_reference(profile)
job_reference = _job_reference(job)
if profile_reference is None or job_reference is None:
return DistanceAssessment(
exact_distance_km=None,
distance_confidence=None,
commute_minutes=None,
commute_estimate=False,
commute_confidence=None,
commute_source=None,
commute_source_version=None,
has_distance_data=False,
)
distance_km = haversine_km(
profile_reference.latitude,
profile_reference.longitude,
job_reference.latitude,
job_reference.longitude,
)
commute = estimate_commute(distance_km)
exact = (
profile_reference.confidence >= EXACT_DISTANCE_CONF_THRESHOLD
and job_reference.confidence >= EXACT_DISTANCE_CONF_THRESHOLD
)
distance_confidence = min(profile_reference.confidence, job_reference.confidence)
return DistanceAssessment(
exact_distance_km=round(distance_km, 1) if exact else None,
distance_confidence=distance_confidence,
commute_minutes=commute.minutes if commute else None,
commute_estimate=commute.is_estimate if commute else False,
commute_confidence=commute.confidence if commute else None,
commute_source=commute.source if commute else None,
commute_source_version=commute.source_version if commute else None,
has_distance_data=True,
)
def _hard_exclusions(
job: JobPosting, profile: SearchProfile, distance: DistanceAssessment
) -> tuple[list[str], float | None]:
reasons: list[str] = []
distance_limit = float(profile.max_distance_km)
title = normalize_token(job.original_title)
configured_terms = list(profile.excluded_titles)
configured_terms += list(profile.hard_rules.get("excluded_title_terms", []))
for term in configured_terms:
if _contains_term(title, normalize_token(term), allow_plural=True):
reasons.append(f"Uitgesloten titelterm: {term}")
excluded_types = set(profile.hard_rules.get("excluded_employment_types", []))
conflict_types = excluded_types.intersection(job.employment_types)
if conflict_types:
reasons.append("Uitgesloten contractvorm: " + ", ".join(sorted(conflict_types)))
if (
profile.allowed_employment_types
and job.employment_types
and not set(profile.allowed_employment_types).intersection(job.employment_types)
):
reasons.append("Geen toegestane contractvorm")
excluded_regions = {normalize_token(v) for v in profile.excluded_regions}
if (
normalize_token(job.region) in excluded_regions
or normalize_token(job.municipality) in excluded_regions
):
reasons.append(f"Uitgesloten regio: {job.region or job.municipality}")
if (
distance.exact_distance_km is not None
and distance.exact_distance_km > distance_limit
and job.workplace_type != "remote"
):
reasons.append(
f"Afstand {distance.exact_distance_km:.0f} km boven maximum "
f"{profile.max_distance_km} km"
)
max_commute_minutes = profile.hard_rules.get("max_commute_minutes")
try:
max_commute_limit = int(max_commute_minutes)
except (TypeError, ValueError):
max_commute_limit = 0
if (
distance.commute_minutes is not None
and max_commute_limit > 0
and distance.commute_minutes > max_commute_limit
):
reasons.append(
f"Geschatte reistijd {distance.commute_minutes} minuten boven limiet van "
f"{max_commute_limit}"
)
excluded_skills = {
normalize_token(v)
for v in (profile.excluded_skills + list(profile.hard_rules.get("excluded_skills", [])))
}
explicit_job_skills = {normalize_token(v) for v in job.skills_required}
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
if conflicts:
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
it_relevance = assess_it_relevance(job.original_title, job.description_text)
if profile_requires_it_focus(profile) and not it_relevance.relevant:
reasons.append(it_relevance.reason)
return reasons, distance.distance_confidence
def _cap_ai_weight(profile: SearchProfile) -> float:
try:
raw_weight = float(profile.weights.get("ai", 0) or 0)
except (TypeError, ValueError):
return 0.0
if raw_weight <= 0:
return 0.0
return min(raw_weight, AI_MAX_WEIGHT)
def _ai_feature_score(features: dict[str, Any]) -> float:
support_ratio = float(features.get("support_ratio") or 0.0)
consultancy_ratio = float(features.get("consultancy_ratio") or 0.0)
travel_ratio = float(features.get("travel_ratio") or 0.0)
score = (
0.5 * (1.0 - support_ratio) + 0.25 * (1.0 - consultancy_ratio) + 0.15 * (1.0 - travel_ratio)
)
return max(0.0, min(1.0, score))
def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysis, float, float]:
if not profile.ai_scoring_enabled:
return (
AiAnalysis(
features={},
summary_nl="",
warnings=["AI-analyse is uitgeschakeld voor dit profiel."],
model="",
status=AiAnalysisCache.Status.DISABLED,
error_category="profile_disabled",
),
0.0,
0.0,
)
analysis = analyze_job_text(
job.original_title,
job.description_text,
content_hash=job.content_hash or "",
)
if analysis.status != AiAnalysisCache.Status.OK:
return analysis, 0.0, 0.0
ai_weight = _cap_ai_weight(profile)
if ai_weight <= 0:
return analysis, 0.0, 0.0
return analysis, _ai_feature_score(analysis.features), ai_weight
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
distance = _distance(job, profile)
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
it_relevance = assess_it_relevance(job.original_title, job.description_text)
title_fit = _title_fit(job, profile)
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
features = job.analysis_features or {}
support_ratio = float(features.get("support_ratio") or 0.0)
content_fit = max(0.0, min(1.0, 0.75 * title_fit + 0.25 * (1.0 - support_ratio)))
if distance.exact_distance_km is None:
if distance.has_distance_data:
location_fit = 0.60
elif job.workplace_type == "remote":
location_fit = 1.0
else:
location_fit = 0.60
elif job.workplace_type == "remote":
location_fit = 1.0
else:
location_fit = max(0.0, 1.0 - distance.exact_distance_km / max(1, profile.max_distance_km))
if profile.preferred_regions and normalize_token(job.region) in {
normalize_token(v) for v in profile.preferred_regions
}:
location_fit = min(1.0, location_fit + 0.15)
if profile.allowed_employment_types and job.employment_types:
conditions_fit = (
1.0 if set(profile.allowed_employment_types).intersection(job.employment_types) else 0.0
)
else:
conditions_fit = 0.65
employer_fit = 1.0 if job.direct_employer and not job.recruiter else 0.45
if profile.preferred_workplace:
preference_fit = 1.0 if job.workplace_type in profile.preferred_workplace else 0.45
else:
preference_fit = 0.65
if float(features.get("public_sector_signal") or 0) > 0:
preference_fit = min(
1.0, preference_fit + 0.1 * float(profile.soft_preferences.get("public_sector", 0))
)
raw_components = {
"content": content_fit,
"skills": skill_fit,
"location": location_fit,
"conditions": conditions_fit,
"employer": employer_fit,
"preferences": preference_fit,
}
ai_analysis, ai_component, ai_weight = _analyze_with_ai(job, profile)
if ai_analysis.status == AiAnalysisCache.Status.OK and ai_component > 0 and ai_weight > 0:
raw_components["ai"] = ai_component
weights = {key: float(profile.weights.get(key, 0)) for key in raw_components}
if "ai" in raw_components and "ai" in weights:
weights["ai"] = ai_weight
total_weight = sum(weights.values()) or 1.0
components = {
key: round(raw_components[key] * weights[key] / total_weight * 100, 2)
for key in raw_components
}
score = round(sum(components.values()), 2)
completeness = (
sum(
bool(value)
for value in [
job.original_title,
job.employer,
job.description_text,
job.raw_location,
job.employment_types,
job.date_posted,
]
)
/ 6
)
confidence = round(min(1.0, 0.65 * float(job.extraction_confidence) + 0.35 * completeness), 3)
if distance_confidence is not None:
confidence = round(min(1.0, confidence * 0.96 + distance_confidence * 0.04), 3)
positives: list[str] = []
concerns: list[str] = []
if title_fit >= 0.75:
positives.append("Functietitel sluit goed aan op het zoekprofiel.")
if it_relevance.relevant:
positives.append("IT-focus bevestigd via de functietitel.")
if present_skills:
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
if job.direct_employer and not job.recruiter:
positives.append("Rechtstreekse werkgeversbron.")
if (
distance.exact_distance_km is not None
and distance.exact_distance_km <= profile.max_distance_km
):
positives.append(f"Binnen de ingestelde afstand ({distance.exact_distance_km:.0f} km).")
if (
distance.exact_distance_km is None
and distance.commute_minutes is not None
and job.workplace_type != "remote"
):
estimate_label = "geschatte" if distance.commute_estimate else "ingeschatte"
concerns.append(
f"Schatting: {estimate_label} reistijd ca. {distance.commute_minutes} min "
"(conservatief)."
)
if support_ratio >= 0.5:
concerns.append("Vacature bevat sterke first-line/helpdesksignalen.")
if (
distance.exact_distance_km is None
and distance.has_distance_data
and job.workplace_type != "remote"
):
concerns.append("Afstand kon nog niet exact betrouwbaar worden berekend.")
if not job.compensation:
concerns.append("Salaris of barema is niet vermeld.")
if ai_analysis.status == AiAnalysisCache.Status.OK and ai_analysis.summary_nl:
positives.append(f"AI: {ai_analysis.summary_nl}")
elif ai_analysis.status != AiAnalysisCache.Status.DISABLED and ai_analysis.warnings:
concerns.append("AI-analyse: " + " ".join(ai_analysis.warnings))
if exclusions:
recommendation = ScoreRun.Recommendation.HIDDEN
elif score >= profile.top_match_threshold and confidence >= 0.65:
recommendation = ScoreRun.Recommendation.STRONG
elif score >= profile.recommendation_threshold:
recommendation = ScoreRun.Recommendation.POSSIBLE
else:
recommendation = ScoreRun.Recommendation.WEAK
return ScoreResult(
score=score,
confidence=confidence,
recommendation=recommendation,
components=components,
positives=positives,
concerns=concerns,
hard_exclusions=exclusions,
evidence={
"it_relevance": {
"relevant": it_relevance.relevant,
"signals": list(it_relevance.signals),
"reason": it_relevance.reason,
},
"skills": {
"matched": present_skills,
"configured_count": len(present_skills) + len(missing_skills),
"match_target": min(
SKILL_MATCH_TARGET,
len(present_skills) + len(missing_skills),
),
"not_observed": missing_skills,
},
"distance_km": distance.exact_distance_km,
"distance_has_data": distance.has_distance_data,
"distance_exact": distance.exact_distance_km is not None,
"distance_confidence": distance.distance_confidence,
"commute_minutes": distance.commute_minutes,
"commute_is_estimate": distance.commute_estimate,
"commute_source": distance.commute_source,
"commute_source_version": distance.commute_source_version,
"commute_confidence": distance.commute_confidence,
"distance_raw_used": distance.has_distance_data and distance.exact_distance_km is None,
"ai": {
"status": ai_analysis.status,
"error_category": ai_analysis.error_category,
"model": ai_analysis.model,
"prompt_version": ai_analysis.prompt_version,
"schema_version": ai_analysis.schema_version,
"cached": ai_analysis.cached,
"summary_nl": ai_analysis.summary_nl,
"warnings": ai_analysis.warnings,
"features": ai_analysis.features,
"weight_requested": float(profile.weights.get("ai", 0) or 0),
"weight_applied": ai_weight
if ai_analysis.status == AiAnalysisCache.Status.OK
else 0.0,
},
},
model_version=ai_analysis.model,
prompt_version=ai_analysis.prompt_version,
)
def _iter_active_profiles(profile_id: int | None):
profiles = SearchProfile.objects.filter(is_active=True)
if profile_id:
profiles = profiles.filter(pk=profile_id)
return profiles
def rescore_jobs_with_profiles(jobs: Iterable[JobPosting], *, profile_id: int | None = None) -> int:
count = 0
for profile in _iter_active_profiles(profile_id):
for job in jobs:
score_and_save(job, profile)
count += 1
return count
@transaction.atomic
def score_and_save(job: JobPosting, profile: SearchProfile) -> ScoreRun:
result = calculate_score(job, profile)
return ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=result.score,
confidence=result.confidence,
recommendation=result.recommendation,
components=result.components,
positives=result.positives,
concerns=result.concerns,
hard_exclusions=result.hard_exclusions,
evidence=result.evidence,
model_version=result.model_version,
prompt_version=result.prompt_version,
)