feat: personalize scoring and add skills radar
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"] = (
|
||||
|
||||
@@ -22,7 +22,6 @@ LEARNING_FEATURES = {
|
||||
"location",
|
||||
"conditions",
|
||||
"employer",
|
||||
"seniority",
|
||||
"preferences",
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 10–14 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user