How LLM-Based Section Parsing Extracts Structured JSON from Resume PDFs
The repository processes PDF résumés by extracting raw Markdown, prompting a Large Language Model (LLM) with section-specific templates, cleaning the JSON response, and mapping the results onto Pydantic models to produce a fully-typed JSONResume object.
The interviewstreet/hiring-agent repository implements a sophisticated pipeline that converts unstructured PDF documents into structured JSON using LLM-based section parsing. This approach divides the résumé into logical segments—such as basics, work, and education—and processes each independently through a type-safe extraction workflow. By combining PyMuPDF for text extraction, Jinja templating for prompt engineering, and Pydantic for data validation, the system reliably transforms human-readable documents into machine-readable schemas.
The Seven-Step Extraction Pipeline
The core logic resides in pdf.py, which orchestrates a seven-stage pipeline from raw PDF bytes to validated JSON objects.
Step 1: PDF to Markdown Conversion
The process begins with PDFHandler.extract_text_from_pdf, which uses PyMuPDF to open the document and iterate over all pages. Each page converts to Markdown format via to_markdown, preserving structural hints like headings and bullet points that guide later section detection.
# Located in pdf.py:52-58
def extract_text_from_pdf(self, pdf_path: str) -> str:
doc = fitz.open(pdf_path)
text = ""
for page in doc:
text += page.to_markdown()
return text
Step 2: Template-Based Prompt Construction
For each résumé section, the TemplateManager.render_template method loads a Jinja template containing a system message and user-side prompt. The template injects the extracted Markdown text and instructions specific to the target section (e.g., "You are extracting the work section").
# Located in pdf.py:79-81
messages = self.template_manager.render_template(
section_name=section,
resume_text=markdown_text
)
Step 3: LLM Invocation
The prepared messages route through initialize_llm_provider (defined in llm_utils.py), which selects the appropriate API client based on configuration. The provider's chat method receives the messages along with model-specific parameters, returning a raw text response.
# Located in pdf.py:88-99
provider = initialize_llm_provider()
response = provider.chat(
messages=messages,
model=DEFAULT_MODEL,
temperature=0.1
)
Step 4: JSON Extraction and Cleaning
Raw LLM outputs often contain explanatory text or markdown fences. The extract_json_from_response helper (in llm_utils.py) strips extraneous content, while a fallback brace-search algorithm locates the first { and last } to isolate the JSON payload before json.loads parses it.
# Located in pdf.py:110-117
json_str = extract_json_from_response(response.text)
if not json_str:
start = response.text.find('{')
end = response.text.rfind('}') + 1
json_str = response.text[start:end]
parsed_data = json.loads(json_str)
Step 5: Data Normalization
The parsed dictionary passes through transform_parsed_data (in transform.py) to normalize field names, flatten nested structures, and reconcile the LLM's output with the expected schema shape. This step ensures the data conforms to the Pydantic model definitions before instantiation.
# Located in pdf.py:119
normalized_data = transform_parsed_data(section, parsed_data)
Step 6: Pydantic Model Construction
After all sections process independently, the pipeline constructs individual Pydantic objects such as Basics(**data) or Work(**data). These populate the final JSONResume container, providing type safety and autocompletion for downstream consumers.
# Located in pdf.py:102-112 and pdf.py:121-127
sections = {}
for section, data in parsed_sections.items():
model_class = SECTION_MODELS[section]
sections[section] = model_class(**data)
return JSONResume(**sections)
Step 7: Orchestration Across Sections
The PDFHandler._extract_all_sections_separately method coordinates the workflow, iterating over the six core sections defined in the JSON Resume schema. It merges individual section results into a unified object, handling failures gracefully to ensure partial extractions don't invalidate the entire document.
# Located in pdf.py:71-74 and pdf.py:89-99
def _extract_all_sections_separately(self, markdown_text: str) -> JSONResume:
sections = {}
for section in SECTION_NAMES:
result = self._extract_section(section, markdown_text)
sections[section] = result
return JSONResume(**sections)
Key Implementation Strategies
Prompt Engineering for Structured Output
Each section utilizes a dedicated system message that constrains the LLM to output-specific schemas. The templates in prompts/template_manager.py explicitly request JSON format and provide field descriptions, reducing hallucination and ensuring consistent key naming across different résumé styles.
Schema-Guided Generation
When a return_model is supplied, the LLM provider receives the model's JSON schema via a format parameter (see pdf.py:101-104). This schema-guided approach instructs the model to emit conforming JSON structures, significantly reducing parsing errors and eliminating the need for complex regex-based extraction.
Robust Post-Processing
The dual-layer extraction strategy—first using extract_json_from_response then falling back to brace-search—ensures reliable JSON isolation even when models deviate from instructions. This robustness handles edge cases where the LLM wraps the JSON in markdown code blocks or adds conversational context.
Complete Implementation Example
The following end-to-end example demonstrates how to extract structured data from a résumé PDF using the pipeline:
from pdf import PDFHandler
# Initialize the handler (loads templates & LLM provider)
handler = PDFHandler()
# Path to a résumé PDF on disk
pdf_path = "example_resume.pdf"
# Extract a fully-typed JSONResume object
json_resume = handler.extract_json_from_pdf(pdf_path)
if json_resume:
# Access typed fields or serialize to JSON
print(f"Candidate: {json_resume.basics.name}")
print(json_resume.json(indent=2))
else:
print("Failed to parse the résumé.")
This snippet instantiates PDFHandler, which configures the default LLM provider and template manager. The extract_json_from_pdf method executes the complete pipeline—text extraction, per-section LLM calls, JSON cleaning, and model construction—returning a JSONResume object with full type safety.
Summary
- PDF Processing: The system uses PyMuPDF in
pdf.py:52-58to convert PDF pages to Markdown, preserving document structure. - Section-Specific Prompts:
TemplateManager.render_templategenerates targeted prompts for each résumé section, improving extraction accuracy. - JSON Cleaning:
extract_json_from_responseand brace-search logic inpdf.py:110-117isolate valid JSON from LLM noise. - Type Safety: Pydantic models in
models.pyenforce schema compliance, withtransform.pynormalizing data between the LLM output and model requirements. - Orchestration:
PDFHandler._extract_all_sections_separatelycoordinates the six-section pipeline, assembling results into a finalJSONResumeinstance.
Frequently Asked Questions
What LLM providers does the hiring-agent support?
The repository uses initialize_llm_provider in llm_utils.py to abstract the underlying API client, allowing configuration of different providers through environment variables or settings. The specific model defaults to DEFAULT_MODEL from prompt.py, but the architecture supports any provider implementing the chat interface with messages and formatting parameters.
How does the system handle malformed PDFs or unscannable text?
The extract_text_from_pdf method in pdf.py relies on PyMuPDF's robust extraction capabilities. If text extraction fails or returns empty content, the subsequent section parsing attempts will return empty results for those sections, but the pipeline continues processing other sections. The JSONResume model construction handles optional fields gracefully, allowing partial résumé parsing rather than complete failure.
Why split the résumé into sections rather than parsing the entire document at once?
Processing sections independently via _extract_all_sections_separately reduces context window pressure and allows specialized prompting for each data type. According to the source code in pdf.py:71-74, this approach enables the LLM to focus on specific schemas (e.g., work history vs. education) and facilitates parallel processing opportunities while maintaining schema compliance through section-specific Pydantic models.
Can the parser handle non-standard résumé formats or creative layouts?
The combination of Markdown conversion and LLM-based section parsing accommodates varied layouts better than rigid template matching. The transform_parsed_data function in transform.py normalizes heterogeneous field names and structures, mapping unconventional layouts to the standard JSON Resume schema. However, extreme visual layouts with heavy graphics may lose semantic structure during the PDF-to-Markdown conversion step.
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 →