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, )