Skip to content
← BlogAI & agents· 7 min read

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.

AIDev
Jobspark résumé editor: form by section on the left, rendered résumé preview on the right, “65 Perfectible” badge

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.

apps/api/src/cv-generation/cv-generation.payload.ts (excerpt)
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

Generating a résumé: where credits go and where the model steps in

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:

apps/api/src/cv-generation/grounding/experience-lock.ts
/**
 * 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.

Jobspark résumé editor: form by section on the left, rendered résumé preview on the right, “65 Perfectible” badge
The generated résumé lands in an editor: every field stays editable and the preview on the right is the HTML that gets printed to PDF.

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.

From document to score: rules, excluded dimension, bounded model, caps

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.

packages/ats-score/src/weights.ts (comments shortened)
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:

packages/ats-score/dist on a demo résumé

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.

Public “Free analysis” page on the Jobspark landing: drop zone for a PDF or DOCX résumé and an option to target an offer
The public scan, no account needed: the only surface where the model weighs in on impact, bounded to 25 points.
packages/ats-score/src/dimensions/impact.ts
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.

“ATS analysis of this résumé” panel: score out of 100, findings to fix and per-criterion breakdown
The analysis panel in the app: the engine’s findings turned into advice, and “Machine readability: not assessed”, because there is no file to inspect yet.

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.

Landing pricing section: three packs, Essentiel, Recherche active and Intensif, expressed in applications
Packs are sold as complete applications, computed from the same constants the API charges against.
“Translate the résumé” dialog saying name and contact details are not sent to the AI and that it costs 3 credits
Every paid action shows its price before it runs, and says what never leaves the server.

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:

apps/api/src/ai/openrouter.config.ts
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

Jobspark: how a job ad becomes a tailored résumé, and how the ATS score is computed — Yahse Koffi — koklo.dev