mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Run document summaries and filing suggestions in the background
The summary and suggest routes accept `Prefer: respond-async`: after the same quick refusals they answer 202 with a run id, and the background document AI worker runs the read under two new run kinds (DOCUMENT_SUMMARY, DOCUMENT_SUGGEST, migration 0370). Without the header both routes answer synchronously as before; both paths run one shared body, so they cannot drift. The worker asks the capability for the record and re-checks the wire for the provider it picks before every read. A summary that is to be stored marks the document PENDING while queued, READY when it lands, and UNAVAILABLE when the run fails or the write does not land. A second request attaches to a live run only when it would produce the same thing. The vault asks for both in the background and shows the same calm waiting line the index uses, worded so a transient read never promises that leaving the page keeps it. The egress guard and the route inventory now hold every run kind to its dispatcher re-check and its capability.
This commit is contained in:
1 parent
7cd323499e
commit
eda5776886
24 files changed
+2061
-800
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,27 @@ 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",
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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