Project — A Resume-to-JSON Extractor
What This Project Builds
This project combines every technique from this unit into one realistic, end-to-end feature: a command-line tool that reads a plain-text resume and extracts a structured, validated record from it — contact information, work history, education, and skills — suitable for storing in a database or feeding into an applicant-tracking system. Resumes are an excellent test case for structured extraction precisely because they're genuinely messy in practice: people format them inconsistently, omit sections, use varying date formats, and describe the same kind of information (a job title, a degree) in wildly different phrasings — exactly the kind of real-world irregularity Lesson 1 argued free-text parsing handles poorly and Lessons 2 through 4 showed how to handle robustly with schema-enforced structured outputs.
Defining the Data Model
Following Lesson 3's guidance to default to Pydantic models in a Python codebase, the project's data model is defined as a set of nested classes reflecting a resume's natural structure — a person, their work history, their education, and their skills.
# models.py
from pydantic import BaseModel
from typing import Optional
from enum import Enum
class ContactInfo(BaseModel):
full_name: str
email: Optional[str] = None
phone: Optional[str] = None
location: Optional[str] = None
class WorkExperience(BaseModel):
job_title: str
company: str
start_date: str # kept as a string — see "Why Dates Stay Strings" below
end_date: Optional[str] = None # None signals "current position"
is_current: bool
responsibilities: list[str]
class EducationLevel(str, Enum):
HIGH_SCHOOL = "high_school"
ASSOCIATE = "associate"
BACHELORS = "bachelors"
MASTERS = "masters"
DOCTORATE = "doctorate"
OTHER = "other"
class Education(BaseModel):
institution: str
degree_level: EducationLevel
field_of_study: Optional[str] = None
graduation_year: Optional[int] = None
class Resume(BaseModel):
contact: ContactInfo
work_experience: list[WorkExperience]
education: list[Education]
skills: list[str]
extraction_confidence: bool # per Lesson 4's confidence-signal pattern
Why Dates Stay Strings
A deliberate design choice worth explaining rather than glossing over: start_date and end_date are typed as plain strings, not as a structured date type. Resumes describe dates in a huge variety of formats — "June 2019," "06/2019," "2019," "Summer 2019" — and forcing the model to normalize every one of these into a single strict date format during extraction risks exactly the kind of fabrication problem Lesson 4 covered: a resume that only states a year, with no month, would force the model to either invent a plausible-but-fictional month or violate a stricter date schema. Keeping dates as loosely-typed strings during extraction, with a separate, explicit normalization step afterward (shown later in this lesson) that can handle "we don't know the month" gracefully, avoids pushing that ambiguity onto the extraction step, which is the step least equipped to handle it honestly.
def normalize_date_string(raw: str) -> dict:
"""A separate, explicit normalization step — deliberately kept apart from
extraction itself, so ambiguity in date formatting doesn't pressure the
extraction schema into fabricating precision that isn't in the source text."""
import re
year_match = re.search(r"(19|20)\d{2}", raw)
year = int(year_match.group(0)) if year_match else None
return {"raw": raw, "year": year, "has_month": bool(re.search(r"[A-Za-z]{3,9}|\d{1,2}/", raw))}
This is a direct, concrete illustration of a principle Lesson 4 established more abstractly: separating "what the schema requires the model to produce" from "what further normalization or interpretation your application performs afterward" keeps each step honest about what it actually knows, rather than collapsing both into a single step that has to guess at things it can't determine confidently.
The Extraction Function
With the model defined, the extraction function itself follows the layered pattern Lesson 4 built — request/refusal handling, then schema-driven parsing, then business-logic validation — applied to this project's specific data shape.
# extractor.py
from pydantic import ValidationError
from dataclasses import dataclass, field
from models import Resume
@dataclass
class ExtractionOutcome:
resume: Resume | None = None
issues: list[str] = field(default_factory=list)
@property
def is_usable(self) -> bool:
return self.resume is not None and not self.issues
EXTRACTION_INSTRUCTIONS = """
You extract structured resume data from raw resume text. Follow these rules:
- Only extract information explicitly present in the text; never invent employers, dates, or degrees.
- If contact information (email, phone, location) is missing, leave those fields null.
- Set is_current to true only if the text explicitly indicates the position is ongoing
(e.g., "present", "current", no end date given for the most recent role).
- Set extraction_confidence to false if the resume text is too sparse, garbled, or
ambiguous to extract with reasonable confidence.
"""
def extract_resume(resume_text: str) -> ExtractionOutcome:
try:
response = client.responses.parse(
model="gpt-5.6-luna",
instructions=EXTRACTION_INSTRUCTIONS,
input=resume_text,
text_format=Resume,
)
except ValidationError as e:
return ExtractionOutcome(issues=[f"Schema validation failed: {e}"])
except Exception as e:
return ExtractionOutcome(issues=[f"Request failed: {e}"])
if response.output_parsed is None:
refusal = getattr(response, "refusal", "unknown reason")
return ExtractionOutcome(issues=[f"Model refused: {refusal}"])
resume = response.output_parsed
business_issues = validate_resume(resume)
return ExtractionOutcome(resume=resume, issues=business_issues)
Notice the EXTRACTION_INSTRUCTIONS constant does real work the schema alone cannot: it's what tells the model not to invent employers or dates (a content-level instruction, not a structural constraint), and it's what defines exactly when extraction_confidence should be set to False — directly applying Lesson 3's point that instructions and schemas are complementary, addressing content and structure respectively, neither substituting for the other.
Business-Logic Validation for This Domain
Following Lesson 4's pattern directly, domain-specific validation checks things the schema has no vocabulary to express — checks that depend on understanding what a sensible resume actually looks like, not just what a syntactically valid one looks like.
def validate_resume(resume: Resume) -> list[str]:
issues = []
if not resume.contact.full_name.strip():
issues.append("Missing or empty full name")
if not resume.work_experience and not resume.education:
issues.append("No work experience or education found — likely a poor extraction or unusual input")
for job in resume.work_experience:
if job.is_current and job.end_date is not None:
issues.append(f"Job '{job.job_title}' marked as current but has an end_date set — inconsistent")
if not job.is_current and job.end_date is None:
issues.append(f"Job '{job.job_title}' is not current but has no end_date — likely incomplete extraction")
for edu in resume.education:
if edu.graduation_year is not None and (edu.graduation_year < 1950 or edu.graduation_year > 2035):
issues.append(f"Suspicious graduation year for {edu.institution}: {edu.graduation_year}")
if not resume.extraction_confidence:
issues.append("Model flagged low confidence in this extraction")
return issues
The is_current/end_date consistency check is a good example of a validation rule the schema itself cannot express: nothing in the WorkExperience schema prevents is_current=True and end_date="2020" from coexisting, since each field is individually valid on its own — catching that specific combination requires code that understands the relationship between two fields, which is exactly the kind of cross-field business logic Lesson 4 argued belongs in application code rather than the schema.
Running the Extractor End to End
Bringing the pieces together into a small command-line script demonstrates the complete pipeline against a realistic (if intentionally imperfect) resume text.
# main.py
import sys
from extractor import extract_resume
SAMPLE_RESUME = """
Priya Nair
priya.nair@email.com | Seattle, WA
EXPERIENCE
Senior Data Analyst, Northwind Analytics — March 2021 to Present
- Built dashboards used by 40+ internal stakeholders
- Led a migration from spreadsheets to a proper data warehouse
Data Analyst, Contoso Retail — June 2018 to February 2021
- Analyzed sales trends across 200+ store locations
EDUCATION
B.S. in Statistics, University of Washington, 2018
SKILLS
Python, SQL, Tableau, data visualization, statistical modeling
"""
def main():
outcome = extract_resume(SAMPLE_RESUME)
if outcome.is_usable:
resume = outcome.resume
print(f"Extracted resume for: {resume.contact.full_name}")
print(f" Email: {resume.contact.email}")
print(f" {len(resume.work_experience)} job(s), {len(resume.education)} education record(s)")
for job in resume.work_experience:
status = "current" if job.is_current else f"until {job.end_date}"
print(f" - {job.job_title} at {job.company} ({job.start_date} — {status})")
else:
print("Extraction had issues:")
for issue in outcome.issues:
print(f" - {issue}")
if outcome.resume:
print("(Partial data was still extracted — review before using.)")
if __name__ == "__main__":
main()
Running this against the sample resume text should produce a fully populated, schema-valid, business-validated Resume object — worth then deliberately testing against a much sparser, messier resume text (a single line: "John, worked various jobs, good with computers") to observe the pipeline's behavior on the kind of genuinely low-information input a real system will inevitably encounter, rather than only ever testing against clean, favorable examples.
Handling a Batch of Resumes
A realistic version of this feature processes many resumes, not one — worth extending to show how the single-resume pipeline scales to a batch, and how per-resume failures should be handled without one bad resume derailing the entire batch.
# batch.py
import json
from extractor import extract_resume, ExtractionOutcome
def process_resume_batch(resume_texts: list[str]) -> dict:
results = {"successful": [], "needs_review": [], "failed": []}
for i, text in enumerate(resume_texts):
outcome = extract_resume(text)
if outcome.is_usable:
results["successful"].append({"index": i, "resume": outcome.resume.model_dump()})
elif outcome.resume is not None:
# Schema-valid and parsed, but flagged by business validation
results["needs_review"].append({
"index": i,
"resume": outcome.resume.model_dump(),
"issues": outcome.issues,
})
else:
# Refusal, request failure, or schema validation failure — nothing usable at all
results["failed"].append({"index": i, "issues": outcome.issues})
return results
def summarize_batch(results: dict) -> None:
total = len(results["successful"]) + len(results["needs_review"]) + len(results["failed"])
print(f"Processed {total} resumes:")
print(f" {len(results['successful'])} fully successful")
print(f" {len(results['needs_review'])} need manual review")
print(f" {len(results['failed'])} failed entirely")
This three-way bucketing — successful, needs review, failed — is a direct, practical application of Lesson 4's distinction between complete failure (a refusal, a request error, nothing usable) and a "soft" validation problem (schema-valid data that business logic flagged as suspicious): the former genuinely has nothing to show for it, while the latter has a real, partially or fully extracted record that a human reviewer can quickly look at and either approve or correct, rather than starting from scratch. Structuring a batch pipeline this way — rather than a single binary success/failure split — makes the manual review workload proportional to genuine uncertainty, not to every resume indiscriminately.
Testing the Full Pipeline
Consistent with this course's testing philosophy, the extraction and validation logic is tested against fake, pre-built Resume instances and fake response objects, entirely independent of any live API call.
# test_extractor.py
from models import Resume, ContactInfo, WorkExperience, Education, EducationLevel
def make_valid_resume() -> Resume:
return Resume(
contact=ContactInfo(full_name="Priya Nair", email="priya.nair@email.com"),
work_experience=[
WorkExperience(
job_title="Senior Data Analyst", company="Northwind Analytics",
start_date="March 2021", end_date=None, is_current=True,
responsibilities=["Built dashboards"],
),
],
education=[Education(institution="University of Washington", degree_level=EducationLevel.BACHELORS, graduation_year=2018)],
skills=["Python", "SQL"],
extraction_confidence=True,
)
def test_validate_resume_accepts_clean_data():
resume = make_valid_resume()
issues = validate_resume(resume)
assert issues == []
print("PASS: a clean, consistent resume produces no validation issues")
def test_validate_resume_catches_inconsistent_current_job():
resume = make_valid_resume()
resume.work_experience[0].end_date = "2022" # inconsistent with is_current=True
issues = validate_resume(resume)
assert any("marked as current but has an end_date" in issue for issue in issues)
print("PASS: validate_resume catches an is_current/end_date inconsistency")
def test_validate_resume_catches_suspicious_graduation_year():
resume = make_valid_resume()
resume.education[0].graduation_year = 1890
issues = validate_resume(resume)
assert any("Suspicious graduation year" in issue for issue in issues)
print("PASS: validate_resume flags an implausible graduation year")
test_validate_resume_accepts_clean_data()
test_validate_resume_catches_inconsistent_current_job()
test_validate_resume_catches_suspicious_graduation_year()
Because validate_resume() is ordinary Python operating on ordinary Pydantic instances, every one of these tests runs instantly, deterministically, and at zero cost — exactly the property this course's testing pattern has aimed for throughout, and especially valuable here, where the business-logic rules (the is_current/end_date consistency check, the graduation-year plausibility bound) are exactly the kind of project-specific logic most likely to need adjustment as the tool encounters more real-world resume variety, and therefore most valuable to have covered by fast, cheap tests that don't require a live API call to re-verify after every change.
Exposing the Extractor as a Small Web Endpoint
The command-line script is deliberately minimal so the extraction logic stays the focus, but it's worth sketching how the same extract_resume() function would sit behind a simple web API, since a resume-extraction feature is realistically consumed by an upload form rather than a terminal.
# app.py
from flask import Flask, request, jsonify
from extractor import extract_resume
app = Flask(__name__)
@app.route("/extract-resume", methods=["POST"])
def extract_resume_endpoint():
resume_text = request.json.get("resume_text", "")
if not resume_text.strip():
return jsonify({"error": "resume_text is required"}), 400
outcome = extract_resume(resume_text)
if outcome.resume is None:
return jsonify({"status": "failed", "issues": outcome.issues}), 422
status = "success" if outcome.is_usable else "needs_review"
return jsonify({
"status": status,
"resume": outcome.resume.model_dump(),
"issues": outcome.issues,
}), 200
Notice the HTTP status codes map naturally onto the three-way outcome this project has used throughout: a genuine failure (no usable data at all) returns 422 Unprocessable Entity, while both a clean success and a needs-review result return 200 OK with a status field distinguishing them — since a needs-review result still carries real, partially-trustworthy data the caller can choose to use, display for human confirmation, or discard, rather than being an error condition in the HTTP sense. This is the same tiered-outcome thinking from the batch pipeline, applied at the level of a single API response instead of a whole batch.
Troubleshooting Checklist for This Project
Every extraction comes back with extraction_confidence=False, even for clean resumes. Check the exact wording of EXTRACTION_INSTRUCTIONS — an overly cautious instruction ("only set this to true if you are absolutely certain") can push the model toward under-confidence even on genuinely clear input. Calibrate the instruction's wording against a batch of known-good sample resumes, adjusting until confident extractions on clean input reliably come back True.
Work experience entries are missing responsibilities even when the source resume clearly lists bullet points under each job. This is usually a sign the model is treating the requirement loosely rather than a schema problem — try making the instruction more explicit about capturing bullet points verbatim as a list, and consider whether responsibilities should be nullable-empty (an empty list is valid for list[str] without any schema change) versus something the validation layer should flag as suspicious when empty for a role that clearly had bullet points in the source text.
The is_current/end_date consistency check in validate_resume() fires on résumés where it shouldn't. Double-check the instruction's wording around what counts as "current" — a resume ending a role with "January 2024" for a document written in early 2024 might legitimately be a very recent departure rather than a current position, and the instruction may need a firmer definition of what counts as an explicit "present" or "current" signal versus an inferred one.
A batch run is much slower than expected. Confirm the batch loop isn't accidentally processing resumes sequentially when they could run concurrently — Unit 12 covers the async and concurrency patterns relevant to speeding up a batch pipeline like this one properly, once error handling and retries are also in view.
What This Project Demonstrates, End to End
Stepping back, this project is a deliberately realistic test of everything this unit built toward, in a domain — resumes — chosen specifically because it resists the naive free-text parsing approach Lesson 1 opened with. A resume has no single canonical format; two people might list the same job in a dozen visibly different ways, and any parsing approach hard-coded against one specific arrangement of labels and line breaks would need constant, reactive patching as new resume formats appeared in production. The schema-driven approach this project builds instead separates concerns cleanly: the Resume model (Lesson 3) defines exactly what shape the extracted data must take regardless of the source formatting; EXTRACTION_INSTRUCTIONS (echoing Unit 3) tells the model how to handle judgment calls the schema alone can't express (when to consider a role "current," how to handle vague dates); and validate_resume() (Lesson 4) catches the class of schema-valid-but-substantively-wrong output — an internally inconsistent record, an implausible graduation year — that no amount of schema tightening alone could fully prevent.
The three-way batch bucketing (successful, needs review, failed) is worth calling out one more time as the project's most practically valuable structural decision: it turns "did this work" from a binary judgment into a spectrum that matches how confident an application can actually be about a given extraction, and it routes exactly the right amount of human attention to exactly the records that need it — full automation for the clean cases, a lightweight review queue for the ambiguous ones, and a clear failure log for the cases nothing could be salvaged from at all. This shape — not this project's specific resume fields, but the general pattern of schema plus instructions plus business validation plus tiered outcome handling — is the transferable skill this unit has been building toward, and it applies directly to any other structured-extraction feature: invoices, support tickets, meeting notes, or any other domain where real-world input arrives messier than a schema alone can fully anticipate.
Extending the Project
A few natural extensions are worth attempting independently, each reinforcing a different piece of this unit's material in a slightly more advanced context.
Add a years_of_experience computed field, derived in Python from the extracted work_experience list's dates (using the normalize_date_string() helper from earlier in this lesson) rather than asked of the model directly — a good exercise in deciding which values belong in the schema (facts the model should extract) versus which belong in ordinary post-processing code (derived values computed from already-extracted facts), a distinction this project has applied throughout but is worth deliberately practicing on a new field.
Add streaming support to the extraction call, applying Unit 5's material to check whether your SDK version supports combining parse()-style structured extraction with stream=True — and if direct combination isn't supported, consider what a reasonable fallback looks like for a feature where a user might want to see extraction progress on a very long resume or batch.
Build a small confidence-scoring report across a batch, tallying how often extraction_confidence came back False or business validation flagged an issue across a real sample of resumes, and using that aggregate rate as a concrete signal for whether the extraction instructions or schema need revision — directly applying this unit's logging-and-review guidance from Lesson 4 at the scale of a full batch rather than one resume at a time.