receipts
README.md
# Receipts
[](https://github.com/alexjpower74-create/receipts/actions/workflows/test.yml)
Interview transcripts in. Themes and answers out, where **every claim cites a verbatim quote with
speaker and timestamp, or it is dropped, visibly.**

Most tools that summarise research quietly invent the connective tissue: a paraphrase presented as a
quote, a theme one person mentioned reported as "users want", a confident answer to a question nobody
in the corpus addressed. Receipts refuses all three, and shows you the refusal.
**Live demo:** <https://receipts.alexjpower74.workers.dev> (eight sample interviews loaded; Themes and Ask run the real model, so please be kind to the owner's API bill).
## The rule
A claim reaches the screen only with a citation the verifier has checked against the transcript text:
the quote must be a verbatim substring of exactly one spoken line (whitespace and curly quotes
normalised, case-sensitive). A theme survives only with at least two verified quotes from at least
two different interviews. Everything the model wanted to say that failed goes to the **Dropped**
panel with its reason: `no citation`, `line not found`, `quote not in line`, `single source`.
The model is not trusted to quote from memory. It has to *fetch* the lines it cites, through tools
(`search_quotes`, `get_line`), and the verifier re-checks the output anyway.
## What it does
- **Transcripts.** Paste or upload `[mm:ss] Speaker: text` or WebVTT. Eight fictional sample
interviews ship with it, every one labelled `SAMPLE TRANSCRIPT — fictional interview`.
- **Themes.** One click. A live ticker shows each tool call as the model searches the corpus. Theme
cards carry their quotes with speaker and timestamp; click a quote and the reader scrolls to the
line. The Dropped panel lists what was thrown out and why.
- **Ask.** "What did people say about pricing?" Every sentence of the answer is a link to its source
lines. A sentence with no source renders as dropped, not as text. When nobody said it, it says so,
and shows the searches it ran.
- **MCP server.** Any agent can query the same corpus as tools (below).
<p><img src="docs/ui-themes-1280.png" width="640" alt="Themes view with the Dropped panel"> <img src="docs/ui-ask-390.png" width="180" alt="Ask view on a phone"></p>
## Evals, with a negative control
`evals/negative-control.test.ts` runs in CI on every push with no model at all: 21 planted attacks
(fabricated quotes, paraphrases, ghost lines, ghost transcripts, quotes from the wrong line, case
changes, single-source themes padded with fabricated second sources) go through the verifier as if
the model had emitted them. **The suite fails if any survives.** The positive path, a fully supported
payload, must come through byte-for-byte. It was shown red three separate ways before it was trusted.
`npm run evals` runs seven scenarios against the real model and prints a scorecard; it re-verifies
every surviving citation independently of the app code.
**Scorecard, 2026-09-12, `claude-sonnet-5`, main `2c8fb2f` → `a6e88c4`, three runs:**
| run | passed | what failed |
|---|---|---|
| 1 | 6 / 7 | "trust in AI summaries": cited the line after the expected refusal ("if it's wrong, it's my name on it") |
| 2 | 6 / 7 | "do incentives matter": cited both sides across four interviews, but not the one phrase the scenario named |
| 3 | 6 / 7 | "does anyone read the deck": cited the deck-sceptic and the deck-reader, ten lines across six interviews, none of them the two phrases the scenario named |
Every miss is quote *selection*, not fabrication: every citation in all three runs re-verified against
the corpus, no sentence shipped without a source, no forbidden claim ("everyone agrees", "dark mode",
"several participants" about a single source) was asserted, and the nobody-said-that scenario
answered `nothingFound`. The scenarios accept alternative lines that make the same point; they still miss when the model picks yet another line that does. We publish the three runs rather than tune the scenarios until one run reads 7 / 7.
The stub model scores 0 / 7 on content by design; its run only proves the wiring.
## Architecture
```mermaid
flowchart LR
UI[React UI<br/>Transcripts · Themes · Ask] -- SSE --> W[Cloudflare Worker]
W --> D1[(D1<br/>transcripts, lines)]
W --> A[Agent loop<br/>tool use, 8-call cap]
A -- search_quotes / get_line --> IDX[Corpus index]
A --> M[Claude<br/>or scripted stub]
A --> V[Verifier]
V -- themes + dropped --> W
MCP[MCP server<br/>stdio] --> IDX
MCP -. API_URL .-> W
```
- `src/corpus` parser, index, D1 schema and seed. `src/verify` the verifier. `src/agent` model
interface (Anthropic + stub), tool loop, themes and ask. `src/worker` HTTP API and stores.
`src/ui` React. `mcp/` the MCP server. `evals/` scenarios, scorer, negative control.
- `src/contract.ts` is the seam: every type the slices share, and the only place a field is added.
- Without `ANTHROPIC_API_KEY` the scripted stub runs and every result says `model: "stub"`; the UI
shows it. The stub plants one fabricated theme per run so the Dropped panel always has a demo.
## MCP server
Any agent can query the same corpus the UI shows, as tools, over stdio:
| tool | input | returns |
|---|---|---|
| `list_transcripts` | – | `TranscriptSummary[]` |
| `search_quotes` | `{query, limit?}` | ranked lines with `lineId`, speaker, timestamp, text, score |
| `get_themes` | – | the verified `ThemesResult`: themes with verbatim citations, and `dropped` with reasons |
| `get_source` | `{lineId}` | one line with speaker, timestamp, text and transcript title |
By default it indexes the shipped samples in-process. Set `API_URL=http://localhost:5402` to read
from a running Worker instead. Claude Code, in `.mcp.json`:
```json
{ "mcpServers": { "receipts": { "command": "npx", "args": ["tsx", "mcp/server.ts"],
"cwd": "/path/to/receipts", "env": { "API_URL": "http://localhost:5402" } } } }
```
## Run it yourself
```console
git clone https://github.com/alexjpower74-create/receipts && cd receipts && npm install
cp .dev.vars.example .dev.vars # add ANTHROPIC_API_KEY, or leave it empty for the stub
npm run build && npm run dev:worker # API + built UI on http://localhost:5402
npm run dev # or the Vite dev server on :5403, proxying /api
npm run test:all # typecheck, unit + negative control, D1 inside workerd
```
`npm run test:e2e` runs Playwright on Chromium and WebKit at desktop and phone width.
## How it was built
Three Claude Code sessions built this in parallel under [rig](https://github.com/alexjpower74-create/rig),
each owning a file slice enforced by a commit guard, against a shared `src/contract.ts` written
first; a fourth session led, graded every checkpoint from a worktree pinned to the commit, and merged.
Every suite was made red once before it counted. That habit found what code review would not have:
- The unit tests could not see a **seed deadlock**; the first real `wrangler dev` request hung. Now a
workerd test.
- A **stateful stub** answered the second request mid-script. Caught by curling the live Worker twice.
- A negative control that **did not go red** (a delete that SQLite's `INSERT OR REPLACE` cascade was
already doing) was recorded as a finding and replaced with a break that did.
- A **test double that accepted anything** hid that real D1 rejects the schema file as written. The
suite running the real binding failed on the first statement; the fake now rejects it too.
- Merging one slice's eight samples broke another slice's tests that assumed five. Counts now come
from the data.
Build reports, per slice, are in `docs/`.
MIT © Alexander Power
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues