483 lines
18 KiB
Python
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,
|
|
)
|