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