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