Files
HealthLog/src/lib/documents/ai-route-support.ts
T
Marc-André Bombeck d2451021bc feat(documents): rasterize PDFs so image-only-wire providers can read them
Only Anthropic accepts a native PDF block; codex and other image-only-wire
providers dropped it. Add a bounded, best-effort server-side rasterizer that
renders a PDF's pages to JPEG images those providers read as ordinary image
input, so a scanned/image-only PDF is legible on the auto-read path.

- @napi-rs/canvas promoted to a direct dependency; its musl prebuilts + the
  pdfjs loader pinned into the standalone trace (next.config.ts)
- rasterize-pdf.ts: lazy pdfjs import (keeps DOMMatrix out of the eager chunk),
  first 10 pages, 1600px long edge, JPEG q80, never throws
- prepareVisionInput rasterizes a PDF for a non-Anthropic vision provider and
  keeps the native PDF block for Anthropic; the document capability reports
  PDFs readable via raster so the UI offers the read
2026-07-09 10:49:17 +02:00

137 lines
4.8 KiB
TypeScript

/**
* v1.27.22 (Document vault P2) — shared plumbing for the optional AI actions on
* a stored document (suggest / summary / index). Each of those routes runs the
* same gauntlet the extract route pioneered — module gate → provider resolve →
* consent → rate-limit → budget — and the same vision-input preparation
* (decrypt the stored original, re-derive its MIME from the bytes, gate PDF on
* the Anthropic path). This module centralises the parts that would otherwise be
* copy-pasted across the three routes; the per-route budget + prompt stay local.
*/
import { Buffer } from "node:buffer";
import { prisma } from "@/lib/db";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { decryptDocumentContent } from "@/lib/documents/store";
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
/** The three AI actions share the extract route's 6/hour egress bucket. */
export const DOCUMENT_AI_LIMIT_PER_HOUR = 6;
export const DOCUMENT_AI_WINDOW_MS = 60 * 60 * 1000;
/** The shared rate-limit bucket key prefix (identical to the extract route). */
export const DOCUMENT_AI_BUCKET = "documents-inbound";
/** Cap on a browser-OCR text body (mirrors the extract text mode). */
export const DOCUMENT_AI_TEXT_BODY_MAX_BYTES = 512 * 1024;
/**
* v1.27.33 (Document vault P4 — chat about a document) — the per-user request-
* rate bucket for the document chat. Layered in front of the daily AI budget:
* the budget catches the cost dimension, this catches the request-rate one (a
* tight loop or a stolen session). 20 / minute mirrors the Coach chat bucket —
* well outside any interactive use.
*/
export const DOCUMENT_CHAT_BUCKET = "document-chat";
export const DOCUMENT_CHAT_LIMIT_PER_MINUTE = 20;
export const DOCUMENT_CHAT_WINDOW_MS = 60 * 1000;
/** A loaded, owner-scoped, live document row with its encrypted content. */
export interface LoadedDocument {
id: string;
kind: string;
contentEncrypted: Uint8Array;
contentCodec: string;
mimeType: string;
status: string;
}
/** Load one live, owner-scoped document with the columns the AI actions need. */
export async function loadOwnedDocument(
userId: string,
id: string,
): Promise<LoadedDocument | null> {
return prisma.inboundDocument.findFirst({
where: { id, userId, deletedAt: null },
select: {
id: true,
kind: true,
contentEncrypted: true,
contentCodec: true,
mimeType: true,
status: true,
},
});
}
/** The provider-ready vision payload derived from a stored document. */
export type VisionInput =
| {
ok: true;
images: {
mediaType: "image/jpeg" | "image/png" | "image/webp";
dataBase64: string;
}[];
documents: { mediaType: "application/pdf"; dataBase64: string }[];
}
| { ok: false; reason: "decryptFailed" | "fileType" | "pdfNeedsAnthropic" };
/**
* Decrypt the stored original, re-derive its MIME from the bytes (never trust
* the stored label for a provider call), and split it into the images/documents
* arrays a vision call takes. Fails closed on a decrypt error.
*
* PDF handling by provider capability:
* - `pdfSupported` (Anthropic) → native `document` block, highest fidelity.
* - otherwise (codex / any image-only-wire provider) → rasterize the pages to
* JPEG `input_image` parts so the provider can still read the PDF. Egress is
* already authorised upstream (the consent gate / the `documentsAutoAiRead`
* toggle) before this is reached. If rasterization fails (malformed /
* encrypted / unrenderable), fall back to the shipped `pdfNeedsAnthropic`
* outcome so the job degrades to the local text-layer path and an
* interactive route surfaces the same actionable error.
*
* Images (jpg/png/webp) flow straight to `input_image`, unchanged.
*/
export async function prepareVisionInput(
document: LoadedDocument,
pdfSupported: boolean,
): Promise<VisionInput> {
let buffer: Buffer;
try {
buffer = decryptDocumentContent(
document.contentEncrypted,
document.contentCodec,
);
} catch {
return { ok: false, reason: "decryptFailed" };
}
const mime = detectOcrMimeType(buffer);
if (!mime) return { ok: false, reason: "fileType" };
if (mime === "application/pdf") {
if (pdfSupported) {
return {
ok: true,
images: [],
documents: [
{
mediaType: "application/pdf",
dataBase64: buffer.toString("base64"),
},
],
};
}
// Non-Anthropic vision provider — render the PDF pages to images it can read.
const raster = await rasterizePdf(buffer);
if (raster.ok) {
return { ok: true, images: raster.images, documents: [] };
}
return { ok: false, reason: "pdfNeedsAnthropic" };
}
return {
ok: true,
images: [{ mediaType: mime, dataBase64: buffer.toString("base64") }],
documents: [],
};
}