fix(labs): reserve and re-check each page of a scan, and read digits from the text layer

The route reserved one read's budget and checked the wire once, and a
20-page report then ran up to 40 calls for many minutes on that single
admission. Every read after the first now reserves its own share of the
day's budget and asks the wire again (aiEgressRefusal) before it is sent;
when either refuses, the scan stops there, keeps the pages already read
and names the rest with stoppedBy budget or unavailable. The run settles
against everything it reserved, with the provider's reported usage net of
its cached share, and a scan that fails after several calls is charged
for them instead of refunded in full.

A rendered PDF page now goes out with its own text layer, read by pdfjs
beside the image, so a digitally produced report's digits, decimal
separators and units come from its characters rather than from a 1600 px
picture. Splitting the PDF into single-page documents would need a PDF
writer the project does not ship; the text layer needs none and helps
every provider. A scanned page has no text layer and reads as before.

Codex's cut reply keeps the text it streamed (on a non-enumerable
property, so no log or event serialises it), and its closed rows are
salvaged like every other provider's. The merge folds a reading only into
one an earlier page listed, never two rows of the same page, and the
printed range is part of the key. OCR'd text the deadline cannot split
small enough for one read is refused up front as labs.ocr.tooLarge.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 11:10:02 +02:00
1 parent 6376710254
commit 79d905f089
26 files changed
+715 -126

No files matched your search

@@ -242,6 +242,9 @@ describe("extractErrorMessage", () => {
expect(extractErrorMessage(refusal("labs.ocr.outputLimit", 422), t)).toBe(
"labs.ocr.outputLimit",
);
expect(extractErrorMessage(refusal("labs.ocr.tooLarge", 422), t)).toBe(
"labs.ocr.tooLarge",
);
});
it("never calls an unrelated 403 a missing consent", () => {
@@ -296,6 +299,15 @@ describe("<OcrReadNotes> — what a read could not give", () => {
expect(render({ unreadPages: [] })).toContain("Part of the report");
});
it("says why a scan stopped early when it was not the provider", () => {
expect(render({ unreadPages: [4], stoppedBy: "budget" })).toContain(
"AI budget for today ran out",
);
expect(render({ unreadPages: [4], stoppedBy: "unavailable" })).toContain(
"AI was turned off while the report was being read",
);
});
it("says how many readings the report had when it ran past the cap", () => {
expect(render({ rowsCapped: { kept: 400, found: 512 } })).toContain(
"The report lists 512 readings. One scan takes 400",
+12 -2
View File
@@ -107,6 +107,8 @@ export function extractErrorMessage(
return t("labs.ocr.pdfNeedsAnthropic");
case "labs.ocr.outputLimit":
return t("labs.ocr.outputLimit");
case "labs.ocr.tooLarge":
return t("labs.ocr.tooLarge");
case "consent.ai.required":
return t("labs.ocr.consentRequired");
case "ai.provider.none":
@@ -272,6 +274,7 @@ export function OcrReviewDialog({
setReadNotes({
truncated: data.truncated,
unreadPages: data.unreadPages,
stoppedBy: data.stoppedBy,
rowsCapped: data.rowsCapped,
});
setStage("review");
@@ -516,7 +519,7 @@ export function OcrPageCoverageNote({
type OcrReadNotesValue = Pick<
OcrExtractResponseDto,
"truncated" | "unreadPages" | "rowsCapped"
"truncated" | "unreadPages" | "stoppedBy" | "rowsCapped"
>;
/**
@@ -528,7 +531,7 @@ type OcrReadNotesValue = Pick<
*/
export function OcrReadNotes({ notes }: { notes: OcrReadNotesValue }) {
const { t } = useTranslations();
const { truncated, unreadPages, rowsCapped } = notes;
const { truncated, unreadPages, stoppedBy, rowsCapped } = notes;
if (!truncated && !unreadPages && !rowsCapped) return null;
return (
<div data-slot="ocr-read-notes" className="text-warning space-y-1 text-sm">
@@ -541,6 +544,13 @@ export function OcrReadNotes({ notes }: { notes: OcrReadNotesValue }) {
: t("labs.ocr.truncated")}
</p>
) : null}
{stoppedBy ? (
<p>
{stoppedBy === "budget"
? t("labs.ocr.stoppedBudget")
: t("labs.ocr.stoppedUnavailable")}
</p>
) : null}
{unreadPages ? (
<p>
{unreadPages.length > 0