# ============================================================================= # 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 — ." / "Overall: Moderate Fit — ." / "Overall: Weak Fit — ." 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