A test set for AI extraction of council contracts
Synthetic UK local-authority procurement documents with exact gold labels (generated from the same spec as each PDF), the tricky layouts that break OCR-and-regex pipelines, the compliance flags a Procurement Act 2023 rule engine should raise, and a scorer. Built for the ContractRadar pilot; free for anyone to use.
How it works
Synthetic PDF in, expected CSV out, plus the wrong extraction. Each case folder has document.pdf, gold.json / expected.csv
(what an extractor should return), notes.md (why the case is hard) and the expected rule-engine flags. A deliberately naive
pdftotext + regex baseline is scored on every case so you can see exactly where a text-dump approach breaks.
Every figure, authority and supplier is fictional and each page carries a visible "synthetic test document" marking. Real council notices
can be pulled from Find a Tender and Contracts Finder (Open Government Licence) with crtest.collect, which records source and licence per file.
git clone https://github.com/Marxist-Leninist/contractradar-testset
cd contractradar-testset
pip install -r requirements.txt
# score your own extractor: write predictions in the
# results/ format, then
python -m crtest.score --pred results/my_extractor.json
# or run the built-in LLM extractor against any
# OpenAI-compatible endpoint (no keys in the repo)
export LLM_BASE_URL=https://api.deepseek.com/v1 LLM_API_KEY=... LLM_MODEL=deepseek-chat
python -m crtest.extract_llm --out results/deepseek.json --vision --ocr
python -m crtest.score --pred results/deepseek.json
Leaderboard
Field accuracy counts every field that is non-null in the gold labels (plus any value an extractor invents where the gold is null). "Critical-field pass" means authority, supplier, title, value and dates (and the modification fields for change notices) were all correct. High-severity flag precision/recall is only shown for extractors that output flags.
| Extractor | Field accuracy | Critical-field pass | High-severity flags | Files |
|---|---|---|---|---|
| llm:nvidia/nemotron-3-ultra-550b-a55b:free:text nvidia/nemotron-3-ultra-550b-a55b:free via https://openrouter.ai/api/v1 (text+ocr) | 94.5% | 7/12 | P 0.88 / R 0.88 | scored json · predictions 2026-10-31 |
| naive_text pdftotext -layout + label regexes. Deliberately simple baseline. | 51.9% | 2/12 | no flags output | scored json · predictions 2026-10-31 |
Cases
Contract award notice, clean layout
Contract details notice with a priced schedule crossing a page
Contract details notice with borderless, grouped particulars
Contract change notice relying on the wrong ground
Scanned contract particulars (image-only PDF)
Contract details notice in a two-column stacked layout
Multi-lot award notice with an unawarded lot and a short standstill
Contract change notice correctly below threshold (negative control)
Deed of variation extending an expired contract with no change notice
Works contract details notice with wrapped supplier name and value in words
Works change notice exceeding the 50% unforeseeable-circumstances cap
Direct award notice with values embedded in prose
Hazards covered
| Hazard | What it is | Cases |
|---|---|---|
baseline | Plain single-column notice with bordered key/value table. Should be easy. | 1 |
table_crosses_page | A table runs over a page boundary; the header row repeats on the next page. | 2 |
repeated_header | Repeated table header rows must not be read as data rows. | 1 |
footer_interleaves | Page footer text (authority, page x of y) falls between table rows in a text dump. | 1 |
annual_vs_total_value | An annual price and a total contract value both appear; only the total is the contract value. | 2 |
borderless_table | Key/value table has no rules; column boundaries must be inferred from alignment. | 1 |
grouped_rows | A section label is printed once and spans several rows (blank cells below it). | 1 |
label_synonyms | Fields use non-standard labels (e.g. 'Initial expiry' for the end date). | 1 |
two_column_layout | Page is laid out in two columns; a line-based text dump interleaves them. | 1 |
stacked_label_value | Each label is printed on one line and its value on the next. | 1 |
money_short_format | Money printed as £2.31m rather than £2,310,000. | 1 |
money_spaced_format | Money printed with spaces as thousands separators (£4 200 000). | 1 |
money_words_and_figures | Money printed in words followed by figures. | 1 |
money_gbp_suffix | Money printed as 1,234.00 GBP. | 1 |
date_uk_format | Dates printed as DD/MM/YYYY. | 2 |
date_iso_format | Dates printed as YYYY-MM-DD. | 2 |
date_ordinal | Dates printed as 3rd March 2026. | 2 |
date_abbr | Dates printed as 03-Mar-2026. | 1 |
scanned_no_text_layer | Image-only PDF (simulated photocopy): no text layer, slight skew and noise. Needs OCR or vision. | 1 |
trading_as_name | Supplier printed with a trading name in brackets; the legal name is the answer. | 2 |
supplier_name_wrapped | Supplier legal name wraps across two lines mid-name. | 1 |
multi_lot | Several lots awarded to different suppliers. | 1 |
unawarded_lot | One lot was not awarded; it must appear with awarded=false and no supplier. | 1 |
totals_row | A totals row must not be read as a lot or a line item. | 2 |
percent_not_printed | The modification percentage is not printed and must be computed from the values. | 3 |
values_in_prose | Key values are embedded in running text rather than in a table. | 2 |
negative_control | Nothing is wrong with this document; a rule engine should raise no high-severity flag. | 1 |
wrong_legal_ground | The authority cites a modification ground that does not cover the change made. | 2 |
retrospective_extension | The variation is dated after the contract had already expired. | 1 |
no_ccn_published | The document states that no contract change notice was published before the modification. | 1 |
standstill_too_short | The stated standstill end date is fewer than 8 working days after publication. | 1 |
direct_award | Direct award under section 41 with a stated justification. | 1 |
kpi_shortfall | Contract over £5m with fewer than three KPIs. | 1 |
late_contract_details_notice | Contract details notice published more than 30 days after signature. | 1 |
payment_terms_over_30 | Stated payment terms exceed 30 days. | 1 |
evaluation_scores_table | Tenderer scoring table that could be mistaken for lot values. | 1 |
Contribute
Add a case. Add a spec to crtest/gen.py (gold fields + layout style + hazards) and run make cases. The PDF and gold labels are generated from the same spec, so they cannot disagree. Good targets from the pilot discussion: tables without set boundaries, grouped ID rows such as dates, and page boundaries - we have one of each, more variants welcome.
Score an extractor. Write predictions as {"extractor": "name", "cases": {"CR-001": {"fields": {...}, "flags": [...]}}} and run the scorer. Commit the predictions and the .scored.json to results/ and the leaderboard picks them up.
Label real documents. Use crtest.collect to fetch council notices, copy the OCDS values into a gold.json with the same field names, and attach the published document. Keep the _provenance block: the pilot's data rules require source and licence for everything uploaded.
Rules. crtest/pa2023.py holds the reference checks (thresholds from 1 Jan 2026, 10%/15% modification limits, 8-working-day standstill, 30-day notices, 3 KPIs over £5m). If you think a check is wrong, open an issue with the section of the Act.
Discussion: pilot thread on Discord. Generated 31 October 2026.