Spaces:
Sleeping
Sleeping
Download engine/explainer.py from ResumeSelectors/resume-analyzer: direct link, hf CLI and curl.
- Browser
- Download file 37.2 kB
-
https://huggingface.co/spaces/ResumeSelectors/resume-analyzer/resolve/main/engine/explainer.py
- Command line
-
hf download hf://spaces/ResumeSelectors/resume-analyzer/engine/explainer.py
-
curl -L -o explainer.py https://huggingface.co/spaces/ResumeSelectors/resume-analyzer/resolve/main/engine/explainer.py
37.2 kB
| # ============================================================================= | |
| # engine/explainer.py β Explainability Engine + Interview Question Generator | |
| # ============================================================================= | |
| # Takes a completed match result (from matcher.py) and adds two things: | |
| # | |
| # 1. A structured plain-language explanation of the score β the "why" | |
| # behind every number. This is the core USP of the entire system. | |
| # | |
| # 2. Targeted interview questions generated from the candidate's specific | |
| # CV profile β skill gaps, unverified claims, and red flags. | |
| # | |
| # LLM STRATEGY (three-tier, most reliable first): | |
| # Tier 1 β Groq API (llama-3.1-70b-versatile, free tier, fast) | |
| # Tier 2 β OpenAI API (gpt-4o, paid, best quality) | |
| # Tier 3 β Template fallback (no API needed, always works) | |
| # | |
| # The system tries Tier 1 first. On failure, falls back to Tier 2, then | |
| # Tier 3. Every candidate always gets interview questions β no silent gaps. | |
| # | |
| # PUBLIC API: | |
| # build_full_output(match_result, resume, jd) β dict β only call this | |
| # generate_summary(match_result, resume, jd) β str | |
| # generate_interview_questions(match_result, resume, jd) β list[dict] | |
| # ============================================================================= | |
| import os | |
| import sys | |
| import re | |
| import json | |
| import time | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| import config | |
| # ββ LLM client setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| _GROQ_OK = False | |
| _groq_client = None | |
| _groq_model = "" | |
| # Priority 1: Groq via OpenAI-compatible API (no groq package needed) | |
| _groq_key = os.getenv("GROQ_API_KEY", "") | |
| if _groq_key: | |
| try: | |
| from openai import OpenAI as _OpenAI | |
| _groq_client = _OpenAI( | |
| api_key = _groq_key, | |
| base_url = "https://api.groq.com/openai/v1", | |
| ) | |
| _groq_model = "llama-3.3-70b-versatile" | |
| _GROQ_OK = True | |
| except ImportError: | |
| pass | |
| # Priority 2: OpenAI directly | |
| if not _GROQ_OK: | |
| _openai_key = os.getenv("OPENAI_API_KEY", "") | |
| if _openai_key: | |
| try: | |
| from openai import OpenAI as _OpenAI | |
| _groq_client = _OpenAI(api_key=_openai_key) | |
| _groq_model = "gpt-4o-mini" | |
| _GROQ_OK = True | |
| except ImportError: | |
| pass | |
| # ============================================================================= | |
| # INTERNAL HELPERS | |
| # ============================================================================= | |
| def _load_json(value, fallback): | |
| if not value: | |
| return fallback | |
| if isinstance(value, (list, dict)): | |
| return value | |
| try: | |
| return json.loads(value) | |
| except (json.JSONDecodeError, TypeError): | |
| return fallback | |
| def _llm_call(prompt: str, system: str, max_tokens: int = 800) -> str: | |
| """ | |
| Call the configured LLM (Groq or OpenAI). Returns response text or "". | |
| """ | |
| if not _GROQ_OK or not _groq_client: | |
| return "" | |
| try: | |
| response = _groq_client.chat.completions.create( | |
| model = _groq_model, | |
| messages = [ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| max_tokens = max_tokens, | |
| temperature = 0.4, | |
| timeout = config.OPENAI_TIMEOUT, | |
| ) | |
| return response.choices[0].message.content.strip() | |
| except Exception: | |
| return "" | |
| # ============================================================================= | |
| # 1. EXPLAINABILITY SUMMARY | |
| # ============================================================================= | |
| def _verdict_from_tier(tier: str) -> str: | |
| """Map matcher tier β human verdict label used in the recruiter summary.""" | |
| t = (tier or "").strip().lower() | |
| if "strong" in t: return "Strong Fit" | |
| if "border" in t or "moderate" in t or "medium" in t: return "Moderate Fit" | |
| return "Weak Fit" | |
| def _template_recruiter_summary(name: str, title: str, exp_years: float, | |
| current_role: str, matched: list, missing: list, | |
| standout: list, achievements: list, tier: str, | |
| req_years: float, education: str) -> str: | |
| """ | |
| Deterministic 80-120 word recruiter summary used when the LLM is | |
| unavailable or returns an unusable response. Hits the same six bullets the | |
| spec asks for, just less fluent than an LLM output. | |
| """ | |
| verdict = _verdict_from_tier(tier) | |
| role_str = current_role or "previous role unspecified in CV" | |
| exp_str = (f"{exp_years:.0f}+ years" if exp_years >= 1 | |
| else "early-career (under a year)") | |
| top_match = (", ".join(matched[:4]) if matched | |
| else "no directly matching skills") | |
| edu_str = "" | |
| if education: | |
| edu_str = f" Education on file: {education}." | |
| standout_str = "" | |
| if standout: | |
| standout_str = ( | |
| f" Brings depth beyond the JD in {', '.join(standout[:3])}, " | |
| f"which can add lift on adjacent work." | |
| ) | |
| gap_str = "" | |
| if missing: | |
| gap_str = ( | |
| f" Important gap to probe at interview: {', '.join(missing[:2])}, " | |
| f"both called out in the JD as required." | |
| ) | |
| elif req_years > exp_years > 0: | |
| gap_str = ( | |
| f" Experience runs {req_years - exp_years:.1f} years short of the " | |
| f"JD's {req_years:.0f}-year bar; weigh against demonstrated impact." | |
| ) | |
| ach_str = "" | |
| if achievements: | |
| a = achievements[0] | |
| if len(a) > 110: | |
| a = a[:107] + "β¦" | |
| ach_str = f" Notable on record: {a.rstrip('.')}." | |
| strength_str = "" | |
| if matched: | |
| strength_str = ( | |
| f" Strongest direct alignment is on {matched[0]} and " | |
| f"{matched[1] if len(matched) > 1 else 'the JD core stack'}, " | |
| f"making them worth a screen for {title}." | |
| ) | |
| fit_reason = { | |
| "Strong Fit" : "most must-haves are present and experience clears the bar.", | |
| "Moderate Fit": "core skills match but key gaps remain; suitable with caveats.", | |
| "Weak Fit" : "major JD requirements are unmet; pass unless pipeline is thin.", | |
| }[verdict] | |
| parts = [ | |
| f"{name} brings {exp_str} of experience, currently {role_str}.", | |
| f"Strongest direct matches for the {title} role: {top_match}.", | |
| edu_str, | |
| ach_str, | |
| strength_str, | |
| standout_str, | |
| gap_str, | |
| f"Overall: {verdict} β {fit_reason}", | |
| ] | |
| out = " ".join(p.strip() for p in parts if p and p.strip()) | |
| return re.sub(r"\s+", " ", out).strip() | |
| def generate_summary(match_result: dict, resume: dict, jd: dict) -> str: | |
| """ | |
| Recruiter-focused candidate-vs-JD summary (80-120 words). | |
| Format spec (per project requirements): | |
| - Mention total experience and current role | |
| - Highlight strongest matching skills / domain relevant to the JD | |
| - Mention notable achievements or responsibilities | |
| - Identify strengths that make the candidate suitable | |
| - Briefly mention any important gap or limitation if relevant | |
| - End with overall fit verdict (Strong / Moderate / Weak Fit with reason) | |
| Uses the LLM when available (Groq β OpenAI). Falls back to a deterministic | |
| template that still covers all six points if no LLM is reachable. | |
| Args: | |
| match_result : Dict from matcher.match_resume_to_jd() | |
| resume : Dict from extractor.build_resume_record() | |
| jd : Dict from jd_parser.parse_jd_text() | |
| Returns: | |
| Plain-text paragraph, ~80-120 words. | |
| """ | |
| name = resume.get("candidate_name") or "The candidate" | |
| title = jd.get("jd_title") or "the role" | |
| tier = match_result.get("confidence_tier", "Unknown") | |
| score = match_result.get("final_score", 0) | |
| matched = _load_json(match_result.get("matched_skills"), []) | |
| missing = _load_json(match_result.get("missing_skills"), []) | |
| standout = _load_json(match_result.get("standout_skills"), []) | |
| required = _load_json(jd.get("required_skills"), []) | |
| exp_years = float(resume.get("total_experience_years") or | |
| resume.get("experience_years") or 0) | |
| req_years = float(jd.get("required_experience") or 0) | |
| avg_tenure = float(resume.get("avg_tenure_months") or 0) | |
| recency_flag = (resume.get("recency_flag") or "Unknown").title() | |
| job_titles = _load_json(resume.get("job_titles"), []) | |
| achievements = _load_json(resume.get("achievements"), []) | |
| last_company = (resume.get("last_company") or "").strip() | |
| current_role = (job_titles[0] if job_titles else "").strip() | |
| if current_role and last_company: | |
| current_role = f"{current_role} at {last_company}" | |
| edu_data = _load_json(resume.get("education"), {}) | |
| candidate_deg = edu_data.get("degree", "") | |
| # ββ LLM path ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if _GROQ_OK: | |
| concerns = [] | |
| if avg_tenure > 0 and avg_tenure < config.MIN_TENURE_MONTHS: | |
| concerns.append( | |
| f"avg tenure {avg_tenure:.0f} months (below " | |
| f"{config.MIN_TENURE_MONTHS}-month threshold)" | |
| ) | |
| if recency_flag == "Stale": | |
| concerns.append("key skills not used in most recent role") | |
| if req_years and exp_years < req_years: | |
| concerns.append( | |
| f"experience {exp_years:.1f} yrs vs {req_years:.0f} yrs required" | |
| ) | |
| system = ( | |
| "You are a senior technical recruiter writing a decision-oriented " | |
| "summary for a hiring manager. Output ONE paragraph of plain prose, " | |
| "80 to 120 words. No bullet points, no headings, no markdown, no " | |
| "preamble like 'Here is the summary'. Be specific to the candidate " | |
| "and the JD; do not paraphrase the resume. Do not invent facts " | |
| "that are not in the data provided." | |
| ) | |
| prompt = f"""Write a recruiter summary comparing this candidate to the JD. | |
| Candidate: {name} | |
| Total experience (computed to today's date): {exp_years:.1f} years | |
| Current role: {current_role or 'unknown'} | |
| Education: {candidate_deg or 'not stated'} | |
| Top notable achievements: {'; '.join(achievements[:3]) if achievements else 'none listed'} | |
| JD title: {title} | |
| JD required experience: {req_years:.0f} years | |
| JD required skills: {', '.join(required[:10]) if required else 'unspecified'} | |
| Match details: | |
| - Skills matched with JD: {', '.join(matched[:8]) if matched else 'none'} | |
| - Strong/standout skills beyond JD: {', '.join(standout[:5]) if standout else 'none'} | |
| - Missing required skills: {', '.join(missing[:5]) if missing else 'none'} | |
| - Concerns: {'; '.join(concerns) if concerns else 'none'} | |
| - Composite match score: {score:.1f}/100 | |
| - System verdict: {_verdict_from_tier(tier)} | |
| Required content, in order, all in one paragraph (80β120 words): | |
| 1) Total experience and current role. | |
| 2) Strongest matching skills / domain relevance to the JD. | |
| 3) One notable achievement or responsibility (only if listed above). | |
| 4) Strengths that make this candidate suitable for the role. | |
| 5) The most important gap or limitation (only if relevant β skip if none). | |
| 6) End with EXACTLY one of these verdicts followed by a short reason: | |
| "Overall: Strong Fit β <reason>." / "Overall: Moderate Fit β <reason>." / "Overall: Weak Fit β <reason>." | |
| Do not repeat the resume verbatim. Do not hallucinate skills, employers, or | |
| achievements not listed above. Output ONLY the paragraph.""" | |
| raw = _llm_call(prompt, system, max_tokens=400) | |
| if raw: | |
| # Strip stray markdown fences / leading labels | |
| clean = re.sub(r"^```(?:\w+)?|```$", "", raw.strip(), | |
| flags=re.MULTILINE).strip() | |
| clean = re.sub(r"^(summary|recruiter summary)\s*[:\-]\s*", "", | |
| clean, flags=re.IGNORECASE).strip() | |
| # Sanity-check word count (50β180 keeps near-misses) | |
| wc = len(clean.split()) | |
| if 50 <= wc <= 180 and "Overall:" in clean: | |
| return clean | |
| # ββ Template fallback ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| edu_str = candidate_deg | |
| if candidate_deg and edu_data.get("field"): | |
| edu_str = f"{candidate_deg} in {edu_data.get('field')}" | |
| return _template_recruiter_summary( | |
| name=name, title=title, exp_years=exp_years, | |
| current_role=current_role, matched=matched, missing=missing, | |
| standout=standout, achievements=achievements, tier=tier, | |
| req_years=req_years, education=edu_str, | |
| ) | |
| # ============================================================================= | |
| # 2. INTERVIEW QUESTION GENERATOR | |
| # ============================================================================= | |
| def _template_questions(match_result: dict, | |
| resume: dict, | |
| jd: dict) -> list: | |
| """ | |
| Generate interview questions from templates β no LLM needed. | |
| This is Tier 3: always available, always produces sensible questions. | |
| Used when both Groq and OpenAI are unavailable or fail. | |
| Returns: | |
| List of question dicts: | |
| [{"type": "Skill Validation", "question": "..."}] | |
| """ | |
| name = resume.get("candidate_name") or "the candidate" | |
| matched = _load_json(match_result.get("matched_skills"), []) | |
| missing = _load_json(match_result.get("missing_skills"), []) | |
| avg_tenure = float(resume.get("avg_tenure_months") or 0) | |
| recency = (resume.get("recency_flag") or "Unknown").title() | |
| exp_years = float(resume.get("experience_years") or 0) | |
| req_years = float(jd.get("required_experience") or 0) | |
| projects = _load_json(resume.get("projects"), []) | |
| jd_title = jd.get("jd_title") or "this role" | |
| questions = [] | |
| cfg = config.INTERVIEW_QUESTIONS_PER_TYPE | |
| # ββ Skill Validation (verify top matched skills) ββββββββββββββββββββββββββ | |
| sv_count = 0 | |
| for skill in matched[:4]: | |
| if sv_count >= cfg.get("skill_validation", 2): | |
| break | |
| questions.append({ | |
| "type" : "Skill Validation", | |
| "question": ( | |
| f"Your CV highlights {skill} as a key skill. " | |
| f"Can you walk me through the most complex problem " | |
| f"you solved using {skill}, and what the business impact was?" | |
| ), | |
| }) | |
| sv_count += 1 | |
| # If no matched skills, ask about top resume skills | |
| if not matched: | |
| resume_skills = _load_json(resume.get("skills"), []) | |
| for skill in resume_skills[:cfg.get("skill_validation", 2)]: | |
| questions.append({ | |
| "type" : "Skill Validation", | |
| "question": ( | |
| f"Tell me about a project where {skill} was central " | |
| f"to your work. What was your specific contribution?" | |
| ), | |
| }) | |
| # ββ Gap Probing (explore missing JD requirements) βββββββββββββββββββββββββ | |
| gp_count = 0 | |
| for skill in missing[:4]: | |
| if gp_count >= cfg.get("gap_probing", 2): | |
| break | |
| questions.append({ | |
| "type" : "Gap Probing", | |
| "question": ( | |
| f"This role requires experience with {skill}, which isn't " | |
| f"explicitly mentioned on your CV. Have you worked with it " | |
| f"in any capacity, and how quickly do you think you could " | |
| f"get up to speed?" | |
| ), | |
| }) | |
| gp_count += 1 | |
| # If no missing skills, probe the JD preferred skills | |
| if not missing: | |
| preferred = _load_json(jd.get("preferred_skills"), []) | |
| for skill in preferred[:cfg.get("gap_probing", 2)]: | |
| questions.append({ | |
| "type" : "Gap Probing", | |
| "question": ( | |
| f"We'd ideally like someone with {skill} exposure. " | |
| f"What's your familiarity with it, and how have you " | |
| f"approached learning new tools in the past?" | |
| ), | |
| }) | |
| # ββ Behavioural / Concern βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| beh_count = 0 | |
| limit = cfg.get("behavioural", 1) | |
| if avg_tenure > 0 and avg_tenure < config.MIN_TENURE_MONTHS and beh_count < limit: | |
| questions.append({ | |
| "type" : "Behavioural", | |
| "question": ( | |
| f"Looking at your career history, your average tenure per role " | |
| f"is around {avg_tenure:.0f} months. Can you help me understand " | |
| f"what drove those transitions, and what would make " | |
| f"{jd_title} a long-term commitment for you?" | |
| ), | |
| }) | |
| beh_count += 1 | |
| if recency == "Stale" and beh_count < limit: | |
| top_skills = _load_json(resume.get("skills"), [])[:2] | |
| skill_str = " and ".join(top_skills) if top_skills else "your listed skills" | |
| questions.append({ | |
| "type" : "Behavioural", | |
| "question": ( | |
| f"It looks like {skill_str} may not have been used actively " | |
| f"in your most recent role. How have you kept those skills " | |
| f"current, and can you share a recent example of applying them?" | |
| ), | |
| }) | |
| beh_count += 1 | |
| if beh_count < limit and exp_years < req_years and req_years > 0: | |
| shortfall = round(req_years - exp_years, 1) | |
| questions.append({ | |
| "type" : "Behavioural", | |
| "question": ( | |
| f"This role typically requires around {req_years:.0f} years of " | |
| f"experience, and you have {exp_years:.0f}. What specific " | |
| f"experiences do you feel compensate for that gap, and how " | |
| f"have you handled situations that required knowledge beyond " | |
| f"your experience level?" | |
| ), | |
| }) | |
| beh_count += 1 | |
| if beh_count < limit and projects: | |
| questions.append({ | |
| "type" : "Behavioural", | |
| "question": ( | |
| f"Tell me about '{projects[0]}' from your CV. " | |
| f"What was the biggest challenge you faced, and how did " | |
| f"you handle it under pressure?" | |
| ), | |
| }) | |
| return questions[:sum(cfg.values())] | |
| def _llm_questions(match_result: dict, | |
| resume: dict, | |
| jd: dict) -> list: | |
| """ | |
| Generate interview questions using an LLM (Groq β OpenAI fallback). | |
| Prompts the model for structured JSON output β one question object per | |
| type. Falls back to template questions if LLM output is invalid. | |
| Returns: | |
| List of {"type": str, "question": str} dicts, | |
| or empty list if LLM is unavailable. | |
| """ | |
| if not _GROQ_OK: | |
| return [] | |
| name = resume.get("candidate_name") or "the candidate" | |
| jd_title = jd.get("jd_title") or "the role" | |
| matched = _load_json(match_result.get("matched_skills"), []) | |
| missing = _load_json(match_result.get("missing_skills"), []) | |
| standout = _load_json(match_result.get("standout_skills"), []) | |
| avg_tenure = float(resume.get("avg_tenure_months") or 0) | |
| recency = resume.get("recency_flag") or "Unknown" | |
| exp_years = float(resume.get("experience_years") or 0) | |
| req_years = float(jd.get("required_experience") or 0) | |
| projects = _load_json(resume.get("projects"), []) | |
| cfg = config.INTERVIEW_QUESTIONS_PER_TYPE | |
| sv = cfg.get("skill_validation", 2) | |
| gp = cfg.get("gap_probing", 2) | |
| bh = cfg.get("behavioural", 1) | |
| concern_lines = [] | |
| if avg_tenure > 0 and avg_tenure < config.MIN_TENURE_MONTHS: | |
| concern_lines.append( | |
| f"- Average tenure is {avg_tenure:.0f} months " | |
| f"(below {config.MIN_TENURE_MONTHS}-month threshold)" | |
| ) | |
| if recency == "Stale": | |
| concern_lines.append("- Key skills may be out of date β not used recently") | |
| if exp_years < req_years and req_years > 0: | |
| concern_lines.append( | |
| f"- Experience gap: {exp_years} yrs vs {req_years} yrs required" | |
| ) | |
| system = ( | |
| "You are an expert HR interviewer helping a recruiter evaluate candidates. " | |
| "Generate precise, role-specific interview questions based on the candidate profile provided. " | |
| "Return ONLY a valid JSON array. No explanation, no markdown fences, no extra text. " | |
| "Each item: {\"type\": string, \"question\": string}" | |
| ) | |
| prompt = f"""Candidate: {name} | |
| Role: {jd_title} | |
| Matched skills: {', '.join(matched) if matched else 'None'} | |
| Missing required skills: {', '.join(missing) if missing else 'None'} | |
| Standout skills (beyond JD): {', '.join(standout[:3]) if standout else 'None'} | |
| Notable projects: {', '.join(projects[:2]) if projects else 'None'} | |
| Red flags / concerns: | |
| {chr(10).join(concern_lines) if concern_lines else '- None identified'} | |
| Generate exactly {sv + gp + bh} interview questions: | |
| - {sv} "Skill Validation" questions: probe depth of claimed skills or standout skills | |
| - {gp} "Gap Probing" questions: explore missing skills or weak areas | |
| - {bh} "Behavioural" questions: address the red flags above, or strongest project | |
| Rules: | |
| - Each question must be specific to THIS candidate β no generic questions | |
| - Reference actual skill names, tenure numbers, or project names from above | |
| - Questions should be open-ended, not yes/no | |
| - Return ONLY the JSON array""" | |
| raw = _llm_call(prompt, system, max_tokens=900) | |
| if not raw: | |
| return [] | |
| # Parse JSON β strip accidental markdown fences | |
| clean = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.MULTILINE).strip() | |
| try: | |
| parsed = json.loads(clean) | |
| if not isinstance(parsed, list): | |
| return [] | |
| # Validate structure | |
| valid = [] | |
| for item in parsed: | |
| if (isinstance(item, dict) and | |
| "type" in item and | |
| "question" in item and | |
| len(item["question"]) > 10): | |
| valid.append({ | |
| "type" : str(item["type"]).strip(), | |
| "question": str(item["question"]).strip(), | |
| }) | |
| return valid | |
| except (json.JSONDecodeError, KeyError, TypeError): | |
| return [] | |
| def generate_interview_questions(match_result: dict, | |
| resume: dict, | |
| jd: dict) -> list: | |
| """ | |
| Generate targeted interview questions for a shortlisted candidate. | |
| Three-tier strategy: | |
| 1. LLM (Groq β OpenAI): personalised, context-aware questions | |
| 2. Templates: rule-based questions from structured profile data | |
| (automatic fallback if LLM unavailable or returns invalid output) | |
| Args: | |
| match_result : Dict from matcher.match_resume_to_jd() | |
| resume : Dict from extractor.build_resume_record() | |
| jd : Dict from jd_parser.parse_jd_text() | |
| Returns: | |
| List of dicts: [{"type": "Skill Validation", "question": "..."}, ...] | |
| Always returns at least 3 questions. | |
| """ | |
| # Try LLM first | |
| questions = _llm_questions(match_result, resume, jd) | |
| # Fall back to templates if LLM returned nothing useful | |
| if len(questions) < 2: | |
| questions = _template_questions(match_result, resume, jd) | |
| return questions | |
| # ============================================================================= | |
| # 3. BIAS REPORT | |
| # ============================================================================= | |
| def generate_bias_report(match_results: list, jd: dict) -> dict: | |
| """ | |
| Analyse a shortlist for potential proxy bias signals. | |
| Checks: | |
| - Over-representation of a single institution type in top-N | |
| - Score distribution across candidates (wide gap may suggest threshold bias) | |
| Args: | |
| match_results : List of match result dicts, sorted by final_score DESC | |
| (output of database.get_matches_by_jd()) | |
| jd : JD dict | |
| Returns: | |
| { | |
| "top5_institution_flag" : bool, | |
| "institution_breakdown" : dict, | |
| "score_range" : {"min": float, "max": float, "spread": float}, | |
| "flags" : list[str], # plain-language warnings | |
| "recommendation" : str, | |
| } | |
| """ | |
| top5 = match_results[:5] | |
| flags = [] | |
| # Institution analysis β count how many top-5 share the same institution | |
| # Education is a JSON string in match_results joined from resumes table | |
| institution_counts = {} | |
| for r in top5: | |
| edu = _load_json(r.get("education"), {}) | |
| inst = (edu.get("institution") or "Unknown").strip().title() | |
| institution_counts[inst] = institution_counts.get(inst, 0) + 1 | |
| top_inst = max(institution_counts, key=institution_counts.get, default="") | |
| top_inst_share = institution_counts.get(top_inst, 0) / max(len(top5), 1) | |
| if top_inst_share > config.BIAS_INSTITUTION_THRESHOLD: | |
| flags.append( | |
| f"{top_inst_share:.0%} of your top-5 shortlist are from " | |
| f"'{top_inst}'. Review whether institution prestige is " | |
| f"influencing rankings beyond skill relevance." | |
| ) | |
| # Score spread analysis | |
| scores = [r.get("final_score", 0) for r in match_results] | |
| if scores: | |
| score_min = min(scores) | |
| score_max = max(scores) | |
| score_spread = score_max - score_min | |
| if score_spread > 60: | |
| flags.append( | |
| f"Score spread is {score_spread:.0f} points " | |
| f"({score_min:.0f}β{score_max:.0f}). " | |
| f"Candidates near the cutoff threshold deserve manual review." | |
| ) | |
| else: | |
| score_min = score_max = score_spread = 0.0 | |
| recommendation = ( | |
| "No significant bias signals detected in this shortlist." | |
| if not flags else | |
| "Manual review recommended for the flagged items above before finalising shortlist." | |
| ) | |
| return { | |
| "top5_institution_flag" : top_inst_share > config.BIAS_INSTITUTION_THRESHOLD, | |
| "institution_breakdown" : institution_counts, | |
| "score_range" : { | |
| "min" : round(score_min, 2), | |
| "max" : round(score_max, 2), | |
| "spread": round(score_spread, 2), | |
| }, | |
| "flags" : flags, | |
| "recommendation" : recommendation, | |
| } | |
| # ============================================================================= | |
| # 4. JD-AWARE STRENGTHS / WEAKNESSES | |
| # ============================================================================= | |
| def generate_jd_pros_cons(match_result: dict, resume: dict, jd: dict) -> tuple: | |
| """ | |
| Produce JD-aware Strengths (pros) and Weaknesses (cons) for the candidate | |
| card. These differ from the generic resume_data pros/cons because they | |
| EXPLICITLY compare the candidate against the JD, so a recruiter gets a | |
| clear "where they fit / where they don't" signal at a glance. | |
| Output format: | |
| (pros: list[str], cons: list[str]) | |
| Each list contains 3-5 short, action-oriented bullets. Strategy: | |
| 1. Try LLM (Groq β OpenAI) for nuanced, candidate-specific phrasing. | |
| 2. Fall back to a deterministic rule-based builder that hits the same | |
| points using matched/missing skills, experience gap, recency, | |
| stability, and achievement signals. | |
| """ | |
| name = resume.get("candidate_name") or "the candidate" | |
| title = jd.get("jd_title") or "the role" | |
| matched = _load_json(match_result.get("matched_skills"), []) | |
| missing = _load_json(match_result.get("missing_skills"), []) | |
| standout = _load_json(match_result.get("standout_skills"), []) | |
| required = _load_json(jd.get("required_skills"), []) | |
| exp_years = float(resume.get("total_experience_years") or | |
| resume.get("experience_years") or 0) | |
| req_years = float(jd.get("required_experience") or 0) | |
| avg_tenure = float(resume.get("avg_tenure_months") or 0) | |
| recency_flag = (resume.get("recency_flag") or "Unknown").title() | |
| achievements = _load_json(resume.get("achievements"), []) | |
| certs = _load_json(resume.get("certifications"), []) | |
| edu_data = _load_json(resume.get("education"), {}) | |
| candidate_deg = edu_data.get("degree", "") | |
| jd_edu_req = jd.get("education_requirement", "") | |
| # ββ LLM path ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if _GROQ_OK: | |
| system = ( | |
| "You are a senior recruiter. Produce ONE JSON object with exactly " | |
| "two keys: \"pros\" and \"cons\". Each value is a JSON list of 3-5 " | |
| "short bullet strings (β€18 words each). Every bullet MUST compare " | |
| "the candidate to the JD β not just describe the resume. NO markdown, " | |
| "NO commentary, NO preamble. Output ONLY the JSON object." | |
| ) | |
| prompt = f"""Compare this candidate to the JD and produce JD-aware pros and cons. | |
| Candidate: {name} | |
| Total experience (today): {exp_years:.1f} yrs | |
| Education: {candidate_deg or 'not stated'} | |
| Avg tenure per role: {avg_tenure:.0f} months | |
| Recency: {recency_flag} | |
| Top achievements: {'; '.join(achievements[:3]) if achievements else 'none'} | |
| Certifications: {', '.join(certs[:5]) if certs else 'none'} | |
| JD title: {title} | |
| JD required experience: {req_years:.0f} yrs | |
| JD required skills: {', '.join(required[:10]) if required else 'unspecified'} | |
| JD education requirement: {jd_edu_req or 'unspecified'} | |
| Match details: | |
| - Matched skills (resume vs JD): {', '.join(matched[:8]) if matched else 'none'} | |
| - Standout skills beyond JD: {', '.join(standout[:5]) if standout else 'none'} | |
| - Missing JD-required skills: {', '.join(missing[:5]) if missing else 'none'} | |
| Pros rules: | |
| - Each bullet must explain WHY it's a strength FOR THIS JD. | |
| - Reference an actual matched skill, year count, achievement, or cert. | |
| - No generic praise ("strong candidate", "good fit"). | |
| Cons rules: | |
| - Each bullet must explain WHY it's a gap FOR THIS JD. | |
| - Reference an actual missing skill, experience shortfall, tenure issue, | |
| recency issue, or education mismatch. | |
| - If there are genuinely no significant gaps, return a one-bullet cons list | |
| saying so. | |
| Output JSON only, e.g. {{"pros":["..."], "cons":["..."]}}""" | |
| raw = _llm_call(prompt, system, max_tokens=500) | |
| if raw: | |
| clean = re.sub(r"^```(?:\w+)?|```$", "", raw.strip(), | |
| flags=re.MULTILINE).strip() | |
| try: | |
| obj = json.loads(clean) | |
| if (isinstance(obj, dict) and | |
| isinstance(obj.get("pros"), list) and | |
| isinstance(obj.get("cons"), list)): | |
| pros = [str(p).strip() for p in obj["pros"] if str(p).strip()][:5] | |
| cons = [str(c).strip() for c in obj["cons"] if str(c).strip()][:5] | |
| if pros and cons: | |
| return pros, cons | |
| except (json.JSONDecodeError, TypeError): | |
| pass | |
| # ββ Rule-based fallback βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| pros: list = [] | |
| cons: list = [] | |
| # Pros β start with the strongest skill match | |
| if matched: | |
| top = matched[:3] | |
| if len(top) == 1: | |
| pros.append(f"Direct match on JD-required {top[0]}.") | |
| else: | |
| pros.append(f"Direct matches on JD-required {', '.join(top)}.") | |
| if exp_years > 0 and req_years > 0 and exp_years >= req_years: | |
| pros.append( | |
| f"Experience of {exp_years:.1f} yrs clears the JD's " | |
| f"{req_years:.0f}-yr bar." | |
| ) | |
| elif exp_years >= 3: | |
| pros.append(f"Brings {exp_years:.1f} yrs of relevant experience.") | |
| if standout: | |
| pros.append( | |
| f"Adds depth beyond the JD in {', '.join(standout[:3])}." | |
| ) | |
| if recency_flag == "Recent": | |
| pros.append("Currently active in the field β skills look current.") | |
| if avg_tenure >= 24: | |
| pros.append( | |
| f"Stable career history (avg {avg_tenure:.0f} months per role)." | |
| ) | |
| if achievements: | |
| a = achievements[0] | |
| if len(a) > 110: | |
| a = a[:107] + "β¦" | |
| pros.append(f"Notable achievement: {a.rstrip('.')}.") | |
| if certs: | |
| pros.append(f"Holds {len(certs)} relevant certification(s).") | |
| if not pros: | |
| pros.append( | |
| f"Resume on file but limited direct alignment with {title}; " | |
| f"manual review recommended." | |
| ) | |
| # Cons β start with the most damaging gap | |
| if missing: | |
| if len(missing) <= 2: | |
| cons.append( | |
| f"Missing JD-required {', '.join(missing)} β verify at interview." | |
| ) | |
| else: | |
| cons.append( | |
| f"Missing {len(missing)} JD-required skills " | |
| f"({', '.join(missing[:3])}β¦)." | |
| ) | |
| if req_years > 0 and exp_years < req_years: | |
| shortfall = req_years - exp_years | |
| cons.append( | |
| f"Experience runs {shortfall:.1f} yrs short of the " | |
| f"{req_years:.0f}-yr JD bar." | |
| ) | |
| if avg_tenure > 0 and avg_tenure < config.MIN_TENURE_MONTHS: | |
| cons.append( | |
| f"Avg tenure of {avg_tenure:.0f} months suggests job-hopping risk " | |
| f"(JD threshold: {config.MIN_TENURE_MONTHS} months)." | |
| ) | |
| if recency_flag == "Stale": | |
| cons.append( | |
| "Key skills may be stale β not visible in most-recent role." | |
| ) | |
| if jd_edu_req and candidate_deg: | |
| if not any(d.lower() in candidate_deg.lower() | |
| for d in jd_edu_req.split("/")): | |
| cons.append( | |
| f"Education mismatch: JD asks {jd_edu_req}, candidate has " | |
| f"{candidate_deg}." | |
| ) | |
| if not cons: | |
| cons.append( | |
| "No significant gaps detected against this JD β strong alignment." | |
| ) | |
| return pros[:5], cons[:5] | |
| # ============================================================================= | |
| # MASTER FUNCTION β Only Call From main.py | |
| # ============================================================================= | |
| def build_full_output(match_result: dict, | |
| resume: dict, | |
| jd: dict) -> dict: | |
| """ | |
| Enrich a match result with explainability summary, interview questions | |
| and JD-aware strengths/weaknesses. | |
| Takes the raw match result from matcher.match_resume_to_jd() and adds: | |
| - A recruiter-focused summary (80-120 words, JD-comparative) | |
| - Targeted interview questions (LLM or template-based) | |
| - JD-aware pros and cons (strengths/weaknesses vs the specific JD) | |
| The returned dict is a direct drop-in replacement for the match_result β | |
| all original keys are preserved and the following fields are added/updated: | |
| - "summary" : recruiter-focused JD-comparative paragraph | |
| - "interview_questions" : JSON list of question dicts | |
| - "pros_jd" : JSON list of JD-aware strength bullets | |
| - "cons_jd" : JSON list of JD-aware weakness bullets | |
| Args: | |
| match_result : Dict from matcher.match_resume_to_jd() | |
| resume : Dict from extractor.build_resume_record() | |
| jd : Dict from jd_parser.parse_jd_text() | |
| Returns: | |
| Enriched match_result dict, ready for database.insert_match_result() | |
| """ | |
| enriched = dict(match_result) # Shallow copy β do not mutate the original | |
| # Generate structured summary | |
| enriched["summary"] = generate_summary(match_result, resume, jd) | |
| # Generate interview questions | |
| questions = generate_interview_questions(match_result, resume, jd) | |
| enriched["interview_questions"] = json.dumps(questions) | |
| # Generate JD-aware pros and cons | |
| pros_jd, cons_jd = generate_jd_pros_cons(match_result, resume, jd) | |
| enriched["pros_jd"] = json.dumps(pros_jd) | |
| enriched["cons_jd"] = json.dumps(cons_jd) | |
| return enriched |