← Back to DevBytes

Building a HR Screening Agent with LangGraph: Step-by-Step Tutorial

What is an HR Screening Agent?

An HR Screening Agent is an AI-powered application that automates the initial stages of the recruitment process. It reviews resumes, evaluates candidate qualifications against job requirements, conducts preliminary conversational screenings, and routes qualified candidates to human recruiters. Unlike traditional keyword-matching systems, a modern HR screening agent uses large language models (LLMs) to understand context, infer skills from experience descriptions, and engage in natural dialogue with applicants.

With LangGraph, you can build this agent as a stateful, multi-step workflow — one that doesn't just classify a resume once but can move through distinct stages: receiving a resume, asking follow-up questions, checking for gaps, making a decision, and logging the outcome.

Why This Matters

Recruiters spend up to 40% of their time on tasks that an intelligent agent can handle: screening out clearly unqualified candidates, verifying basic requirements, and scheduling follow-ups. An HR Screening Agent built with LangGraph delivers three critical advantages:

LangGraph is particularly well-suited here because recruitment screening is inherently multi-step: you review a resume, identify gaps, ask clarifying questions, possibly reject or advance the candidate, and log everything. A simple LLM call isn't enough — you need a coordinated sequence of decisions and actions.

Prerequisites and Setup

Before building the agent, install the required packages. You'll need LangGraph itself, an LLM provider (we'll use OpenAI), and a few utility libraries:

pip install langgraph langchain langchain-openai python-dotenv pydantic

Set up your environment variables. Create a .env file:

OPENAI_API_KEY=your-api-key-here

Now let's scaffold the project structure:

hr_screening_agent/
├── agent.py          # Main graph definition
├── state.py          # State schema
├── tools.py          # Resume parsing and evaluation tools
├── prompts.py        # LLM prompt templates
└── main.py           # Entry point to run the agent

Step 1: Define the State Schema

The state is the backbone of a LangGraph application. It holds everything the agent knows at any point in the workflow. For HR screening, the state must carry the candidate's information, the job requirements, the agent's internal reasoning, and a final decision.

Create state.py:

from typing import TypedDict, Optional, List
from langgraph.graph import State

class ScreeningState(TypedDict):
    # Input fields
    resume_text: str
    job_title: str
    job_requirements: str
    required_skills: List[str]
    min_years_experience: int
    
    # Parsed information extracted from resume
    candidate_name: Optional[str]
    candidate_email: Optional[str]
    candidate_skills: Optional[List[str]]
    candidate_years_experience: Optional[int]
    candidate_current_role: Optional[str]
    
    # Agent workflow fields
    parsed_summary: Optional[str]
    skill_gaps: Optional[List[str]]
    follow_up_questions: Optional[List[str]]
    candidate_responses: Optional[str]
    
    # Decision fields
    decision: Optional[str]  # "ADVANCE", "REJECT", "MORE_INFO"
    decision_reasoning: Optional[str]
    interview_notes: Optional[str]
    
    # Conversation tracking
    messages: List[str]
    current_stage: str

def create_initial_state(resume_text: str, job_title: str, 
                         job_requirements: str, required_skills: List[str],
                         min_years_experience: int) -> ScreeningState:
    return {
        "resume_text": resume_text,
        "job_title": job_title,
        "job_requirements": job_requirements,
        "required_skills": required_skills,
        "min_years_experience": min_years_experience,
        "candidate_name": None,
        "candidate_email": None,
        "candidate_skills": None,
        "candidate_years_experience": None,
        "candidate_current_role": None,
        "parsed_summary": None,
        "skill_gaps": None,
        "follow_up_questions": None,
        "candidate_responses": None,
        "decision": None,
        "decision_reasoning": None,
        "interview_notes": None,
        "messages": [],
        "current_stage": "parse_resume"
    }

This schema gives you a complete picture of what flows through the agent. Notice the current_stage field — it helps LangGraph's routing logic decide which node to invoke next.

Step 2: Build the Resume Parsing Node

The first real node in the graph extracts structured information from the raw resume text. This is where the LLM does its initial heavy lifting. Create prompts.py to hold your prompt templates:

RESUME_PARSING_PROMPT = """You are an expert HR resume parser. Extract the following information from the resume text below.
Return the data as valid JSON with these exact keys:
- candidate_name: Full name of the candidate
- candidate_email: Email address
- candidate_skills: List of skills mentioned (technical and soft skills)
- candidate_years_experience: Total years of professional experience (estimate if not explicit)
- candidate_current_role: Current or most recent job title
- parsed_summary: A 3-sentence summary of the candidate's background

Resume text:
{resume_text}

Return ONLY the JSON object, no additional text."""

SKILL_GAP_PROMPT = """You are an HR screener comparing a candidate against job requirements.

Job Title: {job_title}
Required Skills: {required_skills}
Minimum Years Experience: {min_years_experience}

Candidate Skills: {candidate_skills}
Candidate Experience Years: {candidate_years_experience}
Candidate Summary: {parsed_summary}

1. Identify which required skills the candidate clearly possesses.
2. Identify skill gaps — required skills that are missing or unclear.
3. If the candidate's years of experience is below the minimum, note this as a gap.
4. Generate 2-3 specific follow-up questions to clarify gaps or probe depth of experience.
5. Make a preliminary assessment: ADVANCE (meets all requirements), REJECT (clearly unqualified), or MORE_INFO (need follow-up).

Return valid JSON:
{{
    "matched_skills": [...],
    "skill_gaps": [...],
    "follow_up_questions": [...],
    "preliminary_decision": "ADVANCE" or "REJECT" or "MORE_INFO",
    "reasoning": "brief explanation"
}}"""

Now implement the parsing node in agent.py:

import json
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from state import ScreeningState
from prompts import RESUME_PARSING_PROMPT, SKILL_GAP_PROMPT

llm = ChatOpenAI(model="gpt-4o", temperature=0.2)

def parse_resume_node(state: ScreeningState) -> dict:
    """Extract structured candidate information from the resume."""
    state["messages"].append("Parsing resume...")
    
    prompt = RESUME_PARSING_PROMPT.format(resume_text=state["resume_text"])
    response = llm.invoke([HumanMessage(content=prompt)])
    
    # Parse the JSON response
    try:
        parsed = json.loads(response.content)
    except json.JSONDecodeError:
        # Fallback: try to extract JSON from the response
        import re
        json_match = re.search(r'\{.*\}', response.content, re.DOTALL)
        if json_match:
            parsed = json.loads(json_match.group(0))
        else:
            raise ValueError("Could not parse LLM response as JSON")
    
    return {
        "candidate_name": parsed.get("candidate_name"),
        "candidate_email": parsed.get("candidate_email"),
        "candidate_skills": parsed.get("candidate_skills", []),
        "candidate_years_experience": parsed.get("candidate_years_experience"),
        "candidate_current_role": parsed.get("candidate_current_role"),
        "parsed_summary": parsed.get("parsed_summary"),
        "current_stage": "analyze_gaps",
        "messages": state["messages"] + ["Resume parsed successfully."]
    }

Step 3: Build the Skill Gap Analysis Node

After parsing, the agent needs to compare the candidate's profile against the job requirements. This node decides whether the candidate moves forward, gets rejected, or needs follow-up questions:

def analyze_skill_gaps_node(state: ScreeningState) -> dict:
    """Compare candidate profile against job requirements."""
    state["messages"].append("Analyzing skill gaps...")
    
    prompt = SKILL_GAP_PROMPT.format(
        job_title=state["job_title"],
        required_skills=state["required_skills"],
        min_years_experience=state["min_years_experience"],
        candidate_skills=state["candidate_skills"],
        candidate_years_experience=state["candidate_years_experience"],
        parsed_summary=state["parsed_summary"]
    )
    
    response = llm.invoke([HumanMessage(content=prompt)])
    
    try:
        analysis = json.loads(response.content)
    except json.JSONDecodeError:
        import re
        json_match = re.search(r'\{.*\}', response.content, re.DOTALL)
        analysis = json.loads(json_match.group(0)) if json_match else {}
    
    preliminary_decision = analysis.get("preliminary_decision", "MORE_INFO")
    
    return {
        "skill_gaps": analysis.get("skill_gaps", []),
        "follow_up_questions": analysis.get("follow_up_questions", []),
        "decision": preliminary_decision,
        "decision_reasoning": analysis.get("reasoning", ""),
        "current_stage": preliminary_decision.lower(),  # routes to next node
        "messages": state["messages"] + [
            f"Skill gap analysis complete. Preliminary decision: {preliminary_decision}"
        ]
    }

Step 4: Define the Routing Logic

LangGraph uses conditional edges to route between nodes. After the gap analysis, the agent should go to different nodes based on the decision — advance the candidate, reject them, or ask follow-up questions:

from langgraph.graph import StateGraph, END

def route_after_gap_analysis(state: ScreeningState) -> str:
    """Determine which node to visit next based on the preliminary decision."""
    decision = state.get("decision", "MORE_INFO")
    
    if decision == "ADVANCE":
        return "advance_candidate"
    elif decision == "REJECT":
        return "reject_candidate"
    else:
        return "ask_follow_up"  # MORE_INFO path

Step 5: Implement the Follow-Up Node

When the agent needs more information, it should present questions to the candidate and process their responses. In a real system, this would involve an interactive interface. Here, we'll simulate it with a function that takes responses as input:

def ask_follow_up_node(state: ScreeningState) -> dict:
    """Present follow-up questions and process candidate responses."""
    questions = state.get("follow_up_questions", [])
    
    if not questions:
        return {
            "decision": "ADVANCE",
            "decision_reasoning": "No gaps identified, proceeding with candidate.",
            "current_stage": "advance_candidate",
            "messages": state["messages"] + ["No follow-up needed, advancing."]
        }
    
    # In a production system, you'd send these questions to the candidate
    # and wait for their response. Here we simulate with candidate_responses.
    formatted_questions = "\n".join([f"{i+1}. {q}" for i, q in enumerate(questions)])
    
    state["messages"].append(
        f"Follow-up questions sent to candidate:\n{formatted_questions}"
    )
    
    # If we already have responses, evaluate them
    if state.get("candidate_responses"):
        evaluation_prompt = f"""You are an HR screener. You asked the candidate these questions:

{formatted_questions}

The candidate responded:
{state["candidate_responses"]}

Based on these responses, make a final decision: ADVANCE or REJECT.
Return JSON: {{"final_decision": "ADVANCE" or "REJECT", "reasoning": "..."}}"""
        
        response = llm.invoke([HumanMessage(content=evaluation_prompt)])
        try:
            result = json.loads(response.content)
        except json.JSONDecodeError:
            result = {"final_decision": "ADVANCE", "reasoning": "Could not parse clearly"}
        
        final_decision = result.get("final_decision", "ADVANCE")
        
        return {
            "decision": final_decision,
            "decision_reasoning": result.get("reasoning", ""),
            "current_stage": "advance_candidate" if final_decision == "ADVANCE" else "reject_candidate",
            "messages": state["messages"] + [f"Follow-up evaluation: {final_decision}"]
        }
    
    # If no responses yet, pause the workflow
    return {
        "current_stage": "awaiting_responses",
        "messages": state["messages"]
    }

Step 6: Build the Decision Nodes

The advance and reject nodes finalize the screening process. They compile interview notes and produce a structured recommendation for the human recruiter:

def advance_candidate_node(state: ScreeningState) -> dict:
    """Finalize an advancement decision with interview preparation notes."""
    notes_prompt = f"""You are an HR screener advancing a candidate to the next round.
    
Candidate: {state["candidate_name"]}
Role: {state["job_title"]}
Candidate Summary: {state["parsed_summary"]}
Matched Skills: {[s for s in state["required_skills"] if s in (state.get("candidate_skills") or [])]}
Skill Gaps: {state.get("skill_gaps", [])}

Generate concise interview notes for the hiring manager:
1. Key strengths to probe deeper
2. Areas to verify or explore
3. Suggested interview focus areas

Keep it to 5 bullet points."""

    response = llm.invoke([HumanMessage(content=notes_prompt)])
    
    return {
        "decision": "ADVANCE",
        "interview_notes": response.content,
        "current_stage": "complete",
        "messages": state["messages"] + [
            f"Candidate {state['candidate_name']} ADVANCED to next round."
        ]
    }

def reject_candidate_node(state: ScreeningState) -> dict:
    """Finalize a rejection with constructive feedback notes."""
    rejection_prompt = f"""You are an HR screener. The candidate below has been rejected for the role.
    
Candidate: {state["candidate_name"]}
Role: {state["job_title"]}
Reason for rejection: {state.get("decision_reasoning", "Does not meet minimum requirements")}
Skill Gaps: {state.get("skill_gaps", [])}

Write a brief, respectful rejection rationale (2-3 sentences) that could be shared with the candidate. 
Focus on specific requirements not met, without being overly negative."""

    response = llm.invoke([HumanMessage(content=rejection_prompt)])
    
    return {
        "decision": "REJECT",
        "decision_reasoning": state.get("decision_reasoning", "") + "\n\nFeedback: " + response.content,
        "current_stage": "complete",
        "messages": state["messages"] + [
            f"Candidate {state['candidate_name']} REJECTED."
        ]
    }

Step 7: Assemble the LangGraph Graph

Now wire everything together. The graph defines the nodes, edges, and conditional routing that make the agent a coordinated workflow:

def build_screening_graph() -> StateGraph:
    """Construct the complete HR screening agent graph."""
    
    # Initialize the graph with our state schema
    graph = StateGraph(ScreeningState)
    
    # Add all nodes
    graph.add_node("parse_resume", parse_resume_node)
    graph.add_node("analyze_gaps", analyze_skill_gaps_node)
    graph.add_node("ask_follow_up", ask_follow_up_node)
    graph.add_node("advance_candidate", advance_candidate_node)
    graph.add_node("reject_candidate", reject_candidate_node)
    
    # Set the entry point — where the workflow begins
    graph.set_entry_point("parse_resume")
    
    # Add edges between nodes
    graph.add_edge("parse_resume", "analyze_gaps")
    
    # Conditional routing after gap analysis
    graph.add_conditional_edges(
        "analyze_gaps",
        route_after_gap_analysis,
        {
            "advance_candidate": "advance_candidate",
            "reject_candidate": "reject_candidate",
            "ask_follow_up": "ask_follow_up"
        }
    )
    
    # After follow-up, route based on the updated decision
    def route_after_follow_up(state: ScreeningState) -> str:
        decision = state.get("decision", "ADVANCE")
        if decision == "ADVANCE":
            return "advance_candidate"
        elif decision == "REJECT":
            return "reject_candidate"
        else:
            # Still awaiting responses — end the run for now
            return END
    
    graph.add_conditional_edges(
        "ask_follow_up",
        route_after_follow_up,
        {
            "advance_candidate": "advance_candidate",
            "reject_candidate": "reject_candidate",
            END: END
        }
    )
    
    # Final edges — both terminal nodes end the workflow
    graph.add_edge("advance_candidate", END)
    graph.add_edge("reject_candidate", END)
    
    return graph.compile()

Step 8: Create the Main Entry Point

Create main.py to run the agent with a sample resume and job description:

from agent import build_screening_graph
from state import create_initial_state

def run_screening(resume_text: str, job_title: str, job_requirements: str,
                  required_skills: list, min_years_experience: int,
                  candidate_responses: str = None):
    """Run the HR screening agent on a single candidate."""
    
    # Build the graph
    graph = build_screening_graph()
    
    # Create initial state
    initial_state = create_initial_state(
        resume_text=resume_text,
        job_title=job_title,
        job_requirements=job_requirements,
        required_skills=required_skills,
        min_years_experience=min_years_experience
    )
    
    if candidate_responses:
        initial_state["candidate_responses"] = candidate_responses
    
    # Execute the graph
    final_state = graph.invoke(initial_state)
    
    return final_state

# Example usage
if __name__ == "__main__":
    sample_resume = """
    John Doe
    john.doe@email.com
    
    Senior Software Engineer with 8 years of experience in Python, TypeScript, and cloud architecture.
    Led a team of 5 engineers at TechCorp building microservices on AWS. 
    Experience with React, Node.js, PostgreSQL, and Docker.
    Bachelor's in Computer Science from State University.
    """
    
    job_title = "Staff Software Engineer"
    job_requirements = "Need a seasoned engineer with deep Python and AWS experience, team leadership, and system design skills."
    required_skills = ["Python", "AWS", "System Design", "Team Leadership", "TypeScript"]
    min_years_experience = 6
    
    result = run_screening(
        resume_text=sample_resume,
        job_title=job_title,
        job_requirements=job_requirements,
        required_skills=required_skills,
        min_years_experience=min_years_experience
    )
    
    print("\n========== SCREENING RESULT ==========")
    print(f"Candidate: {result['candidate_name']}")
    print(f"Decision: {result['decision']}")
    print(f"Reasoning: {result['decision_reasoning']}")
    print(f"Skill Gaps: {result.get('skill_gaps', [])}")
    if result.get('interview_notes'):
        print(f"\nInterview Notes:\n{result['interview_notes']}")
    print(f"\nMessage Log:")
    for msg in result['messages']:
        print(f"  - {msg}")

Step 9: Running the Agent End-to-End

When you run the script, the agent executes the full pipeline. Here's what happens under the hood:

  1. parse_resume — The LLM extracts John's name, email, skills (Python, TypeScript, AWS, React, Node.js, PostgreSQL, Docker), 8 years of experience, and his current role
  2. analyze_gaps — Compares against required skills. Matches Python, AWS, TypeScript, Team Leadership. Identifies "System Design" as a potential gap (not explicitly listed in resume). Preliminary decision: MORE_INFO
  3. ask_follow_up — Generates a question like "Can you describe a system design project you've led?"
  4. If candidate_responses are provided, the agent evaluates them and routes to advance_candidate or reject_candidate
  5. The final node produces structured interview notes or rejection feedback

Best Practices for HR Screening Agents

1. Build Guardrails into the Graph Structure

Never allow the LLM to make decisions based on protected characteristics. Add a validation node early in the graph that strips or flags demographic information from resumes before analysis:

def sanitize_resume_node(state: ScreeningState) -> dict:
    """Remove protected demographic information before analysis."""
    sanitization_prompt = """Redact any demographic information (age, race, gender, religion, marital status)
from the following resume. Return only the redacted resume text.
Resume: {resume_text}"""
    
    prompt = sanitization_prompt.format(resume_text=state["resume_text"])
    response = llm.invoke([HumanMessage(content=prompt)])
    
    return {
        "resume_text": response.content,
        "messages": state["messages"] + ["Resume sanitized for bias-free screening."]
    }

Insert this node between the entry point and parse_resume, and you've added a crucial compliance layer.

2. Log Every Decision Path

LangGraph's state object is inherently auditable. Persist the full state after each run — you'll have a complete record of what the agent saw, what it decided, and why. This is essential for compliance with labor regulations that require explainable hiring decisions.

3. Use Temperature Control Strategically

Set temperature=0.0 or 0.1 for extraction and evaluation nodes where consistency is paramount. Use temperature=0.5-0.7 only for generating human-readable summaries or feedback text where variation is acceptable.

4. Implement a Human-in-the-Loop Pause

Before the final ADVANCE or REJECT decision, insert a node that pauses execution and waits for human approval. LangGraph supports this natively with interrupt:

def human_review_node(state: ScreeningState) -> dict:
    """Pause for human review before final decision."""
    # This node sets a flag that triggers LangGraph's interrupt mechanism
    return {
        "current_stage": "pending_human_review",
        "messages": state["messages"] + ["Awaiting human reviewer approval."]
    }
# In the graph: set an interrupt before the terminal decision nodes

5. Handle Edge Cases Gracefully

Resumes come in wildly different formats — PDFs pasted as plain text, bullet-heavy LinkedIn exports, narrative CVs. Your parsing node should handle failures gracefully. Add a fallback that asks the candidate to reformat their resume if parsing completely fails:

def parse_resume_node(state: ScreeningState) -> dict:
    try:
        # ... parsing logic ...
        return { /* parsed fields */ }
    except Exception as e:
        return {
            "decision": "MORE_INFO",
            "follow_up_questions": [
                "We couldn't parse your resume. Please provide your skills, years of experience, and current role in a structured format."
            ],
            "current_stage": "ask_follow_up",
            "messages": state["messages"] + [f"Resume parsing failed: {str(e)}"]
        }

6. Version Your Prompts

Prompts are the "business logic" of your LLM agent. Store them with version numbers and track which prompt version produced which screening outcome. When a hiring manager challenges a decision, you can trace it back to the exact prompt and model combination used.

7. Parallelize for Batch Screening

LangGraph supports parallel execution via Send APIs. When screening multiple candidates against the same job, you can fan out the parse-and-analyze stages across candidates and aggregate results:

from langgraph.pregel import Send

def fan_out_to_candidates(state):
    for resume in state["resumes"]:
        yield Send("parse_resume", {"resume_text": resume})

Extending the Agent with Tool Calls

A more advanced version of this agent can give the LLM access to external tools — querying a company's internal skills taxonomy, checking past interview feedback databases, or integrating with calendar APIs for scheduling. LangGraph integrates seamlessly with LangChain tools:

from langchain.tools import tool

@tool
def lookup_skill_taxonomy(skill_name: str) -> str:
    """Query the company's internal skill definitions."""
    taxonomy = {
        "System Design": "Ability to design distributed systems with >10k QPS, including CAP theorem trade-offs, microservice decomposition, and data modeling.",
        "Team Leadership": "Managed teams of 3+ engineers with formal mentoring and performance review responsibilities."
    }
    return taxonomy.get(skill_name, f"No definition found for {skill_name}")

# Bind tools to the LLM
llm_with_tools = llm.bind_tools([lookup_skill_taxonomy])

Add a tool-calling node to your graph that lets the agent look up precise skill definitions before making a gap determination, ensuring it doesn't misinterpret what "System Design" actually means for your organization.

Testing Your Screening Agent

Create a test suite with diverse resume profiles. Test edge cases methodically:

# test_scenarios.py

test_cases = [
    {
        "name": "Clearly Qualified",
        "resume": "Senior Python developer, 10 years, AWS certified, led teams...",
        "required_skills": ["Python", "AWS"],
        "min_years": 5,
        "expected_decision": "ADVANCE"
    },
    {
        "name": "Clearly Unqualified",
        "resume": "Junior frontend dev, 1 year React experience...",
        "required_skills": ["Python", "AWS", "System Design"],
        "min_years": 7,
        "expected_decision": "REJECT"
    },
    {
        "name": "Borderline - Needs Follow-up",
        "resume": "Mid-level engineer, 4 years Python, some cloud work...",
        "required_skills": ["Python", "AWS", "System Design"],
        "min_years": 5,
        "expected_decision": "MORE_INFO"
    },
    {
        "name": "Empty Resume",
        "resume": "",
        "required_skills": ["Python"],
        "min_years": 3,
        "expected_decision": "MORE_INFO"
    }
]

def run_tests():
    for case in test_cases:
        result = run_screening(
            resume_text=case["resume"],
            job_title="Test Role",
            job_requirements="Test requirements",
            required_skills=case["required_skills"],
            min_years_experience=case["min_years"]
        )
        assert result["decision"] == case["expected_decision"], \
            f"Failed {case['name']}: expected {case['expected_decision']}, got {result['decision']}"
    print("All tests passed!")

Deployment Considerations

When deploying this agent to production, consider these operational factors:

Complete Agent Code

Here is the full agent.py file combining all components for a ready-to-use implementation:

# agent.py — Complete HR Screening Agent with LangGraph

import json
import re
from typing import TypedDict, Optional, List

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, END

# ---------- State Definition ----------

class ScreeningState(TypedDict):
    resume_text: str
    job_title: str
    job_requirements: str
    required_skills: List[str]
    min_years_experience: int
    candidate_name: Optional[str]
    candidate_email: Optional[str]
    candidate_skills: Optional[List[str]]
    candidate_years_experience: Optional[int]
    candidate_current_role: Optional[str]
    parsed_summary: Optional[str]
    skill_gaps: Optional[List[str]]
    follow_up_questions: Optional[List[str]]
    candidate_responses: Optional[str]
    decision: Optional[str]
    decision_reasoning: Optional[str]
    interview_notes: Optional[str]
    messages: List[str]
    current_stage: str

# ---------- LLM Configuration ----------

llm = ChatOpenAI(model="gpt-4o", temperature=0.2)

# ---------- Prompts ----------

RESUME_PARSING_PROMPT = """You are an expert HR resume parser. Extract the following information from the resume text below.
Return the data as valid JSON with these exact keys:
- candidate_name: Full name of the candidate
- candidate_email: Email address
- candidate_skills: List of skills mentioned (technical and soft skills)
- candidate_years_experience: Total years of professional experience (estimate if not explicit)
- candidate_current_role: Current or most recent job title
- parsed_summary: A 3-sentence summary of the candidate's background

Resume text:
{resume_text}

Return ONLY the JSON object, no additional text."""

SKILL_GAP_PROMPT = """You are an HR screener comparing a candidate against job requirements.

Job Title: {job_title}
Required Skills: {required_skills}
Minimum Years Experience: {min_years_experience}

Candidate Skills: {candidate_skills}
Candidate Experience Years: {candidate_years_experience}
Candidate Summary: {parsed_summary}

1. Identify which required skills the candidate clearly possesses.
2. Identify skill gaps — required skills that are missing or unclear.
3. If the candidate's years of experience is below the minimum, note this as a gap.
4. Generate 2-3 specific follow-up questions to clarify gaps or probe depth of experience.
5. Make a preliminary assessment: ADVANCE (meets all requirements), REJECT (clearly unqualified), or MORE_INFO (need follow-up).

Return valid JSON:
{{
    "matched_skills": [...],
    "skill_gaps": [...],
    "follow_up_questions": [...],
    "preliminary_decision": "ADVANCE" or "REJECT" or "MORE_INFO",
    "reasoning": "brief explanation"
}}"""

# ---------- Node Functions ----------

def parse_resume_node(state: ScreeningState) -> dict:
    """Extract structured candidate information from the resume."""
    messages = state.get("messages", [])
    messages.append("Parsing resume...")
    
    prompt = RESUME_PARSING_PROMPT.format(resume_text=state["resume_text"])
    response = llm.invoke([HumanMessage(content=prompt)])
    
    try:
        parsed = json.loads(response.content)
    except json.JSONDecodeError:
        json_match = re.search(r'\{.*\}', response.content, re.DOTALL)
        if json_match:
            parsed = json.loads(json_match.group(0))
        else:
            raise ValueError("Could not parse LLM response as JSON")
    
    return {
        "candidate_name": parsed.get("candidate_name"),
        "candidate_email": parsed.get("candidate_email"),
        "candidate_skills": parsed.get("candidate_skills", []),
        "candidate_years_experience": parsed.get("candidate_years_experience"),
        "candidate_current_role": parsed.get("candidate_current_role"),
        "parsed_summary": parsed.get("parsed_summary"),
        "current_stage": "analyze_gaps",
        "messages": messages + ["Resume parsed successfully."]
    }

def analyze_skill_gaps_node(state: ScreeningState) -> dict:
    """Compare candidate profile against job requirements."""
    messages = state.get("messages", [])
    messages.append("Analyzing skill gaps...")
    
    prompt = SKILL_GAP_PROMPT.format(
        job_title=state["job_title"],
        required_skills=state["required_skills"],
        min_years_experience=state["min_years_experience"],
        candidate_skills=state["candidate_skills"],
        candidate_years_experience=state["candidate_years_experience"],
        parsed_summary=state["parsed_summary"]
    )
    
    response = llm.invoke([

— Ad —

Google AdSense will appear here after approval

← Back to all articles