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

483 lines
18 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Any, Iterable
from django.db import transaction
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.jobs.models import JobPosting, ScoreRun
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
@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
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 _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 and skill in text)
missing = sorted(desired - set(present))
return len(present) / len(desired), 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 normalize_token(term) and normalize_token(term) in title:
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:
if job.workplace_type != "remote":
reasons.append(f"Afstand {distance.exact_distance_km:.0f} km boven maximum {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 {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))
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)
seniority = str(features.get("seniority") or "").strip().lower()
seniority_boost = {
"junior": 0.0,
"medior": 0.08,
"senior": 0.12,
"lead": 0.14,
"expert": 0.16,
"unknown": 0.03,
"": 0.03,
}.get(seniority, 0.05)
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 + seniority_boost))
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)
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
experience_years = features.get("experience_years_max")
seniority_fit = (
0.75
if experience_years is None
else max(0.25, 1.0 - max(0, int(experience_years) - 5) * 0.1)
)
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,
"seniority": seniority_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 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 missing_skills:
concerns.append("Niet duidelijk teruggevonden: " + ", ".join(missing_skills[:6]))
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={
"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,
)