Fix release blockers and deployment build
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-21 21:22:29 +02:00
parent b8091e59bd
commit a4eced8be5
64 changed files with 1011 additions and 675 deletions
+2 -2
View File
@@ -1,15 +1,15 @@
from django.contrib import admin
from .models import (
AiAnalysisCache,
Application,
ApplicationTimelineEvent,
Employer,
Feedback,
FieldProvenance,
AiAnalysisCache,
GeocodeLocationLookup,
JobPosting,
JobSourceAlias,
GeocodeLocationLookup,
JobVersion,
ScoreRun,
)
@@ -2,13 +2,13 @@ from __future__ import annotations
from pathlib import Path
from urllib.parse import urlparse
from django.db.models import Q
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Q
from apps.jobs.models import JobPosting
from apps.jobs.services.scoring import rescore_jobs_with_profiles
from apps.jobs.services.geocoding import import_csv_geodata, validate_csv_geodata
from apps.jobs.services.scoring import rescore_jobs_with_profiles
class Command(BaseCommand):
+16 -9
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from decimal import Decimal
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import models
@@ -193,8 +193,12 @@ class GeocodeLocationLookup(TimeStampedModel):
class Meta:
ordering = ["query_kind", "query_value", "municipality"]
indexes = [
models.Index(fields=["query_kind", "query_value"]),
models.Index(fields=["source_name", "source_version"]),
models.Index(
fields=["query_kind", "query_value"], name="geocode_lookup_kind_query_idx"
),
models.Index(
fields=["source_name", "source_version"], name="geocode_lookup_source_idx"
),
]
constraints = [
models.UniqueConstraint(
@@ -272,7 +276,7 @@ class AiAnalysisCache(TimeStampedModel):
status = models.CharField(max_length=16, choices=Status.choices)
error_category = models.CharField(max_length=120, blank=True)
summary_nl = models.TextField(blank=True)
features = models.JSONField(default=dict, blank=True)
features = models.JSONField(default=dict)
warnings = models.JSONField(default=list, blank=True)
class Meta:
@@ -284,8 +288,11 @@ class AiAnalysisCache(TimeStampedModel):
)
]
indexes = [
models.Index(fields=["content_hash"]),
models.Index(fields=["model_name", "prompt_version", "schema_version"]),
models.Index(fields=["content_hash"], name="jobs_aianal_cache_content_idx"),
models.Index(
fields=["model_name", "prompt_version", "schema_version"],
name="jobs_aianal_cache_model_idx",
),
]
def __str__(self) -> str:
@@ -364,9 +371,9 @@ class ApplicationTimelineEvent(TimeStampedModel):
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["application", "created_at"]),
models.Index(fields=["user", "created_at"]),
models.Index(fields=["application", "created_at"], name="jobs_app_timeline_app_idx"),
models.Index(fields=["user", "created_at"], name="jobs_app_timeline_user_idx"),
]
def __str__(self) -> str:
return f"{self.application}{self.event_type}"
return f"{self.application}{self.event_type}"
+15 -5
View File
@@ -57,7 +57,13 @@ def _schema() -> dict[str, Any]:
"seniority": {"type": "string"},
"evidence": {"type": "array", "items": {"type": "string"}},
},
"required": ["support_ratio", "consultancy_ratio", "travel_ratio", "seniority", "evidence"],
"required": [
"support_ratio",
"consultancy_ratio",
"travel_ratio",
"seniority",
"evidence",
],
},
"warnings": {"type": "array", "items": {"type": "string"}},
},
@@ -73,8 +79,8 @@ def _normalize_text(title: str, description: str) -> str:
def _coerce_ratio(name: str, value: Any) -> float:
try:
ratio = float(value)
except (TypeError, ValueError):
raise ValueError(f"ratio:{name}")
except (TypeError, ValueError) as exc:
raise ValueError(f"ratio:{name}") from exc
if not (MIN_FEATURE_VALUE <= ratio <= MAX_FEATURE_VALUE):
raise ValueError(f"ratio:{name}")
return round(ratio, 6)
@@ -108,7 +114,9 @@ def _parse_and_validate_payload(payload: Any, *, title: str, description: str) -
evidence_values = features.get("evidence")
if not isinstance(evidence_values, list):
raise ValueError("evidence")
normalized_evidence = [normalize_token(item) for item in evidence_values if isinstance(item, str)]
normalized_evidence = [
normalize_token(item) for item in evidence_values if isinstance(item, str)
]
normalized_evidence = [item for item in normalized_evidence if item]
if not normalized_evidence:
raise ValueError("evidence")
@@ -289,7 +297,9 @@ def analyze_job_text(
)
try:
return _cache_get(content_hash=content_hash, model=model_name, prompt_version=prompt_version)
return _cache_get(
content_hash=content_hash, model=model_name, prompt_version=prompt_version
)
except AiUnavailable:
pass
+45 -21
View File
@@ -26,7 +26,7 @@ SNAPSHOT_VERSION = "1.0.0"
def _coerce_json_value(value: Any) -> Any:
if isinstance(value, (date,)):
if isinstance(value, date):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
@@ -85,7 +85,17 @@ def _latest_score_snapshot(job: JobPosting, user) -> dict[str, Any] | None:
run = (
ScoreRun.objects.filter(job=job, profile=profile)
.order_by("-created_at")
.only("score", "recommendation", "confidence", "components", "positives", "concerns", "hard_exclusions", "evidence", "profile_version")
.only(
"score",
"recommendation",
"confidence",
"components",
"positives",
"concerns",
"hard_exclusions",
"evidence",
"profile_version",
)
.first()
)
if not run:
@@ -121,7 +131,9 @@ def _build_application_snapshot_payload(application: Application) -> dict[str, A
}
def _record_timeline_event(*, application: Application, user, event_type: str, metadata: dict[str, Any] | None = None) -> ApplicationTimelineEvent:
def _record_timeline_event(
*, application: Application, user, event_type: str, metadata: dict[str, Any] | None = None
) -> ApplicationTimelineEvent:
payload: dict[str, Any] = {}
if metadata:
payload.update({key: value for key, value in metadata.items() if value is not None})
@@ -243,8 +255,11 @@ def track_application_changes(
)
events += 1
if (_normalize_str(previous.get("contact_name")) != _normalize_str(current.get("contact_name"))) or (
_normalize_str(previous.get("contact_email")) != _normalize_str(current.get("contact_email"))
if (
_normalize_str(previous.get("contact_name")) != _normalize_str(current.get("contact_name"))
) or (
_normalize_str(previous.get("contact_email"))
!= _normalize_str(current.get("contact_email"))
):
_record_timeline_event(
application=application,
@@ -266,16 +281,14 @@ def build_print_html(application: Application) -> str:
safe_rows = []
for row in timeline_rows:
safe_rows.append(
(
f"<tr>"
f"<td>{escape(row['timestamp'])}</td>"
f"<td>{escape(row['type'])}</td>"
f"<td>{escape(row['actor'])}</td>"
f"<td>{escape(row['from'])}</td>"
f"<td>{escape(row['to'])}</td>"
f"<td>{escape(row['note'])}</td>"
"</tr>"
)
f"<tr>"
f"<td>{escape(row['timestamp'])}</td>"
f"<td>{escape(row['type'])}</td>"
f"<td>{escape(row['actor'])}</td>"
f"<td>{escape(row['from'])}</td>"
f"<td>{escape(row['to'])}</td>"
f"<td>{escape(row['note'])}</td>"
"</tr>"
)
source_rows = []
@@ -284,20 +297,26 @@ def build_print_html(application: Application) -> str:
source_url = escape(str(source.get("url", "")))
source_rows.append(f"<li>{source_name}: {source_url}</li>")
source_section = "".join(source_rows) if source_rows else "<li>Niet beschikbaar</li>"
frozen_at = (
application.snapshot.get("frozen_at", "") if isinstance(application.snapshot, dict) else ""
)
snapshot_json = json.dumps(snapshot, indent=2, ensure_ascii=False, sort_keys=True)
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>Sollicitatiedossier</title><style>body{font-family:Arial,sans-serif;margin:24px}"
"table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px;text-align:left}"
"table{border-collapse:collapse;width:100%}"
"th,td{border:1px solid #ddd;padding:8px;text-align:left}"
"th{background:#f2f2f2} </style></head><body>"
f"<h1>{escape(job.original_title)}</h1>"
f"<p>Vacature: {escape(job.canonical_url)}</p>"
f"<p>Status: {escape(application.get_status_display())}</p>"
f"<p>Geslaagd op: {escape(str(application.snapshot.get('frozen_at') if isinstance(application.snapshot, dict) else ''))}</p>"
f"<p>Geslaagd op: {escape(str(frozen_at))}</p>"
f"<h2>Bronnen</h2><ul>{source_section}</ul>"
f"<h2>Timeline</h2><table><thead><tr><th>Tijd</th><th>Type</th><th>Actor</th><th>Van</th><th>Naar</th><th>Notitie</th></tr></thead><tbody>"
"<h2>Timeline</h2><table><thead><tr><th>Tijd</th><th>Type</th>"
"<th>Actor</th><th>Van</th><th>Naar</th><th>Notitie</th></tr></thead><tbody>"
f"{''.join(safe_rows)}</tbody></table>"
f"<h2>Snapshot</h2><pre>{escape(json.dumps(snapshot, indent=2, ensure_ascii=False, sort_keys=True))}</pre>"
f"<h2>Snapshot</h2><pre>{escape(snapshot_json)}</pre>"
"</body></html>"
)
@@ -315,7 +334,9 @@ def build_application_export(application: Application) -> tuple[bytes, str]:
"snapshot_version": SNAPSHOT_VERSION,
"exported_at": timezone.now().isoformat(),
"applied_at": application.applied_at.isoformat() if application.applied_at else None,
"follow_up_date": application.follow_up_date.isoformat() if application.follow_up_date else None,
"follow_up_date": application.follow_up_date.isoformat()
if application.follow_up_date
else None,
},
"snapshot": _coerce_json_value(application.snapshot),
"timeline": _timeline_dict_rows(application),
@@ -332,7 +353,10 @@ def build_application_export(application: Application) -> tuple[bytes, str]:
content_buffer = io.BytesIO()
with zipfile.ZipFile(content_buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("application.json", json.dumps(_coerce_json_value(payload), indent=2, ensure_ascii=False, sort_keys=True))
zf.writestr(
"application.json",
json.dumps(_coerce_json_value(payload), indent=2, ensure_ascii=False, sort_keys=True),
)
zf.writestr("timeline.csv", timeline_buffer.getvalue())
zf.writestr("application_print.html", build_print_html(application))
+1 -1
View File
@@ -3,10 +3,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from apps.sources.models import Source
from django.db.models import Q
from apps.jobs.models import JobPosting, JobSourceAlias
from apps.sources.models import Source
from .employer_resolution import EmployerResolutionDecision, resolve_direct_employer_match
from .normalization import CanonicalJobDraft, normalize_token
+1 -2
View File
@@ -27,8 +27,7 @@ class CommuteEstimator(Protocol):
name: str
version: str
def estimate(self, distance_km: float) -> CommuteEstimate:
...
def estimate(self, distance_km: float) -> CommuteEstimate: ...
@dataclass(frozen=True)
+40 -16
View File
@@ -9,7 +9,6 @@ from apps.sources.models import Source
from .normalization import CanonicalJobDraft, normalize_token
MERGE_THRESHOLD = 0.96
TITLE_MIN_THRESHOLD = 0.92
CONFLICT_TITLE_THRESHOLD = 0.70
@@ -46,11 +45,13 @@ def _weighted_similarity(
employer_domain_match: bool,
canonical_host_match: bool,
) -> float:
weights: list[tuple[float, float]] = [(title_score, 0.68), (employer_domain_match and 1.0 or 0.0, 0.12)]
weights: list[tuple[float, float]] = [(title_score, 0.68)]
if location_score is not None:
weights.append((location_score, 0.12))
if employer_score is not None:
weights.append((employer_score, 0.06))
if employer_domain_match:
weights.append((1.0, 0.12))
if canonical_host_match:
weights.append((1.0, 0.05))
total_weight = sum(weight for _, weight in weights)
@@ -62,7 +63,10 @@ def _weighted_similarity(
def _domain_match(value: str, candidate: str) -> bool:
if not value or not candidate:
return False
return _normalize_similarity(value).strip(".").lower() == _normalize_similarity(candidate).strip(".").lower()
return (
_normalize_similarity(value).strip(".").lower()
== _normalize_similarity(candidate).strip(".").lower()
)
def _host(value: str) -> str:
@@ -73,12 +77,18 @@ def resolve_direct_employer_match(
draft: CanonicalJobDraft, *, source: Source | None
) -> EmployerResolutionDecision:
if source is None or source.source_type == Source.Type.EMPLOYER or not draft.normalized_title:
return EmployerResolutionDecision(None, "no_direct_resolution", 0.0, canonical_url=None, conflict=False)
return EmployerResolutionDecision(
None, "no_direct_resolution", 0.0, canonical_url=None, conflict=False
)
candidates = JobPosting.objects.filter(
status__in=[JobPosting.Status.ACTIVE, JobPosting.Status.NEW],
direct_employer=True,
).select_related("employer").order_by("id")
candidates = (
JobPosting.objects.filter(
status__in=[JobPosting.Status.ACTIVE, JobPosting.Status.NEW],
direct_employer=True,
)
.select_related("employer")
.order_by("id")
)
best: JobPosting | None = None
best_score = 0.0
@@ -92,7 +102,13 @@ def resolve_direct_employer_match(
continue
location_score: float | None = None
if draft.location_text and candidate.raw_location:
if (
draft.municipality
and candidate.municipality
and normalize_token(draft.municipality) == normalize_token(candidate.municipality)
):
location_score = 1.0
elif draft.location_text and candidate.raw_location:
location_score = _token_similarity(draft.location_text, candidate.raw_location)
employer_score: float | None = None
@@ -112,15 +128,23 @@ def resolve_direct_employer_match(
canonical_host_match=canonical_host_match,
)
has_conflict = False
if draft.location_text and candidate.raw_location:
if location_score is not None and location_score < CONFLICT_LOCATION_THRESHOLD:
has_conflict = True
if draft.employer_name and candidate.employer_name and (
employer_score is not None and employer_score < CONFLICT_EMPLOYER_THRESHOLD
has_conflict = bool(
draft.location_text
and candidate.raw_location
and location_score is not None
and location_score < CONFLICT_LOCATION_THRESHOLD
)
if (
draft.employer_name
and candidate.employer_name
and (employer_score is not None and employer_score < CONFLICT_EMPLOYER_THRESHOLD)
):
has_conflict = True
if draft.employer_name and not candidate.employer_name and title_score < CONFLICT_TITLE_THRESHOLD:
if (
draft.employer_name
and not candidate.employer_name
and title_score < CONFLICT_TITLE_THRESHOLD
):
has_conflict = True
if score > best_score:
+20 -9
View File
@@ -1,18 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from django.db import transaction
from django.utils import timezone
from apps.jobs.models import Application, Feedback, JobPosting, ScoreRun
from apps.jobs.models import Feedback, JobPosting, ScoreRun
from apps.jobs.services.applications import apply_application_on_feedback
from apps.profiles.models import SearchProfile
from apps.profiles.services import apply_feedback_delta
LEARNING_MIN_SAMPLES = 2
LEARNING_DELTA_BY_ACTION = {
Feedback.Action.INTERESTING: 1.0,
@@ -47,15 +44,21 @@ def _latest_score_run(profile: SearchProfile, job: JobPosting) -> ScoreRun | Non
def _best_signal_feature(score_run: ScoreRun | None) -> str:
if score_run is None:
return "content"
components = {
key: value for key, value in (score_run.components or {}).items() if key in LEARNING_FEATURES
key: value
for key, value in (score_run.components or {}).items()
if key in LEARNING_FEATURES
}
if not components:
return "content"
return max(components, key=components.get)
def _classify_hide_signal(profile: SearchProfile, score_run: ScoreRun | None, reason: str) -> _LearningSignal:
def _classify_hide_signal(
profile: SearchProfile, score_run: ScoreRun | None, reason: str
) -> _LearningSignal:
normalized = _normalize_reason_text(reason)
if not normalized:
return _LearningSignal(
@@ -71,7 +74,9 @@ def _classify_hide_signal(profile: SearchProfile, score_run: ScoreRun | None, re
reason_code="non_learning_title",
learnable=False,
)
if any(token in normalized for token in ("afstand", "afstands", "km", "locatie", "verplaatsing")):
if any(
token in normalized for token in ("afstand", "afstands", "km", "locatie", "verplaatsing")
):
return _LearningSignal(
feature="",
delta=0.0,
@@ -128,7 +133,9 @@ def _learning_signal_count(profile: SearchProfile, feature: str) -> int:
return count
def _apply_learning_metadata(feedback: Feedback, signal: _LearningSignal, *, samples: int, applied: bool) -> None:
def _apply_learning_metadata(
feedback: Feedback, signal: _LearningSignal, *, samples: int, applied: bool
) -> None:
metadata: dict[str, Any] = dict(feedback.metadata or {})
metadata["learning"] = {
"status": "applied" if applied else "queued",
@@ -187,7 +194,11 @@ def record_feedback(
action=action,
reason=reason[:200],
)
signal = _classify_learning_signal(profile=profile, job=job, action=action, reason=reason) if profile else None
signal = (
_classify_learning_signal(profile=profile, job=job, action=action, reason=reason)
if profile
else None
)
if signal:
_evaluate_learning(profile=profile, feedback=feedback, signal=signal)
if action == Feedback.Action.APPLIED:
+17 -16
View File
@@ -20,8 +20,7 @@ class GeocodeProvider(Protocol):
confidence: float
metadata: dict[str, Any]
def resolve(self, query: str) -> list["LocationMatch"]:
...
def resolve(self, query: str) -> list[LocationMatch]: ...
@dataclass(frozen=True)
@@ -63,10 +62,7 @@ def parse_belgian_location_query(raw: str) -> tuple[str | None, str | None]:
for chunk in re.findall(r"\b\d{4}\b", normalized):
postal = chunk
break
if "," in normalized:
municipality_part = normalized.split(",", 1)[0]
else:
municipality_part = normalized
municipality_part = normalized.split(",", 1)[0] if "," in normalized else normalized
municipality_part = re.sub(r"\b\d{4}\b", " ", municipality_part)
municipality_part = re.sub(r"[^a-z0-9 ]", " ", municipality_part)
municipality = " ".join(municipality_part.split())
@@ -100,11 +96,12 @@ def _read_rows(path: str | Path) -> list[_ParsedRow]:
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
headers = set((reader.fieldnames or []))
headers = set(reader.fieldnames or [])
required = {"postal_code", "municipality", "region", "latitude", "longitude"}
if not required.issubset(headers):
raise CommandError(
"Verplichte kolommen ontbreken: postal_code, municipality, region, latitude, longitude"
"Verplichte kolommen ontbreken: postal_code, municipality, region, "
"latitude, longitude"
)
for row_number, raw_row in enumerate(reader, start=2):
@@ -116,7 +113,9 @@ def _read_rows(path: str | Path) -> list[_ParsedRow]:
if not postal_code:
raise CommandError(f"regel {row_number}: postal_code mag niet leeg zijn")
if len(postal_code) != 4 or not postal_code.isdigit():
raise CommandError(f"regel {row_number}: ongeldige Belgische postcode {postal_code}")
raise CommandError(
f"regel {row_number}: ongeldige Belgische postcode {postal_code}"
)
if not municipality:
raise CommandError(f"regel {row_number}: municipality mag niet leeg zijn")
@@ -136,7 +135,8 @@ def _read_rows(path: str | Path) -> list[_ParsedRow]:
row_key = (postal_code, normalized_municipality)
if row_key in seen:
raise CommandError(
f"regel {row_number}: dubbel record in bestand voor {postal_code} {municipality}"
f"regel {row_number}: dubbel record in bestand voor "
f"{postal_code} {municipality}"
)
seen.add(row_key)
@@ -171,7 +171,9 @@ class CsvGeocodeProvider:
self.metadata: dict[str, Any] = metadata or {}
@staticmethod
def _load_candidates(query_value: str, query_kind: str, *, source_name: str, source_version: str):
def _load_candidates(
query_value: str, query_kind: str, *, source_name: str, source_version: str
):
return GeocodeLocationLookup.objects.filter(
source_name=source_name,
source_version=source_version,
@@ -307,7 +309,9 @@ def import_csv_geodata(
rows = _read_rows(path)
if len(rows) > 50000:
raise CommandError("Importbestand bevat meer dan 50.000 records; import in delen aanbevolen.")
raise CommandError(
"Importbestand bevat meer dan 50.000 records; import in delen aanbevolen."
)
existing_rows = GeocodeLocationLookup.objects.filter(
source_name=source_name,
@@ -347,10 +351,7 @@ def import_csv_geodata(
row.postal_code,
row.municipality,
)
if (not replace) and (
municipal_key in existing_keys
or postal_key in existing_keys
):
if (not replace) and (municipal_key in existing_keys or postal_key in existing_keys):
raise CommandError(
"Import zou bestaande lookuprecords overschrijven zonder --replace."
)
+3 -1
View File
@@ -271,7 +271,9 @@ def persist_draft(
else:
alias.last_seen = timezone.now()
alias.raw_document = document
alias.payload = _alias_payload(alias.payload, decision, fallback_canonical_url=draft.canonical_url)
alias.payload = _alias_payload(
alias.payload, decision, fallback_canonical_url=draft.canonical_url
)
if direct:
alias.is_canonical = True
alias.save(
+35 -15
View File
@@ -1,15 +1,16 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Any, Iterable
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.jobs.models import JobPosting, ScoreRun
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
@@ -106,7 +107,9 @@ def _profile_reference(profile: SearchProfile) -> _GeoReference | 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)
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]]:
@@ -191,9 +194,15 @@ def _hard_exclusions(
):
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")
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:
@@ -206,7 +215,8 @@ def _hard_exclusions(
and distance.commute_minutes > max_commute_limit
):
reasons.append(
f"Geschatte reistijd {distance.commute_minutes} minuten boven limiet van {max_commute_limit}"
f"Geschatte reistijd {distance.commute_minutes} minuten boven limiet van "
f"{max_commute_limit}"
)
excluded_skills = {
@@ -244,7 +254,9 @@ def _ai_feature_score(features: dict[str, Any]) -> float:
"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)
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))
@@ -373,7 +385,10 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
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:
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
@@ -382,13 +397,18 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
):
estimate_label = "geschatte" if distance.commute_estimate else "ingeschatte"
concerns.append(
f"Schatting: {estimate_label} reistijd ca. {distance.commute_minutes} min (conservatief)."
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":
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.")
@@ -436,7 +456,9 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
"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,
"weight_applied": ai_weight
if ai_analysis.status == AiAnalysisCache.Status.OK
else 0.0,
},
},
model_version=ai_analysis.model,
@@ -451,9 +473,7 @@ def _iter_active_profiles(profile_id: int | None):
return profiles
def rescore_jobs_with_profiles(
jobs: Iterable[JobPosting], *, profile_id: int | None = None
) -> int:
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:
+2 -2
View File
@@ -3,11 +3,11 @@ from django.urls import path
from .views import (
ApplicationListView,
ApplicationUpdateView,
JobDetailView,
JobListView,
application_delete,
application_export,
application_print,
JobDetailView,
JobListView,
job_feedback,
)
+8 -7
View File
@@ -4,7 +4,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
from django.http import HttpResponse
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views.decorators.http import require_POST
@@ -118,12 +118,11 @@ class ApplicationUpdateView(LoginRequiredMixin, UpdateView):
return Application.objects.filter(user=self.request.user).select_related("job")
def form_valid(self, form):
previous = {
"status": self.object.status,
"notes": self.object.notes,
"contact_name": self.object.contact_name,
"contact_email": self.object.contact_email,
}
previous = (
Application.objects.filter(pk=self.object.pk)
.values("status", "notes", "contact_name", "contact_email")
.get()
)
response = super().form_valid(form)
track_application_changes(
application=self.object,
@@ -165,6 +164,8 @@ def application_print(request, pk: int):
@login_required
@require_POST
def application_delete(request, pk: int):
if Application.objects.filter(pk=pk).exclude(user=request.user).exists():
raise Http404
deleted = delete_application_dossier(application_id=pk, user=request.user)
if deleted:
messages.success(request, "Sollicitatiedossier verwijderd.")