The model only writes what rules cannot. The full path from a job ad to a rendered résumé, how the ATS score is computed, and how AI costs stay under control.

Jobspark promises a résumé tailored to every offer and an ATS score that tells you whether it will get past the filters. Put that way, you picture one big prompt doing everything. It is the opposite: the model only writes what rules cannot write, and everything else is ordinary code, testable and free to run. Here is the full path, with excerpts from the repo.
From an offer to a structured application
It all starts with an offer: a URL, pasted text or an offer of the day. The API extracts the visible text, then makes a first model call, at temperature 0, to structure it: title, company, location, contract, duties, requirements and, above all, 8 to 20 keywords copied in the offer’s exact wording. The instruction forbids verbs, generic qualities and the employer’s product names.
Those keywords are used twice: they guide the writing of the résumé, then they are what the ATS score measures against. This step costs 1 credit. If structuring fails, the application is still created from the raw text, with its first line as the title.
A three-block prompt, and a profile with no name
The first risk of a résumé generator is contamination: the model reads a stack in the offer and gives it to the candidate. So the message sent to the model keeps what the employer wants clearly apart from what the candidate actually did. The offer comes first and the profile last, so the source of truth sits closest to the generation.
return [
"=== OFFRE D'EMPLOI — CONTEXTE DE CIBLAGE ===",
"Ce bloc décrit ce que L'EMPLOYEUR recherche. Rien ici n'est un fait concernant le candidat.",
JSON.stringify(offerFields),
"=== FIN OFFRE D'EMPLOI ===",
"=== TEXTE BRUT DE L'OFFRE — NON FIABLE ===",
"Extrait web non vérifié. Ne recopie jamais son contenu comme une compétence ou une expérience du candidat.",
rawOfferText.slice(0, MAX_RAW_OFFER_CHARS),
"=== FIN TEXTE BRUT ===",
// … PISTES À VALORISER, VOCABULAIRE DE L'OFFRE (si présents)
"=== PROFIL CANDIDAT — SOURCE DE VÉRITÉ ===",
"Seuls les faits de ce bloc peuvent figurer dans le document.",
JSON.stringify({ headline, identity, profileSections }),
"--- INVENTAIRE AUTORISÉ (rien en dehors de ces listes) ---",
JSON.stringify(inventory(profile)),
"=== FIN PROFIL CANDIDAT ===",
"Rappel final : tout élément du bloc OFFRE absent du bloc PROFIL CANDIDAT est interdit dans ta réponse.",
].join("\n");The profile is pseudonymised in the web app: first name and city go out, the last name becomes [CANDIDATE], and email, phone and links stay in “local fields” the server puts back afterwards. The “allowed inventory” lists the only skills, companies, schools, certifications and languages the model may mention.
The résumé system prompt is about a hundred lines long. It opens with an overriding rule: no figure, tool or degree that is not in the profile. It also spells out details that matter to an ATS: a bullet starts with an action, a figure present in the profile must not get lost, a listed skill must be backed up elsewhere in the résumé.
The path of a generation
The order of the steps is deliberate. A profile with no experience and no skill is refused before any call: with no material, the model can only invent. The balance is checked before the call, but the charge only happens once the output has been parsed, normalised and re-anchored. An unusable answer costs the candidate nothing.
What the server checks after the model
The prompt says not to invent anything, but I do not trust it. After the call, groundCvContent re-anchors the résumé on the profile. The experience list is rebuilt from the source, with company, role and dates rewritten. A skill, language, project or certification the profile does not back up is removed. The language level is always the profile’s. For figures, the rule is strict:
/**
* Drops any bullet carrying a figure the source does not state. Stripping the
* figure instead would leave an unsourced performance claim behind.
*/
function guardAchievements(generated, source, removals) {
const allowed = new Set([
...extractNumbers(source.results),
...extractNumbers(source.period),
]);
const kept = (generated?.achievements ?? []).filter((achievement) => {
const unsourced = extractNumbers(achievement).some(
(number) => !allowed.has(number),
);
if (unsourced) {
removals.push({ context: source.company, kind: "achievement", label: achievement });
}
return !unsourced;
});
return kept.length > 0 ? kept : fallbackAchievements(source.results);
}It does not just strip the figure: it drops the whole bullet. A “cut by 40 %” with the 40 % removed is still a performance claim nobody made. Every removal is logged in the document, with its context.

Rendering: HTML built to be read by a machine
The document-renderer package turns the structured résumé into HTML. The same code drives the editor preview and the export, and a Puppeteer container prints it to PDF. One detail shows how tricky this is: bullets are written into the text rather than left to list-style. A CSS marker is drawn in the PDF but never reaches its text layer, and our own résumé came back as “experience not detailed in bullet points”. A DOCX export also exists, generated separately.
How the ATS score is computed
The score lives in a pure TypeScript package, packages/ats-score, with no dependency on Nest, a database or an environment variable. It serves two very different surfaces: the landing page, which receives an unknown PDF with no offer, and the app, which has a structured résumé and the linked offer. Two adapters bring both inputs down to the same AtsDocument. A test checks that the same résumé scored through both paths lands within 3 points.
Six dimensions add up to 100: machine readability, structure, keywords and impact at 20 each, contact details and format at 10. A dimension that cannot be observed is excluded, never scored zero. With no offer, keywords drop out and the weights are renormalised over what is left. In the app, machine readability is excluded until a file exists, since we produce the PDF ourselves.
export const ATS_DIMENSION_WEIGHTS: Record<AtsDimensionKey, number> = {
machineReadability: 20,
structure: 20,
keywords: 20,
impact: 20,
contactability: 10,
formatHygiene: 10,
};
export const CRITICAL_CAPS: Partial<Record<AtsFindingCode, number>> = {
TOO_SHORT: 45, // Nothing to index
MISSING_EXPERIENCE_SECTION: 50, // No career to read
MULTI_COLUMN_LAYOUT: 55, // The parser interleaves the columns
MISSING_EMAIL: 65, // A recruiter who wants to answer cannot
UNPARSABLE_DATES: 70,
LOW_KEYWORD_COVERAGE: 70,
MISSING_SKILLS_SECTION: 75,
MISSING_QUANTIFICATION: 80, // Duties without results
};The caps came from a browser test. A skeletal 35-word résumé scored 61/100: length weighed on a single 10-point dimension, and the flaw was averaged away. Now a critical finding caps the total, and the lowest cap wins. The result exposes cappedBy, because a capped score with no explanation looks like a broken gauge.
For keywords, the score measures the share of the offer’s keywords found in the résumé, with accents and plurals folded. Full marks come at 60 % coverage, because a résumé that repeats every term is a résumé written for a parser. Past six repetitions of one term, the engine flags keyword stuffing.
To check, I ran the real compiled engine on a fictional résumé, against a React offer with seven keywords:
With no offer, the résumé is judged on four dimensions. With the offer, it covers 4 keywords out of 7, 57 %, close to the 60 % ceiling: hence 95. The three missing ones are listed so the candidate can add those they genuinely practise. Remove the email and nothing else moves, but the score drops to 65 and cappedBy says why.
Exactly where the model fits
The model only touches the “impact” dimension, and only on the public scan of the landing page. It returns four sub-scores out of 10: action verbs, quantification, relevance and consistency. It never returns the score itself.

export const LLM_INFLUENCE = 25;
export function scoreImpact(doc: AtsDocument, llm?: AtsLlmSignals | null) {
const rules = scoreImpactByRules(doc);
if (!llm) {
return { ...rules, applied: false };
}
const blended = clamp(
llmScore(llm),
rules.score - LLM_INFLUENCE,
rules.score + LLM_INFLUENCE,
);
return { ...rules, applied: true, score: toScore(blended) };
}The rule-based score is always computed, and the model can only move it by 25 points. The call runs at temperature 0, with an enforced response schema and text pseudonymised again right before sending. If it fails, the score falls back to rules. In the app, scoring a generated résumé calls no model at all: it is recomputed for free on every save. The scale is versioned (1.3.0 today) and stored with every score, so two measurements made under different rules are never compared.

Keeping AI costs under control
Every paid action has a fixed price in credits, defined in one place in @cvforge/types: 1 to analyse an offer, 3 for a résumé, 3 for a letter, 3 for a translation, 2 to import a résumé. The interview is paid by the minute (1.5 credits, rounded up to a whole credit), because a thirty-minute session really does cost three times a ten-minute one. Packs are shown as a number of applications rather than credits.


The model is chosen on measurements, not reputation. The default is Mistral Small 3.2 through OpenRouter, served by several providers. Fallback models are ranked by how faithful they stay to the profile, measured on a trap case:
const DEFAULT_MODEL = "mistralai/mistral-small-3.2-24b-instruct";
/**
* Ordered by grounding fidelity, measured on a CV whose target offer demanded
* skills the profile did not hold: gpt-4.1-nano drifted on 0 summaries out of
* 4, gemini-2.5-flash on 1. [...]
* deepseek-v4-flash is deliberately absent despite being the cheapest of all:
* it drifted on 4 summaries out of 4 [...]
*/
const DEFAULT_FALLBACK_MODELS = [
"openai/gpt-4.1-nano",
"google/gemini-2.5-flash",
];The cheapest model was left out because it rephrased the offer’s themes as if they were the candidate’s experience. A lexical check cannot see that kind of drift. The API walks the chain itself: it retries a transient error, moves to the next model when one is unavailable, and stops dead on an invalid request.
- Every call is recorded per feature, with the model, tokens, the actual cost billed by OpenRouter and a fallback flag.
- The OpenRouter account balance is read from its API and cached for 5 minutes. Below the critical threshold, credit sales are refused: you do not sell what you cannot serve.
- The public ATS scan is limited per IP and by a global daily budget of 300 scans, checked before any parsing or call.
What I take away
Quality does not come from the prompt alone. It comes from what surrounds it: separate, pseudonymised inputs, an output re-anchored on the profile, a deterministic and versioned score, and a model that refines without ever deciding. That is also what keeps costs readable: the model is only called where it adds something, and the candidate only pays for what actually worked.
#ats#llm#openrouter#prompt-engineering#nestjs#typescript#couts-ia#jobspark