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