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
+19
View File
@@ -699,6 +699,25 @@ and the cross-repo contract are in smarthome `plans/intake-router-slice-plan.md`
delivery order's total is items + fees − promotions ($118.94 of Coles lines
became $85.74), so checking lines against the total would hold every
delivery order. Tolerance $1 or 2%; a failure is `held`, nothing written.
- **Nothing that cannot be a receipt reaches the model, and the prompt names no
real shop.** On 2026-08-22 an iOS Shortcut coerced a shared photo to text, so
a batch arrived as the five characters `Image` — and the model returned a
whole receipt: `Burgertory (Braybrook)`, two "The Pride" lines, $41.15. None
of it existed; "Burgertory" was in `ORDER_ITEM_PROMPT` as the example of how
to print a branch in parentheses. Only the absent date kept it out of the
matcher, and the invented header and items were still stored on the batch as
`needs_header`. Three layers now, in order: `textCouldBeReceipt()` rejects a
TEXT part with fewer than two words, no digit, or under 40 characters with no
money figure (`TOTAL $17.00` passes — length alone is *not* the test)
**before** any model call; the prompt's merchant examples are
`PROMPT_PLACEHOLDER_MERCHANTS` format placeholders and it is told to return
nulls for a non-receipt; and `confabulationReason()` catches what the
pre-check cannot see — a PDF that renders to junk — plus any answer echoing a
placeholder. All three file `unsupported`, never `needs_header`: a
confabulated header is not a partial one. Same shape as the ingestion
engine's 2026-08-05 empty-body defect — **a stage that can be handed nothing
must say so, because the model downstream always answers, and it answers out
of its prompt.**
- **Names stay verbatim.** Burgertory calls its burgers "Michael" and "Joel";
the prompt forbids canonicalising and the stored shape is the order lane's
(`qty`, `description`, `amount`, `options`) so `order-details.tsx` renders it.