Skip to content

Commit 5758672

Browse files
DylanMerigaudclaude
andcommitted
Make extraction the first step of the Mastra workflow
Extraction was running in the API route before the workflow — an arbitrary split that left "extraction" outside the orchestration. It's now the workflow's intake step, so the pipeline is one Mastra flow end to end (PDF → extract → match → investigate → approve → reconcile). - intakeStep renders the PDF + runs vision extraction, writes an intake-document chunk early so the reveal shows the page immediately, and passes the input through unchanged (the pipeline still runs on the trusted seeded record). - Fails open: a timeout/error proceeds on the record (now proven offline too, since the mock-model integration test runs intake with no API key). - A phase-2 resume sets skipExtraction so approve/reject doesn't re-run the vision call (3s vs 15s). - Route no longer does extraction; trace adapter surfaces the intake step's document + extracted result. README/comments updated — nothing says extraction is outside Mastra. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5639d15 commit 5758672

4 files changed

Lines changed: 133 additions & 104 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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.** 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.
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.
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

app/api/run/route.ts

Lines changed: 4 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ 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";
96
import {
107
RunRequest,
118
STREAM_CONTENT_TYPE,
@@ -43,86 +40,6 @@ function line(obj: unknown): Uint8Array {
4340
return new TextEncoder().encode(ndjsonLine(obj));
4441
}
4542

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-
12643
export async function POST(request: Request): Promise<Response> {
12744
// 0. Rate-limit by IP first — before any work or model calls. The demo is
12845
// public and each run spends Anthropic tokens, so this caps abuse. Fails
@@ -201,17 +118,6 @@ export async function POST(request: Request): Promise<Response> {
201118
controller.enqueue(line(stamp(e)));
202119

203120
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-
215121
const workflow = mastra.getWorkflow("p2p");
216122
const run = await workflow.createRun();
217123
const result = run.stream({
@@ -221,6 +127,10 @@ export async function POST(request: Request): Promise<Response> {
221127
goodsReceipt: bundle.goodsReceipt,
222128
priorInvoiceNumbers: bundle.priorInvoiceNumbers,
223129
humanApproval,
130+
// On a phase-2 resume (approve/reject) the workflow re-runs from the
131+
// top, but the document was already read — skip the (costly) vision
132+
// call the second time. The reveal from phase 1 still stands.
133+
skipExtraction: decision !== undefined,
224134
},
225135
});
226136

lib/trace.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,20 @@ export function toTraceEvent(
222222
};
223223
}
224224

225+
if (innerType === "intake-document") {
226+
// The intake step writes this the instant it starts so the reveal can
227+
// show the document + scan before the vision model returns.
228+
return {
229+
kind: "step",
230+
stage: "intake",
231+
status: "running",
232+
stepId: "intake",
233+
label: "Intake — reading invoice PDF",
234+
detail: "Extracting the document with the vision model…",
235+
data: { document: asRecord(innerPayload?.["document"]) },
236+
};
237+
}
238+
225239
return null;
226240
}
227241

@@ -245,14 +259,31 @@ export function toTraceEvent(
245259
case "workflow-step-finish": {
246260
if (isMappingStep(stepId)) return null; // hide the .map() plumbing step
247261
const rawOut = asRecord(payload?.["output"]);
248-
// Steps emit a domain object plus a `narration` string, and some wrap the
249-
// domain object (the approval step outputs `{ decision, match, vendor }`).
250-
// `domain` is the object the UI should colour + render; `narration` is the
251-
// agent's prose. Unwrapping here keeps the UI decoupled from step shapes.
252262
const narration =
253263
typeof rawOut?.["narration"] === "string"
254264
? (rawOut["narration"] as string)
255265
: undefined;
266+
267+
// Intake carries the extraction result for the reveal (extracted invoice
268+
// + whether it reconciled with the record). Surface just that as the
269+
// node's data; a failed extraction (extracted = null) is a soft warn.
270+
if (stage === "intake") {
271+
const extracted = rawOut?.["extracted"];
272+
return {
273+
kind: "step",
274+
stage: "intake",
275+
status: extracted ? "ok" : "warn",
276+
stepId: "intake",
277+
label: extracted ? "Intake — extracted" : "Intake — using record",
278+
detail: narration,
279+
data: { extracted, matches: rawOut?.["matches"] === true },
280+
};
281+
}
282+
283+
// Other steps emit a domain object plus a `narration` string, and some
284+
// wrap the domain object (approval outputs `{ decision, match, vendor }`).
285+
// `domain` is the object the UI colours + renders. Unwrapping here keeps
286+
// the UI decoupled from step shapes.
256287
const domain = unwrapStageData(rawOut);
257288
return {
258289
kind: "step",

src/mastra/workflows/p2p.ts

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { runMatch } from "@/lib/matching";
1414
import { routeApproval } from "@/lib/policy";
1515
import { reconcile } from "@/lib/erp";
1616
import { runInvestigation, type InvestigatorAgent } from "@/lib/investigation";
17+
import { renderInvoicePdfBase64 } from "@/lib/invoice-pdf";
18+
import { extractInvoice } from "@/lib/extract";
1719
import { CTX } from "../tools/context";
1820

1921
/**
@@ -47,15 +49,100 @@ const RunInput = z.object({
4749
goodsReceipt: GoodsReceipt.nullable(),
4850
priorInvoiceNumbers: z.array(z.string()),
4951
humanApproval: z.enum(["pending", "approve", "reject"]).default("pending"),
52+
/* On a phase-2 resume the document was already read in phase 1 — skip the
53+
vision call so an approve/reject doesn't re-extract (wasted cost + latency). */
54+
skipExtraction: z.boolean().default(false),
5055
});
5156

5257
/* A narration field every stage adds for the trace. */
5358
const Narrated = z.object({ narration: z.string() });
5459

55-
/* ── Step 1: Matching ───────────────────────────────────────────────────────
56-
The first workflow step. (Intake — the document extraction — runs in the route
57-
before the workflow and is surfaced on the trace there; see app/api/run.) The
58-
authoritative verdict comes from the pure matcher; its `verdict` drives the
60+
/* Hard cap on the extraction call so a slow model never stalls a run. */
61+
const EXTRACTION_TIMEOUT_MS = 20_000;
62+
63+
/* ── Step 1: Intake (document extraction) ───────────────────────────────────
64+
The first workflow step: render the invoice to a PDF and have the vision model
65+
read it back into structured data. This is one of the two AI touch-points (the
66+
other is the exception investigator); the rest of the pipeline is deterministic.
67+
68+
Deliberate split: the extraction is SHOWN (the reveal proves the read happened),
69+
but the pipeline downstream runs on the TRUSTED seeded record, so a model
70+
misread degrades the reveal, never the verdicts — the seeded edge cases stay
71+
reliable. So intake passes the input through UNCHANGED to matching, and only
72+
surfaces what it extracted for the trace. Fails open: a timeout/error just
73+
notes it and proceeds on the record.
74+
75+
The reveal needs the document on screen the instant the run starts (before the
76+
model returns), so we write an `intake-document` chunk early via the stream
77+
writer; the step's final output then carries `{ extracted, matches }`. */
78+
const IntakeOut = RunInput.merge(Narrated).merge(
79+
z.object({
80+
extracted: Invoice.nullable(),
81+
matches: z.boolean(),
82+
}),
83+
);
84+
const intakeStep = createStep({
85+
id: "intake",
86+
inputSchema: RunInput,
87+
outputSchema: IntakeOut,
88+
execute: async ({ inputData, writer }) => {
89+
const seed = inputData.invoice;
90+
91+
// Phase-2 resume: the document was already read in phase 1, so skip the vision
92+
// call and pass straight through (the reveal from phase 1 still stands).
93+
if (inputData.skipExtraction) {
94+
return { ...inputData, extracted: null, matches: false, narration: "" };
95+
}
96+
97+
// Show the document immediately (the on-screen twin of the PDF being read).
98+
try {
99+
await writer?.write({
100+
type: "intake-document",
101+
payload: { document: seed },
102+
});
103+
} catch {
104+
/* ignore writer errors — never let the trace affect the result */
105+
}
106+
107+
let extracted: Invoice | null = null;
108+
let matches = false;
109+
let narration =
110+
"Couldn't extract the document just now; proceeding on the seeded record.";
111+
try {
112+
const pdfBase64 = await renderInvoicePdfBase64(seed);
113+
const result = await Promise.race([
114+
extractInvoice(pdfBase64),
115+
new Promise<{ ok: false; kind: "timeout" }>((resolve) =>
116+
setTimeout(
117+
() => resolve({ ok: false, kind: "timeout" }),
118+
EXTRACTION_TIMEOUT_MS,
119+
),
120+
),
121+
]);
122+
if (result.ok) {
123+
extracted = result.invoice;
124+
// Did the model's read agree with the seeded record on the fields the
125+
// pipeline routes on? (Drives the "reconciled with PO record" note.)
126+
matches =
127+
result.invoice.invoiceNumber === seed.invoiceNumber &&
128+
(result.invoice.poNumber ?? null) === (seed.poNumber ?? null) &&
129+
Math.abs(result.invoice.total - seed.total) < 0.01;
130+
narration = matches
131+
? "Extracted and reconciled with the PO record."
132+
: "Extracted; key fields differ from the record (pipeline uses the record).";
133+
}
134+
} catch {
135+
/* fail open — narration already set to the fallback */
136+
}
137+
138+
// Pass the input through unchanged (matching runs on the trusted record);
139+
// `extracted`/`matches` ride along only for the trace/reveal.
140+
return { ...inputData, extracted, matches, narration };
141+
},
142+
});
143+
144+
/* ── Step 2: Matching ───────────────────────────────────────────────────────
145+
The authoritative verdict comes from the pure matcher; its `verdict` drives the
59146
branch below. We carry the vendor + the reviewer's decision forward. */
60147
const HumanApproval = z.object({
61148
humanApproval: z.enum(["pending", "approve", "reject"]),
@@ -65,7 +152,7 @@ const MatchStepOut = MatchResult.merge(Narrated)
65152
.merge(HumanApproval);
66153
const matchingStep = createStep({
67154
id: "matching",
68-
inputSchema: RunInput,
155+
inputSchema: IntakeOut,
69156
outputSchema: MatchStepOut,
70157
execute: async ({ inputData }) => {
71158
const match = runMatch({
@@ -252,6 +339,7 @@ export const p2pWorkflow = createWorkflow({
252339
inputSchema: RunInput,
253340
outputSchema: ReconResult.merge(Narrated),
254341
})
342+
.then(intakeStep)
255343
.then(matchingStep)
256344
.branch([
257345
// Exception → investigate (agent) then route to Approval.

0 commit comments

Comments
 (0)