Comparing Change Request Models¶
Change requests are written by one call to an AI model, set per
site as change-request-model. Before choosing a default (or changing the prompt), run the same
requests through several models and compare cost per accepted post, counting the reviewer's
time, not cost per call. The harness for that is a profile of the worker.
This costs real money
Every case is sent to every model you list, with the providers' paid APIs. The script prints how many calls it will make and asks before it starts.
What it does¶
The eval set lives in the worker, under worker/src/main/resources/change-request-evals/:
| Folder | Holds |
|---|---|
sites/ |
Reference sites: a hedgehog charity (built on the hedgerow demo's content), a mountain rescue team and a firm of solicitors, each with a description, a voice (as the design brief's voice), blogs, pages a button can link to and a couple of earlier posts |
cases/ |
20 real-style requests: charity news, fundraising appeals, events, call-out reports, staff profiles and practice updates. Each has the request text, its photos with the tags image tagging would give them, the recipe a good draft uses, and the facts a good draft keeps |
photos/ |
The photos. The shipped ones are plain placeholders (see Photos) |
For each run, the worker started with the change-request-eval profile:
- refuses to start unless the
devprofile is on, no production profile is (production,k3s, anything containingprod), and every tenant it would touch is namedcr-eval-...; - deletes and rebuilds one tenant per reference site (
cr-eval-hedgehog,cr-eval-rescue,cr-eval-law), the way a new shop is created, then adds the site's content and settings; - stores each case's photos as a change request's photos, through the media library;
- sends each case to each model by calling the draft writer directly, with the same prompt, schema, checks and draft page writing as a real request (the requests themselves aren't stored, so the normal worker never picks them up);
- writes a report, and leaves every draft page on its tenant to preview.
The worker exits when it has finished.
Running it¶
You need the dev stack's dependencies running and the jars built (./dev-scripts/devEnv.sh, after a
build that includes the worker). Then:
./dev-scripts/change-requests/eval.sh # every case, the default models
./dev-scripts/change-requests/eval.sh --models anthropic:claude-haiku-5-5,anthropic:claude-sonnet-5-5
./dev-scripts/change-requests/eval.sh --cases rescue # one site's cases
./dev-scripts/change-requests/eval.sh --cases "staff bio,law-update-01" # categories or case ids
| Option | Default | |
|---|---|---|
--models |
anthropic:claude-haiku-5-5,anthropic:claude-sonnet-5-5,anthropic:claude-opus-5-5 |
provider:model pairs, comma-separated. OpenAI models are openai:<model id> |
--cases |
every case | Case ids, categories or site names, comma-separated |
--cases-dir |
the shipped set | A directory laid out like change-request-evals/ (sites/, cases/, photos/), read instead of the shipped set |
--tagging |
prebaked |
prebaked stores each photo's tags from the case file, so every run and model sees the same descriptions. live sends the photos through image tagging (which costs too) |
--out |
dev-scripts/change-requests/out |
Where the reports go |
--yes |
Don't ask before starting |
Keys: the script reads ANTHROPIC_API_KEY and OPENAI_API_KEY from ~/devEnv.conf (or the
environment) without printing them, and the eval tenants store them as their own keys. Without
them, the platform keys set in your dev database are used. The run stops before calling anything if
a provider in --models has no key, or if it would make more than 200 calls
(badger.change-request-eval.max-calls).
Each eval tenant is served at https://cr-eval-<site>.bdgr.localhost, and its drafts show in its
admin like any other draft page. The tenants are wiped at the start of the next run.
The report¶
Each run writes a folder named for when it ran:
| File | Holds |
|---|---|
results.csv |
One row per case and model (see below) |
summary.csv |
Per model: drafts written, recipe matches, facts kept, possible inventions per draft, total cost, cost per run and per written draft, tokens and time |
report.json |
Everything, with each answer in full |
content/<case>__<model>.json |
Each answer on its own, to diff two models' answers to the same request |
results.csv columns, by group:
- What happened:
valid(a draft was written),error(the reason code when not, for exampleinvalid_json,refusalorinvalid_content: MEDIA_FOREIGN@leadMediaId),served_model(a provider fallback can answer with another model),page_idandpreview_url(a preview link to the draft, valid for 14 days). - Automatic checks:
recipeandrecipe_match;media_usedandforeign_media(photos that weren't the request's);facts_foundoffacts_expected, andmissing_facts;unsupported_numbersandunsupported_names, the numbers and capitalised words in the copy that appear in neither the request, its photos' tags, nor the site's name, description and page titles. These are a plain word match, so treat them as things to look at: an invented name or figure shows up here, and so may an unusual heading word. Lengths (title_chars,meta_chars,excerpt_chars,prose_words,blocks) andover_limits. - Cost:
input_tokens(uncached),cached_input_tokens,cache_write_tokens,output_tokens,cost_usd(blank for a model with no price inModelPricing),latency_ms(the whole step: building the prompt, the call and writing the draft). - For the reviewer:
accepted,voice_matchandedit_minutesare left blank.
Cases are sent model by model within each case, so a model reads the site's rules and details from
its prompt cache from the second request on that site; cached_input_tokens shows how much. A real
site sending a request now and then gets fewer cache hits, so compare uncached input too.
Scoring¶
Open each draft from preview_url (or the tenant's admin) and fill in, per row:
accepted:yif you'd approve it after editing,nif you'd reject it or send it back;voice_match: 1 (wrong voice) to 5 (sounds like the organisation);edit_minutes: how long the edits before approval took (or how long you spent before rejecting).
Then work out cost per accepted post, model by model:
python3 dev-scripts/change-requests/score.py dev-scripts/change-requests/out/<run>/results.csv --rate 30
which is (model cost of every run + reviewer minutes × hourly rate / 60) ÷ accepted drafts. The
rate is in dollars an hour, like the model costs. A cheap model whose drafts take ten minutes to fix
usually costs more per accepted post than a dearer one whose drafts need a glance.
Things to try with the results: the cheapest model by default, moving up a tier when the automatic checks fail or a reviewer rejects twice.
Photos¶
The shipped photos are plain colour placeholders: the case files say so ("placeholder": true), so
the model is sent each photo's description instead of the picture, exactly as when a photo can't be
fetched. That keeps the set small and free of anyone's pictures, and keeps runs repeatable.
To include real photos, copy change-request-evals/ somewhere, put the photos (JPEG, PNG, GIF or
WebP, about 1024 pixels on the long side, under 5 MB) in its photos/ folder, point each case at its
file with "placeholder": false, and run with --cases-dir. Use only photos you're allowed to use.
With --tagging live the photos are also described by image tagging instead of the case's tags.
Adding a case¶
Add a JSON file to cases/:
{
"id": "rescue-callout-05",
"category": "call-out report",
"site": "rescue",
"expectedRecipe": "NEWS_POST",
"sentAt": "2026-11-09T18:00:00Z",
"request": "Call-out 47 this afternoon ...",
"photos": [
{"file": "placeholder-11.png", "placeholder": true, "title": "...", "description": "...",
"altText": "...", "keywords": ["mountain rescue"]}
],
"expectedFacts": ["47", "Blencathra"]
}
sentAt fixes when the request was sent, so "last Saturday" means the same day every run. Every
expected fact must appear in the request or its photos' tags; EvalCaseLoaderTest checks that, and
that every site and photo a case names exists.
Notes¶
- The harness is a Spring profile of the worker (
ChangeRequestEvalRunner, packageuk.co.kedos.badger.settbuilder.changerequests.eval). Its beans only exist under that profile, somvn testand deployed workers never create them; its unit tests call no model. - The OpenAI candidates are opt-in: add
openai:gpt-6-luna,openai:gpt-6.1-terraandopenai:gpt-6.1-solto--modelsonce an OpenAI key is set up.ModelPricinghas their input and output list prices (per million tokens: Luna $0.10 / $0.50, Terra $1 / $5, Sol $2 / $10); cached input is priced as full input, so theircost_usdis an upper bound. Any other model without a price has a blankcost_usd. - The report holds the requests and the generated copy. Keep it on your machine; the worker's log only has case ids, models, reason codes, tokens and costs.