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

> Discover how Hiring Agent parses resume sections using LLMs. Learn the technical deep dive into converting PDFs into structured JSON with a template-driven pipeline and Pydantic models.

- Repository: [HackerRank/hiring-agent](https://github.com/interviewstreet/hiring-agent)
- Tags: deep-dive
- Published: 2026-07-11

---

**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`](https://github.com/interviewstreet/hiring-agent/blob/main/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`](https://github.com/interviewstreet/hiring-agent/blob/main/main/llm_utils.py) to determine which LLM backend to use. The selection logic checks the `DEFAULT_MODEL` constant defined in [`main/prompt.py`](https://github.com/interviewstreet/hiring-agent/blob/main/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`](https://github.com/interviewstreet/hiring-agent/blob/main/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:

```python
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`](https://github.com/interviewstreet/hiring-agent/blob/main/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`](https://github.com/interviewstreet/hiring-agent/blob/main/main/transform.py) to normalize field names and types. The transformed data instantiates the Pydantic models defined in [`main/models.py`](https://github.com/interviewstreet/hiring-agent/blob/main/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:

```python
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`](https://github.com/interviewstreet/hiring-agent/blob/main/main/pdf.py) | Orchestrates PDF text extraction, renders prompts, calls the LLM for each section, and builds the final `JSONResume`. |
| [`main/llm_utils.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/llm_utils.py) | Provides `initialize_llm_provider` (chooses Ollama or Gemini) and `extract_json_from_response` (cleans LLM output). |
| [`main/prompt.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/prompt.py) | Stores default model name, provider mapping, and model-specific parameters that drive provider selection. |
| [`main/prompts/template_manager.py`](https://github.com/interviewstreet/hiring-agent/blob/main/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`](https://github.com/interviewstreet/hiring-agent/blob/main/main/models.py) | Pydantic schemas (`JSONResume`, `Basics`, `Work`, etc.) that the parsed JSON is transformed into. |
| [`main/transform.py`](https://github.com/interviewstreet/hiring-agent/blob/main/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`](https://github.com/interviewstreet/hiring-agent/blob/main/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`](https://github.com/interviewstreet/hiring-agent/blob/main/main/llm_utils.py) checks the `DEFAULT_MODEL` value from [`main/prompt.py`](https://github.com/interviewstreet/hiring-agent/blob/main/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`](https://github.com/interviewstreet/hiring-agent/blob/main/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`](https://github.com/interviewstreet/hiring-agent/blob/main/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`](https://github.com/interviewstreet/hiring-agent/blob/main/main/transform.py) maps raw LLM output to these strictly typed models.