intake: the word "Image" is not a receipt, and the prompt must not be data
ci / lint-test (push) Successful in 52s
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:
@@ -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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user