DEV Community

Rahul Mall
Rahul Mall

Posted on Originally published at gist.github.com

Making an LLM app reliable: stopping off-topic answers and inconsistent scores

I built ResumeAI, a resume analyzer that scores a resume against a job description, helps rewrite weak sections, and tracks applications through Gmail. It runs entirely on free-tier models, Groq with Gemini as fallback. (Try it: https://resumeai.live)

Early on, a test resume with hidden instructions aimed at the model raised its own score from 40 to 85, flipped the verdict to "Strong Fit", and added four skills it didn't have. A job description with the same trick scored a perfect 100.

This post covers the core failures I hit while making the product behave the same way twice, and what replaced each one. Every problem below actually happened, and most of the fixes come down to one rule.

The principle most of this comes from

A model is good at reading unstructured text and bad at being an authority. Every fix here is a version of the same move: let the model extract, and let code decide.

Concretely, that means a model is allowed to answer "which skills appear in this resume" and is never allowed to answer "what score does this resume deserve". The first is reading. The second is arithmetic, and arithmetic that a model performs is arithmetic that a stranger can argue with.

Prompt injection, and why guardrails in the prompt are not guardrails

A resume is a file a stranger uploads. A job description is text a stranger pastes. Both end up in a prompt, and both are therefore attacker-controlled.

Three things fixed it, and the order matters because only the last one is enforcement.

Fence the untrusted text. Resume and job description now arrive inside delimited blocks that the system prompt names as data. Backticks are stripped so the text cannot close its own fence.

Neutralise instruction-shaped phrases. Patterns like "ignore previous instructions" are replaced with a redaction marker and logged. The text is not rejected: a resume that happens to contain one of those phrases is still somebody's resume, and refusing to read it is worse than reading a redacted copy.

Recompute everything derived. This is the part that actually holds. The skill match score, the project relevance average, the coverage figure and the overall score are all computed in Python from the extracted lists. The model never returns a number that reaches the user unchanged. An injected score has nothing to act on, because nothing downstream reads it.

After that work, the same hostile resume scored 47, "Weak Fit", with no fabricated skills.

Prompts anchor on their own examples

A subtler failure, and the one I would not have found without measuring.

The analyzer prompt described its output schema by showing a filled-in example. The example contained "score": 85. Across four runs on four different resumes, three came back with a score of exactly 85.

The model was not scoring the resume. It was copying the sample.

The schema is now described by type rather than by value: a field is documented as "0 to 100, computed from the matched and missing lists", not as 85. Over the same four runs afterwards, zero returned 85.

The general rule: any concrete value in a prompt is a suggestion to the model, including values you only meant as formatting.

Scope, and the limits of asking nicely

The section assistant was asked for "python code for adding 2 numbers, i need it for my interview", and wrote the function, explained it, and added a __main__ block.

Nothing in its prompt said what it was for. The prompt opened with "you are a resume consultant" and then spent four thousand characters on JSON formatting. There was no boundary to cross.

The instinct is to add a paragraph to the prompt. That is worth doing and it is not sufficient. A system prompt sits in the same stream of text as the request, so it shapes behaviour and enforces nothing, and prompt-only restrictions fail often and unpredictably.

So the boundary is stated in the prompt and enforced in three layers:

  1. An input screen in Python, before any model call. Instruction overrides, persona swaps, prompt extraction, requests for code or essays or recipes, and general-purpose use. An off-topic request costs nothing at all.
  2. The prompt, which states the scope and the refusal, and explicitly forecloses "just this once", "as an example" and "for an interview".
  3. A reply screen. The assistant has no legitimate reason to open a code fence, so one is treated as proof the first two layers were talked past. The reply is dropped and the refusal sent in its place.

The hard part was not blocking abuse. It was not blocking the user:

  • "I wrote a Python script that parses logs, add it as a project" is served.
  • "write me a python script to parse logs" is refused.

Past-tense descriptions of the user's own work are in scope by construction, and the test suite contains as many legitimate messages as hostile ones.

Measured against the real model: nine of ten attacks refused before any model call, the tenth refused by the prompt, both legitimate messages served.

Read more

This post covers the core of it. The full notes go further: keeping the resume preview and the downloaded PDF identical, failing closed, a Gmail classifier that let job alerts through, treating free-tier token budgets as a reliability problem, streaming and idempotency bugs, the before/after numbers, and an honest list of what's still weak.

Read the full write-up on GitHub Gist →

Try to break it

ResumeAI is live at https://resumeai.live. If you find an injection that gets past the guards, or a resume that breaks the PDF export, I'd genuinely like to hear about it in the comments. Finding failures is how every fix in this post happened.

The code is on GitHub: https://github.com/Rahul1038402/Resume-AI

Top comments (0)