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.