mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user