diff --git a/CHANGELOG.md b/CHANGELOG.md index c1fa1bf..aef561d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index 135ee63..7aa2efe 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -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"] = ( diff --git a/apps/jobs/services/feedback.py b/apps/jobs/services/feedback.py index 9c5c1d1..106a803 100644 --- a/apps/jobs/services/feedback.py +++ b/apps/jobs/services/feedback.py @@ -22,7 +22,6 @@ LEARNING_FEATURES = { "location", "conditions", "employer", - "seniority", "preferences", } diff --git a/apps/jobs/services/normalization.py b/apps/jobs/services/normalization.py index f6ea05c..4541d78 100644 --- a/apps/jobs/services/normalization.py +++ b/apps/jobs/services/normalization.py @@ -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", diff --git a/apps/jobs/services/relevance.py b/apps/jobs/services/relevance.py index fe429f7..1a7a986 100644 --- a/apps/jobs/services/relevance.py +++ b/apps/jobs/services/relevance.py @@ -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} ) diff --git a/apps/jobs/services/scoring.py b/apps/jobs/services/scoring.py index 3167668..72730a8 100644 --- a/apps/jobs/services/scoring.py +++ b/apps/jobs/services/scoring.py @@ -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, diff --git a/apps/jobs/services/skill_demand.py b/apps/jobs/services/skill_demand.py new file mode 100644 index 0000000..e866c44 --- /dev/null +++ b/apps/jobs/services/skill_demand.py @@ -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, + ) diff --git a/apps/jobs/services/skill_terms.py b/apps/jobs/services/skill_terms.py new file mode 100644 index 0000000..3fd5de8 --- /dev/null +++ b/apps/jobs/services/skill_terms.py @@ -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"(? 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 diff --git a/apps/jobs/urls.py b/apps/jobs/urls.py index c5fd904..3198a02 100644 --- a/apps/jobs/urls.py +++ b/apps/jobs/urls.py @@ -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//", ApplicationUpdateView.as_view(), name="application-edit"), path("applications//export/", application_export, name="application-export"), diff --git a/apps/jobs/views.py b/apps/jobs/views.py index 717f748..de3ddca 100644 --- a/apps/jobs/views.py +++ b/apps/jobs/views.py @@ -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): diff --git a/apps/profiles/defaults.py b/apps/profiles/defaults.py index b3c6e3c..dbeb07a 100644 --- a/apps/profiles/defaults.py +++ b/apps/profiles/defaults.py @@ -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, } diff --git a/apps/profiles/forms.py b/apps/profiles/forms.py index 1dcd167..479a3c3 100644 --- a/apps/profiles/forms.py +++ b/apps/profiles/forms.py @@ -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 ] diff --git a/apps/profiles/migrations/0005_searchprofile_experience_years.py b/apps/profiles/migrations/0005_searchprofile_experience_years.py new file mode 100644 index 0000000..cf01c3f --- /dev/null +++ b/apps/profiles/migrations/0005_searchprofile_experience_years.py @@ -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), + ), + ] diff --git a/apps/profiles/migrations/0006_remove_seniority_score_weight.py b/apps/profiles/migrations/0006_remove_seniority_score_weight.py new file mode 100644 index 0000000..0bf3b7d --- /dev/null +++ b/apps/profiles/migrations/0006_remove_seniority_score_weight.py @@ -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), + ] diff --git a/apps/profiles/models.py b/apps/profiles/models.py index a5b9c67..8730494 100644 --- a/apps/profiles/models.py +++ b/apps/profiles/models.py @@ -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, diff --git a/apps/profiles/taxonomy.py b/apps/profiles/taxonomy.py new file mode 100644 index 0000000..6bc6c27 --- /dev/null +++ b/apps/profiles/taxonomy.py @@ -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) diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml index 349747a..1df90db 100644 --- a/docs/ai/BACKLOG.yaml +++ b/docs/ai/BACKLOG.yaml @@ -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 diff --git a/docs/ai/PROJECT_STATE.md b/docs/ai/PROJECT_STATE.md index 5e03a68..026ae60 100644 --- a/docs/ai/PROJECT_STATE.md +++ b/docs/ai/PROJECT_STATE.md @@ -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. diff --git a/docs/architecture/SCORING_ENGINE.md b/docs/architecture/SCORING_ENGINE.md index cc50228..cf06bc7 100644 --- a/docs/architecture/SCORING_ENGINE.md +++ b/docs/architecture/SCORING_ENGINE.md @@ -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. diff --git a/docs/product/PRODUCT_REQUIREMENTS.md b/docs/product/PRODUCT_REQUIREMENTS.md index 9a4bd2a..d2944ef 100644 --- a/docs/product/PRODUCT_REQUIREMENTS.md +++ b/docs/product/PRODUCT_REQUIREMENTS.md @@ -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. diff --git a/docs/quality/TRACEABILITY_MATRIX.md b/docs/quality/TRACEABILITY_MATRIX.md index 794a931..d6f6b3b 100644 --- a/docs/quality/TRACEABILITY_MATRIX.md +++ b/docs/quality/TRACEABILITY_MATRIX.md @@ -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. diff --git a/static/css/app.css b/static/css/app.css index 01e4250..19e4883 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; } +} diff --git a/templates/base.html b/templates/base.html index 1e098ae..29d8613 100644 --- a/templates/base.html +++ b/templates/base.html @@ -34,6 +34,9 @@ Vacatureszoeken en filteren + + Skillsradarvraag en leerkansen + Sollicitatiespersoonlijke pipeline diff --git a/templates/jobs/list.html b/templates/jobs/list.html index 38914d0..34a8344 100644 --- a/templates/jobs/list.html +++ b/templates/jobs/list.html @@ -61,7 +61,7 @@ diff --git a/templates/jobs/skill_insights.html b/templates/jobs/skill_insights.html new file mode 100644 index 0000000..e4234c9 --- /dev/null +++ b/templates/jobs/skill_insights.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}Skillsradar · VacatureRadar{% endblock %} +{% block content %} + + +{% if report %} +
+ +
+ Jouw dekking van de zichtbare vraag +

{{ report.covered_count }} van {{ report.signal_count }} gevraagde skills staan al in je profiel

+

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.

+
+ Skills in profiel beheren +
+ +
+
Vacatures onderzocht{{ report.total_jobs }}huidige profielversie
+
Met herkenbaar signaal{{ report.jobs_with_signals }}titel, skillveld of tekst
+
Unieke skills gevraagd{{ report.signal_count }}deterministisch herkend
+
Mogelijke leerkansen{{ report.gap_count }}gevraagd, niet aangevinkt
+
+ +
+ + + + {% if report.selected_category != 'all' or report.selected_coverage != 'all' %}Filters wissen{% endif %} +
+ +
+
+
Vraagindicatoren

{{ report.items|length }} skills in deze selectie

gesorteerd op vacaturevolume
+
+ {% for item in report.items %} +
+
+
+
{{ item.category }}

{{ item.label }}

+ {% if item.covered %}In je profiel{% else %}Leerkans{% endif %} +
+
+
{{ item.vacancy_count }} vacature{{ item.vacancy_count|pluralize:"s" }}{{ item.share_percent }}% van je radar{{ item.explicit_count }} in skillveld · {{ item.inferred_count }} via titel/tekst
+ {% if not item.covered %}

Mogelijke volgende stap{{ item.learning_focus }}

{% endif %} + {% if item.examples %}{% endif %} +
+ +
+ {% empty %} +

Geen skills binnen deze filters

Wis de filters of kies een ander skilldomein. De onderliggende vacatures blijven ongewijzigd.

Alle signalen tonen
+ {% endfor %} +
+
+ + +
+{% else %} +

Geen actief zoekprofiel

Activeer eerst een profiel. De skillsradar blijft persoonlijk en mengt nooit gegevens van andere gebruikers.

Profielen bekijken
+{% endif %} +{% endblock %} diff --git a/templates/profiles/edit.html b/templates/profiles/edit.html index 83c24db..9c1ed24 100644 --- a/templates/profiles/edit.html +++ b/templates/profiles/edit.html @@ -7,14 +7,14 @@ {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %}
{% for field in form %} - {% if field.name == 'name' %}

Profielbasis

Je postcode volstaat; technische locatievelden worden op de achtergrond afgeleid.

{% elif field.name == 'desired_titles' %}

Gewenste rollen & technologie

Kies herkenbare opties. Uitsluitingen blijven altijd doorslaggevend.

{% elif field.name == 'allowed_employment_types' %}

Werkvorm, regio & reistijd

Selecteer contractvormen, werkmodellen en regio's zonder technische notatie.

{% elif field.name == 'recommendation_threshold' %}

Scoring & automatisering

Een topmatch ligt altijd boven de gewone aanbevelingsdrempel.

{% endif %} + {% if field.name == 'name' %}

Profielbasis

Je postcode volstaat voor de afstand. De ervaringsband is alleen profielcontext en heeft geen invloed op score of uitsluiting.

{% elif field.name == 'desired_titles' %}

Gewenste rollen & technologie

Kies per categorie wat echt bij je profiel past. De beste titelmatch en maximaal vier aangetroffen skills bepalen de inhoudelijke aansluiting.

{% elif field.name == 'allowed_employment_types' %}

Werkvorm, regio & reistijd

Selecteer contractvormen, werkmodellen en regio's zonder technische notatie.

{% elif field.name == 'recommendation_threshold' %}

Scoring & automatisering

Een topmatch ligt altijd boven de gewone aanbevelingsdrempel.

{% endif %} {% if field.field.widget.allow_multiple_selected %}
{{ field.label }}{{ field }}{% if field.help_text %}{{ field.help_text }}{% endif %}{% for error in field.errors %}{{ error }}{% endfor %}
{% else %}{% endif %} {% endfor %}
Annuleren
diff --git a/tests/integration/test_profile_preferences.py b/tests/integration/test_profile_preferences.py index 27b3877..c1f8178 100644 --- a/tests/integration/test_profile_preferences.py +++ b/tests/integration/test_profile_preferences.py @@ -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"] diff --git a/tests/integration/test_skill_insights.py b/tests/integration/test_skill_insights.py new file mode 100644 index 0000000..c38d0d2 --- /dev/null +++ b/tests/integration/test_skill_insights.py @@ -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 diff --git a/tests/unit/test_feedback_learning.py b/tests/unit/test_feedback_learning.py index b79f260..ccd4e1b 100644 --- a/tests/unit/test_feedback_learning.py +++ b/tests/unit/test_feedback_learning.py @@ -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): diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py index d643751..8e78108 100644 --- a/tests/unit/test_profiles.py +++ b/tests/unit/test_profiles.py @@ -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() diff --git a/tests/unit/test_relevance.py b/tests/unit/test_relevance.py index c1b9fa2..e2ecdd6 100644 --- a/tests/unit/test_relevance.py +++ b/tests/unit/test_relevance.py @@ -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) diff --git a/tests/unit/test_scoring.py b/tests/unit/test_scoring.py index 694a99b..1719d31 100644 --- a/tests/unit/test_scoring.py +++ b/tests/unit/test_scoring.py @@ -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" diff --git a/tests/unit/test_skill_demand.py b/tests/unit/test_skill_demand.py new file mode 100644 index 0000000..bc86828 --- /dev/null +++ b/tests/unit/test_skill_demand.py @@ -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