mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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
137 lines
4.8 KiB
TypeScript
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: [],
|
|
};
|
|
}
|