feat: personalize scoring and add skills radar
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user