fix(labs): read a lab report page by page and keep a cut-off read's rows

A scan sent the whole report to one call under an 8000-token ceiling and
validated the reply against a 100-row envelope. A long multi-panel report
lost everything either way: the reply stopped mid-row and failed to parse,
which the person saw as "try a clearer photo", or it held more than 100
rows and the schema refused all of them. One malformed row refused the lot
as well. Reasoning models made it worse, because the call sent no
reasoning setting and OpenAI, Codex and Claude's 5 generation reason by
default against the same output allowance.

A PDF of more than one page is now rendered and read a page per call on
every provider (up to 20 pages), as many reads as the run's deadline
allows, and the pages are merged in order with a reading printed twice
listed once. A photo, a one-page PDF on a provider that reads PDFs, and
OCR'd text up to 4000 characters stay one read; longer text is split at
line boundaries. Each read carries an explicit reasoning off, so a model
that can stop reasoning does and one that cannot gets its minimum budget
on top of the answer's. A reply cut at the output limit is detected from
the finish reason (Codex's incomplete stream included), keeps the rows
that closed before the cut and is named in the result instead of retried;
when no read produced a row the run fails as labs.ocr.outputLimit. A
provider failure after the first page keeps the pages already read.
Rows are validated one by one, the row cap is 400 for the scan and the
commit, and a report past it keeps its first 400 and says how many it
had. The run settles what the provider reported it spent rather than the
fixed reservation, so a page-by-page read still counts against the day's
budget in full.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 10:40:45 +02:00
1 parent 421aacf457
commit 10cfdf8b66
20 files changed
+1596 -257

No files matched your search

+64 -31
View File
@@ -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);
});
});
+68 -8
View File
@@ -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<typeof extractedLabsSchema>;
export type ExtractedRow = z.infer<typeof extractedRowSchema>;
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,
/**