From b5569b8ccc96c88c754c05f0c6e1a05bb5639ecf Mon Sep 17 00:00:00 2001 From: siddharthd Date: Fri, 21 Aug 2026 18:23:32 +1000 Subject: [PATCH] schema: previous_line_items on the model; say what the finish_reason rule is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0034 has the column and the Prisma model did not, so a regenerated client and the database disagreed. The extraction comment claimed only a finished reply is accepted while the code accepts an absent finish_reason. The code is right — failing every call on a provider that omits an optional field would be a certain outage in exchange for a case that also requires syntactically complete JSON, and the harm a truncated reply does is closed on the write side. The prose now states the rule as it is: a reason that is PRESENT must be `stop`. --- prisma/schema.prisma | 2 ++ src/lib/intake-extract.ts | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) 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(