258 lines
8.8 KiB
Python
258 lines
8.8 KiB
Python
"""
|
|
matcher.py
|
|
----------
|
|
Scores a job posting against a parsed resume profile.
|
|
Returns a 0-100 score with detailed breakdown.
|
|
"""
|
|
|
|
import re
|
|
import math
|
|
import logging
|
|
from collections import Counter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from resume_parser import SKILL_TAXONOMY, TITLE_VARIANTS, CERT_PATTERNS
|
|
|
|
|
|
def _tokenize(text):
|
|
return re.findall(r'[a-z0-9/\.\+\#]+', text.lower())
|
|
|
|
|
|
def _cosine_similarity(vec_a, vec_b):
|
|
common = set(vec_a.keys()) & set(vec_b.keys())
|
|
if not common:
|
|
return 0.0
|
|
dot = sum(vec_a[k] * vec_b[k] for k in common)
|
|
mag_a = math.sqrt(sum(v ** 2 for v in vec_a.values()))
|
|
mag_b = math.sqrt(sum(v ** 2 for v in vec_b.values()))
|
|
if mag_a == 0 or mag_b == 0:
|
|
return 0.0
|
|
return dot / (mag_a * mag_b)
|
|
|
|
|
|
def score_skills(profile, job_text, aspirational_skills=None):
|
|
job_lower = job_text.lower()
|
|
aspirational_skills = [s.lower() for s in (aspirational_skills or [])]
|
|
|
|
job_skills = {}
|
|
for skill_name, patterns in SKILL_TAXONOMY.items():
|
|
for pattern in patterns:
|
|
if pattern.lower() in job_lower:
|
|
count = job_lower.count(pattern.lower())
|
|
job_skills[skill_name] = min(count, 5)
|
|
break
|
|
|
|
if not job_skills:
|
|
return {"score": 50, "matched": [], "missing": [], "aspirational_hits": []}
|
|
|
|
profile_skills = profile.get("skills", {})
|
|
matched = []
|
|
missing = []
|
|
aspirational_hits = []
|
|
|
|
for skill in job_skills:
|
|
if skill in profile_skills:
|
|
matched.append(skill)
|
|
elif skill.lower() in aspirational_skills:
|
|
aspirational_hits.append(skill)
|
|
matched.append(skill)
|
|
else:
|
|
missing.append(skill)
|
|
|
|
total = len(job_skills)
|
|
effective_matches = len([s for s in matched if s not in aspirational_hits])
|
|
effective_matches += len(aspirational_hits) * 0.5
|
|
score = (effective_matches / total) * 100 if total > 0 else 50
|
|
|
|
return {
|
|
"score": min(round(score), 100),
|
|
"matched": matched,
|
|
"missing": missing,
|
|
"aspirational_hits": aspirational_hits,
|
|
"job_requires": list(job_skills.keys()),
|
|
}
|
|
|
|
|
|
def score_title(profile, job_title):
|
|
job_title_lower = job_title.lower().strip()
|
|
profile_titles = [t.lower() for t in profile.get("titles", [])]
|
|
|
|
for pt in profile_titles:
|
|
if pt in job_title_lower or job_title_lower in pt:
|
|
return {"score": 100, "reason": f"Direct match: {pt}"}
|
|
|
|
job_normalized = set()
|
|
for norm, variants in TITLE_VARIANTS.items():
|
|
if any(v in job_title_lower for v in variants):
|
|
job_normalized.add(norm)
|
|
|
|
profile_normalized = set()
|
|
for pt in profile_titles:
|
|
for norm, variants in TITLE_VARIANTS.items():
|
|
if any(v in pt for v in variants):
|
|
profile_normalized.add(norm)
|
|
|
|
if job_normalized & profile_normalized:
|
|
return {"score": 95, "reason": f"Normalized match: {job_normalized & profile_normalized}"}
|
|
|
|
related_groups = [
|
|
{"network engineer", "network administrator", "infrastructure engineer", "system administrator"},
|
|
{"cloud engineer", "devops engineer", "infrastructure engineer"},
|
|
{"security engineer", "network engineer"},
|
|
]
|
|
|
|
for group in related_groups:
|
|
if (job_normalized & group) and (profile_normalized & group):
|
|
return {"score": 70, "reason": "Related role family"}
|
|
|
|
job_words = set(_tokenize(job_title_lower))
|
|
profile_words = set()
|
|
for pt in profile_titles:
|
|
profile_words.update(_tokenize(pt))
|
|
|
|
overlap = job_words & profile_words
|
|
if overlap:
|
|
ratio = len(overlap) / max(len(job_words), 1)
|
|
return {"score": round(ratio * 60), "reason": f"Keyword overlap: {overlap}"}
|
|
|
|
return {"score": 20, "reason": "No meaningful match"}
|
|
|
|
|
|
def score_experience(profile, job_text):
|
|
profile_years = profile.get("years_experience", 0)
|
|
patterns = [
|
|
r'(\d+)\+?\s*years?\s*(?:of\s+)?(?:experience|exp)',
|
|
r'(\d+)\+?\s*years?\s*(?:in|with|of)',
|
|
r'minimum\s+(\d+)\s*years?',
|
|
r'at\s+least\s+(\d+)\s*years?',
|
|
]
|
|
|
|
required_years = []
|
|
for pattern in patterns:
|
|
matches = re.findall(pattern, job_text.lower())
|
|
required_years.extend(int(m) for m in matches)
|
|
|
|
if not required_years:
|
|
return {"score": 70, "reason": "No experience requirement found", "required": None}
|
|
|
|
max_required = max(required_years)
|
|
|
|
if profile_years >= max_required:
|
|
score = 100
|
|
reason = f"Exceeds requirement ({profile_years} >= {max_required})"
|
|
elif profile_years >= max_required - 1:
|
|
score = 85
|
|
reason = f"Close match ({profile_years} vs {max_required} required)"
|
|
elif profile_years >= max_required * 0.7:
|
|
score = 60
|
|
reason = f"Slightly under ({profile_years} vs {max_required} required)"
|
|
else:
|
|
score = max(20, 100 - (max_required - profile_years) * 15)
|
|
reason = f"Under-qualified ({profile_years} vs {max_required} required)"
|
|
|
|
return {"score": round(score), "reason": reason, "required": max_required}
|
|
|
|
|
|
def score_keywords(profile, job_text):
|
|
resume_text = profile.get("full_text", "")
|
|
if not resume_text or not job_text:
|
|
return {"score": 50, "similarity": 0}
|
|
|
|
resume_tokens = _tokenize(resume_text)
|
|
job_tokens = _tokenize(job_text)
|
|
|
|
stopwords = {
|
|
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
|
|
"for", "of", "with", "by", "is", "are", "was", "were", "be",
|
|
"been", "being", "have", "has", "had", "do", "does", "did",
|
|
"will", "would", "could", "should", "may", "might", "shall",
|
|
"can", "this", "that", "these", "those", "it", "its", "we",
|
|
"you", "they", "them", "their", "our", "your", "from", "as",
|
|
"not", "all", "also", "more", "other", "such", "than", "into",
|
|
"about", "over", "between", "through", "during", "before",
|
|
"after", "above", "below", "up", "down", "out", "off", "then",
|
|
"once", "here", "there", "when", "where", "why", "how", "any",
|
|
"both", "each", "few", "most", "some", "no", "nor", "only",
|
|
"own", "same", "so", "very", "just", "if", "while", "work",
|
|
"working", "experience", "including", "ability", "strong",
|
|
"knowledge", "skills", "required", "preferred", "minimum",
|
|
"must", "etc", "ensure", "across", "using", "within",
|
|
}
|
|
|
|
resume_tf = Counter(t for t in resume_tokens if t not in stopwords and len(t) > 1)
|
|
job_tf = Counter(t for t in job_tokens if t not in stopwords and len(t) > 1)
|
|
similarity = _cosine_similarity(resume_tf, job_tf)
|
|
score = min(round(similarity * 300), 100)
|
|
return {"score": score, "similarity": round(similarity, 3)}
|
|
|
|
|
|
def score_certifications(profile, job_text):
|
|
job_lower = job_text.lower()
|
|
profile_certs = [c.lower() for c in profile.get("certifications", [])]
|
|
|
|
if not profile_certs:
|
|
return {"score": 50, "matched": [], "required": []}
|
|
|
|
job_certs = []
|
|
for pattern, cert_name in CERT_PATTERNS:
|
|
if re.search(pattern, job_lower):
|
|
job_certs.append(cert_name)
|
|
|
|
if not job_certs:
|
|
return {"score": 75, "matched": [], "required": []}
|
|
|
|
matched = [c for c in job_certs if c.lower() in profile_certs]
|
|
score = (len(matched) / len(job_certs)) * 100 if job_certs else 75
|
|
|
|
return {"score": round(score), "matched": matched, "required": job_certs}
|
|
|
|
|
|
def score_job(profile, job_title, job_text, weights=None, aspirational_skills=None):
|
|
if weights is None:
|
|
weights = {
|
|
"skills": 0.40,
|
|
"title": 0.25,
|
|
"experience": 0.15,
|
|
"keywords": 0.15,
|
|
"certifications": 0.05,
|
|
}
|
|
|
|
skill_result = score_skills(profile, job_text, aspirational_skills)
|
|
title_result = score_title(profile, job_title)
|
|
exp_result = score_experience(profile, job_text)
|
|
kw_result = score_keywords(profile, job_text)
|
|
cert_result = score_certifications(profile, job_text)
|
|
|
|
final_score = (
|
|
skill_result["score"] * weights["skills"]
|
|
+ title_result["score"] * weights["title"]
|
|
+ exp_result["score"] * weights["experience"]
|
|
+ kw_result["score"] * weights["keywords"]
|
|
+ cert_result["score"] * weights["certifications"]
|
|
)
|
|
final_score = round(final_score)
|
|
|
|
if final_score >= 80:
|
|
verdict = "Strong Match"
|
|
elif final_score >= 70:
|
|
verdict = "Good Match"
|
|
elif final_score >= 55:
|
|
verdict = "Weak Match"
|
|
else:
|
|
verdict = "Poor Match"
|
|
|
|
return {
|
|
"score": final_score,
|
|
"verdict": verdict,
|
|
"breakdown": {
|
|
"skills": skill_result,
|
|
"title": title_result,
|
|
"experience": exp_result,
|
|
"keywords": kw_result,
|
|
"certifications": cert_result,
|
|
},
|
|
"matched_skills": skill_result.get("matched", []),
|
|
"missing_skills": skill_result.get("missing", []),
|
|
}
|