Architecture¶
The three packages¶
flowchart TB
subgraph your["your machine"]
CLI["<b>cv</b> (client)<br/>single executable"]
FILES["candidate_profile.json<br/>candidate_data.json<br/>candidate_preferences.md<br/>resume.tex.jinja · prompts · images"]
OUT["cv/ · discarded/ · error/ · working/<br/>applications.xlsx"]
FILES --> CLI
CLI --> OUT
end
subgraph wire["contracts"]
CT["cv_contracts<br/>Envelope · CVStatus · RenderedCV<br/>JDAnalysis · JDDetection · LogEntry<br/>CV session events · static_jd_guess"]
end
subgraph host["wherever you run it"]
API["<b>ats-strike-server</b><br/>FastAPI + uvicorn"]
PIPE["pipeline<br/>jd_validator · cv_generator (LangGraph) · cv_review<br/>cv_renderer · letter_generator"]
RES["resources<br/>11 prompts · 2 schemas · resume.tex.jinja<br/>models.toml"]
STORE["ATS_WORK_DIR<br/>one session per CV job"]
API --> PIPE
RES --> PIPE
PIPE --> STORE
end
PROV["OpenAI-compatible<br/>/chat/completions"]
TEX["pdflatex"]
CLI -->|"multipart/form-data"| API
API -->|"JSON envelope<br/>CV: server-sent events"| CLI
CLI -.imports.-> CT
API -.imports.-> CT
PIPE --> PROV
PIPE --> TEX
client → contracts ← server. Neither half imports the other, and
tests/test_architecture.py fails the build if one ever does. The contracts
package depends on pydantic and nothing else: it is loaded both by a web
server and by a frozen executable, and has to stay cheap in both.
| Package | Holds | Ships as |
|---|---|---|
client/ |
Your profile, your contact data, your template and images, the output tree, the spreadsheet | A PyInstaller executable, ~1 file |
server/ |
The provider API keys, the prompts and schemas, the LaTeX template, the LaTeX toolchain | A container image, ~780 MB |
contracts/ |
The response envelope, the payload models, the free JD pre-check | A pydantic-only package, vendored into both |
What crosses the wire¶
Everything personal arrives with the request. The server has no database, no accounts and no seeded state; the only thing it keeps is a CV job's session, one directory of events:
$ATS_WORK_DIR/<session id>/events/
000001.json {"type": "status", "data": {...}}
000002.json {"type": "log", "data": {...}}
000003.json {"type": "usage", "data": {...}}
000004.json {"type": "artifact", "data": {...}} a step's result
...
000042.json {"type": "result", "data": {...}} the CV: the last event
Each event is its own file, written once and never changed — which is what a
bucket mounted as a folder (OSS, Cloud Storage) can do: an object there cannot
be appended to, and a rename is a copy. A reader lists the directory and reads
what is new; the one file that may not read whole yet is the newest, so a
reader stops there and finds it complete on its next look. The session id is
the client's: the SHA-256 of the posting's text, so the same posting finds its
session again. A session is deleted when the client acknowledges it, or once
its last event is older than ATS_SESSION_TTL_DAYS; a running one, never.
The scratch directory a job compiles in is on local disk, and is gone with the
job.
Inside the server¶
flowchart TB
REQ["request"] --> MW["RequestContextMiddleware<br/>assigns request_id, binds the Run"]
MW --> AUTH{"ATS_API_TOKEN set?"}
AUTH -->|"no match"| E401["401 unauthorized"]
AUTH -->|"ok / not set"| PARTS["multipart parts<br/>size, UTF-8, JSON, ≤10 images"]
PARTS --> SLOT{"a free job slot?"}
SLOT -->|"no"| E429["429 too_many_jobs<br/>Retry-After: 30"]
SLOT -->|"yes"| KIND{"which endpoint?"}
KIND -->|"/v1/jd/*, /v1/letter, /v1/cv/render"| POOL["threadpool<br/>answer when done"]
KIND -->|"/v1/cv"| THREAD["worker thread<br/>answer 202 now"]
POOL --> ENV["Envelope<br/>its log: stdout"]
THREAD --> EVENTS["an event per step, step result,<br/>log line and model call"]
EVENTS --> STREAM["GET /v1/cv/{id}/events"]
POST /v1/cv for a session id that already has one skips the slot and the
thread: it answers 202 with reattached: true, and the caller follows the
events that are there.
- One exception handler.
describe_failure()is the single place that decides what an exception produces on the wire, so a failure body has the same shape as a success: arequest_id,ok: falseand one line naming the kind and the stage. See error model. - One semaphore.
ATS_MAX_CONCURRENT_JOBS(10) bounds requests in flight. A full server refuses immediately rather than queueing a caller for minutes. - The pipeline is a library. Everything under
pipeline/takes its inputs in memory and returns its outputs; it reads no configuration, resolves no paths and writes nothing outside the scratch directory it is handed. That is what makes it safe to run per request. - The LaTeX subprocess is sandboxed. Shell escape off, reads and writes
confined to the scratch directory, no stdin, a timeout, its own
HOMEandTEXMFVAR. A request may supply both the template and a whole.tex, so both are treated as hostile input.
The models¶
One provider, one API key, one endpoint — declared once in the [provider]
table of resources/models.toml. Every role calls it, except a role that sets
use_alternate_provider = N (N ≥ 2), which calls provider N instead — the
[provider] variable names with N appended (MODEL_API_KEY2 /
MODEL_BASE_URL2 for 2):
| Role | Used for | Shipped as |
|---|---|---|
detect |
JD detection: a one-word YES/NO on text that passed the free checks | deepseek-v4.1-flash, thinking off, 1000 max output tokens, temperature 0 |
analysis |
JD analysis; searches the web for the company evaluation | qwen3.8-flash, thinking on, max effort |
letter |
Cover letters; searches the web for the employer | qwen3.8-flash, thinking on, max effort |
jd_evaluation |
A CV's first step: the JD's key points, each with an importance | deepseek-flash on DeepSeek's API (provider 2), medium effort |
profile_scoring |
Each list of the profile scored against the key points | the same, high effort |
format_selection |
The CV format, the experiences shown and their duty caps | the same, medium effort |
cv |
Writing the CV body, and each experience on its own | the same, medium effort |
validation |
Checking the body and each experience against the profile, as replace_in_* tool calls |
the same, medium effort |
highlight |
The **bold**/*italic* keyword pass over the validated CV |
the same, medium effort |
See the CV pipeline for how the last six fit together.
Every call goes through cv_server.models: one LangChain chat model per
role, built once at startup by the role's model_provider (standard, on
ChatOpenAI, or deepseek, on ChatDeepSeek), and two methods.
ModelSelector.call_llm(role, type, system_prompt, messages) returns the reply
as a pydantic model, a bool (YES/NO), a str, or a JSON list or dict;
call_tools(role, tool, …) returns the arguments of the tool calls the reply
made. Both retry a reply that is empty, cut off or invalid — three calls in
all, each retry told what was wrong. Provider-specific request fields
(thinking_budget, enable_search) are the role's extra_body, sent as
given: nothing checks a combination against what the endpoint supports. Any
key of a role can be overridden with a ATS_<ROLE>_<FIELD> environment
variable without editing the file; an empty value drops the key. A request
can bring a <role>_extra_body for every role but detect, which supersedes
all of that role's settings for that request.
Every pydantic reply is validated. Its schema reaches the model once: in the
request with json_schema, in the system prompt with json_mode. A weak mode
costs a retry, never correctness. The CV steps reply with JSON their prompts
describe — the writer's two schemas are part of its input — and are parsed,
not validated.
Asynchrony, and why¶
Writing a CV takes minutes. POST /v1/cv answers 202 and hands the work to
a plain thread that outlives the response; the thread reports itself by
appending events to the session, and GET /v1/cv/{id}/events streams them —
everything so far, then each new one, until the last. A stream is a reader of
files, so it can be opened again at any time: after a dropped connection, with
Last-Event-ID, to get what it missed; after a client crash, from the start.
The client acknowledges the session (DELETE) only once the CV is stored.
The consequences are worth knowing before you deploy:
- A running job lives in one instance. Its events are files, so any instance sharing the work root can replay them, but only the instance that started it is writing them. Run one instance.
WEB_CONCURRENCY=1. The slots and the workers are per-process, and a starting process ends every session that has not ended with a503error — correct for its own orphans, wrong for a sibling's live jobs.- A job cannot be cancelled yet. An abandoned one holds a slot until its
own budget runs out (
ATS_REQUEST_BUDGET_SECONDS, 20 minutes), and its session cannot be acknowledged until then.
Observability¶
Every response carries a request_id, and so does the X-Request-Id header.
Every line the server logs to stdout carries it too.
A CV job streams its own account: every line it logged as a log event, every
model call as a usage event with its duration and token counts, each step's
result as an artifact event, and the run's total in its last event — failed
attempts included, because they were paid for. The client writes the lines
and the calls into log.log next to the CV, and each result into steps/. Nothing
else rides on a reply: for a detection, an analysis, a letter or a render, the
server's own log is where the commentary is.