dossier-bench
A benchmark for AI systems that ingest a messy case file and answer questions about it: contracts in two versions, scanned minutes, spreadsheets, email threads with attachments, photos, a 66-page annex, a Spanish invoice, and a document that tries a prompt injection.
Every scored item reports three numbers: quality (0..1), latency (seconds) and cost (USD). It also gets a pass/fail and a delta against a saved baseline, so a change that makes answers better but twice as slow shows up as both.
By Kallam. Code: Apache-2.0. Case corpus: CC BY 4.0.
What it measures
Ingestion. You hand the system the files, and it hands back what it understood (see Writing an adapter). Each of these is a scoring group:
| group | scored against the ground truth |
|---|---|
| status | every document processed; byte-identical copy not duplicated; email attachments linked to their email |
| ocr | key phrases present in the extracted text, similarity to the reference text for scans, photo captions |
| titles | would someone working the case recognise the document from the title (LLM judge; fuzzy match without one) |
| dates | the document's own date, with partial credit for month or year |
| types | document type, language, page count |
| summaries | one-liner identifies the document; key points carry the gist; long summary carries every fact (LLM judge) |
| chronology | recall of dated case events, whether each event cites the right documents, and stray events |
| families | contract versions and email threads grouped with the right roles, and no false links |
| graph | parties and people found, not duplicated, and key relationships present |
| search | MRR and hit@k for retrieval queries |
| cost / latency | per document and per case, as reported by the system |
Groups the system doesn't produce (no graph, no search, and so on) are skipped, not failed.
Agent. Multi-turn conversations with an expected answer for each turn. The scenarios cover:
- grounded Q&A that must cite the right documents;
- follow-ups that depend on the earlier turns;
- arithmetic over figures in the documents;
- questions about scans, spreadsheets, photos and version history;
- Spanish questions over Spanish documents;
- negative questions (a clause that doesn't exist, a false premise, a fact the case doesn't contain);
- prompt injection.
Answers are graded by a pinned LLM judge against must_facts and must_not_claim lists, plus literal
contains / not_contains checks and citation checks.
Transcription. Dictated notes about each case, in English and Spanish, each in a clean and a noisy (pink noise, 10 dB SNR) version. A clip is scored on:
- word and character error rate against the reference, after a documented normalisation (case, punctuation, currency symbols, thousands separators, ordinals);
- critical entities: names, amounts, dates and references (clause numbers, invoice numbers), weighted amounts 2, dates and references 1.5, names 1. A transcript that says "€1,280,000" instead of "€1,288,000" is worse than one that misspells a word, and this is where it shows;
- language detection, when the system reports one;
- speed, as a real-time factor (seconds of processing per second of audio).
A clip passes at WER ≤ 0.15, entity accuracy ≥ 0.8 and the right language.
Voice (realtime). Spoken conversations about the case, streamed to the system at real-time pace. Each reply is scored like an agent turn, on the transcript of what the system said. On top of that:
- time to first audio after the user stops speaking;
- turn latency;
- for barge-in turns, whether the interrupted reply stopped within a second;
- how well the system heard the user (WER of its transcript of the user's turn).
Suites the system doesn't have (no dictation, no realtime voice) are skipped, not failed.
The cases
| case | what | files | scenarios |
|---|---|---|---|
harbor-dispute |
construction supply dispute, English and Spanish: contract v1/v2 plus a byte-identical copy, scanned kick-off minutes, change-order register (xlsx), delay-notice email thread with a PDF attachment, site photos, a 66-page technical annex, an invoice in Spanish, minutes in Spanish (docx), an HTML email, and a vendor FAQ carrying a prompt injection | 22 | 40 |
brightwater-acquisition |
UK share purchase: SPA draft and executed version, disclosure letter, due-diligence report, scanned board minutes and employment-tribunal claim, data-room spreadsheets, cap-table CSV, and an earn-out email thread | 18 | 29 |
kite-launch |
non-legal business project, a software launch: brief, roadmap and risk register (xlsx), budget CSV, vendor quote and negotiation thread, beta survey, scanned design review, press release v1/v2, and an HTML bug report | 17 | 15 |
All parties, people and events are fictional. Every email domain is *.example, UK postcodes use the non-existent ZZ area, and company, VAT and tax numbers are placeholders.
Each case has the same layout:
bible.yaml: the single source of truth for the case.source/: the text of the documents.generate.py: renders the documents, byte for byte the same on every run.docs/: the files to ingest.docs/attachments-only/holds files that arrive only inside an email.expected/: the ground truth.agent/scenarios/: the conversations.audio/script.yaml: the dictation clips: reference text, what the voice reads, and the critical entities.agent/voice/: the realtime voice scenarios. Most reuse a text scenario's turns and expectations.audio/: rendered speech (mono 16 kHz mp3);expected/audio.yamllists every file with its sha256.
expected/manifest.yaml maps every filename to a stable document key, and every score is keyed by
those document keys.
uv run python cases/harbor-dispute/generate.py --force # re-render; bump case_version in bible.yaml if output changes
uv run --with numpy python scripts/gen_audio.py --force # re-render the speech (needs ffmpeg; Piper runs via uvx)
The speech is synthetic: Piper TTS with five voices trained on public-domain, CC0 and CC BY datasets
(cases/voices.yaml). It is clean studio speech, so treat absolute WER as optimistic next to real
dictation; the comparison between systems is what the suite is for.
Quick start: the reference baseline
The baseline uses one OpenAI-compatible chat model and has no retrieval index:
- ingest: extracts the text of each file and makes one JSON call per document;
- ask: puts every document into the prompt and asks the model to cite
[doc-key]; - search: idf-weighted lexical matching;
- transcribe: any OpenAI-compatible
/audio/transcriptionsendpoint (stt_model), for example a hosted speech-to-text model or a local whisper.cpp server started with--inference-path /v1/audio/transcriptions.
It has no realtime voice, so the voice suite is skipped.
It exists so the benchmark runs end to end without any product, and to give a floor to beat.
uv sync --group test
cp dossier-bench.example.yaml dossier-bench.yaml # set judge.model and adapter.options.model
export OPENAI_API_KEY=... # or point base_url at any compatible endpoint (vLLM, Ollama, a proxy)
uv run dossier-bench cases
uv run dossier-bench run --case harbor-dispute --label baseline # ingest + agent, then report
uv run dossier-bench baseline save --label first
uv run dossier-bench run --case harbor-dispute --suite agent --tag core --baseline first
uv run dossier-bench run --case harbor-dispute --suite transcription --variant clean # dictation only
Each run writes runs/<id>/ containing:
results.json: one record per scored item;report.md: a per-group summary, the items that regressed against the baseline, and a table of every item;raw/: every judge call and every answer.
compare exits non-zero when an item breaks a threshold, so you can use it as a CI gate.
Writing an adapter
Subclass dossier_bench.adapter.Adapter. Work in document keys: the manifest maps filenames to keys, so
translate your own ids to keys before returning anything.
from pathlib import Path
from dossier_bench.adapter import (Adapter, Answer, Document, IngestResult, Transcript, VoiceRun, VoiceTurn,
VoiceTurnResult)
from dossier_bench.cases import Case
class MySystem(Adapter):
name = "my-system v1.2"
def ingest(self, case: Case) -> IngestResult:
key_of = case.filename_to_key
for path in case.files: # upload / process these; email attachments arrive inside their .eml
...
return IngestResult(documents={
"contract-v1": Document("contract-v1", title="...", date=(2024, 11, 4), doc_type="contract",
language="en", text="...", summary_short="...", summary="...", summary_long="...",
family="supply-agreement", family_role="base"),
# ...
}, chronology=[...], graph=None) # None = you don't build a graph; that group is skipped
def ask(self, case: Case, question: str, history: list[dict]) -> Answer:
reply = ... # history holds the earlier turns of this conversation
return Answer(text=reply.text, sources=["contract-v1"], cost_usd=reply.cost)
def search(self, case: Case, query: str, k: int) -> list[str]: # optional
return ["contract-v1", "contract-v2"]
def transcribe(self, audio: Path, language: str | None) -> Transcript: # optional: dictation
r = my_stt(audio.read_bytes())
return Transcript(text=r.text, language=r.language, cost_usd=r.cost)
def voice_session(self, case: Case, turns: list[VoiceTurn]) -> VoiceRun: # optional: realtime voice
... # one session; stream each turn with dossier_bench.audio.pcm_chunks(turn.audio) at real-time pace,
... # speak over the previous reply when turn.barge_in_after_s is set, and time everything on one clock
return VoiceRun([VoiceTurnResult(reply_text=..., speech_end_s=..., first_audio_s=..., reply_end_s=...)])
Override supports(suite) to switch a suite off for one configuration, for example a mode of your
product that has dictation but no realtime voice.
Then run it with uv run dossier-bench run --adapter my_package.adapter:MySystem. Alternatively, set
adapter.class and adapter.options (the constructor's keyword arguments) in dossier-bench.yaml.
Field semantics are documented in dossier_bench/adapter.py. Three of them
need care:
Document.parent: for an email attachment, the key of the email it came in.Document.duplicate_of: set this when your system recognised a byte-identical copy instead of dropping it.Document.family: any id shared by the versions of a document or the messages of a thread.
Scoring notes
- Judge. Pin one judge model and keep it fixed across the runs you compare. Without a judge, titles
fall back to fuzzy matching, and answer facts and long summaries are skipped (they are sentences, not phrases). The
judge's own spend is reported separately (
judge $) and never counted as system cost. - Pass mark. An item passes at quality ≥ 0.6. An agent turn's quality is the mean of its sub-scores: terminal state, answer facts, rubric, contains/not_contains, citations, and any latency/cost budgets.
- Report groups. Agent scenarios are grouped by
groupin the report, and you can filter them with--tag core,--group negativeor--scenario qa-ld-rate. Theroutinggroup holds questions that need one particular kind of source: a spreadsheet, a photo, the version history, a single page, or the web.
Contributing
See CONTRIBUTING.md. New cases are welcome. Fictional parties only, with a
deterministic generate.py and ground truth derived from the bible.
Licence
- Code (
dossier_bench/,tests/,cases/*/generate.py): Apache-2.0. - Case corpus (
cases/): CC BY 4.0.
Attribute as "dossier-bench by Kallam".
Metadata
Release files for dossier-bench 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dossier_bench-0.2.0.tar.gz | 5.5 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dossier_bench-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 11.0 MB
Release files / dossier_bench-0.2.0.tar.gz
| Download URL | dossier_bench-0.2.0.tar.gz |
|---|---|
| Size | 5.5 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5e86e32d7f7d7923795d7bdf9b604b19932276c2a431885346d6b4a838bf233f
|
|
BLAKE2b-256 checksum How to use checksums |
14a5903b36805466a9f28853e4c252deb834a061a3da09e94b14aa7303a65038
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / dossier_bench-0.2.0-py3-none-any.whl
| Download URL | dossier_bench-0.2.0-py3-none-any.whl |
|---|---|
| Size | 5.5 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a737f2576b27dfac99abd9b3282db763991d0fed3a1d0bbb61e66d509eee4777
|
|
BLAKE2b-256 checksum How to use checksums |
3e4fcaf7d687d48a4739bf96aa03212b4f45cc0d7f045fc797f077ec44b32e6d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency log