A CV has no schema, no authority and no check digit. What it does have is a consistent set of shapes that break a naive employer-title-start-end record, and every one of them can be designed out before you write the prompt rather than patched afterwards.
The unit is the role, not the employer
The tempting record is one row per company — and the choice between a row per company and a row per role is the general question in multi-entity document schema design. It fails on the single most common CV pattern in existence: an internal promotion, written as one block with the employer named once and two or three titles nested under it with their own date ranges:
Borealis Logistics Apr 2022 - Present
Senior Platform Engineer Feb 2024 - Present
Platform Engineer Apr 2022 - Feb 2024
Under a company-level schema you must pick one title and one range, and whichever you pick you have thrown away real information. Under a role-level schema this is two rows sharing an employer value and nothing has to be discarded. The outer date range is then derivable — it is the union of the roles — rather than a fourth thing the model has to reconcile.
The same choice handles the other three shapes for free. A contractor who worked at four clients through one agency is four roles with the agency in a separate field. Someone holding a day job and an advisory position simultaneously is two roles whose ranges overlap, which is legal and not an error. A rehire — two spells at the same company years apart — is two roles rather than one range that swallows the gap between them.
That last one matters more than it looks. A company-level record for a rehire spans the whole period including the years the person was somewhere else, and any downstream tenure or gap calculation built on it is wrong in a way nobody will spot. See extracting employment gaps from a resume for what that arithmetic actually needs.
Dates: precision, open ends and ambiguity
Three separate problems get conflated into “parse the dates”, and each needs its own decision in the schema.
Precision varies within one document. March 2019, 03/2019, 2019 and Spring 2019 all appear, sometimes on adjacent lines of the same CV. Storing them all as a full ISO date forces the model to invent a day, and an invented day is indistinguishable from a real one once it is in the database. Store the value at the precision it was written and store the precision alongside it, so any consumer knows whether 2019-03-01 means the first of March or means March.
The end date is often open. Present, Current, Now, to date, — with nothing after it, and simply omitting the end entirely all mean the same thing. Do not let the model substitute today’s date: the record would then silently change meaning depending on when it was extracted, and a CV written two years ago would appear to claim the person is still there. Model it as an explicit null end with a boolean, and if you need a closed interval for arithmetic, close it at the extraction date and record that date.
Numeric dates are ambiguous by locale. 04/07/2021 is April in a US-formatted CV and July in most of the rest of the world, and nothing in the token tells you which. The only reliable disambiguator is context within the same document: if any date on the CV has a first component above 12, the whole document is day-first and you can apply that to the rest. If no date disambiguates, record the ambiguity rather than guessing — a three-month error in a start date is exactly the size that corrupts a tenure calculation without looking wrong.
The schema
const ROLE_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["employer", "title", "start", "end", "raw"],
properties: {
employer: { type: "string" },
employer_via: { type: ["string", "null"] }, // agency or umbrella company
title: { type: "string" },
employment_type: {
enum: ["full_time", "part_time", "contract", "internship",
"freelance", "volunteer", "unknown"],
},
location: { type: ["string", "null"] },
start: {
type: "object",
additionalProperties: false,
required: ["value", "precision"],
properties: {
value: { type: "string" }, // "2022-04" or "2022"
precision: { enum: ["day", "month", "year"] },
},
},
end: {
type: ["object", "null"], // null means still in role
additionalProperties: false,
required: ["value", "precision"],
properties: {
value: { type: "string" },
precision: { enum: ["day", "month", "year"] },
},
},
is_current: { type: "boolean" },
date_order_ambiguous: { type: "boolean" },
raw: { type: "string" }, // the block as it appeared
},
};
employer_via earns its place because agency work is otherwise unrepresentable: the CV says “Ravensworth Consulting (client: Northgate Bank)” and a single employer field has to lose one of them. raw earns its place for the same reason it does everywhere in this cluster — it is what you diff against when the prompt or the model changes, which is the point of an extraction model version audit trail, and it is what a reviewer reads instead of reopening the PDF.
The extraction call
Two instructions do most of the work, and both are about forbidding helpfulness rather than requesting it.
Extract every distinct role. A role is one job title at one employer over
one continuous date range.
- If an employer block lists several titles with their own date ranges,
emit one role per title. Do not emit a role for the employer block itself.
- Copy dates as written. Do not convert a month to a day, do not infer a
missing month, and do not replace "Present" with a date. If an end date
is absent or open, set end to null and is_current to true.
- If a numeric date could be either day-first or month-first and no other
date in the document resolves it, set date_order_ambiguous to true.
- raw must be the text of the block verbatim, including line breaks.
The instruction not to convert precision is the one most often left out and the one that changes the output most. Without it a model returns 2019-03-01 for “March 2019” every time, and the fabricated day is now a fact in your system. This is a general property of extraction prompts rather than anything specific to CVs — extraction prompts covers the pattern.
A CV is personal data under most privacy regimes, and a CV sent to a third-party model leaves your infrastructure. Whether that is lawful turns on your basis for processing and your contract with the provider rather than on anything in the extraction; the relevant obligations are covered in the GDPR subprocessor checklist. Nothing here is legal advice.
Checks that run without a human
A CV has no external authority to validate against, but the extraction is internally checkable in five ways, and all five are cheap:
- Every role’s start precedes its end. A reversed range is nearly always the model swapping two columns on a right-aligned date layout, and it is the single most common structural error.
- No date is in the future, except an end date on a role marked current, which should be null anyway. A future start date usually means a two-digit year was expanded to the wrong century.
- At most one role is marked current per employer, unless the CV genuinely shows concurrent positions. Two current roles at the same employer means a promotion block was mis-split.
- Every
rawvalue is a substring of the source text after whitespace normalisation. This is the strongest check available on an unstructured document: if the raw block cannot be found in the input, the model composed it, and everything parsed out of it is suspect. - The union of role ranges does not exceed a plausible working life from the earliest education date. This catches a century error and little else, but it costs one comparison.
The substring check is worth implementing before anything else. It converts “the model made something up” from a thing you discover during review into a thing your pipeline detects on every record, and it requires no ground truth, no labels and no second model call.
CV parsing is a workload where the choice of model changes with the document rather than with the task: a clean text CV needs a small cheap model and a two-column designed PDF needs a vision-capable one, at a per-page price that differs sharply between providers. Routing per document through one API means the batch driver picks a tier rather than a vendor, and per-request cost attribution tells you afterwards what the expensive tenth of the corpus actually was.
Top comments (0)