mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
5caa15f4e1
commit
5f548d331f
16 files changed
+147
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8992,6 +8992,7 @@
|
||||
"reviewTitle": "저장 전에 확인하기",
|
||||
"reviewHint": "측정값을 하나씩 확인하고, 남길 항목만 저장하세요.",
|
||||
"foundCount": "측정값 {count}개를 찾았어요",
|
||||
"pagesCapped": "전체 {total}쪽 중 처음 {read}쪽만 읽었습니다. 이후 쪽의 값은 이 목록에 없습니다.",
|
||||
"confirmRow": "이 측정값 포함",
|
||||
"analyteLabel": "검사 항목",
|
||||
"resultLabel": "결과",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user