feat(labs-ocr): read PDFs on the configured provider, retire the separate scan provider

Lab-report OCR now rasterizes a PDF to page images for any non-Anthropic
vision provider, exactly as the document vault already does — Anthropic keeps
its native PDF block, every other vision provider (codex/OAuth, OpenAI, local)
reads the rendered pages. The capability probe reports `pdfSupported` whenever
the picked provider reads PDFs natively or the server-side rasterizer is
available, so the "scan a PDF" affordance shows for a codex provider too.

Drop the dedicated document-scan (Lab-OCR) provider: labs OCR always uses the
main configured provider chain. Removes the settings card, the `aiOcr*`
resolution branch, the GET/PATCH handler fields, the OpenAPI schema fields, the
i18n keys across all locales, and the five backing `User` columns (migration
0234). The on-device tesseract fallback is untouched.

Dashboard hero: stack the overdue-dose action directly beneath the verdict
text (tight gap, left-aligned) on every viewport instead of pushing it to the
far container edge on desktop.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 23:29:06 +02:00
1 parent c8e67042a8
commit 46f8c71ee5
25 files changed
+191 -775

No files matched your search

@@ -30,6 +30,9 @@ vi.mock("@/lib/labs/ocr-capability", () => ({
resolveTextProvider: vi.fn(),
resolveVisionProvider: vi.fn(),
}));
vi.mock("@/lib/documents/rasterize-pdf", () => ({
rasterizePdf: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
}));
@@ -54,7 +57,11 @@ import { POST } from "../route";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { resolveTextProvider } from "@/lib/labs/ocr-capability";
import {
resolveTextProvider,
resolveVisionProvider,
} from "@/lib/labs/ocr-capability";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
@@ -130,3 +137,78 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
);
});
});
describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
function pdfReq(): Request {
// A minimal `%PDF-` header is all `detectOcrMimeType` needs to sniff a PDF.
const bytes = new Uint8Array([
0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0x0a, 0x25, 0xe2, 0xe3,
0xcf, 0xd3, 0x0a,
]);
const file = new File([bytes], "report.pdf", {
type: "application/pdf",
});
const form = new FormData();
form.append("file", file);
return new Request("http://localhost/api/labs/ocr/extract", {
method: "POST",
body: form,
});
}
beforeEach(() => {
// A non-Anthropic vision provider (codex): no native PDF block, so the
// route must rasterize.
vi.mocked(resolveVisionProvider).mockResolvedValue({
chain: [{ providerType: "codex", instance: {} as never }],
localOcrEnabled: false,
pick: {
entry: { providerType: "codex", instance: {} as never },
providerType: "codex",
pdfSupported: false,
},
} as never);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
reserved: AI_BUDGETS.ocrExtract.maxTokens ?? 0,
totalAfter: AI_BUDGETS.ocrExtract.maxTokens ?? 0,
} as never);
});
it("rasterizes a PDF for a non-Anthropic vision provider and sends the page images", async () => {
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(pdfReq());
expect(res.status).toBe(200);
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.
expect(runOcrExtraction).toHaveBeenCalledWith(
expect.objectContaining({
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
documents: [],
}),
);
});
it("falls back to pdfNeedsAnthropic when rasterization fails", async () => {
vi.mocked(rasterizePdf).mockResolvedValue({ ok: false });
const res = await POST(pdfReq());
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string | null };
expect(body.error).toBeTruthy();
// A failed render never reaches the provider — and refunds the reservation.
expect(runOcrExtraction).not.toHaveBeenCalled();
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
AI_BUDGETS.ocrExtract.maxTokens,
0,
"2026-06-26",
);
});
});
+46 -20
View File
@@ -38,6 +38,7 @@ import {
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import {
resolveTextProvider,
resolveVisionProvider,
@@ -323,27 +324,52 @@ async function handleVisionExtract(
});
}
// 7. PDF gate — only Anthropic reads a native document block in v1.
if (mime === "application/pdf" && !pick.pdfSupported) {
annotate({
action: { name: "labs.ocr.fileRejected" },
meta: { reason: "pdf_provider_unsupported" },
});
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
return apiError(
"PDF scanning needs a Claude vision provider; upload a photo instead.",
422,
{ errorCode: "labs.ocr.pdfNeedsAnthropic" },
);
}
// 7. PDF handling — mirror the document vault's `prepareVisionInput`
// (`src/lib/documents/ai-route-support.ts`) exactly:
// - Anthropic (`pick.pdfSupported`) reads the native `document` block at
// highest fidelity;
// - every other vision provider (codex / any image-only wire) reads the
// PDF via `rasterizePdf`, which renders the pages to JPEG `input_image`
// parts the provider already handles. Rendering is pure local compute
// (no egress). `rasterizePdf` never throws: a malformed / encrypted /
// unrenderable PDF returns `{ ok: false }`, and we keep the shipped
// `pdfNeedsAnthropic` reject as the graceful fallback.
let images: {
mediaType: "image/jpeg" | "image/png" | "image/webp";
dataBase64: string;
}[];
let documents: { mediaType: "application/pdf"; dataBase64: string }[];
const dataBase64 = buffer.toString("base64");
const images =
mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }];
const documents =
mime === "application/pdf"
? [{ mediaType: "application/pdf" as const, dataBase64 }]
: [];
if (mime === "application/pdf") {
if (pick.pdfSupported) {
images = [];
documents = [
{
mediaType: "application/pdf",
dataBase64: buffer.toString("base64"),
},
];
} else {
const raster = await rasterizePdf(buffer);
if (!raster.ok) {
annotate({
action: { name: "labs.ocr.fileRejected" },
meta: { reason: "pdf_rasterize_failed" },
});
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
return apiError(
"Couldn't read this PDF; upload a photo instead.",
422,
{ errorCode: "labs.ocr.pdfNeedsAnthropic" },
);
}
images = raster.images;
documents = [];
}
} else {
images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }];
documents = [];
}
// 8. Run the extraction. The actual token spend reconciles the reservation.
let actualTokens = 0;
@@ -139,7 +139,7 @@ describe("GET /api/user/ai-provider availability", () => {
expect(body.data.managedBy).toBeNull();
});
it("returns the v1.22 timeout + OCR fields", async () => {
it("returns the v1.22 response timeout field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProvider: "OPENAI",
aiModel: "gpt-4o",
@@ -148,11 +148,6 @@ describe("GET /api/user/ai-provider availability", () => {
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiResponseTimeoutSeconds: 240,
aiOcrEnabled: true,
aiOcrProvider: "ANTHROPIC",
aiOcrModel: "claude-sonnet-4-6",
aiOcrBaseUrl: null,
aiOcrKeyEncrypted: "enc-ocr-1234",
} as never);
vi.mocked(resolveProviderAvailability).mockResolvedValue({
aiAvailable: true,
@@ -161,21 +156,10 @@ describe("GET /api/user/ai-provider availability", () => {
const res = await (GET as () => Promise<Response>)();
const body = (await res.json()) as {
data: {
responseTimeoutSeconds: number | null;
ocrEnabled: boolean;
ocrProvider: string | null;
hasOcrKey: boolean;
ocrKeyPreview: string | null;
};
data: { responseTimeoutSeconds: number | null };
};
expect(body.data.responseTimeoutSeconds).toBe(240);
expect(body.data.ocrEnabled).toBe(true);
expect(body.data.ocrProvider).toBe("ANTHROPIC");
expect(body.data.hasOcrKey).toBe(true);
// Preview is `...` + last 4 of the decrypted value (decrypt → `dec:...`).
expect(body.data.ocrKeyPreview).toMatch(/^\.\.\./);
});
});
@@ -207,32 +191,4 @@ describe("PATCH /api/user/ai-provider — v1.22 fields", () => {
}),
);
});
it("persists OCR provider config and encrypts the key", async () => {
const res = await patch(
patchRequest({
ocrEnabled: true,
ocrProvider: "OPENAI",
ocrModel: "gpt-4o",
ocrKey: "sk-ocr-secret",
}),
);
expect(res.status).toBe(200);
expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
aiOcrEnabled: true,
aiOcrProvider: "OPENAI",
aiOcrModel: "gpt-4o",
aiOcrKeyEncrypted: "enc:sk-ocr-secret",
}),
}),
);
});
it("rejects an invalid OCR provider", async () => {
await expect(
patch(patchRequest({ ocrProvider: "CHATGPT_OAUTH" })),
).rejects.toThrow(/Invalid OCR provider/);
});
});
+1 -75
View File
@@ -11,9 +11,6 @@ import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
const ALLOWED = new Set(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]);
// v1.22 (#90) — the dedicated document-scan provider has no OAuth path, so the
// allowlist is the three keyed/self-hosted providers only (no CHATGPT_OAUTH).
const OCR_PROVIDERS = new Set(["OPENAI", "ANTHROPIC", "LOCAL"]);
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -28,13 +25,8 @@ export const GET = apiHandler(async () => {
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
// v1.22 (#89 + #90)
// v1.22 (#89)
aiResponseTimeoutSeconds: true,
aiOcrEnabled: true,
aiOcrProvider: true,
aiOcrModel: true,
aiOcrBaseUrl: true,
aiOcrKeyEncrypted: true,
},
});
@@ -62,16 +54,6 @@ export const GET = apiHandler(async () => {
: null,
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
// v1.22 (#90) — dedicated document-scan (Lab-OCR) provider config. Key
// material is presence + 4-char preview only, like every other provider.
ocrEnabled: Boolean(u?.aiOcrEnabled),
ocrProvider: u?.aiOcrProvider ?? null,
ocrModel: u?.aiOcrModel ?? null,
ocrBaseUrl: u?.aiOcrBaseUrl ?? null,
hasOcrKey: Boolean(u?.aiOcrKeyEncrypted),
ocrKeyPreview: u?.aiOcrKeyEncrypted
? `...${decrypt(u.aiOcrKeyEncrypted).slice(-4)}`
: null,
});
});
@@ -179,62 +161,6 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
}
}
// ── v1.22 (#90) — dedicated document-scan (Lab-OCR) provider ──
if (body.ocrEnabled !== undefined) {
if (typeof body.ocrEnabled === "boolean") {
updates.aiOcrEnabled = body.ocrEnabled;
} else {
throw new HttpError(422, "Invalid ocrEnabled");
}
}
if (body.ocrProvider !== undefined) {
if (body.ocrProvider === null || body.ocrProvider === "") {
updates.aiOcrProvider = null;
} else if (
typeof body.ocrProvider === "string" &&
OCR_PROVIDERS.has(body.ocrProvider)
) {
updates.aiOcrProvider = body.ocrProvider;
} else {
throw new HttpError(422, "Invalid OCR provider");
}
}
if (body.ocrModel !== undefined) {
if (body.ocrModel === null || body.ocrModel === "") {
updates.aiOcrModel = null;
} else if (typeof body.ocrModel === "string") {
updates.aiOcrModel = body.ocrModel.trim() || null;
}
}
if (body.ocrBaseUrl !== undefined) {
if (body.ocrBaseUrl === null || body.ocrBaseUrl === "") {
updates.aiOcrBaseUrl = null;
} else if (typeof body.ocrBaseUrl === "string") {
const trimmed = body.ocrBaseUrl.trim();
// Same SSRF guard as the main LOCAL base URL: reject private/internal
// hosts unless the operator opted in via ALLOW_LOCAL_AI_PRIVATE_HOSTS.
const allowPrivate = isLocalAiHostAllowed(trimmed);
if (!allowPrivate && !isPublicUrl(trimmed)) {
return apiError(
"OCR base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
422,
);
}
updates.aiOcrBaseUrl = trimmed;
}
}
if (body.ocrKey !== undefined) {
if (body.ocrKey === null || body.ocrKey === "") {
updates.aiOcrKeyEncrypted = null;
} else if (typeof body.ocrKey === "string") {
updates.aiOcrKeyEncrypted = encrypt(body.ocrKey.trim());
}
}
if (Object.keys(updates).length === 0) {
throw new HttpError(422, "No valid fields");
}