feat(documents): read documents and lab scans in the background

"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.

Both now run as a background run (`DocumentAiRun`, migration 0366):

- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
  answers 202 with a run id once every quick refusal has passed. Without
  the header it answers exactly as before, which the iPhone app relies on.
  A second request while a read of the same document is queued or running
  attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
  calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
  route answers with, or the failure code and status it would have given.
  Scoped to the caller; another account's run is a 404.

The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.

Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.

The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:18:56 +02:00
1 parent 556bd6d6cd
commit 0acf3da575
49 files changed
+4533 -870

No files matched your search

@@ -194,6 +194,14 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
* claim the guard checks against the file.
*/
export const DATA_ROUTES: Readonly<Record<string, string>> = {
// v1.40 — the poll for a background document AI run. A plain read of a row
// the caller created: every capability question was asked when the run was
// queued and again in the worker before anything left, and serving the
// answer to a read the person already asked for calls no model. Not
// `aiCapabilityToServe` either: a run result is the caller's own request
// result, not stored model text shown later.
"src/app/api/ai-runs/[id]/route.ts":
"The caller's own background run: status, result, failure.",
// The design's list of formerly AI-gated data reads and writes.
"src/app/api/insights/derived/batch/route.ts":
"Deterministic scores and assessments for the whole grid.",
+45
View File
@@ -0,0 +1,45 @@
/**
* v1.40 — GET /api/ai-runs/{id}: where one background document AI run stands.
*
* A plain read of a row the caller created: the run id came back in the 202
* of the route that queued it, and the row is scoped to the caller here, so
* another account's run (or one that never existed, or one an hour past its
* end) is the same 404. No AI gate: serving the answer to a read the person
* already asked for calls no model, and every capability question was asked
* when the run was queued and again in the worker before anything left.
*
* `result` is the body the synchronous route answers with (by `kind`), and
* `error` carries the code and status it would have answered with, so a
* client handles both paths through one table.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { readAiRunForUser } from "@/lib/documents/ai-runs/store";
import { AI_RUN_ERROR_CODES } from "@/lib/documents/ai-runs/types";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const run = await readAiRunForUser(user.id, id);
if (!run) {
return apiError("Run not found", 404, {
errorCode: AI_RUN_ERROR_CODES.notFound,
});
}
const response = apiSuccess(run);
// A poll answer is for one person at one instant.
response.headers.set("Cache-Control", "no-store");
if (run.retryAfterMs !== null) {
response.headers.set(
"Retry-After",
String(Math.max(1, Math.ceil(run.retryAfterMs / 1000))),
);
}
return response;
},
);
+85 -167
View File
@@ -19,46 +19,52 @@
* the lab / extract text mode already uses — no provider egress at all.
*
* Persists ONLY AES-256-GCM ciphertext text + opaque HMAC token hashes (A4).
*
* v1.40 — `Prefer: respond-async` (RFC 7240) runs the read in the background
* worker: every refusal that can be answered quickly is still answered here,
* then the route answers 202 with a run id and `GET /api/ai-runs/{id}` serves
* the same body this route answers with synchronously. Without the header the
* route behaves exactly as before; the iPhone app relies on that. Both paths
* run one body (`executeDocumentIndex`).
*/
import { Buffer } from "node:buffer";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
import {
checkDocumentAiRateLimit,
documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
loadOwnedDocument,
prepareVisionInput,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
import { upsertContentIndex } from "@/lib/documents/content-index";
import { recordIndexAttempt } from "@/lib/documents/index-document";
import {
DocumentDescribeError,
transcribeDocument,
} from "@/lib/documents/describe";
acceptedRunResponse,
outcomeResponse,
prefersRespondAsync,
} from "@/lib/documents/ai-runs/http";
import {
DOCUMENT_INDEX_RESERVE_TOKENS,
executeDocumentIndex,
} from "@/lib/documents/ai-runs/index-run";
import { findLiveDocumentRun, startAiRun } from "@/lib/documents/ai-runs/start";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
@@ -82,57 +88,26 @@ export const POST = apiHandler(
});
}
const background = prefersRespondAsync(request);
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return handleTextIndex(request, user.id, document);
return handleTextIndex(request, user.id, document, background);
}
return handleVisionIndex(request, user.id, document);
return handleVisionIndex(request, user.id, document, background);
},
);
async function finishIndex(
request: NextRequest,
/**
* A read of this document already queued or running: attach to it rather than
* charge a second one. Background path only; the synchronous path keeps its
* behaviour of one request, one read.
*/
async function liveRunResponse(
userId: string,
documentId: string,
source: "vision" | "text-ocr",
tokenCount: number,
): Promise<Response> {
// Refs #776 — the manual route is the second writer of the attempt record
// (the auto job + backfill share `indexLoadedDocument`): a success clears
// any stored failure reason so the detail view stops explaining a problem
// that no longer exists.
await recordIndexAttempt(userId, documentId, {
indexed: true,
source,
tokenCount,
});
// Read with AI is the person reading the document on purpose, so an
// import's hold on automatic AI reading (`aiRead=defer`) ends here, before
// the lab staging below that would otherwise refuse it.
await prisma.inboundDocument.updateMany({
where: { id: documentId, userId, aiReadDeferred: true },
data: { aiReadDeferred: false },
});
await auditLog("documents.inbound.index", {
userId,
ipAddress: getClientIp(request),
details: { documentId, source, tokens: tokenCount },
});
annotate({
action: { name: "documents.contentIndex.upsert" },
meta: { documentId, source, tokens: tokenCount },
});
// A manual read continues into the SAME lab staging the automatic index
// worker performs, so a skipped or failed auto run is recoverable per
// document without a re-upload. Every guard lives inside the helper (both
// modules on, still STORED with no facts, provider + consent, looks like a
// lab report) — a non-lab document is a tagged no-op, and a staging failure
// never fails the index that just succeeded.
const staging = await maybeAutoStageLabFacts(userId, documentId).catch(
() => null,
);
const labFactsStaged = staging?.staged === true ? staging.facts : 0;
return apiSuccess({ documentId, indexed: true, tokenCount, labFactsStaged });
): Promise<Response | null> {
const live = await findLiveDocumentRun(userId, documentId, "DOCUMENT_INDEX");
return live ? acceptedRunResponse(live) : null;
}
/** TEXT mode — index browser-OCR'd text (no provider egress). */
@@ -140,6 +115,7 @@ async function handleTextIndex(
request: NextRequest,
userId: string,
document: LoadedDocument,
background: boolean,
): Promise<Response> {
const row = await prisma.user.findUnique({
where: { id: userId },
@@ -151,6 +127,11 @@ async function handleTextIndex(
});
}
if (background) {
const live = await liveRunResponse(userId, document.id);
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -174,14 +155,27 @@ async function handleTextIndex(
);
}
const { tokenCount } = await upsertContentIndex({
userId,
documentId: document.id,
text: parsed.data.text,
source: "text-ocr",
providerType: null,
});
return finishIndex(request, userId, document.id, "text-ocr", tokenCount);
if (background) {
// The text is indexed in the worker, which then continues into the same
// lab staging (model calls) the synchronous path makes.
return startAiRun({
userId,
kind: "DOCUMENT_INDEX",
documentId: document.id,
params: { mode: "text" },
input: Buffer.from(parsed.data.text, "utf8"),
refundSlot: () => refundDocumentAiSlot(userId),
});
}
return outcomeResponse(
await executeDocumentIndex({
userId,
document,
input: { mode: "text", text: parsed.data.text },
origin: { ipAddress: getClientIp(request), worker: false },
}),
);
}
/** VISION mode — transcribe the stored original, then index the text. */
@@ -189,6 +183,7 @@ async function handleVisionIndex(
request: NextRequest,
userId: string,
document: LoadedDocument,
background: boolean,
): Promise<Response> {
// Transcribing the stored original is model work; indexing text the browser
// already read (the text mode above) is not, and stays open with AI off so
@@ -197,59 +192,18 @@ async function handleVisionIndex(
await requireAiCapability("documentAi", { pickDecides: true });
const pick = await requireDocumentVisionProvider(userId);
if (background) {
const live = await liveRunResponse(userId, document.id);
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
// Preparation failed before any provider dispatch — the slot goes back.
// Refs #776 — each failure is also recorded on the row so the detail view
// can explain the missing index after the toast is gone.
await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "pdf-needs-anthropic",
});
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
422,
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
);
}
if (vision.reason === "rasterFailed") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "raster-failed",
});
return apiError("The PDF pages couldn't be rendered for scanning.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
if (vision.reason === "fileType") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "local-unsupported",
});
return apiError(
"This document can't be scanned. Use local OCR (text mode).",
422,
{ errorCode: "documents.inbound.fileType" },
);
}
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "decrypt-error",
});
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.documentTranscribe.maxTokens,
DOCUMENT_INDEX_RESERVE_TOKENS,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
@@ -261,66 +215,30 @@ async function handleVisionIndex(
errorCode: "documents.inbound.budgetExceeded",
});
}
const budget = {
reserved: reservation.reserved,
owner: reservation.owner,
dateKey,
};
try {
const { text } = await transcribeDocument({
provider: pick.entry.instance,
providerType: pick.providerType,
images: vision.images,
documents: vision.documents,
});
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
// Refs #776 — the empty-transcription guard, same contract as the auto
// path (`tryProviderIndex`): a provider answer with no text must never
// become a "successful" empty index. The spend stays charged (the
// provider was called); the honest answer is an error plus the recorded
// reason.
if (text.trim().length === 0) {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "empty-transcription",
});
return apiError(
"The provider returned no text for this document. Try a clearer copy.",
422,
{ errorCode: "documents.inbound.extractFailed" },
);
}
const { tokenCount } = await upsertContentIndex({
if (background) {
// The worker picks the provider again and re-checks the wire for it right
// before the stored original leaves.
return startAiRun({
userId,
kind: "DOCUMENT_INDEX",
documentId: document.id,
text,
source: "vision",
providerType: pick.providerType,
});
return finishIndex(request, userId, document.id, "vision", tokenCount);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "provider-error",
});
if (err instanceof DocumentDescribeError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
annotate({
action: { name: "documents.contentIndex.failed" },
meta: { reason: "provider_error", mode: "vision" },
});
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
errorCode: "documents.inbound.extractFailed",
params: { mode: "vision", budget },
refundSlot: () => refundDocumentAiSlot(userId),
});
}
return outcomeResponse(
await executeDocumentIndex({
userId,
document,
input: { mode: "vision", pick, budget },
origin: { ipAddress: getClientIp(request), worker: false },
}),
);
}
@@ -1,10 +1,13 @@
/**
* v1.20.1 — POST /api/labs/ocr/extract, TEXT mode.
* v1.20.1 — POST /api/labs/ocr/extract.
*
* Focus: the text-mode structuring pass reserves the proportionate text budget
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling, and
* on a clean extraction failure it refunds the reservation in full rather than
* charging it.
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling.
*
* v1.40 — the route queues the read and answers 202; the read itself, its
* budget settlement and its failures are pinned in
* `src/lib/labs/__tests__/ocr-run.test.ts`. What stays here is everything the
* route still refuses before anything is queued.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -48,6 +51,18 @@ vi.mock("@/lib/ai/coach/budget", () => ({
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/documents/ai-runs/start", () => ({
startAiRun: vi.fn(
async () =>
new Response(
JSON.stringify({
data: { runId: "run-1", status: "QUEUED", pollAfterMs: 1500 },
error: null,
}),
{ status: 202 },
),
),
}));
vi.mock("@/lib/labs/ocr-extract", async () => {
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
"@/lib/labs/ocr-extract",
@@ -56,6 +71,7 @@ vi.mock("@/lib/labs/ocr-extract", async () => {
});
import { POST } from "../route";
import { startAiRun } from "@/lib/documents/ai-runs/start";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
@@ -63,10 +79,9 @@ import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { reserveBudget } from "@/lib/ai/coach/budget";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
import { annotate } from "@/lib/logging/context";
import { runOcrExtraction } from "@/lib/labs/ocr-extract";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -113,10 +128,8 @@ beforeEach(() => {
describe("POST /api/labs/ocr/extract — text mode budget", () => {
it("reserves the cheaper text ceiling, not the vision ceiling", async () => {
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(textReq());
expect(res.status).toBe(200);
expect(res.status).toBe(202);
// The reservation must use the text ceiling — proven distinct from the
// vision ceiling so a future merge can't silently re-point it.
@@ -136,61 +149,29 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
);
});
it("refunds the reservation in full on a clean extract failure", async () => {
vi.mocked(runOcrExtraction).mockRejectedValue(
new OcrExtractError("unreadable"),
);
const res = await POST(textReq());
expect(res.status).toBe(422);
// actual spend reconciles to 0 — the failed structuring pass is refunded,
// not charged at the reserved estimate.
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
AI_BUDGETS.ocrExtractText.maxTokens,
0,
"2026-06-26",
0,
{ servedBy: null, reservedOwner: "operator" },
);
});
it("identifies provider failures instead of blaming image quality", async () => {
vi.mocked(runOcrExtraction).mockRejectedValue(new Error("provider down"));
const res = await POST(textReq());
const body = (await res.json()) as { error: string };
expect(res.status).toBe(502);
expect(body.error).toContain("configured AI provider");
});
it("never passes the upstream body on, to the caller or to the event", async () => {
const upstreamBody = "ami-id instance-id iam/security-credentials/admin";
vi.mocked(runOcrExtraction).mockRejectedValue(
Object.assign(new Error("Local AI request failed (403)"), {
httpStatus: 403,
model: "llama3",
bodyExcerpt: upstreamBody,
}),
);
const res = await POST(textReq());
expect(res.status).toBe(502);
expect(await res.text()).not.toContain("iam/security-credentials");
const events = JSON.stringify(vi.mocked(annotate).mock.calls);
expect(events).not.toContain("iam/security-credentials");
// The status and the model stay, so an operator can still tell why.
expect(annotate).toHaveBeenCalledWith(
it("queues the text, sealed in the run, with the reservation it took", async () => {
const res = await POST(textReq("Glucose 95 mg/dL"));
expect(res.status).toBe(202);
expect(runOcrExtraction).not.toHaveBeenCalled();
expect(startAiRun).toHaveBeenCalledWith(
expect.objectContaining({
meta: expect.objectContaining({ upstreamStatus: 403, model: "llama3" }),
userId: "user-1",
kind: "LABS_OCR_EXTRACT",
params: {
mode: "text",
budget: {
reserved: AI_BUDGETS.ocrExtractText.maxTokens,
owner: "operator",
dateKey: "2026-06-26",
},
},
input: Buffer.from("Glucose 95 mg/dL", "utf8"),
}),
);
});
});
describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
describe("POST /api/labs/ocr/extract — vision uploads", () => {
function pdfReq(): Request {
// A minimal `%PDF-` header is all `detectOcrMimeType` needs to sniff a PDF.
const bytes = new Uint8Array([
@@ -225,50 +206,23 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
} 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==" }],
pageCount: 1,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
it("queues a PDF with its sniffed type; the worker renders it", async () => {
const res = await POST(pdfReq());
expect(res.status).toBe(200);
// The whole PDF was read: no coverage note on the response.
const whole = (await res.json()) as { data: Record<string, unknown> };
expect(whole.data).not.toHaveProperty("pageCoverage");
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(res.status).toBe(202);
// Rendering is the worker's job now; nothing is read in the request.
expect(rasterizePdf).not.toHaveBeenCalled();
expect(runOcrExtraction).not.toHaveBeenCalled();
expect(startAiRun).toHaveBeenCalledWith(
expect.objectContaining({
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
documents: [],
kind: "LABS_OCR_EXTRACT",
params: expect.objectContaining({
mode: "vision",
mime: "application/pdf",
}),
}),
);
});
it("says on the response when only the first pages of a long PDF were read", async () => {
const tenPages = Array.from({ length: 10 }, () => ({
mediaType: "image/jpeg" as const,
dataBase64: "cGFnZQ==",
}));
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: tenPages,
pageCount: 23,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(pdfReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { pageCoverage?: { read: number; total: number } };
};
expect(body.data.pageCoverage).toEqual({ read: 10, total: 23 });
});
it("hands the slot back when the upload is not an image or PDF", async () => {
const form = new FormData();
form.append(
@@ -287,36 +241,13 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
expect(refundRateLimit).toHaveBeenCalledWith("labs-ocr:user-1");
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("falls back to pdfNeedsAnthropic when rasterization fails", async () => {
vi.mocked(rasterizePdf).mockResolvedValue({
ok: false,
reason: "render-failed",
});
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",
0,
{ servedBy: null, reservedOwner: "operator" },
);
});
});
describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
it("charges the ceiling the operator set", async () => {
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "40");
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(textReq() as never);
expect(res.status).toBe(200);
expect(res.status).toBe(202);
expect(checkRateLimit).toHaveBeenCalledWith(
"labs-ocr:user-1",
40,
@@ -366,15 +297,9 @@ describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("keeps the slot once the provider was actually called", async () => {
vi.mocked(runOcrExtraction).mockRejectedValue(new Error("provider down"));
const failed = await POST(textReq() as never);
expect(failed.status).toBe(502);
expect(refundRateLimit).not.toHaveBeenCalled();
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const ok = await POST(textReq() as never);
expect(ok.status).toBe(200);
it("keeps the slot once the scan is queued", async () => {
const queued = await POST(textReq() as never);
expect(queued.status).toBe(202);
expect(refundRateLimit).not.toHaveBeenCalled();
});
});
+54 -188
View File
@@ -3,7 +3,15 @@
*
* Read-only (NOT idempotent) extraction of a paper lab report into STRUCTURED
* proposed rows for the mandatory human-review screen. Nothing is written to
* the database here; the raw upload is never persisted or logged.
* the person's record here; the raw upload is never logged.
*
* v1.40 — the read runs in the background worker. This route answers every
* refusal it can answer quickly (capability, provider, rate bucket, budget, a
* malformed or oversized upload, an unknown file type), seals the upload or
* the text into a `DocumentAiRun` (AES-256-GCM, dropped the moment the run
* finishes, the row deleted an hour later) and answers 202 with the run id.
* `GET /api/ai-runs/{id}` serves the proposed rows. Only the web client calls
* this route, so there is no synchronous form to keep.
*
* Two modes, dispatched on the request content-type:
*
@@ -22,9 +30,11 @@
* re-check the capability for that pick (`requireLabsOcrProvider`: the
* operator's switch, the labs module, and a document consent receipt for a
* pick that leaves the machine) → rate-limit
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → run extraction
* → reconcile budget. A slot is charged early so a 429 stays cheap, and it is
* handed back when the scan fails before the provider is called.
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → queue the run.
* The worker resolves the provider again, re-checks the wire, runs the
* extraction and reconciles the budget (`src/lib/labs/ocr-run.ts`). A slot is
* charged early so a 429 stays cheap, and it is handed back when the scan
* fails before the provider is called.
*
* Extracted text is UNTRUSTED (prompt-injection): the server never acts on an
* instruction inside the document — the human review step is the safety
@@ -35,12 +45,10 @@ import { Buffer } from "node:buffer";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
@@ -49,10 +57,10 @@ import {
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { startAiRun } from "@/lib/documents/ai-runs/start";
import { prisma } from "@/lib/db";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
import { OCR_RESERVE_TOKENS } from "@/lib/labs/ocr-run";
import {
BodyTooLargeError,
detectOcrMimeType,
@@ -72,27 +80,6 @@ export const dynamic = "force-dynamic";
/** OCR'd text is bounded in the schema; cap the JSON body proportionally. */
const TEXT_BODY_MAX_BYTES = 512 * 1024;
/**
* The wide-event facts of a failed provider call: the status and the model,
* never the upstream body. The base URL can be one a user typed, and what an
* arbitrary endpoint answers has no business in the event stream (v1.39.3);
* the response to the caller carries neither.
*/
function providerFailureMeta(error: unknown, mode?: "text") {
const err = error as {
httpStatus?: unknown;
model?: unknown;
};
return {
reason: "provider_error",
...(mode ? { mode } : {}),
...(typeof err.httpStatus === "number"
? { upstreamStatus: err.httpStatus }
: {}),
...(typeof err.model === "string" ? { model: err.model } : {}),
};
}
export const POST = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
@@ -111,7 +98,7 @@ export const POST = apiHandler(async (request: Request) => {
});
/**
* TEXT mode (v1.18.10) — structure in-browser-OCR'd text via any configured
* TEXT mode (v1.18.10) — queue in-browser-OCR'd text for any configured
* provider. No image bytes reach the server; the raw image stayed on-device.
*/
async function handleTextExtract(
@@ -173,7 +160,7 @@ async function handleTextExtract(
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtractText.maxTokens,
OCR_RESERVE_TOKENS.text,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
@@ -190,51 +177,27 @@ async function handleTextExtract(
});
}
try {
const result = await runOcrExtraction({
userId,
provider: pick.entry.instance,
providerType: pick.providerType,
ocrText: parsed.data.text,
});
// A clean structuring pass spent (at most) the reservation; the provider
// already billed it, so reconcile against the reserved estimate.
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return apiSuccess(result);
} catch (err) {
// A failed structuring call produced no usable rows; mirror the vision
// path and refund the reservation in full rather than charging it.
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof OcrExtractError) {
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
errorCode: "labs.ocr.extractFailed",
});
}
annotate({
action: { name: "labs.ocr.extractFailed" },
meta: providerFailureMeta(err, "text"),
});
return apiError(
"The configured AI provider could not process this report. Check the provider configuration and retry.",
502,
{ errorCode: "labs.ocr.extractFailed" },
);
}
// The structuring pass runs in the worker, which picks the provider again
// and re-checks the wire for it before the text leaves.
return startAiRun({
userId,
kind: "LABS_OCR_EXTRACT",
params: {
mode: "text",
budget: {
reserved: reservation.reserved,
owner: reservation.owner,
dateKey,
},
},
input: Buffer.from(parsed.data.text, "utf8"),
refundSlot: () => refundLabsOcrSlot(userId),
});
}
/**
* VISION mode — run a multipart photo / PDF through the user's vision-capable
* provider. The image transits server memory ephemerally and is never stored.
* VISION mode — queue a multipart photo / PDF for the user's vision-capable
* provider. The upload is sealed into the run until the read finishes.
*/
async function handleVisionExtract(
request: Request,
@@ -258,7 +221,7 @@ async function handleVisionExtract(
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtract.maxTokens,
OCR_RESERVE_TOKENS.vision,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
@@ -359,120 +322,23 @@ async function handleVisionExtract(
});
}
// 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 }[];
let pageCoverage: { read: number; total: number } | undefined;
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, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
await refundLabsOcrSlot(userId);
return apiError(
"Couldn't read this PDF; upload a photo instead.",
422,
{ errorCode: "labs.ocr.pdfNeedsAnthropic" },
);
}
images = raster.images;
documents = [];
// A PDF longer than the page cap is read from its first pages only.
// That used to live in the wide event alone; the person reviewing the
// rows is the one who needs to know the rest of the report was not
// read.
if (raster.pageCount > raster.images.length) {
pageCoverage = {
read: raster.images.length,
total: raster.pageCount,
};
annotate({
action: { name: "labs.ocr.pagesCapped" },
meta: { read: raster.images.length, total: raster.pageCount },
});
}
}
} else {
images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }];
documents = [];
}
// 8. Run the extraction. The actual token spend reconciles the reservation.
let actualTokens = 0;
try {
const result = await runOcrExtraction({
userId,
provider: pick.entry.instance,
providerType: pick.providerType,
images,
documents,
});
// The orchestration does not surface token counts; reconcile against the
// reserved estimate as the spend ceiling (the provider already billed it).
actualTokens = reservation.reserved;
await reconcileSpend(
userId,
reservation.reserved,
actualTokens,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return apiSuccess(pageCoverage ? { ...result, pageCoverage } : result);
} catch (err) {
// Provider/extraction failure — the call may still have burned tokens, so
// reconcile against the reserved estimate rather than refunding in full.
await reconcileSpend(
userId,
reservation.reserved,
actualTokens,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
if (err instanceof OcrExtractError) {
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
errorCode: "labs.ocr.extractFailed",
});
}
annotate({
action: { name: "labs.ocr.extractFailed" },
meta: providerFailureMeta(err),
});
return apiError(
"The configured AI provider could not process this report. Check the provider configuration and retry.",
502,
{ errorCode: "labs.ocr.extractFailed" },
);
}
// 7. Queue the read. The worker renders a PDF for a provider that cannot
// read one natively, runs the extraction, and settles the reservation.
return await startAiRun({
userId,
kind: "LABS_OCR_EXTRACT",
params: {
mode: "vision",
mime,
budget: {
reserved: reservation.reserved,
owner: reservation.owner,
dateKey,
},
},
input: buffer,
refundSlot: () => refundLabsOcrSlot(userId),
});
} catch (err) {
// A guard threw after the reservation (e.g. consent races) — refund fully.
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {