Merge remote-tracking branch 'origin/main' into wip/v140-I

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 22:16:29 +02:00
commit ef0028c020
45 files changed
+3161 -1010

No files matched your search

@@ -20,6 +20,11 @@
* files (`tests/integration/ai-optional-*.test.ts`) prove behaviour; this
* proves no route escapes classification and no data route grows a gate.
*
* 5. Every kind of background run (`DOCUMENT_AI_RUN_KINDS`) is queued by a
* route on `AI_ROUTES` that asks the capability the worker asks again for
* that kind (`DOCUMENT_AI_RUN_CAPABILITY`), so a run cannot answer to a
* different switch than the request that started it.
*
* Mutation checks (each turned this file red by name): adding
* `await requireAiCapability("statusText")` to `insights/ecg/route.ts`;
* removing the capability call from `insights/chat/route.ts`; swapping
@@ -31,6 +36,11 @@ import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { AI_CAPABILITY_KEYS } from "@/lib/ai/capabilities/types";
import {
DOCUMENT_AI_RUN_CAPABILITY,
DOCUMENT_AI_RUN_KINDS,
type DocumentAiRunKindValue,
} from "@/lib/documents/ai-runs/types";
import { AI_ROUTES, DATA_ROUTES } from "./ai-route-inventory";
@@ -154,3 +164,28 @@ describe("AI capability route inventory", () => {
expect(both).toEqual([]);
});
});
/** The route that queues each kind of background run. */
const RUN_QUEUED_BY: Record<DocumentAiRunKindValue, string> = {
DOCUMENT_INDEX: "src/app/api/documents/inbound/[id]/index/route.ts",
LABS_OCR_EXTRACT: "src/app/api/labs/ocr/extract/route.ts",
DOCUMENT_SUMMARY: "src/app/api/documents/inbound/[id]/summary/route.ts",
DOCUMENT_SUGGEST: "src/app/api/documents/inbound/[id]/suggest/route.ts",
DOCUMENT_EXTRACT: "src/app/api/documents/inbound/[id]/extract/route.ts",
};
describe("background run kinds", () => {
it.each(DOCUMENT_AI_RUN_KINDS)(
"%s is queued by an AI route under the capability the worker re-checks",
(kind) => {
const path = RUN_QUEUED_BY[kind];
const entry = AI_ROUTES[path];
expect(entry, `${path} is not on AI_ROUTES`).toBeDefined();
expect(entry.capabilities).toContain(DOCUMENT_AI_RUN_CAPABILITY[kind]);
expect(
new RegExp(`kind:\\s*"${kind}"`).test(code(path)),
`${path} does not queue ${kind}`,
).toBe(true);
},
);
});
@@ -19,8 +19,11 @@ import { NextRequest } from "next/server";
*/
vi.mock("@/lib/db", () => {
// The staging transaction locks the document row and asks again whether
// it may stage (a review can finish while a background read runs).
const tx = {
extractedFact: { deleteMany: vi.fn() },
$queryRaw: vi.fn(async () => [{ status: "STORED" }]),
extractedFact: { deleteMany: vi.fn(), count: vi.fn(async () => 0) },
inboundDocument: { update: vi.fn(), findUniqueOrThrow: vi.fn() },
};
return {
@@ -123,7 +126,11 @@ import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
const tx = (
prisma as unknown as {
__tx: {
extractedFact: { deleteMany: ReturnType<typeof vi.fn> };
$queryRaw: ReturnType<typeof vi.fn>;
extractedFact: {
deleteMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
inboundDocument: {
update: ReturnType<typeof vi.fn>;
findUniqueOrThrow: ReturnType<typeof vi.fn>;
@@ -304,6 +311,27 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
);
});
it("refuses to stage when a fact was approved while the read ran, keeping the review", async () => {
tx.extractedFact.count.mockResolvedValueOnce(1);
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(409);
const body = await res.json();
expect(body.meta.errorCode).toBe(
"documents.inbound.alreadyPartlyConfirmed",
);
expect(tx.extractedFact.deleteMany).not.toHaveBeenCalled();
expect(tx.inboundDocument.update).not.toHaveBeenCalled();
});
it("refuses to stage when the document was confirmed while the read ran", async () => {
tx.$queryRaw.mockResolvedValueOnce([{ status: "CONFIRMED" }]);
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta.errorCode).toBe("documents.inbound.alreadyConfirmed");
expect(tx.extractedFact.deleteMany).not.toHaveBeenCalled();
});
it("422s (notIndexed) without stored text, before any bucket charge", async () => {
vi.mocked(loadDocumentChatText).mockResolvedValue(null as never);
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
@@ -22,6 +22,15 @@
* The document is UNTRUSTED (prompt-injection): the server never acts on an
* instruction inside it. The staged facts land PENDING for the mandatory
* review-then-confirm screen; nothing reaches the structured stores here.
*
* v1.40 — `Prefer: respond-async` (RFC 7240) runs the extraction in the
* background worker: every refusal that can be answered quickly is still
* answered here, then the route answers 202 with a run id. The run's result is
* `{ documentId, factsStaged, status }` rather than the whole detail (the
* facts are staged on the document; the client reads them from there).
* Without the header the route behaves exactly as before; the iPhone app
* relies on that. Both paths run one body (`executeDocumentExtract`), which
* re-checks the document under a row lock before it stages anything.
*/
import { Buffer } from "node:buffer";
@@ -40,24 +49,13 @@ 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 type { ProviderChainType } from "@/lib/ai/provider-chain";
import { prisma } from "@/lib/db";
import {
InboundExtractError,
runInboundExtraction,
type InboundExtractionResult,
} from "@/lib/documents/extract";
import {
decryptDocumentContent,
encryptFactData,
encryptFactProvenance,
serialiseDocumentDetail,
} from "@/lib/documents/store";
import { serialiseDocumentDetail } from "@/lib/documents/store";
import {
requireDocumentTextProvider,
requireDocumentVisionProvider,
@@ -66,73 +64,34 @@ import {
checkDocumentAiRateLimit,
documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
loadOwnedDocument,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
executeDocumentExtract,
type DocumentExtractInput,
} from "@/lib/documents/ai-runs/extract-run";
import {
acceptedRunResponse,
outcomeResponse,
prefersRespondAsync,
} from "@/lib/documents/ai-runs/http";
import { findLiveDocumentRun, startAiRun } from "@/lib/documents/ai-runs/start";
import type { AiRunBudget } from "@/lib/documents/ai-runs/types";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
inboundStoredExtractSchema,
inboundTextExtractSchema,
} from "@/lib/validations/inbound-documents";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
/** A loaded, owner-scoped, still-extractable document row. */
type LoadedDocument = {
id: string;
kind: string;
contentEncrypted: Uint8Array;
contentCodec: string;
mimeType: string;
status: string;
};
/**
* Replace the document's staged facts and flip it to EXTRACTED in one
* transaction. Re-extraction is allowed only when NO fact is APPROVED yet (the
* POST handler refuses it otherwise), so clearing every prior staged fact here
* can never drop an approved row or sever a committed-record provenance link —
* the only facts present are PENDING / REJECTED leftovers from an earlier run.
*/
async function stageExtraction(
documentId: string,
userId: string,
result: InboundExtractionResult,
) {
return prisma.$transaction(async (tx) => {
await tx.extractedFact.deleteMany({ where: { documentId, userId } });
await tx.inboundDocument.update({
where: { id: documentId },
data: {
status: "EXTRACTED",
providerType: result.providerType,
reportDate: result.reportDate
? dateOnlyAtNoonUtc(result.reportDate)
: null,
facts: {
create: result.facts.map((f) => ({
userId,
factType: f.factType,
status: "PENDING" as const,
confidence: f.confidence,
needsReview: f.needsReview,
dataEncrypted: encryptFactData(f.data),
provenanceEncrypted: encryptFactProvenance(f.provenance),
})),
},
},
});
return tx.inboundDocument.findUniqueOrThrow({
where: { id: documentId },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
});
}
type ExtractInputMode = "text" | "stored" | "vision";
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
@@ -146,17 +105,7 @@ export const POST = apiHandler(
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await prisma.inboundDocument.findFirst({
where: { id, userId: user.id, deletedAt: null },
select: {
id: true,
kind: true,
contentEncrypted: true,
contentCodec: true,
mimeType: true,
status: true,
},
});
const document = await loadOwnedDocument(user.id, id);
if (!document) {
return apiError("Document not found", 404, {
errorCode: "documents.inbound.notFound",
@@ -175,7 +124,8 @@ export const POST = apiHandler(
// committed-record provenance link — and re-stage the same facts as PENDING,
// letting the user approve them a second time and duplicate the committed
// lab / condition / medication. Block it: the user must finish reviewing or
// discard the document first.
// discard the document first. Staging asks again under a row lock, because
// a review can finish while the read runs.
const approvedCount = await prisma.extractedFact.count({
where: { documentId: document.id, userId: user.id, status: "APPROVED" },
});
@@ -191,6 +141,7 @@ export const POST = apiHandler(
);
}
const background = prefersRespondAsync(request);
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
// One body read for both JSON modes, BEFORE any bucket charge — a
@@ -200,20 +151,106 @@ export const POST = apiHandler(
});
if (jsonError) return jsonError;
if (inboundStoredExtractSchema.safeParse(body).success) {
return handleStoredExtract(request, user.id, document);
return handleStoredExtract(request, user.id, document, background);
}
return handleTextExtract(request, user.id, document, body);
return handleTextExtract(request, user.id, document, body, background);
}
return handleVisionExtract(request, user.id, document);
return handleVisionExtract(request, user.id, document, background);
},
);
/**
* An extraction of this document from the same input, already queued or
* running: attach to it rather than charge a second one. Background only.
*/
async function liveRunResponse(
userId: string,
documentId: string,
input: ExtractInputMode,
): Promise<Response | null> {
const live = await findLiveDocumentRun(
userId,
documentId,
"DOCUMENT_EXTRACT",
(params) => params.extract?.input === input,
);
return live ? acceptedRunResponse(live) : null;
}
/** Reserve the read's budget; null (slot refunded) when the day is spent. */
async function reserveFor(
userId: string,
providerType: ProviderChainType,
tokens: number,
): Promise<AiRunBudget | null> {
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
tokens,
dateKey,
resolveDailyCap([{ providerType }]),
resolveCostOwner([{ providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return null;
}
return { reserved: reservation.reserved, owner: reservation.owner, dateKey };
}
function budgetExceeded(): Response {
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
}
/** Queue the extraction for the background worker. */
function queueExtract(
userId: string,
document: LoadedDocument,
input: ExtractInputMode,
budget: AiRunBudget,
text: string | null,
): Promise<Response> {
return startAiRun({
userId,
kind: "DOCUMENT_EXTRACT",
documentId: document.id,
params: {
mode: input === "vision" ? "vision" : "text",
budget,
extract: { input },
},
input: text === null ? null : Buffer.from(text, "utf8"),
refundSlot: () => refundDocumentAiSlot(userId),
});
}
/** The synchronous path: run the shared body and answer with the detail. */
async function runNow(
request: NextRequest,
userId: string,
document: LoadedDocument,
input: DocumentExtractInput,
): Promise<Response> {
const outcome = await executeDocumentExtract({
userId,
document,
input,
origin: { ipAddress: getClientIp(request), worker: false },
});
if (!outcome.ok) return outcomeResponse(outcome);
return apiSuccess(serialiseDocumentDetail(outcome.data, outcome.data.facts));
}
/** TEXT mode — structure in-browser-OCR'd text against the stored row. */
async function handleTextExtract(
request: NextRequest,
userId: string,
document: LoadedDocument,
body: unknown,
background: boolean,
): Promise<Response> {
const row = await prisma.user.findUnique({
where: { id: userId },
@@ -228,6 +265,11 @@ async function handleTextExtract(
const pick = await requireDocumentTextProvider(userId);
if (background) {
const live = await liveRunResponse(userId, document.id, "text");
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -244,68 +286,22 @@ async function handleTextExtract(
);
}
const dateKey = buildDateKey();
const reservation = await reserveBudget(
const budget = await reserveFor(
userId,
pick.entry.providerType,
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
}
if (!budget) return budgetExceeded();
try {
const result = await runInboundExtraction({
provider: pick.entry.instance,
providerType: pick.providerType,
ocrText: parsed.data.text,
});
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
const updated = await stageExtraction(document.id, userId, result);
await auditLog("documents.inbound.extract", {
userId,
ipAddress: getClientIp(request),
details: {
documentId: document.id,
facts: updated.facts.length,
mode: "text",
},
});
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof InboundExtractError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
annotate({
action: { name: "documents.inbound.extractFailed" },
meta: { reason: "provider_error", mode: "text" },
});
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
errorCode: "documents.inbound.extractFailed",
});
if (background) {
return queueExtract(userId, document, "text", budget, parsed.data.text);
}
return runNow(request, userId, document, {
mode: "text",
text: parsed.data.text,
pick,
budget,
});
}
/**
@@ -324,6 +320,7 @@ async function handleStoredExtract(
request: NextRequest,
userId: string,
document: LoadedDocument,
background: boolean,
): Promise<Response> {
const chat = await loadDocumentChatText(userId, document.id);
if (!chat || !chat.text.trim()) {
@@ -334,71 +331,31 @@ async function handleStoredExtract(
const pick = await requireDocumentTextProvider(userId);
if (background) {
const live = await liveRunResponse(userId, document.id, "stored");
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const dateKey = buildDateKey();
const reservation = await reserveBudget(
const budget = await reserveFor(
userId,
pick.entry.providerType,
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
}
if (!budget) return budgetExceeded();
try {
const result = await runInboundExtraction({
provider: pick.entry.instance,
providerType: pick.providerType,
ocrText: chat.text,
});
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
const updated = await stageExtraction(document.id, userId, result);
await auditLog("documents.inbound.extract", {
userId,
ipAddress: getClientIp(request),
details: {
documentId: document.id,
facts: updated.facts.length,
mode: "stored",
},
});
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof InboundExtractError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
annotate({
action: { name: "documents.inbound.extractFailed" },
meta: { reason: "provider_error", mode: "stored" },
});
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
errorCode: "documents.inbound.extractFailed",
});
if (background) {
// The worker loads the stored text itself; nothing is sealed into the run.
return queueExtract(userId, document, "stored", budget, null);
}
return runNow(request, userId, document, {
mode: "stored",
text: chat.text,
pick,
budget,
});
}
/** VISION mode — run the stored original through the vision provider. */
@@ -406,116 +363,29 @@ async function handleVisionExtract(
request: NextRequest,
userId: string,
document: LoadedDocument,
background: boolean,
): Promise<Response> {
const pick = await requireDocumentVisionProvider(userId);
if (background) {
const live = await liveRunResponse(userId, document.id, "vision");
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
// Decrypt the stored original and re-derive its MIME from the bytes (never
// trust a stored label for the provider call). Every miss below happens
// before the provider dispatch, so the charged slot goes back.
let buffer: Buffer;
try {
buffer = decryptDocumentContent(
document.contentEncrypted,
document.contentCodec,
);
} catch {
await refundDocumentAiSlot(userId);
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
const mime = detectOcrMimeType(buffer);
if (!mime) {
await refundDocumentAiSlot(userId);
return apiError(
"This document can't be scanned. Use local OCR (text mode).",
422,
{ errorCode: "documents.inbound.fileType" },
);
}
if (mime === "application/pdf" && !pick.pdfSupported) {
await refundDocumentAiSlot(userId);
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
422,
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
);
}
const dateKey = buildDateKey();
const reservation = await reserveBudget(
const budget = await reserveFor(
userId,
pick.entry.providerType,
AI_BUDGETS.ocrExtract.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
}
if (!budget) return budgetExceeded();
const dataBase64 = buffer.toString("base64");
const images =
mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }];
const documents =
mime === "application/pdf"
? [{ mediaType: "application/pdf" as const, dataBase64 }]
: [];
try {
const result = await runInboundExtraction({
provider: pick.entry.instance,
providerType: pick.providerType,
images,
documents,
});
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
const updated = await stageExtraction(document.id, userId, result);
await auditLog("documents.inbound.extract", {
userId,
ipAddress: getClientIp(request),
details: {
documentId: document.id,
facts: updated.facts.length,
mode: "vision",
},
});
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof InboundExtractError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
annotate({
action: { name: "documents.inbound.extractFailed" },
meta: { reason: "provider_error", mode: "vision" },
});
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
errorCode: "documents.inbound.extractFailed",
});
if (background) {
// The worker decrypts the stored original itself, after it has picked
// the provider again and re-checked the wire for it.
return queueExtract(userId, document, "vision", budget, null);
}
return runNow(request, userId, document, { mode: "vision", pick, budget });
}
@@ -11,13 +11,21 @@
* The document is UNTRUSTED (prompt-injection): the server never acts on an
* instruction inside it. With no provider configured this 422s the enhancement;
* the stored document and the manual edit form are untouched.
*
* 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 (`executeDocumentSuggest`).
*/
import { Buffer } from "node:buffer";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
@@ -25,33 +33,36 @@ import {
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
import {
DocumentAssistError,
runDocumentAssist,
type DocumentSuggestion,
} from "@/lib/documents/assist";
import {
checkDocumentAiRateLimit,
documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
loadOwnedDocument,
prepareVisionInput,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
acceptedRunResponse,
outcomeResponse,
prefersRespondAsync,
} from "@/lib/documents/ai-runs/http";
import { findLiveDocumentRun, startAiRun } from "@/lib/documents/ai-runs/start";
import {
executeDocumentSuggest,
type DocumentSuggestInput,
} from "@/lib/documents/ai-runs/suggest-run";
import type { AiRunBudget } from "@/lib/documents/ai-runs/types";
import {
requireDocumentTextProvider,
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";
@@ -79,38 +90,75 @@ export const POST = apiHandler(
});
}
const background = prefersRespondAsync(request);
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return handleTextSuggest(request, user.id, document);
return handleTextSuggest(request, user.id, document, background);
}
return handleVisionSuggest(request, user.id, document);
return handleVisionSuggest(request, user.id, document, background);
},
);
/** Persist the AI-budget ledger side effect + the audit trail. NEVER the row. */
async function finishSuggest(
request: NextRequest,
/**
* A suggestion read of this document over the same transport, already queued
* or running: attach to it rather than charge a second one. Background only.
*/
async function liveRunResponse(
userId: string,
documentId: string,
mode: "vision" | "text",
suggestion: DocumentSuggestion,
): Promise<Response> {
await auditLog("documents.inbound.suggest", {
): Promise<Response | null> {
const live = await findLiveDocumentRun(
userId,
ipAddress: getClientIp(request),
details: { documentId, mode },
documentId,
"DOCUMENT_SUGGEST",
(params) => params.mode === mode,
);
return live ? acceptedRunResponse(live) : null;
}
/** Reserve the read's budget; null (slot refunded) when the day is spent. */
async function reserveFor(
userId: string,
providerType: ProviderChainType,
): Promise<AiRunBudget | null> {
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.documentAssist.maxTokens,
dateKey,
resolveDailyCap([{ providerType }]),
resolveCostOwner([{ providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return null;
}
return { reserved: reservation.reserved, owner: reservation.owner, dateKey };
}
function budgetExceeded(): Response {
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
annotate({
action: { name: "documents.assist.suggest" },
meta: {
documentId,
mode,
hasTitle: suggestion.title !== null,
hasKind: suggestion.kind !== null,
hasDate: suggestion.documentDate !== null,
},
});
return apiSuccess({ suggestions: suggestion });
}
/** The synchronous path: run the shared body inside this request. */
async function runNow(
request: NextRequest,
userId: string,
document: LoadedDocument,
input: DocumentSuggestInput,
): Promise<Response> {
return outcomeResponse(
await executeDocumentSuggest({
userId,
document,
input,
origin: { ipAddress: getClientIp(request), worker: false },
}),
);
}
/** TEXT mode — suggest from in-browser-OCR'd text (opt-in local OCR). */
@@ -118,6 +166,7 @@ async function handleTextSuggest(
request: NextRequest,
userId: string,
document: LoadedDocument,
background: boolean,
): Promise<Response> {
const row = await prisma.user.findUnique({
where: { id: userId },
@@ -131,6 +180,11 @@ async function handleTextSuggest(
const pick = await requireDocumentTextProvider(userId);
if (background) {
const live = await liveRunResponse(userId, document.id, "text");
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -154,55 +208,28 @@ async function handleTextSuggest(
);
}
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.documentAssist.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
const budget = await reserveFor(userId, pick.entry.providerType);
if (!budget) return budgetExceeded();
if (background) {
// The worker picks the provider again and re-checks the wire for it right
// before the text leaves.
return startAiRun({
userId,
kind: "DOCUMENT_SUGGEST",
documentId: document.id,
params: { mode: "text", budget },
input: Buffer.from(parsed.data.text, "utf8"),
refundSlot: () => refundDocumentAiSlot(userId),
});
}
try {
const suggestion = await runDocumentAssist({
provider: pick.entry.instance,
providerType: pick.providerType,
ocrText: parsed.data.text,
});
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return finishSuggest(request, userId, document.id, "text", suggestion);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof DocumentAssistError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
annotate({
action: { name: "documents.assist.failed" },
meta: { reason: "provider_error", mode: "text" },
});
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
errorCode: "documents.inbound.extractFailed",
});
}
return runNow(request, userId, document, {
mode: "text",
text: parsed.data.text,
pick,
budget,
});
}
/** VISION mode — suggest from the stored original via the vision provider. */
@@ -210,83 +237,32 @@ async function handleVisionSuggest(
request: NextRequest,
userId: string,
document: LoadedDocument,
background: boolean,
): Promise<Response> {
const pick = await requireDocumentVisionProvider(userId);
if (background) {
const live = await liveRunResponse(userId, document.id, "vision");
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.
await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
422,
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
);
}
if (vision.reason === "fileType") {
return apiError(
"This document can't be scanned. Use local OCR (text mode).",
422,
{ errorCode: "documents.inbound.fileType" },
);
}
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
const budget = await reserveFor(userId, pick.entry.providerType);
if (!budget) return budgetExceeded();
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.documentAssist.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
}
try {
const suggestion = await runDocumentAssist({
provider: pick.entry.instance,
providerType: pick.providerType,
images: vision.images,
documents: vision.documents,
});
await reconcileSpend(
if (background) {
// The worker decrypts the stored original itself, after it has picked
// the provider again and re-checked the wire for it.
return startAiRun({
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return finishSuggest(request, userId, document.id, "vision", suggestion);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof DocumentAssistError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
annotate({
action: { name: "documents.assist.failed" },
meta: { reason: "provider_error", mode: "vision" },
});
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
errorCode: "documents.inbound.extractFailed",
kind: "DOCUMENT_SUGGEST",
documentId: document.id,
params: { mode: "vision", budget },
refundSlot: () => refundDocumentAiSlot(userId),
});
}
return runNow(request, userId, document, { mode: "vision", pick, budget });
}
@@ -18,7 +18,18 @@
* Same VISION/TEXT dispatch and gauntlet as the extract route. The document is
* UNTRUSTED (prompt-injection): the server never acts on an instruction inside
* it. With no provider configured this 422s; nothing is stored either way.
*
* 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. A summary that is to
* be stored marks the document PENDING while it is queued. Without the header
* the route behaves exactly as before; the iPhone app relies on that. Both
* paths run one body (`executeDocumentSummary`). A transcription of text the
* browser already read calls no model and is answered here either way.
*/
import { Buffer } from "node:buffer";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -32,9 +43,9 @@ import {
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
@@ -45,19 +56,23 @@ import {
documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
loadOwnedDocument,
prepareVisionInput,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
DocumentDescribeError,
runDocumentSummary,
documentSummaryBlockedCopy,
transcribeDocument,
type DescribeInput,
} from "@/lib/documents/describe";
import { encryptDocumentSummary } from "@/lib/documents/store";
import type { OutboundReason } from "@/lib/ai/safety/outbound-screen";
acceptedRunResponse,
outcomeResponse,
prefersRespondAsync,
} from "@/lib/documents/ai-runs/http";
import { findLiveDocumentRun, startAiRun } from "@/lib/documents/ai-runs/start";
import {
executeDocumentSummary,
markSummaryQueued,
type DocumentSummaryInput,
type SummaryPersistence,
} from "@/lib/documents/ai-runs/summary-run";
import type { AiRunBudget, AiRunParams } from "@/lib/documents/ai-runs/types";
import { transcribeDocument } from "@/lib/documents/describe";
import {
requireDocumentTextProvider,
requireDocumentVisionProvider,
@@ -78,11 +93,15 @@ export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
type SummaryPersistenceTarget = {
/** What one request asks for, read once from the query and the headers. */
interface SummaryRequest {
userId: string;
documentId: string;
replaceExisting: boolean;
};
document: LoadedDocument;
mode: DocumentSummaryMode;
locale: Locale;
persist: SummaryPersistence | null;
background: boolean;
}
function resolveMode(request: NextRequest): DocumentSummaryMode {
const raw = new URL(request.url).searchParams.get("mode");
@@ -98,100 +117,6 @@ function resolveReplaceExisting(request: NextRequest): boolean {
return new URL(request.url).searchParams.get("replace") === "true";
}
/**
* Run the requested describe call (summary or raw text) over the input.
*
* Ordinary action-row summaries and raw transcription remain session-only.
* The document summary block opts into persistence so an empty document can be
* repaired and an existing stored summary can be explicitly regenerated.
*/
async function describe(
mode: DocumentSummaryMode,
input: DescribeInput,
locale: Locale,
persist: SummaryPersistenceTarget | null,
): Promise<
| {
summary: string;
persistence?: "stored" | "withheld" | "failed";
}
| { text: string }
> {
if (mode === "text") return transcribeDocument(input);
// A normal summary request fills an empty slot only. Replacement is reserved
// for the document detail's explicit "Generate again" action.
const { summary, blocked } = await runDocumentSummary({ ...input, locale });
const persistence = persist
? await persistSummary(persist, summary, blocked)
: undefined;
return {
summary: blocked ? documentSummaryBlockedCopy(locale) : summary,
...(persistence ? { persistence } : {}),
};
}
/**
* Store a screened-clean summary, or record the refusal as a state.
*
* A blocked summary NEVER lands as text — the honest statement the caller shows
* is generated copy for this response, not the model's prose, and persisting it
* would put a refusal where a summary belongs. Only the WITHHELD state is kept,
* and it is not terminal: the user can ask again.
*
* Storing is best-effort. The user is waiting on the summary they can already
* read in the response; a failed write must not turn that into an error.
*/
async function persistSummary(
target: SummaryPersistenceTarget,
summary: string,
blocked: OutboundReason | null,
): Promise<"stored" | "withheld" | "failed"> {
try {
if (blocked) {
await prisma.inboundDocument.updateMany({
where: {
id: target.documentId,
userId: target.userId,
deletedAt: null,
summaryState: { not: "READY" },
},
// An explicit request is the person reading it on purpose, so an
// import's hold on automatic AI reading ends here.
data: { summaryState: "WITHHELD", aiReadDeferred: false },
});
return "withheld";
}
// Preserve a previously stored summary unless the caller explicitly chose
// the replacement action. Failed or screened attempts never reach this
// write, so the previous clean summary remains available in those cases.
const written = await prisma.inboundDocument.updateMany({
where: {
id: target.documentId,
userId: target.userId,
deletedAt: null,
...(target.replaceExisting ? {} : { summaryEncrypted: null }),
},
data: {
summaryEncrypted: encryptDocumentSummary(summary),
summaryGeneratedAt: new Date(),
summaryState: "READY",
aiReadDeferred: false,
},
});
annotate({
action: { name: "documents.summary.persisted" },
meta: { documentId: target.documentId, stored: written.count > 0 },
});
return written.count > 0 ? "stored" : "failed";
} catch {
annotate({
action: { name: "documents.summary.persistFailed" },
meta: { documentId: target.documentId },
});
return blocked ? "withheld" : "failed";
}
}
/** The budget the requested mode charges. */
function budgetFor(mode: DocumentSummaryMode): number {
return mode === "text"
@@ -219,13 +144,11 @@ export const POST = apiHandler(
}
const mode = resolveMode(request);
const persist: SummaryPersistenceTarget | null =
// A normal summary request fills an empty slot only. Replacement is
// reserved for the document detail's explicit "Generate again" action.
const persist: SummaryPersistence | null =
mode === "summary" && resolvePersistRequested(request)
? {
userId: user.id,
documentId: document.id,
replaceExisting: resolveReplaceExisting(request),
}
? { replaceExisting: resolveReplaceExisting(request) }
: null;
// The outbound screen on the summary needs the reader's locale to pick its
// pattern banks; resolve it once here and thread it into both describe legs.
@@ -233,57 +156,112 @@ export const POST = apiHandler(
request,
userLocale: user.locale ?? null,
});
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return handleTextSummary(
request,
user.id,
document,
mode,
locale,
persist,
);
}
return handleVisionSummary(
request,
user.id,
const ask: SummaryRequest = {
userId: user.id,
document,
mode,
locale,
persist,
);
background: prefersRespondAsync(request),
};
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return handleTextSummary(request, ask);
}
return handleVisionSummary(request, ask);
},
);
async function finishSummary(
request: NextRequest,
userId: string,
documentId: string,
/** The run parameters for this request (never health data). */
function runParams(
ask: SummaryRequest,
inputMode: "vision" | "text",
mode: DocumentSummaryMode,
result: { summary: string } | { text: string },
budget: AiRunBudget,
): AiRunParams {
return {
mode: inputMode,
budget,
summary: {
output: ask.mode,
persist: ask.persist !== null,
replace: ask.persist?.replaceExisting ?? false,
locale: ask.locale,
},
};
}
/**
* A run that will produce the same thing, already queued or running: attach
* to it rather than charge a second one. Background path only.
*/
async function liveRunResponse(
ask: SummaryRequest,
inputMode: "vision" | "text",
): Promise<Response | null> {
const live = await findLiveDocumentRun(
ask.userId,
ask.document.id,
"DOCUMENT_SUMMARY",
(params) =>
params.mode === inputMode &&
params.summary?.output === ask.mode &&
params.summary.persist === (ask.persist !== null) &&
params.summary.replace === (ask.persist?.replaceExisting ?? false),
);
return live ? acceptedRunResponse(live) : null;
}
/** Reserve the read's budget; null (slot refunded) when the day is spent. */
async function reserveFor(
ask: SummaryRequest,
providerType: ProviderChainType,
): Promise<AiRunBudget | null> {
const dateKey = buildDateKey();
const reservation = await reserveBudget(
ask.userId,
budgetFor(ask.mode),
dateKey,
resolveDailyCap([{ providerType }]),
resolveCostOwner([{ providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(ask.userId);
return null;
}
return { reserved: reservation.reserved, owner: reservation.owner, dateKey };
}
function budgetExceeded(): Response {
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
}
/** Queue the read; a summary that is to be stored is marked PENDING first. */
async function queueSummary(
ask: SummaryRequest,
inputMode: "vision" | "text",
budget: AiRunBudget,
text: string | null,
): Promise<Response> {
await auditLog("documents.inbound.summary", {
userId,
ipAddress: getClientIp(request),
details: { documentId, mode, inputMode },
if (ask.persist) await markSummaryQueued(ask.userId, ask.document.id);
return startAiRun({
userId: ask.userId,
kind: "DOCUMENT_SUMMARY",
documentId: ask.document.id,
params: runParams(ask, inputMode, budget),
input: text === null ? null : Buffer.from(text, "utf8"),
refundSlot: () => refundDocumentAiSlot(ask.userId),
});
annotate({
action: { name: "documents.summary.serve" },
meta: { documentId, mode, inputMode },
});
return apiSuccess(result);
}
/** TEXT mode — summarise / echo in-browser-OCR'd text. */
async function handleTextSummary(
request: NextRequest,
userId: string,
document: LoadedDocument,
mode: DocumentSummaryMode,
locale: Locale,
persist: SummaryPersistenceTarget | null,
ask: SummaryRequest,
): Promise<Response> {
const { userId, document, mode } = ask;
const row = await prisma.user.findUnique({
where: { id: userId },
select: { labsLocalOcrEnabled: true },
@@ -294,6 +272,11 @@ async function handleTextSummary(
});
}
if (ask.background && mode !== "text") {
const live = await liveRunResponse(ask, "text");
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -319,13 +302,24 @@ async function handleTextSummary(
// `mode=text` over posted OCR text is a pure echo — the text IS the
// transcription. No provider egress, no consent, no budget: session-only.
// There is nothing to run in the background, so the preference is not
// applied and the answer comes straight back.
if (mode === "text") {
const result = await transcribeDocument({
provider: {} as never,
providerType: "local-ocr",
ocrText: parsed.data.text,
});
return finishSummary(request, userId, document.id, "text", mode, result);
await auditLog("documents.inbound.summary", {
userId,
ipAddress: getClientIp(request),
details: { documentId: document.id, mode, inputMode: "text" },
});
annotate({
action: { name: "documents.summary.serve" },
meta: { documentId: document.id, mode, inputMode: "text" },
});
return apiSuccess(result);
}
let pick: Awaited<ReturnType<typeof requireDocumentTextProvider>>;
@@ -337,145 +331,65 @@ async function handleTextSummary(
throw err;
}
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
budgetFor(mode),
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
const budget = await reserveFor(ask, pick.entry.providerType);
if (!budget) return budgetExceeded();
if (ask.background) {
// The worker picks the provider again and re-checks the wire for it right
// before the text leaves.
return queueSummary(ask, "text", budget, parsed.data.text);
}
try {
const result = await describe(
mode,
{
provider: pick.entry.instance,
providerType: pick.providerType,
ocrText: parsed.data.text,
},
locale,
persist,
);
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return finishSummary(request, userId, document.id, "text", mode, result);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return summaryError(err, "text");
}
return runNow(request, ask, {
mode: "text",
text: parsed.data.text,
pick,
budget,
});
}
/** VISION mode — summarise / transcribe the stored original. */
async function handleVisionSummary(
request: NextRequest,
userId: string,
document: LoadedDocument,
mode: DocumentSummaryMode,
locale: Locale,
persist: SummaryPersistenceTarget | null,
ask: SummaryRequest,
): Promise<Response> {
const pick = await requireDocumentVisionProvider(userId);
const pick = await requireDocumentVisionProvider(ask.userId);
const rl = await checkDocumentAiRateLimit(userId);
if (ask.background) {
const live = await liveRunResponse(ask, "vision");
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(ask.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.
await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
422,
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
);
}
if (vision.reason === "fileType") {
return apiError(
"This document can't be scanned. Use local OCR (text mode).",
422,
{ errorCode: "documents.inbound.fileType" },
);
}
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
});
const budget = await reserveFor(ask, pick.entry.providerType);
if (!budget) return budgetExceeded();
if (ask.background) {
// The worker decrypts the stored original itself, after it has picked
// the provider again and re-checked the wire for it.
return queueSummary(ask, "vision", budget, null);
}
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
budgetFor(mode),
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
return runNow(request, ask, { mode: "vision", pick, budget });
}
/** The synchronous path: run the shared body inside this request. */
async function runNow(
request: NextRequest,
ask: SummaryRequest,
input: DocumentSummaryInput,
): Promise<Response> {
return outcomeResponse(
await executeDocumentSummary({
userId: ask.userId,
document: ask.document,
input,
output: ask.mode,
locale: ask.locale,
persist: ask.persist,
origin: { ipAddress: getClientIp(request), worker: false },
}),
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
}
try {
const result = await describe(
mode,
{
provider: pick.entry.instance,
providerType: pick.providerType,
images: vision.images,
documents: vision.documents,
},
locale,
persist,
);
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return finishSummary(request, userId, document.id, "vision", mode, result);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return summaryError(err, "vision");
}
}
function summaryError(err: unknown, mode: "vision" | "text"): Response {
if (err instanceof DocumentDescribeError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
annotate({
action: { name: "documents.summary.failed" },
meta: { reason: "provider_error", mode },
});
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
errorCode: "documents.inbound.extractFailed",
});
}