feat: personalize scoring and add skills radar

This commit is contained in:
Jens
2026-07-22 19:33:29 +02:00
parent 029df89265
commit a30ecbc553
33 changed files with 1529 additions and 114 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ from apps.profiles.models import SearchProfile
def navigation_context(request: HttpRequest) -> dict[str, Any]:
context = {
"app_name": "VacatureRadar",
"app_version": "0.2.11",
"app_version": "0.2.13",
}
if request.user.is_authenticated:
context["navigation_profile"] = (
-1
View File
@@ -22,7 +22,6 @@ LEARNING_FEATURES = {
"location",
"conditions",
"employer",
"seniority",
"preferences",
}
+8
View File
@@ -49,13 +49,21 @@ EMPLOYMENT_MAP = {
"vast": "permanent",
}
TITLE_FAMILIES = {
"it field engineer": "field-support",
"field service engineer": "field-support",
"it engineer": "infrastructure",
"system engineer": "infrastructure",
"system network engineer": "infrastructure",
"system administrator": "infrastructure",
"systeembeheerder": "infrastructure",
"infrastructure engineer": "infrastructure",
"network engineer": "network",
"network administrator": "network",
"netwerkbeheerder": "network",
"workplace engineer": "workplace",
"support engineer": "support",
"it technician": "support",
"pc technician": "support",
"helpdesk": "support",
"developer": "software-development",
"data engineer": "data",
+82 -2
View File
@@ -29,10 +29,13 @@ IT_TITLE_TERMS = (
"bi analyst",
"system engineer",
"systems engineer",
"system network engineer",
"system administrator",
"systeembeheer",
"infrastructure engineer",
"infrastructuur engineer",
"network engineer",
"network administrator",
"network architect",
"netwerkbeheer",
"netwerk engineer",
@@ -44,6 +47,10 @@ IT_TITLE_TERMS = (
"information security",
"informatiebeveiliging",
"workplace engineer",
"endpoint engineer",
"microsoft 365 engineer",
"m365 engineer",
"intune engineer",
"modern workplace",
"digital workplace",
"service desk",
@@ -51,6 +58,8 @@ IT_TITLE_TERMS = (
"helpdesk",
"support engineer",
"support specialist",
"it technician",
"pc technician",
"application lead",
"application engineer",
"application manager",
@@ -106,6 +115,48 @@ IT_TITLE_TERMS = (
"test automation",
)
# These engineering titles occur both in IT and in unrelated technical sectors. They are
# accepted only when the title supplies the role context and the description independently
# supplies a strong IT signal. A description signal by itself remains insufficient.
CONTEXTUAL_IT_TITLE_TERMS = (
"field engineer",
"field service engineer",
"implementation engineer",
"implementation consultant",
"service engineer",
)
IT_DESCRIPTION_TERMS = (
"microsoft 365",
"m365",
"windows server",
"active directory",
"entra id",
"intune",
"autopilot",
"exchange online",
"sharepoint",
"vmware",
"proxmox",
"hyper-v",
"networking",
"netwerkbeheer",
"tcp/ip",
"vlan",
"vpn",
"dhcp",
"dns",
"firewall",
"switches",
"routers",
"workstations",
"desktops",
"laptops",
"voip",
"3cx",
"powershell",
)
# These phrases can contain IT vocabulary while describing commercial, recruitment,
# or educational-design work. They therefore override positive title signals.
NON_IT_TITLE_TERMS = (
@@ -126,8 +177,16 @@ def _term_pattern(term: str) -> str:
IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in IT_TITLE_TERMS) + ")"
NON_IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in NON_IT_TITLE_TERMS) + ")"
CONTEXTUAL_IT_TITLE_PATTERN = (
"(?:" + "|".join(_term_pattern(term) for term in CONTEXTUAL_IT_TITLE_TERMS) + ")"
)
IT_DESCRIPTION_PATTERN = (
"(?:" + "|".join(_term_pattern(term) for term in IT_DESCRIPTION_TERMS) + ")"
)
_IT_TITLE_RE = re.compile(IT_TITLE_PATTERN, re.IGNORECASE)
_NON_IT_TITLE_RE = re.compile(NON_IT_TITLE_PATTERN, re.IGNORECASE)
_CONTEXTUAL_IT_TITLE_RE = re.compile(CONTEXTUAL_IT_TITLE_PATTERN, re.IGNORECASE)
_IT_DESCRIPTION_RE = re.compile(IT_DESCRIPTION_PATTERN, re.IGNORECASE)
@dataclass(frozen=True)
@@ -137,7 +196,7 @@ class ItRelevanceAssessment:
reason: str
def assess_it_relevance(title: str) -> ItRelevanceAssessment:
def assess_it_relevance(title: str, description: str = "") -> ItRelevanceAssessment:
normalized_title = normalize_token(title)
blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
if blocked_signal:
@@ -157,6 +216,23 @@ def assess_it_relevance(title: str) -> ItRelevanceAssessment:
signals=signals[:4],
reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
)
contextual_title = _CONTEXTUAL_IT_TITLE_RE.search(normalized_title)
if contextual_title:
description_signal = _IT_DESCRIPTION_RE.search(normalize_token(description))
if description_signal:
return ItRelevanceAssessment(
relevant=True,
signals=(contextual_title.group(0), description_signal.group(0)),
reason=(
"IT-context bevestigd via ambigue functietitel en vacaturetekst: "
f"{contextual_title.group(0)}, {description_signal.group(0)}."
),
)
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason="Ambigue technische functietitel zonder aantoonbare IT-context.",
)
return ItRelevanceAssessment(
relevant=False,
signals=(),
@@ -175,6 +251,10 @@ def profile_requires_it_focus(profile: SearchProfile) -> bool:
def it_relevance_query(prefix: str = "") -> Q:
"""Return the database equivalent of the conservative title-led classifier."""
return Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN}) & ~Q(
clear_title = Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN})
contextual_title = Q(**{f"{prefix}original_title__iregex": CONTEXTUAL_IT_TITLE_PATTERN}) & Q(
**{f"{prefix}description_text__iregex": IT_DESCRIPTION_PATTERN}
)
return (clear_title | contextual_title) & ~Q(
**{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
)
+26 -25
View File
@@ -15,6 +15,7 @@ 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)
@@ -52,6 +53,7 @@ class DistanceAssessment:
EXACT_DISTANCE_CONF_THRESHOLD = 0.80
AI_MAX_WEIGHT = 20.0
SKILL_MATCH_TARGET = 4
def _similarity(left: str, right: str) -> float:
@@ -113,6 +115,14 @@ def _title_fit(job: JobPosting, profile: SearchProfile) -> float:
)
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:
@@ -120,9 +130,10 @@ def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str
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)
present = sorted(skill for skill in desired if _skill_is_present(skill, text))
missing = sorted(desired - set(present))
return len(present) / len(desired), present, missing
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:
@@ -173,7 +184,7 @@ def _hard_exclusions(
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:
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", []))
@@ -228,7 +239,7 @@ def _hard_exclusions(
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)
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
@@ -248,20 +259,10 @@ 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))
return max(0.0, min(1.0, score))
def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysis, float, float]:
@@ -296,7 +297,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
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)
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 {}
@@ -326,12 +327,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
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:
@@ -347,7 +342,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
"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)
@@ -409,8 +403,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
)
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
@@ -447,6 +439,15 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
"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,
+252
View File
@@ -0,0 +1,252 @@
from __future__ import annotations
from dataclasses import dataclass
from django.db.models import CharField, Exists, OuterRef, Q, QuerySet, Subquery
from django.utils.text import slugify
from apps.jobs.models import JobPosting, JobSourceAlias, ScoreRun
from apps.profiles.models import SearchProfile
from apps.profiles.taxonomy import SKILL_CHOICES
from apps.sources.models import Source
from .normalization import normalize_token
from .relevance import it_relevance_query
from .skill_terms import canonical_skill_key, skill_is_present
@dataclass(frozen=True)
class SkillDefinition:
key: str
label: str
category: str
category_key: str
@dataclass(frozen=True)
class SkillJobExample:
id: str
title: str
employer: str
@dataclass(frozen=True)
class SkillDemandItem:
key: str
label: str
category: str
category_key: str
vacancy_count: int
share_percent: int
explicit_count: int
inferred_count: int
covered: bool
learning_focus: str
search_term: str
examples: tuple[SkillJobExample, ...]
@dataclass(frozen=True)
class SkillDemandReport:
total_jobs: int
jobs_with_signals: int
signal_count: int
covered_count: int
gap_count: int
coverage_percent: int
items: tuple[SkillDemandItem, ...]
categories: tuple[tuple[str, str], ...]
selected_category: str
selected_coverage: str
def skill_catalog() -> tuple[SkillDefinition, ...]:
return tuple(
SkillDefinition(
key=normalize_token(value),
label=label,
category=category,
category_key=slugify(category),
)
for category, choices in SKILL_CHOICES
for value, label in choices
)
def relevant_jobs_for_profile(profile: SearchProfile) -> QuerySet[JobPosting]:
latest_score = ScoreRun.objects.filter(
job=OuterRef("pk"),
profile=profile,
profile_version=profile.version,
).order_by("-created_at")
source_aliases = JobSourceAlias.objects.filter(job=OuterRef("pk"))
active_source_aliases = source_aliases.filter(source__status=Source.Status.ACTIVE)
return (
JobPosting.objects.select_related("employer")
.filter(status=JobPosting.Status.ACTIVE)
.filter(it_relevance_query())
.annotate(
profile_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
has_source_alias=Exists(source_aliases),
has_active_source_alias=Exists(active_source_aliases),
)
.filter(
profile_recommendation__in=(
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
)
)
.filter(Q(has_source_alias=False) | Q(has_active_source_alias=True))
.order_by("-first_seen")
)
def _structured_skill_text(job: JobPosting) -> str:
return normalize_token(
" ".join(str(value) for value in [*job.skills_required, *job.skills_preferred])
)
def _inferred_skill_text(job: JobPosting) -> str:
requirements = " ".join(str(value) for value in job.requirements)
return normalize_token(f"{job.original_title} {requirements} {job.description_text}")
def _learning_focus(definition: SkillDefinition) -> str:
focuses = {
"Microsoft & endpoint": (
"Oefen een praktische beheer- of migratiecase en leg de relevante "
"Microsoft Learn-modules vast."
),
"Netwerk & security": (
"Bouw een kleine labcase rond configuratie, troubleshooting en beveiligde toegang."
),
"Back-up & monitoring": (
"Oefen detectie, herstel en rapportering met een reproduceerbare homelabcase."
),
"Virtualisatie & platform": (
"Maak een deployment- of platformlab en documenteer beschikbaarheid en herstel."
),
"IT-servicemanagement": (
"Koppel de methodiek aan een concrete incident-, problem- of changecase."
),
"Digitale werkplek & adoptie": (
"Werk een kleine governance- of adoptiecase uit met meetbare gebruikersimpact."
),
"Automation & scripting": (
"Automatiseer één herkenbare beheertaak en publiceer een veilig voorbeeldscript."
),
"VoIP & telefonie": "Simuleer configuratie en troubleshooting in een kleine telefoniecase.",
"Field service & uitvoering": (
"Documenteer een end-to-end interventie: diagnose, oplossing en overdracht."
),
}
return focuses.get(
definition.category,
"Maak een kleine praktijkcase en leg vast welke vacature-eis je ermee kunt aantonen.",
)
def _profile_skill_keys(profile: SearchProfile, catalog: tuple[SkillDefinition, ...]) -> set[str]:
catalog_keys = {definition.key for definition in catalog}
return {
canonical
for value in profile.desired_skills
if (canonical := canonical_skill_key(str(value), catalog_keys)) is not None
}
def build_skill_demand_report(
profile: SearchProfile,
*,
category: str = "all",
coverage: str = "all",
jobs: QuerySet[JobPosting] | list[JobPosting] | None = None,
) -> SkillDemandReport:
catalog = skill_catalog()
valid_categories = {definition.category_key for definition in catalog}
selected_category = category if category in valid_categories else "all"
selected_coverage = coverage if coverage in {"all", "gap", "covered"} else "all"
job_list = list(relevant_jobs_for_profile(profile) if jobs is None else jobs)
profile_skills = _profile_skill_keys(profile, catalog)
items: list[SkillDemandItem] = []
jobs_with_signals: set[str] = set()
for definition in catalog:
examples: list[SkillJobExample] = []
explicit_count = 0
inferred_count = 0
for job in job_list:
explicit = skill_is_present(definition.key, _structured_skill_text(job))
inferred = skill_is_present(definition.key, _inferred_skill_text(job))
if not explicit and not inferred:
continue
if explicit:
explicit_count += 1
else:
inferred_count += 1
jobs_with_signals.add(str(job.pk))
if len(examples) < 3:
examples.append(
SkillJobExample(
id=str(job.pk),
title=job.original_title,
employer=job.employer_name,
)
)
vacancy_count = explicit_count + inferred_count
if vacancy_count == 0:
continue
items.append(
SkillDemandItem(
key=definition.key,
label=definition.label,
category=definition.category,
category_key=definition.category_key,
vacancy_count=vacancy_count,
share_percent=round(vacancy_count / len(job_list) * 100) if job_list else 0,
explicit_count=explicit_count,
inferred_count=inferred_count,
covered=definition.key in profile_skills,
learning_focus=_learning_focus(definition),
search_term=definition.key,
examples=tuple(examples),
)
)
ranked = sorted(
items,
key=lambda item: (-item.vacancy_count, -item.explicit_count, item.label.casefold()),
)
covered_count = sum(item.covered for item in ranked)
gap_count = len(ranked) - covered_count
filtered = tuple(
item
for item in ranked
if (selected_category == "all" or item.category_key == selected_category)
and (
selected_coverage == "all"
or (selected_coverage == "covered" and item.covered)
or (selected_coverage == "gap" and not item.covered)
)
)
categories = tuple(
(slugify(category_label), category_label)
for category_label, _choices in SKILL_CHOICES
if any(item.category == category_label for item in ranked)
)
return SkillDemandReport(
total_jobs=len(job_list),
jobs_with_signals=len(jobs_with_signals),
signal_count=len(ranked),
covered_count=covered_count,
gap_count=gap_count,
coverage_percent=round(covered_count / len(ranked) * 100) if ranked else 0,
items=filtered,
categories=categories,
selected_category=selected_category,
selected_coverage=selected_coverage,
)
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import re
from apps.jobs.services.normalization import normalize_token
SKILL_ALIASES: dict[str, tuple[str, ...]] = {
"microsoft 365": ("m365", "office 365", "o365"),
"microsoft teams": ("ms teams",),
"entra id": ("azure active directory", "azure ad"),
"intune": ("microsoft intune", "endpoint manager"),
"autopilot": ("windows autopilot",),
"group policy": ("group policy object", "gpo"),
"dynamics 365": ("microsoft dynamics 365", "d365"),
"microsoft copilot": ("copilot for microsoft 365", "m365 copilot"),
"microsoft defender": ("defender for endpoint", "microsoft defender for endpoint"),
"microsoft sentinel": ("azure sentinel", "sentinel siem"),
"conditional access": ("voorwaardelijke toegang",),
"networking": ("network", "netwerk", "lan", "wan"),
"routing": ("routering",),
"switching": ("network switches", "switches"),
"sd-wan": ("sd wan",),
"firewalls": ("firewall",),
"fortinet": ("fortigate",),
"palo alto": ("palo alto networks",),
"wi-fi": ("wifi", "wireless"),
"backup and restore": ("backup", "back-up", "restore"),
"disaster recovery": ("business continuity", "bcp", "dr plan"),
"monitoring": ("infrastructure monitoring", "system monitoring"),
"observability": ("telemetry", "tracing"),
"high availability": ("hoogbeschikbaarheid", "ha architecture"),
"vmware": ("vsphere", "esxi"),
"ci/cd": ("continuous integration", "continuous delivery", "ci cd"),
"deployment": ("deployments", "software deployment", "uitrol"),
"reliability": ("platform reliability", "site reliability", "sre"),
"scalability": ("scalable", "schaalbaarheid"),
"voip": ("voice over ip", "telefonie", "telephony", "3cx", "innovaphone"),
"teams telephony": ("teams phone", "teams telefonie", "teams voice"),
"onsite support": ("on-site support", "support op locatie", "field support"),
"second line support": ("second-line support", "2nd line", "tweedelijnssupport"),
"installations": ("installatie", "installaties", "roll-out", "rollout"),
"migrations": ("migratie", "migraties"),
"technical documentation": ("technische documentatie",),
"customer support": ("klantondersteuning", "user support"),
"incident management": ("incidentbeheer", "incident response", "escalations"),
"problem management": ("probleembeheer", "root cause analysis"),
"change management": ("wijzigingsbeheer", "organizational change"),
"itil": ("itil 4", "it service management"),
"servicenow": ("service now",),
"jira service management": ("jira service desk", "jsm"),
"digital workplace": ("digitale werkplek",),
"m365 governance": ("microsoft 365 governance", "office 365 governance"),
"document management": ("documentbeheer", "document management system", "dms"),
"data governance": ("data governance", "datagovernance"),
"user adoption": ("gebruikersadoptie", "technology adoption"),
"training and workshops": ("user training", "workshops", "training geven"),
"customer experience": ("customer satisfaction", "csat", "nps"),
}
def contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
normalized_term = normalize_token(term)
if not normalized_term:
return False
pattern = re.escape(normalized_term).replace(r"\ ", r"[\s/_-]+")
if allow_plural and normalized_term[-1].isalpha() and not normalized_term.endswith("s"):
pattern += "s?"
return re.search(rf"(?<!\w){pattern}(?!\w)", text, re.IGNORECASE) is not None
def skill_terms(skill: str) -> tuple[str, ...]:
normalized = normalize_token(skill)
return (normalized, *SKILL_ALIASES.get(normalized, ()))
def skill_is_present(skill: str, text: str) -> bool:
return any(contains_term(text, candidate) for candidate in skill_terms(skill))
def canonical_skill_key(value: str, catalog_keys: set[str]) -> str | None:
normalized = normalize_token(value)
if normalized in catalog_keys:
return normalized
for key in catalog_keys:
if normalized in {normalize_token(term) for term in SKILL_ALIASES.get(key, ())}:
return key
return None
+2
View File
@@ -5,6 +5,7 @@ from .views import (
ApplicationUpdateView,
JobDetailView,
JobListView,
SkillInsightsView,
application_delete,
application_export,
application_print,
@@ -13,6 +14,7 @@ from .views import (
urlpatterns = [
path("", JobListView.as_view(), name="list"),
path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
path("applications/", ApplicationListView.as_view(), name="applications"),
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
path("applications/<int:pk>/export/", application_export, name="application-export"),
+21 -1
View File
@@ -18,7 +18,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, ListView, UpdateView
from django.views.generic import DetailView, ListView, TemplateView, UpdateView
from apps.profiles.models import SearchProfile
@@ -32,6 +32,26 @@ from .services.applications import (
)
from .services.feedback import record_feedback
from .services.relevance import it_relevance_query
from .services.skill_demand import build_skill_demand_report
class SkillInsightsView(LoginRequiredMixin, TemplateView):
template_name = "jobs/skill_insights.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
context["active_profile"] = profile
context["report"] = (
build_skill_demand_report(
profile,
category=self.request.GET.get("category", "all"),
coverage=self.request.GET.get("coverage", "all"),
)
if profile
else None
)
return context
class JobListView(LoginRequiredMixin, ListView):
+4 -5
View File
@@ -3,13 +3,12 @@ from __future__ import annotations
def default_weights() -> dict[str, float]:
return {
"content": 25.0,
"skills": 20.0,
"location": 15.0,
"content": 30.0,
"skills": 25.0,
"location": 20.0,
"conditions": 10.0,
"employer": 10.0,
"seniority": 10.0,
"preferences": 10.0,
"preferences": 5.0,
"ai": 0.0,
}
+24 -56
View File
@@ -5,55 +5,12 @@ import re
from django import forms
from .models import SearchProfile
TITLE_CHOICES = (
("system engineer", "System engineer"),
("infrastructure engineer", "Infrastructure engineer"),
("cloud engineer", "Cloud engineer"),
("devops engineer", "DevOps engineer"),
("network engineer", "Network engineer"),
("security engineer", "Security engineer"),
("workplace engineer", "Workplace engineer"),
("support engineer", "Support engineer"),
("software engineer", "Software engineer"),
("data engineer", "Data engineer"),
("solution architect", "Solution architect"),
("project manager", "Projectmanager"),
)
EXCLUDED_TITLE_CHOICES = (
("sales", "Sales"),
("recruiter", "Recruiter"),
("account manager", "Accountmanager"),
("callcenter", "Callcenter"),
("stage", "Stage"),
("student", "Studentenjob"),
)
SKILL_CHOICES = (
("azure", "Microsoft Azure"),
("aws", "AWS"),
("microsoft 365", "Microsoft 365"),
("active directory", "Active Directory"),
("entra id", "Entra ID"),
("intune", "Microsoft Intune"),
("linux", "Linux"),
("windows server", "Windows Server"),
("networking", "Netwerken"),
("security", "Security"),
("python", "Python"),
("powershell", "PowerShell"),
("terraform", "Terraform"),
("docker", "Docker"),
("kubernetes", "Kubernetes"),
("ci/cd", "CI/CD"),
)
EXCLUDED_SKILL_CHOICES = (
("cold calling", "Cold calling"),
("door to door", "Deur-aan-deurverkoop"),
("commission only", "Alleen commissieloon"),
("night shift", "Nachtwerk"),
from .taxonomy import (
EXCLUDED_SKILL_CHOICES,
EXCLUDED_TITLE_CHOICES,
SKILL_CHOICES,
TITLE_CHOICES,
iter_choices,
)
EMPLOYMENT_CHOICES = (
@@ -91,27 +48,31 @@ class SearchProfileForm(forms.ModelForm):
label="Gewenste functietitels",
required=False,
choices=TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple,
help_text="Selecteer alle rollen die bij je zoekrichting passen.",
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text="Selecteer alle rollen die bij je zoekrichting passen; de beste titelmatch telt.",
)
excluded_titles = forms.MultipleChoiceField(
label="Uitgesloten functietitels",
required=False,
choices=EXCLUDED_TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text="Vacatures met deze titelwoorden worden hard uitgesloten.",
)
desired_skills = forms.MultipleChoiceField(
label="Gewenste skills",
required=False,
choices=SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text=(
"Kies je herkenbare cv-skills ruim. Maximaal vier aangetroffen skills volstaan voor "
"de volledige skillcomponent; extra keuzes verwateren je score niet."
),
)
excluded_skills = forms.MultipleChoiceField(
label="Uitgesloten kenmerken",
required=False,
choices=EXCLUDED_SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
)
allowed_employment_types = forms.MultipleChoiceField(
label="Toegestane contractvormen",
@@ -145,6 +106,7 @@ class SearchProfileForm(forms.ModelForm):
"is_active",
"home_postal_code",
"max_distance_km",
"experience_years",
"desired_titles",
"excluded_titles",
"desired_skills",
@@ -163,6 +125,7 @@ class SearchProfileForm(forms.ModelForm):
"is_active": "Actief profiel",
"home_postal_code": "Thuispostcode",
"max_distance_km": "Maximale afstand (km)",
"experience_years": "Relevante IT-ervaring",
"recommendation_threshold": "Aanbevelingsdrempel",
"top_match_threshold": "Topmatchdrempel",
"digest_time": "Tijdstip dagelijkse samenvatting",
@@ -173,6 +136,10 @@ class SearchProfileForm(forms.ModelForm):
"Vier cijfers volstaan. Gemeente en coördinaten worden veilig afgeleid wanneer "
"lokale geodata beschikbaar is."
),
"experience_years": (
"Optionele profielcontext. Deze waarde telt nooit mee voor je matchscore en "
"kan een vacature niet uitsluiten."
),
"learning_enabled": "Past alleen zachte voorkeuren aan; harde regels blijven vast.",
}
widgets = {
@@ -203,14 +170,15 @@ class SearchProfileForm(forms.ModelForm):
field = self.fields[field_name]
stored = getattr(self.instance, field_name, [])
existing_by_casefold = {
str(value).casefold(): str(value) for value, _label in field.choices
str(value).casefold(): str(value) for value, _label in iter_choices(field.choices)
}
extra = [
(str(value), str(value))
for value in stored
if str(value).casefold() not in existing_by_casefold
]
field.choices = [*field.choices, *extra]
if extra:
field.choices = [*field.choices, ("Eerder opgeslagen", tuple(extra))]
field.initial = [
existing_by_casefold.get(str(value).casefold(), str(value)) for value in stored
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.16 on 2026-07-22 13:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('profiles', '0004_reminder_settings'),
]
operations = [
migrations.AddField(
model_name='searchprofile',
name='experience_years',
field=models.PositiveSmallIntegerField(choices=[(0, 'Niet ingesteld'), (1, 'Minder dan 2 jaar'), (3, '2 tot 4 jaar'), (5, '5 tot 7 jaar'), (8, '8 tot 9 jaar'), (10, '10 tot 14 jaar'), (15, '15 jaar of meer')], default=0),
),
]
@@ -0,0 +1,22 @@
from django.db import migrations
def remove_seniority_weight(apps, schema_editor):
search_profile = apps.get_model("profiles", "SearchProfile")
for profile in search_profile.objects.all().iterator():
weights = dict(profile.weights or {})
if "seniority" not in weights:
continue
weights.pop("seniority", None)
profile.weights = weights
profile.save(update_fields=["weights"])
class Migration(migrations.Migration):
dependencies = [
("profiles", "0005_searchprofile_experience_years"),
]
operations = [
migrations.RunPython(remove_seniority_weight, migrations.RunPython.noop),
]
+15
View File
@@ -16,6 +16,16 @@ from .defaults import (
default_weights,
)
EXPERIENCE_YEARS_CHOICES = (
(0, "Niet ingesteld"),
(1, "Minder dan 2 jaar"),
(3, "2 tot 4 jaar"),
(5, "5 tot 7 jaar"),
(8, "8 tot 9 jaar"),
(10, "10 tot 14 jaar"),
(15, "15 jaar of meer"),
)
class SearchProfile(TimeStampedModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@@ -29,6 +39,10 @@ class SearchProfile(TimeStampedModel):
home_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
home_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
max_distance_km = models.PositiveIntegerField(default=45)
experience_years = models.PositiveSmallIntegerField(
choices=EXPERIENCE_YEARS_CHOICES,
default=0,
)
desired_titles = models.JSONField(default=list, blank=True)
excluded_titles = models.JSONField(default=list, blank=True)
@@ -105,6 +119,7 @@ class SearchProfile(TimeStampedModel):
if self.home_longitude is not None
else None,
"max_distance_km": self.max_distance_km,
"experience_years": self.experience_years,
"desired_titles": self.desired_titles,
"excluded_titles": self.excluded_titles,
"desired_skills": self.desired_skills,
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
from collections.abc import Iterable, Iterator, Sequence
Choice = tuple[str, str]
ChoiceGroup = tuple[str, tuple[Choice, ...]]
TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Field service & support",
(
("it field engineer", "IT Field Engineer"),
("field service engineer", "Field Service Engineer"),
("it engineer", "IT Engineer"),
("it support engineer", "IT Support Engineer"),
("support engineer", "Support Engineer"),
("onsite support engineer", "On-site Support Engineer"),
("service desk engineer", "Service Desk Engineer"),
("technical support engineer", "Technical Support Engineer"),
("it technician", "IT Technician"),
("pc technician", "PC Technician"),
),
),
(
"Systemen & infrastructuur",
(
("system engineer", "System Engineer"),
("system network engineer", "System & Network Engineer"),
("infrastructure engineer", "Infrastructure Engineer"),
("system administrator", "System Administrator"),
("systeembeheerder", "Systeembeheerder"),
("network engineer", "Network Engineer"),
("network administrator", "Network Administrator"),
("netwerkbeheerder", "Netwerkbeheerder"),
("infrastructure consultant", "Infrastructure Consultant"),
("implementation engineer", "Implementation Engineer"),
),
),
(
"Modern Workplace & endpoint",
(
("workplace engineer", "Workplace Engineer"),
("modern workplace engineer", "Modern Workplace Engineer"),
("microsoft 365 engineer", "Microsoft 365 Engineer"),
("microsoft 365 consultant", "Microsoft 365 Consultant"),
("modern workplace consultant", "Modern Workplace Consultant"),
("endpoint engineer", "Endpoint Engineer"),
("intune engineer", "Intune Engineer"),
),
),
(
"Andere IT-richtingen",
(
("cloud engineer", "Cloud Engineer"),
("devops engineer", "DevOps Engineer"),
("security engineer", "Security Engineer"),
("platform engineer", "Platform Engineer"),
("software engineer", "Software Engineer"),
("data engineer", "Data Engineer"),
("solution architect", "Solution Architect"),
("project manager", "IT-projectmanager"),
),
),
)
EXCLUDED_TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Commercieel & werving",
(
("sales", "Sales"),
("recruiter", "Recruiter"),
("account manager", "Accountmanager"),
("business developer", "Business Developer"),
("callcenter", "Callcenter"),
),
),
(
"Software, data & analyse",
(
("software developer", "Software Developer"),
("software engineer", "Software Engineer"),
("web developer", "Web Developer"),
("developer", "Developmentfuncties (breed)"),
("software", "Softwarefuncties (breed)"),
("backend", "Backendfuncties"),
("frontend", "Frontendfuncties"),
("php", "PHP-ontwikkeling"),
("data engineer", "Data Engineer"),
("data scientist", "Data Scientist"),
("data", "Datafuncties (breed)"),
("ai engineer", "AI Engineer"),
("ai", "AI-functies (breed)"),
("machine learning", "Machine Learning"),
("functional analyst", "Functional Analyst"),
("functioneel analist", "Functioneel Analist"),
("business analyst", "Business Analyst"),
),
),
(
"Management & starters",
(
("product owner", "Product Owner"),
("project manager", "Projectmanager"),
("architect", "Architectrollen"),
("service delivery manager", "Service Delivery Manager"),
("team lead", "Teamlead"),
("head of", "Head-of-rollen"),
("procurement", "Procurement"),
("stage", "Stage"),
("internship", "Internship"),
("student", "Studentenjob"),
),
),
(
"Niet-IT-techniek",
(
("electromechanical", "Elektromechanica"),
("maintenance technician", "Onderhoudstechnieker"),
("process engineer", "Process Engineer"),
("automation engineer", "Automation Engineer"),
),
),
(
"Onderzoek & beeldverwerking",
(
("research", "Onderzoeksfuncties"),
("scientist", "Scientist"),
("image processing", "Beeldverwerking"),
("remote sensing", "Remote sensing"),
),
),
(
"Cloud & businessplatformen",
(
("cloud", "Cloudfuncties (breed)"),
("devops", "DevOps"),
("azure consultant", "Azure Consultant"),
("sap", "SAP"),
("power platform", "Power Platform"),
),
),
(
"Onderwijs",
(
("lecturer", "Lecturer"),
("lector", "Lector"),
("docent", "Docent"),
("gastdocent", "Gastdocent"),
),
),
)
SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Field service & uitvoering",
(
("troubleshooting", "Troubleshooting"),
("onsite support", "On-site support"),
("hardware", "Hardware & randapparatuur"),
("installations", "Installaties & roll-outs"),
("migrations", "Migraties"),
("technical documentation", "Technische documentatie"),
("customer support", "Klantondersteuning"),
("second line support", "Tweede- en derdelijnssupport"),
("customer experience", "Klanttevredenheid / CSAT"),
),
),
(
"Microsoft & endpoint",
(
("microsoft 365", "Microsoft 365"),
("exchange online", "Exchange Online"),
("sharepoint", "SharePoint"),
("microsoft teams", "Microsoft Teams"),
("entra id", "Entra ID"),
("intune", "Microsoft Intune"),
("autopilot", "Windows Autopilot"),
("windows server", "Windows Server"),
("active directory", "Active Directory"),
("group policy", "Group Policy / GPO"),
("dynamics 365", "Microsoft Dynamics 365"),
("microsoft copilot", "Microsoft Copilot"),
("microsoft defender", "Microsoft Defender"),
("microsoft sentinel", "Microsoft Sentinel"),
("conditional access", "Conditional Access"),
),
),
(
"Netwerk & security",
(
("networking", "Netwerken"),
("tcp/ip", "TCP/IP"),
("vlan", "VLAN"),
("vpn", "VPN"),
("dhcp", "DHCP"),
("dns", "DNS"),
("firewalls", "Firewalls"),
("watchguard", "WatchGuard"),
("cisco", "Cisco"),
("ubiquiti", "Ubiquiti"),
("wi-fi", "Wi-Fi"),
("routing", "Routing"),
("switching", "Switching"),
("sd-wan", "SD-WAN"),
("fortinet", "Fortinet / FortiGate"),
("palo alto", "Palo Alto Networks"),
("aruba", "HPE Aruba"),
("meraki", "Cisco Meraki"),
("sophos", "Sophos"),
("zero trust", "Zero Trust"),
),
),
(
"Back-up & monitoring",
(
("backup and restore", "Back-up & restore"),
("veeam", "Veeam"),
("windows server backup", "Windows Server Backup"),
("synology", "Synology"),
("zabbix", "Zabbix"),
("monitoring", "Infrastructuurmonitoring"),
("observability", "Observability"),
("high availability", "High availability"),
("disaster recovery", "Disaster recovery"),
),
),
(
"Virtualisatie & platform",
(
("vmware", "VMware"),
("proxmox", "Proxmox"),
("hyper-v", "Hyper-V"),
("docker", "Docker"),
("azure", "Microsoft Azure"),
("aws", "AWS"),
("linux", "Linux"),
("kubernetes", "Kubernetes"),
("terraform", "Terraform"),
("ci/cd", "CI/CD"),
("deployment", "Deployment & uitrol"),
("reliability", "Platform reliability"),
("scalability", "Schaalbaarheid"),
),
),
(
"VoIP & telefonie",
(
("voip", "VoIP"),
("3cx", "3CX"),
("innovaphone", "Innovaphone"),
("teams telephony", "Teams-telefonie"),
("yealink", "Yealink"),
("snom", "Snom"),
),
),
(
"Automation & scripting",
(
("powershell", "PowerShell"),
("bash", "Bash"),
("python", "Python"),
("c#", "C#"),
),
),
(
"IT-servicemanagement",
(
("itil", "ITIL / IT-servicemanagement"),
("incident management", "Incidentmanagement"),
("problem management", "Problemmanagement"),
("change management", "Changemanagement"),
("servicenow", "ServiceNow"),
("jira service management", "Jira Service Management"),
),
),
(
"Digitale werkplek & adoptie",
(
("digital workplace", "Digital Workplace"),
("m365 governance", "Microsoft 365-governance"),
("document management", "Documentmanagement"),
("data governance", "Datagovernance"),
("user adoption", "Gebruikersadoptie"),
("training and workshops", "Training & workshops"),
),
),
)
EXCLUDED_SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Commerciële voorwaarden",
(
("cold calling", "Cold calling"),
("door to door", "Deur-aan-deurverkoop"),
("commission only", "Alleen commissieloon"),
),
),
(
"Werkregeling",
(
("night shift", "Nachtwerk"),
("rotating shifts", "Ploegendienst"),
("on-call duty", "Structurele wachtdienst"),
),
),
)
def iter_choices(choices: Iterable[Choice | tuple[str, Sequence[Choice]]]) -> Iterator[Choice]:
"""Flatten Django optgroups while retaining compatibility with legacy flat choices."""
for value, label_or_choices in choices:
if isinstance(label_or_choices, tuple | list):
yield from iter_choices(label_or_choices)
else:
yield str(value), str(label_or_choices)