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

+55 -11
View File
@@ -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:
+3 -1
View File
@@ -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;
@@ -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);
+17 -18
View File
@@ -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 },
+9
View File
@@ -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;
}
+16
View File
@@ -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`.
*
+10 -3
View File
@@ -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;
@@ -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", () => {
+37 -6
View File
@@ -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<OcrSchedule> {
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 },
});
}
+306
View File
@@ -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> = {},
): 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<CompletionResult | Error>) {
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");
});
});
@@ -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<Record<string, unknown>> {
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",
});
});
});
+229 -38
View File
@@ -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" },
);
});
});
+344 -74
View File
@@ -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<ReturnType<typeof extractedLabsSchema.safeParse>> {
const raw = await provider.generateCompletion(params);
): Promise<OcrCallOutcome> {
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<OcrCallOutcome> {
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<OcrExtractResponseDto> {
const isTextMode = typeof args.ocrText === "string";
): Promise<OcrExtraction> {
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<string>();
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,
};
}
+176 -58
View File
@@ -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<void>;
}
/**
* 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<VisionProviderPick["pick"]>;
export interface OcrTextPick {
entry: ProviderChainResolved;
@@ -74,36 +155,60 @@ const failure = (f: {
errorCode: string;
}): AiRunOutcome<never> => ({ ok: false, ...f });
/** The 422 a read that produced nothing usable answers with. */
function unreadable(err: OcrExtractError): AiRunOutcome<never> {
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<AiRunOutcome<OcrExtractResponseDto>> {
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),
+1
View File
@@ -249,6 +249,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"labs.ocr.fileTooLarge",
"labs.ocr.fileType",
"labs.ocr.localOcrDisabled",
"labs.ocr.outputLimit",
"labs.ocr.pdfNeedsAnthropic",
"labs.ocr.providerUnsupported",
"labs.ocr.rateLimited",
+24 -3
View File
@@ -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
+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,
/**
@@ -81,7 +81,10 @@ vi.mock("@/lib/documents/assist", async (importOriginal) => ({
}));
vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/labs/ocr-extract")>()),
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<typeof import("@/lib/documents/describe")>()),
+6 -4
View File
@@ -91,9 +91,8 @@ vi.mock("@/lib/documents/assist", async (importOriginal) => ({
vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/labs/ocr-extract")>()),
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({