mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge remote-tracking branch 'origin/main' into wip/v140-I
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
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",
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user