How Hiring Agent Parses Resume Sections Using LLMs: A Technical Deep Dive

The Hiring Agent converts raw PDF resumes into structured JSON by delegating each section (basics, work, education, skills, projects, awards) to a Large Language Model through a template-driven pipeline, then aggregates the results into a type-safe Pydantic model.

The interviewstreet/hiring-agent repository implements a modular parsing architecture that uses Large Language Models to extract structured data from unstructured PDF resumes. Instead of relying on regex or rule-based parsing, the system treats each resume section as a separate LLM inference task, allowing for flexible handling of varied resume formats while maintaining strict type safety through Pydantic models.

The Section Parsing Architecture

The PDFHandler class in main/pdf.py orchestrates the multi-stage pipeline that transforms PDF text into a structured JSONResume object.

LLM Provider Selection and Initialization

When instantiated, PDFHandler calls initialize_llm_provider from main/llm_utils.py to determine which LLM backend to use. The selection logic checks the DEFAULT_MODEL constant defined in main/prompt.py against available API keys. If the model belongs to the Gemini family and a valid GEMINI_API_KEY is present, the system instantiates a GeminiProvider; otherwise, it falls back to an OllamaProvider for local inference.

Template-Based Prompt Generation

For each resume section, the system loads a specialized Jinja template from prompts/templates/ (e.g., basics.jinja, work.jinja). The TemplateManager class in main/prompts/template_manager.py injects the full resume text (text_content) into these templates. Each section prompt is paired with a system message rendered from system_message.jinja to establish the LLM's role as a structured data extractor.

Per-Section LLM Execution

The _call_llm_for_section method constructs a chat payload with specific generation parameters:

chat_params = {
    "model": DEFAULT_MODEL,
    "messages": [
        {"role": "system", "content": section_system_message},
        {"role": "user", "content": prompt},
    ],
    "options": {"stream": False, "temperature": ..., "top_p": ...},
}
response = self.provider.chat(**chat_params, **kwargs)

The provider sends this payload to either Ollama or Gemini. The raw response undergoes cleaning via extract_json_from_response in main/llm_utils.py, which strips markdown code fences and normalizes the output.

JSON Extraction and Data Transformation

After cleaning, the handler parses the JSON string into a Python dictionary. It then passes this raw data through transform_parsed_data from main/transform.py to normalize field names and types. The transformed data instantiates the Pydantic models defined in main/models.py (such as Basics, Work, and Education), ensuring type safety and validation.

Aggregating Section Results

The _extract_all_sections_separately method iterates over the six target sections—basics, work, education, skills, projects, and awards—invoking _call_llm_for_section for each. Successful extractions merge into a single JSONResume instance that downstream evaluation and scoring modules consume.

Practical Implementation Example

To parse a resume using the Hiring Agent:

from pdf import PDFHandler

# Path to a candidate's PDF resume

pdf_path = "candidates/jane_doe_resume.pdf"

handler = PDFHandler()

# Convert the PDF to a structured JSONResume object

json_resume = handler.extract_json_from_pdf(pdf_path)

if json_resume:
    # Access structured data

    print("Name:", json_resume.basics.name)
    print("Work experiences:", len(json_resume.work or []))
    print("Top skills:", [s.name for s in (json_resume.skills or [])[:5]])
else:
    print("Failed to parse the resume.")

The extract_json_from_pdf method handles the complete pipeline: text extraction, per-section LLM calls, JSON cleaning, and model conversion.

Key Source Files and Their Roles

File Role in Section Parsing
main/pdf.py Orchestrates PDF text extraction, renders prompts, calls the LLM for each section, and builds the final JSONResume.
main/llm_utils.py Provides initialize_llm_provider (chooses Ollama or Gemini) and extract_json_from_response (cleans LLM output).
main/prompt.py Stores default model name, provider mapping, and model-specific parameters that drive provider selection.
main/prompts/template_manager.py Loads Jinja templates for each resume section and the system message.
prompts/templates/*.jinja Section-specific prompt files (e.g., basics.jinja, work.jinja) that guide the LLM to return structured JSON.
main/models.py Pydantic schemas (JSONResume, Basics, Work, etc.) that the parsed JSON is transformed into.
main/transform.py Normalises raw LLM output into the exact shape expected by the Pydantic models.

Summary

  • The Hiring Agent uses a section-by-section LLM approach rather than monolithic parsing, improving accuracy for complex resume layouts.
  • Provider selection automatically chooses between Gemini (cloud) and Ollama (local) based on API key availability and model configuration.
  • Jinja templating ensures consistent prompt engineering across different resume sections while allowing flexibility for section-specific instructions.
  • Pydantic models enforce type safety through transform.py, converting raw LLM JSON into structured Python objects.
  • The PDFHandler class serves as the central orchestrator, managing the entire pipeline from PDF bytes to validated JSONResume instances.

Frequently Asked Questions

What triggers the selection between Gemini and Ollama providers?

The initialize_llm_provider function in main/llm_utils.py checks the DEFAULT_MODEL value from main/prompt.py against the presence of a GEMINI_API_KEY environment variable. If the model name indicates a Gemini family model and the API key exists, it instantiates GeminiProvider; otherwise, it defaults to OllamaProvider for local inference.

How does the system handle malformed JSON responses from the LLM?

The extract_json_from_response function in main/llm_utils.py sanitizes raw LLM outputs by stripping markdown code fences and other formatting artifacts before parsing. This ensures the downstream JSON parser receives clean, valid JSON even if the LLM includes markdown formatting.

Why parse resume sections separately rather than in a single LLM call?

Processing sections separately (basics, work, education, skills, projects, awards) allows for specialized prompting via dedicated Jinja templates and reduces context window pressure. This modular approach, implemented in _extract_all_sections_separately, improves extraction accuracy for specific data types and makes debugging easier when individual sections fail.

Which Pydantic models validate the parsed resume data?

The main/models.py file defines the schema hierarchy, including JSONResume (the root object), Basics (contact information), Work (employment history), Education (academic credentials), Skills (competencies), and other section-specific models. The transform_parsed_data function in main/transform.py maps raw LLM output to these strictly typed models.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →