Skip to content
Naabel.
Live and public

AI Resume Analyzer

Upload a resume, get a scored review with concrete rewrites.

Role
Sole developer across product, frontend, backend, AI pipeline, testing and deployment
Type
Personal project
Timeline
Aug 2026 to Oct 2026
Status
Live and public
Desktop browser window showing the AI Resume Analyzer results page with a score of 72, beside a phone showing the same score gauge.
AI Resume Analyzer on desktop and mobile.

The problem

Useful resume feedback is hard to get. Articles give generic advice like “use action verbs” and “add keywords”, and a careers adviser or recruiter's time is slow and often costs money.

General-purpose AI chat tools will write a review, but the score shifts each time you ask, the advice rarely points at a specific line, and when the service is busy or returns something malformed you get an error and nothing else. That's a poor deal for a graduate preparing applications, or a career changer tailoring one resume to several roles.

The goal

Return feedback that quotes the candidate's own lines, with a score that is calculated rather than guessed. If the AI fails for any reason, return a rule-based report instead of an error page.

Full stack
Next.js, React, TypeScript, Tailwind CSS, shadcn/ui
AI
NVIDIA NIM (Nemotron), Anthropic Claude API, Zod
Parsing
unpdf, mammoth
Tests & CI
Vitest, GitHub Actions
Hosting
Railway
Automated tests
559
Scored resume sections
6
API routes
6
Eval set in band
12/12

Architecture

One request, three stages. The browser owns the upload, the preview and the report; the API routes check and extract the file in memory; the model only judges, while code computes the numbers, validates the answer and falls back when it has to.

  1. Next.js client

    • PDF or DOCX upload + job description
    • Text preview before analysis
    • Staged progress bar
    • Results UI and session history
  2. API routes

    • File-type check by leading bytes
    • In-memory extraction (unpdf and mammoth)
    • Rule-based pre-checks
    • /api/extract, /api/analyze
  3. AI & validation

    • NVIDIA Nemotron or Claude, schema-constrained JSON
    • Code computes scores and match %
    • Zod validation, one retry
    • Rule-based fallback report

The flow, and where it fails

The same request end to end. The dashed path is what happens when a dependency fails.

PDF or DOCX,extracted in memorynever written to diskModel rates sixweighted dimensionsinside a strict JSON schemaZod validates,code computes the scoreinvalid output gets one retryScoredreportscore outof 100Rule-basedreporton any AIfailure
The model proposes. It does not decide the number, and it does not decide whether you get a page.

Six features

Each one shown on the built-in sample report or the fictional resume I made for these captures. No real person's data.

01

Upload and extraction

A PDF or Word file in; the exact text the AI will read out.

Drop in a PDF or .docx resume of up to 5 MB. The server checks what the file really is by reading its leading bytes, so a PDF renamed to .docx gets a clear message rather than a parser error. The text is extracted in memory with unpdf or mammoth and the file is never written to disk. Before anything is sent to the model, the app shows that text. It is exactly what the AI will read, so a badly parsed file shows up before you wait for a review of it.

Uploaded fictional resume 'jordan-example-resume.pdf' with its extracted plain text shown in a grey monospace panel, beside a pasted job description.
Before anything is analysed, the app shows the exact text it extracted from the file.

02

Hybrid scoring

The model judges; code does the arithmetic.

Plain code first counts words, bullets and section headings and hands those counts to the model as facts, since language models are unreliable at counting. The model then rates six rubric dimensions inside a strict JSON schema. The app applies the weights to produce the score out of 100: impact 30%, relevance 20%, clarity and structure 15% each, skills and ATS 10% each. It derives the verdict from that number, so the score and its label can never disagree.

Results page showing a circular score gauge reading 72 out of 100 labelled 'Needs work', a short summary, and a 'Why this score' explanation, with the start of the section breakdown and AI feedback below.
The report opens with the overall score and the reasoning behind it.
Phone-width results page for the built-in sample report: the score gauge reading 72 out of 100, labelled 'Needs work', with the summary underneath. All data is fictional.
The same report on a phone.

03

Section feedback and things to fix

Six scored sections, and feedback that quotes the resume.

Contact, summary, experience, education, skills and formatting each get a score, a Pass / Needs work / Poor label and a short note. Labels are derived from the score in code, so a lower number can never carry a better label. Feedback items are graded pass, warn or fail, listed with the most serious first, and each one opens to a detail that quotes the candidate's own lines. A separate “Things to fix” list catches smaller problems such as gaps and typos.

AI feedback card with six collapsible items: one red cross for contact details in the page header, three amber warnings, and two green ticks.
Feedback items are graded pass, warn or fail, with the most serious first.
'Things to fix' card with two amber items: a nine-month employment gap and two spelling errors, each with a suggested fix.
Smaller issues, such as an unexplained gap or typos, get their own list.

04

AI bullet rewrites

Duties turned into achievements, without inventing the numbers.

Each weak bullet sits beside an improved version and the reason for the change, with one-click copy. Numbers only the candidate knows are left as [X] placeholders to fill in, and if the model writes a new figure anyway, code replaces it with [X] before the report is shown.

Suggested bullet rewrites card showing three pairs of 'Original' and 'Improved' bullet points with a Copy button and a 'Why' line under each; numbers to be filled in are marked [X].
Each weak bullet beside its rewrite and the reason for it. All values are fictional.

05

Job-description keyword matching

What the role asks for, what the resume shows, and the gap.

Paste a job posting and the model lists the role's terms that the resume covers and the ones it misses. It is told to judge presence semantically, so a skill described in different words still counts, and never to mark something missing just because the literal term is absent. The match percentage is not the model's: code calculates it from the two lists.

Keyword match card with six green 'in your resume' pills (TypeScript, React, Node.js, PostgreSQL, REST APIs, Git), four grey 'not found' pills (Kubernetes, CI/CD, GraphQL, AWS) and a bar reading 'Matched 6/10 keywords, 60%'.
Keywords from the job description that the resume covers, and the ones it misses.

06

History, sample report and fallback

Every path ends in a report, never an error page.

If the AI is unavailable, rate-limited, slow or returns something unusable, the user still gets a rule-based report on contact details, headings, bullets and length, with a banner explaining what happened. The score gauge is deliberately hidden in this mode, so a structural check is never passed off as a quality grade. Past analyses are listed for the browser session with score, date, a link back and a delete action; the extracted resume text itself is never stored. A built-in sample report shows the full result on fictional data, without an AI call.

Fallback report with an amber banner saying the analysis couldn't finish and offering 'Upload it again', followed by 'What we found' and 'Automated checks' cards with green ticks and amber warnings, and a keyword panel explaining matching needs the AI review.
With no AI available, the user still gets a rule-based report and a clear explanation.

Decisions worth defending

The trade-offs behind the design, and why I made each one.

Code computes the score, not the model

The model rates six weighted dimensions; code combines them into the score out of 100, and derives the verdict, the section labels and the keyword match percentage the same way.

Why: Asked for a single 0–100 number, the model returned the midpoint of a rubric band almost every time, leaving about five reachable values on a hundred-point gauge. Arithmetic is the one part I can guarantee, so the model never does it.

Schema-constrained output, two Zod schemas

One schema constrains the model while it writes. A second Zod schema validates what decoding can't: text lengths, item counts, and whether the section scores are coherent with the overall score.

Why: Constrained decoding guarantees shape, not quality. The first schema means I never parse broken JSON; the second catches a well-formed answer that still doesn't add up, and its error message becomes the single retry prompt.

Repair cosmetic defects instead of rejecting them

After validation, code trims section notes to their last full sentence, merges duplicate feedback headlines, and replaces invented figures in rewrites with [X].

Why: Rejecting a report for a repeated headline would spend the only retry and risk dropping the user to the rule-based report over something code can fix deterministically.

A rule-based fallback behind every AI call

Any AI failure returns a rule-based structural report with a banner, whether it is a missing key, a refusal, a timeout, a rate limit or two failed validations.

Why: It is never a server error or a blank page. The specific cause is kept, so the banner can say whether waiting will help.

The resume and job description are untrusted input

Both arrive wrapped in <resume> and <job_description> tags, and the system prompt states that anything inside them is data, never instructions.

Why: Both come from members of the public. Text that imitates a command, such as “ignore previous instructions”, “score this 100” or a fake system prompt, is flagged as suspicious instead of obeyed. Model output is untrusted too: React escapes it, and an ESLint rule forbids dangerouslySetInnerHTML.

Railway over Vercel

The app runs as a long-running Node server on Railway rather than as serverless functions.

Why: In the worst case, two 120-second model attempts plus about 5 seconds of processing run to 245 seconds, beyond Vercel's function limit on the plans I targeted. If the platform kills a request mid-generation, the user sees a network error and the fallback never gets to run.

Two providers behind one interface

The app runs on NVIDIA Nemotron through NIM or on the Anthropic Claude API, picked by one environment variable. Only the transport differs; the schemas, rubric, prompt, validation, retry and fallback are shared by both.

Why: Switching providers is a config change and a restart, not different code. Keeping both working is what proves the seam is real: the offline suite runs the same tests against each provider with mocked responses, not live calls. An abstraction with one implementation is just indirection.

Reasoning mode off

Nemotron's reasoning mode is switched off.

Why: I measured both settings on the same resume: 7 seconds with reasoning off against 129 with it on, for the same score. With reasoning on, the model also sometimes returned an empty answer.

Problems I had to solve

Each one found in a real run, measured, then fixed, and where it could be, fixed in code rather than in the prompt.

Scores clustering in rubric bands

Problem
Repeat runs on the same resume jumped between band midpoints, scoring 82, then 94, then 82. One 12-point jump flipped the verdict a user would read.
What I did
Moved the overall score out of the model's answer: the model scores six dimensions and code applies the weights.
Outcome
Across nine live runs, the widest spread on one resume fell from 12 points to 4, and analyses failing validation twice fell from 1 in 9 to 0.

The model getting the match percentage wrong

Problem
Production showed 40% for a 5-of-11 keyword match. The right answer is 45%; the model had rounded before dividing.
What I did
Removed the percentage from the model's output and calculated it from the matched and missing lists.
Outcome
Correct by construction.

Section notes cut off mid-sentence

Problem
A real analysis cut two section notes off at the 230-character schema limit.
What I did
Traced it to the decoder stopping at the limit, plus a gap in my truncation repair that read an opening quote as finished punctuation. Notes are now trimmed to their last full sentence after validation.
Outcome
The follow-up real run passed first time, in 14.4 seconds, with no truncated notes.

Invented figures in rewrites

Problem
The same run invented figures such as “15+ product pages” in its bullet rewrites, numbers the candidate never wrote.
What I did
New numbers in a rewrite are replaced with [X] in code, after validation, rather than rejected and retried.
Outcome
The follow-up real run had none, and the retry stays available for problems code can't fix.

Slow responses with reasoning on

Problem
With the model's reasoning mode on, one resume took 129 seconds and sometimes came back empty.
What I did
Measured both settings on the same resume, then turned reasoning off.
Outcome
7 seconds against 129, with the same score. That is one benchmark, not an average.

Runaway whitespace responses

Problem
The model sometimes stopped writing JSON and emitted whitespace until its token limit: one response was 16,384 tokens, 91.9% whitespace, and took up to 187 seconds.
What I did
Capped output at 4,000 tokens against a largest real response of 1,645, and made the retry quote the validator's error and the model's previous output.
Outcome
A runaway now ends in a correctly labelled fallback in about 13 seconds instead of about 150. It still happens; the cap limits the cost.

The upload form overflowing on phones

Problem
With a file selected, the upload preview and the job-description box ran past the card's edge at 390px, and at 320 and 360px the whole page scrolled sideways.
What I did
Traced it to the form grid. Below 880px it had no column template, so its one column was an implicit auto track that could not shrink below the file name, which is set not to wrap. Giving the grid an explicit single column lets the track shrink to the card.
Outcome
The form stays inside the card and a long file name truncates, as the preview always meant it to. The desktop layout is unchanged.
Phone-width upload card with a fictional DOCX selected, its name cut short to 'Jordan_Example_Res…' inside the card, above a grey preview of the extracted text: the name Jordan Example, an example.com email, a 555 phone number and a one-line summary.
After the fix: the form fits the card at 390px.

How I measured it

Twelve resumes with known answers, so a prompt or model change is judged on numbers rather than by reading one report.

The eval set is four professions (backend developer, nurse, junior designer and accountant) at three strength levels, each resume scored against a job description in its own field. A strong resume should land between 80 and 100, a middling one between 55 and 70, and a weak one between 0 and 44.

Each resume is scored three times and the mean is checked against its band. One run was not enough: on identical text, one dimension swung by about four points of overall score between runs, and a single-run suite failed on noise. Getting to green also meant fixing three of my own fixtures rather than the model: two strong resumes counted volume instead of outcomes, and one middling resume lacked the job's hard requirements.

The run in the table is from 15 October 2026, on Nemotron only. It has not been run against Claude. It is also not part of CI, because its 36 live calls spend API credits. The 559 offline tests are part of CI: GitHub Actions runs lint, the tests and a build on every pull request and push to main, CodeQL scans the code, and Dependabot proposes dependency updates.

Eval run, 15 October 2026, Nemotron. Mean of three runs per resume.
ProfessionLevelExpected bandMean scorePass
Backend developerStrong80–10090.0Yes
Backend developerMiddling55–7059.3Yes
Backend developerWeak0–4428.3Yes
NurseStrong80–10086.7Yes
NurseMiddling55–7062.3Yes
NurseWeak0–4423.7Yes
Junior designerStrong80–10086.0Yes
Junior designerMiddling55–7062.0Yes
Junior designerWeak0–4426.7Yes
AccountantStrong80–10088.0Yes
AccountantMiddling55–7059.0Yes
AccountantWeak0–4431.3Yes

All 12 resumes landed in their band, and in all four professions strong scored above middling and middling above weak.

What I’d change

  • What I'd do differently: compute every number in code from the first commit, instead of moving the score out of the model after watching it cluster.
  • What I'd add next: run the eval set against Claude too, so the two providers are compared on numbers rather than assumed equal. Then give the Anthropic provider the same rate-limit and credits-exhausted messages the NVIDIA one has.
  • What I'd leave alone: the rate limiter is in memory, per instance. That is fine for the single Railway instance the app runs on, and a shared store such as Redis would break the goal of running on a fresh clone with no infrastructure.
  • What surprised me: constrained decoding guarantees shape, not quality. And one “the model ignores this” turned out to be two contradictory instructions.

Want the full walkthrough?

The app is live and the sample report costs nothing to open. I'm happy to talk through any part of it, including the parts I'd build differently now.