The CV pipeline¶
What POST /v1/cv does with the minutes it takes: six steps, each on a model
of its own, then a render that trims the CV until it fits. The method is the
one adapticv-ai worked out.
The code is server/src/cv_server/pipeline/cv_generator.py
(CVGenerator, the steps as a LangGraph graph), cv_review.py (the
validation loop) and cv_renderer.py (the compile and the page count).
The steps¶
flowchart TD
START(["POST /v1/cv accepted<br/>202 + request_id"]) --> JD["<b>jd_evaluation</b><br/>the JD's key points, each with an importance"]
JD -->|"Send, one per list"| SC["<b>profile_scoring</b><br/>one list of the profile against the key points"]
SC --> MS["the scores into the profile<br/>text|3-80,7-40"]
MS --> FS["<b>format_selection</b><br/>a format, the experiences, their duty caps"]
FS -->|"Send"| WB["<b>cv</b><br/>the body"]
FS -->|"Send, one per experience"| WD["<b>cv</b><br/>one experience"]
WB --> CC["back in order"]
WD --> CC
CC -->|"Send"| VB["<b>validation</b><br/>the body, up to 5 rounds"]
CC -->|"Send, one per experience"| VD["<b>validation</b><br/>one experience, up to 5 rounds"]
VB --> CK["back in order"]
VD --> CK
CK --> HL["<b>highlight</b><br/>**bold** keywords, *italic* stacks"]
HL --> R{"rendered:<br/>over the page limit?"}
R -->|"yes, and a duty can go"| TRIM["remove the duty<br/>worth least"] --> R
R -->|"no, or none can go"| FIN(["<b>result</b><br/>document · tex · pdf"])
| Step | Calls | Shown | Hands over |
|---|---|---|---|
jd_evaluation |
1 | the JD | key_points: 6 to 15 requirements, each with keywords and an importance from 0 to 100 |
profile_scoring |
one per list of the profile — 9 for the example candidate | the key points, one list, and its experience when the list is inside one | scored_profile, score_reasons |
format_selection |
1 | the key points, the formats, the scored experiences | format_selection: the format, the experiences shown, the duties of each, and why |
cv |
1 for the body, 1 per experience shown | the key points, the writing style, the schema, the scored profile or experience, its duty cap and its evidence | cv_body, cv_experiences |
validation |
1 to 5 per document | the writing style, the round, the profile or the experience without scores, the generated text, the replacements that failed | checked_body, checked_experiences |
highlight |
1 | the key points, the CV the models wrote, the evidence | highlighted_resume |
Each step has its own role in models.toml;
cv and validation each serve two prompts, the body's and the
experiences'. Each step's result is streamed to the client as it is ready,
and stored beside the CV under steps/.
Scores. profile_scoring gives every item of the profile the key points
it matches, at most four, each scored 0–100 for how completely the item proves
it: a string becomes Led the migration.|3-80,7-40 (key point 3 at 80, key
point 7 at 40), an object gets a "score": "3-80,7-40" field. The words that
prove each match stay out of the profile, in score_reasons; the writer and
the highlighter are shown them as EVIDENCE. The scores ride along to the end
and come off at the render.
Coverage. A key point counts its two best duty scores, up to twice its
importance (COVERAGE_BEST, COVERAGE_CAP): a duty on a key point already
covered adds nothing, and weak matches cannot fill a key point before its
strong ones. The duties prompt says the same in words, and the trim below
uses the same rule.
Formats. What format_selection chooses from, and what the trim keeps:
| Format | Experiences | Duties each | Duties in all |
|---|---|---|---|
| A | 3 | 3–8 | 22 |
| B | 4 | 2–6 | 17 |
| C | 5 | 1–4 | 13 |
The code assumes nothing about the CV or the profile beyond the work
experiences (each with an id and its duties), their duties and the
writing_style: the prompts and the template decide everything else.
Side by side¶
A step that makes several calls fans them out with LangGraph's Send: one
node per profile list, per experience, and they run at the same time. Each
writes its answer, tagged with its position, into a list the graph collects;
a node in code puts the answers back in profile order before the next step
starts. The steps themselves run one after another.
The parallel nodes run in the job's own context, so their log lines and their token counts land in the job's session like any other. A call that fails — three unusable replies, an endpoint that refuses — fails the job with its own cause and stage, after the calls beside it have finished.
Validation¶
The validators check facts and grammar against your profile, with minimal
edits; style is the writers' business. Each violation comes back as a call of
a tool — replace_in_body or replace_in_duties — which is only a schema:
the code applies the replacement, in the order the calls came, and asks
again. A round with no call ends the review. A replacement whose text is not
found goes back to the model as a FAILED REPLACEMENT. After five rounds
(MAX_VALIDATIONS) the CV goes on with the last edits: a CV with a doubtful
line in it is worth more to you than an error.
The validators count no pages. The page limit is the render's business alone.
The render¶
After the highlight the CV is rendered — your candidate_data merged over
the top, the scores removed — and its pages are counted. While it runs past
the page limit (pages, 2 by default), one duty goes: of the experiences with
more duties than the format's minimum, the duty whose removal loses the
fewest coverage points; on a tie, the one with the lowest total score, then
the older experience's. Then it is rendered again. When no duty can go, the
longer CV is delivered.
Every render is of the merged document, so every page count is the page
count of the CV you will send. That is why the endpoint takes
candidate_data. No model is ever shown it: the highlighter sees what the
models wrote, and your data goes over the top at the render, so a field a
model invents can never replace a real name, email or address.
The renders before the last stay in the job's scratch directory. Only the final PDF is sent, in the result.
Retries, budgets and what each failure costs¶
| Guard | Limit | Set by | On exhaustion |
|---|---|---|---|
| Calls per model request (empty, cut off, or not the JSON asked for) | 3 | ATTEMPTS in models/selector.py |
502 model_output, except for the highlight |
| Highlight | the same | the same | a warning; the CV is delivered unmarked |
| Validation rounds per document | 5 | MAX_VALIDATIONS in pipeline/cv_review.py |
the CV goes on with the last edits |
| Wall clock for the whole run | 1200 s | ATS_REQUEST_BUDGET_SECONDS |
504 budget_exceeded, checked before each step |
One pdflatex compile |
120 s | ATS_LATEX_TIMEOUT |
504 latex_timeout |
A reply that is JSON is parsed and its type checked — a list or an object — and nothing more, as adapticv-ai does: no schema is enforced. A reply whose items do not line up with what was asked (fewer scores than items) fails the job.
What the client sees while it runs¶
The job's session streams a status event as each step starts, with one line
about the step that just finished: what it produced, and its tokens, thinking
and time. Between them come an artifact event with each step's result, as
the JSON it is, and the log and usage events that say what each model
call did and cost.
{"status": "cv",
"detail": "format A: experiences [0, 1, 2], duties [8, 8, 6], tokens used=4210, thinking=1952, elapsed=24.3s"}
The render belongs to the highlight step: it takes a second or a few.
The session ends with a result event — the document, its LaTeX and its PDF,
and what the whole run cost — or, for a job that failed, an error event
with the status and cause the work produced (502, 504, 422) and what it
cost anyway. Either way it is the last thing the stream carries.
Changing it for one request¶
Every prompt and both schemas the writer is shown can be replaced by a part
of the same name: sys_prompt_jd_evaluation, sys_prompt_profile_scoring,
sys_prompt_format_selection, sys_prompt_cv_body, cv_body_schema,
sys_prompt_cv_duties, cv_duties_schema, sys_prompt_validation_body,
sys_prompt_validation_duties, sys_prompt_highlight. So can the template
and its images. The prompts and schemas are what the models read: editing one
changes what they write.
Each step's model settings can be replaced too: <step>_extra_body, a JSON
object, supersedes every setting models.toml gives that step — temperature,
thinking, reasoning effort, the output cap, its own extra_body — and only the
model, the endpoint and what you sent go out.
What comes back¶
The result event's cv is {document, tex, pdf_base64}.
document is one flat object: what the models wrote, without the scores,
with your candidate_data merged over the top. It has no schema of its
own — what the template reads from it is between you and the template. Store
it, edit it, and post it back to POST /v1/cv/render, which costs one LaTeX
compile and no model call.
Rendering¶
render_document() is pure up to the compile: the document is escaped for
LaTeX (including turning **bold** into \textbf{} and *italics* into
\textit{}), generation_date is added — so re-rendering yesterday's file
dates it today — and Jinja fills the template. The Jinja delimiters are
LaTeX-safe: \VAR{}, \BLOCK{}, \#{}.
Images sent with the request are written next to the .tex under exactly the
file name the part carried, because \includegraphics resolves relative to
it. One pdflatex pass, not two: the shipped template has no \ref,
\label or \tableofcontents, so a second pass would change nothing.
The other three pipelines¶
None has a loop. Each runs on its own model: detect, analysis and letter.
jd_validator.detect— the free structural checks first (length band, no binary payload:static_jd_guess, shared with the client), and only if they pass does it cost one small model call.jd_validator.analyze— extracts the posting's facts and scores it against your profile and your stated preferences. Up to three calls, the validation error fed back between them.letter_generator.generate— prose in, prose out; 180–450 words, validated. The prompt asks the model to look the employer up when it can search the web; whether it can is theletterrole'sextra_body.