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:
- Stateful workflows: LangGraph maintains a typed state object across nodes, so parsed resume data, JD analysis, and scores persist cleanly through the entire pipeline.
- Conditional routing: You can branch the graph based on scores — route high-scorers to a "fast-track" node, low-scorers to rejection, and borderline cases to human review.
- LLM integration: Each node can call an LLM for semantic understanding — extracting skills from unstructured text, inferring experience levels, or generating human-readable summaries.
- Observability: With LangGraph's checkpointing, you can inspect every intermediate state, making the screening process auditable and transparent.
- Modularity: Nodes are isolated functions. You can swap in different parsers, scoring algorithms, or LLM providers without restructuring the entire pipeline.
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:
- Use a consistent temperature setting: Set
temperature=0(or very low) for all LLM calls in screening workflows. You need deterministic, reproducible results — not creative variation. Different temperature settings across nodes can cause the same resume to score differently on repeated runs. - Implement robust error handling: Every LLM call can fail due to rate limits, malformed JSON, or timeout. Always wrap
json.loads()in try/except blocks with sensible fallback values. The tutorial code demonstrates this pattern — never let a single parse failure halt the entire pipeline. - Validate and sanitize extracted data: LLMs sometimes hallucinate years of experience or invent skills not present in the resume. Consider adding a validation node that cross-references extracted claims against the raw text using simple keyword checks before passing data to the matching stage.
- Make thresholds configurable: The screening threshold should be a parameter, not hardcoded. Different roles warrant different cutoffs — a senior architect position might use 75, while an entry-level role might use 40. Expose this as a configurable field in your state.
- Add human-in-the-loop checkpoints: LangGraph supports interrupting