# Key Design Decisions Behind Hiring Agent's Architecture: A Modular Pipeline for LLM-Powered Resume Processing

> Discover the key design decisions behind Hiring Agent's modular pipeline for LLM resume processing. Learn how its provider-agnostic architecture ensures flexibility and simplifies vendor integration.

- Repository: [HackerRank/hiring-agent](https://github.com/interviewstreet/hiring-agent)
- Tags: architecture
- Published: 2026-06-29

---

**Hiring Agent implements a provider-agnostic, template-driven pipeline that isolates LLM integration, PDF parsing, and evaluation logic through strict abstraction layers and Pydantic validation, enabling teams to swap vendors or modify prompts without touching core business logic.**

The interviewstreet/hiring-agent repository transforms unstructured resume PDFs into structured JSON data and evaluates candidate fit using Large Language Models. Its architecture relies on six critical design decisions that enforce separation of concerns, making the system testable, extensible, and vendor-independent.

## Provider-Agnostic LLM Abstraction

The system decouples LLM implementation details from business logic through a uniform provider interface.

### Enum-Based Provider Registration

The `ModelProvider` enum in [`main/models.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/models.py) enumerates supported backends (`OLLAMA`, `GEMINI`) at lines 6-11, creating a single source of truth for available services. This enum drives the mapping logic in [`main/prompt.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/prompt.py) (lines 46-64), where `MODEL_PROVIDER_MAPPING` associates specific model names with their respective providers.

### Runtime Provider Selection

The `initialize_llm_provider` function in [`main/llm_utils.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/llm_utils.py) (lines 40-62) implements the factory pattern: it checks for the presence of a Gemini API key, consults the mapping, and returns either an `OllamaProvider` or `GeminiProvider` instance. Both concrete classes implement an identical `chat` method signature, ensuring that calling code remains unchanged regardless of which service processes the request.

**Why this matters:** Adding a new LLM vendor requires only implementing a thin wrapper that respects the `chat` protocol, with zero changes to PDF extraction or evaluation logic.

## Environment-Driven Configuration

All deployment-specific settings live outside the code to support different environments without modification.

Default model names, provider selection, and API keys are defined in [`main/prompt.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/prompt.py) (lines 15-22) through environment variables: `DEFAULT_MODEL`, `LLM_PROVIDER`, and `GEMINI_API_KEY`. Additionally, [`main/config.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/config.py) (lines 5-7) maintains a global `DEVELOPMENT_MODE` flag that toggles behavior across the codebase.

**Why this matters:** Production deployments can switch from Gemini to Ollama or change model versions by updating environment variables, not source code.

## Prompt Templating with Jinja2

The system externalizes all LLM prompts from Python logic to support rapid iteration and version control.

The `TemplateManager` class in [`main/prompts/template_manager.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/prompts/template_manager.py) (lines 13-31) loads Jinja2 templates from the `prompts/templates` directory at initialization, caches them in memory, and renders them on demand. This manager is reused across both PDF section extraction and resume evaluation workflows.

**Why this matters:** Prompt engineering becomes a matter of editing `.jinja` files rather than debugging Python strings, and the same templates work across different LLM providers.

## Strongly-Typed Data Models with Pydantic

The architecture enforces schema validation at the data boundaries to prevent malformed data from propagating downstream.

The comprehensive `JSONResume` hierarchy—including `Basics`, `Work`, and `Skill` models—is defined in [`main/models.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/models.py) (lines 27-166). When `PDFHandler` completes extraction, it instantiates these models, triggering automatic validation. Similarly, evaluation results are captured by the `EvaluationData` model (lines 44-52 in the same file), ensuring that scoring logic receives guaranteed well-formed data.

**Why this matters:** Schema changes surface as explicit validation errors during development, and downstream components can rely on type safety without defensive programming.

## Separation of PDF Parsing and Section Extraction

The extraction layer treats PDF processing as an orchestration concern, separating text extraction from LLM-based structuring.

The `PDFHandler` class in [`main/pdf.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/pdf.py) first extracts raw text using `pymupdf`, then delegates to section-specific methods (`extract_basics_section`, `extract_work_section`, etc.). Each method reuses the generic `_call_llm_for_section` helper (lines 66-84) to maintain consistency. The pipeline implements fail-fast logic (lines 95-100) that aborts the entire extraction if any section fails, preventing partially populated resumes from entering the system.

**Why this matters:** This design enables future parallelization of section extraction and ensures data integrity through atomic operations.

## Dedicated Evaluation Component

Scoring logic is isolated from parsing to support independent iteration of evaluation criteria.

The `ResumeEvaluator` class in [`main/evaluator.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/evaluator.py) (lines 24-92) encapsulates the end-to-end scoring flow: it builds evaluation prompts using the `TemplateManager`, calls the selected provider via `initialize_llm_provider`, parses the JSON response, and returns a typed `EvaluationData` object. Model-specific parameters (`temperature`, `top_p`) are automatically attached using the same mapping defined for extraction, ensuring consistent LLM behavior across pipeline stages.

**Why this matters:** Teams can develop new scoring rubrics or switch evaluation models without impacting the PDF ingestion layer.

## Implementation Examples

### Converting PDF to Structured JSON

The `PDFHandler` orchestrates the entire extraction workflow, automatically validating the output against the `JSONResume` schema:

```python
from pdf import PDFHandler

handler = PDFHandler()
json_resume = handler.extract_json_from_pdf("path/to/resume.pdf")
if json_resume:
    print(json_resume.dict())

```

### Evaluating a Plain-Text Resume

Use `ResumeEvaluator` to score candidate fit against specific criteria, with automatic provider selection:

```python
from evaluator import ResumeEvaluator

evaluator = ResumeEvaluator(model_name="gemini-2.5-pro")
evaluation = evaluator.evaluate_resume("John Doe\nSoftware Engineer ...")
print(evaluation.json())

```

### Switching LLM Backends via Environment Variables

No code changes are required to switch between local and cloud providers:

```bash
export DEFAULT_MODEL="gemma3:4b"
export LLM_PROVIDER="ollama"      # or "gemini"

export GEMINI_API_KEY="YOUR_KEY"  # required only for Gemini

python my_script.py

```

The `initialize_llm_provider` function in [`main/llm_utils.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/llm_utils.py) reads these variables and instantiates the correct wrapper class automatically.

## Summary

- **Provider abstraction**: The `ModelProvider` enum and `initialize_llm_provider` factory enable runtime LLM selection without code changes.
- **Externalized configuration**: Environment variables in [`main/prompt.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/prompt.py) and [`main/config.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/config.py) control deployment-specific behavior.
- **Template-driven prompts**: The `TemplateManager` caches Jinja2 templates for consistent, version-controlled prompting across providers.
- **Schema enforcement**: Pydantic models in [`main/models.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/models.py) validate data at ingestion and evaluation boundaries.
- **Atomic PDF processing**: `PDFHandler` uses section-specific extraction with fail-fast logic to prevent partial data corruption.
- **Isolated evaluation**: `ResumeEvaluator` separates scoring logic from parsing, supporting independent rubric development.

## Frequently Asked Questions

### How does Hiring Agent switch between Gemini and Ollama at runtime?

The system checks the `LLM_PROVIDER` environment variable and `GEMINI_API_KEY` presence in [`main/llm_utils.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/llm_utils.py), then returns the appropriate concrete class (`OllamaProvider` or `GeminiProvider`) that implements a uniform `chat` interface. This allows the rest of the pipeline to call `provider.chat()` without knowing which vendor is active.

### What happens if PDF section extraction fails for one resume section?

The `PDFHandler` implements fail-fast logic in [`main/pdf.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/pdf.py) (lines 95-100) that aborts the entire extraction process if any section fails to parse. This prevents partially populated `JSONResume` objects from entering downstream evaluation, ensuring data integrity.

### Why use Jinja2 templates instead of Python f-strings for LLM prompts?

Externalizing prompts to Jinja2 files in `prompts/templates` allows prompt engineers to modify text without redeploying Python code. The `TemplateManager` caches these templates at startup, making them reusable across different LLM providers and enabling version control of prompt iterations.

### Where are the default model parameters and provider settings defined?

Default model names, provider mappings, and API keys are defined in [`main/prompt.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/prompt.py) (lines 15-22 and 46-64), while global flags like `DEVELOPMENT_MODE` reside in [`main/config.py`](https://github.com/interviewstreet/hiring-agent/blob/main/main/config.py) (lines 5-7). All settings can be overridden via environment variables to support different deployment environments.