feat: personalize scoring and add skills radar
This commit is contained in:
@@ -22,7 +22,6 @@ LEARNING_FEATURES = {
|
||||
"location",
|
||||
"conditions",
|
||||
"employer",
|
||||
"seniority",
|
||||
"preferences",
|
||||
}
|
||||
|
||||
|
||||
@@ -49,13 +49,21 @@ EMPLOYMENT_MAP = {
|
||||
"vast": "permanent",
|
||||
}
|
||||
TITLE_FAMILIES = {
|
||||
"it field engineer": "field-support",
|
||||
"field service engineer": "field-support",
|
||||
"it engineer": "infrastructure",
|
||||
"system engineer": "infrastructure",
|
||||
"system network engineer": "infrastructure",
|
||||
"system administrator": "infrastructure",
|
||||
"systeembeheerder": "infrastructure",
|
||||
"infrastructure engineer": "infrastructure",
|
||||
"network engineer": "network",
|
||||
"network administrator": "network",
|
||||
"netwerkbeheerder": "network",
|
||||
"workplace engineer": "workplace",
|
||||
"support engineer": "support",
|
||||
"it technician": "support",
|
||||
"pc technician": "support",
|
||||
"helpdesk": "support",
|
||||
"developer": "software-development",
|
||||
"data engineer": "data",
|
||||
|
||||
@@ -29,10 +29,13 @@ IT_TITLE_TERMS = (
|
||||
"bi analyst",
|
||||
"system engineer",
|
||||
"systems engineer",
|
||||
"system network engineer",
|
||||
"system administrator",
|
||||
"systeembeheer",
|
||||
"infrastructure engineer",
|
||||
"infrastructuur engineer",
|
||||
"network engineer",
|
||||
"network administrator",
|
||||
"network architect",
|
||||
"netwerkbeheer",
|
||||
"netwerk engineer",
|
||||
@@ -44,6 +47,10 @@ IT_TITLE_TERMS = (
|
||||
"information security",
|
||||
"informatiebeveiliging",
|
||||
"workplace engineer",
|
||||
"endpoint engineer",
|
||||
"microsoft 365 engineer",
|
||||
"m365 engineer",
|
||||
"intune engineer",
|
||||
"modern workplace",
|
||||
"digital workplace",
|
||||
"service desk",
|
||||
@@ -51,6 +58,8 @@ IT_TITLE_TERMS = (
|
||||
"helpdesk",
|
||||
"support engineer",
|
||||
"support specialist",
|
||||
"it technician",
|
||||
"pc technician",
|
||||
"application lead",
|
||||
"application engineer",
|
||||
"application manager",
|
||||
@@ -106,6 +115,48 @@ IT_TITLE_TERMS = (
|
||||
"test automation",
|
||||
)
|
||||
|
||||
# These engineering titles occur both in IT and in unrelated technical sectors. They are
|
||||
# accepted only when the title supplies the role context and the description independently
|
||||
# supplies a strong IT signal. A description signal by itself remains insufficient.
|
||||
CONTEXTUAL_IT_TITLE_TERMS = (
|
||||
"field engineer",
|
||||
"field service engineer",
|
||||
"implementation engineer",
|
||||
"implementation consultant",
|
||||
"service engineer",
|
||||
)
|
||||
|
||||
IT_DESCRIPTION_TERMS = (
|
||||
"microsoft 365",
|
||||
"m365",
|
||||
"windows server",
|
||||
"active directory",
|
||||
"entra id",
|
||||
"intune",
|
||||
"autopilot",
|
||||
"exchange online",
|
||||
"sharepoint",
|
||||
"vmware",
|
||||
"proxmox",
|
||||
"hyper-v",
|
||||
"networking",
|
||||
"netwerkbeheer",
|
||||
"tcp/ip",
|
||||
"vlan",
|
||||
"vpn",
|
||||
"dhcp",
|
||||
"dns",
|
||||
"firewall",
|
||||
"switches",
|
||||
"routers",
|
||||
"workstations",
|
||||
"desktops",
|
||||
"laptops",
|
||||
"voip",
|
||||
"3cx",
|
||||
"powershell",
|
||||
)
|
||||
|
||||
# These phrases can contain IT vocabulary while describing commercial, recruitment,
|
||||
# or educational-design work. They therefore override positive title signals.
|
||||
NON_IT_TITLE_TERMS = (
|
||||
@@ -126,8 +177,16 @@ def _term_pattern(term: str) -> str:
|
||||
|
||||
IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in IT_TITLE_TERMS) + ")"
|
||||
NON_IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in NON_IT_TITLE_TERMS) + ")"
|
||||
CONTEXTUAL_IT_TITLE_PATTERN = (
|
||||
"(?:" + "|".join(_term_pattern(term) for term in CONTEXTUAL_IT_TITLE_TERMS) + ")"
|
||||
)
|
||||
IT_DESCRIPTION_PATTERN = (
|
||||
"(?:" + "|".join(_term_pattern(term) for term in IT_DESCRIPTION_TERMS) + ")"
|
||||
)
|
||||
_IT_TITLE_RE = re.compile(IT_TITLE_PATTERN, re.IGNORECASE)
|
||||
_NON_IT_TITLE_RE = re.compile(NON_IT_TITLE_PATTERN, re.IGNORECASE)
|
||||
_CONTEXTUAL_IT_TITLE_RE = re.compile(CONTEXTUAL_IT_TITLE_PATTERN, re.IGNORECASE)
|
||||
_IT_DESCRIPTION_RE = re.compile(IT_DESCRIPTION_PATTERN, re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -137,7 +196,7 @@ class ItRelevanceAssessment:
|
||||
reason: str
|
||||
|
||||
|
||||
def assess_it_relevance(title: str) -> ItRelevanceAssessment:
|
||||
def assess_it_relevance(title: str, description: str = "") -> ItRelevanceAssessment:
|
||||
normalized_title = normalize_token(title)
|
||||
blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
|
||||
if blocked_signal:
|
||||
@@ -157,6 +216,23 @@ def assess_it_relevance(title: str) -> ItRelevanceAssessment:
|
||||
signals=signals[:4],
|
||||
reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
|
||||
)
|
||||
contextual_title = _CONTEXTUAL_IT_TITLE_RE.search(normalized_title)
|
||||
if contextual_title:
|
||||
description_signal = _IT_DESCRIPTION_RE.search(normalize_token(description))
|
||||
if description_signal:
|
||||
return ItRelevanceAssessment(
|
||||
relevant=True,
|
||||
signals=(contextual_title.group(0), description_signal.group(0)),
|
||||
reason=(
|
||||
"IT-context bevestigd via ambigue functietitel en vacaturetekst: "
|
||||
f"{contextual_title.group(0)}, {description_signal.group(0)}."
|
||||
),
|
||||
)
|
||||
return ItRelevanceAssessment(
|
||||
relevant=False,
|
||||
signals=(),
|
||||
reason="Ambigue technische functietitel zonder aantoonbare IT-context.",
|
||||
)
|
||||
return ItRelevanceAssessment(
|
||||
relevant=False,
|
||||
signals=(),
|
||||
@@ -175,6 +251,10 @@ def profile_requires_it_focus(profile: SearchProfile) -> bool:
|
||||
|
||||
def it_relevance_query(prefix: str = "") -> Q:
|
||||
"""Return the database equivalent of the conservative title-led classifier."""
|
||||
return Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN}) & ~Q(
|
||||
clear_title = Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN})
|
||||
contextual_title = Q(**{f"{prefix}original_title__iregex": CONTEXTUAL_IT_TITLE_PATTERN}) & Q(
|
||||
**{f"{prefix}description_text__iregex": IT_DESCRIPTION_PATTERN}
|
||||
)
|
||||
return (clear_title | contextual_title) & ~Q(
|
||||
**{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from apps.profiles.models import SearchProfile
|
||||
|
||||
from .normalization import normalize_token
|
||||
from .relevance import assess_it_relevance, profile_requires_it_focus
|
||||
from .skill_terms import contains_term, skill_is_present
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -52,6 +53,7 @@ class DistanceAssessment:
|
||||
|
||||
EXACT_DISTANCE_CONF_THRESHOLD = 0.80
|
||||
AI_MAX_WEIGHT = 20.0
|
||||
SKILL_MATCH_TARGET = 4
|
||||
|
||||
|
||||
def _similarity(left: str, right: str) -> float:
|
||||
@@ -113,6 +115,14 @@ def _title_fit(job: JobPosting, profile: SearchProfile) -> float:
|
||||
)
|
||||
|
||||
|
||||
def _contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
|
||||
return contains_term(text, term, allow_plural=allow_plural)
|
||||
|
||||
|
||||
def _skill_is_present(skill: str, text: str) -> bool:
|
||||
return skill_is_present(skill, text)
|
||||
|
||||
|
||||
def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str], list[str]]:
|
||||
desired = {normalize_token(skill) for skill in profile.desired_skills if skill}
|
||||
if not desired:
|
||||
@@ -120,9 +130,10 @@ def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str
|
||||
text = normalize_token(
|
||||
" ".join(job.skills_required + job.skills_preferred) + " " + job.description_text
|
||||
)
|
||||
present = sorted(skill for skill in desired if skill and skill in text)
|
||||
present = sorted(skill for skill in desired if _skill_is_present(skill, text))
|
||||
missing = sorted(desired - set(present))
|
||||
return len(present) / len(desired), present, missing
|
||||
evidence_target = min(SKILL_MATCH_TARGET, len(desired))
|
||||
return min(1.0, len(present) / evidence_target), present, missing
|
||||
|
||||
|
||||
def _distance(job: JobPosting, profile: SearchProfile) -> DistanceAssessment:
|
||||
@@ -173,7 +184,7 @@ def _hard_exclusions(
|
||||
configured_terms = list(profile.excluded_titles)
|
||||
configured_terms += list(profile.hard_rules.get("excluded_title_terms", []))
|
||||
for term in configured_terms:
|
||||
if normalize_token(term) and normalize_token(term) in title:
|
||||
if _contains_term(title, normalize_token(term), allow_plural=True):
|
||||
reasons.append(f"Uitgesloten titelterm: {term}")
|
||||
|
||||
excluded_types = set(profile.hard_rules.get("excluded_employment_types", []))
|
||||
@@ -228,7 +239,7 @@ def _hard_exclusions(
|
||||
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
|
||||
if conflicts:
|
||||
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
|
||||
it_relevance = assess_it_relevance(job.original_title)
|
||||
it_relevance = assess_it_relevance(job.original_title, job.description_text)
|
||||
if profile_requires_it_focus(profile) and not it_relevance.relevant:
|
||||
reasons.append(it_relevance.reason)
|
||||
return reasons, distance.distance_confidence
|
||||
@@ -248,20 +259,10 @@ def _ai_feature_score(features: dict[str, Any]) -> float:
|
||||
support_ratio = float(features.get("support_ratio") or 0.0)
|
||||
consultancy_ratio = float(features.get("consultancy_ratio") or 0.0)
|
||||
travel_ratio = float(features.get("travel_ratio") or 0.0)
|
||||
seniority = str(features.get("seniority") or "").strip().lower()
|
||||
seniority_boost = {
|
||||
"junior": 0.0,
|
||||
"medior": 0.08,
|
||||
"senior": 0.12,
|
||||
"lead": 0.14,
|
||||
"expert": 0.16,
|
||||
"unknown": 0.03,
|
||||
"": 0.03,
|
||||
}.get(seniority, 0.05)
|
||||
score = (
|
||||
0.5 * (1.0 - support_ratio) + 0.25 * (1.0 - consultancy_ratio) + 0.15 * (1.0 - travel_ratio)
|
||||
)
|
||||
return max(0.0, min(1.0, score + seniority_boost))
|
||||
return max(0.0, min(1.0, score))
|
||||
|
||||
|
||||
def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysis, float, float]:
|
||||
@@ -296,7 +297,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
|
||||
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
distance = _distance(job, profile)
|
||||
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
|
||||
it_relevance = assess_it_relevance(job.original_title)
|
||||
it_relevance = assess_it_relevance(job.original_title, job.description_text)
|
||||
title_fit = _title_fit(job, profile)
|
||||
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
|
||||
features = job.analysis_features or {}
|
||||
@@ -326,12 +327,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
else:
|
||||
conditions_fit = 0.65
|
||||
employer_fit = 1.0 if job.direct_employer and not job.recruiter else 0.45
|
||||
experience_years = features.get("experience_years_max")
|
||||
seniority_fit = (
|
||||
0.75
|
||||
if experience_years is None
|
||||
else max(0.25, 1.0 - max(0, int(experience_years) - 5) * 0.1)
|
||||
)
|
||||
if profile.preferred_workplace:
|
||||
preference_fit = 1.0 if job.workplace_type in profile.preferred_workplace else 0.45
|
||||
else:
|
||||
@@ -347,7 +342,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
"location": location_fit,
|
||||
"conditions": conditions_fit,
|
||||
"employer": employer_fit,
|
||||
"seniority": seniority_fit,
|
||||
"preferences": preference_fit,
|
||||
}
|
||||
ai_analysis, ai_component, ai_weight = _analyze_with_ai(job, profile)
|
||||
@@ -409,8 +403,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
)
|
||||
if support_ratio >= 0.5:
|
||||
concerns.append("Vacature bevat sterke first-line/helpdesksignalen.")
|
||||
if missing_skills:
|
||||
concerns.append("Niet duidelijk teruggevonden: " + ", ".join(missing_skills[:6]))
|
||||
if (
|
||||
distance.exact_distance_km is None
|
||||
and distance.has_distance_data
|
||||
@@ -447,6 +439,15 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
"signals": list(it_relevance.signals),
|
||||
"reason": it_relevance.reason,
|
||||
},
|
||||
"skills": {
|
||||
"matched": present_skills,
|
||||
"configured_count": len(present_skills) + len(missing_skills),
|
||||
"match_target": min(
|
||||
SKILL_MATCH_TARGET,
|
||||
len(present_skills) + len(missing_skills),
|
||||
),
|
||||
"not_observed": missing_skills,
|
||||
},
|
||||
"distance_km": distance.exact_distance_km,
|
||||
"distance_has_data": distance.has_distance_data,
|
||||
"distance_exact": distance.exact_distance_km is not None,
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import CharField, Exists, OuterRef, Q, QuerySet, Subquery
|
||||
from django.utils.text import slugify
|
||||
|
||||
from apps.jobs.models import JobPosting, JobSourceAlias, ScoreRun
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.profiles.taxonomy import SKILL_CHOICES
|
||||
from apps.sources.models import Source
|
||||
|
||||
from .normalization import normalize_token
|
||||
from .relevance import it_relevance_query
|
||||
from .skill_terms import canonical_skill_key, skill_is_present
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillDefinition:
|
||||
key: str
|
||||
label: str
|
||||
category: str
|
||||
category_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillJobExample:
|
||||
id: str
|
||||
title: str
|
||||
employer: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillDemandItem:
|
||||
key: str
|
||||
label: str
|
||||
category: str
|
||||
category_key: str
|
||||
vacancy_count: int
|
||||
share_percent: int
|
||||
explicit_count: int
|
||||
inferred_count: int
|
||||
covered: bool
|
||||
learning_focus: str
|
||||
search_term: str
|
||||
examples: tuple[SkillJobExample, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillDemandReport:
|
||||
total_jobs: int
|
||||
jobs_with_signals: int
|
||||
signal_count: int
|
||||
covered_count: int
|
||||
gap_count: int
|
||||
coverage_percent: int
|
||||
items: tuple[SkillDemandItem, ...]
|
||||
categories: tuple[tuple[str, str], ...]
|
||||
selected_category: str
|
||||
selected_coverage: str
|
||||
|
||||
|
||||
def skill_catalog() -> tuple[SkillDefinition, ...]:
|
||||
return tuple(
|
||||
SkillDefinition(
|
||||
key=normalize_token(value),
|
||||
label=label,
|
||||
category=category,
|
||||
category_key=slugify(category),
|
||||
)
|
||||
for category, choices in SKILL_CHOICES
|
||||
for value, label in choices
|
||||
)
|
||||
|
||||
|
||||
def relevant_jobs_for_profile(profile: SearchProfile) -> QuerySet[JobPosting]:
|
||||
latest_score = ScoreRun.objects.filter(
|
||||
job=OuterRef("pk"),
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
).order_by("-created_at")
|
||||
source_aliases = JobSourceAlias.objects.filter(job=OuterRef("pk"))
|
||||
active_source_aliases = source_aliases.filter(source__status=Source.Status.ACTIVE)
|
||||
return (
|
||||
JobPosting.objects.select_related("employer")
|
||||
.filter(status=JobPosting.Status.ACTIVE)
|
||||
.filter(it_relevance_query())
|
||||
.annotate(
|
||||
profile_recommendation=Subquery(
|
||||
latest_score.values("recommendation")[:1], output_field=CharField()
|
||||
),
|
||||
has_source_alias=Exists(source_aliases),
|
||||
has_active_source_alias=Exists(active_source_aliases),
|
||||
)
|
||||
.filter(
|
||||
profile_recommendation__in=(
|
||||
ScoreRun.Recommendation.STRONG,
|
||||
ScoreRun.Recommendation.POSSIBLE,
|
||||
ScoreRun.Recommendation.WEAK,
|
||||
)
|
||||
)
|
||||
.filter(Q(has_source_alias=False) | Q(has_active_source_alias=True))
|
||||
.order_by("-first_seen")
|
||||
)
|
||||
|
||||
|
||||
def _structured_skill_text(job: JobPosting) -> str:
|
||||
return normalize_token(
|
||||
" ".join(str(value) for value in [*job.skills_required, *job.skills_preferred])
|
||||
)
|
||||
|
||||
|
||||
def _inferred_skill_text(job: JobPosting) -> str:
|
||||
requirements = " ".join(str(value) for value in job.requirements)
|
||||
return normalize_token(f"{job.original_title} {requirements} {job.description_text}")
|
||||
|
||||
|
||||
def _learning_focus(definition: SkillDefinition) -> str:
|
||||
focuses = {
|
||||
"Microsoft & endpoint": (
|
||||
"Oefen een praktische beheer- of migratiecase en leg de relevante "
|
||||
"Microsoft Learn-modules vast."
|
||||
),
|
||||
"Netwerk & security": (
|
||||
"Bouw een kleine labcase rond configuratie, troubleshooting en beveiligde toegang."
|
||||
),
|
||||
"Back-up & monitoring": (
|
||||
"Oefen detectie, herstel en rapportering met een reproduceerbare homelabcase."
|
||||
),
|
||||
"Virtualisatie & platform": (
|
||||
"Maak een deployment- of platformlab en documenteer beschikbaarheid en herstel."
|
||||
),
|
||||
"IT-servicemanagement": (
|
||||
"Koppel de methodiek aan een concrete incident-, problem- of changecase."
|
||||
),
|
||||
"Digitale werkplek & adoptie": (
|
||||
"Werk een kleine governance- of adoptiecase uit met meetbare gebruikersimpact."
|
||||
),
|
||||
"Automation & scripting": (
|
||||
"Automatiseer één herkenbare beheertaak en publiceer een veilig voorbeeldscript."
|
||||
),
|
||||
"VoIP & telefonie": "Simuleer configuratie en troubleshooting in een kleine telefoniecase.",
|
||||
"Field service & uitvoering": (
|
||||
"Documenteer een end-to-end interventie: diagnose, oplossing en overdracht."
|
||||
),
|
||||
}
|
||||
return focuses.get(
|
||||
definition.category,
|
||||
"Maak een kleine praktijkcase en leg vast welke vacature-eis je ermee kunt aantonen.",
|
||||
)
|
||||
|
||||
|
||||
def _profile_skill_keys(profile: SearchProfile, catalog: tuple[SkillDefinition, ...]) -> set[str]:
|
||||
catalog_keys = {definition.key for definition in catalog}
|
||||
return {
|
||||
canonical
|
||||
for value in profile.desired_skills
|
||||
if (canonical := canonical_skill_key(str(value), catalog_keys)) is not None
|
||||
}
|
||||
|
||||
|
||||
def build_skill_demand_report(
|
||||
profile: SearchProfile,
|
||||
*,
|
||||
category: str = "all",
|
||||
coverage: str = "all",
|
||||
jobs: QuerySet[JobPosting] | list[JobPosting] | None = None,
|
||||
) -> SkillDemandReport:
|
||||
catalog = skill_catalog()
|
||||
valid_categories = {definition.category_key for definition in catalog}
|
||||
selected_category = category if category in valid_categories else "all"
|
||||
selected_coverage = coverage if coverage in {"all", "gap", "covered"} else "all"
|
||||
job_list = list(relevant_jobs_for_profile(profile) if jobs is None else jobs)
|
||||
profile_skills = _profile_skill_keys(profile, catalog)
|
||||
items: list[SkillDemandItem] = []
|
||||
jobs_with_signals: set[str] = set()
|
||||
|
||||
for definition in catalog:
|
||||
examples: list[SkillJobExample] = []
|
||||
explicit_count = 0
|
||||
inferred_count = 0
|
||||
for job in job_list:
|
||||
explicit = skill_is_present(definition.key, _structured_skill_text(job))
|
||||
inferred = skill_is_present(definition.key, _inferred_skill_text(job))
|
||||
if not explicit and not inferred:
|
||||
continue
|
||||
if explicit:
|
||||
explicit_count += 1
|
||||
else:
|
||||
inferred_count += 1
|
||||
jobs_with_signals.add(str(job.pk))
|
||||
if len(examples) < 3:
|
||||
examples.append(
|
||||
SkillJobExample(
|
||||
id=str(job.pk),
|
||||
title=job.original_title,
|
||||
employer=job.employer_name,
|
||||
)
|
||||
)
|
||||
vacancy_count = explicit_count + inferred_count
|
||||
if vacancy_count == 0:
|
||||
continue
|
||||
items.append(
|
||||
SkillDemandItem(
|
||||
key=definition.key,
|
||||
label=definition.label,
|
||||
category=definition.category,
|
||||
category_key=definition.category_key,
|
||||
vacancy_count=vacancy_count,
|
||||
share_percent=round(vacancy_count / len(job_list) * 100) if job_list else 0,
|
||||
explicit_count=explicit_count,
|
||||
inferred_count=inferred_count,
|
||||
covered=definition.key in profile_skills,
|
||||
learning_focus=_learning_focus(definition),
|
||||
search_term=definition.key,
|
||||
examples=tuple(examples),
|
||||
)
|
||||
)
|
||||
|
||||
ranked = sorted(
|
||||
items,
|
||||
key=lambda item: (-item.vacancy_count, -item.explicit_count, item.label.casefold()),
|
||||
)
|
||||
covered_count = sum(item.covered for item in ranked)
|
||||
gap_count = len(ranked) - covered_count
|
||||
filtered = tuple(
|
||||
item
|
||||
for item in ranked
|
||||
if (selected_category == "all" or item.category_key == selected_category)
|
||||
and (
|
||||
selected_coverage == "all"
|
||||
or (selected_coverage == "covered" and item.covered)
|
||||
or (selected_coverage == "gap" and not item.covered)
|
||||
)
|
||||
)
|
||||
categories = tuple(
|
||||
(slugify(category_label), category_label)
|
||||
for category_label, _choices in SKILL_CHOICES
|
||||
if any(item.category == category_label for item in ranked)
|
||||
)
|
||||
return SkillDemandReport(
|
||||
total_jobs=len(job_list),
|
||||
jobs_with_signals=len(jobs_with_signals),
|
||||
signal_count=len(ranked),
|
||||
covered_count=covered_count,
|
||||
gap_count=gap_count,
|
||||
coverage_percent=round(covered_count / len(ranked) * 100) if ranked else 0,
|
||||
items=filtered,
|
||||
categories=categories,
|
||||
selected_category=selected_category,
|
||||
selected_coverage=selected_coverage,
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from apps.jobs.services.normalization import normalize_token
|
||||
|
||||
SKILL_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
"microsoft 365": ("m365", "office 365", "o365"),
|
||||
"microsoft teams": ("ms teams",),
|
||||
"entra id": ("azure active directory", "azure ad"),
|
||||
"intune": ("microsoft intune", "endpoint manager"),
|
||||
"autopilot": ("windows autopilot",),
|
||||
"group policy": ("group policy object", "gpo"),
|
||||
"dynamics 365": ("microsoft dynamics 365", "d365"),
|
||||
"microsoft copilot": ("copilot for microsoft 365", "m365 copilot"),
|
||||
"microsoft defender": ("defender for endpoint", "microsoft defender for endpoint"),
|
||||
"microsoft sentinel": ("azure sentinel", "sentinel siem"),
|
||||
"conditional access": ("voorwaardelijke toegang",),
|
||||
"networking": ("network", "netwerk", "lan", "wan"),
|
||||
"routing": ("routering",),
|
||||
"switching": ("network switches", "switches"),
|
||||
"sd-wan": ("sd wan",),
|
||||
"firewalls": ("firewall",),
|
||||
"fortinet": ("fortigate",),
|
||||
"palo alto": ("palo alto networks",),
|
||||
"wi-fi": ("wifi", "wireless"),
|
||||
"backup and restore": ("backup", "back-up", "restore"),
|
||||
"disaster recovery": ("business continuity", "bcp", "dr plan"),
|
||||
"monitoring": ("infrastructure monitoring", "system monitoring"),
|
||||
"observability": ("telemetry", "tracing"),
|
||||
"high availability": ("hoogbeschikbaarheid", "ha architecture"),
|
||||
"vmware": ("vsphere", "esxi"),
|
||||
"ci/cd": ("continuous integration", "continuous delivery", "ci cd"),
|
||||
"deployment": ("deployments", "software deployment", "uitrol"),
|
||||
"reliability": ("platform reliability", "site reliability", "sre"),
|
||||
"scalability": ("scalable", "schaalbaarheid"),
|
||||
"voip": ("voice over ip", "telefonie", "telephony", "3cx", "innovaphone"),
|
||||
"teams telephony": ("teams phone", "teams telefonie", "teams voice"),
|
||||
"onsite support": ("on-site support", "support op locatie", "field support"),
|
||||
"second line support": ("second-line support", "2nd line", "tweedelijnssupport"),
|
||||
"installations": ("installatie", "installaties", "roll-out", "rollout"),
|
||||
"migrations": ("migratie", "migraties"),
|
||||
"technical documentation": ("technische documentatie",),
|
||||
"customer support": ("klantondersteuning", "user support"),
|
||||
"incident management": ("incidentbeheer", "incident response", "escalations"),
|
||||
"problem management": ("probleembeheer", "root cause analysis"),
|
||||
"change management": ("wijzigingsbeheer", "organizational change"),
|
||||
"itil": ("itil 4", "it service management"),
|
||||
"servicenow": ("service now",),
|
||||
"jira service management": ("jira service desk", "jsm"),
|
||||
"digital workplace": ("digitale werkplek",),
|
||||
"m365 governance": ("microsoft 365 governance", "office 365 governance"),
|
||||
"document management": ("documentbeheer", "document management system", "dms"),
|
||||
"data governance": ("data governance", "datagovernance"),
|
||||
"user adoption": ("gebruikersadoptie", "technology adoption"),
|
||||
"training and workshops": ("user training", "workshops", "training geven"),
|
||||
"customer experience": ("customer satisfaction", "csat", "nps"),
|
||||
}
|
||||
|
||||
|
||||
def contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
|
||||
normalized_term = normalize_token(term)
|
||||
if not normalized_term:
|
||||
return False
|
||||
pattern = re.escape(normalized_term).replace(r"\ ", r"[\s/_-]+")
|
||||
if allow_plural and normalized_term[-1].isalpha() and not normalized_term.endswith("s"):
|
||||
pattern += "s?"
|
||||
return re.search(rf"(?<!\w){pattern}(?!\w)", text, re.IGNORECASE) is not None
|
||||
|
||||
|
||||
def skill_terms(skill: str) -> tuple[str, ...]:
|
||||
normalized = normalize_token(skill)
|
||||
return (normalized, *SKILL_ALIASES.get(normalized, ()))
|
||||
|
||||
|
||||
def skill_is_present(skill: str, text: str) -> bool:
|
||||
return any(contains_term(text, candidate) for candidate in skill_terms(skill))
|
||||
|
||||
|
||||
def canonical_skill_key(value: str, catalog_keys: set[str]) -> str | None:
|
||||
normalized = normalize_token(value)
|
||||
if normalized in catalog_keys:
|
||||
return normalized
|
||||
for key in catalog_keys:
|
||||
if normalized in {normalize_token(term) for term in SKILL_ALIASES.get(key, ())}:
|
||||
return key
|
||||
return None
|
||||
@@ -5,6 +5,7 @@ from .views import (
|
||||
ApplicationUpdateView,
|
||||
JobDetailView,
|
||||
JobListView,
|
||||
SkillInsightsView,
|
||||
application_delete,
|
||||
application_export,
|
||||
application_print,
|
||||
@@ -13,6 +14,7 @@ from .views import (
|
||||
|
||||
urlpatterns = [
|
||||
path("", JobListView.as_view(), name="list"),
|
||||
path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
|
||||
path("applications/", ApplicationListView.as_view(), name="applications"),
|
||||
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
|
||||
path("applications/<int:pk>/export/", application_export, name="application-export"),
|
||||
|
||||
+21
-1
@@ -18,7 +18,7 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
from django.views.generic import DetailView, ListView, TemplateView, UpdateView
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
@@ -32,6 +32,26 @@ from .services.applications import (
|
||||
)
|
||||
from .services.feedback import record_feedback
|
||||
from .services.relevance import it_relevance_query
|
||||
from .services.skill_demand import build_skill_demand_report
|
||||
|
||||
|
||||
class SkillInsightsView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "jobs/skill_insights.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
context["active_profile"] = profile
|
||||
context["report"] = (
|
||||
build_skill_demand_report(
|
||||
profile,
|
||||
category=self.request.GET.get("category", "all"),
|
||||
coverage=self.request.GET.get("coverage", "all"),
|
||||
)
|
||||
if profile
|
||||
else None
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class JobListView(LoginRequiredMixin, ListView):
|
||||
|
||||
Reference in New Issue
Block a user