AI-Powered Resume Optimization
**Note:: this project's DB may not be active. Reach out to jake@jjhdigital.com if so.
ResumeStream is the most engineering I have ever put behind a single text field. You paste a job description and the app rebuilds your resume against it, writes the cover letter, and keeps the record of where everything went. The deployment died (the Vercel URL loads and spins forever), which is a shame, because the machinery underneath was the good part.
So this is less a case study than a look under the hood: everything below ran behind one text field, and none of it showed itself unless something broke.
The pipeline
Every run was a chain of five passes over the job description, each with its own prompt, its own model, and its own output contract. The stages never talked to each other in prose; they passed structured JSON, so a failure at one stage was a validation error instead of a weird resume.
- Read. Pull the title, seniority, must-haves, nice-to-haves and the literal phrases an ATS is likely to match on. Claude 3.5 Haiku, because extraction is cheap work and there is a lot of it.
- Map. Score every bullet in the existing resume against those requirements and build a coverage report: what the job asks for, what the resume already proves, and what is genuinely missing.
- Rewrite. Rebuild each bullet in the job's vocabulary without inventing employers, titles or dates. Sonnet, with the coverage report in context so it fills gaps from evidence instead of guessing.
- Budget. Fit the result to one printable page. Word and line budgets per section, applied as constraints before generation rather than as edits after it, which is the difference between a tight resume and a trimmed one.
- Critique. A second pass with a different prompt reads the output as a hiring manager would and throws out anything that reads like a keyword salad.
Making the model behave
The hard part was never writing a prompt. It was turning a probabilistic text generator into something that could be trusted with a document someone sends to an employer, so every run had guarantees around it:
- A schema gate between the model and the interface. Nothing reached a screen unless it validated, which sounds obvious and is the single decision that made the rest of the app possible.
- A repair loop: a failed validation re-asked with the error attached, twice, then fell back to the last good draft instead of showing a half-parsed resume.
- Versioned prompts. Every prompt lived in a file with a version, and every generated document recorded the version that produced it, so a quality regression was a diff to read, not a mystery to chase.
- A regression set in the repo: a dozen real job descriptions against one resume, re-run whenever a model or a prompt changed. Switching Sonnet for Haiku in one stage became a measurement instead of a vibe.
Documents are the hard part
Two output formats, PDF and DOCX, from one content model, so the resume could not drift between them.
- A server-side HTML template with print CSS as the single source of layout, because layout logic in two places is two resumes.
- Headless Chrome for the PDF, pinned to exact page dimensions.
- OOXML written directly for the DOCX, because every converter I tried mangled spacing in ways hiring managers notice and never mention.
- Margins in points and fonts embedded, so A4 and Letter both landed on exactly one page.
Batch, and the bill
Batch mode looked like a UI feature and turned out to be an architecture problem. A dozen job descriptions is a dozen long generations, so:
- A queue of idempotent jobs, so a retry could never produce two resumes for one application.
- Exponential backoff and a per-user budget, so one enthusiastic afternoon could not eat the API allowance for the week.
- Progress streamed per document, because a batch that looks frozen is a batch people abandon.
What I would change
Two systems were overkill from the first week, and I knew it. MongoDB: the only documents were resumes, small and structured, and Postgres would have been less work. Auth0: it put a redirect between the user and a single form, which is exactly where you lose people.
What stuck
Put a contract between the model and the interface. Version prompts like code, because you will change them more than you expect. Treat a batch job as a distributed system, not a loop. And budget for document rendering as its own project, because it always is.
The code is still in the repo if you want to read it. The deployment is not.