Skip to content

Commit c523c42

Browse files
DylanMerigaudclaude
andcommitted
Add document-extraction intake: vision PDF read + live reveal
The intake step now reads a real invoice PDF, closing the end-to-end loop (document → extract → match → investigate → human gate → ERP). - lib/invoice-pdf.ts renders each seeded invoice to a real PDF on demand (app/api/pdf/[id]); no stored binary, can't drift from the data. - lib/extract.ts: vision extraction (PDF → schema-validated Invoice) via the Anthropic SDK + Sonnet, ported from the sibling ai-invoice-parser repo. - The run streams a phase-0 intake event; the UI shows the actual PDF (pdf.js) with a scan sweep and the extracted fields filling in (components/ extraction-reveal.tsx, pdf-document.tsx). - Safety: extraction is shown, but the pipeline runs on the trusted seeded record, so a model misread degrades the reveal, never the verdicts. Fails open on timeout/error. - UX: PDF preview on select/hover (header follows), optimistic running state, trace auto-follow that yields to manual scroll, queue lock during a run. - Strip unsupported JSON-schema keywords (minimum/maximum) for the structured- output model; runtime Zod still enforces them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9e9bb54 commit c523c42

17 files changed

Lines changed: 1239 additions & 90 deletions

File tree

README.md

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,42 @@
11
# ledgerloop
22

3-
An invoice **procure-to-pay** pipeline: 2/3-way matching, approval routing, and reconciliation, with a live execution trace you watch as it runs.
3+
An invoice **procure-to-pay** pipeline: a vendor PDF comes in, gets extracted, matched, routed, and reconciled, with a live execution trace you watch as it runs.
44

5-
The money decisions are deterministic code. An AI agent is used for one thing — investigating a flagged exception, where the answer lives in messy records and there's a real judgment to make. It recommends; a human decides; nothing posts until they do. Built with [Mastra](https://mastra.ai).
5+
AI is used in the two places it earns its keep, and nowhere else. **Extraction** reads the messy vendor PDF into structured data (vision). **Investigation** judges a flagged exception against unstructured records, recommends, and a human decides. Everything in between — matching, approval tiering, reconciliation — is deterministic code, because a payment decision must be exact and repeatable, never a model's guess. Nothing posts until a human approves. Built with [Mastra](https://mastra.ai).
66

77
### ▶︎ [Try the live demo →](https://ledgerloop-eta.vercel.app/)
88

9-
[![CI](https://github.com/DylanMerigaud/ledgerloop/actions/workflows/ci.yml/badge.svg)](https://github.com/DylanMerigaud/ledgerloop/actions/workflows/ci.yml) ![Mastra](https://img.shields.io/badge/agent-Mastra-000000) ![Next.js](https://img.shields.io/badge/Next.js-15-black) ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6) ![model](https://img.shields.io/badge/Claude-Haiku_4.5-4F46E5) ![database](https://img.shields.io/badge/database-Supabase-3ECF8E)
9+
[![CI](https://github.com/DylanMerigaud/ledgerloop/actions/workflows/ci.yml/badge.svg)](https://github.com/DylanMerigaud/ledgerloop/actions/workflows/ci.yml) ![Mastra](https://img.shields.io/badge/agent-Mastra-000000) ![Next.js](https://img.shields.io/badge/Next.js-15-black) ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6) ![models](https://img.shields.io/badge/Claude-Sonnet_4.6_%2B_Haiku_4.5-4F46E5) ![database](https://img.shields.io/badge/database-Supabase-3ECF8E)
1010

1111
---
1212

1313
## What it does
1414

1515
```mermaid
1616
flowchart LR
17-
I([Invoice]) --> M[Matching<br/>deterministic]
17+
PDF([Vendor PDF]) --> X[Extraction AI<br/>vision → structured]
18+
X --> M[Matching<br/>deterministic]
1819
M --> V{Verdict?}
1920
2021
V -- "clean" --> AUTO[Auto-approve<br/>straight-through]
2122
V -- "duplicate" --> BLK[Blocked<br/>never posted]
22-
V -- "exception" --> INV[Investigator AGENT<br/>reads messy records,<br/>recommends]
23+
V -- "exception" --> INV[Investigator AI<br/>reads messy records,<br/>recommends]
2324
2425
INV --> R[Approval routing<br/>deterministic tier]
2526
R --> H{{Human decision<br/>Approve / Reject}}
2627
H -- "approve" --> POST
2728
H -- "reject" --> REJ[Rejected<br/>not posted]
2829
AUTO --> POST[Reconciliation → ERP<br/>vendor bill + GL]
2930
30-
classDef agent fill:#EEF2FF,stroke:#4F46E5,color:#0A0A0A;
31+
classDef ai fill:#EEF2FF,stroke:#4F46E5,color:#0A0A0A;
3132
classDef gate fill:#FEF3C7,stroke:#B45309,color:#0A0A0A;
3233
classDef stop fill:#FEE2E2,stroke:#B91C1C,color:#0A0A0A;
33-
class INV agent;
34+
class X,INV ai;
3435
class H gate;
3536
class BLK,REJ stop;
3637
```
3738

39+
- **Extraction (AI)** — the vendor's invoice PDF is read by a vision model into a schema-validated `Invoice`. You watch the document get scanned and the fields fill in. (The seeded pipeline then runs on the trusted record, so a model misread degrades the reveal, never the downstream verdicts — see below.)
3840
- **Matching** — a 2-way (invoice ↔ PO) or 3-way (invoice ↔ PO ↔ goods receipt) match, returning a verdict: `clean`, `exception`, or `duplicate`.
3941
- **Investigation** — runs only on an exception, and the one step that's an agent. A number ("9% over the PO") doesn't tell a reviewer whether it's a legitimate price increase or an overcharge; that lives in unstructured records, and which records matter depends on what you find. The agent **chooses** which tools to call, reads the records, and writes a recommendation. It decides nothing about the money.
4042
- **Approval routing** — tiers an exception (manager / director by the money and variance at stake) and **pauses for a human**; a duplicate is blocked so it's never paid twice; a clean match skips straight through.
@@ -57,9 +59,11 @@ The split-view dashboard shows the **invoice queue** (color-coded by outcome) an
5759

5860
## How it's built
5961

60-
**Code decides the money; the agent investigates the ambiguity.** Whether an invoice is a 9%-over price variance, a duplicate, or which approval tier applies is arithmetic and policy, not a language problem — so it's pure, unit-tested functions ([`lib/matching.ts`](lib/matching.ts), [`lib/policy.ts`](lib/policy.ts), [`lib/erp.ts`](lib/erp.ts)): exact, auditable, identical on every run. The agent is reserved for the one step where the right sequence of actions isn't knowable in advance — judging a flagged exception against messy records — and even there it only *recommends*. The deterministic routing and the human gate own the outcome, so a wrong recommendation is caught by the reviewer, and a slow or unavailable model just means the run proceeds without the agent's note. Autonomy sits where a mistake is recoverable, not where it isn't.
62+
**AI at the edges, deterministic code in the core.** The two ends of the pipeline are language/perception problems — reading a messy PDF, judging a fuzzy exception — so they use a model. The middle (is this a 9%-over variance? a duplicate? which approval tier?) is arithmetic and policy, so it's pure, unit-tested functions ([`lib/matching.ts`](lib/matching.ts), [`lib/policy.ts`](lib/policy.ts), [`lib/erp.ts`](lib/erp.ts)): exact, auditable, identical on every run. An LLM never decides a payment amount.
6163

62-
**The investigator agent.** [`src/mastra/agents/investigator.ts`](src/mastra/agents/investigator.ts) is a Mastra [`Agent`](https://mastra.ai) with three tools — `get-vendor-price-history`, `get-po-notes`, `get-receipt-notes` — returning deliberately unstructured, free-text records ([`lib/vendor-context.ts`](lib/vendor-context.ts)). It runs an open-ended loop: it picks which tools to call and in what order (a quantity problem pulls the receipt notes first; a price problem pulls the price history), reads them, and writes a recommendation (`likely_legitimate` / `likely_overcharge` / `unclear`). Each tool call and the recommendation appear live on the trace. Tools read the trusted vendor from `requestContext`, not model-generated args, so the agent can't pull the wrong vendor's file. Model id lives in one place ([`src/mastra/model.ts`](src/mastra/model.ts)), resolved by Mastra's router (`"anthropic/claude-haiku-4-5"`).
64+
**Extraction reads the document; the record decides.** The vendor's PDF is rendered ([`lib/invoice-pdf.ts`](lib/invoice-pdf.ts)) and read by a vision model into a schema-validated `Invoice` ([`lib/extract.ts`](lib/extract.ts), `Invoice.safeParse`). Here's the deliberate part: the extracted invoice is *shown* (the reveal proves the read happened), but the downstream pipeline runs on the **trusted seeded record**, not the model's output. So a misread degrades the reveal, never the verdicts — the demo's edge cases stay reliable. It also fails open: if the model is slow or down, the run notes it and proceeds on the record. In production you'd reconcile extraction against the PO and surface the diff; the seam is the same.
65+
66+
**The investigator agent.** [`src/mastra/agents/investigator.ts`](src/mastra/agents/investigator.ts) is a Mastra [`Agent`](https://mastra.ai) with three tools — `get-vendor-price-history`, `get-po-notes`, `get-receipt-notes` — returning deliberately unstructured, free-text records ([`lib/vendor-context.ts`](lib/vendor-context.ts)). It runs an open-ended loop: it picks which tools to call and in what order (a quantity problem pulls the receipt notes first; a price problem pulls the price history), reads them, and writes a recommendation (`likely_legitimate` / `likely_overcharge` / `unclear`). It only *recommends* — the deterministic routing and the human gate own the outcome, so a wrong call is caught by the reviewer. Each tool call and the recommendation appear live on the trace. Tools read the trusted vendor from `requestContext`, not model-generated args, so the agent can't pull the wrong vendor's file. Model id lives in one place ([`src/mastra/model.ts`](src/mastra/model.ts)), resolved by Mastra's router (`"anthropic/claude-haiku-4-5"`).
6367

6468
**A real human-in-the-loop, statelessly.** On an exception the run pauses before reconciliation (`awaiting`) and the ERP post does not happen until a human clicks Approve. The demo never writes to the database, yet a pause normally needs a persisted run to resume — so instead of a stored snapshot, the Approve/Reject click fires a second request that recomputes the cheap deterministic prefix and continues into reconciliation, gated by a `humanApproval` input ([`app/api/run/route.ts`](app/api/run/route.ts)). Mastra's native `suspend`/`resume` would need durable storage across two serverless requests; recomputing a pure prefix is the stateless-friendly choice.
6569

@@ -83,9 +87,14 @@ src/mastra/
8387
testing/ mock model + offline integration tests
8488
lib/
8589
matching.ts · policy.ts · erp.ts pure, unit-tested decision logic
90+
extract.ts vision extraction (invoice PDF → validated Invoice)
91+
invoice-pdf.ts render an Invoice to a PDF (so there's a real doc to read)
8692
vendor-context.ts the messy free-text records the agent reasons over
8793
schema.ts Zod source of truth → types + JSON schema
8894
trace.ts · ndjson.ts stream adapter + framing
95+
app/api/
96+
run/ streams the run (extraction → pipeline → trace)
97+
pdf/[id]/ renders the invoice PDF on demand
8998
db/
9099
schema.ts · seed-data.ts · seed.ts · client.ts Drizzle + read-only query layer
91100
```
@@ -125,7 +134,7 @@ pnpm dev # http://localhost:3000
125134

126135
| Variable | Required | Purpose |
127136
| --- | --- | --- |
128-
| `ANTHROPIC_API_KEY` | **yes** | The investigator agent (Claude Haiku via Mastra's router) |
137+
| `ANTHROPIC_API_KEY` | **yes** | Extraction (Claude Sonnet vision) + the investigator agent (Claude Haiku via Mastra's router) |
129138
| `DATABASE_URL` | **yes** | Supabase Postgres — use the **transaction pooler** string |
130139
| `DIRECT_DATABASE_URL` | optional | Direct (non-pooled) string for `db:push` / `db:seed` |
131140

@@ -142,7 +151,7 @@ This is a stateless demo with a fake ERP; the decision logic (matching, routing,
142151
- swap the fake ERP adapter for a real one (same `ErpAdapter` interface),
143152
- add persistence and an audit trail (the `agent_runs` table is already shaped for it),
144153
- wire real approver identity to the human gate,
145-
- a document-extraction intake step (PDF/email → `Invoice`) ahead of matching,
154+
- make extraction authoritative — reconcile the extracted invoice against the PO and surface the diff for review, rather than running on the seeded record,
146155
- batch processing across the whole queue.
147156

148157
---

app/api/pdf/[id]/route.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { loadInvoiceById } from "@/db/client";
2+
import { renderInvoicePdf } from "@/lib/invoice-pdf";
3+
4+
/**
5+
* GET /api/pdf/[id] — render the seeded invoice as a PDF on demand.
6+
*
7+
* The PDF isn't stored: it's generated deterministically from the seeded invoice
8+
* row each request (same invoice → same bytes), so there's no extra DB column,
9+
* no migration, and the document can never drift from the data. The intake
10+
* extraction reveal (and the queue hover preview) fetch this, render it with
11+
* pdf.js, and the model reads the same bytes.
12+
*
13+
* Only the invoice is loaded (`loadInvoiceById`), not the whole run bundle — the
14+
* document doesn't need the PO / receipt / ledger. Because the bytes are
15+
* deterministic and the data is read-only seed data, the response is cacheable,
16+
* so re-hovering or re-selecting an invoice is served from the browser cache
17+
* instead of regenerating the PDF.
18+
*
19+
* Node runtime — pdf-lib runs in Node, not Edge.
20+
*/
21+
22+
export const runtime = "nodejs";
23+
24+
export async function GET(
25+
_request: Request,
26+
{ params }: { params: Promise<{ id: string }> },
27+
): Promise<Response> {
28+
const { id } = await params;
29+
30+
let invoice: Awaited<ReturnType<typeof loadInvoiceById>>;
31+
try {
32+
invoice = await loadInvoiceById(id);
33+
} catch {
34+
return new Response("Could not load the invoice.", { status: 500 });
35+
}
36+
if (!invoice) {
37+
return new Response(`No seeded invoice with id "${id}".`, { status: 404 });
38+
}
39+
40+
const bytes = await renderInvoicePdf(invoice);
41+
// Copy into a fresh ArrayBuffer so the body is a plain BodyInit (not a typed
42+
// array view over a possibly-larger buffer).
43+
const body = bytes.slice().buffer;
44+
return new Response(body, {
45+
status: 200,
46+
headers: {
47+
"content-type": "application/pdf",
48+
// Deterministic bytes from read-only seed data → safe to cache. Re-hovering
49+
// a seen invoice is then instant (served from cache, no regeneration).
50+
"cache-control": "public, max-age=3600, immutable",
51+
},
52+
});
53+
}

app/api/run/route.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { loadRunBundle } from "@/db/client";
33
import { toTraceEvent, pipelineErrorEvent, type TraceEvent } from "@/lib/trace";
44
import { ndjsonLine } from "@/lib/ndjson";
55
import { checkRateLimit, clientIpFrom } from "@/lib/ratelimit";
6+
import { renderInvoicePdfBase64 } from "@/lib/invoice-pdf";
7+
import { extractInvoice } from "@/lib/extract";
8+
import type { Invoice } from "@/lib/schema";
69
import {
710
RunRequest,
811
STREAM_CONTENT_TYPE,
@@ -16,8 +19,9 @@ import {
1619
* Runtime: NODE, not Edge. The Postgres driver needs raw TCP sockets that the
1720
* Edge runtime doesn't provide. Vercel's Node functions support HTTP response
1821
* streaming AND a configurable maxDuration, which meets the real goal — a long-
19-
* enough, non-timing-out stream — while keeping the DB driver working. A run is a
20-
* few seconds (one short agent call, only on an exception), so 60s is ample.
22+
* enough, non-timing-out stream — while keeping the DB driver working. A run is
23+
* the intake extraction (one vision call, capped at 20s) plus the deterministic
24+
* pipeline and, on an exception, one short agent call — comfortably inside 60s.
2125
*
2226
* STATELESS BY DESIGN: this reads the seeded invoice/PO/receipt, runs the steps,
2327
* streams the trace, and FORGETS. It never writes to the database — not the
@@ -39,6 +43,86 @@ function line(obj: unknown): Uint8Array {
3943
return new TextEncoder().encode(ndjsonLine(obj));
4044
}
4145

46+
/** Hard cap on the extraction call so a slow model never stalls the demo. */
47+
const EXTRACTION_TIMEOUT_MS = 20_000;
48+
49+
/**
50+
* Phase-0 intake extraction. Renders the seeded invoice to a PDF, has the model
51+
* read it back, and emits intake trace events carrying the extracted invoice (for
52+
* the reveal) — then leaves the pipeline to run on the trusted seed. Never throws:
53+
* any failure becomes a "could not extract, proceeding on record" note.
54+
*/
55+
async function emitExtraction(
56+
emit: (e: Omit<TraceEvent, "seq" | "atMs">) => void,
57+
seed: Invoice,
58+
): Promise<void> {
59+
emit({
60+
kind: "step",
61+
stage: "intake",
62+
status: "running",
63+
stepId: "intake",
64+
label: "Intake — reading invoice PDF",
65+
detail: "Extracting the document with the vision model…",
66+
// Carry the document so the reveal can render + scan it immediately, before
67+
// the model returns. (This is the on-screen twin of the PDF being read.)
68+
data: { document: seed },
69+
});
70+
71+
try {
72+
const pdfBase64 = await renderInvoicePdfBase64(seed);
73+
const result = await Promise.race([
74+
extractInvoice(pdfBase64),
75+
new Promise<{ ok: false; kind: "timeout" }>((resolve) =>
76+
setTimeout(
77+
() => resolve({ ok: false, kind: "timeout" }),
78+
EXTRACTION_TIMEOUT_MS,
79+
),
80+
),
81+
]);
82+
83+
if (result.ok) {
84+
// Did the model's read agree with the seeded record on the key fields the
85+
// pipeline routes on? (It drives a "validated against PO record" note.)
86+
const matches =
87+
result.invoice.invoiceNumber === seed.invoiceNumber &&
88+
(result.invoice.poNumber ?? null) === (seed.poNumber ?? null) &&
89+
Math.abs(result.invoice.total - seed.total) < 0.01;
90+
emit({
91+
kind: "step",
92+
stage: "intake",
93+
status: "ok",
94+
stepId: "intake",
95+
label: "Intake — extracted",
96+
detail: matches
97+
? "Extracted and reconciled with the PO record."
98+
: "Extracted; key fields differ from the record (pipeline uses the record).",
99+
data: { extracted: result.invoice, matches },
100+
});
101+
return;
102+
}
103+
104+
// Extraction failed/timed out — proceed on the seed.
105+
emit({
106+
kind: "step",
107+
stage: "intake",
108+
status: "warn",
109+
stepId: "intake",
110+
label: "Intake — using record",
111+
detail:
112+
"Couldn't extract the document just now; proceeding on the seeded record.",
113+
});
114+
} catch {
115+
emit({
116+
kind: "step",
117+
stage: "intake",
118+
status: "warn",
119+
stepId: "intake",
120+
label: "Intake — using record",
121+
detail: "Extraction unavailable; proceeding on the seeded record.",
122+
});
123+
}
124+
}
125+
42126
export async function POST(request: Request): Promise<Response> {
43127
// 0. Rate-limit by IP first — before any work or model calls. The demo is
44128
// public and each run spends Anthropic tokens, so this caps abuse. Fails
@@ -117,6 +201,17 @@ export async function POST(request: Request): Promise<Response> {
117201
controller.enqueue(line(stamp(e)));
118202

119203
try {
204+
// Phase 0: INTAKE EXTRACTION. Only on the first request (not the
205+
// Approve/Reject resume — re-extracting then would be wasted work). The
206+
// agent reads the rendered invoice PDF and we surface what it extracted.
207+
// The extracted invoice is shown to prove the read happened; the pipeline
208+
// below runs on the TRUSTED seeded record, so a model misread degrades the
209+
// reveal, never the deterministic verdicts. Fails open: if extraction
210+
// errors or times out, we note it and proceed on the seed.
211+
if (decision === undefined) {
212+
await emitExtraction(emit, bundle.invoice);
213+
}
214+
120215
const workflow = mastra.getWorkflow("p2p");
121216
const run = await workflow.createRun();
122217
const result = run.stream({

app/globals.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,26 @@ body {
8484
.animate-pulse-ring {
8585
animation: pulse-ring 1.4s ease-out infinite;
8686
}
87+
88+
/* Extraction reveal: a scan band sweeps down the document while the model reads
89+
it, so the intake step reads as "the AI is looking at the page". */
90+
@keyframes scan {
91+
0% {
92+
transform: translateY(-4rem);
93+
opacity: 0;
94+
}
95+
15% {
96+
opacity: 1;
97+
}
98+
85% {
99+
opacity: 1;
100+
}
101+
100% {
102+
transform: translateY(420px);
103+
opacity: 0;
104+
}
105+
}
106+
107+
.animate-scan {
108+
animation: scan 1.6s ease-in-out infinite;
109+
}

0 commit comments

Comments
 (0)