""" explainability.py ----------------- Resume matching explainability -- identify key phrases and sections that drove the score. Features: - Extract high-impact phrases from resume matching JD - Section-level importance (experience, skills, education) - Attention-based highlighting - SHAP-inspired local explanations (without external models) Author: SmartHire AI """ import logging import re from typing import Dict, List, Tuple logger = logging.getLogger(__name__) def extract_sections(resume_text: str) -> Dict[str, str]: """Parse resume into common sections (Education, Experience, Skills, etc).""" sections = { "experience": "", "education": "", "skills": "", "projects": "", "certifications": "", "summary": "", "other": "", } # Section headers (flexible matching) section_patterns = { "experience": r"(?i)(professional\s+experience|work\s+experience|employment|career)", "education": r"(?i)(education|academic|degree|university|college)", "skills": r"(?i)(technical\s+skills|skills|competencies|expertise)", "projects": r"(?i)(projects?|portfolio|achievements)", "certifications": r"(?i)(certifications?|licenses|credentials)", "summary": r"(?i)(professional\s+summary|objective|executive\s+summary|about)", } lines = resume_text.split('\n') current_section = "summary" for line in lines: matched = False for section_key, pattern in section_patterns.items(): if re.search(pattern, line): current_section = section_key matched = True break if matched or not line.strip(): continue sections[current_section] += line + "\n" return {k: v.strip() for k, v in sections.items() if v.strip()} def extract_key_phrases(text: str, jd_text: str, top_k: int = 5) -> List[Tuple[str, float]]: """ Extract phrases from resume that appear in JD (TF-IDF style scoring). Returns list of (phrase, importance_score) tuples. """ # Simple phrase extraction (2-4 word chunks) phrases = [] words = re.findall(r'\w+', text.lower()) for i in range(len(words) - 1): for j in range(i + 2, min(i + 5, len(words) + 1)): phrase = ' '.join(words[i:j]) phrases.append(phrase) # Score each phrase by frequency in JD jd_lower = jd_text.lower() scored_phrases = [] seen = set() for phrase in set(phrases): if phrase in seen: continue seen.add(phrase) count_jd = len(re.findall(rf'\b{re.escape(phrase)}\b', jd_lower)) count_resume = len(re.findall(rf'\b{re.escape(phrase)}\b', text.lower())) if count_jd > 0: score = count_jd * count_resume scored_phrases.append((phrase, score)) scored_phrases.sort(key=lambda x: x[1], reverse=True) return scored_phrases[:top_k] def compute_section_importance( resume_sections: Dict[str, str], jd_text: str, ) -> Dict[str, float]: """ Score each section by how much it overlaps with JD. Returns dict of section -> importance_score (0-100). """ scores = {} jd_lower = jd_text.lower() for section_name, section_text in resume_sections.items(): if not section_text: scores[section_name] = 0.0 continue section_words = set(re.findall(r'\b\w+\b', section_text.lower())) jd_words = set(re.findall(r'\b\w+\b', jd_lower)) if not section_words: scores[section_name] = 0.0 continue overlap = len(section_words & jd_words) score = min(100.0, (overlap / len(section_words)) * 100) scores[section_name] = round(score, 2) return scores def highlight_matching_phrases( resume_text: str, jd_text: str, top_k: int = 10, ) -> Dict: """ Generate a comprehensive explainability report. Returns: { "key_phrases": [(phrase, score), ...], "sections": { "experience": 75.5, ... }, "highlight_text": "Resume with highlighted phrases", "summary": "Human-readable explanation" } """ sections = extract_sections(resume_text) key_phrases = extract_key_phrases(resume_text, jd_text, top_k=top_k) section_scores = compute_section_importance(sections, jd_text) # Generate highlight text highlight_text = resume_text for phrase, score in key_phrases: if score > 0: highlight_text = re.sub( rf'\b{re.escape(phrase)}\b', f'**{phrase}**', highlight_text, flags=re.IGNORECASE ) # Generate summary top_section = max(section_scores.items(), key=lambda x: x[1]) if section_scores else ("", 0) summary = ( f"Key drivers: {', '.join(p[0] for p in key_phrases[:3])}. " f"Strongest section: {top_section[0]} ({top_section[1]:.0f}% alignment). " ) return { "key_phrases": key_phrases, "sections": section_scores, "highlight_text": highlight_text, "summary": summary, "top_section": top_section[0], "top_section_score": top_section[1], } def generate_explainability_report( candidate_name: str, resume_text: str, jd_text: str, match_score: float, skill_data: Dict, ) -> Dict: """ Generate a full explainability report for a candidate match. Includes: - Why the score is X% (key drivers) - Which resume sections matter most - Top matching phrases - Recommendations for improvement """ explainability = highlight_matching_phrases(resume_text, jd_text, top_k=7) # Compute improvement recommendations improvements = [] if skill_data.get("critical_missing"): improvements.append( f"Learn/gain experience in: {', '.join(skill_data['critical_missing'][:2])}" ) if skill_data.get("important_missing"): improvements.append( f"Strengthen: {', '.join(skill_data['important_missing'][:2])}" ) if match_score < 70 and explainability["top_section_score"] < 50: improvements.append("Restructure resume to emphasize relevant experience") return { "candidate": candidate_name, "match_score": match_score, "key_drivers": explainability["key_phrases"], "section_alignment": explainability["sections"], "strongest_section": explainability["top_section"], "summary": explainability["summary"], "improvements": improvements, "highlight_text": explainability["highlight_text"], }