skills/braintrust-vision-evals/SKILL.md
--- name: braintrust-vision-evals description: Eval OCR / AI-vision document intake — measure the JSON a vision model extracts from a document against a golden sample. The framing that makes it simple: the image is just input, you score extracted JSON vs ground-truth JSON (no special OCR monitor). Covers doc-as-Attachment datasets, ValidJSON / JSONDiff(+tolerance) / custom per-field F1 scorers, EU-resident vision model choice (Mistral OCR 4, Gemini via Vertex EU), and the hard BSN-never-stored +
npx skillsauth add RonanCodes/ronan-skills skills/braintrust-vision-evalsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
The key framing: an OCR or document-intake eval is a JSON-vs-JSON eval where the input happens to be an image. There is no special OCR monitor or named recipe. You feed the document in, the task runs the vision model and returns parsed JSON, and you score that JSON against a golden sample with the same recursive scorers you would use for any structured output.
Read /ro:braintrust first for install + auth.
Put the document in input and the hand-checked field JSON in expected. Use a Braintrust Attachment for the doc, it dodges span-size limits that base64 in the span would hit. A base64 data URL or an external URL also work for small docs.
import { Attachment } from "braintrust";
import { readFileSync } from "node:fs";
const data = () => [
{
input: new Attachment({
data: readFileSync("fixtures/payslip-001.pdf"),
filename: "payslip-001.pdf",
contentType: "application/pdf",
}),
expected: { grossMonthly: 4250.0, employer: "Acme BV", iban: "NL00BANK0000000000" },
},
];
task: async (input) => {
const bytes = await input.data(); // Attachment.data() → the raw bytes
const res = await extractWithVisionModel(bytes); // your model call, structured-output mode
return JSON.parse(res); // return the parsed object, not a string
},
import { ValidJSON, JSONDiff, NumericDiff, Levenshtein } from "autoevals";
ValidJSON — did it parse and match the expected shape. The floor.
JSONDiff — the headline number. Recursively compares objects (per-key, averaged), arrays (element-wise) and leaves: strings get Levenshtein partial credit, numbers get tolerant NumericDiff. So a near-miss on a name or a rounding wobble on an amount scores high instead of zero. Parameterize the leaf scorers:
JSONDiff({ output, expected, stringScorer: Levenshtein, numberScorer: NumericDiff });
Custom per-field F1 — there is no built-in. Iterate the expected keys, apply a per-field comparator (exact for IBAN / dates / IDs, NumericDiff for amounts, Levenshtein or embeddings for names), average to one score and emit per-field sub-scores in metadata so a regression points at the field that broke.
const fieldF1 = ({ output, expected }) => {
const cmp = { iban: exact, grossMonthly: numeric, employer: fuzzy };
const subs = Object.keys(expected).map((k) => ({ k, s: (cmp[k] ?? exact)(output?.[k], expected[k]) }));
const mean = subs.reduce((a, b) => a + b.s, 0) / subs.length;
return { name: "field_f1", score: mean, metadata: Object.fromEntries(subs.map((x) => [x.k, x.s])) };
};
trialCount)A vision model is as nondeterministic as a chat model: the JSON it extracts from the same scan can wobble between runs. Pass trialCount to Eval() so every document runs N times and the field scores aggregate to mean plus variance, which measures consistency rather than a single lucky extraction.
Eval("makely-doc-extraction", {
data,
task,
scores: [ValidJSON, JSONDiff, fieldF1],
trialCount: 3, // each doc extracted 3x; read the mean + variance per field
maxConcurrency: 5,
});
Keep trialCount low (3) when each trial is a paid vision call. A field that flips between trials is flaky extraction even if its mean looks acceptable.
For Dutch documents the data must stay in the EU. As of mid-2026:
Re-validate any 2026 benchmark numbers on your own documents before committing, and confirm zero-retention plus self-host licensing before any document carrying a BSN.
verified boolean or a salted token, never the digits.api-eu.braintrust.dev) so traces and dataset rows stay EU-resident, matching the EU Sentry and PostHog setup."eval:extract": "bt eval evals/doc-extraction.eval.ts"
bt eval evals/ --no-input --json for CI; braintrustdata/eval-action@v1 with fail_on_regression: true to gate. Vision evals cost model spend, so run them on-demand or nightly as uploaded experiments rather than in the pre-push gate; keep pre-push to the deterministic tool-call gate.
/ro:braintrust — install, auth, Eval() basics, the Vercel-vs-Workers flush note./ro:braintrust-tool-evals — tool-selection + argument evals./ro:braintrust-trace-surfaces — trace the production document-intake calls.testing
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".