intake: the word "Image" is not a receipt, and the prompt must not be data
ci / lint-test (push) Successful in 52s

Batch 3d2ffbe8 arrived as one text part whose entire content was the five
characters `Image` — an iOS Shortcut coerced the shared photo to text. The
model was asked anyway and answered out of its prompt: Burgertory
(Braybrook), two "The Pride" lines at $22.80, a $41.15 total. Nothing on it
existed. Burgertory was in ORDER_ITEM_PROMPT as the example of how to print
a branch in parentheses. Only the missing date kept it out of the matcher;
the invented header and items were stored on the batch regardless.

Three layers, none of them a model:

1. textCouldBeReceipt() — deterministic, before any model call. A TEXT part
   with fewer than two words, no digit, or under 40 characters and no money
   figure cannot be a receipt. Length is NOT the test on its own: `TOTAL
   $17.00` is twelve characters and is exactly what this door accepts. A
   rejected part is dropped like an unreadable file type, so one junk part
   beside a good screenshot still goes through; if nothing usable is left
   the card quotes what the phone actually sent. PDFs and images untouched.

2. The prompt names no real shop — merchant examples are
   PROMPT_PLACEHOLDER_MERCHANTS, exported from intake-rules so the prompt
   and the guard cannot drift — and it is told what to return when the input
   is not a receipt at all.

3. confabulationReason() — the belt. Reachable where the pre-check cannot
   look: a PDF that renders to a scrap of junk still reaches the model. Also
   rejects any answer whose merchant IS a placeholder.

All three file `unsupported`, never `needs_header`. A confabulated header is
not a partial one, and the needs_header path stores what it is given.

Same shape as the ingestion engine's 2026-08-05 empty-body defect: a stage
that can be handed nothing has to say so, because the model downstream will
always answer.

Tests: 16 unit cases over the two pure functions and the prompt; three
integration cases — `Image` is declined with zero model calls and nothing
but `unsupported` on the batch row, a junk part beside a real receipt still
enriches, and a junk PDF is unsupported rather than needs_header. The first
and third fail on the tree before this commit.
This commit is contained in:
2026-08-22 11:46:05 +10:00
parent d88ca2a24c
commit 0823244e4a
6 changed files with 335 additions and 5 deletions
+58
View File
@@ -339,6 +339,64 @@ describe("POST /api/intake — gates", () => {
expect((await batch(id)).status).toBe("unsupported");
expect(calls).toHaveLength(0);
});
// 2026-08-22, live batch 3d2ffbe8…: an iOS Shortcut coerced the shared photo
// to text, so the whole upload was the five characters `Image`. The model was
// asked anyway and returned Burgertory (Braybrook), two "The Pride" lines and
// a $41.15 total — none of which existed. Only the absent date kept it out of
// the matcher, and the invented header was stored on the batch as
// `needs_header`. The model must never be asked.
it("a text part that cannot be a receipt is declined BEFORE any model call", async () => {
const id = uuid();
modelReply = { ...burgertory, merchant: "Burgertory (Braybrook)", order_date: null, total: 41.15 };
const res = await intakePOST(
multipart({ source: "other", batch_id: id }, [{ name: "shared.txt", type: "text/plain", bytes: Buffer.from("Image") }])
);
const body = await res.json();
expect(body).toMatchObject({ kind: "unsupported", status: "unsupported" });
expect(body.text).toMatch(/single word/);
expect(body.text).toMatch(/"Image"/);
// The model was never asked — the only thing that makes confabulation impossible.
expect(calls).toHaveLength(0);
const row = await batch(id);
expect(row.status).toBe("unsupported");
expect(row.kind).toBe("unsupported");
expect(row.header).toBeNull();
expect(row.items).toBeNull();
expect(row.transaction_id).toBeNull();
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
});
it("a junk text part beside a real receipt does not sink the batch", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const body = await (
await intakePOST(
multipart({ source: "uber_eats" }, [
{ name: "shared.txt", type: "text/plain", bytes: Buffer.from("Image") },
{ name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY },
])
)
).json();
expect(body.status).toBe("enriched");
expect(await lineItemsOf(txn)).toHaveLength(2);
});
it("an order extracted from an unreadable PDF is unsupported, not needs_header", async () => {
const id = uuid();
// A PDF that renders to a scrap of junk still reaches the model — the
// pre-check only inspects text parts. `confabulationReason` is the belt.
vi.spyOn(await import("../../lib/intake-extract"), "extractPdfText").mockResolvedValueOnce("Image");
modelReply = { ...burgertory, merchant: "Burgertory (Braybrook)" };
const body = await (
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [{ name: "x.pdf", type: "application/pdf", bytes: BURGERTORY }]))
).json();
expect(body).toMatchObject({ kind: "unsupported", status: "unsupported" });
expect(body.text).toMatch(/nothing shared could be read/);
const row = await batch(id);
expect(row.status).toBe("unsupported");
expect(row.header).toBeNull();
expect(row.items).toBeNull();
});
});
describe("POST /api/intake — food (enrich vs candidates)", () => {