Skip to main content
Glama
README.md
# Receipts

[![test](https://github.com/alexjpower74-create/receipts/actions/workflows/test.yml/badge.svg)](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.**

![Receipts: run themes, inspect a dropped claim, ask a question, follow a sentence to its source](docs/receipts-flow.gif)

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