chore: untrack and ignore venv directory
This commit is contained in:
+257
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
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", []),
|
||||
}
|
||||
Reference in New Issue
Block a user