feat: personalize scoring and add skills radar
This commit is contained in:
@@ -5,6 +5,7 @@ from django.urls import reverse
|
||||
|
||||
from apps.jobs.models import GeocodeLocationLookup
|
||||
from apps.profiles.forms import SearchProfileForm
|
||||
from apps.profiles.taxonomy import iter_choices
|
||||
|
||||
|
||||
def _profile_post_data(**overrides):
|
||||
@@ -13,6 +14,7 @@ def _profile_post_data(**overrides):
|
||||
"is_active": "on",
|
||||
"home_postal_code": "3500",
|
||||
"max_distance_km": "45",
|
||||
"experience_years": "10",
|
||||
"desired_titles": ["infrastructure engineer", "cloud engineer"],
|
||||
"excluded_titles": ["sales"],
|
||||
"desired_skills": ["azure", "terraform"],
|
||||
@@ -38,8 +40,18 @@ def test_profile_form_uses_guided_choices_and_preserves_existing_custom_values(p
|
||||
assert "home_latitude" not in form.fields
|
||||
assert "home_longitude" not in form.fields
|
||||
assert form.fields["desired_titles"].widget.allow_multiple_selected is True
|
||||
assert ("systeembeheerder", "systeembeheerder") in form.fields["desired_titles"].choices
|
||||
assert ("VMware", "VMware") in form.fields["desired_skills"].choices
|
||||
title_choices = list(iter_choices(form.fields["desired_titles"].choices))
|
||||
skill_choices = list(iter_choices(form.fields["desired_skills"].choices))
|
||||
assert ("it field engineer", "IT Field Engineer") in title_choices
|
||||
assert ("system network engineer", "System & Network Engineer") in title_choices
|
||||
assert ("systeembeheerder", "Systeembeheerder") in title_choices
|
||||
assert ("vmware", "VMware") in skill_choices
|
||||
assert ("microsoft copilot", "Microsoft Copilot") in skill_choices
|
||||
assert ("fortinet", "Fortinet / FortiGate") in skill_choices
|
||||
assert ("itil", "ITIL / IT-servicemanagement") in skill_choices
|
||||
assert form.fields["desired_titles"].widget.attrs["class"] == "grouped-choices"
|
||||
assert form.fields["experience_years"].widget.allow_multiple_selected is False
|
||||
assert "nooit mee" in form.fields["experience_years"].help_text
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -83,6 +95,7 @@ def test_profile_update_derives_home_location_from_postcode(client, user, profil
|
||||
assert profile.home_postal_code == "3500"
|
||||
assert profile.home_municipality == "Hasselt"
|
||||
assert profile.home_latitude == Decimal("50.930700")
|
||||
assert profile.experience_years == 10
|
||||
assert profile.desired_titles == ["infrastructure engineer", "cloud engineer"]
|
||||
assert profile.preferred_workplace == ["hybrid"]
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.jobs.models import JobPosting, ScoreRun
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_skill_insights_require_login(client):
|
||||
response = client.get(reverse("jobs:skill-insights"))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert reverse("login") in response.url
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_skill_insights_use_current_profile_and_exclude_hidden_jobs(client, user, profile, job):
|
||||
job.skills_required = ["Microsoft 365"]
|
||||
job.description_text = "Microsoft 365, Dynamics 365 en Microsoft Copilot governance."
|
||||
job.save(update_fields=["skills_required", "description_text"])
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=74,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
hidden = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="Network Engineer buiten bereik",
|
||||
normalized_title="network engineer buiten bereik",
|
||||
canonical_url="https://jobs.example.org/vacatures/network-hidden",
|
||||
canonical_key="1" * 64,
|
||||
content_hash="2" * 64,
|
||||
description_text="Beheer FortiGate-firewalls.",
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=hidden,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=80,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.HIDDEN,
|
||||
hard_exclusions=["Buiten de zoekradius."],
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("jobs:skill-insights"))
|
||||
body = response.content.decode()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Skillsradar" in body
|
||||
assert "Microsoft 365" in body
|
||||
assert "Microsoft Dynamics 365" in body
|
||||
assert "Microsoft Copilot" in body
|
||||
assert "Fortinet / FortiGate" not in body
|
||||
assert "Ervaringsjaren tellen nergens mee" in body
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_skill_insights_offer_category_and_gap_filters(client, user, profile, job):
|
||||
job.skills_required = ["Microsoft 365", "FortiGate"]
|
||||
job.description_text = "Microsoft 365 en FortiGate-beheer."
|
||||
job.save(update_fields=["skills_required", "description_text"])
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=74,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
reverse("jobs:skill-insights"),
|
||||
{"category": "netwerk-security", "coverage": "gap"},
|
||||
)
|
||||
body = response.content.decode()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Fortinet / FortiGate" in body
|
||||
assert "Microsoft 365" not in body
|
||||
assert "Leerkans" in body
|
||||
@@ -14,7 +14,7 @@ def test_learning_signals_wait_for_min_samples(profile, job, user):
|
||||
assert first_metadata.get("reason_code") == "positive_feedback"
|
||||
assert second_metadata.get("status") == "applied"
|
||||
profile.refresh_from_db()
|
||||
assert profile.weights["content"] == 26.0
|
||||
assert profile.weights["content"] == 31.0
|
||||
|
||||
|
||||
def test_learning_skip_for_implicit_hide(profile, job, user):
|
||||
@@ -23,7 +23,7 @@ def test_learning_skip_for_implicit_hide(profile, job, user):
|
||||
assert metadata["status"] == "queued"
|
||||
assert metadata["reason_code"] == "implicit_hide_no_reason"
|
||||
profile.refresh_from_db()
|
||||
assert profile.weights["content"] == 25.0
|
||||
assert profile.weights["content"] == 30.0
|
||||
|
||||
|
||||
def test_learning_marks_explicit_title_hide_as_nonlearning(profile, job, user):
|
||||
|
||||
@@ -13,6 +13,9 @@ def test_profile_validation_snapshot_activation_and_revisions(user, profile):
|
||||
snapshot = profile.snapshot()
|
||||
assert snapshot["home_municipality"] == "Hasselt"
|
||||
assert snapshot["home_latitude"] == pytest.approx(50.9307)
|
||||
assert snapshot["experience_years"] == 0
|
||||
assert "seniority" not in profile.weights
|
||||
assert sum(profile.weights.values()) == 100
|
||||
|
||||
other = SearchProfile.objects.create(user=user, name="Tweede", is_active=False)
|
||||
other.activate()
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
from apps.jobs.services.relevance import assess_it_relevance, profile_requires_it_focus
|
||||
import pytest
|
||||
|
||||
from apps.jobs.models import JobPosting
|
||||
from apps.jobs.services.relevance import (
|
||||
assess_it_relevance,
|
||||
it_relevance_query,
|
||||
profile_requires_it_focus,
|
||||
)
|
||||
|
||||
|
||||
def test_title_led_it_relevance_accepts_clear_it_roles():
|
||||
@@ -30,6 +37,34 @@ def test_title_led_it_relevance_rejects_non_it_and_ambiguous_roles():
|
||||
assert assessment.reason
|
||||
|
||||
|
||||
def test_ambiguous_field_role_requires_strong_it_context():
|
||||
accepted = assess_it_relevance(
|
||||
"Field Service Engineer",
|
||||
"Installatie van Microsoft 365, Windows Server en netwerkswitches bij klanten.",
|
||||
)
|
||||
rejected = assess_it_relevance(
|
||||
"Field Service Engineer",
|
||||
"Onderhoud en herstelling van industriële HVAC- en koelinstallaties.",
|
||||
)
|
||||
|
||||
assert accepted.relevant
|
||||
assert "vacaturetekst" in accepted.reason
|
||||
assert not rejected.relevant
|
||||
assert "Ambigue technische functietitel" in rejected.reason
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_database_it_query_matches_contextual_assessment(job):
|
||||
job.original_title = "Field Service Engineer"
|
||||
job.description_text = "Implementeer Intune, Windows Server en VPN-oplossingen bij klanten."
|
||||
job.save(update_fields=["original_title", "description_text"])
|
||||
assert JobPosting.objects.filter(it_relevance_query(), pk=job.pk).exists()
|
||||
|
||||
job.description_text = "Onderhoud industriële pompen en productielijnen."
|
||||
job.save(update_fields=["description_text"])
|
||||
assert not JobPosting.objects.filter(it_relevance_query(), pk=job.pk).exists()
|
||||
|
||||
|
||||
def test_active_it_profile_requires_deterministic_it_focus(profile):
|
||||
assert profile_requires_it_focus(profile)
|
||||
|
||||
|
||||
@@ -55,6 +55,72 @@ def test_matching_job_gets_recommendation(matching_job, profile):
|
||||
assert not result.hard_exclusions
|
||||
|
||||
|
||||
def test_broad_relevant_skill_profile_is_not_diluted_without_limit(matching_job, profile):
|
||||
profile.desired_skills = [
|
||||
"microsoft 365",
|
||||
"vmware",
|
||||
"windows server",
|
||||
"active directory",
|
||||
"intune",
|
||||
"networking",
|
||||
"veeam",
|
||||
"powershell",
|
||||
"3cx",
|
||||
"zabbix",
|
||||
]
|
||||
|
||||
result = calculate_score(matching_job, profile)
|
||||
|
||||
assert result.components["skills"] == 12.5
|
||||
assert result.evidence["skills"]["configured_count"] == 10
|
||||
assert result.evidence["skills"]["match_target"] == 4
|
||||
assert not any("Niet duidelijk teruggevonden" in concern for concern in result.concerns)
|
||||
|
||||
|
||||
def test_skill_aliases_match_common_product_names(matching_job, profile):
|
||||
profile.desired_skills = ["microsoft 365", "entra id", "group policy", "vmware"]
|
||||
matching_job.skills_required = []
|
||||
matching_job.skills_preferred = []
|
||||
matching_job.description_text = "Beheer M365, Azure AD, GPO en vSphere voor meerdere klanten."
|
||||
|
||||
result = calculate_score(matching_job, profile)
|
||||
|
||||
assert result.components["skills"] == 25.0
|
||||
assert result.evidence["skills"]["matched"] == [
|
||||
"entra id",
|
||||
"group policy",
|
||||
"microsoft 365",
|
||||
"vmware",
|
||||
]
|
||||
|
||||
|
||||
def test_experience_never_changes_score_or_recommendation(matching_job, profile):
|
||||
profile.experience_years = 10
|
||||
matching_job.analysis_features = {
|
||||
**matching_job.analysis_features,
|
||||
"experience_years_max": 8,
|
||||
}
|
||||
|
||||
experienced = calculate_score(matching_job, profile)
|
||||
profile.experience_years = 3
|
||||
less_experienced = calculate_score(matching_job, profile)
|
||||
|
||||
assert experienced.score == less_experienced.score
|
||||
assert experienced.recommendation == less_experienced.recommendation
|
||||
assert "seniority" not in experienced.components
|
||||
assert "experience" not in experienced.evidence
|
||||
assert not any("ervaring" in concern.lower() for concern in less_experienced.concerns)
|
||||
|
||||
|
||||
def test_ai_seniority_label_is_informational_only():
|
||||
base = {"support_ratio": 0.1, "consultancy_ratio": 0.2, "travel_ratio": 0.0}
|
||||
|
||||
junior = scoring._ai_feature_score({**base, "seniority": "junior"})
|
||||
expert = scoring._ai_feature_score({**base, "seniority": "expert"})
|
||||
|
||||
assert junior == expert
|
||||
|
||||
|
||||
def test_hard_title_exclusion_wins(matching_job, profile):
|
||||
matching_job.original_title = "IT Sales Infrastructure Engineer"
|
||||
matching_job.save(update_fields=["original_title"])
|
||||
@@ -63,6 +129,27 @@ def test_hard_title_exclusion_wins(matching_job, profile):
|
||||
assert any("sales" in reason.lower() for reason in result.hard_exclusions)
|
||||
|
||||
|
||||
def test_short_title_exclusion_uses_word_boundaries(matching_job, profile):
|
||||
profile.excluded_titles = ["ai"]
|
||||
matching_job.original_title = "Maintenance IT Engineer"
|
||||
allowed = calculate_score(matching_job, profile)
|
||||
|
||||
matching_job.original_title = "AI Engineer"
|
||||
excluded = calculate_score(matching_job, profile)
|
||||
|
||||
assert not any("Uitgesloten titelterm" in reason for reason in allowed.hard_exclusions)
|
||||
assert any("Uitgesloten titelterm: ai" in reason for reason in excluded.hard_exclusions)
|
||||
|
||||
|
||||
def test_title_exclusion_matches_simple_english_plural(matching_job, profile):
|
||||
profile.excluded_titles = ["developer"]
|
||||
matching_job.original_title = "Full Stack Developers"
|
||||
|
||||
result = calculate_score(matching_job, profile)
|
||||
|
||||
assert any("Uitgesloten titelterm: developer" in reason for reason in result.hard_exclusions)
|
||||
|
||||
|
||||
def test_non_it_vacancy_is_hidden_for_it_profile(matching_job, profile):
|
||||
matching_job.original_title = "Spontane sollicitatie - logopedist"
|
||||
matching_job.normalized_title = "spontane sollicitatie logopedist"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import pytest
|
||||
|
||||
from apps.jobs.models import JobSourceAlias, ScoreRun
|
||||
from apps.jobs.services.skill_demand import build_skill_demand_report
|
||||
from apps.sources.models import Source
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_skill_demand_distinguishes_structured_evidence_and_text_signals(job, profile):
|
||||
job.skills_required = ["Microsoft 365"]
|
||||
job.skills_preferred = []
|
||||
job.description_text = (
|
||||
"Bouw governance voor Dynamics 365 en Microsoft Copilot. "
|
||||
"Zorg ook voor high availability en gebruikersadoptie."
|
||||
)
|
||||
|
||||
report = build_skill_demand_report(profile, jobs=[job])
|
||||
by_key = {item.key: item for item in report.items}
|
||||
|
||||
assert by_key["microsoft 365"].covered is True
|
||||
assert by_key["microsoft 365"].explicit_count == 1
|
||||
assert by_key["dynamics 365"].covered is False
|
||||
assert by_key["dynamics 365"].inferred_count == 1
|
||||
assert by_key["microsoft copilot"].vacancy_count == 1
|
||||
assert by_key["high availability"].learning_focus
|
||||
assert report.gap_count >= 3
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_skill_demand_filters_category_and_profile_coverage(job, profile):
|
||||
job.skills_required = ["Microsoft 365", "FortiGate"]
|
||||
job.description_text = "Microsoft 365 en FortiGate-beheer."
|
||||
|
||||
gaps = build_skill_demand_report(
|
||||
profile,
|
||||
category="netwerk-security",
|
||||
coverage="gap",
|
||||
jobs=[job],
|
||||
)
|
||||
|
||||
assert [item.key for item in gaps.items] == ["fortinet"]
|
||||
assert gaps.selected_category == "netwerk-security"
|
||||
assert gaps.selected_coverage == "gap"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_generic_team_word_is_not_microsoft_teams_demand(job, profile):
|
||||
job.skills_required = []
|
||||
job.skills_preferred = []
|
||||
job.description_text = "Je werkt samen met internationale teams aan klanttevredenheid."
|
||||
|
||||
report = build_skill_demand_report(profile, jobs=[job])
|
||||
|
||||
assert "microsoft teams" not in {item.key for item in report.items}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_disabled_source_vacancies_do_not_feed_learning_advice(job, profile, source):
|
||||
JobSourceAlias.objects.create(
|
||||
job=job,
|
||||
source=source,
|
||||
external_id="disabled-source-job",
|
||||
url=job.canonical_url,
|
||||
canonical_url=job.canonical_url,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=75,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
source.status = Source.Status.DISABLED
|
||||
source.save(update_fields=["status"])
|
||||
|
||||
report = build_skill_demand_report(profile)
|
||||
|
||||
assert report.total_jobs == 0
|
||||
assert report.signal_count == 0
|
||||
Reference in New Issue
Block a user