Agentic Resume-Job Matching System

Build a LangGraph agentic workflow that matches resumes to jobs via Azure OpenAI.

Introduction

30 Second Summary

Job seekers send out dozens of resumes hoping for a match, and recruiters sift through hundreds of applications trying to find the right candidate. What if an AI agent could analyze both sides and make intelligent recommendations automatically?

In this project, you will build an agentic resume-job matching system that uses Azure OpenAI to extract skills from resumes, score candidates against job descriptions, and produce ranked recommendations through a multi-step graph-based workflow.

What You'll Build

A local Python application that orchestrates multiple LLM-powered steps using LangGraph to parse resumes, match candidates to jobs, and generate intelligent recommendations with conditional branching logic.

By the end of this project, you'll have:

  • An Azure OpenAI resource with gpt-4.1-mini deployed for LLM inference.
  • A LangGraph StateGraph with multiple nodes and conditional routing that demonstrates agentic workflow patterns.
  • A working recommendation system that extracts skills from resumes, scores job-candidate fit, and produces ranked results.
  • Secret Mission: Add a gap analysis agent node that identifies missing skills and suggests learning resources for the candidate's top job match.

Are there any prerequisites?

You'll need basic Python knowledge, an Azure account with credits (a free Azure account comes with $200 in credits), and a GitHub account. This project uses Python 3.10, which you should have installed before starting.

Set Up Azure OpenAI and Your Python Environment

Your agentic matching system needs a brain. Azure OpenAI provides managed access to GPT models that will power each reasoning step in the workflow.

In this step, you will provision an Azure OpenAI resource, deploy the gpt-4.1-mini model, and set up a local Python project with all the packages you need to start building.

In this step, get ready to:
  • Create an Azure OpenAI resource and deploy gpt-4.1-mini.
  • Set up a Python project with a virtual environment and required packages.
  • Initialize a Git repository for version control.
Create an Azure OpenAI resource

Azure OpenAI Service is Azure's managed LLM platform. You will create a resource that gives you an API key and endpoint for calling GPT models.

  • Open your browser and navigate to the Azure Portal at portal.azure.com.
  • Sign in with your Azure account.
  • In the search bar at the top of the portal, type Azure OpenAI and select Azure OpenAI from the results.
  • Click Create in the toolbar.
  • Select your Azure subscription.
  • Create a new resource group or select an existing one.
  • Select a region that supports gpt-4.1-mini (for example, Sweden Central or North Central US).
  • Enter a unique resource name.
  • Select the Standard S0 pricing tier.
  • Click Next until you reach the Review + submit screen.
  • Click Create.

Wait for the deployment to complete. Once created, navigate to your new Azure OpenAI resource.

  • Select Resource Management, then Keys and Endpoint.
  • Copy the KEY 1 value and save it somewhere safe (you will need it shortly).
  • Copy the Endpoint value as well.

What is Azure OpenAI Service?

Azure OpenAI Service gives you access to OpenAI's GPT models through Azure's infrastructure. It is the Azure equivalent of AWS Bedrock for managed model inference.

The endpoint URL and API key you just copied are how your Python code will authenticate with the model.

Don't see Azure OpenAI in the search results?

Make sure you are signed in with a subscription that has Azure OpenAI access enabled. Pay-as-you-go and sponsored subscriptions should work automatically.

Help me troubleshoot why Azure OpenAI is not showing up.

Deploy gpt-4.1-mini

With the resource created, you now need to deploy a specific model to it. The deployment name is what your code passes as the model parameter when making API calls.

  • In your Azure OpenAI resource, select Resource Management, then Model deployments.
  • Click the Manage Deployments button to open Azure AI Foundry.
  • Select Deploy model, then Deploy base model.
  • Choose gpt-4.1-mini from the model list.
  • Set the deployment name to gpt-4.1-mini.
  • Click Deploy.

Wait until the deployment status shows Succeeded. This confirms the model is ready for API calls.

Why gpt-4.1-mini?

This model is cost-effective at $0.40 per 1M input tokens and supports chat completions, function calling, and structured outputs. It is powerful enough for skill extraction and scoring while keeping costs well under $1 for this entire project.

Don't see gpt-4.1-mini in the model list?

This model is only available in certain regions. Make sure your Azure OpenAI resource was created in Sweden Central or North Central US. If you chose a different region, you may need to create a new resource in a supported region.

Help me find gpt-4.1-mini in Azure AI Foundry.

Create your local project environment

Now you will set up the local project directory where all the Python code will live. A virtual environment keeps your project's packages isolated from your system Python.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir resume-matcher
cd resume-matcher
  • Create and activate a Python virtual environment:

macOS

python3 -m venv .venv
source .venv/bin/activate

Windows

python -m venv .venv
.venv\Scripts\activate

You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (.venv) in your prompt?

On macOS, make sure you ran source .venv/bin/activate (not just the python3 -m venv line). On Windows, if you see a permissions error, run your terminal as Administrator or use Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned first.

Help me activate my Python virtual environment.

  • Install the required packages by running:
pip install langgraph==1.2.6 langchain==1.3.11 langchain-openai==1.3.3 python-dotenv

What are these packages?

  • langgraph is the graph-based orchestration framework for building agentic workflows.
  • langchain provides the core abstractions for working with LLMs.
  • langchain-openai adds the ChatOpenAI class for connecting to Azure OpenAI endpoints.
  • python-dotenv loads environment variables from your .env file so you do not hardcode secrets in your code.

Seeing installation errors?

Make sure your virtual environment is active (you should see (.venv) in your prompt). If pip is not found, try pip3 instead.

Help me fix my pip install errors.

Now create the .env file that stores your Azure credentials. Open VS Code in the project folder:

  • Press Cmd+Space (macOS) or the Windows key (Windows), type Visual Studio Code, and press Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the resume-matcher folder on your Desktop, then click Open.
  • Create a new file by clicking File, then New File.
  • Name the file .env and paste the following content:
AZURE_OPENAI_API_KEY=your-azure-openai-key-here
AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com
  • Replace your-azure-openai-key-here with the KEY 1 value you copied earlier.
  • Replace your-resource-name in the endpoint URL with the actual resource name from the Azure Portal.
  • Save the file.

Why a .env file?

Storing credentials in a .env file keeps them out of your source code. The python-dotenv package loads these values at runtime so your Python scripts can access them without hardcoding secrets.

Next, create the .gitignore file to prevent secrets from being committed to version control.

  • In VS Code, create another new file named .gitignore and paste the following content:
.env
__pycache__/
*.pyc
.venv/

What does .gitignore do?

Any file or folder listed here is invisible to Git. This keeps your API keys (.env), cached bytecode (__pycache__/), and your virtual environment (.venv/) out of your repository.

  • Save .gitignore.

Now initialize a Git repository and make your first commit. Open the integrated terminal in VS Code by pressing Ctrl+` (same on macOS and Windows).

  • Initialize the repository and commit by running these commands:
git init
git add .
git commit -m "Initial commit: project setup with .env and .gitignore"

Git command not found?

Make sure git is installed. You can check by running git --version. If it is not installed, download it from git-scm.com.

Help me install Git on my system.

  • Confirm LangGraph installed correctly by running this command:
python -c "import langgraph; print(langgraph.__version__)"

You should see 1.2.6 printed in your terminal.

✔️ I see 1.2.6

Your environment is correctly set up. All packages are installed at the right versions.

ⓧ I see a different version or an error

Make sure your virtual environment is active (you should see (.venv) in your prompt). Then try running the install command again:

pip install langgraph==1.2.6

If you see ModuleNotFoundError, you may be running the wrong Python. Make sure you are using the Python from your virtual environment, not the system Python.

Your cloud resource is live and your local environment is ready. Next up, you will define the data structures that flow through your agent's graph.

Define the Agent State and Sample Data

Your Azure OpenAI resource is live and your Python environment has all the packages it needs. Now it's time to design the data structures that will flow through your agentic workflow.

Every LangGraph agent needs a state schema that defines what information moves between nodes. Think of it as a shared clipboard that each node can read from and write to as the workflow progresses. In this step, you will define that schema and create realistic sample data for the agent to process.

In this step, get ready to:
  • Define the agent state schema that carries data between nodes.
  • Create sample resume and job description data for testing.
  • Verify both modules import cleanly.
Create the agent state schema

In LangGraph, the state is a Python TypedDict that declares every field the graph's nodes can read or update. Each node receives the full state and returns a partial dictionary with only the fields it changed.

  • Create a new file called state.py in your project folder.
  • Add the following code to define the AgentState schema:
from typing import TypedDict


class AgentState(TypedDict):
    resume_text: str
    extracted_skills: list[str]
    confidence: str
    job_descriptions: list[dict]
    match_scores: list[dict]
    recommendation: str

What does this code do?

This TypedDict defines the shape of data that flows through your entire agent graph. Every node in the graph will receive this state and return updates to specific fields.

  • resume_text holds the raw resume input the agent will analyze.
  • extracted_skills stores the skills the LLM pulls from the resume.
  • confidence is a flag ("high" or "low") used for conditional routing later.
  • job_descriptions carries the list of jobs to match against.
  • match_scores stores the scored results after the LLM evaluates each job.
  • recommendation holds the final ranked recommendation output.
  • Save state.py.

Seeing a syntax error?

Make sure you are using Python 3.10 or higher. The list[str] and list[dict] syntax requires Python 3.9+. Verify your version by running python --version in your terminal.

Help me debug a syntax error in my state.py file

Create the sample data module

Your agent needs realistic inputs to process. You will create a sample resume for a mid-level data scientist and three job descriptions that represent different career paths.

  • Create a new file called data.py in your project folder.
  • Add the following sample resume string:
SAMPLE_RESUME = """
John Smith - Data Scientist
Experience: 3 years

Skills: Python, Pandas, NumPy, SQL, Tableau, Machine Learning, 
Scikit-learn, TensorFlow, A/B Testing, Statistical Analysis

Experience:
- Built predictive models for customer churn reducing attrition by 15%
- Developed automated reporting dashboards in Tableau
- Conducted A/B tests for product feature launches
- Created ETL pipelines processing 2M+ records daily using Python and SQL

Education: M.S. Statistics, University of California
"""

Why this resume?

This resume has a deliberate mix of clearly stated skills (Python, SQL) and implied skills (data engineering from the ETL pipeline experience). This mix will exercise the conditional routing logic you build later. The LLM may flag confidence as "high" or "low" depending on how it interprets the experience section.

  • Below the SAMPLE_RESUME variable, add the three job descriptions:
JOB_DESCRIPTIONS = [
    {
        "title": "Senior Data Scientist - NLP",
        "company": "TechCorp",
        "required_skills": [
            "Python", "NLP", "LLMs", "Transformers", "PyTorch",
            "SQL", "Machine Learning", "Deep Learning"
        ],
        "description": "Build and deploy NLP models for text classification and entity extraction."
    },
    {
        "title": "Data Scientist - Recommendations",
        "company": "RetailAI",
        "required_skills": [
            "Python", "Machine Learning", "Collaborative Filtering",
            "SQL", "A/B Testing", "Pandas", "Scikit-learn"
        ],
        "description": "Design and optimize recommendation engines for e-commerce personalization."
    },
    {
        "title": "Analytics Engineer",
        "company": "DataFlow Inc",
        "required_skills": [
            "SQL", "Python", "dbt", "Tableau", "ETL",
            "Data Modeling", "Airflow"
        ],
        "description": "Build and maintain data transformation pipelines and analytics dashboards."
    },
]

What do these jobs test?

Each job targets a different skill overlap with the resume. The NLP role has low overlap (the candidate lacks NLP, Transformers, PyTorch). The Recommendations role has high overlap (Python, ML, A/B Testing, Pandas, Scikit-learn). The Analytics Engineer role has moderate overlap (SQL, Python, Tableau, ETL).

This spread means the agent will produce meaningfully different scores for each job, proving the matching logic works.

  • Save data.py.

Getting an indentation error?

Python is sensitive to whitespace. Make sure each dictionary in the list is indented with 4 spaces, and that the required_skills list items are indented one more level. Check that you have matching opening and closing brackets for every list and dictionary.

Help me fix an indentation or bracket error in my data.py file

✔️ Awesome, I've got everything!

Great. Make sure both state.py and data.py are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete state.py:

from typing import TypedDict


class AgentState(TypedDict):
    resume_text: str
    extracted_skills: list[str]
    confidence: str
    job_descriptions: list[dict]
    match_scores: list[dict]
    recommendation: str

Here is the complete data.py:

SAMPLE_RESUME = """
John Smith - Data Scientist
Experience: 3 years

Skills: Python, Pandas, NumPy, SQL, Tableau, Machine Learning, 
Scikit-learn, TensorFlow, A/B Testing, Statistical Analysis

Experience:
- Built predictive models for customer churn reducing attrition by 15%
- Developed automated reporting dashboards in Tableau
- Conducted A/B tests for product feature launches
- Created ETL pipelines processing 2M+ records daily using Python and SQL

Education: M.S. Statistics, University of California
"""

JOB_DESCRIPTIONS = [
    {
        "title": "Senior Data Scientist - NLP",
        "company": "TechCorp",
        "required_skills": [
            "Python", "NLP", "LLMs", "Transformers", "PyTorch",
            "SQL", "Machine Learning", "Deep Learning"
        ],
        "description": "Build and deploy NLP models for text classification and entity extraction."
    },
    {
        "title": "Data Scientist - Recommendations",
        "company": "RetailAI",
        "required_skills": [
            "Python", "Machine Learning", "Collaborative Filtering",
            "SQL", "A/B Testing", "Pandas", "Scikit-learn"
        ],
        "description": "Design and optimize recommendation engines for e-commerce personalization."
    },
    {
        "title": "Analytics Engineer",
        "company": "DataFlow Inc",
        "required_skills": [
            "SQL", "Python", "dbt", "Tableau", "ETL",
            "Data Modeling", "Airflow"
        ],
        "description": "Build and maintain data transformation pipelines and analytics dashboards."
    },
]
Verify both modules import cleanly
  • Confirm both modules work by running this command in your terminal:
python -c "from state import AgentState; from data import SAMPLE_RESUME, JOB_DESCRIPTIONS; print('State fields:', list(AgentState.__annotations__.keys())); print('Jobs loaded:', len(JOB_DESCRIPTIONS))"

You should see output showing the six state fields and 3 jobs loaded:

State fields: ['resume_text', 'extracted_skills', 'confidence', 'job_descriptions', 'match_scores', 'recommendation']
Jobs loaded: 3

Seeing ModuleNotFoundError?

Make sure you are running the command from inside your project folder where state.py and data.py live. Also confirm your virtual environment is still activated (you should see the environment name in your terminal prompt).

Help me fix a ModuleNotFoundError when importing state or data modules

Your state schema and sample data are locked in. Next up, you will build the LLM-powered node functions that read this state, call Azure OpenAI, and write results back.

Build the Agent Nodes

You have your state schema and sample data ready. Now you need the brains of the system. Each node in your agent graph is a Python function that calls the LLM, processes the response, and returns a partial state update.

In this step, you will build four node functions that handle the core intelligence of the matching workflow. These functions will parse resumes, refine vague skills, score job matches, and generate final recommendations.

In this step, get ready to:
  • Create the LLM client and resume-parsing nodes.
  • Build a job-scoring node that rates each job on a 0-100 scale.
  • Add a recommendation node that produces a ranked summary.
Set up the LLM client and build the resume parsing nodes

Each node function in a LangGraph workflow accepts the current state and returns a dictionary of fields to update. The first nodes you will build handle resume analysis.

  • Create a new file called nodes.py in your project directory.
  • Add the following imports and LLM client configuration:
import os

from langchain_openai import ChatOpenAI
from state import AgentState

# Configure the Azure OpenAI client using the v1 API endpoint
llm = ChatOpenAI(
    model="gpt-4.1-mini",
    base_url=os.environ["AZURE_OPENAI_ENDPOINT"].rstrip("/") + "/openai/v1/",
    api_key=os.environ["AZURE_OPENAI_API_KEY"],
)

What does this code do?

This creates a ChatOpenAI instance that talks to your Azure OpenAI deployment. The base_url appends /openai/v1/ to your endpoint, which is the GA v1 API path that lets you use ChatOpenAI directly with Azure.

The model parameter matches your Azure deployment name (gpt-4.1-mini). All four node functions you build next will share this single llm instance.

You won't see a change yet. The next chunk adds the first node function.

  • Below the llm variable, add the parse_resume function:
def parse_resume(state: AgentState) -> dict:
    # Build a prompt that asks the LLM to extract skills and confidence
    prompt = f"""Analyze this resume and extract:
1. A list of technical skills (return as comma-separated)
2. Confidence level: "high" if the skills are clearly stated, "low" if they are vague or implicit.

Resume:
{state["resume_text"]}

Respond in this exact format:
SKILLS: skill1, skill2, skill3
CONFIDENCE: high or low"""

    response = llm.invoke(prompt)
    content = response.content

    # Parse the structured response into skills list and confidence flag
    skills_line = ""
    confidence_line = ""
    for line in content.strip().split("\n"):
        if line.startswith("SKILLS:"):
            skills_line = line.replace("SKILLS:", "").strip()
        elif line.startswith("CONFIDENCE:"):
            confidence_line = line.replace("CONFIDENCE:", "").strip().lower()

    skills = [s.strip() for s in skills_line.split(",") if s.strip()]
    # Default to "low" if the LLM returns an unexpected value
    confidence = confidence_line if confidence_line in ("high", "low") else "low"

    return {"extracted_skills": skills, "confidence": confidence}

What does this code do?

The parse_resume node sends the candidate's resume text to the LLM and asks it to extract skills in a structured format. It also asks the LLM to self-assess its confidence.

The parsing logic splits the LLM response by line, looking for lines starting with SKILLS: and CONFIDENCE:. This structured prompting pattern ensures you get machine-parseable output. The confidence flag drives conditional routing later in the graph.

You won't see a change yet. The next chunk adds the refinement node.

  • Below parse_resume, add the refine_resume function that handles low-confidence results:
def refine_resume(state: AgentState) -> dict:
    # Ask the LLM to look deeper for implicit skills in the experience section
    prompt = f"""The following resume had unclear or vague skills. 
Look deeper for implicit skills based on the experience described.

Resume:
{state["resume_text"]}

Previously extracted skills: {", ".join(state["extracted_skills"])}

Identify additional implicit skills from the work experience 
(e.g., if they built ETL pipelines, they likely know data engineering concepts).

Return ALL skills (original + newly identified) as a comma-separated list:
SKILLS: skill1, skill2, skill3"""

    response = llm.invoke(prompt)
    content = response.content

    # Parse the enriched skills list from the response
    skills_line = ""
    for line in content.strip().split("\n"):
        if line.startswith("SKILLS:"):
            skills_line = line.replace("SKILLS:", "").strip()

    skills = [s.strip() for s in skills_line.split(",") if s.strip()]
    # After refinement, confidence is always upgraded to high
    return {"extracted_skills": skills, "confidence": "high"}

What does this code do?

The refine_resume node is the fallback path for when parse_resume reports low confidence. It sends a second, deeper prompt that asks the LLM to infer implicit skills from work experience descriptions.

For example, if a candidate "built ETL pipelines," the LLM can infer data engineering knowledge even if it is not explicitly listed as a skill. After refinement, the confidence is always set to "high" so the graph moves forward.

  • Save nodes.py.
  • Verify the syntax is valid by running:
python -c "import ast; ast.parse(open('nodes.py').read()); print('Syntax OK')"

You should see Syntax OK printed in your terminal.

Seeing a SyntaxError?

Check for mismatched triple-quote strings (""") in your prompt f-strings. Each opening f""" must have a matching closing """.

Also check that the return statement at the end of each function is indented inside the function body, not at the module level.

help me fix a SyntaxError in my nodes.py file

Add the job scoring node

The scoring node is the heart of the matching system. It compares the candidate's extracted skills against each job's requirements and produces a numerical score.

  • In nodes.py, add the score_matches function below refine_resume:
def score_matches(state: AgentState) -> dict:
    # Format the candidate's skills and job listings for the prompt
    skills_str = ", ".join(state["extracted_skills"])
    jobs_str = ""
    for i, job in enumerate(state["job_descriptions"]):
        jobs_str += f"\nJob {i+1}: {job['title']} at {job['company']}\n"
        jobs_str += f"  Required: {', '.join(job['required_skills'])}\n"
        jobs_str += f"  Description: {job['description']}\n"

    prompt = f"""Score how well this candidate matches each job on a scale of 0-100.

Candidate Skills: {skills_str}

Jobs:
{jobs_str}

For each job, provide a score and brief reason.
Format your response exactly like this (one per line):
JOB 1: [score] - [reason]
JOB 2: [score] - [reason]
JOB 3: [score] - [reason]"""

    response = llm.invoke(prompt)
    content = response.content

    # Parse each JOB line into a score dict with fallback defaults
    scores = []
    for i, job in enumerate(state["job_descriptions"]):
        score = 50
        reason = "Unable to parse"
        for line in content.strip().split("\n"):
            if line.upper().startswith(f"JOB {i+1}:"):
                parts = line.split(":", 1)[1].strip().split("-", 1)
                try:
                    score = int(parts[0].strip())
                except ValueError:
                    score = 50
                reason = parts[1].strip() if len(parts) > 1 else "No reason given"
        scores.append({
            "title": job["title"],
            "company": job["company"],
            "score": score,
            "reason": reason,
        })

    return {"match_scores": scores}

What does this code do?

The score_matches node formats all job descriptions and the candidate's skills into a single prompt. It asks the LLM to score each job 0-100 based on skill overlap.

The parsing logic includes fallback defaults (score of 50, reason of "Unable to parse") in case the LLM response does not follow the expected format exactly. This defensive parsing makes the agent robust to minor LLM output variations.

  • Save nodes.py.
  • Verify the syntax again by running:
python -c "import ast; ast.parse(open('nodes.py').read()); print('Syntax OK')"

You should see Syntax OK printed again.

Seeing an IndentationError?

Make sure the scores.append({...}) call is inside the outer for i, job loop, not inside the inner for line loop. The append happens once per job, after scanning all response lines.

help me fix an IndentationError in my score_matches function

Add the recommendation node

The final node takes the scored matches and asks the LLM to produce a human-readable recommendation explaining the candidate's best options.

  • In nodes.py, add the generate_recommendation function below score_matches:
def generate_recommendation(state: AgentState) -> dict:
    # Sort matches by score descending so the best fit appears first
    sorted_scores = sorted(
        state["match_scores"], key=lambda x: x["score"], reverse=True
    )

    prompt = f"""Based on these job match scores, write a brief recommendation 
for the candidate explaining their best options.

Match Results:
"""
    for s in sorted_scores:
        prompt += f"- {s['title']} at {s['company']}: {s['score']}/100 - {s['reason']}\n"

    prompt += """
Write 2-3 sentences recommending their best path forward. 
Be specific about why the top match is strongest."""

    response = llm.invoke(prompt)
    return {"recommendation": response.content}

What does this code do?

The generate_recommendation node sorts the match scores from highest to lowest, then asks the LLM to write a brief recommendation. It returns the LLM's response directly as the recommendation field.

This is the final node in the main workflow path. It transforms raw scores into actionable career guidance for the candidate.

  • Save nodes.py.
  • Verify that all four nodes load correctly by running:
python -c "from dotenv import load_dotenv; load_dotenv(); from nodes import parse_resume, refine_resume, score_matches, generate_recommendation; print('All nodes loaded successfully')"

You should see All nodes loaded successfully printed in your terminal. This confirms the file has no syntax errors and the Azure OpenAI client initialized without issues.

Seeing a KeyError?

A KeyError: 'AZURE_OPENAI_ENDPOINT' means your .env file is missing the variable or python-dotenv could not find it. Make sure your .env file is in the same directory where you are running the command.

Also confirm that both AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT are present in .env with no spaces around the = sign.

help me fix a KeyError when importing nodes.py

✔️ Awesome, I've got everything!

Great. Double check you have saved nodes.py before moving on.

ⓧ I'd like to double check the full code

import os

from langchain_openai import ChatOpenAI
from state import AgentState

llm = ChatOpenAI(
    model="gpt-4.1-mini",
    base_url=os.environ["AZURE_OPENAI_ENDPOINT"].rstrip("/") + "/openai/v1/",
    api_key=os.environ["AZURE_OPENAI_API_KEY"],
)


def parse_resume(state: AgentState) -> dict:
    prompt = f"""Analyze this resume and extract:
1. A list of technical skills (return as comma-separated)
2. Confidence level: "high" if the skills are clearly stated, "low" if they are vague or implicit.

Resume:
{state["resume_text"]}

Respond in this exact format:
SKILLS: skill1, skill2, skill3
CONFIDENCE: high or low"""

    response = llm.invoke(prompt)
    content = response.content

    skills_line = ""
    confidence_line = ""
    for line in content.strip().split("\n"):
        if line.startswith("SKILLS:"):
            skills_line = line.replace("SKILLS:", "").strip()
        elif line.startswith("CONFIDENCE:"):
            confidence_line = line.replace("CONFIDENCE:", "").strip().lower()

    skills = [s.strip() for s in skills_line.split(",") if s.strip()]
    confidence = confidence_line if confidence_line in ("high", "low") else "low"

    return {"extracted_skills": skills, "confidence": confidence}


def refine_resume(state: AgentState) -> dict:
    prompt = f"""The following resume had unclear or vague skills. 
Look deeper for implicit skills based on the experience described.

Resume:
{state["resume_text"]}

Previously extracted skills: {", ".join(state["extracted_skills"])}

Identify additional implicit skills from the work experience 
(e.g., if they built ETL pipelines, they likely know data engineering concepts).

Return ALL skills (original + newly identified) as a comma-separated list:
SKILLS: skill1, skill2, skill3"""

    response = llm.invoke(prompt)
    content = response.content

    skills_line = ""
    for line in content.strip().split("\n"):
        if line.startswith("SKILLS:"):
            skills_line = line.replace("SKILLS:", "").strip()

    skills = [s.strip() for s in skills_line.split(",") if s.strip()]
    return {"extracted_skills": skills, "confidence": "high"}


def score_matches(state: AgentState) -> dict:
    skills_str = ", ".join(state["extracted_skills"])
    jobs_str = ""
    for i, job in enumerate(state["job_descriptions"]):
        jobs_str += f"\nJob {i+1}: {job['title']} at {job['company']}\n"
        jobs_str += f"  Required: {', '.join(job['required_skills'])}\n"
        jobs_str += f"  Description: {job['description']}\n"

    prompt = f"""Score how well this candidate matches each job on a scale of 0-100.

Candidate Skills: {skills_str}

Jobs:
{jobs_str}

For each job, provide a score and brief reason.
Format your response exactly like this (one per line):
JOB 1: [score] - [reason]
JOB 2: [score] - [reason]
JOB 3: [score] - [reason]"""

    response = llm.invoke(prompt)
    content = response.content

    scores = []
    for i, job in enumerate(state["job_descriptions"]):
        score = 50
        reason = "Unable to parse"
        for line in content.strip().split("\n"):
            if line.upper().startswith(f"JOB {i+1}:"):
                parts = line.split(":", 1)[1].strip().split("-", 1)
                try:
                    score = int(parts[0].strip())
                except ValueError:
                    score = 50
                reason = parts[1].strip() if len(parts) > 1 else "No reason given"
        scores.append({
            "title": job["title"],
            "company": job["company"],
            "score": score,
            "reason": reason,
        })

    return {"match_scores": scores}


def generate_recommendation(state: AgentState) -> dict:
    sorted_scores = sorted(
        state["match_scores"], key=lambda x: x["score"], reverse=True
    )

    prompt = f"""Based on these job match scores, write a brief recommendation 
for the candidate explaining their best options.

Match Results:
"""
    for s in sorted_scores:
        prompt += f"- {s['title']} at {s['company']}: {s['score']}/100 - {s['reason']}\n"

    prompt += """
Write 2-3 sentences recommending their best path forward. 
Be specific about why the top match is strongest."""

    response = llm.invoke(prompt)
    return {"recommendation": response.content}

Your agent nodes are ready. Next up, you will wire them into a LangGraph StateGraph with conditional routing that chooses different paths based on the confidence flag.

Wire the Graph with Conditional Routing

You have four agent nodes that each handle one piece of the matching workflow. On their own, they are standalone functions with no orchestration. The missing piece is a graph that connects them in the right order and decides which path to take at runtime.

In this step, you will wire your nodes into a LangGraph StateGraph with conditional routing. The graph inspects the confidence flag after parsing the resume and branches accordingly. If confidence is high, it skips straight to scoring. If low, it refines the skills first.

In this step, get ready to:
  • Assemble a StateGraph with conditional edges that branch based on resume quality.
  • Create a main script that runs the full agent pipeline end-to-end.
  • Verify the system produces ranked job recommendations from sample resume data.
Create the graph with conditional routing

A StateGraph is the core orchestration object in LangGraph. You register nodes (your functions), then connect them with edges. A conditional edge evaluates a routing function at runtime to decide which node runs next.

  • Create a new file called graph.py in your project folder.
  • Add the following code to define the routing logic and build the graph:
from langgraph.graph import StateGraph, END

from state import AgentState
from nodes import parse_resume, refine_resume, score_matches, generate_recommendation


def route_by_confidence(state: AgentState) -> str:
    # If the resume had clearly stated skills, skip refinement
    if state["confidence"] == "high":
        return "score_matches"
    return "refine_resume"


def build_graph() -> StateGraph:
    graph = StateGraph(AgentState)

    # Register each node function with the graph
    graph.add_node("parse_resume", parse_resume)
    graph.add_node("refine_resume", refine_resume)
    graph.add_node("score_matches", score_matches)
    graph.add_node("generate_recommendation", generate_recommendation)

    # Set the first node that runs when the graph is invoked
    graph.set_entry_point("parse_resume")

    # After parsing, branch based on confidence level
    graph.add_conditional_edges(
        "parse_resume",
        route_by_confidence,
        {"score_matches": "score_matches", "refine_resume": "refine_resume"},
    )
    # Refinement always leads to scoring
    graph.add_edge("refine_resume", "score_matches")
    # Scoring leads to the final recommendation
    graph.add_edge("score_matches", "generate_recommendation")
    # Recommendation is the last step
    graph.add_edge("generate_recommendation", END)

    return graph.compile()

What does this code do?

route_by_confidence is a plain Python function that reads the confidence field from the state and returns a string naming the next node. LangGraph calls this function automatically after parse_resume finishes.

build_graph constructs the full workflow. It registers four nodes, sets the entry point, adds the conditional branch, and connects the remaining linear edges. Calling graph.compile() locks the structure and returns a runnable object.

The mapping dictionary {"score_matches": "score_matches", "refine_resume": "refine_resume"} tells LangGraph which node name to route to for each possible return value. This makes the routing explicit and avoids typos.

  • Save graph.py.

You will not see a visible change yet. The next chunk creates the script that invokes this graph.

Seeing an import error?

Make sure your virtual environment is activated and that you installed langgraph in it. Also confirm that state.py and nodes.py are in the same directory as graph.py.

Help me fix my LangGraph import error

✔️ Awesome, I've got everything!

Great. Make sure you have saved graph.py before moving on.

ⓧ I'd like to double check the full code

from langgraph.graph import StateGraph, END

from state import AgentState
from nodes import parse_resume, refine_resume, score_matches, generate_recommendation


def route_by_confidence(state: AgentState) -> str:
    if state["confidence"] == "high":
        return "score_matches"
    return "refine_resume"


def build_graph() -> StateGraph:
    graph = StateGraph(AgentState)

    graph.add_node("parse_resume", parse_resume)
    graph.add_node("refine_resume", refine_resume)
    graph.add_node("score_matches", score_matches)
    graph.add_node("generate_recommendation", generate_recommendation)

    graph.set_entry_point("parse_resume")

    graph.add_conditional_edges(
        "parse_resume",
        route_by_confidence,
        {"score_matches": "score_matches", "refine_resume": "refine_resume"},
    )
    graph.add_edge("refine_resume", "score_matches")
    graph.add_edge("score_matches", "generate_recommendation")
    graph.add_edge("generate_recommendation", END)

    return graph.compile()
Create the main script and run the pipeline

The graph is defined but nothing invokes it yet. You need an entry point script that loads your environment variables, builds the graph, feeds it the sample data, and prints the results.

  • Create a new file called main.py in your project folder.
  • Add the following code to run the full agent pipeline:
from dotenv import load_dotenv

# Load .env before any module that reads environment variables
load_dotenv()

from data import SAMPLE_RESUME, JOB_DESCRIPTIONS
from graph import build_graph


def main():
    # Compile the StateGraph into a runnable app
    app = build_graph()

    # Set up the initial state with sample data
    initial_state = {
        "resume_text": SAMPLE_RESUME,
        "extracted_skills": [],
        "confidence": "",
        "job_descriptions": JOB_DESCRIPTIONS,
        "match_scores": [],
        "recommendation": "",
    }

    print("Running Resume-Job Matching Agent...")
    print("=" * 50)

    # Invoke the graph - this runs all nodes in sequence
    result = app.invoke(initial_state)

    # Display extracted skills and confidence
    print(f"\nExtracted Skills: {', '.join(result['extracted_skills'])}")
    print(f"Confidence: {result['confidence']}")
    # Display match scores sorted by best fit
    print(f"\nMatch Scores:")
    for score in sorted(result["match_scores"], key=lambda x: x["score"], reverse=True):
        print(f"  {score['title']} at {score['company']}: {score['score']}/100")
        print(f"    Reason: {score['reason']}")
    # Display the final recommendation
    print(f"\nRecommendation:\n{result['recommendation']}")


if __name__ == "__main__":
    main()

What does this code do?

load_dotenv() reads your .env file and sets AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT as environment variables. It must run before importing graph because nodes.py reads those variables at import time to configure the LLM client.

app.invoke(initial_state) kicks off the graph. LangGraph executes each node in order, passing the updated state dictionary from one node to the next. The final result contains every field populated by the full pipeline.

  • Save main.py.
  • Run the full pipeline by running this command:
python main.py

The script takes 10-20 seconds because it makes several LLM calls to Azure OpenAI. You should see output that includes extracted skills, a confidence level, match scores for all three jobs (each with a 0-100 score and reason), and a final recommendation paragraph.

What should I see?

Your terminal prints a header line, then a list of extracted skills (Python, Pandas, SQL, etc.), a confidence value (likely "high" for this clearly-written resume), three job scores sorted from highest to lowest, and a recommendation paragraph. The exact scores vary because the LLM generates them, but the Recommendations job at RetailAI typically scores highest given the skill overlap.

Seeing an authentication or connection error?

  • Double check that your .env file has no extra spaces around the = sign and no quotes around the values.
  • Confirm your AZURE_OPENAI_ENDPOINT value looks like https://your-resource-name.openai.azure.com with no trailing slash.
  • Make sure the gpt-4.1-mini deployment status shows "Succeeded" in the Azure Portal.

Help me debug my Azure OpenAI connection error

Getting a ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see (.venv) in your terminal prompt.
  • Verify that all five files (state.py, data.py, nodes.py, graph.py, main.py) are in the same directory.

Help me fix my ModuleNotFoundError in main.py

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py and confirmed the output in your terminal.

ⓧ I'd like to double check the full code

from dotenv import load_dotenv

load_dotenv()

from data import SAMPLE_RESUME, JOB_DESCRIPTIONS
from graph import build_graph


def main():
    app = build_graph()

    initial_state = {
        "resume_text": SAMPLE_RESUME,
        "extracted_skills": [],
        "confidence": "",
        "job_descriptions": JOB_DESCRIPTIONS,
        "match_scores": [],
        "recommendation": "",
    }

    print("Running Resume-Job Matching Agent...")
    print("=" * 50)

    result = app.invoke(initial_state)

    print(f"\nExtracted Skills: {', '.join(result['extracted_skills'])}")
    print(f"Confidence: {result['confidence']}")
    print(f"\nMatch Scores:")
    for score in sorted(result["match_scores"], key=lambda x: x["score"], reverse=True):
        print(f"  {score['title']} at {score['company']}: {score['score']}/100")
        print(f"    Reason: {score['reason']}")
    print(f"\nRecommendation:\n{result['recommendation']}")


if __name__ == "__main__":
    main()

Your agentic workflow is running end-to-end. Next up, you will push this project to GitHub with a professional README that documents the architecture.

Push to GitHub and Document

Your agentic matching system is working locally and producing ranked job recommendations. The next step is turning this into a portfolio piece that an interviewer can browse.

A clean GitHub repository with a clear README shows that you can not only write code, but also communicate your architecture decisions. In this step, you'll document the project, commit with descriptive messages, and push everything to a public repository.

In this step, get ready to:
  • Add a README.md with an architecture diagram and setup instructions.
  • Commit all project files with meaningful messages.
  • Push to GitHub and verify the project runs end-to-end.
Add a README and requirements file

A README is the first thing someone sees when they visit your repository. It should explain what the project does, how to set it up, and how the architecture works.

  • Create a requirements.txt file so others can install your dependencies by running this command:
pip freeze > requirements.txt
  • Create a new file called README.md in your project folder and paste in the following content:
# Resume-Job Matching Agent

A LangGraph-powered agentic system that matches resumes to job descriptions using Azure OpenAI.

## Architecture
[parse_resume] --high--> [score_matches] --> [generate_recommendation] --> END
                --low --> [refine_resume] -->

## Setup
1. Create Azure OpenAI resource and deploy gpt-4.1-mini
2. Copy .env.example to .env and fill in your credentials
3. pip install -r requirements.txt
4. python main.py

## Azure/AWS Synonyms
- Azure OpenAI = AWS Bedrock
- Azure Portal = AWS Console

What does this README cover?

  • The Architecture section shows the graph flow as a text-based diagram. Readers can immediately see the conditional branching between high and low confidence paths.
  • The Setup section gives a new user the four steps they need to run the project from scratch.
  • The Azure/AWS Synonyms section maps cloud concepts for interviewers familiar with either platform.
  • Save README.md.
  • Confirm the file exists by running:
ls

You should see README.md and requirements.txt alongside your other project files.

Don't see the new files?

Make sure you saved both files and that you're running ls from inside your project directory. If you accidentally created the files elsewhere, move them into the project folder.

help me find where my files were saved

Commit all files with meaningful messages

Good commit messages tell a story about how the project was built. Each commit should group related files and describe the feature they implement.

  • Stage and commit your state and data files by running these commands:
git add state.py data.py
git commit -m "feat: add LangGraph state schema"
  • Commit the agent node implementations by running:
git add nodes.py
git commit -m "feat: implement agent nodes with Azure OpenAI"
  • Commit the graph wiring and entry point by running:
git add graph.py main.py
git commit -m "feat: wire conditional routing graph"
  • Commit the documentation files by running:
git add README.md requirements.txt
git commit -m "docs: add README and requirements"

Why separate commits?

Each commit groups files by feature. An interviewer reviewing your commit history can see how the project evolved: schema first, then nodes, then orchestration, then documentation. This mirrors how professional teams build software incrementally.

  • Verify your commit history by running:
git log --oneline

You should see four or five commits listed, starting from the most recent at the top.

Seeing 'nothing to commit'?

If a file was already committed in your initial commit from Step 1, git will skip it. Check with git status to see which files are already tracked. If everything shows as committed, you're in good shape.

help me understand my git status output

Push to GitHub and verify

Now you need a remote repository on GitHub to push your code to.

  • Open your browser and go to github.com/new.
  • Enter a repository name like resume-job-matching-agent.
  • Set the visibility to Public.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why leave all options unchecked?

You already have a local repository with commits, a README, and a .gitignore. If GitHub creates its own, you'll get merge conflicts on your very first push.

  • GitHub will show you setup commands. Copy the URL for your new repository, then add the remote and push by running:
git remote add origin [[REPO_URL="https://github.com/your-username/resume-job-matching-agent.git"]]
git branch -M main
git push -u origin main
  • Refresh your GitHub repository page in the browser.
  • You should see all your project files listed with the README.md rendered below them.

Push rejected or permission denied?

If you see a permission error, make sure you're signed into GitHub in your terminal. Run git push again and follow the authentication prompt. If using HTTPS, GitHub may ask for a personal access token instead of your password.

help me fix my GitHub push error

  • Run the project one final time to confirm everything works end-to-end:
python main.py

You should see the full output: extracted skills, confidence level, match scores for all three jobs, and a final recommendation. This confirms your project is complete and working.

Capture your sample output

Copy the terminal output from this final run. You can paste it into your README under a new "Sample Output" section later if you want to show interviewers what the system produces without them needing to set up Azure.

Secret mission

Add a Gap Analysis Agent Node

Your matching system tells candidates which jobs fit best. But what about the skills they're missing? Add a new agent node that identifies skill gaps for the top-matched job and generates a personalized learning plan with specific courses, certifications, and projects for each missing skill.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The Azure OpenAI resource charges per token used, but there is no idle charge when the model is not being called.

Cost warning

Your Azure OpenAI resource is billed per token consumed. For this project, total usage should be well under $1. However, if you leave the deployment active and someone else obtains your API key, they could generate charges against your account.

If you are done experimenting, deleting the resource group is the safest way to stop all potential charges.

Resources you used:

  • Azure OpenAI resource with gpt-4.1-mini deployment (pay-as-you-go, billed per token).
  • Local project directory with virtual environment and Python files.
  • GitHub repository (free, no ongoing cost).

Keep everything running

No action needed. Choose this if you are still actively experimenting with your resume-matching agent or want to add more features.

  • Your Azure OpenAI deployment only costs money when you send tokens to it. With your $200 in Azure credits, continued development is well within budget.
  • Your local project files and GitHub repository remain available as-is.

Pause - I'll come back to this later

Free up local resources while keeping your cloud setup intact for later use.

  • Azure OpenAI deployments cannot be paused, but you are only charged for tokens consumed. No calls means no cost.
  • Deactivate your virtual environment by running:
deactivate
  • Your GitHub repository stays available at no cost.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • Delete the Azure resource group (this removes the Azure OpenAI resource and all deployments):
  • Sign in to the Azure Portal.
  • Search for Resource groups in the top search bar and select it.
  • Select the resource group you created for this project.
  • Click Delete resource group at the top of the page.
  • Type the resource group name to confirm, then click Delete.
  • Delete your local project directory (macOS/Linux):
cd ~/Desktop
rm -rf [[PROJECT_DIR="your project folder name"]]
  • On Windows, use this instead:
cd ~\Desktop
rmdir /s /q [[PROJECT_DIR="your project folder name"]]
  • Delete the GitHub repository:
  • Navigate to your repository page on GitHub.
  • Click Settings in the repository navigation bar.
  • Scroll down to the Danger Zone section.
  • Click Delete this repository.
  • Click I want to delete this repository, read the warnings, then click I have read and understand these effects.
  • Type the repository name to confirm and click Delete this repository.

Nice Work!

Nice Work!

Nice work! You just built a production-grade agentic resume-job matching system powered by Azure OpenAI and LangGraph.

You've learned how to:

  • Built a multi-node agentic system using LangGraph StateGraph with conditional routing that branches based on resume quality.
  • Deployed and connected to Azure OpenAI (gpt-4.1-mini) for LLM inference, demonstrating cloud AI integration.
  • Implemented NLP-driven skill extraction and job-candidate scoring to build a working recommendation system.
  • Secret Mission: Added a gap analysis node that identifies missing skills and generates personalized learning plans.

Ready to quiz yourself?