Skip to content
All projects

Case study / 2025

AgentCV

A resume engine that generates no prose of its own. It selects from material I have written, fits it to the posting and to the page, and assesses the match candidly enough to advise against applying.

Resume engineFlaskpython-docxWeasyPrintPlaywrightDocker

01

Why I built it

Tailoring a resume for each application is slow, repetitive work that nonetheless affects the outcome: the same experience reads very differently depending on what is presented first. The objective was to keep the tailoring and remove the retyping.

The conventional approach is to pass the job description and the resume to a language model. I chose a different design, and that decision shaped the rest of the project.

02

Every line traces back to written material

A resume is a factual claim about a person’s own history. When a model rewrites it, detail drifts: six months of contract work becomes "led the team", and a library used once becomes a core competency. Verifying every line afterwards costs as much as the tailoring it was meant to replace.

The engine therefore generates no prose. Every sentence in the output already exists, word for word, in a library I maintain by hand. It decides only which entries to include, in what order, and what to remove so the document fits the page. Everything that reaches the page is material I can defend in an interview.

  • Content is held as tagged, hand-written JSON — version controlled, one file per domain.
  • Figures live in a single file, so revising a number updates every bullet that cites it.
  • Edits made before an export apply to that export alone and never modify the library.

03

A score that identifies its evidence

The first version of the score carried no information. It returned 92–100% for every posting, including roles I was plainly unqualified for. A score that is always high is not merely useless but misleading, since it recommends applying everywhere.

The cause was a misplaced denominator: the metric measured how much of my own content reached the output, rather than how much of the posting that content covered. Reversing it made the figures meaningful and made a low score possible.

It no longer reports a single percentage. Each requirement receives its own verdict, and the report names the bullet that earned it.

  • Four verdicts per requirement: evidenced, mentioned only, present in the library but unused, or a genuine gap.
  • Placement warnings: a must-have appearing once, or not until page two, is flagged as effectively invisible.
  • Requirements are weighted by position — a skill under "Requirements" counts for more than the same term in a culture paragraph.

04

Recognising unfamiliar technologies

Job postings name tools that no fixed list can contain: in-house frameworks, recently released libraries, and misspellings. A dictionary lookup discards them silently, which is the most damaging failure available to a matching tool, because the score still appears healthy afterwards.

The vocabulary covers 211 canonical skills and the 614 surface forms postings use for them. Terms outside it are identified from the structure of the surrounding sentence — the phrasing that marks a word as a technology rather than a verb. The method requires no model, no GPU and no additional image size, and it fails visibly: unmatched terms are surfaced rather than dropped.

05

Fitting the page without rewriting

A resume that runs four lines onto a third page is unlikely to be read in full. The usual remedies are to reduce the font size or to let a model condense the prose; the first compromises legibility and the second alters what was written.

Instead the document is trimmed in a fixed priority order and re-measured after each step, until it occupies exactly one or two pages. Nothing is reworded and no type is reduced — the least relevant item is removed.

06

Persistence on a host without storage

The free tier restarts the container and provides no persistent disk, which ruled out SQLite and local files. A managed Postgres instance was not justifiable in cost or operational overhead for a tool opened a few times a week.

In production every write goes through the GitHub Contents API, which makes a private repository the datastore: small JSON documents, a single writer, infrequent writes, and a complete history of every change at no cost. Each save is a commit that can be inspected and reverted. One module touches storage, which is what made replacing the entire layer practical.

07

Tests that measure the rendered page

Most of the 46 test suites inspect code, which cannot detect a broken layout. Nine drive a real browser and measure what was actually rendered, across 10 device widths and 4 breakpoints.

They verify the conditions only a human eye would otherwise catch: text rendered over text, edges that should align and do not, controls with identical function rendered differently, text below 11.5px, and tap targets below 44px on a phone.

What I would do differently

The score is the component I would approach differently. I shipped a metric before establishing any means of evaluating it, and because it was consistently high it appeared to be working. A small set of deliberately poor-fit postings as fixtures would have exposed the fault immediately.

Using GitHub as the datastore is a decision I would repeat at this scale and would not defend for a second concurrent writer. It succeeds precisely because there is no concurrency to manage.

Next case study

EIGENN