resume-analyzer / engine /explainer.py
mohit kumar
Initial Resume Analyzer deploy
a4db4ad
Raw History Blame Contribute Delete
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