253 lines
8.5 KiB
Python
253 lines
8.5 KiB
Python
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,
|
|
)
|