Skip to content

Commit 27409ac

Browse files
DylanMerigaudclaude
andcommitted
Run the pipeline on the extracted invoice, not the seed
The extraction was theatre: the vision model read the PDF but its output was discarded — matching ran on the seeded record. Now the extracted invoice is what the pipeline runs on (matching joins the extracted lines against the PO/receipt), so the data genuinely comes from the read, like production. - The demo PDFs now print the SKU (lib/invoice-pdf.ts) — matching joins on it, so the model has to read the item code off the page; without it every line missed its PO line and clean invoices turned into exceptions. Verified: all 10 seeded cases produce the same verdict from extracted data as from the record. - Intake fails closed: a failed/timed-out/invalid read stops the run with an error, no silent fallback to a stored record (lib/intake.ts). - lib/intake.ts is the injectable core; lib/intake.test.ts is the "mock vision" coverage (success, header-mismatch, validation/refusal/timeout/throw). - The offline pipeline test runs with skipExtraction so it stays key-free. - README/comments corrected — nothing claims the pipeline runs on the seed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bf0b859 commit 27409ac

9 files changed

Lines changed: 289 additions & 82 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ flowchart LR
3838
class BLK,REJ stop;
3939
```
4040

41-
- **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.)
41+
- **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. That extracted invoice is what the rest of the pipeline runs on — matching joins the extracted lines against the PO, like production.
4242
- **Matching** — a 2-way (invoice ↔ PO) or 3-way (invoice ↔ PO ↔ goods receipt) match, returning a verdict: `clean`, `exception`, or `duplicate`.
4343
- **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.
4444
- **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.
@@ -63,7 +63,7 @@ The split-view dashboard shows the **invoice queue** (color-coded by outcome) an
6363

6464
**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.
6565

66-
**Extraction reads the document; the record decides.** Extraction is the **first step of the Mastra workflow** (`intake`): 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`) — so it's one pipeline end to end, from PDF to ERP, not a step bolted on outside. Here's the deliberate part: the extracted invoice is *shown* (the reveal proves the read happened), but the downstream steps run 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 step notes it and the pipeline proceeds on the record. In production you'd reconcile extraction against the PO and surface the diff; the seam is the same.
66+
**Extraction feeds the pipeline; the document is the source of truth.** Extraction is the **first step of the Mastra workflow** (`intake`): 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`). The **extracted invoice is what the rest of the pipeline runs on** — matching joins the extracted lines against the PO and goods receipt (which come from the DB, as they'd come from the ERP). One pipeline end to end, PDF to ERP, and the data genuinely comes from the read. Like production: if the read fails (timeout, refusal, invalid output), the run stops with an error rather than fabricating data — there's no silent fallback to a stored record. (The seeded invoice is only what we render the PDF from — the stand-in for "a vendor PDF arrived" — and it's why the demo's PDFs carry SKUs: matching joins on them, so the model has to read the item code off the page.)
6767

6868
**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"`).
6969

@@ -153,7 +153,7 @@ This is a stateless demo with a fake ERP; the decision logic (matching, routing,
153153
- swap the fake ERP adapter for a real one (same `ErpAdapter` interface),
154154
- add persistence and an audit trail (the `agent_runs` table is already shaped for it),
155155
- wire real approver identity to the human gate,
156-
- make extraction authoritative — reconcile the extracted invoice against the PO and surface the diff for review, rather than running on the seeded record,
156+
- accept real uploaded PDFs at intake (today the demo renders its own from seed data),
157157
- batch processing across the whole queue.
158158

159159
---

components/extraction-reveal.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,18 @@ import type { Invoice } from "@/lib/schema";
1313
*
1414
* Two states, driven by the intake trace node:
1515
* • running — scanning: the sweep animates, fields are still "reading…"
16-
* • done — the extracted fields are shown; matching ones are checked.
16+
* • done — the extracted fields are shown; a badge says whether the header
17+
* reconciled with the PO record.
1718
*
18-
* The downstream pipeline runs on the trusted seeded record, so a model misread
19-
* degrades this reveal, never the verdicts.
19+
* The extracted invoice is what the rest of the pipeline runs on (matching joins
20+
* it against the PO) — the data on screen is the data that drives the verdicts.
2021
*/
2122

2223
export interface ExtractionState {
2324
status: "running" | "done";
2425
/** The invoice the model extracted (present once done). */
2526
extracted: Invoice | null;
26-
/** Did the extracted key fields reconcile with the seeded record? */
27+
/** Did the extracted header reconcile with the PO record? */
2728
matches: boolean;
2829
}
2930

lib/extract.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ import {
1616
* model reads the PDF directly (vision); nothing is guessed off filenames.
1717
*
1818
* Result is a tagged union so callers map each failure to a state instead of a
19-
* thrown error. Note: in the demo, the extracted invoice is shown to prove the
20-
* read happened, but the downstream pipeline runs on the trusted seeded record —
21-
* so a model misread degrades the reveal, never the deterministic verdicts.
19+
* thrown error. The extracted invoice is what the pipeline then runs on (see
20+
* `lib/intake.ts`); a failure stops the run rather than fabricating data.
2221
*/
2322

2423
/** Sonnet reads the PDF directly via vision; extraction is transcription, not reasoning. */
@@ -40,7 +39,7 @@ Rules:
4039
- Numbers are plain JSON numbers — no currency symbols, no thousands separators, a period as decimal.
4140
- "lineItems" is one entry per billed line; "amount" is that line's total.
4241
- "subtotal" is the pre-tax sum; "total" is the final amount due. Transcribe the numbers as printed, even if they don't add up — a downstream checker flags inconsistencies. Do NOT silently fix the math.
43-
- Each line item needs a "sku": use the printed code if present, else a short slug of the description.`;
42+
- Each line item needs a "sku": copy the printed item/SKU code for that line EXACTLY as shown (e.g. the "Item" column). Only if no code is printed, fall back to a short slug of the description.`;
4443

4544
class MissingApiKeyError extends Error {
4645
constructor() {

lib/intake.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { runIntake, type Extractor } from "./intake";
4+
import type { Invoice } from "./schema";
5+
6+
/**
7+
* Intake tests — the "mock vision" coverage. The real extraction calls the
8+
* Anthropic vision API; here we inject a mock extractor so the whole intake path
9+
* (render → extract → reconcile-with-record / fail) is exercised offline.
10+
*/
11+
12+
const SOURCE: Invoice = {
13+
invoiceNumber: "INV-9001",
14+
poNumber: "PO-9001",
15+
vendor: "Acme Corp",
16+
issueDate: "2026-05-01",
17+
currency: "USD",
18+
lineItems: [
19+
{ sku: "A-1", description: "Widget", qty: 2, unitPrice: 10, amount: 20 },
20+
],
21+
subtotal: 20,
22+
tax: null,
23+
total: 20,
24+
};
25+
26+
// A render stub so no PDF is actually generated.
27+
const render = async () => "ZmFrZS1wZGY=";
28+
29+
/** An extractor that returns a given invoice (or failure). */
30+
function mockExtractor(invoice: Invoice): Extractor {
31+
return async () => ({ ok: true, invoice });
32+
}
33+
34+
test("successful extraction returns the extracted invoice", async () => {
35+
const extracted: Invoice = { ...SOURCE, vendor: "Acme Corporation Ltd" };
36+
const res = await runIntake(SOURCE, {
37+
extract: mockExtractor(extracted),
38+
render,
39+
});
40+
assert.equal(res.ok, true);
41+
if (res.ok) {
42+
assert.equal(res.invoice.vendor, "Acme Corporation Ltd");
43+
// Header reconciles (invoiceNumber + poNumber + total all match the record).
44+
assert.equal(res.matchesRecord, true);
45+
}
46+
});
47+
48+
test("extracted header differing from the record → matchesRecord false", async () => {
49+
const extracted: Invoice = { ...SOURCE, total: 999 };
50+
const res = await runIntake(SOURCE, {
51+
extract: mockExtractor(extracted),
52+
render,
53+
});
54+
assert.equal(res.ok, true);
55+
if (res.ok) assert.equal(res.matchesRecord, false);
56+
});
57+
58+
test("the pipeline runs on the EXTRACTED data, not the source", async () => {
59+
// The model reads a different unit price than the record — runIntake must
60+
// return the model's number (that's the whole point: data comes from the read).
61+
const extracted: Invoice = {
62+
...SOURCE,
63+
lineItems: [{ ...SOURCE.lineItems[0]!, unitPrice: 11, amount: 22 }],
64+
subtotal: 22,
65+
total: 22,
66+
};
67+
const res = await runIntake(SOURCE, {
68+
extract: mockExtractor(extracted),
69+
render,
70+
});
71+
assert.equal(res.ok, true);
72+
if (res.ok) assert.equal(res.invoice.lineItems[0]?.unitPrice, 11);
73+
});
74+
75+
test("a validation failure surfaces as a failure (no fabricated data)", async () => {
76+
const res = await runIntake(SOURCE, {
77+
extract: async () => ({
78+
ok: false,
79+
kind: "validation",
80+
issues: ["total: expected a number"],
81+
}),
82+
render,
83+
});
84+
assert.equal(res.ok, false);
85+
if (!res.ok) assert.match(res.reason, /validation/i);
86+
});
87+
88+
test("a refusal surfaces as a failure", async () => {
89+
const res = await runIntake(SOURCE, {
90+
extract: async () => ({ ok: false, kind: "refusal" }),
91+
render,
92+
});
93+
assert.equal(res.ok, false);
94+
});
95+
96+
test("a timeout surfaces as a failure (does not hang)", async () => {
97+
const res = await runIntake(SOURCE, {
98+
// Never resolves → the internal timeout must win.
99+
extract: () => new Promise(() => {}),
100+
render,
101+
timeoutMs: 20,
102+
});
103+
assert.equal(res.ok, false);
104+
});
105+
106+
test("a thrown extractor is caught, not propagated", async () => {
107+
const res = await runIntake(SOURCE, {
108+
extract: async () => {
109+
throw new Error("network down");
110+
},
111+
render,
112+
});
113+
assert.equal(res.ok, false);
114+
});

lib/intake.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Invoice } from "./schema";
2+
import { extractInvoice, type ExtractionResult } from "./extract";
3+
import { renderInvoicePdfBase64 } from "./invoice-pdf";
4+
5+
/**
6+
* The intake core — render the source document to a PDF, read it back with the
7+
* vision model, and return the structured invoice the pipeline will run on.
8+
*
9+
* This is what makes the extraction REAL: the downstream matching runs on what
10+
* the model extracted, not on the seeded record. Like production — the document
11+
* is the source of truth; if the read fails, you don't invent data, the run
12+
* can't proceed (the caller surfaces an error). The seeded record is only the
13+
* thing we render the PDF from (our stand-in for "a vendor PDF arrived").
14+
*
15+
* The extractor is injectable so tests can run the whole pipeline offline with a
16+
* mock instead of a live vision call.
17+
*/
18+
19+
/** Render `source` to a PDF and extract it; same tagged result as `extractInvoice`. */
20+
export type Extractor = (pdfBase64: string) => Promise<ExtractionResult>;
21+
22+
interface IntakeOk {
23+
ok: true;
24+
/** The invoice the pipeline runs on (the model's output). */
25+
invoice: Invoice;
26+
/** True when the extracted header reconciles with the source record. */
27+
matchesRecord: boolean;
28+
}
29+
interface IntakeFail {
30+
ok: false;
31+
reason: string;
32+
}
33+
export type IntakeResult = IntakeOk | IntakeFail;
34+
35+
const DEFAULT_TIMEOUT_MS = 20_000;
36+
37+
/**
38+
* Read `source` (the seeded record we render to a PDF) into a structured invoice.
39+
* Returns the extracted invoice on success, or a failure the caller turns into an
40+
* error trace event. `extract` + `render` are injectable for offline tests.
41+
*/
42+
export async function runIntake(
43+
source: Invoice,
44+
opts: {
45+
extract?: Extractor;
46+
render?: (inv: Invoice) => Promise<string>;
47+
timeoutMs?: number;
48+
} = {},
49+
): Promise<IntakeResult> {
50+
const extract = opts.extract ?? extractInvoice;
51+
const render = opts.render ?? renderInvoicePdfBase64;
52+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
53+
54+
let result: ExtractionResult;
55+
try {
56+
const pdfBase64 = await render(source);
57+
result = await Promise.race([
58+
extract(pdfBase64),
59+
new Promise<ExtractionResult>((resolve) =>
60+
setTimeout(
61+
() =>
62+
resolve({
63+
ok: false,
64+
kind: "api_error",
65+
message: "extraction timed out",
66+
}),
67+
timeoutMs,
68+
),
69+
),
70+
]);
71+
} catch {
72+
return { ok: false, reason: "Could not read the document." };
73+
}
74+
75+
if (!result.ok) {
76+
return {
77+
ok: false,
78+
reason:
79+
result.kind === "validation"
80+
? "Extracted data failed validation."
81+
: result.kind === "refusal"
82+
? "The model declined to read the document."
83+
: result.kind === "no_json"
84+
? "The model returned no structured data."
85+
: "Could not read the document.",
86+
};
87+
}
88+
89+
// Did the extracted header reconcile with the source record on the key fields?
90+
// (A clean signal for the reveal; the line-level match is the matching step.)
91+
const matchesRecord =
92+
result.invoice.invoiceNumber === source.invoiceNumber &&
93+
(result.invoice.poNumber ?? null) === (source.poNumber ?? null) &&
94+
Math.abs(result.invoice.total - source.total) < 0.01;
95+
96+
return { ok: true, invoice: result.invoice, matchesRecord };
97+
}

lib/invoice-pdf.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,16 @@ export async function renderInvoicePdf(invoice: Invoice): Promise<Uint8Array> {
7777
}
7878

7979
// ── Line-item table ───────────────────────────────────────────────────────
80+
// The SKU column is printed because matching joins invoice ↔ PO ↔ receipt by
81+
// SKU — the extraction has to be able to read the item code off the document
82+
// (a real invoice carries one), otherwise the downstream match has no key.
8083
y += 24;
81-
const colDesc = MARGIN;
82-
const colQty = 330;
83-
const colUnit = 400;
84+
const colSku = MARGIN;
85+
const colDesc = MARGIN + 110;
86+
const colQty = 360;
87+
const colUnit = 420;
8488
const colAmt = PAGE_W - MARGIN;
89+
draw("Item", colSku, y, bold, 9, muted);
8590
draw("Description", colDesc, y, bold, 9, muted);
8691
draw("Qty", colQty, y, bold, 9, muted);
8792
draw("Unit price", colUnit, y, bold, 9, muted);
@@ -96,6 +101,7 @@ export async function renderInvoicePdf(invoice: Invoice): Promise<Uint8Array> {
96101
y += 18;
97102

98103
for (const li of invoice.lineItems) {
104+
draw(li.sku, colSku, y, font, 9);
99105
draw(li.description, colDesc, y, font, 10);
100106
draw(String(li.qty), colQty, y, font, 10);
101107
draw(money(li.unitPrice, invoice.currency), colUnit, y, font, 10);

lib/trace.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -241,22 +241,26 @@ export function toTraceEvent(
241241
}
242242

243243
if (innerType === "intake-result") {
244-
// The extraction result (the extracted invoice + whether it reconciled
245-
// with the record), written when intake finishes. Upserts the same
246-
// intake node. A failed extraction (extracted = null) is a soft warn.
247-
const extracted = innerPayload?.["extracted"] ?? null;
244+
// The intake result (`runIntake`): on success the extracted invoice +
245+
// whether its header reconciled with the record; on failure a reason.
246+
// Upserts the same intake node.
247+
const ok = innerPayload?.["ok"] === true;
248+
const extracted = ok ? (innerPayload?.["invoice"] ?? null) : null;
249+
const matches = innerPayload?.["matchesRecord"] === true;
248250
return {
249251
kind: "step",
250252
stage: "intake",
251-
status: extracted ? "ok" : "warn",
253+
status: ok ? "ok" : "error",
252254
stepId: "intake",
253-
label: extracted ? "Intake — extracted" : "Intake — using record",
254-
detail: extracted
255-
? innerPayload?.["matches"] === true
256-
? "Extracted and reconciled with the PO record."
257-
: "Extracted; key fields differ from the record (pipeline uses the record)."
258-
: "Couldn't extract the document; proceeding on the seeded record.",
259-
data: { extracted, matches: innerPayload?.["matches"] === true },
255+
label: ok ? "Intake — extracted" : "Intake — failed",
256+
detail: ok
257+
? matches
258+
? "Read the document and reconciled it with the PO record."
259+
: "Read the document; header differs from the PO record."
260+
: typeof innerPayload?.["reason"] === "string"
261+
? (innerPayload["reason"] as string)
262+
: "Could not read the document.",
263+
data: { extracted, matches },
260264
};
261265
}
262266

src/mastra/testing/pipeline-trace.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ async function runTrace(mastra: Mastra, b: SeedBundle) {
5858
purchaseOrder: b.purchaseOrder ?? null,
5959
goodsReceipt: b.goodsReceipt ?? null,
6060
priorInvoiceNumbers,
61+
// Skip the intake vision call — this test runs offline (no API key) and is
62+
// about the matching → investigation → routing wiring, not extraction.
63+
// The intake step + runIntake have their own test (lib/intake.test.ts).
64+
skipExtraction: true,
6165
},
6266
});
6367
const raw: unknown[] = [];

0 commit comments

Comments
 (0)