Skip to content

Commit bf0b859

Browse files
DylanMerigaudclaude
andcommitted
Stop threading the extraction result through the pipeline
The intake step put extracted/matches into its output, so every downstream step's input schema carried fields none of them read (matching runs on the trusted record). That phantom data suggested a link that doesn't exist. Intake now writes the document + extraction result to the trace via the stream writer (intake-document / intake-result chunks) and outputs the run input unchanged, so matching receives exactly what it consumes. The trace adapter owns the intake node from those chunks; intake's bare step lifecycle events are dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5758672 commit bf0b859

2 files changed

Lines changed: 58 additions & 56 deletions

File tree

lib/trace.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ export function toTraceEvent(
173173

174174
case "workflow-step-start":
175175
if (isMappingStep(stepId)) return null; // hide the .map() plumbing step
176+
// Intake owns its node via the intake-document/intake-result chunks it
177+
// writes (they carry the document + extraction result); its bare
178+
// lifecycle events would be dataless duplicates, so drop them.
179+
if (stage === "intake") return null;
176180
return {
177181
kind: "step",
178182
stage,
@@ -236,6 +240,26 @@ export function toTraceEvent(
236240
};
237241
}
238242

243+
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;
248+
return {
249+
kind: "step",
250+
stage: "intake",
251+
status: extracted ? "ok" : "warn",
252+
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 },
260+
};
261+
}
262+
239263
return null;
240264
}
241265

@@ -258,29 +282,16 @@ export function toTraceEvent(
258282
case "workflow-step-result":
259283
case "workflow-step-finish": {
260284
if (isMappingStep(stepId)) return null; // hide the .map() plumbing step
285+
// Intake's node is owned by its intake-document/intake-result chunks; its
286+
// step-result is a bare passthrough (the run input) with nothing to show.
287+
if (stage === "intake") return null;
261288
const rawOut = asRecord(payload?.["output"]);
262289
const narration =
263290
typeof rawOut?.["narration"] === "string"
264291
? (rawOut["narration"] as string)
265292
: undefined;
266293

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
294+
// Steps emit a domain object plus a `narration` string, and some
284295
// wrap the domain object (approval outputs `{ decision, match, vendor }`).
285296
// `domain` is the object the UI colours + renders. Unwrapping here keeps
286297
// the UI decoupled from step shapes.

src/mastra/workflows/p2p.ts

Lines changed: 30 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -73,44 +73,38 @@ const EXTRACTION_TIMEOUT_MS = 20_000;
7373
notes it and proceeds on the record.
7474
7575
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-
);
76+
model returns), and the final extraction result, are written to the TRACE via
77+
the stream writer (an `intake-document` then an `intake-result` chunk) — NOT
78+
threaded into the step output, because the downstream steps don't consume them
79+
(they run on the trusted record). So intake's output is the run input passed
80+
through unchanged: matching receives exactly what it uses, no phantom fields. */
8481
const intakeStep = createStep({
8582
id: "intake",
8683
inputSchema: RunInput,
87-
outputSchema: IntakeOut,
84+
outputSchema: RunInput,
8885
execute: async ({ inputData, writer }) => {
8986
const seed = inputData.invoice;
9087

9188
// Phase-2 resume: the document was already read in phase 1, so skip the vision
9289
// 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-
}
90+
if (inputData.skipExtraction) return inputData;
91+
92+
const emit = async (chunk: unknown) => {
93+
try {
94+
await writer?.write(chunk);
95+
} catch {
96+
/* ignore writer errors — never let the trace affect the result */
97+
}
98+
};
9699

97100
// 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-
}
101+
await emit({ type: "intake-document", payload: { document: seed } });
106102

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.";
103+
let result: { extracted: Invoice; matches: boolean } | { extracted: null } =
104+
{ extracted: null };
111105
try {
112106
const pdfBase64 = await renderInvoicePdfBase64(seed);
113-
const result = await Promise.race([
107+
const out = await Promise.race([
114108
extractInvoice(pdfBase64),
115109
new Promise<{ ok: false; kind: "timeout" }>((resolve) =>
116110
setTimeout(
@@ -119,25 +113,22 @@ const intakeStep = createStep({
119113
),
120114
),
121115
]);
122-
if (result.ok) {
123-
extracted = result.invoice;
116+
if (out.ok) {
124117
// Did the model's read agree with the seeded record on the fields the
125118
// 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).";
119+
const matches =
120+
out.invoice.invoiceNumber === seed.invoiceNumber &&
121+
(out.invoice.poNumber ?? null) === (seed.poNumber ?? null) &&
122+
Math.abs(out.invoice.total - seed.total) < 0.01;
123+
result = { extracted: out.invoice, matches };
133124
}
134125
} catch {
135-
/* fail open — narration already set to the fallback */
126+
/* fail open — result stays { extracted: null } */
136127
}
137128

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 };
129+
await emit({ type: "intake-result", payload: result });
130+
// Matching runs on the trusted record — pass the input through unchanged.
131+
return inputData;
141132
},
142133
});
143134

@@ -152,7 +143,7 @@ const MatchStepOut = MatchResult.merge(Narrated)
152143
.merge(HumanApproval);
153144
const matchingStep = createStep({
154145
id: "matching",
155-
inputSchema: IntakeOut,
146+
inputSchema: RunInput,
156147
outputSchema: MatchStepOut,
157148
execute: async ({ inputData }) => {
158149
const match = runMatch({

0 commit comments

Comments
 (0)