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
+31
View File
@@ -2,6 +2,37 @@
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
## 0.2.13 — 2026-07-22
### Gewijzigd
- Ervaringsjaren en senioriteitslabels zijn volledig uit de deterministische én AI-score gehaald; de profielwaarde blijft uitsluitend informatieve context en bestaande senioriteitsgewichten worden via migratie verwijderd.
- De nieuwe persoonlijke Skillsradar analyseert alleen actieve, IT-relevante, niet-uitgesloten vacatures van de huidige profielversie en negeert vacatures waarvan alle bronaliassen uitgeschakeld zijn.
- Per technologie of capability toont de radar vacaturevolume, profieldekking, skillveldbewijs versus tekstinferentie, onderliggende vacatures en een niet-blokkerende mogelijke leer-/bijscholingsstap.
- Categorie- en dekkingsfilters maken de marktvraag doorzoekbaar; de profielcheckboxes zijn uitgebreid met actuele Microsoft-, netwerk/security-, observability-, platform-, ITSM- en adoptieskills.
- De Stitch-cockpit gebruikt een aparte vraag-/leerkansencompositie met adaptieve kaarten, context-rail en tweekoloms skillfeed op ultrawide.
### Verificatie
- 240 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,73% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De geïntegreerde browsercontrole bevestigde filtering en bronselectie plus layouts op 2560×1440, 1440×900 en 390×844 zonder horizontale paginaoverflow of consolewaarschuwingen.
## 0.2.12 — 2026-07-22
### Gewijzigd
- De zoekprofieleditor biedt gegroepeerde selecties voor field service, infrastructuur, Modern Workplace, netwerk/security, back-up/monitoring, virtualisatie, VoIP en scripting.
- Relevante IT-ervaring is configureerbaar als ervaringsband en wordt alleen met expliciet gevraagde vacaturejaren vergeleken.
- Een breed cv-profiel verwatert de skillscore niet onbeperkt: vier aangetroffen skills volstaan voor de volledige skillcomponent en niet-vermelde voorkeurskills zijn geen bewezen tekort.
- Ambigue field-/implementatietitels vereisen naast de titel een sterk IT-contextsignaal in de vacaturetekst; korte en meervoudige uitsluittermen gebruiken woordgrenzen.
- De mobiele categoriekaarten stapelen op één kolom en behouden de Sticky-opslagactie zonder horizontale paginaoverflow.
### Verificatie
- 232 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,59% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De geïntegreerde browsercontrole bevestigde de gegroepeerde editor op ultrawide en 390 px, de ervaringsband, lokale profielselecties en afwezigheid van horizontale paginaoverflow.
- Persoonlijke cv-inhoud is alleen gebruikt om de lokale gebruikersprofielen af te stellen en is niet in code, fixtures, tests of documentatie opgenomen.
## 0.2.11 — 2026-07-22
### Gewijzigd
+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)
+87
View File
@@ -1488,6 +1488,93 @@ tasks:
completed_at: '2026-07-22'
note: 'Volledige code- en browseraudit: deterministische IT-focus, laatste gebruikersscore, premium Stitch-feed/detail
en adaptieve 320-2560px-layout; 225 tests groen en browsermatrix geslaagd.'
- id: VR-207
title: Cv-gestuurd field- en infrastructuurprofiel
status: done
priority: P0
requirement_ids:
- PR-002
- PR-040
- PR-041
- NFR-002
- NFR-004
depends_on:
- VR-206
summary: Breid de begeleide profielconfiguratie uit voor een ervaren IT field-/infrastructuurprofiel en voorkom
dat een brede, relevante skillset de matchscore kunstmatig verlaagt. Persoonlijke cv-data blijft uitsluitend
in de gebruikersdatabase en komt niet in repositoryfixtures of documentatie.
acceptance_criteria:
- Rollen en skills zijn in begrijpelijke categorieën selecteerbaar voor field service, infrastructuur, Microsoft
365/endpoint, netwerk/security, back-up/monitoring, virtualisatie, VoIP en automation.
- Relevante IT-ervaring is via een selectielijst configureerbaar en wordt deterministisch vergeleken met expliciet
gevraagde ervaringsjaren; onbekende ervaring leidt niet tot uitsluiting.
- Ambigue field-/implementatietitels gelden alleen als IT wanneer de vacaturetekst tevens een sterk technisch
contextsignaal bevat; niet-IT field service blijft gefilterd.
- Meer relevante cv-skills aanvinken verwatert de skillscore niet onbeperkt en niet-vermelde voorkeurskills worden
niet als bewezen tekort gepresenteerd.
- Bestaande vrije profielwaarden blijven selecteerbaar en persoonlijke gegevens uit cv's worden niet gecommit.
verification:
- uv run pytest tests/unit/test_relevance.py tests/unit/test_scoring.py tests/unit/test_profiles.py tests/integration/test_profile_preferences.py
- uv run python manage.py makemigrations --check --dry-run
- ./scripts/codex_verify.sh
primary_paths:
- apps/profiles/forms.py
- apps/profiles/models.py
- apps/profiles/taxonomy.py
- apps/jobs/services/relevance.py
- apps/jobs/services/scoring.py
- templates/profiles/edit.html
- static/css/app.css
result:
completed_at: '2026-07-22'
note: Cv-gestuurde gegroepeerde profielconfiguratie, ervaringsband, begrensde skill-evidence, contextgebonden
fieldrelevantie en lokale profielrescore geverifieerd; 232 tests groen en browsercontrole op ultrawide/390
px geslaagd.
- id: VR-208
title: Skillsvraag- en leerkansenradar zonder ervaringsscore
status: done
priority: P0
requirement_ids:
- PR-002
- PR-040
- PR-041
- PR-043
- PR-050
- NFR-004
depends_on:
- VR-207
summary: Maak ervaringsduur volledig informatief en bouw uit de actuele, relevante vacatures een uitlegbare marktvraaganalyse
met profieldekking, skillsfilters en concrete bijleerkansen.
acceptance_criteria:
- Ervaringsjaren en senioriteitslabels hebben geen invloed op score, aanbeveling of harde uitsluiting; de profielwaarde
is uitsluitend informatieve context en bestaande senioriteitsgewichten worden veilig genegeerd en opgeruimd.
- De analyse gebruikt alleen actieve IT-vacatures die voor het actieve profiel niet hard uitgesloten zijn en blijft
strikt gebruikersgescopeerd.
- Een deterministische, uitbreidbare skillcatalogus toont per technologie of capability in hoeveel vacatures een
expliciet tekstsignaal voorkomt en onderscheidt profieldekking van mogelijke leer-/bijscholingskansen.
- Het profiel biedt extra relevante skills als gegroepeerde checkboxes; de inzichtspagina biedt categorie- en
dekkingsfilters en links naar de onderliggende vacatures.
- Feiten, tekstinferenties en onbekende gegevens zijn in de Nederlandstalige UI duidelijk onderscheiden; lege
toestanden en layouts werken van 320 tot 2560 px zonder horizontale paginaoverflow.
verification:
- uv run pytest tests/unit/test_scoring.py tests/unit/test_skill_demand.py tests/integration/test_skill_insights.py
tests/integration/test_profile_preferences.py
- uv run python manage.py makemigrations --check --dry-run
- ./scripts/codex_verify.sh
- Browsercontrole op 2560x1440, 1440x900 en 390x844
primary_paths:
- apps/jobs/services/scoring.py
- apps/jobs/services/skill_demand.py
- apps/jobs/views.py
- apps/profiles/taxonomy.py
- templates/jobs/skill_insights.html
- static/css/app.css
- docs/architecture/SCORING_ENGINE.md
result:
completed_at: '2026-07-22'
note: Ervarings- en senioriteitsinvloed uit score en learning verwijderd; gebruikersgescopeerde Skillsradar
met actuele bronselectie, vraagbewijs, profieldekking, leerkansen, uitgebreide skillfilters en 2560/1440/390-browsercontrole
voltooid.
- id: VR-301
title: Multi-tenant SaaS, teams en billing
status: deferred
+32 -1
View File
@@ -1,7 +1,7 @@
# Projectstatus
- Laatst bijgewerkt: 2026-07-22
- Repositoryversie: 0.2.11 premium IT-radar en ultrawide Stitch-cockpit
- Repositoryversie: 0.2.13 persoonlijke skillsvraag- en leerkansenradar
- Uitvoeringsmodus: autonome backlog
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
@@ -140,6 +140,25 @@ VR-206-herverificatie op 2026-07-22:
- de Stitch-cockpit benut maximaal 2240 px met tweekoloms matchfeeds op ultrawide; de zes kernroutes zijn handmatig gecontroleerd op 320, 390, 1024, 1440, 1920 en 2560 px zonder horizontale paginaoverflow;
- dark/light, gelabelde velden, mobiele drawer, score-/confidencecopy, archieffilters en inline bewaren zijn in de geïntegreerde browser gecontroleerd.
VR-207-herverificatie op 2026-07-22:
- 232 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,59% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- het profiel bevat gegroepeerde field-/infrastructuur-, Microsoft/endpoint-, netwerk/security-, back-up/monitoring-, virtualisatie-, VoIP- en scriptingkeuzes plus een ervaringsband;
- de skillcomponent bereikt zijn maximum bij vier aangetroffen cv-skills en presenteert niet-vermelde voorkeurskills niet meer als tekort;
- ambigue field-/implementatietitels vereisen een onafhankelijk IT-contextsignaal en titeluitsluitingen respecteren woordgrenzen en eenvoudige Engelse meervouden;
- de lokale actieve auditprofielen zijn zonder persoonsgegevens in Git afgestemd op 1014 jaar ervaring, 27 relevante roltitels en 46 cv-skills; na herscoring blijven 14 plausibele field-/infrastructuurkansen zichtbaar;
- geïntegreerde browsercontrole bevestigde de categorie-editor op ultrawide en 390 px, één mobiele categoriekolom, geldige profielselecties en geen horizontale paginaoverflow.
VR-208-herverificatie op 2026-07-22:
- 240 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,73% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- ervaringsduur en senioriteitslabels hebben geen invloed meer op deterministische score, AI-score, aanbeveling, uitsluiting of feedbacklearning; migratie 0006 ruimt het oude gewicht op;
- de persoonlijke Skillsradar analyseert alleen actieve IT-vacatures met een niet-verborgen score voor de actuele profielversie en laat records van uitsluitend uitgeschakelde bronnen buiten beschouwing;
- de lokale radar toont 9 onderzochte vacatures, 5 herkenbare vraagsignalen, 2 gedekte skills en 3 mogelijke leerkansen zonder een tekstsignaal als harde eis te presenteren;
- geïntegreerde browsercontrole bevestigde filters, 88 profielskillcheckboxes, één `h1`, lege console en geen horizontale paginaoverflow op 2560×1440, 1440×900 en 390×844.
## Deploymentstatus
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
@@ -150,6 +169,18 @@ VR-206-herverificatie op 2026-07-22:
## Laatste uitgevoerde backlogtaak
- 2026-07-22: `VR-208` voltooid.
- Ervaringsjaren zijn alleen nog profielcontext; alle score-, recommendation-, concern-, evidence- en learninginvloed is verwijderd.
- Een gebruikersgescopeerde vraaganalyse brengt actuele skillfrequentie, bewijskwaliteit, profieldekking en niet-blokkerende leer-/bijscholingskansen in kaart.
- De profielcatalogus en nieuwe responsieve Skillsradar ondersteunen Microsoft, netwerk/security, monitoring/platform, ITSM en digitale adoptie met begeleide filters.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
- 2026-07-22: `VR-207` voltooid.
- De twee aangeleverde cv's zijn visueel en tekstueel lokaal beoordeeld; alleen afgeleide technische profielkeuzes zijn in de lokale gebruikersdatabase opgeslagen.
- Relevante ervaring, rollen, skills en doelgerichte uitsluitingen zijn toegepast en alle 336 actieve vacatures zijn opnieuw gescoord voor beide lokale auditprofielen.
- De profiel- en scorelogica is regressiegedekt; afwezige cv-skills verlagen een breed profiel niet onbeperkt en worden niet als bewezen tekort getoond.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
- 2026-07-22: `VR-206` voltooid.
- Niet-IT-vacatures en commercieel/recruitmentmisleidende IT-titels zijn standaard uit Vandaag en de vacaturefeed verwijderd zonder bronhistoriek te wissen.
- Matchsortering, radar-/archieffilters, score-evidence en gebruikersgescopeerde dashboardscores maken de dagelijkse selectie beslisbaar.
+9 -6
View File
@@ -24,6 +24,7 @@ Ondersteund of gepland:
- uitgesloten regio/gemeente;
- afstand boven maximum, behalve remote;
- uitgesloten verplichte skill;
- een titelgestuurde IT-grens; ambigue field-/implementatietitels vereisen daarnaast een sterk IT-contextsignaal in de vacaturetekst;
- expliciete rijbewijs-/reis-/taalvereisten wanneer betrouwbaar geëxtraheerd;
- verlopen of niet-actieve vacature buiten de ranking.
@@ -35,15 +36,16 @@ Standaardgewichten, totaal 100:
| Component | Gewicht | Kernsignalen |
|---|---:|---|
| Content | 25 | titelovereenkomst, taken, weinig ongewenste support |
| Skills | 20 | gewenste skills in expliciete velden/tekst |
| Locatie | 15 | afstand, remote/hybrid, voorkeursregio |
| Content | 30 | titelovereenkomst, taken, weinig ongewenste support |
| Skills | 25 | gewenste skills in expliciete velden/tekst |
| Locatie | 20 | afstand, remote/hybrid, voorkeursregio |
| Voorwaarden | 10 | toegestane contract-/werkvorm |
| Werkgever | 10 | directe bron versus recruiter |
| Senioriteit | 10 | gevraagde ervaring versus profiel |
| Voorkeuren | 10 | publieke sector en andere soft signals |
| Voorkeuren | 5 | publieke sector en andere soft signals |
Bij aangepaste gewichten worden componentbijdragen genormaliseerd op de totale som.
- De profielervaring is uitsluitend informatieve context. Gevraagde ervaringsjaren en senioriteitslabels veranderen de score, aanbeveling en uitsluitingen nooit.
- Een breed cv-profiel wordt niet bestraft omdat veel relevante skills zijn geselecteerd: maximaal vier aangetroffen profielskills volstaan voor de volledige skillcomponent. Niet-aangetroffen voorkeurskills blijven evidence, maar worden niet als bewezen tekort of concern gepresenteerd.
- AI-boost is opt-in via profielinstelling en telt alleen mee als profielgewicht > 0.
- De AI-component is softwarematig begrensd (maximale bijdrage via `ai`-gewicht) om dominantie te vermijden.
@@ -91,6 +93,7 @@ Een score bewaart:
Copyregels:
- zeg "niet teruggevonden" in plaats van "ontbreekt" wanneer brondata onvolledig is;
- presenteer niet-vermelde cv-skills niet als tekort; alleen expliciet aangetroffen skills zijn positieve evidence;
- label inference als inference;
- toon maximaal enkele kernredenen bovenaan en volledige details uitklapbaar;
- geef geen kanspercentage op aanwerving zonder gevalideerd model.
@@ -119,7 +122,7 @@ Offline evaluatie is beschikbaar via `scripts/feedback_learning_report.py` met f
Toegestaan:
- support-/consultancy-/travelratio schatten;
- senioritylabel;
- senioritylabel als informatieve extractie, zonder score-invloed;
- korte Nederlandse samenvatting;
- evidencefragmenten selecteren;
- ambiguïteitswarnings.
+1 -1
View File
@@ -97,7 +97,7 @@ De IDs zijn stabiel en worden gebruikt in tests en traceability.
### Matching en ranking
- **PR-040** — Harde regels worden vóór scoring toegepast en zijn uitlegbaar.
- **PR-041** — Scorecomponenten omvatten inhoud, skills, locatie, voorwaarden, werkgever, senioriteit en voorkeuren.
- **PR-041** — Scorecomponenten omvatten inhoud, skills, locatie, voorwaarden, werkgever en voorkeuren. Ervaringsduur en senioriteitslabels blijven uitsluitend informatieve context en beïnvloeden score, aanbeveling of uitsluiting niet.
- **PR-042** — Onbekende data verlaagt confidence en wordt niet automatisch als negatief feit behandeld.
- **PR-043** — Iedere score bewaart componenten, positives, concerns, uitsluitingen en evidence.
- **PR-044** — Profielversie en model-/promptversie worden aan een score gekoppeld.
+8 -7
View File
@@ -7,7 +7,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| ID | Status | Primaire implementatie | Bewijs/tests | Vervolgtaak |
|---|---|---|---|---|
| PR-001 | I | `profiles/models.py`, forms/views | `test_profiles.py`, `test_views.py` | — |
| PR-002 | I | `SearchProfile`, defaults/form, geocodeparser | `tests/unit/test_geocoding.py`, `tests/integration/test_import_geodata.py`, profiel- en viewtests | VR-105 verfijnt afstand |
| PR-002 | I | `SearchProfile`, begeleide skilltaxonomie, informatieve ervaringscontext, defaults/form en geocodeparser | `tests/unit/test_geocoding.py`, `tests/integration/test_profile_preferences.py`, profiel- en viewtests | VR-105/VR-207/VR-208 |
| PR-003 | I | `ProfileRevision`, profile services | `test_profiles.py` | — |
| PR-004 | I | begrensde feedbackservice/velden | `tests/unit/test_feedback_learning.py`, `test_profiles.py` | VR-110 uitgevoerd |
| PR-005 | I | `bootstrap_instance`, defaults | task/viewtests | — |
@@ -32,14 +32,14 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| PR-031 | I | conservatieve fuzzy dedupe | `tests/unit/test_dedupe_lifecycle_feedback.py`, `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 benchmark |
| PR-032 | I | direct-sourcepreferentie/alias | pipeline- en dedupetests | VR-108 resolver |
| PR-033 | I | canonical constraints/transacties | replaytests | — |
| PR-040 | I | features/scoring + deterministische IT-relevantie vóór AI | `tests/unit/test_scoring.py`, `tests/unit/test_relevance.py`, viewtests | VR-206 |
| PR-041 | I | scorecomponenten | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 afgerond |
| PR-040 | I | features/scoring + deterministische IT-relevantie vóór AI, inclusief contextgebonden fieldtitels | `tests/unit/test_scoring.py`, `tests/unit/test_relevance.py`, viewtests | VR-206/VR-207 |
| PR-041 | I | scorecomponenten met begrensde skill-evidence; ervaring/senioriteit aantoonbaar zonder score-invloed | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105/VR-207/VR-208 |
| PR-042 | I | unknown/confidencegedrag | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 |
| PR-043 | I | `ScoreRun` | pipeline/scoringtests | — |
| PR-043 | I | `ScoreRun` plus uitlegbare skillvraag-/dekkingsanalyse | pipeline-/scoringtests, `tests/unit/test_skill_demand.py` | VR-208 |
| PR-044 | I | profiel/modelversievelden | scoring/profiletests | VR-109 promptversie |
| PR-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 |
| PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties |
| PR-050 | I | dashboard/list views + Stitch-dominante cockpitshell/feed/rail en ultrawide compositie | viewtests, VR-119/206 browsermatrix | VR-114/118/119/206 e2e/a11y |
| PR-050 | I | dashboard/list/skillsradar + Stitch-dominante cockpitshell/feed/rail en ultrawide compositie | viewtests, skill-insighttests, VR-119/206/208 browsermatrix | VR-114/118/119/206/208 e2e/a11y |
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
| PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — |
| PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier |
@@ -59,7 +59,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| NFR-001 | I | policy-, URL- en fetchersecuritytests + settings/rate-limit tests | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/integration/test_views.py`, `tests/integration/test_sources_manual_import.py` |
| NFR-002 | P | raw-retentie, minimale modellen, privacyrunbooks | VR-113/115 |
| NFR-003 | I/P | replay-, mail- en outboxtests; per-bronfouten | VR-103/112 |
| NFR-004 | I | ScoreRun, provenance, UI-copy | VR-109 uitgebreid evidence |
| NFR-004 | I | ScoreRun, provenance, skillvraag met expliciet veld-/tekstonderscheid en UI-copy | VR-109/208 uitgebreid evidence |
| NFR-005 | I | domeinapps/services/adapters/ADRs | continue review |
| NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e |
| NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 |
@@ -99,4 +99,5 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
- VR-205 laadt de officiële Belgische GeoNames-postcodedataset 2026-04-03 onder CC BY 4.0, documenteert bron- en afgeleide checksums en lost 2400 Mol op naar het benaderende postcodecentrum. Bewijs staat in `config-data/geodata/README.local.md`, het importlog, geocodingtests en de opnieuw berekende profiel-vacaturescores.
- VR-206 voegt een titelgestuurde IT-relevantiegrens vóór scoring/AI toe, maakt de persoonlijke radarselectie de standaard en benut Stitch-panelen dynamisch tot 2560 px. Bewijs staat in `apps/jobs/services/relevance.py`, de score-/viewtests en `docs/design/validation/VR-206_AUDIT.md`.
- VR-207 breidt het profiel uit met gegroepeerde field-/infrastructuurrollen, cv-skills en een ervaringsband. Skill-evidence is op vier treffers begrensd, afwezigheid wordt niet als tekort gepresenteerd en ambigue fieldtitels vereisen een onafhankelijk IT-contextsignaal. Persoonlijke cv-waarden staan uitsluitend in de lokale gebruikersdatabase; repositorytests en documentatie blijven gesaneerd.
- VR-208 maakt die ervaringsband uitsluitend informatief en verwijdert ook senioriteitsinvloed uit de AI-component en feedbacklearning. De gebruikersgescopeerde Skillsradar analyseert actuele niet-uitgesloten IT-vacatures van actieve bronnen, onderscheidt skillveldbewijs van tekstinferentie en toont profieldekking en mogelijke leerkansen met categorie-/dekkingsfilters.
+95
View File
@@ -293,6 +293,12 @@ textarea { resize: vertical; }
.choice-field label { display: flex; align-items: center; gap: 9px; min-height: 44px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--surface) 92%, transparent); color: var(--text); font-size: 13px; font-weight: 600; cursor: pointer; }
.choice-field label:has(input:checked) { border-color: color-mix(in srgb, var(--primary) 62%, var(--border)); background: color-mix(in srgb, var(--primary) 10%, var(--surface)); }
.choice-field small { display: block; margin-top: 7px; }
.choice-field > .grouped-choices { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
.choice-field .grouped-choices > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start; gap: 7px; padding: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-low); }
.choice-field .grouped-choices > div > label { grid-column: 1 / -1; min-height: 0; margin: 0 0 3px; padding: 0; border: 0; border-radius: 0; background: transparent; color: var(--text-soft); font-size: 12px; font-weight: 800; letter-spacing: .02em; cursor: default; }
.choice-field .grouped-choices > div > label:has(input:checked) { border: 0; background: transparent; }
.choice-field .grouped-choices > div > div { min-width: 0; }
.choice-field .grouped-choices > div > div > label { height: 100%; }
.form-section { grid-column: 1 / -1; margin: 12px 0 -2px; padding-top: 18px; border-top: 1px solid var(--border); }
.form-section h2 { margin: 0; font-size: 17px; }
.form-section p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }
@@ -391,6 +397,8 @@ textarea { resize: vertical; }
.source-card-head { flex-direction: column; }
.source-actions { width: 100%; justify-content: space-between; }
.choice-field > div { grid-template-columns: 1fr; }
.choice-field > .grouped-choices { grid-template-columns: 1fr; }
.choice-field .grouped-choices > div { grid-template-columns: 1fr; }
.form-actions { bottom: 8px; }
}
@@ -681,3 +689,90 @@ textarea { resize: vertical; }
.result-heading h2 { font-size: 20px; }
.score-block small { max-width: none; text-align: left; }
}
/* VR-208: personal market-demand and learning radar. */
.skills-page-header { align-items: center; }
.insight-hero {
position: relative;
display: grid;
grid-template-columns: 126px minmax(0, 1fr) auto;
align-items: center;
gap: 24px;
margin-bottom: 16px;
padding: 26px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--primary) 32%, var(--border));
border-radius: var(--radius);
background:
radial-gradient(circle at 8% 50%, color-mix(in srgb, var(--primary) 13%, transparent), transparent 30%),
linear-gradient(125deg, color-mix(in srgb, var(--primary) 5%, var(--surface)), var(--surface));
box-shadow: 0 18px 48px rgba(0, 0, 0, .12);
}
.insight-hero h2 { margin: 7px 0; font-size: clamp(22px, 2vw, 31px); letter-spacing: -.025em; }
.insight-hero p { max-width: 80ch; margin: 0; color: var(--muted); }
.insight-orbit { position: relative; width: 104px; height: 104px; display: grid; place-items: center; }
.insight-orbit span { position: absolute; inset: 0; border: 1px solid color-mix(in srgb, var(--primary) 50%, transparent); border-radius: 50%; }
.insight-orbit span:nth-child(2) { inset: 13px; opacity: .65; }
.insight-orbit span:nth-child(3) { inset: 26px; opacity: .4; }
.insight-orbit strong { z-index: 1; color: var(--primary-strong); font-size: 24px; }
.skill-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.skill-metrics article { display: grid; gap: 5px; padding: 18px 20px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.skill-metrics article.metric-accent { border-color: color-mix(in srgb, var(--warning) 38%, var(--border)); background: linear-gradient(145deg, var(--warning-soft), var(--surface)); }
.skill-metrics span { color: var(--text-soft); font-size: 12px; }
.skill-metrics strong { font-size: 28px; line-height: 1; }
.skill-metrics small { color: var(--muted); }
.skills-filter-console { grid-template-columns: minmax(220px, 1fr) minmax(190px, .65fr) auto auto; }
.skills-insight-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 28px; align-items: start; }
.skill-demand-list { display: grid; gap: 12px; }
.skill-demand-card { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 12px 32px rgba(0, 0, 0, .08); }
.skill-demand-card.is-gap { border-left: 3px solid var(--warning); }
.skill-demand-main { min-width: 0; display: grid; gap: 13px; padding: 20px 22px; }
.skill-card-heading { display: flex; align-items: start; justify-content: space-between; gap: 16px; }
.skill-card-heading h3 { margin: 4px 0 0; font-size: 21px; }
.skill-category { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .07em; }
.demand-meter { height: 7px; overflow: hidden; border-radius: 999px; background: var(--surface-high); }
.demand-meter span { display: block; min-width: 3px; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--primary), var(--primary-strong)); }
.skill-demand-stats { display: flex; flex-wrap: wrap; gap: 8px 18px; color: var(--muted); font-size: 12px; }
.skill-demand-stats strong { color: var(--text); }
.learning-focus { display: grid; gap: 3px; margin: 0; padding: 12px 14px; border-left: 2px solid var(--warning); background: var(--warning-soft); color: var(--text-soft); font-size: 13px; }
.skill-job-examples { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
.skill-job-examples li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding-top: 7px; border-top: 1px solid var(--border); font-size: 12px; }
.skill-job-examples a, .skill-job-examples span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.skill-job-examples span { color: var(--muted); }
.skill-demand-action { min-width: 174px; display: grid; place-content: center; padding: 20px; border-left: 1px solid var(--border); background: var(--surface-low); }
.skills-method-rail { position: sticky; top: calc(var(--utility) + 20px); display: grid; gap: 14px; }
.legend-list { display: grid; gap: 12px; margin: 16px 0 0; padding: 0; list-style: none; }
.legend-list li { display: flex; align-items: start; gap: 10px; }
.legend-list li > div { display: grid; gap: 2px; }
.legend-list small { color: var(--muted); line-height: 1.4; }
.legend-dot { width: 9px; height: 9px; flex: 0 0 auto; margin-top: 5px; border-radius: 50%; }
.legend-dot.exact { background: var(--positive); }
.legend-dot.inferred { border: 2px solid var(--primary); }
@media (min-width: 1850px) {
.skills-insight-layout { grid-template-columns: minmax(0, 1fr) 370px; gap: 34px; }
.skill-demand-list { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
.skill-demand-card { height: 100%; grid-template-columns: 1fr; }
.skill-demand-action { min-width: 0; place-content: stretch; padding: 0 22px 20px; border: 0; background: transparent; }
}
@media (max-width: 1020px) {
.insight-hero { grid-template-columns: 104px minmax(0, 1fr); }
.insight-hero > .button { grid-column: 2; justify-self: start; }
.skill-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.skills-insight-layout { grid-template-columns: 1fr; }
.skills-method-rail { position: static; grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 680px) {
.insight-hero { grid-template-columns: 1fr; padding: 20px; }
.insight-orbit { width: 88px; height: 88px; }
.insight-hero > .button { grid-column: auto; justify-self: stretch; }
.skill-metrics, .skills-method-rail { grid-template-columns: 1fr; }
.skills-filter-console { grid-template-columns: 1fr; }
.skill-demand-card { grid-template-columns: 1fr; }
.skill-demand-main { padding: 18px; }
.skill-demand-action { min-width: 0; place-content: stretch; padding: 0 18px 18px; border: 0; background: var(--surface); }
.skill-card-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
.skill-job-examples li { grid-template-columns: 1fr; gap: 2px; }
}
+3
View File
@@ -34,6 +34,9 @@
<a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}active{% endif %}" href="{% url 'jobs:list' %}">
<svg class="icon"><use href="#icon-search"></use></svg><span>Vacatures<small>zoeken en filteren</small></span>
</a>
<a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'skill-insights' %}active{% endif %}" href="{% url 'jobs:skill-insights' %}">
<svg class="icon"><use href="#icon-radar"></use></svg><span>Skillsradar<small>vraag en leerkansen</small></span>
</a>
<a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'applications' or request.resolver_match.url_name == 'application-edit' %}active{% endif %}" href="{% url 'jobs:applications' %}">
<svg class="icon"><use href="#icon-pipeline"></use></svg><span>Sollicitaties<small>persoonlijke pipeline</small></span>
</a>
+1 -1
View File
@@ -61,7 +61,7 @@
<aside class="jobs-insight-rail" aria-label="Radarcontext">
<section class="panel rail-panel radar-summary"><span class="section-kicker">Radarbereik</span><h2>IT-focus</h2><p class="muted">Titels krijgen eerst een deterministische sectorcontrole. AI kan deze grens niet wijzigen.</p><div class="rail-list"><div class="rail-item"><span>Actieve IT-vacatures</span><strong>{{ it_job_count }}</strong></div><div class="rail-item"><span>Niet-IT uit beeld</span><strong>{{ filtered_non_it_count }}</strong></div><div class="rail-item"><span>Volledig actief archief</span><strong>{{ active_job_count }}</strong></div></div><a class="button button-ghost button-block" href="?focus={% if current_focus == 'all' %}it{% else %}all{% endif %}&match={% if current_focus == 'all' %}radar{% else %}all{% endif %}&status=active&sort=match">{% if current_focus == 'all' %}Terug naar IT-focus{% else %}Volledig archief openen{% endif %}</a></section>
<section class="panel rail-panel"><span class="section-kicker">Actief zoekprofiel</span><h2>{{ active_profile.name|default:"Geen profiel actief" }}</h2>{% if active_profile %}<div class="profile-signal-grid"><div><span>Rollen</span><strong>{{ active_profile.desired_titles|length }}</strong></div><div><span>Skills</span><strong>{{ active_profile.desired_skills|length }}</strong></div><div><span>Radius</span><strong>{{ active_profile.max_distance_km }} km</strong></div><div><span>Vanaf</span><strong>{{ active_profile.recommendation_threshold }}%</strong></div></div><a class="button button-secondary button-block" href="{% url 'profiles:edit' active_profile.pk %}">Profiel verfijnen</a>{% else %}<p class="muted">Activeer een zoekprofiel om persoonlijke scores te berekenen.</p>{% endif %}</section>
<section class="panel rail-panel"><span class="section-kicker">Actief zoekprofiel</span><h2>{{ active_profile.name|default:"Geen profiel actief" }}</h2>{% if active_profile %}<div class="profile-signal-grid"><div><span>Rollen</span><strong>{{ active_profile.desired_titles|length }}</strong></div><div><span>Skills</span><strong>{{ active_profile.desired_skills|length }}</strong></div><div><span>Radius</span><strong>{{ active_profile.max_distance_km }} km</strong></div><div><span>Vanaf</span><strong>{{ active_profile.recommendation_threshold }}%</strong></div></div><div class="stack-actions"><a class="button button-secondary button-block" href="{% url 'jobs:skill-insights' %}">Bekijk vraag & leerkansen</a><a class="button button-ghost button-block" href="{% url 'profiles:edit' active_profile.pk %}">Profiel verfijnen</a></div>{% else %}<p class="muted">Activeer een zoekprofiel om persoonlijke scores te berekenen.</p>{% endif %}</section>
<section class="panel rail-panel"><span class="section-kicker">Hoe rangschikking werkt</span><h2>Deterministisch eerst</h2><ol class="radar-steps"><li><strong>IT-titel</strong><span>Sectorfilter op functietitel.</span></li><li><strong>Harde regels</strong><span>Afstand en uitsluitingen winnen altijd.</span></li><li><strong>Matchscore</strong><span>Rollen, skills en voorkeuren bepalen de volgorde.</span></li></ol></section>
</aside>
</div>
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Skillsradar · VacatureRadar{% endblock %}
{% block content %}
<header class="page-header skills-page-header">
<div>
<p class="eyebrow">Marktvraag · persoonlijk leerkompas</p>
<h1>Skillsradar</h1>
<p>Zie wat in je actuele IT-radar terugkomt, wat je profiel al dekt en welke technologieën een gerichte bijscholing waard kunnen zijn.</p>
</div>
{% if active_profile %}<div class="radar-status" aria-label="Analysebasis"><span class="signal-dot" aria-hidden="true"></span><span>{{ active_profile.name }}</span><span aria-hidden="true">·</span><strong>profiel v{{ active_profile.version }}</strong></div>{% endif %}
</header>
{% if report %}
<section class="insight-hero" aria-labelledby="insight-summary-title">
<div class="insight-orbit" aria-hidden="true"><span></span><span></span><span></span><strong>{{ report.coverage_percent }}%</strong></div>
<div>
<span class="section-kicker">Jouw dekking van de zichtbare vraag</span>
<h2 id="insight-summary-title">{{ report.covered_count }} van {{ report.signal_count }} gevraagde skills staan al in je profiel</h2>
<p>Gebaseerd op {{ report.total_jobs }} actieve, IT-relevante en niet-uitgesloten vacature{{ report.total_jobs|pluralize:"s" }}. Ervaringsjaren tellen nergens mee in deze analyse of je matchscore.</p>
</div>
<a class="button button-secondary" href="{% url 'profiles:edit' active_profile.pk %}">Skills in profiel beheren</a>
</section>
<section class="skill-metrics" aria-label="Samenvatting skillsvraag">
<article><span>Vacatures onderzocht</span><strong>{{ report.total_jobs }}</strong><small>huidige profielversie</small></article>
<article><span>Met herkenbaar signaal</span><strong>{{ report.jobs_with_signals }}</strong><small>titel, skillveld of tekst</small></article>
<article><span>Unieke skills gevraagd</span><strong>{{ report.signal_count }}</strong><small>deterministisch herkend</small></article>
<article class="metric-accent"><span>Mogelijke leerkansen</span><strong>{{ report.gap_count }}</strong><small>gevraagd, niet aangevinkt</small></article>
</section>
<form method="get" class="filter-console skills-filter-console">
<label><span class="field-label">Skilldomein</span><select name="category"><option value="all">Alle domeinen</option>{% for value,label in report.categories %}<option value="{{ value }}" {% if report.selected_category == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label><span class="field-label">Profieldekking</span><select name="coverage"><option value="all" {% if report.selected_coverage == 'all' %}selected{% endif %}>Alles</option><option value="gap" {% if report.selected_coverage == 'gap' %}selected{% endif %}>Nog niet in profiel</option><option value="covered" {% if report.selected_coverage == 'covered' %}selected{% endif %}>Al in profiel</option></select></label>
<button class="button button-primary" type="submit">Analyse filteren</button>
{% if report.selected_category != 'all' or report.selected_coverage != 'all' %}<a class="button button-ghost" href="{% url 'jobs:skill-insights' %}">Filters wissen</a>{% endif %}
</form>
<div class="skills-insight-layout">
<section aria-labelledby="demand-heading">
<div class="results-toolbar"><div><span class="section-kicker">Vraagindicatoren</span><h2 id="demand-heading">{{ report.items|length }} skills in deze selectie</h2></div><span class="view-indicator">gesorteerd op vacaturevolume</span></div>
<div class="skill-demand-list">
{% for item in report.items %}
<article class="skill-demand-card {% if not item.covered %}is-gap{% endif %}">
<div class="skill-demand-main">
<div class="skill-card-heading">
<div><span class="skill-category">{{ item.category }}</span><h3>{{ item.label }}</h3></div>
<span class="pill {% if item.covered %}pill-success{% else %}pill-review{% endif %}">{% if item.covered %}In je profiel{% else %}Leerkans{% endif %}</span>
</div>
<div class="demand-meter" aria-label="Komt voor in {{ item.share_percent }} procent van de onderzochte vacatures"><span style="width: {{ item.share_percent }}%"></span></div>
<div class="skill-demand-stats"><strong>{{ item.vacancy_count }} vacature{{ item.vacancy_count|pluralize:"s" }}</strong><span>{{ item.share_percent }}% van je radar</span><span>{{ item.explicit_count }} in skillveld · {{ item.inferred_count }} via titel/tekst</span></div>
{% if not item.covered %}<p class="learning-focus"><strong>Mogelijke volgende stap</strong>{{ item.learning_focus }}</p>{% endif %}
{% if item.examples %}<ul class="skill-job-examples">{% for example in item.examples %}<li><a href="{% url 'jobs:detail' example.id %}">{{ example.title }}</a><span>{{ example.employer }}</span></li>{% endfor %}</ul>{% endif %}
</div>
<div class="skill-demand-action"><a class="button button-ghost" href="{% url 'jobs:list' %}?q={{ item.search_term|urlencode }}&status=active&focus=it&match=radar&sort=match">Vacatures bekijken</a></div>
</article>
{% empty %}
<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-radar"></use></svg></div><h2>Geen skills binnen deze filters</h2><p>Wis de filters of kies een ander skilldomein. De onderliggende vacatures blijven ongewijzigd.</p><a class="button button-secondary" href="{% url 'jobs:skill-insights' %}">Alle signalen tonen</a></div>
{% endfor %}
</div>
</section>
<aside class="skills-method-rail" aria-label="Uitleg bij de skillsanalyse">
<section class="panel rail-panel"><span class="section-kicker">Wat betekent dit?</span><h2>Vraag, geen afwijzingsregel</h2><p class="muted">Een leerkans is een skill die in minstens één zichtbare vacature wordt genoemd maar nog niet in je profiel staat. Dit is nooit een blokkade en zegt niet dat je zonder die skill kansloos bent.</p></section>
<section class="panel rail-panel"><span class="section-kicker">Bewijskwaliteit</span><h2>Feit versus tekstsignaal</h2><ul class="legend-list"><li><span class="legend-dot exact"></span><div><strong>Skillveld</strong><small>Door de bron als vereiste of voorkeur aangeleverd.</small></div></li><li><span class="legend-dot inferred"></span><div><strong>Titel/tekst</strong><small>Deterministisch aangetroffen; mogelijk context en geen harde eis.</small></div></li></ul></section>
<section class="panel rail-panel"><span class="section-kicker">Selectiegrens</span><h2>Alleen jouw radar</h2><ol class="radar-steps"><li><strong>Actief</strong><span>Geen verlopen of verwijderde vacatures.</span></li><li><strong>IT-relevant</strong><span>Dezelfde deterministische IT-grens als de vacaturelijst.</span></li><li><strong>Niet uitgesloten</strong><span>Alleen scores van je huidige profielversie.</span></li></ol></section>
</aside>
</div>
{% else %}
<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-profile"></use></svg></div><h2>Geen actief zoekprofiel</h2><p>Activeer eerst een profiel. De skillsradar blijft persoonlijk en mengt nooit gegevens van andere gebruikers.</p><a class="button button-primary" href="{% url 'profiles:list' %}">Profielen bekijken</a></div>
{% endif %}
{% endblock %}
+2 -2
View File
@@ -7,14 +7,14 @@
{% if form.non_field_errors %}<div class="message error">{{ form.non_field_errors }}</div>{% endif %}
<div class="form-grid">
{% for field in form %}
{% if field.name == 'name' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><h2>Profielbasis</h2><p>Je postcode volstaat; technische locatievelden worden op de achtergrond afgeleid.</p></div></div>{% elif field.name == 'desired_titles' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-briefcase"></use></svg></span><div><h2>Gewenste rollen & technologie</h2><p>Kies herkenbare opties. Uitsluitingen blijven altijd doorslaggevend.</p></div></div>{% elif field.name == 'allowed_employment_types' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-location"></use></svg></span><div><h2>Werkvorm, regio & reistijd</h2><p>Selecteer contractvormen, werkmodellen en regio's zonder technische notatie.</p></div></div>{% elif field.name == 'recommendation_threshold' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-radar"></use></svg></span><div><h2>Scoring & automatisering</h2><p>Een topmatch ligt altijd boven de gewone aanbevelingsdrempel.</p></div></div>{% endif %}
{% if field.name == 'name' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><h2>Profielbasis</h2><p>Je postcode volstaat voor de afstand. De ervaringsband is alleen profielcontext en heeft geen invloed op score of uitsluiting.</p></div></div>{% elif field.name == 'desired_titles' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-briefcase"></use></svg></span><div><h2>Gewenste rollen & technologie</h2><p>Kies per categorie wat echt bij je profiel past. De beste titelmatch en maximaal vier aangetroffen skills bepalen de inhoudelijke aansluiting.</p></div></div>{% elif field.name == 'allowed_employment_types' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-location"></use></svg></span><div><h2>Werkvorm, regio & reistijd</h2><p>Selecteer contractvormen, werkmodellen en regio's zonder technische notatie.</p></div></div>{% elif field.name == 'recommendation_threshold' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-radar"></use></svg></span><div><h2>Scoring & automatisering</h2><p>Een topmatch ligt altijd boven de gewone aanbevelingsdrempel.</p></div></div>{% endif %}
{% if field.field.widget.allow_multiple_selected %}<fieldset class="choice-field span-2"><legend>{{ field.label }}</legend>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset>{% else %}<label><span>{{ field.label }}</span>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endif %}
{% endfor %}
</div>
<div class="form-actions"><a class="button button-ghost" href="{% url 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Instellingen opslaan</button></div>
</form>
<aside class="editor-rail" aria-label="Profielsamenvatting">
<section class="panel radar-preview"><div class="title-row"><span class="section-kicker">Radarprofiel</span><span class="pill {% if object.is_active %}pill-active{% endif %}">{% if object.is_active %}Live{% else %}Pauze{% endif %}</span></div><h2>{{ object.name }}</h2><p class="muted">De huidige opgeslagen configuratie.</p><div class="preview-score"><strong>{{ object.top_match_threshold }}%</strong><span>Topmatch vanaf</span></div><div class="rail-list"><div class="rail-item"><span>Thuislocatie</span><strong>{% if object.home_postal_code %}{{ object.home_postal_code }}{% if object.home_municipality %} · {{ object.home_municipality }}{% endif %}{% else %}Niet ingesteld{% endif %}</strong></div><div class="rail-item"><span>Gewenste rollen</span><strong>{{ object.desired_titles|length }}</strong></div><div class="rail-item"><span>Gewenste skills</span><strong>{{ object.desired_skills|length }}</strong></div><div class="rail-item"><span>Maximumafstand</span><strong>{{ object.max_distance_km }} km</strong></div><div class="rail-item"><span>Gecontroleerd leren</span><strong>{% if object.learning_enabled %}Aan{% else %}Uit{% endif %}</strong></div></div></section>
<section class="panel radar-preview"><div class="title-row"><span class="section-kicker">Radarprofiel</span><span class="pill {% if object.is_active %}pill-active{% endif %}">{% if object.is_active %}Live{% else %}Pauze{% endif %}</span></div><h2>{{ object.name }}</h2><p class="muted">De huidige opgeslagen configuratie.</p><div class="preview-score"><strong>{{ object.top_match_threshold }}%</strong><span>Topmatch vanaf</span></div><div class="rail-list"><div class="rail-item"><span>Thuislocatie</span><strong>{% if object.home_postal_code %}{{ object.home_postal_code }}{% if object.home_municipality %} · {{ object.home_municipality }}{% endif %}{% else %}Niet ingesteld{% endif %}</strong></div><div class="rail-item"><span>IT-ervaring · informatief</span><strong>{{ object.get_experience_years_display }}</strong></div><div class="rail-item"><span>Gewenste rollen</span><strong>{{ object.desired_titles|length }}</strong></div><div class="rail-item"><span>Gewenste skills</span><strong>{{ object.desired_skills|length }}</strong></div><div class="rail-item"><span>Maximumafstand</span><strong>{{ object.max_distance_km }} km</strong></div><div class="rail-item"><span>Gecontroleerd leren</span><strong>{% if object.learning_enabled %}Aan{% else %}Uit{% endif %}</strong></div></div></section>
<section class="panel rail-panel"><span class="section-kicker">Veilige grenzen</span><h2>Deterministisch eerst</h2><p class="muted">Harde uitsluitingen en bronbeleid veranderen niet door AI of feedback.</p><button class="button button-primary button-block" type="submit" form="profile-form">Instellingen opslaan</button></section>
</aside>
</div>
+15 -2
View File
@@ -5,6 +5,7 @@ from django.urls import reverse
from apps.jobs.models import GeocodeLocationLookup
from apps.profiles.forms import SearchProfileForm
from apps.profiles.taxonomy import iter_choices
def _profile_post_data(**overrides):
@@ -13,6 +14,7 @@ def _profile_post_data(**overrides):
"is_active": "on",
"home_postal_code": "3500",
"max_distance_km": "45",
"experience_years": "10",
"desired_titles": ["infrastructure engineer", "cloud engineer"],
"excluded_titles": ["sales"],
"desired_skills": ["azure", "terraform"],
@@ -38,8 +40,18 @@ def test_profile_form_uses_guided_choices_and_preserves_existing_custom_values(p
assert "home_latitude" not in form.fields
assert "home_longitude" not in form.fields
assert form.fields["desired_titles"].widget.allow_multiple_selected is True
assert ("systeembeheerder", "systeembeheerder") in form.fields["desired_titles"].choices
assert ("VMware", "VMware") in form.fields["desired_skills"].choices
title_choices = list(iter_choices(form.fields["desired_titles"].choices))
skill_choices = list(iter_choices(form.fields["desired_skills"].choices))
assert ("it field engineer", "IT Field Engineer") in title_choices
assert ("system network engineer", "System & Network Engineer") in title_choices
assert ("systeembeheerder", "Systeembeheerder") in title_choices
assert ("vmware", "VMware") in skill_choices
assert ("microsoft copilot", "Microsoft Copilot") in skill_choices
assert ("fortinet", "Fortinet / FortiGate") in skill_choices
assert ("itil", "ITIL / IT-servicemanagement") in skill_choices
assert form.fields["desired_titles"].widget.attrs["class"] == "grouped-choices"
assert form.fields["experience_years"].widget.allow_multiple_selected is False
assert "nooit mee" in form.fields["experience_years"].help_text
@pytest.mark.django_db
@@ -83,6 +95,7 @@ def test_profile_update_derives_home_location_from_postcode(client, user, profil
assert profile.home_postal_code == "3500"
assert profile.home_municipality == "Hasselt"
assert profile.home_latitude == Decimal("50.930700")
assert profile.experience_years == 10
assert profile.desired_titles == ["infrastructure engineer", "cloud engineer"]
assert profile.preferred_workplace == ["hybrid"]
+88
View File
@@ -0,0 +1,88 @@
import pytest
from django.urls import reverse
from apps.jobs.models import JobPosting, ScoreRun
@pytest.mark.integration
@pytest.mark.django_db
def test_skill_insights_require_login(client):
response = client.get(reverse("jobs:skill-insights"))
assert response.status_code == 302
assert reverse("login") in response.url
@pytest.mark.integration
@pytest.mark.django_db
def test_skill_insights_use_current_profile_and_exclude_hidden_jobs(client, user, profile, job):
job.skills_required = ["Microsoft 365"]
job.description_text = "Microsoft 365, Dynamics 365 en Microsoft Copilot governance."
job.save(update_fields=["skills_required", "description_text"])
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=74,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
hidden = JobPosting.objects.create(
employer=job.employer,
original_title="Network Engineer buiten bereik",
normalized_title="network engineer buiten bereik",
canonical_url="https://jobs.example.org/vacatures/network-hidden",
canonical_key="1" * 64,
content_hash="2" * 64,
description_text="Beheer FortiGate-firewalls.",
status=JobPosting.Status.ACTIVE,
)
ScoreRun.objects.create(
job=hidden,
profile=profile,
profile_version=profile.version,
score=80,
confidence=0.9,
recommendation=ScoreRun.Recommendation.HIDDEN,
hard_exclusions=["Buiten de zoekradius."],
)
client.force_login(user)
response = client.get(reverse("jobs:skill-insights"))
body = response.content.decode()
assert response.status_code == 200
assert "Skillsradar" in body
assert "Microsoft 365" in body
assert "Microsoft Dynamics 365" in body
assert "Microsoft Copilot" in body
assert "Fortinet / FortiGate" not in body
assert "Ervaringsjaren tellen nergens mee" in body
@pytest.mark.integration
@pytest.mark.django_db
def test_skill_insights_offer_category_and_gap_filters(client, user, profile, job):
job.skills_required = ["Microsoft 365", "FortiGate"]
job.description_text = "Microsoft 365 en FortiGate-beheer."
job.save(update_fields=["skills_required", "description_text"])
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=74,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
client.force_login(user)
response = client.get(
reverse("jobs:skill-insights"),
{"category": "netwerk-security", "coverage": "gap"},
)
body = response.content.decode()
assert response.status_code == 200
assert "Fortinet / FortiGate" in body
assert "Microsoft 365" not in body
assert "Leerkans" in body
+2 -2
View File
@@ -14,7 +14,7 @@ def test_learning_signals_wait_for_min_samples(profile, job, user):
assert first_metadata.get("reason_code") == "positive_feedback"
assert second_metadata.get("status") == "applied"
profile.refresh_from_db()
assert profile.weights["content"] == 26.0
assert profile.weights["content"] == 31.0
def test_learning_skip_for_implicit_hide(profile, job, user):
@@ -23,7 +23,7 @@ def test_learning_skip_for_implicit_hide(profile, job, user):
assert metadata["status"] == "queued"
assert metadata["reason_code"] == "implicit_hide_no_reason"
profile.refresh_from_db()
assert profile.weights["content"] == 25.0
assert profile.weights["content"] == 30.0
def test_learning_marks_explicit_title_hide_as_nonlearning(profile, job, user):
+3
View File
@@ -13,6 +13,9 @@ def test_profile_validation_snapshot_activation_and_revisions(user, profile):
snapshot = profile.snapshot()
assert snapshot["home_municipality"] == "Hasselt"
assert snapshot["home_latitude"] == pytest.approx(50.9307)
assert snapshot["experience_years"] == 0
assert "seniority" not in profile.weights
assert sum(profile.weights.values()) == 100
other = SearchProfile.objects.create(user=user, name="Tweede", is_active=False)
other.activate()
+36 -1
View File
@@ -1,4 +1,11 @@
from apps.jobs.services.relevance import assess_it_relevance, profile_requires_it_focus
import pytest
from apps.jobs.models import JobPosting
from apps.jobs.services.relevance import (
assess_it_relevance,
it_relevance_query,
profile_requires_it_focus,
)
def test_title_led_it_relevance_accepts_clear_it_roles():
@@ -30,6 +37,34 @@ def test_title_led_it_relevance_rejects_non_it_and_ambiguous_roles():
assert assessment.reason
def test_ambiguous_field_role_requires_strong_it_context():
accepted = assess_it_relevance(
"Field Service Engineer",
"Installatie van Microsoft 365, Windows Server en netwerkswitches bij klanten.",
)
rejected = assess_it_relevance(
"Field Service Engineer",
"Onderhoud en herstelling van industriële HVAC- en koelinstallaties.",
)
assert accepted.relevant
assert "vacaturetekst" in accepted.reason
assert not rejected.relevant
assert "Ambigue technische functietitel" in rejected.reason
@pytest.mark.django_db
def test_database_it_query_matches_contextual_assessment(job):
job.original_title = "Field Service Engineer"
job.description_text = "Implementeer Intune, Windows Server en VPN-oplossingen bij klanten."
job.save(update_fields=["original_title", "description_text"])
assert JobPosting.objects.filter(it_relevance_query(), pk=job.pk).exists()
job.description_text = "Onderhoud industriële pompen en productielijnen."
job.save(update_fields=["description_text"])
assert not JobPosting.objects.filter(it_relevance_query(), pk=job.pk).exists()
def test_active_it_profile_requires_deterministic_it_focus(profile):
assert profile_requires_it_focus(profile)
+87
View File
@@ -55,6 +55,72 @@ def test_matching_job_gets_recommendation(matching_job, profile):
assert not result.hard_exclusions
def test_broad_relevant_skill_profile_is_not_diluted_without_limit(matching_job, profile):
profile.desired_skills = [
"microsoft 365",
"vmware",
"windows server",
"active directory",
"intune",
"networking",
"veeam",
"powershell",
"3cx",
"zabbix",
]
result = calculate_score(matching_job, profile)
assert result.components["skills"] == 12.5
assert result.evidence["skills"]["configured_count"] == 10
assert result.evidence["skills"]["match_target"] == 4
assert not any("Niet duidelijk teruggevonden" in concern for concern in result.concerns)
def test_skill_aliases_match_common_product_names(matching_job, profile):
profile.desired_skills = ["microsoft 365", "entra id", "group policy", "vmware"]
matching_job.skills_required = []
matching_job.skills_preferred = []
matching_job.description_text = "Beheer M365, Azure AD, GPO en vSphere voor meerdere klanten."
result = calculate_score(matching_job, profile)
assert result.components["skills"] == 25.0
assert result.evidence["skills"]["matched"] == [
"entra id",
"group policy",
"microsoft 365",
"vmware",
]
def test_experience_never_changes_score_or_recommendation(matching_job, profile):
profile.experience_years = 10
matching_job.analysis_features = {
**matching_job.analysis_features,
"experience_years_max": 8,
}
experienced = calculate_score(matching_job, profile)
profile.experience_years = 3
less_experienced = calculate_score(matching_job, profile)
assert experienced.score == less_experienced.score
assert experienced.recommendation == less_experienced.recommendation
assert "seniority" not in experienced.components
assert "experience" not in experienced.evidence
assert not any("ervaring" in concern.lower() for concern in less_experienced.concerns)
def test_ai_seniority_label_is_informational_only():
base = {"support_ratio": 0.1, "consultancy_ratio": 0.2, "travel_ratio": 0.0}
junior = scoring._ai_feature_score({**base, "seniority": "junior"})
expert = scoring._ai_feature_score({**base, "seniority": "expert"})
assert junior == expert
def test_hard_title_exclusion_wins(matching_job, profile):
matching_job.original_title = "IT Sales Infrastructure Engineer"
matching_job.save(update_fields=["original_title"])
@@ -63,6 +129,27 @@ def test_hard_title_exclusion_wins(matching_job, profile):
assert any("sales" in reason.lower() for reason in result.hard_exclusions)
def test_short_title_exclusion_uses_word_boundaries(matching_job, profile):
profile.excluded_titles = ["ai"]
matching_job.original_title = "Maintenance IT Engineer"
allowed = calculate_score(matching_job, profile)
matching_job.original_title = "AI Engineer"
excluded = calculate_score(matching_job, profile)
assert not any("Uitgesloten titelterm" in reason for reason in allowed.hard_exclusions)
assert any("Uitgesloten titelterm: ai" in reason for reason in excluded.hard_exclusions)
def test_title_exclusion_matches_simple_english_plural(matching_job, profile):
profile.excluded_titles = ["developer"]
matching_job.original_title = "Full Stack Developers"
result = calculate_score(matching_job, profile)
assert any("Uitgesloten titelterm: developer" in reason for reason in result.hard_exclusions)
def test_non_it_vacancy_is_hidden_for_it_profile(matching_job, profile):
matching_job.original_title = "Spontane sollicitatie - logopedist"
matching_job.normalized_title = "spontane sollicitatie logopedist"
+80
View File
@@ -0,0 +1,80 @@
import pytest
from apps.jobs.models import JobSourceAlias, ScoreRun
from apps.jobs.services.skill_demand import build_skill_demand_report
from apps.sources.models import Source
@pytest.mark.django_db
def test_skill_demand_distinguishes_structured_evidence_and_text_signals(job, profile):
job.skills_required = ["Microsoft 365"]
job.skills_preferred = []
job.description_text = (
"Bouw governance voor Dynamics 365 en Microsoft Copilot. "
"Zorg ook voor high availability en gebruikersadoptie."
)
report = build_skill_demand_report(profile, jobs=[job])
by_key = {item.key: item for item in report.items}
assert by_key["microsoft 365"].covered is True
assert by_key["microsoft 365"].explicit_count == 1
assert by_key["dynamics 365"].covered is False
assert by_key["dynamics 365"].inferred_count == 1
assert by_key["microsoft copilot"].vacancy_count == 1
assert by_key["high availability"].learning_focus
assert report.gap_count >= 3
@pytest.mark.django_db
def test_skill_demand_filters_category_and_profile_coverage(job, profile):
job.skills_required = ["Microsoft 365", "FortiGate"]
job.description_text = "Microsoft 365 en FortiGate-beheer."
gaps = build_skill_demand_report(
profile,
category="netwerk-security",
coverage="gap",
jobs=[job],
)
assert [item.key for item in gaps.items] == ["fortinet"]
assert gaps.selected_category == "netwerk-security"
assert gaps.selected_coverage == "gap"
@pytest.mark.django_db
def test_generic_team_word_is_not_microsoft_teams_demand(job, profile):
job.skills_required = []
job.skills_preferred = []
job.description_text = "Je werkt samen met internationale teams aan klanttevredenheid."
report = build_skill_demand_report(profile, jobs=[job])
assert "microsoft teams" not in {item.key for item in report.items}
@pytest.mark.django_db
def test_disabled_source_vacancies_do_not_feed_learning_advice(job, profile, source):
JobSourceAlias.objects.create(
job=job,
source=source,
external_id="disabled-source-job",
url=job.canonical_url,
canonical_url=job.canonical_url,
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=75,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
source.status = Source.Status.DISABLED
source.save(update_fields=["status"])
report = build_skill_demand_report(profile)
assert report.total_jobs == 0
assert report.signal_count == 0