fix(labs): say when a scanned PDF was read from its first pages only

A PDF longer than the pages a scan renders was read from its first ten
pages, and the cut was recorded only in the wide event. The person checking
the extracted rows had no way to know that values on later pages were never
read.

The rasterizer now returns the PDF's page count, the extract response
carries pageCoverage { read, total } when the cap applied, and the review
screen says how many of the pages were read.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:05:35 +02:00
1 parent 5caa15f4e1
commit 5f548d331f
16 files changed
+147 -3

No files matched your search

+17
View File
@@ -63012,6 +63012,23 @@ components:
type: array
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."
type: object
properties:
read:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
total:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
required:
- read
- total
additionalProperties: false
required:
- reportDate
- providerType
+1
View File
@@ -8992,6 +8992,7 @@
"reviewTitle": "Vor dem Speichern prüfen",
"reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.",
"foundCount": "{count} Werte gefunden",
"pagesCapped": "Nur die ersten {read} von {total} Seiten wurden gelesen. Werte auf späteren Seiten fehlen in dieser Liste.",
"confirmRow": "Diesen Wert übernehmen",
"analyteLabel": "Bezeichnung",
"resultLabel": "Ergebnis",
+1
View File
@@ -8992,6 +8992,7 @@
"reviewTitle": "Review before saving",
"reviewHint": "Check each reading, then save the ones you want to keep.",
"foundCount": "{count} readings found",
"pagesCapped": "Only the first {read} of {total} pages were read. Values on later pages are not in this list.",
"confirmRow": "Include this reading",
"analyteLabel": "Test name",
"resultLabel": "Result",
+1
View File
@@ -8992,6 +8992,7 @@
"reviewTitle": "Revisar antes de guardar",
"reviewHint": "Revisa cada valor y guarda los que quieras conservar.",
"foundCount": "{count} valores encontrados",
"pagesCapped": "Solo se leyeron las primeras {read} de {total} páginas. Los valores de las páginas siguientes no están en esta lista.",
"confirmRow": "Incluir este valor",
"analyteLabel": "Nombre de la prueba",
"resultLabel": "Resultado",
+1
View File
@@ -8992,6 +8992,7 @@
"reviewTitle": "Vérifier avant d'enregistrer",
"reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.",
"foundCount": "{count} valeurs trouvées",
"pagesCapped": "Seules les {read} premières pages sur {total} ont été lues. Les valeurs des pages suivantes ne figurent pas dans cette liste.",
"confirmRow": "Inclure cette valeur",
"analyteLabel": "Nom de l'analyse",
"resultLabel": "Résultat",
+1
View File
@@ -8992,6 +8992,7 @@
"reviewTitle": "Controlla prima di salvare",
"reviewHint": "Controlla ogni valore, poi salva quelli da conservare.",
"foundCount": "{count} valori trovati",
"pagesCapped": "Sono state lette solo le prime {read} pagine su {total}. I valori delle pagine successive non sono in questo elenco.",
"confirmRow": "Includi questo valore",
"analyteLabel": "Nome dell'esame",
"resultLabel": "Risultato",
+1
View File
@@ -8992,6 +8992,7 @@
"reviewTitle": "저장 전에 확인하기",
"reviewHint": "측정값을 하나씩 확인하고, 남길 항목만 저장하세요.",
"foundCount": "측정값 {count}개를 찾았어요",
"pagesCapped": "전체 {total}쪽 중 처음 {read}쪽만 읽었습니다. 이후 쪽의 값은 이 목록에 없습니다.",
"confirmRow": "이 측정값 포함",
"analyteLabel": "검사 항목",
"resultLabel": "결과",
+1
View File
@@ -8992,6 +8992,7 @@
"reviewTitle": "Sprawdź przed zapisaniem",
"reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.",
"foundCount": "Znaleziono {count} wyników",
"pagesCapped": "Odczytano tylko pierwsze {read} z {total} stron. Wartości z dalszych stron nie ma na tej liście.",
"confirmRow": "Uwzględnij ten wynik",
"analyteLabel": "Nazwa badania",
"resultLabel": "Wynik",
@@ -229,11 +229,15 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
pageCount: 1,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(pdfReq());
expect(res.status).toBe(200);
// The whole PDF was read: no coverage note on the response.
const whole = (await res.json()) as { data: Record<string, unknown> };
expect(whole.data).not.toHaveProperty("pageCoverage");
expect(rasterizePdf).toHaveBeenCalledOnce();
// The rendered page images flow through as `input_image`s; no native PDF
// document block is sent for a non-Anthropic provider.
@@ -245,6 +249,26 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
);
});
it("says on the response when only the first pages of a long PDF were read", async () => {
const tenPages = Array.from({ length: 10 }, () => ({
mediaType: "image/jpeg" as const,
dataBase64: "cGFnZQ==",
}));
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: tenPages,
pageCount: 23,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(pdfReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { pageCoverage?: { read: number; total: number } };
};
expect(body.data.pageCoverage).toEqual({ read: 10, total: 23 });
});
it("hands the slot back when the upload is not an image or PDF", async () => {
const form = new FormData();
form.append(
+16 -1
View File
@@ -374,6 +374,7 @@ async function handleVisionExtract(
dataBase64: string;
}[];
let documents: { mediaType: "application/pdf"; dataBase64: string }[];
let pageCoverage: { read: number; total: number } | undefined;
if (mime === "application/pdf") {
if (pick.pdfSupported) {
@@ -404,6 +405,20 @@ async function handleVisionExtract(
}
images = raster.images;
documents = [];
// A PDF longer than the page cap is read from its first pages only.
// That used to live in the wide event alone; the person reviewing the
// rows is the one who needs to know the rest of the report was not
// read.
if (raster.pageCount > raster.images.length) {
pageCoverage = {
read: raster.images.length,
total: raster.pageCount,
};
annotate({
action: { name: "labs.ocr.pagesCapped" },
meta: { read: raster.images.length, total: raster.pageCount },
});
}
}
} else {
images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }];
@@ -431,7 +446,7 @@ async function handleVisionExtract(
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return apiSuccess(result);
return apiSuccess(pageCoverage ? { ...result, pageCoverage } : result);
} catch (err) {
// Provider/extraction failure — the call may still have burned tokens, so
// reconcile against the reserved estimate rather than refunding in full.
@@ -10,6 +10,7 @@ import { ApiError } from "@/lib/api/api-fetch";
import {
extractErrorMessage,
handleFilePickerChange,
OcrPageCoverageNote,
OcrReviewDialog,
} from "../ocr-review-dialog";
@@ -201,3 +202,24 @@ describe("extractErrorMessage", () => {
);
});
});
describe("<OcrPageCoverageNote> — a long PDF read from its first pages", () => {
it("says how many of the pages were read", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OcrPageCoverageNote coverage={{ read: 10, total: 23 }} />
</I18nProvider>,
);
expect(html).toContain('data-slot="ocr-page-coverage"');
expect(html).toContain("Only the first 10 of 23 pages were read.");
});
it("renders nothing when the whole document was read", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OcrPageCoverageNote coverage={null} />
</I18nProvider>,
);
expect(html).toBe("");
});
});
+32
View File
@@ -138,6 +138,11 @@ export function OcrReviewDialog({
// because the person has not filled it in yet. Once shown they follow the
// edits, so fixing a field clears its mark at once.
const [showErrors, setShowErrors] = useState(false);
// Set when the server read only the first pages of a long PDF.
const [pageCoverage, setPageCoverage] = useState<{
read: number;
total: number;
} | null>(null);
const [focusRequest, setFocusRequest] = useState(0);
const reviewListRef = useRef<HTMLDivElement>(null);
@@ -184,6 +189,7 @@ export function OcrReviewDialog({
function reset() {
setStage("pick");
setShowErrors(false);
setPageCoverage(null);
setRows([]);
setPickedFile(null);
setVisitId(null);
@@ -207,6 +213,7 @@ export function OcrReviewDialog({
return;
}
setRows(seeded);
setPageCoverage(data.pageCoverage ?? null);
setStage("review");
},
onError: (err) => toast.error(extractErrorMessage(err, t)),
@@ -353,6 +360,8 @@ export function OcrReviewDialog({
{t("labs.ocr.foundCount", { count: rows.length })}
</p>
<OcrPageCoverageNote coverage={pageCoverage} />
{showErrors && rowErrors.size > 0 ? (
<p
role="alert"
@@ -389,3 +398,26 @@ export function OcrReviewDialog({
</ResponsiveSheet>
);
}
/**
* The server read only the first pages of a long PDF (`pageCoverage` on the
* extract response). Said on the review screen, beside the rows, because the
* person checking the list is the one who would otherwise assume the whole
* report was read. Renders nothing when the whole document was read.
*/
export function OcrPageCoverageNote({
coverage,
}: {
coverage: { read: number; total: number } | null;
}) {
const { t } = useTranslations();
if (!coverage) return null;
return (
<p data-slot="ocr-page-coverage" className="text-warning text-sm">
{t("labs.ocr.pagesCapped", {
read: coverage.read,
total: coverage.total,
})}
</p>
);
}
@@ -90,6 +90,9 @@ describe("rasterizePdf", () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.images).toHaveLength(RASTER_MAX_PAGES);
// The page count travels with the result, so a caller can say the
// reading stopped short.
expect(result.pageCount).toBe(RASTER_MAX_PAGES + 4);
});
it("honours a caller maxPages bound (thumbnail path renders page 1 only)", async () => {
+11 -2
View File
@@ -74,7 +74,16 @@ export interface RasterImage {
* would not render" (`render-failed` — malformed / encrypted / zero pages).
*/
export type RasterResult =
| { ok: true; images: RasterImage[] }
| {
ok: true;
images: RasterImage[];
/**
* Pages in the PDF. Above `images.length` when the page cap cut the
* render short, which a caller showing the result has to say: the
* reading covers the first pages only.
*/
pageCount: number;
}
| { ok: false; reason: "unsupported" | "render-failed" };
// Minimal structural types for the slice of the pdfjs API this module uses. A
@@ -212,7 +221,7 @@ export async function rasterizePdf(
action: { name: "documents.rasterize.ok" },
meta: { pages: images.length, cappedFrom: doc.numPages },
});
return { ok: true, images };
return { ok: true, images, pageCount: doc.numPages };
} catch (err) {
annotate({
action: { name: "documents.rasterize.failed" },
+9
View File
@@ -85,6 +85,15 @@ const extractResponse = z
reportDate: z.string().nullable(),
providerType: z.string(),
rows: z.array(extractedRow),
pageCoverage: z
.object({
read: z.number().int().positive(),
total: z.number().int().positive(),
})
.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.",
),
})
.meta({
id: "OcrExtractResponse",
+6
View File
@@ -132,6 +132,12 @@ export interface OcrExtractResponseDto {
reportDate: string | null;
providerType: string;
rows: OcrExtractedRowDto[];
/**
* Present only when a PDF was longer than the pages the scan reads
* (`RASTER_MAX_PAGES`): the rows come from the first `read` of `total`
* pages, and the review screen says so.
*/
pageCoverage?: { read: number; total: number };
}
/**