How I built an AI assistant that applies for jobs with you, and never invents a fact about you.
Let me start with the honest part. Everyone uses AI to apply now. Recruiters use it to read the pile. Candidates use it to write the CV and the cover letter. That is not a scandal, it is just how hiring works now. So the only interesting question left is how to use it well.
That question is what this is about. Over one weekend, by directing DeepSeek the whole time, I built a small assistant for job hunting. You paste an offer, it scores it against your profile and your criteria, it writes a CV and a cover letter in your own template, and it drafts the answers to the application form. Apply more, apply better.
Two rules shaped the whole thing.
Both look simple. Both become hard the moment a language model is in the loop. And the fix for each one turned out to be the same fix. I use it in three places.
Give the model the smallest possible decision, inside a closed set. Let deterministic code do everything else.
That is the whole article, in one line. The rest is what it looks like in practice, in three places.
The three places below all rest on the same thing, your profile. So the tool starts by building it well.
You import your CV, as a PDF, a DOCX or a plain text paste. The model turns it into a structured profile under one strict rule, use only what the CV says and leave empty what it does not. Then it interviews you, one question at a time, and it asks exactly what a CV leaves out: the result behind a vague line, the scale of the thing, the decision you actually owned. Every answer goes straight into the profile, and it is checked the same way the documents are, no number you did not say, no line that does not come from what you wrote.
Ten minutes spent there is what makes every later document good. It is, I think, the biggest time saver in the whole tool.
The obvious way to score an offer is to ask the model for a number from 0 to 100. The problem is that it drifts. The same offer gets 82 on Monday and 91 on Tuesday, and nothing tells you why. And you cannot argue with 87. There is nothing to grab.
So the model does not give the number. It fills a fixed grid. Four axes, each as a small integer from 0 to 5, each with one sentence of justification. The weights and the total are in code.
AXIS_WEIGHTS: dict[str, float] = {
"technical_match": 0.40,
"seniority_scope": 0.20,
"wishes": 0.25,
"red_flags": 0.15,
}
def build_score(grid: ScoringGrid) -> Score:
axes = [...]
total_weight = sum(axis.weight for axis in axes) or 1.0
weighted = sum(axis.score * axis.weight for axis in axes) / total_weight
return Score(axes=axes, total=round(weighted / MAX_AXIS_SCORE * 100))
Those four numbers are hardcoded, and that is on purpose. You tune your own wishes and their weights, never the grid itself. If the model picked the weights, it could quietly move the total from one run to the next, and you would never see why.
Now the model's freedom is a few small integers instead of a number it invents from nothing. And the total is a pure function. Same grid, same total, always. When you disagree with a line, you disagree with one axis, and you can see the one sentence behind it.
The axis scores still come from the model. What is deterministic is the aggregation, not the judgement. I do not pretend otherwise. But the drift is now bounded to the step of the grid, and every stored score carries a fingerprint of its inputs, the model included. Change the model or one rule and the older scores are marked out of date, instead of being quietly mixed with the new ones.
A cover letter that invents one number is worse than no cover letter. So the prompt states the rule clearly. Use only the profile. Cite an experience for every claim.
But a prompt is a request, not a guarantee. A model can ignore it, and it often does in a subtle way.
So the same rule is checked again in code, line by line, after the draft comes back.
invented = [n for n in numbers(line.text) if n not in profile_material]
if invented:
flag(line, f"states {', '.join(invented)}, not in your profile")
Every number in a line must already be somewhere in the profile. Every factual line must cite an experience that really exists. A skills line may not name something the candidate never listed. The name on the document must be the name in the profile.
The design rule behind it is this. Be strict about facts and relaxed about wording. The tool has no opinion on your style. It only refuses to let you say something untrue. The report is then shown next to the document, in the review screen, and you decide what to do with each line.
The number check is a search for a substring. So a number that already appears somewhere else in the profile can pass in the wrong place. A claim with no number in it is judged only by its citation. It catches the worst failure, which is an invented figure, and it does not catch everything.
The model writes a CV, but the CV has to come out in your template, with your fonts, your margins, your spacing.
The naive way is to read the template, note the styles, then rebuild a fresh document wearing those styles. It never matches. Word documents are a mess of local formatting, and the model's idea of your template is only an approximation of it.
So I never rebuild anything. The user's file stays the base, and I re-use the exact XML of the paragraph that each line was modelled on. First I keep every block with its own XML.
blocks.append(
ExtractedBlock(text=text[:MAX_TEXT], xml=paragraph._p.xml,
hint=_hint(paragraph))
)
The model looks at the list of blocks and, for each one, picks a role from a closed list. A bullet, a section title, an entry title, a body line. It never sees the formatting, only the text and a short hint. Then to render, the code deep copies the XML of the block that carries the role, replaces its text, and puts it back into the document.
element = parse_xml(xml)
_fill(element, line.text, line.role, link_ids)
Fonts, colours, numbering, borders, headers and the clickable links all come from the template, because they are literally the template's own XML.
It works well on normal CVs and it can be wrong on strange ones.
When a tool writes things about you, the failures are not the failures of a chat window. A chatbot that is a bit off is mildly annoying. A document that claims a number you never earned is a real problem, in a real inbox, with a real recruiter at the other end.
So the model gets the part where judgement helps, which is reading a messy offer and putting a line in the right bucket. The code gets the part that has to be exactly right, which is the arithmetic, the facts and the layout. Each one does what it is good at, and every output can be explained.
Ask two questions about every step.
First, what is the smallest useful decision here? Then, can I put it inside a closed set? A role from a list. A small integer on a fixed grid. Not a paragraph of free text that you will have to parse back out, because parsing it back out is where trust goes to die.
Everything the model is not deciding, keep it in code, where you can read it, test it and defend it in a meeting. That is not a limit on the model. It is what makes the model safe to use.
The code is open source, MIT licensed, and it runs on your machine. Profile, offers and documents never leave it, except the text you send to the model provider you configure.