Files
HealthLog/src/lib/documents/ocr-plan.ts
T
Marc-André Bombeck 763d6c600f fix(labs): bound each text-layer read of an unrenderable PDF
`planTextLayerReads` grouped whole pages into the reads the deadline
allows, with no bound on a read's size, while the text path refuses a part
past `OCR_TEXT_READ_MAX_CHARS`. A dense report in a request's three reads
went out as reads of 12 000 characters and more, which ends at the output
limit or loses rows.

`planTextLayerChunks` now cuts a page longer than one read at line
boundaries, keeps a page per read while the deadline allows that many, and
otherwise packs pages into reads of at most twice the surface's target
(8 000 characters for the lab scan, 5 000 for the document extraction).
What the reads cannot take is read from the start, and the pages left over
are reported with the pages without a text layer: `unreadPages` on the lab
scan, `extractionIncomplete` on a document.

Refs #1311
2026-11-03 23:24:38 +01:00

401 lines
14 KiB
TypeScript

/**
* How a report is split into reads (`OcrChunk`) before `runOcrReads` reads
* them: a PDF a page at a time, as many reads as the run's deadline allows; a
* photo as one read; text in parts at line boundaries. Shared by the lab scan
* and the document extraction, so a long report is planned the same way
* whichever surface reads it.
*/
import type { Buffer } from "node:buffer";
import {
readPdfTextLayer,
textLayerText,
} from "@/lib/documents/pdf-text-layer";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import type { OcrChunk } from "./ocr-read";
/** 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, and the most text parts a read without a
* deadline is split into. 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 most OCR'd text one read may carry. Past it the JSON for that much text
* no longer fits one read's output ceiling, so a part this long is refused up
* front instead of being read into a certain cut.
*/
export const OCR_TEXT_READ_MAX_CHARS = 8000;
/**
* 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>;
}
/**
* The most reads one HTTP request makes: the extract route, and the lab
* staging the index route continues into, both without `Prefer:
* respond-async` (the iPhone app sends neither). Each read is up to
* `OCR_EXTRACT_MODEL_CALLS` model calls, so this bounds the request at six
* calls where an unplanned read of a twenty-page report made forty. A longer
* document is read in as many reads by sharing its pages or text out between
* them, and what does not fit is reported as `extractionIncomplete` on the
* document; a background run, which has a deadline of its own, reads it a
* page at a time.
*/
export const SYNC_EXTRACT_MAX_READS = 3;
/** The read plan of a request: a fixed number of calls, nothing to arm. */
export const SYNCHRONOUS_READ_SCHEDULE: OcrSchedule = {
maxModelCalls: () => SYNC_EXTRACT_MAX_READS * OCR_EXTRACT_MODEL_CALLS,
arm: async () => undefined,
};
/** How many reads the deadline leaves room for. */
export function maxReadsFor(schedule: OcrSchedule): number {
return Math.max(
1,
Math.floor(schedule.maxModelCalls() / OCR_EXTRACT_MODEL_CALLS),
);
}
/**
* 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, each about
* `target` characters. Text that fits one read stays one read.
*/
export function splitOcrText(
text: string,
maxReads: number,
target: number = OCR_TEXT_READ_CHARS,
): string[] {
const reads = Math.max(1, Math.floor(maxReads));
const size = Math.max(target, Math.ceil(text.length / reads));
if (text.length <= size) return [text];
const parts: string[] = [];
let current = "";
for (const line of text.split("\n")) {
if (current && current.length + 1 + line.length > size) {
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;
}
/**
* Text split into reads of about `target` characters, as many as the
* deadline allows (at most `OCR_MAX_PAGES`). When the deadline leaves too few
* reads for the whole text, the parts grow to at most twice `target`; past
* that the text is read from its start and the parts left over are counted
* in `unreadParts`, rather than sent in reads too long to come back whole.
*/
export function planTextReads(
text: string,
schedule: OcrSchedule,
target: number,
): { chunks: OcrChunk[]; unreadParts: number } {
const maxReads = Math.min(maxReadsFor(schedule), OCR_MAX_PAGES);
let parts = splitOcrText(text, maxReads, target);
let unreadParts = 0;
if (parts.some((part) => part.length > target * 2)) {
const all = splitOcrText(text, Number.POSITIVE_INFINITY, target);
parts = all.slice(0, maxReads);
unreadParts = all.length - parts.length;
}
return {
chunks: parts.map((part) => ({ text: part, pages: [] })),
unreadParts,
};
}
/** The text layer of these pages, those that have one. */
function pageTextsFor(
pages: number[],
texts: string[] | undefined,
): { page: number; text: string }[] {
if (!texts) return [];
return pages
.map((page) => ({ page, text: texts[page - 1]?.trim() ?? "" }))
.filter((p) => p.text.length > 0);
}
export type OcrVisionMime =
"image/jpeg" | "image/png" | "image/webp" | "application/pdf";
/** The reads of an image or a PDF, or `ok: false` for a PDF nothing can read. */
export type OcrVisionPlan =
| {
ok: true;
mode: "vision";
chunks: OcrChunk[];
/** The PDF's page count, when it was rendered. */
totalPages?: number;
/** Set when the PDF is longer than the page cap: only the first pages are read. */
pageCoverage?: { read: number; total: number };
}
/**
* #1311 — a PDF this server cannot render, for a provider that cannot take
* it whole, read by its text layer: one text part per page group, as the
* rendered pages would have been, each read under the text bound. Pages
* without a text layer (scanned pages), and pages the reads could not take
* in full, are listed in `unreadPages`.
*/
| {
ok: true;
mode: "text";
chunks: OcrChunk[];
/** The pages' text the chunks were cut from, for provenance. */
ocrText: string;
totalPages: number;
pageCoverage?: { read: number; total: number };
unreadPages: number[];
}
| {
ok: false;
/**
* `busy`: the reader could not take the PDF now (busy past the wait a
* read gets, or its process ended for no measured reason). Not a
* verdict on the file; the person asks again. `renderFailed`: this PDF
* would not render on a server that renders PDFs. `rendererUnavailable`:
* this server cannot render PDF pages at all, and the PDF has no text
* layer to read instead (a scan).
*/
reason: "busy" | "renderFailed" | "rendererUnavailable";
};
/** The page-cap note for a PDF read from its first `read` of `total` pages. */
function coverage(
read: number,
total: number,
): { pageCoverage?: { read: number; total: number } } {
return total > read ? { pageCoverage: { read, total } } : {};
}
/**
* #1311 — read a PDF this server cannot render by its text layer, in page
* groups planned as the rendered pages would be.
*/
async function planTextLayerReads(
bytes: Buffer,
schedule: OcrSchedule,
textReadChars: number,
): Promise<OcrVisionPlan> {
const read = await readPdfTextLayer(bytes, OCR_MAX_PAGES, "documents.ocr");
if (!read.ok) {
return {
ok: false,
reason:
read.reason === "busy"
? "busy"
: read.reason === "none"
? "rendererUnavailable"
: "renderFailed",
};
}
const { layer } = read;
const { chunks, unreadPages } = planTextLayerChunks(
layer.pages,
maxReadsFor(schedule),
textReadChars,
);
return {
ok: true,
mode: "text",
chunks,
ocrText: textLayerText(layer),
totalPages: layer.pageCount,
...coverage(layer.pagesRead, layer.pageCount),
unreadPages: [...new Set([...layer.pagesWithoutText, ...unreadPages])].sort(
(a, b) => a - b,
),
};
}
/**
* The reads of a text layer, page-aware and under the same per-read bound as
* any other text: a page longer than `target` is read in parts at line
* boundaries, each part its own read while the deadline allows that many;
* otherwise the parts are packed into at most `maxReads` reads of about the
* share the deadline leaves, never past twice `target` (the most one read's
* answer is sized for). When the reads cannot take it all, the text is read
* from its start and the pages left over, wholly or in part, are returned in
* `unreadPages`, as `planTextReads` counts its `unreadParts`.
*/
export function planTextLayerChunks(
pages: { page: number; text: string }[],
maxReads: number,
target: number,
): { chunks: OcrChunk[]; unreadPages: number[] } {
const reads = Math.max(1, Math.floor(maxReads));
const units = pages.flatMap(({ page, text }) =>
splitOcrText(text, Number.POSITIVE_INFINITY, target)
// A single line past the target stays whole in `splitOcrText`; one
// that long is cut, so no read can exceed the bound.
.flatMap((part) =>
part.length <= target
? [part]
: Array.from({ length: Math.ceil(part.length / target) }, (_, i) =>
part.slice(i * target, (i + 1) * target),
),
)
.map((part) => ({ page, text: part })),
);
// As many reads as parts: a page (or a page part) per read, as rendered
// pages are read.
if (units.length <= reads) {
return {
chunks: units.map((u) => ({ text: u.text, pages: [u.page] })),
unreadPages: [],
};
}
const total = units.reduce((n, u) => n + u.text.length, 0);
const size = Math.min(target * 2, Math.max(target, Math.ceil(total / reads)));
const packed: { page: number; text: string }[][] = [];
let current: { page: number; text: string }[] = [];
let length = 0;
for (const unit of units) {
if (current.length > 0 && length + 2 + unit.text.length > size) {
packed.push(current);
current = [];
length = 0;
}
current.push(unit);
length += (current.length > 1 ? 2 : 0) + unit.text.length;
}
if (current.length > 0) packed.push(current);
const kept = packed.slice(0, reads);
const unreadPages = [
...new Set(packed.slice(reads).flatMap((c) => c.map((u) => u.page))),
];
return {
chunks: kept.map((chunk) => ({
text: chunk.map((u) => u.text).join("\n\n"),
pages: [...new Set(chunk.map((u) => u.page))],
})),
unreadPages,
};
}
/**
* Plan the reads of an image or a PDF. 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. With each page's text layer: a digitally produced
* report states its digits exactly there, and the rendered page alone is a
* picture at a capped resolution. On a server that cannot render at all, a
* provider that cannot take the PDF whole reads its text layer instead.
*/
export async function planVisionReads(
bytes: Buffer,
mime: OcrVisionMime,
pdfSupported: boolean,
schedule: OcrSchedule,
/** Text per read when the PDF is read by its text layer (the surface's own). */
textReadChars: number = OCR_TEXT_READ_CHARS,
): Promise<OcrVisionPlan> {
if (mime !== "application/pdf") {
return {
ok: true,
mode: "vision",
chunks: [
{
images: [{ mediaType: mime, dataBase64: bytes.toString("base64") }],
documents: [],
pages: [],
},
],
};
}
const raster = await rasterizePdf(bytes, OCR_MAX_PAGES, { withText: true });
if (raster.ok && (raster.images.length > 1 || !pdfSupported)) {
const chunks = planPageReads(
raster.images.length,
maxReadsFor(schedule),
).map((pages) => ({
images: pages.map((p) => raster.images[p - 1]),
documents: [],
pages,
pageTexts: pageTextsFor(pages, raster.texts),
}));
return {
ok: true,
mode: "vision",
chunks,
totalPages: raster.pageCount,
// 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.
...coverage(raster.images.length, raster.pageCount),
};
}
if (!pdfSupported && !raster.ok) {
if (raster.reason === "busy" || raster.reason === "interrupted") {
return { ok: false, reason: "busy" };
}
// Refs #776 — a render failure on a server that renders is this
// document's; a server that cannot render at all is the server's.
if (raster.reason !== "unsupported") {
return { ok: false, reason: "renderFailed" };
}
return planTextLayerReads(bytes, schedule, textReadChars);
}
return {
ok: true,
mode: "vision",
chunks: [
{
images: [],
documents: [
{
mediaType: "application/pdf",
dataBase64: bytes.toString("base64"),
},
],
pages: [],
},
],
};
}