This commit is contained in:
+2
-2
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user