diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e6e09540c..3271e670b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 553d63b6c..6bf4db0c6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 55d0718d6..04555344a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 1718c1acc..c90525051 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 0afad1da7..1ebea9c37 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 883d3c4a1..c163f1bef 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index f814ab916..08156e094 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8992,6 +8992,7 @@ "reviewTitle": "저장 전에 확인하기", "reviewHint": "측정값을 하나씩 확인하고, 남길 항목만 저장하세요.", "foundCount": "측정값 {count}개를 찾았어요", + "pagesCapped": "전체 {total}쪽 중 처음 {read}쪽만 읽었습니다. 이후 쪽의 값은 이 목록에 없습니다.", "confirmRow": "이 측정값 포함", "analyteLabel": "검사 항목", "resultLabel": "결과", diff --git a/messages/pl.json b/messages/pl.json index 7e45f4587..c0ecb344a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 4dcd20385..742700fb7 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -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 }; + 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( diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 16ca28416..bb770f3b4 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -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. diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx index 1ef88b1c9..852746337 100644 --- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx +++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx @@ -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(" — a long PDF read from its first pages", () => { + it("says how many of the pages were read", () => { + const html = renderToStaticMarkup( + + + , + ); + 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( + + + , + ); + expect(html).toBe(""); + }); +}); diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index 0a0b0a17c..707da5efa 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -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(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 })}

+ + {showErrors && rowErrors.size > 0 ? (

); } + +/** + * 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 ( +

+ {t("labs.ocr.pagesCapped", { + read: coverage.read, + total: coverage.total, + })} +

+ ); +} diff --git a/src/lib/documents/__tests__/rasterize-pdf.test.ts b/src/lib/documents/__tests__/rasterize-pdf.test.ts index a00340e8a..cb124491b 100644 --- a/src/lib/documents/__tests__/rasterize-pdf.test.ts +++ b/src/lib/documents/__tests__/rasterize-pdf.test.ts @@ -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 () => { diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index 402f61695..f61e74fc3 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -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" }, diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 82ded9d09..f894cf8aa 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -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", diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index aa18dd7e6..000a78ab5 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -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 }; } /**