diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d38b30a9f..1b6aa6228 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -31776,7 +31776,7 @@ components: properties: rows: minItems: 1 - maxItems: 100 + maxItems: 400 type: array items: type: object @@ -31838,10 +31838,10 @@ components: description: The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the - session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value). The - optional `encounterId` files the panel against a visit the caller owns — ONE link per written result row, so a - marker re-run on a different day belongs to its own visit; always optional, and a link that could not be made - never fails the commit. + session. 1..400 rows (`analyte` and `valueText` up to 120 characters, `unit` up to 40). The route skips a row + that duplicates a live reading (same analyte + day + value). The optional `encounterId` files the panel against + a visit the caller owns — ONE link per written result row, so a marker re-run on a different day belongs to its + own visit; always optional, and a link that could not be made never fails the commit. CreateBiomarkerRequest: type: object properties: @@ -36231,9 +36231,9 @@ components: labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, - labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, - labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, - labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, + labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.outputLimit, labs.ocr.pdfNeedsAnthropic, + labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, + labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, @@ -71279,8 +71279,8 @@ components: items: $ref: "#/components/schemas/OcrExtractedRow" pageCoverage: - description: "Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total` - pages. Absent when the whole document was read." + description: "Present only when a PDF ran past the pages a scan reads (20): the rows come from the first `read` of + `total` pages. Absent when the whole document was read." type: object properties: read: @@ -71295,13 +71295,57 @@ components: - read - total additionalProperties: false + truncated: + description: Present only when a read stopped at the provider's output limit. The rows that read produced before the cut + are kept; readings after the cut may be missing. `pages` names the pages whose read was cut, or is empty + when the report went to the provider as one read (a photo, a one-page PDF, OCR'd text). + type: object + properties: + pages: + type: array + items: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + required: + - pages + additionalProperties: false + unreadPages: + description: "Present only when part of a multi-part read produced nothing: the provider failed partway through, or a + part's reply could not be read. The rows of the other parts are kept; these pages are not in `rows`. Empty + when the parts were not pages (OCR'd text)." + type: array + items: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + rowsCapped: + description: "Present only when the report held more readings than one scan carries (400): `rows` holds the first `kept` + of the `found` readings." + type: object + properties: + kept: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + found: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + required: + - kept + - found + additionalProperties: false required: - reportDate - providerType - rows additionalProperties: false description: The proposed rows for the human review screen, served by `GET /api/ai-runs/{id}` for a `LABS_OCR_EXTRACT` - run. Never written to the person's record; the commit route is the only write. + run. A PDF of more than one page is read a page at a time and the pages merged in order, a reading printed on + two pages listed once. Never written to the person's record; the commit route is the only write. A run in which + no read produced a single complete row fails with 422 `labs.ocr.outputLimit` when the reads stopped at the + provider's output limit, else `labs.ocr.extractFailed`. OcrExtractedRow: type: object properties: diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index b9bd332f0..4b49874ea 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -157,8 +157,10 @@ function inBatchKey( async function commitOcrRows(request: NextRequest) { const { user } = await requireAuth(); + // OCR_MAX_ROWS rows at every field's longest bound run past 256 KB; 512 + // keeps a full report in one body with room to spare. const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 256 * 1024, + maxBytes: 512 * 1024, }); if (jsonError) return jsonError; diff --git a/src/lib/ai/__tests__/response-timeout-binding.test.ts b/src/lib/ai/__tests__/response-timeout-binding.test.ts index 5da896b28..50292635a 100644 --- a/src/lib/ai/__tests__/response-timeout-binding.test.ts +++ b/src/lib/ai/__tests__/response-timeout-binding.test.ts @@ -215,7 +215,8 @@ describe("the surfaces that used to fall back to 60 s", () => { userId: "u1", provider: bound(), providerType: "local", - images: image, + mode: "vision", + chunks: [{ images: image, pages: [] }], }), ).rejects.toBe(STOP); expect(dialledTimeout()).toBe(300_000); diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index d6a189f10..5c033e8ba 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -186,17 +186,18 @@ export const AI_BUDGETS = { selfContext: { temperature: 0.4 }, /** - * v1.18.9 — Lab-OCR structured extraction. ONE vision call transcribes a - * photo / PDF of a paper lab report into a JSON array of analyte rows - * (analyte + value/valueText + unit + reference range + date + per-field - * confidence). A full panel is a dozen-plus rows of compact JSON; 8000 - * tokens leaves room for GPT-5 reasoning plus the dense report envelope. - * Temperature 0 — the task - * is faithful transcription, not generation, so the lowest setting minimises - * invented values. Vision input tokens are expensive, so the route also - * gates with a tight 6/hour rate bucket + reserveBudget + upload-size cap. + * v1.18.9 — Lab-OCR structured extraction, PER READ. A report is read a page + * at a time (`src/lib/labs/ocr-run.ts`), so this ceiling covers one page's + * JSON, not the whole document: a dense page is 30-50 rows at roughly 90 + * tokens each, and 8000 leaves room for that. It is the answer budget only; + * the call carries an explicit reasoning off, and a model that cannot stop + * thinking gets its minimum thinking budget added on top by its client. + * One call used to carry the whole report under this ceiling, and a long + * one stopped mid-row. Temperature 0 — faithful transcription, not + * generation. `timeoutMs` 120 s: one page of JSON on a slower model runs + * past the shared 60 s default; the person's own setting still wins. */ - ocrExtract: { temperature: 0, maxTokens: 8000 }, + ocrExtract: { temperature: 0, maxTokens: 8000, timeoutMs: 120_000 }, /** * v1.43 — a supplement label photo read into its ingredients. One vision @@ -206,14 +207,12 @@ export const AI_BUDGETS = { supplementLabel: { temperature: 0, maxTokens: 4000 }, /** - * v1.20.1 — Lab-OCR text-mode structuring. The browser OCR's the image - * (tesseract.js) and POSTs only the extracted TEXT, so the provider does a - * plain text→JSON structuring pass — no expensive vision input tokens. The - * output contract is identical (the same dozen-plus analyte rows), but the - * spend ceiling is far lower than the vision path's 4000, so the day-budget - * reservation should reflect that rather than over-charging a text call at - * the vision rate. 1500 tokens comfortably covers a dense report's JSON - * envelope. Temperature 0 — faithful structuring, not generation. + * v1.20.1 — Lab-OCR text-mode reservation. The browser OCR's the image + * (tesseract.js) and POSTs only the extracted TEXT, so no vision input + * tokens are spent. This is what the route reserves against the day's + * budget when it queues the read; the structuring calls themselves run + * under `ocrExtract`'s per-read ceiling, and the run settles what the + * provider reported it spent. */ ocrExtractText: { temperature: 0, maxTokens: 1500 }, diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 2eb7f2eaf..c2362ca58 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -982,6 +982,15 @@ export class CodexClient implements AIProvider { httpStatus: 0, kind: "stream_failed", upstream: "codex", + // The documented reason (`max_output_tokens`, + // `content_filter`), so a caller can tell a reply cut at the + // output limit from any other stream failure. + ...(parsed.response?.incomplete_details?.reason + ? { + incompleteReason: + parsed.response.incomplete_details.reason, + } + : {}), }); throw e; } diff --git a/src/lib/ai/reasoning/resolve.ts b/src/lib/ai/reasoning/resolve.ts index ae172a169..901525cd2 100644 --- a/src/lib/ai/reasoning/resolve.ts +++ b/src/lib/ai/reasoning/resolve.ts @@ -176,6 +176,22 @@ export function resolveReasoning( }; } +/** + * The reasoning a transcription call carries: an explicit off. A surface that + * never reasons (lab OCR) cannot simply leave the field out, because several + * models reason when nobody asks: OpenAI's reasoning models default to + * `medium`, Codex's backend to its own default, and Anthropic's 5 generation + * thinks adaptively. Every one of those spends the same output allowance the + * JSON needs, and a long report then stops mid-row. `off` makes each client + * send its lowest setting (`none` where the model has one, else the minimum, + * with that minimum's thinking budget added on top of the answer budget), so + * the answer keeps its allowance. Never above off, so the operator's switch + * has nothing to lower. + */ +export const TRANSCRIPTION_REASONING: NonNullable< + CompletionParams["reasoning"] +> = Object.freeze({ effort: "off", summaries: false }); + /** * The `CompletionParams.reasoning` one call carries for `resolved`. * diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index 452ddfae5..e226fdb1f 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -53,6 +53,13 @@ export const RASTERIZATION_AVAILABLE = true; /** Render at most the first N pages — the token-cost / DoS governor. */ export const RASTER_MAX_PAGES = 10; +/** + * The ceiling a caller may raise the page count to. The lab scan reads a + * multi-panel report page by page and asks for more than the document read's + * default; nothing may ask for more than this. + */ +export const RASTER_HARD_MAX_PAGES = 20; + /** * Longest-edge pixel cap per page. Tighter than the client upload downscale * ceiling because these feed a token-metered vision model; ~1600px is legible @@ -269,8 +276,8 @@ export function __resetPdfjsAssetsForTests(): void { /** * Render the first `RASTER_MAX_PAGES` pages of a PDF to bounded JPEG images - * (or fewer when `maxPages` is passed — the thumbnail path renders page 1 - * only). Returns `{ ok: false }` on any failure (malformed PDF, render throw, + * (or another count when `maxPages` is passed — the thumbnail path renders + * page 1 only, the lab scan up to `RASTER_HARD_MAX_PAGES`). Returns `{ ok: false }` on any failure (malformed PDF, render throw, * missing binary, or zero renderable pages) — the caller degrades to local * text or un-indexed. Never throws. */ @@ -326,7 +333,7 @@ export async function rasterizePdf( doc = await task.promise; // Never exceed the standing DoS ceiling, even if a caller asks for more. - const cap = Math.min(Math.max(1, maxPages), RASTER_MAX_PAGES); + const cap = Math.min(Math.max(1, maxPages), RASTER_HARD_MAX_PAGES); const pageCount = Math.min(doc.numPages, cap); const images: RasterImage[] = []; let blankPages = 0; diff --git a/src/lib/jobs/__tests__/document-ai-run.test.ts b/src/lib/jobs/__tests__/document-ai-run.test.ts index d66bdd9c2..5fc7878f1 100644 --- a/src/lib/jobs/__tests__/document-ai-run.test.ts +++ b/src/lib/jobs/__tests__/document-ai-run.test.ts @@ -346,6 +346,47 @@ describe("runDocumentAiRun", () => { }), ); }); + + it("hands a lab scan a schedule that fits the deadline and moves it", async () => { + claimAiRun.mockResolvedValue( + claimed({ + kind: "LABS_OCR_EXTRACT", + documentId: null, + params: { mode: "vision", mime: "application/pdf", budget }, + input: Buffer.from("%PDF-1.4"), + }), + ); + requireLabsOcrProvider.mockResolvedValue({ + entry: { + providerType: "anthropic", + instance: { responseTimeoutSeconds: null }, + }, + providerType: "anthropic", + pdfSupported: true, + }); + executeOcrExtraction.mockImplementation(async (args) => { + // 2 700 s of job budget, a 60 s margin and a second of slack, at the + // read's own 120 s default per call: 21 sequential calls fit. + expect(args.schedule.maxModelCalls()).toBe(21); + await args.schedule.arm(20); + return { + ok: true, + data: { reportDate: null, providerType: "anthropic", rows: [] }, + }; + }); + expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe( + "succeeded", + ); + // One read armed first, then the twenty calls the read planned. + expect(updateMany).toHaveBeenNthCalledWith(1, { + where: { id: "run1", status: "RUNNING" }, + data: { expiresAt: new Date(NOW + 2 * 120_000 + 60_000) }, + }); + expect(updateMany).toHaveBeenLastCalledWith({ + where: { id: "run1", status: "RUNNING" }, + data: { expiresAt: new Date(NOW + 20 * 120_000 + 60_000) }, + }); + }); }); describe("handleDocumentAiRunJobs", () => { diff --git a/src/lib/jobs/document-ai-run.ts b/src/lib/jobs/document-ai-run.ts index a5494dfd4..2000054b4 100644 --- a/src/lib/jobs/document-ai-run.ts +++ b/src/lib/jobs/document-ai-run.ts @@ -36,6 +36,7 @@ import { PICK_DECIDED_REASONS, type AiCapabilityKey, } from "@/lib/ai/capabilities/types"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { callTimeoutMs } from "@/lib/ai/effective-timeout"; import { prisma } from "@/lib/db"; import { loadOwnedDocument } from "@/lib/documents/ai-route-support"; @@ -87,6 +88,7 @@ import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability"; import { executeOcrExtraction, OCR_EXTRACT_MODEL_CALLS, + type OcrSchedule, } from "@/lib/labs/ocr-run"; import { annotate } from "@/lib/logging/context"; import { logCaught } from "@/lib/logging/signal"; @@ -246,6 +248,35 @@ async function runDocumentIndex( }); } +/** + * Arm the deadline for one lab read and hand the read a schedule: it then + * plans as many reads as the deadline allows (a long PDF a page at a time) + * and moves the deadline to cover them. + */ +async function labsOcrSchedule( + runId: string, + responseTimeoutSeconds: number | null | undefined, + deadlineAt: number | undefined, + now: () => number, +): Promise { + const perCallMs = callTimeoutMs( + { timeoutMs: AI_BUDGETS.ocrExtract.timeoutMs }, + responseTimeoutSeconds, + ); + await armDeadline(runId, perCallMs, OCR_EXTRACT_MODEL_CALLS, deadlineAt, now); + return { + maxModelCalls: () => + deadlineAt === undefined + ? Number.POSITIVE_INFINITY + : // A second of slack for the time between planning and arming. + Math.floor( + (deadlineAt - now() - AI_RUN_MARGIN_MS - 1000) / perCallMs, + ), + arm: (modelCalls) => + armDeadline(runId, perCallMs, modelCalls, deadlineAt, now), + }; +} + async function runLabsOcr( run: ClaimedAiRun, deadlineAt: number | undefined, @@ -255,16 +286,16 @@ async function runLabsOcr( if (run.params.mode === "text") { const pick = await requireLabsOcrProvider(run.userId, "text"); await assertRunMayEgress("labsOcr", run.userId, [pick.providerType]); - await armDeadline( + const schedule = await labsOcrSchedule( run.id, - callTimeoutMs({}, pick.entry.instance.responseTimeoutSeconds), - OCR_EXTRACT_MODEL_CALLS, + pick.entry.instance.responseTimeoutSeconds, deadlineAt, now, ); return executeOcrExtraction({ userId: run.userId, budget: run.params.budget, + schedule, input: { mode: "text", text: run.input.toString("utf8"), pick }, }); } @@ -272,16 +303,16 @@ async function runLabsOcr( if (!mime) throw new Error("lab scan run without a MIME type"); const pick = await requireLabsOcrProvider(run.userId, "vision"); await assertRunMayEgress("labsOcr", run.userId, [pick.providerType]); - await armDeadline( + const schedule = await labsOcrSchedule( run.id, - callTimeoutMs({}, pick.entry.instance.responseTimeoutSeconds), - OCR_EXTRACT_MODEL_CALLS, + pick.entry.instance.responseTimeoutSeconds, deadlineAt, now, ); return executeOcrExtraction({ userId: run.userId, budget: run.params.budget, + schedule, input: { mode: "vision", bytes: run.input, mime, pick }, }); } diff --git a/src/lib/labs/__tests__/ocr-extract.test.ts b/src/lib/labs/__tests__/ocr-extract.test.ts new file mode 100644 index 000000000..eae0b1536 --- /dev/null +++ b/src/lib/labs/__tests__/ocr-extract.test.ts @@ -0,0 +1,306 @@ +/** + * The lab scan's reads and their merge. A report used to go to the provider + * as one call under a fixed output ceiling and a 100-row envelope cap, and a + * long report lost everything: the reply stopped mid-row and failed to parse + * (the person saw "try a clearer photo"), or it held 101 rows and the schema + * refused all of them. These pin the replacement: per-read calls, the cut + * detected from the finish reason with the closed rows kept, the cap keeping + * the first rows, and identical readings across pages merged. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), +})); +vi.mock("@/lib/db", () => ({ + prisma: { + labResult: { findMany: vi.fn().mockResolvedValue([]) }, + biomarker: { findMany: vi.fn().mockResolvedValue([]) }, + biomarkerAlias: { findMany: vi.fn().mockResolvedValue([]) }, + }, +})); + +import type { AIProvider, CompletionResult } from "@/lib/ai/types"; +import { + OcrExtractError, + ocrChunkParams, + runOcrExtraction, + salvageTruncatedJson, + type OcrChunk, +} from "@/lib/labs/ocr-extract"; +import { OCR_MAX_ROWS } from "@/lib/validations/labs-ocr"; + +const row = (analyte: string, value: number, unit = "mg/dl") => ({ + analyte, + value, + valueText: null, + unit, + referenceText: null, + referenceLow: null, + referenceHigh: null, + takenAt: null, + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, +}); + +const reply = ( + content: string, + extra: Partial = {}, +): CompletionResult => ({ + content, + tokensUsed: 1000, + model: "m", + providerType: "anthropic", + finishReason: "stop", + ...extra, +}); + +const envelope = (rows: unknown[], reportDate: string | null = null) => + JSON.stringify({ reportDate, rows }); + +function provider(...replies: Array) { + const generateCompletion = vi.fn(); + for (const r of replies) { + if (r instanceof Error) generateCompletion.mockRejectedValueOnce(r); + else generateCompletion.mockResolvedValueOnce(r); + } + return { + provider: { generateCompletion } as unknown as AIProvider, + generateCompletion, + }; +} + +const page = (n: number): OcrChunk => ({ + images: [{ mediaType: "image/jpeg", dataBase64: `p${n}` }], + documents: [], + pages: [n], +}); +const photo: OcrChunk = { + images: [{ mediaType: "image/jpeg", dataBase64: "photo" }], + documents: [], + pages: [], +}; + +const run = (p: AIProvider, chunks: OcrChunk[], totalPages?: number) => + runOcrExtraction({ + userId: "u1", + provider: p, + providerType: "anthropic", + mode: "vision", + chunks, + totalPages, + }); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("salvageTruncatedJson", () => { + it("keeps every row that closed before the cut", () => { + const whole = envelope([row("Hb", 14), row("MCV", 90)], "2026-03-01"); + const cut = `${whole.slice(0, whole.indexOf('"MCV"') + 10)}`; + expect( + salvageTruncatedJson(`${whole.slice(0, -2)}, {"analyte": "X`), + ).toEqual({ + reportDate: "2026-03-01", + rows: [row("Hb", 14), row("MCV", 90)], + }); + expect(salvageTruncatedJson(cut)).toEqual({ + reportDate: "2026-03-01", + rows: [row("Hb", 14)], + }); + }); + + it("is not fooled by braces and escaped quotes inside strings", () => { + const tricky = { + ...row('Ab "}]{" \\ test', 1), + referenceText: "< 5 } ]", + }; + const whole = envelope([tricky, row("TSH", 2)]); + const cut = whole.slice(0, whole.indexOf('"TSH"') + 3); + expect(salvageTruncatedJson(cut)).toEqual({ + reportDate: null, + rows: [tricky], + }); + }); + + it("is null for a complete document or one without a closed row", () => { + expect(salvageTruncatedJson(envelope([row("Hb", 14)]))).toBeNull(); + expect( + salvageTruncatedJson('{"reportDate": null, "rows": [{"ana'), + ).toBeNull(); + expect(salvageTruncatedJson("")).toBeNull(); + }); +}); + +describe("a read cut at the output limit", () => { + it("keeps the rows before the cut, says so, and does not retry", async () => { + const whole = envelope([row("Hb", 14), row("MCV", 90)]); + const cut = whole.slice(0, whole.indexOf('"MCV"') + 10); + const { provider: p, generateCompletion } = provider( + reply(cut, { finishReason: "length" }), + ); + const out = await run(p, [photo]); + expect(out.data.rows.map((r) => r.analyte)).toEqual(["Hb"]); + expect(out.data.truncated).toEqual({ pages: [] }); + expect(generateCompletion).toHaveBeenCalledTimes(1); + }); + + it("names the page whose read was cut", async () => { + const whole = envelope([row("Hb", 14), row("MCV", 90)]); + const cut = whole.slice(0, whole.indexOf('"MCV"') + 10); + const { provider: p } = provider( + reply(envelope([row("Na", 140, "mmol/l")])), + reply(cut, { finishReason: "length" }), + ); + const out = await run(p, [page(1), page(2)], 2); + expect(out.data.rows.map((r) => r.analyte)).toEqual(["Na", "Hb"]); + expect(out.data.truncated).toEqual({ pages: [2] }); + expect(out.data.unreadPages).toBeUndefined(); + }); + + it("fails as an output limit, not as an unreadable photo, when no row closed", async () => { + const { provider: p, generateCompletion } = provider( + reply("", { finishReason: "length" }), + ); + const err = await run(p, [photo]).catch((e: unknown) => e); + expect(err).toBeInstanceOf(OcrExtractError); + expect((err as OcrExtractError).reason).toBe("output_limit"); + expect(generateCompletion).toHaveBeenCalledTimes(1); + }); + + it("reads Codex's incomplete stream as the same cut", async () => { + const { provider: p } = provider( + Object.assign(new Error("Codex stream incomplete: max_output_tokens"), { + kind: "stream_failed", + incompleteReason: "max_output_tokens", + }), + ); + const err = await run(p, [photo]).catch((e: unknown) => e); + expect((err as OcrExtractError).reason).toBe("output_limit"); + }); +}); + +describe("a reply that is not the schema", () => { + it("is retried once, then fails as unreadable", async () => { + const { provider: p, generateCompletion } = provider( + reply("not json"), + reply("still not json"), + ); + const err = await run(p, [photo]).catch((e: unknown) => e); + expect((err as OcrExtractError).reason).toBe("unparseable"); + expect(generateCompletion).toHaveBeenCalledTimes(2); + }); + + it("costs only its own page when the other pages read", async () => { + const { provider: p } = provider( + reply(envelope([row("Hb", 14)])), + reply("nope"), + reply("nope again"), + reply(envelope([row("TSH", 2, "mU/l")])), + ); + const out = await run(p, [page(1), page(2), page(3)], 3); + expect(out.data.rows.map((r) => r.analyte)).toEqual(["Hb", "TSH"]); + expect(out.data.unreadPages).toEqual([2]); + }); +}); + +describe("a provider failure partway through", () => { + it("keeps the pages already read and names the rest", async () => { + const { provider: p, generateCompletion } = provider( + reply(envelope([row("Hb", 14)])), + Object.assign(new Error("upstream 529"), { httpStatus: 529 }), + ); + const out = await run(p, [page(1), page(2), page(3)], 3); + expect(out.data.rows.map((r) => r.analyte)).toEqual(["Hb"]); + expect(out.data.unreadPages).toEqual([2, 3]); + // It stops: the third page is not sent to a provider that just failed. + expect(generateCompletion).toHaveBeenCalledTimes(2); + }); + + it("is the scan's failure when nothing was read yet", async () => { + const boom = Object.assign(new Error("upstream 401"), { httpStatus: 401 }); + const { provider: p } = provider(boom); + await expect(run(p, [page(1), page(2)], 2)).rejects.toBe(boom); + }); +}); + +describe("the merge", () => { + it("lists a reading printed on two pages once, in report order", async () => { + const { provider: p } = provider( + reply(envelope([row("Hb", 14), row("MCV", 90, "fl")], "2026-03-01")), + reply(envelope([row("hb ", 14, "MG/DL"), row("TSH", 2, "mU/l")])), + ); + const out = await run(p, [page(1), page(2)], 2); + expect(out.data.rows.map((r) => r.analyte)).toEqual(["Hb", "MCV", "TSH"]); + // The report's date reaches the rows of a page that printed none. + expect(out.data.reportDate).toBe("2026-03-01"); + expect(out.data.rows.every((r) => r.takenAt === "2026-03-01")).toBe(true); + }); + + it("keeps the same value on another sample date", async () => { + const { provider: p } = provider( + reply(envelope([row("Hb", 14)], "2026-03-01")), + reply(envelope([row("Hb", 14)], "2026-04-01")), + ); + const out = await run(p, [page(1), page(2)], 2); + expect(out.data.rows.map((r) => r.takenAt)).toEqual([ + "2026-03-01", + "2026-04-01", + ]); + }); + + it("keeps the first OCR_MAX_ROWS rows of a longer report and says how many there were", async () => { + const rows = (from: number, n: number) => + Array.from({ length: n }, (_, i) => row(`Marker ${from + i}`, i)); + const { provider: p } = provider( + reply(envelope(rows(0, 250))), + reply(envelope(rows(250, 200))), + ); + const out = await run(p, [page(1), page(2)], 2); + expect(out.data.rows).toHaveLength(OCR_MAX_ROWS); + expect(out.data.rows[0].analyte).toBe("Marker 0"); + expect(out.data.rowsCapped).toEqual({ kept: OCR_MAX_ROWS, found: 450 }); + }); + + it("reports nothing extra for a clean read", async () => { + const { provider: p } = provider(reply(envelope([row("Hb", 14)]))); + const out = await run(p, [photo]); + expect(out.data).not.toHaveProperty("truncated"); + expect(out.data).not.toHaveProperty("unreadPages"); + expect(out.data).not.toHaveProperty("rowsCapped"); + }); + + it("adds up what the reads cost, the ceiling standing in for an unreported one", async () => { + const { provider: p } = provider( + reply(envelope([row("Hb", 14)]), { tokensUsed: 1200 }), + reply(envelope([row("TSH", 2)]), { tokensUsed: null }), + ); + const out = await run(p, [page(1), page(2)], 2); + expect(out.spentTokens).toBe(1200 + 8000); + }); +}); + +describe("what one read sends", () => { + it("asks for no reasoning, the per-read ceiling and the OCR timeout", () => { + const params = ocrChunkParams("vision", page(3), 2, 9, 9); + expect(params.reasoning).toEqual({ effort: "off", summaries: false }); + expect(params.maxTokens).toBe(8000); + expect(params.timeoutMs).toBe(120_000); + expect(params.responseFormat).toBe("json"); + const content = params.messages[0].content; + const text = Array.isArray(content) ? content[0] : null; + expect(text).toMatchObject({ type: "text" }); + expect((text as { text: string }).text).toContain( + "page 3 of a 9-page report", + ); + }); + + it("carries a text part in the user turn and nothing else", () => { + const params = ocrChunkParams("text", { text: "Hb 14", pages: [] }, 1, 2); + expect(params.reasoning).toEqual({ effort: "off", summaries: false }); + expect(typeof params.messages[0].content).toBe("string"); + expect(params.messages[0].content).toContain("(part 2 of 2)"); + expect(params.messages[0].content).toContain("Hb 14"); + }); +}); diff --git a/src/lib/labs/__tests__/ocr-output-budget.test.ts b/src/lib/labs/__tests__/ocr-output-budget.test.ts new file mode 100644 index 000000000..3da585083 --- /dev/null +++ b/src/lib/labs/__tests__/ocr-output-budget.test.ts @@ -0,0 +1,184 @@ +/** + * What one lab-scan read asks of each provider, at the wire. + * + * The read used to go out with no reasoning field, which several models read + * as "reason at the default": OpenAI's reasoning models at `medium`, Codex at + * its backend's default, Anthropic's 5 generation adaptively. Their reasoning + * is billed against the same output allowance as the answer, so a report's + * JSON stopped mid-row while the request looked like it had 8000 tokens to + * spare. The read now carries an explicit off, and these hold what that + * becomes on each wire: no reasoning where the model can stop, otherwise its + * minimum with that minimum's thinking budget added on top, so the answer + * keeps its full allowance either way. + * + * Mutation check: drop `reasoning: TRANSCRIPTION_REASONING` from + * `ocrChunkParams` and the reasoning-model cases go red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const safeFetch = vi.fn(); +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: (...a: unknown[]) => safeFetch(...a), +})); +vi.mock("@/lib/ai/local-host-allowlist", () => ({ + aiEgressPolicyFor: () => ({}), + isLocalAiHostAllowed: () => true, +})); +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { AnthropicClient } from "@/lib/ai/anthropic-client"; +import { CodexClient } from "@/lib/ai/codex-client"; +import { clearCodexSlugCache } from "@/lib/ai/codex-slug-cache"; +import { LocalOpenAICompatibleClient } from "@/lib/ai/local-client"; +import { OpenAIClient } from "@/lib/ai/openai-client"; +import { REASONING_THINKING_BUDGET } from "@/lib/ai/reasoning/levels"; +import { resetReasoningDialectCache } from "@/lib/ai/reasoning/dialect-cache"; +import type { AIProvider } from "@/lib/ai/types"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { ocrChunkParams } from "@/lib/labs/ocr-extract"; + +const ANSWER = AI_BUDGETS.ocrExtract.maxTokens; +const STOP = new Error("stop after the request was built"); + +const read = () => + ocrChunkParams( + "vision", + { + images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }], + pages: [1], + }, + 0, + 2, + 2, + ); + +/** The JSON body of the request the client dialled. */ +async function sentBody(client: AIProvider): Promise> { + await expect(client.generateCompletion(read())).rejects.toBe(STOP); + const call = safeFetch.mock.calls.at(-1); + expect(call, "safeFetch was never called").toBeDefined(); + return JSON.parse((call![1] as { body: string }).body); +} + +beforeEach(() => { + safeFetch.mockReset(); + safeFetch.mockRejectedValue(STOP); + resetReasoningDialectCache(); + clearCodexSlugCache(); +}); + +describe("Anthropic", () => { + const client = (model: string) => new AnthropicClient({ apiKey: "k", model }); + + it("adds the minimum thinking budget on top for a model that always thinks", async () => { + const body = await sentBody(client("claude-sonnet-5")); + expect(body.thinking).toMatchObject({ type: "adaptive" }); + expect(body.output_config).toEqual({ effort: "low" }); + expect(body.max_tokens).toBe(ANSWER + REASONING_THINKING_BUDGET.low); + }); + + it("sends no thinking and the answer budget where thinking can stay off", async () => { + for (const model of ["claude-sonnet-4-6", "claude-sonnet-4-5"]) { + const body = await sentBody(client(model)); + expect(body).not.toHaveProperty("thinking"); + expect(body.max_tokens).toBe(ANSWER); + } + }); +}); + +describe("OpenAI", () => { + const client = (model: string) => + new OpenAIClient({ + apiKey: "k", + model, + baseUrl: "https://api.openai.com/v1", + }); + + it("asks a gpt-5.1+ model for no reasoning", async () => { + const body = await sentBody(client("gpt-5.1")); + expect(body.reasoning_effort).toBe("none"); + expect(body.max_completion_tokens).toBe(ANSWER); + }); + + it("asks bare gpt-5 for its minimum, with that minimum's budget on top", async () => { + const body = await sentBody(client("gpt-5")); + expect(body.reasoning_effort).toBe("minimal"); + expect(body.max_completion_tokens).toBe( + ANSWER + REASONING_THINKING_BUDGET.low, + ); + }); + + it("leaves a model that does not reason as it was", async () => { + const body = await sentBody(client("gpt-4o")); + expect(body).not.toHaveProperty("reasoning_effort"); + expect(body.max_tokens ?? body.max_completion_tokens).toBe(ANSWER); + }); +}); + +describe("an OpenAI-compatible gateway (Gemini, OpenRouter, LiteLLM)", () => { + it("asks for no reasoning on the endpoint's own dialect", async () => { + const body = await sentBody( + new OpenAIClient({ + apiKey: "k", + model: "gemini-2.5-flash", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + providerType: "openai-compatible", + }), + ); + expect(body.reasoning_effort).toBe("none"); + expect(body.max_tokens ?? body.max_completion_tokens).toBe(ANSWER); + }); +}); + +describe("a local server", () => { + it("asks for no reasoning and keeps the answer budget", async () => { + const body = await sentBody( + new LocalOpenAICompatibleClient({ + apiKey: null, + model: "qwen3-vl", + baseUrl: "https://llm.example.org/v1", + }), + ); + expect(body.reasoning_effort).toBe("none"); + expect(body.max_tokens).toBe(ANSWER); + }); +}); + +describe("Codex", () => { + it("asks for no reasoning instead of the backend's default", async () => { + const body = await sentBody( + new CodexClient({ + accessToken: "t", + accountId: "a", + onTokenRefresh: vi.fn(), + slugChain: ["gpt-5.5"], + }), + ); + expect(body.reasoning).toEqual({ effort: "none" }); + }); + + it("tags a reply cut at the output limit so the read can tell it apart", async () => { + const event = { + type: "response.incomplete", + response: { incomplete_details: { reason: "max_output_tokens" } }, + }; + safeFetch.mockResolvedValueOnce( + new Response( + `event: response.incomplete\ndata: ${JSON.stringify(event)}\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ), + ); + const err = await new CodexClient({ + accessToken: "t", + accountId: "a", + onTokenRefresh: vi.fn(), + slugChain: ["gpt-5.5"], + }) + .generateCompletion(read()) + .catch((e: unknown) => e); + expect(err).toMatchObject({ + kind: "stream_failed", + incompleteReason: "max_output_tokens", + }); + }); +}); diff --git a/src/lib/labs/__tests__/ocr-run.test.ts b/src/lib/labs/__tests__/ocr-run.test.ts index f350c43a1..6f4f6df19 100644 --- a/src/lib/labs/__tests__/ocr-run.test.ts +++ b/src/lib/labs/__tests__/ocr-run.test.ts @@ -1,10 +1,10 @@ /** * The lab scan read the background worker runs (v1.40), moved out of the * route with the guarantees the route's own tests used to pin: the budget is - * settled against what the provider was asked to do, a PDF is rendered for a - * provider that cannot read one, a long PDF says which pages were read, and a - * provider failure is named as one without the upstream body reaching the - * stored failure or the event stream. + * settled against what the provider reported, a PDF is rendered and read a + * page at a time, a long PDF says which pages were read, the reads fit the + * run's deadline, and a provider failure is named as one without the upstream + * body reaching the stored failure or the event stream. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -23,7 +23,13 @@ vi.mock("@/lib/labs/ocr-extract", async () => { import { reconcileSpend } from "@/lib/ai/coach/budget"; import { rasterizePdf } from "@/lib/documents/rasterize-pdf"; import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract"; -import { executeOcrExtraction } from "@/lib/labs/ocr-run"; +import { + executeOcrExtraction, + OCR_MAX_PAGES, + OCR_TEXT_READ_CHARS, + planPageReads, + splitOcrText, +} from "@/lib/labs/ocr-run"; import { annotate } from "@/lib/logging/context"; const budget = { @@ -40,41 +46,150 @@ const codexPick = { providerType: "codex", pdfSupported: false, } as never; +const anthropicPick = { + entry: { providerType: "anthropic", instance: {} as never }, + providerType: "anthropic", + pdfSupported: true, +} as never; -const text = () => ({ mode: "text" as const, text: "Hb 14", pick: textPick }); -const pdf = () => ({ +const text = (t = "Hb 14") => ({ + mode: "text" as const, + text: t, + pick: textPick, +}); +const pdf = (pick: never = codexPick) => ({ mode: "vision" as const, bytes: Buffer.from("%PDF-1.4"), mime: "application/pdf" as const, - pick: codexPick, + pick, +}); +const pages = (n: number) => + Array.from({ length: n }, (_, i) => ({ + mediaType: "image/jpeg" as const, + dataBase64: `page${i + 1}`, + })); +const extraction = (spentTokens = 0) => ({ + data: { rows: [] } as never, + spentTokens, +}); +const schedule = (maxModelCalls: number) => ({ + maxModelCalls: () => maxModelCalls, + arm: vi.fn().mockResolvedValue(undefined), }); beforeEach(() => { vi.clearAllMocks(); }); +describe("planning the reads", () => { + it("reads one page per call when the deadline allows it", () => { + expect(planPageReads(3, Number.POSITIVE_INFINITY)).toEqual([[1], [2], [3]]); + }); + + it("shares the pages out when the deadline allows fewer reads", () => { + expect(planPageReads(10, 4)).toEqual([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + [10], + ]); + expect(planPageReads(5, 1)).toEqual([[1, 2, 3, 4, 5]]); + expect(planPageReads(5, 0)).toEqual([[1, 2, 3, 4, 5]]); + }); + + it("keeps a photo's text as one read", () => { + expect(splitOcrText("Hb 14\nMCV 90", Number.POSITIVE_INFINITY)).toEqual([ + "Hb 14\nMCV 90", + ]); + }); + + it("splits long text at line boundaries, every line kept once", () => { + const lines = Array.from( + { length: 400 }, + (_, i) => `Marker ${i} 1,5 mg/dl`, + ); + const whole = lines.join("\n"); + const parts = splitOcrText(whole, Number.POSITIVE_INFINITY); + expect(parts.length).toBeGreaterThan(1); + for (const part of parts) { + expect(part.length).toBeLessThanOrEqual(OCR_TEXT_READ_CHARS); + } + expect(parts.join("\n")).toBe(whole); + }); + + it("never splits text into more reads than the deadline allows", () => { + const whole = Array.from({ length: 400 }, (_, i) => `Marker ${i}`).join( + "\n", + ); + const parts = splitOcrText(whole, 2); + expect(parts).toHaveLength(2); + expect(parts.join("\n")).toBe(whole); + }); + + it("folds the remainder in when lines do not share out evenly", () => { + // Seven long lines into three reads: packing two lines a read leaves a + // fourth read, which is folded into the third. + const whole = Array.from({ length: 7 }, (_, i) => + String(i).repeat(5000), + ).join("\n"); + const parts = splitOcrText(whole, 3); + expect(parts).toHaveLength(3); + expect(parts.join("\n")).toBe(whole); + }); +}); + describe("text mode", () => { - it("charges the reservation to the provider that served", async () => { - vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never); + it("charges what the provider reported to the provider that served", async () => { + vi.mocked(runOcrExtraction).mockResolvedValue(extraction(2345)); const out = await executeOcrExtraction({ userId: "u1", input: text(), budget, }); expect(out).toEqual({ ok: true, data: { rows: [] } }); + expect(reconcileSpend).toHaveBeenCalledWith( + "u1", + 1500, + 2345, + "2027-01-15", + 0, + { servedBy: "openai", reservedOwner: "operator" }, + ); + }); + + it("charges the reservation when the provider reported nothing", async () => { + vi.mocked(runOcrExtraction).mockResolvedValue(extraction(0)); + await executeOcrExtraction({ userId: "u1", input: text(), budget }); expect(reconcileSpend).toHaveBeenCalledWith( "u1", 1500, 1500, "2027-01-15", 0, - { - servedBy: "openai", - reservedOwner: "operator", - }, + { servedBy: "openai", reservedOwner: "operator" }, ); }); + it("sends long text in parts and moves the deadline to cover them", async () => { + vi.mocked(runOcrExtraction).mockResolvedValue(extraction()); + // ~8.8k characters: three reads of at most OCR_TEXT_READ_CHARS. + const long = Array.from( + { length: 400 }, + (_, i) => `Marker ${i} 1,5 mg/dl`, + ).join("\n"); + const s = schedule(20); + await executeOcrExtraction({ + userId: "u1", + input: text(long), + budget, + schedule: s, + }); + const chunks = vi.mocked(runOcrExtraction).mock.calls[0][0].chunks; + expect(chunks).toHaveLength(3); + expect(chunks.every((c) => c.pages.length === 0)).toBe(true); + expect(s.arm).toHaveBeenCalledWith(6); + }); + it("refunds the reservation in full on a clean extract failure", async () => { vi.mocked(runOcrExtraction).mockRejectedValue(new OcrExtractError("x")); const out = await executeOcrExtraction({ @@ -93,13 +208,26 @@ describe("text mode", () => { 0, "2027-01-15", 0, - { - servedBy: null, - reservedOwner: "operator", - }, + { servedBy: null, reservedOwner: "operator" }, ); }); + it("names a read that stopped at the output limit as exactly that", async () => { + vi.mocked(runOcrExtraction).mockRejectedValue( + new OcrExtractError("x", "output_limit"), + ); + const out = await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + }); + expect(out).toMatchObject({ + ok: false, + status: 422, + errorCode: "labs.ocr.outputLimit", + }); + }); + it("names a provider failure and never keeps the upstream body", async () => { vi.mocked(runOcrExtraction).mockRejectedValue( Object.assign(new Error("Local AI request failed (403)"), { @@ -128,37 +256,103 @@ describe("text mode", () => { }); describe("vision mode", () => { - it("renders a PDF for a provider without native PDF and sends the pages", async () => { + it("reads a rendered PDF one page per call", async () => { vi.mocked(rasterizePdf).mockResolvedValue({ ok: true, - images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }], - pageCount: 1, + images: pages(3), + pageCount: 3, }); - vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never); + vi.mocked(runOcrExtraction).mockResolvedValue(extraction()); + const s = schedule(Number.POSITIVE_INFINITY); const out = await executeOcrExtraction({ userId: "u1", input: pdf(), budget, + schedule: s, }); expect(out).toEqual({ ok: true, data: { rows: [] } }); - expect(runOcrExtraction).toHaveBeenCalledWith( - expect.objectContaining({ - images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }], - documents: [], - }), - ); + expect(rasterizePdf).toHaveBeenCalledWith(expect.anything(), OCR_MAX_PAGES); + const args = vi.mocked(runOcrExtraction).mock.calls[0][0]; + expect(args.totalPages).toBe(3); + expect(args.chunks).toEqual([ + { images: [pages(3)[0]], documents: [], pages: [1] }, + { images: [pages(3)[1]], documents: [], pages: [2] }, + { images: [pages(3)[2]], documents: [], pages: [3] }, + ]); + expect(s.arm).toHaveBeenCalledWith(6); + }); + + it("reads a multi-page PDF page by page on a provider that reads PDFs too", async () => { + vi.mocked(rasterizePdf).mockResolvedValue({ + ok: true, + images: pages(4), + pageCount: 4, + }); + vi.mocked(runOcrExtraction).mockResolvedValue(extraction()); + await executeOcrExtraction({ + userId: "u1", + input: pdf(anthropicPick), + budget, + }); + const args = vi.mocked(runOcrExtraction).mock.calls[0][0]; + expect(args.chunks.map((c) => c.pages)).toEqual([[1], [2], [3], [4]]); + expect(args.chunks.every((c) => c.documents?.length === 0)).toBe(true); + }); + + it("keeps the native document for a one-page PDF, or one that cannot be rendered", async () => { + vi.mocked(runOcrExtraction).mockResolvedValue(extraction()); + vi.mocked(rasterizePdf).mockResolvedValueOnce({ + ok: true, + images: pages(1), + pageCount: 1, + }); + vi.mocked(rasterizePdf).mockResolvedValueOnce({ + ok: false, + reason: "unsupported", + }); + for (let i = 0; i < 2; i += 1) { + await executeOcrExtraction({ + userId: "u1", + input: pdf(anthropicPick), + budget, + }); + const args = vi.mocked(runOcrExtraction).mock.calls[i][0]; + expect(args.chunks).toHaveLength(1); + expect(args.chunks[0].documents).toHaveLength(1); + expect(args.chunks[0].images).toEqual([]); + } + }); + + it("puts several pages in a read when the deadline allows fewer reads", async () => { + vi.mocked(rasterizePdf).mockResolvedValue({ + ok: true, + images: pages(10), + pageCount: 10, + }); + vi.mocked(runOcrExtraction).mockResolvedValue(extraction()); + // Five sequential calls fit: two reads with their retries. + const s = schedule(5); + await executeOcrExtraction({ + userId: "u1", + input: pdf(), + budget, + schedule: s, + }); + const args = vi.mocked(runOcrExtraction).mock.calls[0][0]; + expect(args.chunks.map((c) => c.pages)).toEqual([ + [1, 2, 3, 4, 5], + [6, 7, 8, 9, 10], + ]); + expect(s.arm).toHaveBeenCalledWith(4); }); it("says when only the first pages of a long PDF were read", async () => { vi.mocked(rasterizePdf).mockResolvedValue({ ok: true, - images: Array.from({ length: 10 }, () => ({ - mediaType: "image/jpeg" as const, - dataBase64: "cGFnZQ==", - })), + images: pages(OCR_MAX_PAGES), pageCount: 23, }); - vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never); + vi.mocked(runOcrExtraction).mockResolvedValue(extraction()); const out = await executeOcrExtraction({ userId: "u1", input: pdf(), @@ -166,7 +360,7 @@ describe("vision mode", () => { }); expect(out).toEqual({ ok: true, - data: { rows: [], pageCoverage: { read: 10, total: 23 } }, + data: { rows: [], pageCoverage: { read: OCR_MAX_PAGES, total: 23 } }, }); }); @@ -192,10 +386,7 @@ describe("vision mode", () => { 0, "2027-01-15", 0, - { - servedBy: null, - reservedOwner: "operator", - }, + { servedBy: null, reservedOwner: "operator" }, ); }); }); diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index 640a902e8..c501fc0f0 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -1,10 +1,19 @@ /** * v1.18.9 — Lab-OCR extraction orchestration. * - * Takes the sniffed upload bytes + the resolved vision provider, runs ONE - * (retry-once) structured-extraction call, validates the untrusted model - * output against `extractedLabsSchema`, then annotates each row with a - * server-computed biomarker match + duplicate check before returning the DTO. + * Takes the report split into reads (`OcrChunk`: a page or a few, a photo, + * a part of the OCR'd text) and the resolved provider, runs one (retry-once) + * structured-extraction call per read, validates the untrusted model output + * row by row (`parseExtractedLabs`), merges the reads in report order, then + * annotates each row with a server-computed biomarker match + duplicate check + * before returning the DTO. + * + * Why per read: the output a call may produce is bounded, and a whole + * multi-panel report in one call outgrew any fixed ceiling and the response + * timeout alike. A page's JSON fits one call, so the work scales with the + * document. A read that still stops at the output limit is detected from the + * finish reason, keeps the rows that closed before the cut, and is named on + * the review screen instead of failing the scan as unreadable. * * Nothing here writes to the database — extraction is read-only. The raw image * / PDF bytes live in memory only and are never logged or persisted; the @@ -23,6 +32,7 @@ import { type CompletionParams, } from "@/lib/ai/types"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { TRANSCRIPTION_REASONING } from "@/lib/ai/reasoning/resolve"; import { prisma } from "@/lib/db"; import { isUnreadableRange, @@ -37,7 +47,8 @@ import { import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { annotate } from "@/lib/logging/context"; import { - extractedLabsSchema, + parseExtractedLabs, + type ExtractedLabs, type ExtractedRow, type OcrExtractResponseDto, type OcrExtractedRowDto, @@ -107,30 +118,138 @@ function normaliseDate(raw: string | null): string | null { return statedDateKey(raw); } +/** Appended to the user turn when the first answer was not the schema. */ +const RETRY_SUFFIX = + "\n\nYour previous response was not valid JSON matching the schema. Return ONLY the JSON object described in the system prompt, with no prose or markdown."; + +/** Why a read produced nothing usable. */ +export type OcrExtractFailureReason = "unparseable" | "output_limit"; + +export class OcrExtractError extends Error { + constructor( + message: string, + /** + * `output_limit`: every read stopped at the provider's output limit + * before it returned a single complete row. `unparseable`: the provider + * answered, but not with the schema, even after the corrective retry. + */ + readonly reason: OcrExtractFailureReason = "unparseable", + ) { + super(message); + this.name = "OcrExtractError"; + } +} + /** - * Run the provider extraction call once, parse + schema-validate the JSON, and - * retry once with a corrective suffix on a parse/schema miss. Returns the - * parsed envelope or throws (caller maps to a clear 422 extract-failed). + * True for a provider error that means "the reply was cut at the output + * limit". The Codex stream reports that as a terminal `response.incomplete` + * event rather than a finish reason, and its client throws on it. */ -async function extractOnce( +function isOutputLimitError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + (err as { incompleteReason?: unknown }).incompleteReason === + "max_output_tokens" + ); +} + +/** + * The rows of a JSON reply that was cut off at the output limit, up to the + * last row object that closed. A provider that stops mid-row leaves a prefix + * that no JSON parser accepts, so without this a report that ran one row too + * long lost every row it had already transcribed. + * + * Walks the text once, tracking strings and nesting, and remembers where the + * last object directly inside the top-level object's first array closed + * (`{ "rows": [ {…}, {…}, {…` → after the second). That prefix, closed with + * `]}`, is parsed. Null when the reply holds no complete row, or when it is + * not a cut-off object at all (a complete document is the parser's job). + */ +export function salvageTruncatedJson(raw: string): unknown { + const start = raw.indexOf("{"); + if (start < 0) return null; + const stack: string[] = []; + let inString = false; + let escaped = false; + let lastRowEnd = -1; + for (let i = start; i < raw.length; i += 1) { + const c = raw[i]; + if (inString) { + if (escaped) escaped = false; + else if (c === "\\") escaped = true; + else if (c === '"') inString = false; + continue; + } + if (c === '"') inString = true; + else if (c === "{" || c === "[") stack.push(c); + else if (c === "}" || c === "]") { + stack.pop(); + if (stack.length === 0) return null; + if (c === "}" && stack.length === 2 && stack[1] === "[") lastRowEnd = i; + } + } + if (lastRowEnd < 0) return null; + try { + return JSON.parse(`${raw.slice(start, lastRowEnd + 1)}]}`); + } catch { + return null; + } +} + +/** What one provider call returned, reduced to what the merge needs. */ +interface OcrCallOutcome { + envelope: ExtractedLabs | null; + /** The reply stopped at the output limit. */ + truncated: boolean; + /** Tokens the provider reported, or the call's ceiling when it reported none. */ + spent: number; +} + +async function callOnce( provider: AIProvider, params: CompletionParams, -): Promise> { - const raw = await provider.generateCompletion(params); +): Promise { + const ceiling = params.maxTokens ?? 0; + let raw; + try { + raw = await provider.generateCompletion(params); + } catch (err) { + if (isOutputLimitError(err)) { + return { envelope: null, truncated: true, spent: ceiling }; + } + throw err; + } + const truncated = raw.finishReason === "length"; let json: unknown; try { json = JSON.parse(raw.content); } catch { - return extractedLabsSchema.safeParse(undefined); + json = truncated ? salvageTruncatedJson(raw.content) : undefined; } - return extractedLabsSchema.safeParse(json); + return { + envelope: parseExtractedLabs(json), + truncated, + spent: raw.tokensUsed ?? ceiling, + }; } -export class OcrExtractError extends Error { - constructor(message: string) { - super(message); - this.name = "OcrExtractError"; - } +/** + * One read: the call, and one corrective retry when the answer was not the + * schema. A reply cut at the output limit is not retried — the same request + * stops at the same limit — and keeps whatever rows closed before the cut. + */ +async function readChunk( + provider: AIProvider, + params: CompletionParams, +): Promise { + const first = await callOnce(provider, params); + if (first.envelope || first.truncated) return first; + const retry = await callOnce( + provider, + appendToLastUserMessage(params, RETRY_SUFFIX), + ); + return { ...retry, spent: first.spent + retry.spent }; } /** @@ -253,82 +372,217 @@ export async function annotateRow( }; } -export interface RunOcrExtractionArgs { - userId: string; - provider: AIProvider; - providerType: string; - /** Image inputs (jpeg/png/webp). Empty when a PDF or OCR text is sent. */ +/** + * One provider call's share of a report: some of its pages (images, or the + * whole PDF as one document), or a part of the OCR'd text. + */ +export interface OcrChunk { images?: { mediaType: "image/jpeg" | "image/png" | "image/webp"; dataBase64: string; }[]; - /** PDF input (Anthropic-only). Empty when images or OCR text are sent. */ + /** PDF input (Anthropic-only), sent whole as one read. */ documents?: { mediaType: "application/pdf"; dataBase64: string }[]; /** - * TEXT-mode (local-OCR) input. When set, the extraction structures this + * TEXT-mode (local-OCR) input. When set, the read structures this * in-browser-OCR'd text via a text-only provider; `images`/`documents` are * left unset so the codex/text wire (which only sends `input_text`) works * unchanged and the raw image never reaches the server. */ - ocrText?: string; + text?: string; + /** + * The report pages (1-based) this chunk carries. Empty when the input has + * no pages of its own: a photo, a PDF sent whole, a part of the OCR text. + */ + pages: number[]; +} + +export interface RunOcrExtractionArgs { + userId: string; + provider: AIProvider; + providerType: string; + mode: "vision" | "text"; + /** The reads, in report order. At least one. */ + chunks: OcrChunk[]; + /** The page count of the document the chunks come from, when it has pages. */ + totalPages?: number; +} + +export interface OcrExtraction { + data: OcrExtractResponseDto; + /** What the reads cost, as the provider reported it (else each call's ceiling). */ + spentTokens: number; +} + +/** "3", "3-4". */ +function pageSpan(pages: number[]): string { + if (pages.length === 1) return String(pages[0]); + return `${pages[0]}-${pages[pages.length - 1]}`; +} + +/** The completion one chunk sends. */ +export function ocrChunkParams( + mode: "vision" | "text", + chunk: OcrChunk, + index: number, + count: number, + totalPages?: number, +): CompletionParams { + const base = { + temperature: AI_BUDGETS.ocrExtract.temperature, + maxTokens: AI_BUDGETS.ocrExtract.maxTokens, + timeoutMs: AI_BUDGETS.ocrExtract.timeoutMs, + responseFormat: "json" as const, + }; + if (mode === "text") { + // The text goes in the user prompt and images/documents stay UNSET, so + // the text-only provider wire (codex `input_text`) carries it unchanged. + const part = count > 1 ? ` (part ${index + 1} of ${count})` : ""; + return { + ...singleUserTurn({ + system: TEXT_SYSTEM_PROMPT, + user: `Structure the following OCR'd lab-report text into the JSON schema described in the system prompt. Return only the JSON object.\n\nOCR TEXT${part}:\n${chunk.text ?? ""}`, + ...base, + }), + reasoning: TRANSCRIPTION_REASONING, + }; + } + const paged = + chunk.pages.length > 0 && totalPages !== undefined && totalPages > 1 + ? ` The attached ${chunk.pages.length === 1 ? "image is page" : "images are pages"} ${pageSpan(chunk.pages)} of a ${totalPages}-page report; transcribe the readings printed on ${chunk.pages.length === 1 ? "it" : "them"}.` + : ""; + return { + ...singleUserTurn({ + system: SYSTEM_PROMPT, + user: `${USER_PROMPT}${paged}`, + ...base, + images: chunk.images, + documents: chunk.documents, + }), + reasoning: TRANSCRIPTION_REASONING, + }; +} + +/** The identity of a reading for the merge: the same row printed twice is one. */ +function readingKey(row: ExtractedRow, rowDate: string | null): string { + return [ + row.analyte.trim().toLowerCase(), + row.value ?? "", + (row.valueText ?? "").trim().toLowerCase(), + (row.unit ?? "").trim().toLowerCase(), + normaliseDate(row.takenAt) ?? rowDate ?? "", + ].join("|"); } /** - * Run the extraction end-to-end: provider call → schema-validate → per-row - * server annotation. Caps the returned rows. Throws `OcrExtractError` when the - * provider returns nothing parseable after the retry. + * Run the extraction end-to-end: one read per chunk, merged in report order, + * then per-row server annotation. Throws `OcrExtractError` when no read + * produced a usable envelope, and the provider's own error when the first + * read fails outright (nothing to keep). A provider failure on a later read + * keeps what the earlier reads produced and names the pages it did not reach. */ export async function runOcrExtraction( args: RunOcrExtractionArgs, -): Promise { - const isTextMode = typeof args.ocrText === "string"; +): Promise { + const { chunks } = args; + const reads: { envelope: ExtractedLabs; chunk: OcrChunk }[] = []; + const truncatedPages: number[] = []; + const unreadPages: number[] = []; + let truncatedReads = 0; + let truncatedKeptReads = 0; + let unreadReads = 0; + let spentTokens = 0; - // Text mode: structure the in-browser-OCR'd text. The text goes in the user - // prompt and images/documents stay UNSET, so the text-only provider wire - // (codex `input_text`) carries it unchanged — the image never reaches here. - const params: CompletionParams = isTextMode - ? singleUserTurn({ - system: TEXT_SYSTEM_PROMPT, - user: `Structure the following OCR'd lab-report text into the JSON schema described in the system prompt. Return only the JSON object.\n\nOCR TEXT:\n${args.ocrText}`, - temperature: AI_BUDGETS.ocrExtract.temperature, - maxTokens: AI_BUDGETS.ocrExtract.maxTokens, - responseFormat: "json", - }) - : singleUserTurn({ - system: SYSTEM_PROMPT, - user: USER_PROMPT, - temperature: AI_BUDGETS.ocrExtract.temperature, - maxTokens: AI_BUDGETS.ocrExtract.maxTokens, - responseFormat: "json", - images: args.images, - documents: args.documents, + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; + let read: OcrCallOutcome; + try { + read = await readChunk( + args.provider, + ocrChunkParams(args.mode, chunk, i, chunks.length, args.totalPages), + ); + } catch (err) { + if (reads.length === 0) throw err; + // The provider stopped answering partway through. What the earlier + // pages produced is kept; the rest is named on the review screen. + for (const rest of chunks.slice(i)) unreadPages.push(...rest.pages); + unreadReads += chunks.length - i; + annotate({ + action: { name: "labs.ocr.partialRead" }, + meta: { + reason: "provider_error", + providerType: args.providerType, + readsDone: i, + reads: chunks.length, + }, }); - - let parsed = await extractOnce(args.provider, params); - if (!parsed.success) { - // One corrective retry — re-state the schema requirement in the prompt. - // Reuse the mode's own user prompt (text mode carries the OCR text in it); - // appendToLastUserMessage keeps the single user turn, folding the - // correction into its text (or as a trailing text part on the vision path). - const retryParams = appendToLastUserMessage( - params, - "\n\nYour previous response was not valid JSON matching the schema. Return ONLY the JSON object described in the system prompt, with no prose or markdown.", - ); - parsed = await extractOnce(args.provider, retryParams); + break; + } + spentTokens += read.spent; + if (read.truncated) truncatedReads += 1; + if (read.envelope) { + reads.push({ envelope: read.envelope, chunk }); + if (read.truncated) { + truncatedKeptReads += 1; + truncatedPages.push(...chunk.pages); + } + } else { + unreadReads += 1; + unreadPages.push(...chunk.pages); + } } - if (!parsed.success) { + if (reads.length === 0) { + const outputLimit = truncatedReads > 0; annotate({ action: { name: "labs.ocr.extractFailed" }, - meta: { reason: "schema_mismatch", providerType: args.providerType }, + meta: { + reason: outputLimit ? "output_limit" : "schema_mismatch", + providerType: args.providerType, + reads: chunks.length, + }, }); - throw new OcrExtractError("Provider returned no parseable extraction"); + throw new OcrExtractError( + outputLimit + ? "Provider stopped at its output limit before returning a row" + : "Provider returned no parseable extraction", + outputLimit ? "output_limit" : "unparseable", + ); } - const envelope = parsed.data; - const reportDate = normaliseDate(envelope.reportDate); + // The report's date is the first one a read found; a page that prints its + // own date keeps it for its rows. + const reportDate = + reads + .map((r) => normaliseDate(r.envelope.reportDate)) + .find((d) => d !== null) ?? null; + + // Merge in report order. A reading printed twice (a summary page repeating + // a value, a table running across a page break) is listed once; the commit + // would skip the second copy as a duplicate anyway. + const seen = new Set(); + const merged: { row: ExtractedRow; rowDate: string | null }[] = []; + let mergedDuplicates = 0; + let droppedRows = 0; + for (const { envelope } of reads) { + droppedRows += envelope.droppedRows; + const rowDate = normaliseDate(envelope.reportDate) ?? reportDate; + for (const row of envelope.rows) { + const key = readingKey(row, rowDate); + if (seen.has(key)) { + mergedDuplicates += 1; + continue; + } + seen.add(key); + merged.push({ row, rowDate }); + } + } + + // More readings than one scan carries: keep the first ones and say so, + // rather than refusing the lot. + const found = merged.length; + const kept = merged.slice(0, OCR_MAX_ROWS); - // Cap defensively (the schema already bounds it) and annotate each row. const rows: OcrExtractedRowDto[] = []; // How many rows printed a window the parser could not read. Those rows fall // back to whatever bounds the model itself reported, or to none — either way @@ -338,19 +592,25 @@ export async function runOcrExtraction( let unreadableRanges = 0; const tz = await resolveUserTimezone(args.userId); const markerIndex = await loadMarkerIndex(prisma, args.userId); - for (const row of envelope.rows.slice(0, OCR_MAX_ROWS)) { + for (const { row, rowDate } of kept) { if (isUnreadableRange(parseReferenceRange(row.referenceText, row.unit))) { unreadableRanges += 1; } - rows.push(await annotateRow(args.userId, row, reportDate, tz, markerIndex)); + rows.push(await annotateRow(args.userId, row, rowDate, tz, markerIndex)); } annotate({ action: { name: "labs.ocr.extracted" }, meta: { - mode: isTextMode ? "text" : "vision", + mode: args.mode, providerType: args.providerType, rows: rows.length, + reads: chunks.length, + truncatedReads, + unreadReads, + droppedRows, + mergedDuplicates, + rowsCapped: found > kept.length, duplicates: rows.filter((r) => r.duplicateOf !== null).length, newBiomarkers: rows.filter((r) => r.biomarkerMatch === "new").length, aliasMatches: rows.filter((r) => r.matchKind === "alias").length, @@ -360,8 +620,18 @@ export async function runOcrExtraction( }); return { - reportDate, - providerType: args.providerType, - rows, + data: { + reportDate, + providerType: args.providerType, + rows, + ...(truncatedKeptReads > 0 + ? { truncated: { pages: truncatedPages } } + : {}), + ...(unreadReads > 0 ? { unreadPages } : {}), + ...(found > kept.length + ? { rowsCapped: { kept: kept.length, found } } + : {}), + }, + spentTokens, }; } diff --git a/src/lib/labs/ocr-run.ts b/src/lib/labs/ocr-run.ts index c67c96b14..10077ccd8 100644 --- a/src/lib/labs/ocr-run.ts +++ b/src/lib/labs/ocr-run.ts @@ -3,10 +3,11 @@ * * `POST /api/labs/ocr/extract` checks the capability, the rate bucket and the * budget, validates the upload, seals it into a `DocumentAiRun` and answers - * 202. This is the read itself: the same PDF handling the route used to do in - * the request (a native document block on a PDF-capable provider, rendered - * page images on any other), one extraction with its corrective retry, and the - * reservation settled against what the provider was asked to do. + * 202. This is the read itself: the report split into reads (a PDF a page at + * a time, as many reads as the run's deadline allows; a photo as one; OCR'd + * text in parts at line boundaries), one extraction per read with its + * corrective retry, and the reservation settled against what the provider + * reported it spent. * * Nothing is written to the person's record here. The proposed rows go back * through the run for the mandatory review screen, and the commit route stays @@ -21,19 +22,99 @@ import type { AiRunBudget, AiRunOutcome } from "@/lib/documents/ai-runs/types"; import { rasterizePdf } from "@/lib/documents/rasterize-pdf"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; import type { VisionProviderPick } from "@/lib/labs/ocr-capability"; -import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract"; +import { + OcrExtractError, + runOcrExtraction, + type OcrChunk, +} from "@/lib/labs/ocr-extract"; import { annotate } from "@/lib/logging/context"; import type { OcrExtractResponseDto } from "@/lib/validations/labs-ocr"; -/** The most sequential model calls one scan makes: the read and one retry. */ +/** The most sequential model calls one read makes: the call and one retry. */ export const OCR_EXTRACT_MODEL_CALLS = 2; +/** + * The most PDF pages a scan reads. A multi-panel hospital report runs to a + * dozen pages; past this the review screen says which pages were read. + */ +export const OCR_MAX_PAGES = 20; + +/** + * OCR'd text per read. A line of a lab table is about one reading, and this + * many characters keep a read's JSON well inside the per-read output ceiling. + * One photo's text is usually below it, so it stays one read. + */ +export const OCR_TEXT_READ_CHARS = 4000; + /** The tokens a scan reserves at enqueue, by mode. */ export const OCR_RESERVE_TOKENS = { vision: AI_BUDGETS.ocrExtract.maxTokens, text: AI_BUDGETS.ocrExtractText.maxTokens, } as const; +/** + * What the run's deadline allows. The worker knows the person's response time + * and the job's expiry; the read only knows how many calls it wants to make. + */ +export interface OcrSchedule { + /** Sequential model calls that still fit before the run's deadline. */ + maxModelCalls(): number; + /** Move the run's deadline to cover `modelCalls` sequential calls. */ + arm(modelCalls: number): Promise; +} + +/** + * Split `pageCount` pages into contiguous reads: one page each, unless the + * deadline allows fewer reads than pages, in which case the pages are shared + * out evenly. Every page lands in exactly one read. + */ +export function planPageReads(pageCount: number, maxReads: number): number[][] { + const reads = Math.max(1, Math.min(pageCount, Math.floor(maxReads))); + const perRead = Math.ceil(pageCount / reads); + const out: number[][] = []; + for (let first = 1; first <= pageCount; first += perRead) { + const last = Math.min(first + perRead - 1, pageCount); + out.push(Array.from({ length: last - first + 1 }, (_, i) => first + i)); + } + return out; +} + +/** + * Split OCR'd text into reads at line boundaries (a lab table row is a line, + * so no reading is cut in half), no more than `maxReads` of them. Text that + * fits one read stays one read. + */ +export function splitOcrText(text: string, maxReads: number): string[] { + const reads = Math.max(1, Math.floor(maxReads)); + const target = Math.max(OCR_TEXT_READ_CHARS, Math.ceil(text.length / reads)); + if (text.length <= target) return [text]; + const parts: string[] = []; + let current = ""; + for (const line of text.split("\n")) { + if (current && current.length + 1 + line.length > target) { + parts.push(current); + current = line; + } else { + current = current ? `${current}\n${line}` : line; + } + } + if (current) parts.push(current); + while (parts.length > reads) { + const last = parts.pop() as string; + parts[parts.length - 1] = `${parts[parts.length - 1]}\n${last}`; + } + return parts; +} + +/** How many reads the deadline leaves room for. */ +function maxReadsFor(schedule: OcrSchedule | undefined): number { + if (!schedule) return Number.POSITIVE_INFINITY; + return Math.max( + 1, + Math.floor(schedule.maxModelCalls() / OCR_EXTRACT_MODEL_CALLS), + ); +} + export type OcrVisionPick = NonNullable; export interface OcrTextPick { entry: ProviderChainResolved; @@ -74,36 +155,60 @@ const failure = (f: { errorCode: string; }): AiRunOutcome => ({ ok: false, ...f }); +/** The 422 a read that produced nothing usable answers with. */ +function unreadable(err: OcrExtractError): AiRunOutcome { + return err.reason === "output_limit" + ? failure({ + status: 422, + message: + "The AI provider stopped at its output limit before it returned a reading.", + errorCode: "labs.ocr.outputLimit", + }) + : failure({ + status: 422, + message: "Couldn't read the report. Try a clearer photo.", + errorCode: "labs.ocr.extractFailed", + }); +} + /** Read one lab report into proposed rows. The caller authorised the read. */ export async function executeOcrExtraction(args: { userId: string; input: OcrRunInput; budget: AiRunBudget | undefined; + /** The run's deadline; absent, the read is planned without one. */ + schedule?: OcrSchedule; }): Promise> { - const { userId, input, budget } = args; + const { userId, input, budget, schedule } = args; const servedBy = input.pick.entry.providerType as ProviderChainType; + // The run is charged what the provider reported for every read; a provider + // that reports nothing is charged the reservation. + const settleSpent = (spent: number) => + settleAiRunBudget( + userId, + budget, + spent > 0 ? spent : (budget?.reserved ?? 0), + servedBy, + ); if (input.mode === "text") { + const parts = splitOcrText(input.text, maxReadsFor(schedule)); + await schedule?.arm(parts.length * OCR_EXTRACT_MODEL_CALLS); try { const result = await runOcrExtraction({ userId, provider: input.pick.entry.instance, providerType: input.pick.providerType, - ocrText: input.text, + mode: "text", + chunks: parts.map((text) => ({ text, pages: [] })), }); - await settleAiRunBudget(userId, budget, budget?.reserved ?? 0, servedBy); - return { ok: true, data: result }; + await settleSpent(result.spentTokens); + return { ok: true, data: result.data }; } catch (err) { // A failed structuring call produced no usable rows; the reservation // goes back in full rather than being charged. await settleAiRunBudget(userId, budget, 0, null); - if (err instanceof OcrExtractError) { - return failure({ - status: 422, - message: "Couldn't read the report. Try a clearer photo.", - errorCode: "labs.ocr.extractFailed", - }); - } + if (err instanceof OcrExtractError) return unreadable(err); annotate({ action: { name: "labs.ocr.extractFailed" }, meta: providerFailureMeta(err, "text"), @@ -118,35 +223,25 @@ export async function executeOcrExtraction(args: { } const { pick, bytes, mime } = input; - let images: { - mediaType: "image/jpeg" | "image/png" | "image/webp"; - dataBase64: string; - }[]; - let documents: { mediaType: "application/pdf"; dataBase64: string }[]; + let chunks: OcrChunk[]; + let totalPages: number | undefined; let pageCoverage: { read: number; total: number } | undefined; if (mime === "application/pdf") { - if (pick.pdfSupported) { - images = []; - documents = [ - { mediaType: "application/pdf", dataBase64: bytes.toString("base64") }, - ]; - } else { - const raster = await rasterizePdf(bytes); - if (!raster.ok) { - annotate({ - action: { name: "labs.ocr.fileRejected" }, - meta: { reason: "pdf_rasterize_failed" }, - }); - await settleAiRunBudget(userId, budget, 0, null); - return failure({ - status: 422, - message: "Couldn't read this PDF; upload a photo instead.", - errorCode: "labs.ocr.pdfNeedsAnthropic", - }); - } - images = raster.images; - documents = []; + // Every PDF is rendered first: a report of more than one page is read a + // page at a time, whatever the provider, so no single call has to hold + // the whole report. A provider that reads PDFs natively still gets the + // document itself when it is one page, or when it cannot be rendered. + const raster = await rasterizePdf(bytes, OCR_MAX_PAGES); + if (raster.ok && (raster.images.length > 1 || !pick.pdfSupported)) { + totalPages = raster.pageCount; + chunks = planPageReads(raster.images.length, maxReadsFor(schedule)).map( + (pages) => ({ + images: pages.map((p) => raster.images[p - 1]), + documents: [], + pages, + }), + ); // A PDF longer than the page cap is read from its first pages only, and // the person reviewing the rows is the one who needs to know. if (raster.pageCount > raster.images.length) { @@ -156,36 +251,59 @@ export async function executeOcrExtraction(args: { meta: { read: raster.images.length, total: raster.pageCount }, }); } + } else if (pick.pdfSupported) { + chunks = [ + { + images: [], + documents: [ + { + mediaType: "application/pdf", + dataBase64: bytes.toString("base64"), + }, + ], + pages: [], + }, + ]; + } else { + annotate({ + action: { name: "labs.ocr.fileRejected" }, + meta: { reason: "pdf_rasterize_failed" }, + }); + await settleAiRunBudget(userId, budget, 0, null); + return failure({ + status: 422, + message: "Couldn't read this PDF; upload a photo instead.", + errorCode: "labs.ocr.pdfNeedsAnthropic", + }); } } else { - images = [{ mediaType: mime, dataBase64: bytes.toString("base64") }]; - documents = []; + chunks = [ + { + images: [{ mediaType: mime, dataBase64: bytes.toString("base64") }], + documents: [], + pages: [], + }, + ]; } + await schedule?.arm(chunks.length * OCR_EXTRACT_MODEL_CALLS); try { const result = await runOcrExtraction({ userId, provider: pick.entry.instance, providerType: pick.providerType, - images, - documents, + mode: "vision", + chunks, + totalPages, }); - // The orchestration does not surface token counts; the reserved estimate - // is the spend ceiling (the provider already billed it). - await settleAiRunBudget(userId, budget, budget?.reserved ?? 0, servedBy); + await settleSpent(result.spentTokens); return { ok: true, - data: pageCoverage ? { ...result, pageCoverage } : result, + data: pageCoverage ? { ...result.data, pageCoverage } : result.data, }; } catch (err) { await settleAiRunBudget(userId, budget, 0, servedBy); - if (err instanceof OcrExtractError) { - return failure({ - status: 422, - message: "Couldn't read the report. Try a clearer photo.", - errorCode: "labs.ocr.extractFailed", - }); - } + if (err instanceof OcrExtractError) return unreadable(err); annotate({ action: { name: "labs.ocr.extractFailed" }, meta: providerFailureMeta(err), diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 7849dc564..ee24a3763 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -249,6 +249,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "labs.ocr.fileTooLarge", "labs.ocr.fileType", "labs.ocr.localOcrDisabled", + "labs.ocr.outputLimit", "labs.ocr.pdfNeedsAnthropic", "labs.ocr.providerUnsupported", "labs.ocr.rateLimited", diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 7bc03b47a..c49c6c190 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -34,7 +34,7 @@ import { const ocrCommitRequest = ocrCommitSchema.meta({ id: "OcrCommitRequest", description: - "The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value). The optional `encounterId` files the panel against a visit the caller owns — ONE link per written result row, so a marker re-run on a different day belongs to its own visit; always optional, and a link that could not be made never fails the commit.", + "The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..400 rows (`analyte` and `valueText` up to 120 characters, `unit` up to 40). The route skips a row that duplicates a live reading (same analyte + day + value). The optional `encounterId` files the panel against a visit the caller owns — ONE link per written result row, so a marker re-run on a different day belongs to its own visit; always optional, and a link that could not be made never fails the commit.", }); const capabilityResponse = z @@ -127,13 +127,34 @@ export const ocrExtractResponse = z }) .optional() .describe( - "Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total` pages. Absent when the whole document was read.", + "Present only when a PDF ran past the pages a scan reads (20): the rows come from the first `read` of `total` pages. Absent when the whole document was read.", + ), + truncated: z + .object({ pages: z.array(z.number().int().positive()) }) + .optional() + .describe( + "Present only when a read stopped at the provider's output limit. The rows that read produced before the cut are kept; readings after the cut may be missing. `pages` names the pages whose read was cut, or is empty when the report went to the provider as one read (a photo, a one-page PDF, OCR'd text).", + ), + unreadPages: z + .array(z.number().int().positive()) + .optional() + .describe( + "Present only when part of a multi-part read produced nothing: the provider failed partway through, or a part's reply could not be read. The rows of the other parts are kept; these pages are not in `rows`. Empty when the parts were not pages (OCR'd text).", + ), + rowsCapped: z + .object({ + kept: z.number().int().positive(), + found: z.number().int().positive(), + }) + .optional() + .describe( + "Present only when the report held more readings than one scan carries (400): `rows` holds the first `kept` of the `found` readings.", ), }) .meta({ id: "OcrExtractResponse", description: - "The proposed rows for the human review screen, served by `GET /api/ai-runs/{id}` for a `LABS_OCR_EXTRACT` run. Never written to the person's record; the commit route is the only write.", + "The proposed rows for the human review screen, served by `GET /api/ai-runs/{id}` for a `LABS_OCR_EXTRACT` run. A PDF of more than one page is read a page at a time and the pages merged in order, a reading printed on two pages listed once. Never written to the person's record; the commit route is the only write. A run in which no read produced a single complete row fails with 422 `labs.ocr.outputLimit` when the reads stopped at the provider's output limit, else `labs.ocr.extractFailed`.", }); const committedRow = z diff --git a/src/lib/validations/__tests__/labs-ocr.test.ts b/src/lib/validations/__tests__/labs-ocr.test.ts index 5f83a564d..beddb4574 100644 --- a/src/lib/validations/__tests__/labs-ocr.test.ts +++ b/src/lib/validations/__tests__/labs-ocr.test.ts @@ -1,14 +1,10 @@ import { describe, it, expect } from "vitest"; -import { - extractedLabsSchema, - ocrCommitSchema, - OCR_MAX_ROWS, -} from "../labs-ocr"; +import { ocrCommitSchema, OCR_MAX_ROWS, parseExtractedLabs } from "../labs-ocr"; -describe("extractedLabsSchema (untrusted provider output)", () => { +describe("parseExtractedLabs (untrusted provider output)", () => { it("parses a well-formed numeric + qualitative envelope", () => { - const parsed = extractedLabsSchema.safeParse({ + const parsed = parseExtractedLabs({ reportDate: "2026-06-14", rows: [ { @@ -33,15 +29,13 @@ describe("extractedLabsSchema (untrusted provider output)", () => { }, ], }); - expect(parsed.success).toBe(true); - if (parsed.success) { - expect(parsed.data.rows).toHaveLength(2); - expect(parsed.data.rows[1].valueText).toBe("negativ"); - } + expect(parsed?.rows).toHaveLength(2); + expect(parsed?.rows[1].valueText).toBe("negativ"); + expect(parsed?.droppedRows).toBe(0); }); it("tolerates a missing confidence block (defaults to 0 = flag for review)", () => { - const parsed = extractedLabsSchema.safeParse({ + const parsed = parseExtractedLabs({ reportDate: null, rows: [ { @@ -55,19 +49,16 @@ describe("extractedLabsSchema (untrusted provider output)", () => { }, ], }); - expect(parsed.success).toBe(true); - if (parsed.success) { - expect(parsed.data.rows[0].confidence).toEqual({ - analyte: 0, - value: 0, - unit: 0, - range: 0, - }); - } + expect(parsed?.rows[0].confidence).toEqual({ + analyte: 0, + value: 0, + unit: 0, + range: 0, + }); }); it("coerces a garbage value to null rather than rejecting the row", () => { - const parsed = extractedLabsSchema.safeParse({ + const parsed = parseExtractedLabs({ reportDate: null, rows: [ { @@ -82,13 +73,15 @@ describe("extractedLabsSchema (untrusted provider output)", () => { }, ], }); - expect(parsed.success).toBe(true); - if (parsed.success) expect(parsed.data.rows[0].value).toBeNull(); + expect(parsed?.rows[0].value).toBeNull(); }); - it("rejects more than OCR_MAX_ROWS rows", () => { - const rows = Array.from({ length: OCR_MAX_ROWS + 1 }, () => ({ - analyte: "X", + // A report with more readings than the old 100-row cap used to fail as a + // whole: the envelope's `.max()` rejected the lot. The cap is applied after + // the merge now, keeping the first rows (see ocr-extract tests). + it("accepts more rows than OCR_MAX_ROWS (the cap keeps rows, it never rejects)", () => { + const rows = Array.from({ length: OCR_MAX_ROWS + 1 }, (_, i) => ({ + analyte: `X${i}`, value: 1, valueText: null, unit: "u", @@ -97,9 +90,32 @@ describe("extractedLabsSchema (untrusted provider output)", () => { takenAt: null, confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, })); - expect( - extractedLabsSchema.safeParse({ reportDate: null, rows }).success, - ).toBe(false); + expect(parseExtractedLabs({ reportDate: null, rows })?.rows).toHaveLength( + OCR_MAX_ROWS + 1, + ); + }); + + it("drops a malformed row and keeps the others", () => { + const parsed = parseExtractedLabs({ + reportDate: "2026-06-14", + rows: [ + { analyte: "Ferritin", value: 80, unit: "ng/mL" }, + { analyte: "", value: 1, unit: "u" }, + { value: 2 }, + "not a row", + { analyte: "a".repeat(201), value: 3, unit: "u" }, + { analyte: "TSH", value: 1.2, unit: "mIU/L" }, + ], + }); + expect(parsed?.rows.map((r) => r.analyte)).toEqual(["Ferritin", "TSH"]); + expect(parsed?.droppedRows).toBe(4); + expect(parsed?.reportDate).toBe("2026-06-14"); + }); + + it("is null for an envelope without a rows array", () => { + expect(parseExtractedLabs(undefined)).toBeNull(); + expect(parseExtractedLabs({ reportDate: null })).toBeNull(); + expect(parseExtractedLabs({ rows: "x" })).toBeNull(); }); }); @@ -162,4 +178,21 @@ describe("ocrCommitSchema (confirmed rows)", () => { it("rejects an empty rows array", () => { expect(ocrCommitSchema.safeParse({ rows: [] }).success).toBe(false); }); + + it("takes a 300-row report in one commit, and no more than OCR_MAX_ROWS", () => { + const rows = (n: number) => + Array.from({ length: n }, (_, i) => ({ + ...base, + analyte: `Marker ${i}`, + value: i, + unit: "u", + })); + expect(ocrCommitSchema.safeParse({ rows: rows(300) }).success).toBe(true); + expect( + ocrCommitSchema.safeParse({ rows: rows(OCR_MAX_ROWS) }).success, + ).toBe(true); + expect( + ocrCommitSchema.safeParse({ rows: rows(OCR_MAX_ROWS + 1) }).success, + ).toBe(false); + }); }); diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index f1650e721..6b6136045 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -24,8 +24,22 @@ import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; import { isoDateTime } from "@/lib/validations/datetime"; -/** Max rows a single scan / commit may carry — a dense panel is ~30 analytes. */ -export const OCR_MAX_ROWS = 100; +/** + * Max rows a single scan / commit may carry. A single panel is ~30 analytes, + * but a multi-page hospital report with several panels runs to 150-300 + * readings, and a scan that finds more than this keeps the first rows rather + * than losing all of them (`rowsCapped` on the extract response). + */ +export const OCR_MAX_ROWS = 400; + +/** + * The field lengths a confirmed row may carry, shared by the commit schema and + * the review screen so a row the screen lets through is one the commit takes. + * The same bounds the manual lab-entry schema applies. + */ +export const OCR_ANALYTE_MAX = 120; +export const OCR_VALUE_TEXT_MAX = 120; +export const OCR_UNIT_MAX = 40; /** * Far-past floor for a lab sample, mirroring `createLabResultSchema`'s @@ -91,15 +105,43 @@ export const extractedRowSchema = z.object({ confidence: extractedConfidenceSchema, }); -/** The full JSON envelope the provider returns. */ +/** + * The full JSON envelope the provider returns. Rows are checked one by one + * (`parseExtractedLabs`), so one malformed row costs that row and not the + * whole read. + */ export const extractedLabsSchema = z.object({ reportDate: z.string().nullable().catch(null), - rows: z.array(extractedRowSchema).max(OCR_MAX_ROWS), + rows: z.array(z.unknown()), }); -export type ExtractedLabs = z.infer; export type ExtractedRow = z.infer; +export interface ExtractedLabs { + reportDate: string | null; + rows: ExtractedRow[]; + /** Rows the model returned that did not fit the row schema (no name, …). */ + droppedRows: number; +} + +/** + * Validate an untrusted provider envelope. Null when the envelope itself is + * not the documented shape (no `rows` array); otherwise every row that fits + * the row schema, in order, and a count of the ones that did not. + */ +export function parseExtractedLabs(json: unknown): ExtractedLabs | null { + const envelope = extractedLabsSchema.safeParse(json); + if (!envelope.success) return null; + const rows: ExtractedRow[] = []; + let droppedRows = 0; + for (const raw of envelope.data.rows) { + const row = extractedRowSchema.safeParse(raw); + if (row.success) rows.push(row.data); + else droppedRows += 1; + } + return { reportDate: envelope.data.reportDate, rows, droppedRows }; +} + /** * The per-row DTO the EXTRACT route returns to the client. Carries the model's * extracted fields plus server-computed annotations: @@ -164,6 +206,24 @@ export interface OcrExtractResponseDto { * pages, and the review screen says so. */ pageCoverage?: { read: number; total: number }; + /** + * Present only when a read stopped at the provider's output limit. The rows + * the read produced before the cut are kept; readings after it may be + * missing. `pages` names the pages whose read was cut, or is empty when the + * report went to the provider as one read. + */ + truncated?: { pages: number[] }; + /** + * Present only when the provider failed partway through a multi-page read: + * the rows of the pages before the failure are kept, and these pages were + * not read. + */ + unreadPages?: number[]; + /** + * Present only when the report held more readings than one scan carries + * (`OCR_MAX_ROWS`): the first `kept` of `found` are in `rows`. + */ + rowsCapped?: { kept: number; found: number }; } /** @@ -173,7 +233,7 @@ export interface OcrExtractResponseDto { * always present (the review screen requires a name); the commit route always * resolves-or-mints a biomarker by `(userId, lower(analyte))`. */ -const requiredAnalyte = z.string().trim().min(1).max(120); +const requiredAnalyte = z.string().trim().min(1).max(OCR_ANALYTE_MAX); const referenceLow = z.number().finite().optional(); const referenceHigh = z.number().finite().optional(); @@ -188,8 +248,8 @@ export const ocrCommitRowSchema = z .optional() .or(z.literal("").transform(() => undefined)), value: z.number().finite().optional(), - valueText: z.string().trim().min(1).max(120).optional(), - unit: z.string().trim().min(1).max(40).optional(), + valueText: z.string().trim().min(1).max(OCR_VALUE_TEXT_MAX).optional(), + unit: z.string().trim().min(1).max(OCR_UNIT_MAX).optional(), referenceLow, referenceHigh, /** diff --git a/tests/integration/ai-extraction-capability.test.ts b/tests/integration/ai-extraction-capability.test.ts index 4a326b1cc..98e349cfc 100644 --- a/tests/integration/ai-extraction-capability.test.ts +++ b/tests/integration/ai-extraction-capability.test.ts @@ -81,7 +81,10 @@ vi.mock("@/lib/documents/assist", async (importOriginal) => ({ })); vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({ ...(await importOriginal()), - runOcrExtraction: vi.fn(async () => ({ rows: [], reportDate: null })), + runOcrExtraction: vi.fn(async () => ({ + data: { rows: [], reportDate: null, providerType: "openai" }, + spentTokens: 0, + })), })); vi.mock("@/lib/documents/describe", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/tests/integration/document-ai-runs.test.ts b/tests/integration/document-ai-runs.test.ts index ffa9dbba5..8a2a9c206 100644 --- a/tests/integration/document-ai-runs.test.ts +++ b/tests/integration/document-ai-runs.test.ts @@ -91,9 +91,8 @@ vi.mock("@/lib/documents/assist", async (importOriginal) => ({ vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({ ...(await importOriginal()), runOcrExtraction: vi.fn(async () => ({ - reportDate: "2027-01-10", - providerType: "anthropic", - rows: [], + data: { reportDate: "2027-01-10", providerType: "anthropic", rows: [] }, + spentTokens: 900, })), })); vi.mock("@/lib/documents/extract", async (importOriginal) => ({ @@ -427,7 +426,10 @@ describe("a lab scan in the background", () => { expect(await work(runId)).toBe("succeeded"); expect(runOcrExtraction).toHaveBeenCalledWith( - expect.objectContaining({ ocrText: "Hämoglobin 14,2 g/dL" }), + expect.objectContaining({ + mode: "text", + chunks: [{ text: "Hämoglobin 14,2 g/dL", pages: [] }], + }), ); const after = await getPrismaClient().documentAiRun.findUniqueOrThrow({