← 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 workflow that automates the initial screening of job candidates. Instead of manually reviewing dozens or hundreds of resumes against a job description, the agent parses resumes, analyzes job requirements, matches qualifications, and produces ranked, scored results — all with minimal human intervention. Think of it as an intelligent pipeline that takes a stack of PDFs and a job description as input, and outputs a prioritized shortlist of candidates.

Using LangGraph, we can build this as a stateful, multi-step graph where each node performs a discrete task (parsing, analysis, matching, scoring) and edges define the flow of data between them. The graph-based architecture allows for conditional branching — for example, automatically rejecting candidates below a threshold or flagging exceptional ones for immediate review.

Why LangGraph for HR Screening?

Traditional screening automation relies on brittle regex patterns or simple keyword matching. LangGraph brings several advantages that make it the ideal framework for this task:

Prerequisites and Setup

Before diving into the code, ensure you have the following installed:

pip install langgraph langchain langchain-openai pypdf pdfplumber

You'll also need an OpenAI API key (or another compatible LLM provider) set as an environment variable:

export OPENAI_API_KEY="your-api-key-here"

The tutorial assumes you have Python 3.10+ and basic familiarity with LangChain concepts. All code in this tutorial is self-contained and ready to run after setup.

Step-by-Step Implementation

Step 1: Define the State Schema

The state object is the backbone of a LangGraph agent. It holds all data that flows between nodes. For an HR screening agent, we need fields for the job description, parsed resumes, match scores, and final results. We use Python's TypedDict for type safety.

from typing import TypedDict, List, Dict, Optional, Annotated
from langgraph.graph.message import add_messages
import operator

class ScreeningState(TypedDict):
    # Input fields
    job_description_text: str
    resume_texts: List[str]          # Raw text of each resume
    resume_filenames: List[str]      # Original filenames for reference
    
    # Parsed structured data
    job_requirements: Optional[Dict]  # Extracted skills, experience, education
    parsed_resumes: List[Dict]       # Structured data per resume
    
    # Matching and scoring
    match_scores: List[Dict]         # Score breakdown per candidate
    ranked_candidates: List[Dict]    # Final sorted list
    
    # Flow control
    threshold: float                 # Minimum score to pass screening
    messages: Annotated[List, add_messages]  # For LLM conversation history
    
    # Final output
    shortlisted: List[Dict]
    rejected: List[Dict]
    summary: str

Notice the Annotated type for messages — this tells LangGraph how to merge updates to that field (by appending). For other list fields, we'll manually handle merges in our node functions.

Step 2: Create the Resume Parser Node

The first processing node extracts structured information from raw resume text. We'll use an LLM call to pull out candidate name, skills, years of experience, education, and recent job titles. This is far more robust than regex-based extraction.

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
import json

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

resume_parser_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are an expert resume parser. Extract the following information 
from the resume text and return it as valid JSON:
- full_name: string
- skills: list of strings (technical and soft skills)
- years_of_experience: number (total professional experience in years)
- education: list of {degree, institution, year} objects
- recent_roles: list of strings (last 3 job titles with company names)
- certifications: list of strings
- summary: 2-sentence professional summary

If a field cannot be determined, use null or empty list. Return ONLY valid JSON."""),
    ("human", "Resume text:\n\n{resume_text}")
])

def parse_resume_node(state: ScreeningState) -> ScreeningState:
    """Parse all resumes into structured dictionaries."""
    parsed_resumes = []
    
    for i, resume_text in enumerate(state["resume_texts"]):
        response = llm.invoke(
            resume_parser_prompt.format_messages(resume_text=resume_text)
        )
        try:
            parsed = json.loads(response.content)
            parsed["filename"] = state["resume_filenames"][i]
            parsed["raw_text"] = resume_text
            parsed_resumes.append(parsed)
        except json.JSONDecodeError:
            # Fallback: store minimal data if parsing fails
            parsed_resumes.append({
                "filename": state["resume_filenames"][i],
                "full_name": "Unknown",
                "skills": [],
                "years_of_experience": 0,
                "education": [],
                "recent_roles": [],
                "certifications": [],
                "summary": "Parsing failed",
                "raw_text": resume_text
            })
    
    return {"parsed_resumes": parsed_resumes}

Each resume gets its own LLM call. In production, you'd want to batch these or use async calls for efficiency, but the sequential approach keeps the tutorial clear.

Step 3: Create the JD Analysis Node

Before matching, we need to extract structured requirements from the job description. This node identifies required skills, minimum experience, education level, and nice-to-have qualifications.

jd_analysis_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are an expert at analyzing job descriptions. Extract structured 
requirements and return valid JSON:
- title: string (job title)
- required_skills: list of strings (must-have technical skills)
- preferred_skills: list of strings (nice-to-have skills)
- min_years_experience: number
- required_education: string or null
- responsibilities: list of strings (key duties)
- industry: string or null
- employment_type: string (full-time, contract, etc.)
- key_qualities: list of strings (soft skills, traits mentioned)

Return ONLY valid JSON."""),
    ("human", "Job Description:\n\n{jd_text}")
])

def analyze_jd_node(state: ScreeningState) -> ScreeningState:
    """Extract structured requirements from the job description."""
    response = llm.invoke(
        jd_analysis_prompt.format_messages(jd_text=state["job_description_text"])
    )
    try:
        requirements = json.loads(response.content)
    except json.JSONDecodeError:
        requirements = {
            "title": "Unknown",
            "required_skills": [],
            "preferred_skills": [],
            "min_years_experience": 0,
            "required_education": None,
            "responsibilities": [],
            "industry": None,
            "employment_type": "full-time",
            "key_qualities": []
        }
    
    return {"job_requirements": requirements}

Step 4: Create the Matching Node

This node compares each parsed resume against the job requirements. It performs semantic skill matching (not just keyword overlap), checks experience thresholds, and evaluates education fit. The LLM produces a detailed match analysis for each candidate.

matching_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are a recruitment matching expert. Compare the candidate's profile 
against the job requirements. Return a JSON object with:
- skill_match_percentage: number (0-100, based on semantic overlap of skills)
- missing_required_skills: list of strings
- extra_valuable_skills: list of strings (skills candidate has beyond requirements)
- experience_fit: string ("exceeds", "meets", "below", "significantly_below")
- education_fit: string ("exceeds", "meets", "below")
- overall_fit_assessment: string (2-3 sentences explaining the match quality)
- strengths: list of strings
- gaps: list of strings
- recommendation: string ("strong_hire", "possible_hire", "screen_further", "reject")

Be objective and consistent in your evaluation."""),
    ("human", """Job Requirements:
{job_requirements}

Candidate Profile:
{candidate_profile}""")
])

def match_candidates_node(state: ScreeningState) -> ScreeningState:
    """Match each parsed resume against job requirements."""
    match_scores = []
    job_reqs = state["job_requirements"]
    
    for candidate in state["parsed_resumes"]:
        response = llm.invoke(
            matching_prompt.format_messages(
                job_requirements=json.dumps(job_reqs, indent=2),
                candidate_profile=json.dumps(candidate, indent=2)
            )
        )
        try:
            match_result = json.loads(response.content)
            match_result["candidate_name"] = candidate.get("full_name", "Unknown")
            match_result["filename"] = candidate.get("filename", "unknown")
            match_scores.append(match_result)
        except json.JSONDecodeError:
            match_scores.append({
                "candidate_name": candidate.get("full_name", "Unknown"),
                "filename": candidate.get("filename", "unknown"),
                "skill_match_percentage": 0,
                "missing_required_skills": [],
                "extra_valuable_skills": [],
                "experience_fit": "unknown",
                "education_fit": "unknown",
                "overall_fit_assessment": "Evaluation failed",
                "strengths": [],
                "gaps": [],
                "recommendation": "screen_further"
            })
    
    return {"match_scores": match_scores}

Step 5: Create the Scoring & Ranking Node

With match analyses complete, we compute composite scores and rank candidates. This node uses a weighted formula combining skill match percentage, experience fit, and education fit into a single numeric score, then sorts descending.

def score_and_rank_node(state: ScreeningState) -> ScreeningState:
    """Calculate composite scores and rank candidates."""
    ranked = []
    
    for match in state["match_scores"]:
        # Base score from skill match (0-100)
        skill_score = match.get("skill_match_percentage", 0)
        
        # Experience bonus/penalty
        experience_map = {
            "exceeds": 15,
            "meets": 5,
            "below": -10,
            "significantly_below": -25,
            "unknown": 0
        }
        exp_bonus = experience_map.get(match.get("experience_fit", "unknown"), 0)
        
        # Education bonus/penalty
        education_map = {
            "exceeds": 10,
            "meets": 3,
            "below": -5,
            "unknown": 0
        }
        edu_bonus = education_map.get(match.get("education_fit", "unknown"), 0)
        
        # Composite score (capped at 0-100)
        composite = max(0, min(100, skill_score + exp_bonus + edu_bonus))
        
        ranked.append({
            "candidate_name": match.get("candidate_name", "Unknown"),
            "filename": match.get("filename", "unknown"),
            "composite_score": composite,
            "skill_match": skill_score,
            "experience_fit": match.get("experience_fit", "unknown"),
            "education_fit": match.get("education_fit", "unknown"),
            "recommendation": match.get("recommendation", "screen_further"),
            "strengths": match.get("strengths", []),
            "gaps": match.get("gaps", []),
            "overall_fit_assessment": match.get("overall_fit_assessment", "")
        })
    
    # Sort descending by composite score
    ranked.sort(key=lambda x: x["composite_score"], reverse=True)
    
    return {"ranked_candidates": ranked}

Step 6: Build the LangGraph Workflow

Now we assemble the nodes into a LangGraph StateGraph. The graph defines the execution order and how state flows between operations.

from langgraph.graph import StateGraph, END

def build_screening_graph() -> StateGraph:
    """Construct the HR screening agent graph."""
    
    builder = StateGraph(ScreeningState)
    
    # Add all processing nodes
    builder.add_node("parse_resumes", parse_resume_node)
    builder.add_node("analyze_jd", analyze_jd_node)
    builder.add_node("match_candidates", match_candidates_node)
    builder.add_node("score_and_rank", score_and_rank_node)
    builder.add_node("finalize_results", finalize_results_node)
    
    # Define edges: sequential pipeline
    builder.add_edge("parse_resumes", "analyze_jd")
    builder.add_edge("analyze_jd", "match_candidates")
    builder.add_edge("match_candidates", "score_and_rank")
    builder.add_edge("score_and_rank", "finalize_results")
    builder.add_edge("finalize_results", END)
    
    # Set entry point
    builder.set_entry_point("parse_resumes")
    
    return builder.compile()

Step 7: Add Conditional Routing

A powerful feature of LangGraph is conditional branching. Let's add a routing node that separates candidates into shortlisted and rejected based on the threshold, and optionally routes exceptional candidates to a "fast-track" node for immediate hiring manager notification.

def finalize_results_node(state: ScreeningState) -> ScreeningState:
    """Split ranked candidates into shortlisted and rejected based on threshold."""
    threshold = state.get("threshold", 50)
    ranked = state["ranked_candidates"]
    
    shortlisted = [c for c in ranked if c["composite_score"] >= threshold]
    rejected = [c for c in ranked if c["composite_score"] < threshold]
    
    # Generate a human-readable summary
    summary_lines = [
        f"Screening complete. {len(ranked)} candidates evaluated.",
        f"Threshold: {threshold}/100",
        f"Shortlisted: {len(shortlisted)} candidates",
        f"Rejected: {len(rejected)} candidates",
        "",
        "Shortlisted Candidates:"
    ]
    
    for i, candidate in enumerate(shortlisted, 1):
        summary_lines.append(
            f"  {i}. {candidate['candidate_name']} — Score: {candidate['composite_score']}/100 "
            f"({candidate['recommendation']})"
        )
    
    if rejected:
        summary_lines.append("\nRejected:")
        for candidate in rejected:
            summary_lines.append(
                f"  - {candidate['candidate_name']} — Score: {candidate['composite_score']}/100"
            )
    
    return {
        "shortlisted": shortlisted,
        "rejected": rejected,
        "summary": "\n".join(summary_lines)
    }

For conditional routing before finalization, you can add a routing function and use add_conditional_edges:

def route_after_scoring(state: ScreeningState) -> str:
    """Determine where to route based on scores."""
    ranked = state.get("ranked_candidates", [])
    if not ranked:
        return "finalize_results"
    
    # If any candidate scores above 85, fast-track them
    has_exceptional = any(c["composite_score"] >= 85 for c in ranked)
    if has_exceptional:
        return "fast_track_notification"
    return "finalize_results"

def fast_track_node(state: ScreeningState) -> ScreeningState:
    """Generate urgent notification for exceptional candidates."""
    exceptional = [c for c in state["ranked_candidates"] if c["composite_score"] >= 85]
    names = ", ".join([c["candidate_name"] for c in exceptional])
    
    notification = (
        f"FAST TRACK ALERT: {len(exceptional)} exceptional candidate(s) identified!\n"
        f"Names: {names}\n"
        f"These candidates scored 85+ and should be contacted within 24 hours."
    )
    
    # In production, this would send an email or Slack message
    print("\n" + "="*60)
    print(notification)
    print("="*60 + "\n")
    
    return {"messages": [("assistant", notification)]}

# Add to builder:
# builder.add_node("fast_track_notification", fast_track_node)
# builder.add_conditional_edges(
#     "score_and_rank",
#     route_after_scoring,
#     {
#         "fast_track_notification": "fast_track_notification",
#         "finalize_results": "finalize_results"
#     }
# )
# builder.add_edge("fast_track_notification", "finalize_results")

Step 8: Run the Agent

With the graph compiled, we can invoke it with real resume data. Here's how to load PDF resumes, prepare the state, and execute the full pipeline:

import pdfplumber
import os

def load_resumes_from_directory(directory_path: str) -> tuple:
    """Load all PDF resumes from a directory, returning texts and filenames."""
    resume_texts = []
    filenames = []
    
    for filename in os.listdir(directory_path):
        if filename.endswith(".pdf"):
            filepath = os.path.join(directory_path, filename)
            with pdfplumber.open(filepath) as pdf:
                text = "\n".join([page.extract_text() or "" for page in pdf.pages])
                resume_texts.append(text)
                filenames.append(filename)
    
    return resume_texts, filenames

# Example usage
if __name__ == "__main__":
    # Build the graph
    graph = build_screening_graph()
    
    # Load data (replace with your actual paths)
    resume_texts, filenames = load_resumes_from_directory("./resumes/")
    
    jd_text = """
    Senior Software Engineer - AI/ML Team
    
    We are looking for a Senior Software Engineer with 5+ years of experience 
    in Python, machine learning frameworks (PyTorch or TensorFlow), and cloud 
    infrastructure (AWS/GCP). The ideal candidate has experience deploying 
    ML models to production, strong system design skills, and familiarity 
    with Docker and Kubernetes. A BS/MS in Computer Science or related field 
    is required. Experience with LLMs and LangChain is a plus.
    """
    
    # Prepare initial state
    initial_state: ScreeningState = {
        "job_description_text": jd_text,
        "resume_texts": resume_texts,
        "resume_filenames": filenames,
        "job_requirements": None,
        "parsed_resumes": [],
        "match_scores": [],
        "ranked_candidates": [],
        "threshold": 60,
        "messages": [],
        "shortlisted": [],
        "rejected": [],
        "summary": ""
    }
    
    # Execute the graph
    print("Starting HR Screening Agent...")
    final_state = graph.invoke(initial_state)
    
    # Display results
    print("\n" + final_state["summary"])
    
    # Detailed shortlist
    print("\n--- Detailed Shortlist ---")
    for candidate in final_state["shortlisted"]:
        print(f"\nCandidate: {candidate['candidate_name']}")
        print(f"  Score: {candidate['composite_score']}/100")
        print(f"  Skill Match: {candidate['skill_match']}%")
        print(f"  Experience: {candidate['experience_fit']}")
        print(f"  Education: {candidate['education_fit']}")
        print(f"  Assessment: {candidate['overall_fit_assessment']}")
        if candidate['strengths']:
            print(f"  Strengths: {', '.join(candidate['strengths'])}")
        if candidate['gaps']:
            print(f"  Gaps: {', '.join(candidate['gaps'])}")

Complete Code Example

Below is the entire HR screening agent in one consolidated file. Copy this, install the dependencies, set your API key, and you're ready to screen candidates.

"""
HR Screening Agent with LangGraph
Complete implementation — run with: python hr_screening_agent.py
"""

import json
import os
from typing import TypedDict, List, Dict, Optional, Annotated

import pdfplumber
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages

# ── Configuration ──────────────────────────────────────────────
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# ── State Schema ────────────────────────────────────────────────
class ScreeningState(TypedDict):
    job_description_text: str
    resume_texts: List[str]
    resume_filenames: List[str]
    job_requirements: Optional[Dict]
    parsed_resumes: List[Dict]
    match_scores: List[Dict]
    ranked_candidates: List[Dict]
    threshold: float
    messages: Annotated[List, add_messages]
    shortlisted: List[Dict]
    rejected: List[Dict]
    summary: str

# ── Prompts ─────────────────────────────────────────────────────
resume_parser_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are an expert resume parser. Extract the following information 
from the resume text and return it as valid JSON:
- full_name: string
- skills: list of strings
- years_of_experience: number
- education: list of {degree, institution, year} objects
- recent_roles: list of strings
- certifications: list of strings
- summary: 2-sentence professional summary
If a field cannot be determined, use null or empty list. Return ONLY valid JSON."""),
    ("human", "Resume text:\n\n{resume_text}")
])

jd_analysis_prompt = ChatPromptTemplate.from_messages([
    ("system", """Extract structured job requirements and return valid JSON:
- title: string
- required_skills: list of strings
- preferred_skills: list of strings
- min_years_experience: number
- required_education: string or null
- responsibilities: list of strings
- industry: string or null
- employment_type: string
- key_qualities: list of strings
Return ONLY valid JSON."""),
    ("human", "Job Description:\n\n{jd_text}")
])

matching_prompt = ChatPromptTemplate.from_messages([
    ("system", """Compare candidate against job requirements. Return JSON:
- skill_match_percentage: number (0-100)
- missing_required_skills: list of strings
- extra_valuable_skills: list of strings
- experience_fit: string ("exceeds", "meets", "below", "significantly_below")
- education_fit: string ("exceeds", "meets", "below")
- overall_fit_assessment: string (2-3 sentences)
- strengths: list of strings
- gaps: list of strings
- recommendation: string ("strong_hire", "possible_hire", "screen_further", "reject")
Be objective and consistent."""),
    ("human", "Job Requirements:\n{job_requirements}\n\nCandidate Profile:\n{candidate_profile}")
])

# ── Node Functions ──────────────────────────────────────────────
def parse_resume_node(state: ScreeningState) -> dict:
    parsed_resumes = []
    for i, text in enumerate(state["resume_texts"]):
        resp = llm.invoke(resume_parser_prompt.format_messages(resume_text=text))
        try:
            parsed = json.loads(resp.content)
        except json.JSONDecodeError:
            parsed = {"full_name": "Unknown", "skills": [], "years_of_experience": 0,
                      "education": [], "recent_roles": [], "certifications": [], "summary": "Parsing failed"}
        parsed["filename"] = state["resume_filenames"][i]
        parsed["raw_text"] = text
        parsed_resumes.append(parsed)
    return {"parsed_resumes": parsed_resumes}

def analyze_jd_node(state: ScreeningState) -> dict:
    resp = llm.invoke(jd_analysis_prompt.format_messages(jd_text=state["job_description_text"]))
    try:
        requirements = json.loads(resp.content)
    except json.JSONDecodeError:
        requirements = {"title": "Unknown", "required_skills": [], "preferred_skills": [],
                        "min_years_experience": 0, "required_education": None,
                        "responsibilities": [], "industry": None, "employment_type": "full-time",
                        "key_qualities": []}
    return {"job_requirements": requirements}

def match_candidates_node(state: ScreeningState) -> dict:
    match_scores = []
    job_reqs = state["job_requirements"]
    for candidate in state["parsed_resumes"]:
        resp = llm.invoke(matching_prompt.format_messages(
            job_requirements=json.dumps(job_reqs, indent=2),
            candidate_profile=json.dumps(candidate, indent=2)
        ))
        try:
            result = json.loads(resp.content)
        except json.JSONDecodeError:
            result = {"skill_match_percentage": 0, "missing_required_skills": [],
                      "extra_valuable_skills": [], "experience_fit": "unknown",
                      "education_fit": "unknown", "overall_fit_assessment": "Evaluation failed",
                      "strengths": [], "gaps": [], "recommendation": "screen_further"}
        result["candidate_name"] = candidate.get("full_name", "Unknown")
        result["filename"] = candidate.get("filename", "unknown")
        match_scores.append(result)
    return {"match_scores": match_scores}

def score_and_rank_node(state: ScreeningState) -> dict:
    ranked = []
    exp_map = {"exceeds": 15, "meets": 5, "below": -10, "significantly_below": -25, "unknown": 0}
    edu_map = {"exceeds": 10, "meets": 3, "below": -5, "unknown": 0}
    
    for match in state["match_scores"]:
        skill = match.get("skill_match_percentage", 0)
        exp_bonus = exp_map.get(match.get("experience_fit", "unknown"), 0)
        edu_bonus = edu_map.get(match.get("education_fit", "unknown"), 0)
        composite = max(0, min(100, skill + exp_bonus + edu_bonus))
        ranked.append({
            "candidate_name": match.get("candidate_name", "Unknown"),
            "filename": match.get("filename", "unknown"),
            "composite_score": composite,
            "skill_match": skill,
            "experience_fit": match.get("experience_fit", "unknown"),
            "education_fit": match.get("education_fit", "unknown"),
            "recommendation": match.get("recommendation", "screen_further"),
            "strengths": match.get("strengths", []),
            "gaps": match.get("gaps", []),
            "overall_fit_assessment": match.get("overall_fit_assessment", "")
        })
    ranked.sort(key=lambda x: x["composite_score"], reverse=True)
    return {"ranked_candidates": ranked}

def finalize_results_node(state: ScreeningState) -> dict:
    threshold = state.get("threshold", 50)
    ranked = state["ranked_candidates"]
    shortlisted = [c for c in ranked if c["composite_score"] >= threshold]
    rejected = [c for c in ranked if c["composite_score"] < threshold]
    
    lines = [f"Screening complete. {len(ranked)} candidates evaluated.",
             f"Threshold: {threshold}/100",
             f"Shortlisted: {len(shortlisted)} | Rejected: {len(rejected)}",
             "", "Shortlisted:"]
    for i, c in enumerate(shortlisted, 1):
        lines.append(f"  {i}. {c['candidate_name']} — {c['composite_score']}/100 ({c['recommendation']})")
    if rejected:
        lines.append("\nRejected:")
        for c in rejected:
            lines.append(f"  - {c['candidate_name']} — {c['composite_score']}/100")
    return {"shortlisted": shortlisted, "rejected": rejected, "summary": "\n".join(lines)}

# ── Graph Builder ───────────────────────────────────────────────
def build_screening_graph() -> StateGraph:
    builder = StateGraph(ScreeningState)
    builder.add_node("parse_resumes", parse_resume_node)
    builder.add_node("analyze_jd", analyze_jd_node)
    builder.add_node("match_candidates", match_candidates_node)
    builder.add_node("score_and_rank", score_and_rank_node)
    builder.add_node("finalize_results", finalize_results_node)
    
    builder.add_edge("parse_resumes", "analyze_jd")
    builder.add_edge("analyze_jd", "match_candidates")
    builder.add_edge("match_candidates", "score_and_rank")
    builder.add_edge("score_and_rank", "finalize_results")
    builder.add_edge("finalize_results", END)
    builder.set_entry_point("parse_resumes")
    return builder.compile()

# ── Utility ─────────────────────────────────────────────────────
def load_resumes_from_directory(directory: str) -> tuple:
    texts, filenames = [], []
    for f in os.listdir(directory):
        if f.endswith(".pdf"):
            path = os.path.join(directory, f)
            with pdfplumber.open(path) as pdf:
                text = "\n".join([p.extract_text() or "" for p in pdf.pages])
                texts.append(text)
                filenames.append(f)
    return texts, filenames

# ── Main Execution ──────────────────────────────────────────────
if __name__ == "__main__":
    graph = build_screening_graph()
    
    # Sample data — replace with your actual files and JD
    resume_texts, filenames = load_resumes_from_directory("./resumes/")
    
    jd_text = """Senior Software Engineer - AI/ML Team
We seek a Senior Software Engineer with 5+ years in Python, ML frameworks 
(PyTorch/TensorFlow), and cloud (AWS/GCP). Experience deploying ML models, 
strong system design, Docker/Kubernetes required. BS/MS in CS or related. 
LLMs and LangChain experience a plus."""
    
    initial_state: ScreeningState = {
        "job_description_text": jd_text,
        "resume_texts": resume_texts,
        "resume_filenames": filenames,
        "job_requirements": None,
        "parsed_resumes": [],
        "match_scores": [],
        "ranked_candidates": [],
        "threshold": 60,
        "messages": [],
        "shortlisted": [],
        "rejected": [],
        "summary": ""
    }
    
    print("Starting HR Screening Agent...\n")
    final_state = graph.invoke(initial_state)
    print(final_state["summary"])
    
    print("\n--- Detailed Shortlist ---")
    for c in final_state["shortlisted"]:
        print(f"\n{c['candidate_name']} — {c['composite_score']}/100")
        print(f"  Skills: {c['skill_match']}% | Exp: {c['experience_fit']} | Edu: {c['education_fit']}")
        print(f"  {c['overall_fit_assessment']}")

Best Practices

When building HR screening agents for production use, keep these guidelines in mind:

— Ad —

Google AdSense will appear here after approval

← Back to all articles