""" 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", []), }