You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: README.md
+20-11Lines changed: 20 additions & 11 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,40 +1,42 @@
1
1
# ledgerloop
2
2
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.
4
4
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).
6
6
7
7
### ▶︎ [Try the live demo →](https://ledgerloop-eta.vercel.app/)
-**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.)
38
40
-**Matching** — a 2-way (invoice ↔ PO) or 3-way (invoice ↔ PO ↔ goods receipt) match, returning a verdict: `clean`, `exception`, or `duplicate`.
39
41
-**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.
40
42
-**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
57
59
58
60
## How it's built
59
61
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.
61
63
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"`).
63
67
64
68
**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.
@@ -125,7 +134,7 @@ pnpm dev # http://localhost:3000
125
134
126
135
| Variable | Required | Purpose |
127
136
| --- | --- | --- |
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) |
129
138
|`DATABASE_URL`|**yes**| Supabase Postgres — use the **transaction pooler** string |
130
139
|`DIRECT_DATABASE_URL`| optional | Direct (non-pooled) string for `db:push` / `db:seed`|
131
140
@@ -142,7 +151,7 @@ This is a stateless demo with a fake ERP; the decision logic (matching, routing,
142
151
- swap the fake ERP adapter for a real one (same `ErpAdapter` interface),
143
152
- add persistence and an audit trail (the `agent_runs` table is already shaped for it),
144
153
- 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,
0 commit comments