diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cdf021c..234a5b0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -283,6 +283,8 @@ model intake_batches { status String transaction_id Int? capture_id Int? + // The undo baseline, written once per batch (see 0034 and line-items.ts). + previous_line_items Json? created_at DateTime @default(now()) @db.Timestamptz(6) resolved_at DateTime? @db.Timestamptz(6) } diff --git a/src/lib/intake-extract.ts b/src/lib/intake-extract.ts index 98919a3..1195a00 100644 --- a/src/lib/intake-extract.ts +++ b/src/lib/intake-extract.ts @@ -185,11 +185,18 @@ export function liteLLMExtractor(opts: { }); if (!response.ok) throw new ExtractionError(`LiteLLM returned ${response.status}`); const raw = (await response.json()) as { choices?: { message?: { content?: string }; finish_reason?: string }[] }; - // Only `stop` means the strict schema was satisfied. A truncated reply can - // still parse once the JSON is closed, which quietly drops the tail of a - // long receipt; a `content_filter` or `tool_calls` stop is a different reply - // shape entirely. Accepting anything but `stop` accepts a reply the schema - // never constrained — losing lines silently is worse than failing. + // The rule is: a finish_reason that is PRESENT must be `stop`. An absent + // one is accepted, deliberately — providers differ on whether they send the + // field, and failing every call on a provider that omits it would be a + // certain outage in exchange for a case that also requires the model to + // emit syntactically complete JSON. The harm a truncated reply does — a + // partial item list replacing a good one — is closed on the write side + // instead, by the guard in `routeFood`/`createFromBatch`. + // + // `length` is the common one: a truncated reply still parses once the JSON + // happens to close, quietly dropping the tail of a long receipt. Any other + // reason (`content_filter`, `tool_calls`) is a different reply shape that + // the strict schema never constrained. const finish = raw.choices?.[0]?.finish_reason; if (finish && finish !== "stop") { throw new ExtractionError(