Key Design Decisions Behind Hiring Agent's Architecture: A Modular Pipeline for LLM-Powered Resume Processing
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 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 (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 (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 (lines 15-22) through environment variables: DEFAULT_MODEL, LLM_PROVIDER, and GEMINI_API_KEY. Additionally, 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 (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 (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 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 (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:
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:
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:
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 reads these variables and instantiates the correct wrapper class automatically.
Summary
- Provider abstraction: The
ModelProviderenum andinitialize_llm_providerfactory enable runtime LLM selection without code changes. - Externalized configuration: Environment variables in
main/prompt.pyandmain/config.pycontrol deployment-specific behavior. - Template-driven prompts: The
TemplateManagercaches Jinja2 templates for consistent, version-controlled prompting across providers. - Schema enforcement: Pydantic models in
main/models.pyvalidate data at ingestion and evaluation boundaries. - Atomic PDF processing:
PDFHandleruses section-specific extraction with fail-fast logic to prevent partial data corruption. - Isolated evaluation:
ResumeEvaluatorseparates 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, 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 (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 (lines 15-22 and 46-64), while global flags like DEVELOPMENT_MODE reside in main/config.py (lines 5-7). All settings can be overridden via environment variables to support different deployment environments.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →