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:
Marc-André Bombeck committed 2026-10-03 20:51:57 +02:00
1 parent 7cd323499e
commit eda5776886
24 files changed
+2061 -800

No files matched your search

+341 -321
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
-- Two more kinds of background document AI run.
--
-- Summaries and filing suggestions can now run in the same background worker
-- as "Read with AI" and the lab scan (0366): the request queues the read and
-- answers 202, and the result is polled from the run. The synchronous routes
-- are unchanged; the new kinds only name the run.
--
-- Purely additive. `ADD VALUE IF NOT EXISTS` makes a rerun safe, and no new
-- value is used elsewhere in this migration, which is the one thing Postgres
-- forbids inside the transaction that adds it. No row is rewritten.
--
-- Reversibility: Postgres cannot drop an enum value, so the values would stay
-- (inert with no rows that carry them).
ALTER TYPE "document_ai_run_kind" ADD VALUE IF NOT EXISTS 'DOCUMENT_SUMMARY';
ALTER TYPE "document_ai_run_kind" ADD VALUE IF NOT EXISTS 'DOCUMENT_SUGGEST';
+9
View File
@@ -8031,6 +8031,9 @@ enum InboundDocumentKind {
/// every fact, DISCARDED on a soft delete.
enum InboundDocumentStatus {
STORED
/// @internal: reserved, no writer. An extraction in flight is the state of
/// its background run (`DocumentAiRun`), not of the document, so the vault
/// never shows a status nothing renders.
EXTRACTING
EXTRACTED
FAILED
@@ -9033,6 +9036,12 @@ enum DocumentAiRunKind {
/// A lab report scan (photo, PDF or browser-read text) into proposed rows
/// for the review screen. Nothing is written until the person commits.
LABS_OCR_EXTRACT
/// A plain-language summary or the transcribed text of a stored document;
/// stored on the document only when the request asked for that.
DOCUMENT_SUMMARY
/// Filing suggestions (title, kind, date) for a stored document. Drafts
/// only: nothing is written to the document.
DOCUMENT_SUGGEST
@@map("document_ai_run_kind")
}
@@ -19,6 +19,7 @@ import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { DOCUMENT_AI_RUN_KINDS } from "@/lib/documents/ai-runs/types";
import type { runStatusCompletion } from "@/lib/insights/status-provider";
import { walkSourceFiles } from "./helpers/source-files";
@@ -163,7 +164,7 @@ const EGRESS_SITES: Record<string, EgressSite> = {
*/
const WORKER_REACHERS: Record<string, string> = {
"lib/jobs/document-ai-run.ts":
"Background document reads and lab scans (v1.40): `transcribeDocument` and `runOcrExtraction` through the shared run bodies.",
"Background document reads and lab scans (v1.40): `transcribeDocument`, `runDocumentSummary`, `runDocumentAssist` and `runOcrExtraction` through the shared run bodies.",
};
const JOB_CAPABILITY = /\baiCapabilityForJob\s*\(/;
@@ -190,6 +191,50 @@ describe("workers that reach an admitted helper", () => {
});
});
/** The body of one top-level `async function name(` in a source text. */
function functionBody(text: string, name: string): string | null {
const start = text.search(new RegExp(`async function ${name}\\s*\\(`));
if (start < 0) return null;
const end = text.indexOf("\n}\n", start);
return end < 0 ? null : text.slice(start, end);
}
describe("every kind of background run re-checks before its read", () => {
const worker = code("lib/jobs/document-ai-run.ts");
const table = /const RUN_DISPATCH[^=]*=\s*\{([\s\S]*?)\};/.exec(worker);
const entries = [...(table?.[1] ?? "").matchAll(/(\w+):\s*(\w+),/g)].map(
(m) => ({ kind: m[1], dispatcher: m[2] }),
);
it("finds the dispatch table (an empty match is a failure, not a pass)", () => {
expect(entries.length).toBeGreaterThanOrEqual(2);
});
it("dispatches every run kind", () => {
expect(entries.map((e) => e.kind).sort()).toEqual(
[...DOCUMENT_AI_RUN_KINDS].sort(),
);
});
it("the shared document pick re-checks the wire on both transports", () => {
const pick = functionBody(worker, "pickForDocumentRead") ?? "";
expect(pick.match(/\bassertRunMayEgress\s*\(/g)?.length ?? 0).toBe(2);
});
it.each(DOCUMENT_AI_RUN_KINDS)(
"%s re-checks the capability and the wire before the body runs",
(kind) => {
const entry = entries.find((e) => e.kind === kind);
const body = entry ? functionBody(worker, entry.dispatcher) : null;
expect(body, `${kind}: dispatcher not found`).not.toBeNull();
expect(
/\b(?:assertRunMayEgress|pickForDocumentRead)\s*\(/.test(body ?? ""),
`${kind}: its dispatcher sends to a model without assertRunMayEgress`,
).toBe(true);
},
);
});
describe("AI egress call sites", () => {
const found = sourceFiles().filter((rel) => EGRESS_CALL.test(code(rel)));
@@ -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",
});
}
@@ -254,3 +254,35 @@ describe("<DocumentAiSection> index outcome (#776)", () => {
expect(html).not.toContain('data-slot="document-index-outcome"');
});
});
describe("<DocumentAiSection> background reads (v1.40)", () => {
it("a running suggestion or summary says it reads in the background without promising it is kept", () => {
const html = render(base({ assistRunPhase: "background" }));
expect(html).toContain('data-slot="document-assist-run-phase"');
expect(html).toContain("With a slow model this can take a few minutes.");
expect(html).not.toContain("You can leave this page");
});
it("a read whose result lands on the document says the person may leave", () => {
const html = render(
base({
indexRunPhase: "background",
assistRunPhase: "background",
}),
);
expect(html).toContain('data-slot="document-index-run-phase"');
expect(html).toContain("You can leave this page");
// One line at a time, never two stacked notes.
expect(html).not.toContain('data-slot="document-assist-run-phase"');
});
it("says when no worker has picked the read up yet", () => {
const html = render(base({ assistRunPhase: "waitingForWorker" }));
expect(html).toContain("Waiting for the background worker.");
});
it("renders no note while nothing runs", () => {
const html = render(base());
expect(html).not.toContain("run-phase");
});
});
@@ -163,3 +163,15 @@ describe("DocumentSummaryBlock — the action", () => {
);
});
});
describe("DocumentSummaryBlock — a background read (v1.40)", () => {
it("says the read runs in the background and is stored with the document", () => {
const html = base({ isGenerating: true, runPhase: "background" });
expect(html).toContain('data-slot="document-detail-summary-run-phase"');
expect(html).toContain("You can leave this page");
});
it("renders no note while nothing runs", () => {
expect(base()).not.toContain("run-phase");
});
});
@@ -12,6 +12,8 @@
* user applies it (and the title lands in the editable field, not on disk).
* - `DocumentSummaryPanel` — the transient summary / extracted-text panel with
* the persistent "not saved · not a diagnosis" note.
* - `AiRunPhaseNote` — the calm line under an action while its read runs in
* the background (v1.40), shared by every document AI action.
*/
import { Sparkles } from "lucide-react";
import { Check, FileText, X } from "lucide-react";
@@ -19,6 +21,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import type { AiRunPhase } from "@/hooks/use-ai-run";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
@@ -28,6 +31,37 @@ import {
import type { DocumentDescribeResult } from "./use-document-assist";
/**
* The line under a document AI action while its read runs in the background,
* or nothing when no read is running.
*
* `outcome` keeps the sentence true: a read whose result lands on the document
* (the index, a stored summary, staged facts) tells the person they may leave;
* a read whose answer lives only on this screen (suggestions, a transient
* summary or transcription) does not promise that, because leaving drops it.
*/
export function AiRunPhaseNote({
phase,
outcome,
slot,
}: {
phase: AiRunPhase;
outcome: "savedWithDocument" | "shownHere";
slot: string;
}) {
const { t } = useTranslations();
if (phase === "idle") return null;
return (
<p role="status" data-slot={slot} className="text-muted-foreground text-xs">
{phase === "waitingForWorker"
? t("aiRuns.waitingForWorker")
: outcome === "savedWithDocument"
? t("aiRuns.backgroundDocument")
: t("aiRuns.backgroundScan")}
</p>
);
}
/**
* Calm pointer to the AI settings, shown when assist is unavailable. When the
* document is already searchable (auto-indexed locally) the copy stays honest —
@@ -45,6 +45,7 @@ import {
} from "@/lib/validations/inbound-documents";
import {
AiRunPhaseNote,
AiUnavailableHint,
AssistSuggestionReview,
DocumentSummaryPanel,
@@ -79,6 +80,7 @@ export function DocumentAiSection({
lastIndexOutcome = null,
indexPending,
indexRunPhase = "idle",
assistRunPhase = "idle",
onIndex,
}: {
aiEnabled: boolean;
@@ -124,6 +126,8 @@ export function DocumentAiSection({
indexPending: boolean;
/** v1.40 — where a background read stands, for the calm line under it. */
indexRunPhase?: AiRunPhase;
/** v1.40 — the same for a suggestion or transient summary read. */
assistRunPhase?: AiRunPhase;
onIndex: () => void;
}) {
const { t } = useTranslations();
@@ -253,16 +257,18 @@ export function DocumentAiSection({
) : null}
{indexRunPhase !== "idle" ? (
<p
role="status"
data-slot="document-index-run-phase"
className="text-muted-foreground text-xs"
>
{indexRunPhase === "waitingForWorker"
? t("aiRuns.waitingForWorker")
: t("aiRuns.backgroundDocument")}
</p>
) : null}
<AiRunPhaseNote
phase={indexRunPhase}
outcome="savedWithDocument"
slot="document-index-run-phase"
/>
) : (
<AiRunPhaseNote
phase={assistRunPhase}
outcome="shownHere"
slot="document-assist-run-phase"
/>
)}
{indexOutcomeText ? (
<p
@@ -894,6 +894,7 @@ export function DocumentDetailSheet({
}
aiEnabled={aiEnabled}
isGenerating={storedSummary.isPending}
runPhase={storedSummary.runPhase}
actionsDisabled={capability.isPending}
onGenerate={generateStoredSummary}
/>
@@ -938,6 +939,9 @@ export function DocumentDetailSheet({
lastIndexOutcome={doc.lastIndexOutcome}
indexPending={indexDoc.isPending}
indexRunPhase={indexDoc.runPhase}
assistRunPhase={
suggest.isPending ? suggest.runPhase : summary.runPhase
}
onIndex={runIndex}
/>
)}
@@ -30,9 +30,12 @@
import { FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { AiRunPhase } from "@/hooks/use-ai-run";
import { useTranslations } from "@/lib/i18n/context";
import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents";
import { AiRunPhaseNote } from "./document-ai-panels";
/** Copy key for a state with no summary to show. */
function absentCopyKey(state: DocumentSummaryStateValue): string {
if (state === "WITHHELD") return "documents.detail.summary.withheld";
@@ -46,6 +49,7 @@ export function DocumentSummaryBlock({
generatedAtLabel,
aiEnabled,
isGenerating,
runPhase = "idle",
actionsDisabled,
canGenerate = true,
onGenerate,
@@ -58,6 +62,11 @@ export function DocumentSummaryBlock({
/** Whether an AI provider is configured; false hides the action, not the state. */
aiEnabled: boolean;
isGenerating: boolean;
/**
* v1.40 — where the background read stands while one runs. The summary is
* stored on the document, so the line tells the person they may leave.
*/
runPhase?: AiRunPhase;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
/**
@@ -125,6 +134,11 @@ export function DocumentSummaryBlock({
: t("documents.detail.summary.generate")}
</Button>
) : null}
<AiRunPhaseNote
phase={runPhase}
outcome="savedWithDocument"
slot="document-detail-summary-run-phase"
/>
</section>
);
}
@@ -15,9 +15,10 @@
*/
import { useCallback } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import { useAiRunPhase } from "@/hooks/use-ai-run";
import { apiGet, ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -85,10 +86,15 @@ export function useDocumentsAutoAiRead(enabled: boolean) {
* Suggest filing metadata for a stored document. Returns DRAFTS only — the
* detail sheet prefills its edit fields and the user saves; this call writes
* nothing.
*
* The read runs in the background (v1.40), so no proxy in front of the server
* can cut a slow model; `runPhase` says so while it does.
*/
export function useSuggestDetails() {
const queryClient = useQueryClient();
const { responseTimeoutMs } = useAiProviderState();
return useMutation<
const { phase, onQueued, onProgress, reset } = useAiRunPhase();
const mutation = useMutation<
DocumentSuggestionDto,
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
@@ -100,19 +106,25 @@ export function useSuggestDetails() {
target,
responseTimeoutMs,
modelCalls: 1,
background: { queryClient, onQueued, onProgress },
});
return data.suggestions;
},
onSettled: reset,
});
return { ...mutation, runPhase: mutation.isPending ? phase : "idle" };
}
/**
* On-demand summary or extracted text. Both stay transient unless the document
* summary block explicitly requests persistence or replacement.
* summary block explicitly requests persistence or replacement. The read runs
* in the background (v1.40); `runPhase` says so while it does.
*/
export function useDocumentSummary() {
const queryClient = useQueryClient();
const { responseTimeoutMs } = useAiProviderState();
return useMutation<
const { phase, onQueued, onProgress, reset } = useAiRunPhase();
const mutation = useMutation<
DocumentDescribeResult,
Error,
{
@@ -130,8 +142,11 @@ export function useDocumentSummary() {
target,
responseTimeoutMs,
modelCalls: 1,
background: { queryClient, onQueued, onProgress },
}),
onSettled: reset,
});
return { ...mutation, runPhase: mutation.isPending ? phase : "idle" };
}
/**
+74
View File
@@ -0,0 +1,74 @@
/**
* Pieces the summary, suggest and extract run bodies share: the provider pick
* types, the failure shape, and the answer for a stored original that could
* not be turned into a provider input.
*
* Every failure here carries the status, message and `documents.inbound.*`
* code the synchronous route has always answered with, so the background run
* reports exactly what the request would have.
*/
import type {
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import type { VisionInput } from "@/lib/documents/ai-route-support";
import type { AiRunOutcome } from "./types";
export type DocumentVisionPick = Awaited<
ReturnType<typeof requireDocumentVisionProvider>
>;
export type DocumentTextPick = Awaited<
ReturnType<typeof requireDocumentTextProvider>
>;
/** Who asked: a request (with its client address) or a background run. */
export interface RunOrigin {
ipAddress: string | null;
worker: boolean;
}
/** A failure in the shape the route would answer with. */
export const runFailure = (f: {
status: number;
message: string;
errorCode: string;
}): AiRunOutcome<never> => ({ ok: false, ...f });
/** The provider error every document read answers with (422 parse, 502 wire). */
export function unreadableDocument(parseError: boolean): AiRunOutcome<never> {
return runFailure({
status: parseError ? 422 : 502,
message: "Couldn't read the document. Try a clearer copy.",
errorCode: "documents.inbound.extractFailed",
});
}
/**
* A stored original that could not become a provider input, as the summary
* and suggest routes have always answered it.
*/
export function visionPreparationFailure(
reason: Extract<VisionInput, { ok: false }>["reason"],
): AiRunOutcome<never> {
if (reason === "pdfNeedsAnthropic") {
return runFailure({
status: 422,
message:
"PDF scanning needs a Claude vision provider; use local OCR instead.",
errorCode: "documents.inbound.pdfNeedsAnthropic",
});
}
if (reason === "fileType") {
return runFailure({
status: 422,
message: "This document can't be scanned. Use local OCR (text mode).",
errorCode: "documents.inbound.fileType",
});
}
return runFailure({
status: 422,
message: "Couldn't read the stored document.",
errorCode: "documents.inbound.extractFailed",
});
}
+12 -3
View File
@@ -23,13 +23,18 @@ import {
* The live run of this kind for this document, if one is queued or running:
* a second press of "Read with AI" (another tab, a double click) attaches to
* it instead of paying for a second read.
*
* `paramsMatch` narrows the attach to a run that will produce the same thing:
* a summary that is to be stored must not attach to a transient transcription
* of the same document.
*/
export async function findLiveDocumentRun(
userId: string,
documentId: string,
kind: DocumentAiRunKindValue,
paramsMatch?: (params: AiRunParams) => boolean,
): Promise<string | null> {
const row = await prisma.documentAiRun.findFirst({
const rows = await prisma.documentAiRun.findMany({
where: {
userId,
documentId,
@@ -37,10 +42,14 @@ export async function findLiveDocumentRun(
status: { in: ["QUEUED", "RUNNING"] },
expiresAt: { gt: new Date() },
},
select: { id: true },
select: { id: true, paramsJson: true },
orderBy: { createdAt: "desc" },
take: 10,
});
return row?.id ?? null;
const live = paramsMatch
? rows.find((row) => paramsMatch(row.paramsJson as unknown as AiRunParams))
: rows[0];
return live?.id ?? null;
}
export async function startAiRun(args: {
+112
View File
@@ -0,0 +1,112 @@
/**
* Filing suggestions for a stored document: the body both the synchronous
* suggest route and the background run execute.
*
* One provider call over the stored original (vision) or text the browser read
* (text) returns a `{ title, kind, documentDate }` draft. Nothing is written to
* the document: the person reviews the draft and saves it on the edit form.
* The reservation the caller took is settled here.
*/
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import { auditLog } from "@/lib/auth/audit";
import {
prepareVisionInput,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
DocumentAssistError,
runDocumentAssist,
type DocumentSuggestion,
} from "@/lib/documents/assist";
import { annotate } from "@/lib/logging/context";
import {
unreadableDocument,
visionPreparationFailure,
type DocumentTextPick,
type DocumentVisionPick,
type RunOrigin,
} from "./run-support";
import { settleAiRunBudget } from "./store";
import type {
AiRunBudget,
AiRunOutcome,
DocumentSuggestRunResult,
} from "./types";
/** One provider call. */
export const DOCUMENT_SUGGEST_MODEL_CALLS = 1;
export type DocumentSuggestInput =
| { mode: "text"; text: string; pick: DocumentTextPick; budget: AiRunBudget }
| { mode: "vision"; pick: DocumentVisionPick; budget: AiRunBudget };
/** Suggest filing details for one owned document. The caller authorised and charged it. */
export async function executeDocumentSuggest(args: {
userId: string;
document: LoadedDocument;
input: DocumentSuggestInput;
origin: RunOrigin;
}): Promise<AiRunOutcome<DocumentSuggestRunResult>> {
const { userId, document, input, origin } = args;
const { pick, budget } = input;
const servedBy = pick.entry.providerType as ProviderChainType;
let suggestion: DocumentSuggestion;
try {
if (input.mode === "text") {
suggestion = await runDocumentAssist({
provider: pick.entry.instance,
providerType: pick.providerType,
ocrText: input.text,
});
} else {
const vision = await prepareVisionInput(
document,
input.pick.pdfSupported,
);
if (!vision.ok) {
// Preparation failed before any provider dispatch: the reservation
// and the slot go back.
await settleAiRunBudget(userId, budget, 0, null);
await refundDocumentAiSlot(userId);
return visionPreparationFailure(vision.reason);
}
suggestion = await runDocumentAssist({
provider: pick.entry.instance,
providerType: pick.providerType,
images: vision.images,
documents: vision.documents,
});
}
} catch (err) {
await settleAiRunBudget(userId, budget, 0, null);
if (!(err instanceof DocumentAssistError)) {
annotate({
action: { name: "documents.assist.failed" },
meta: { reason: "provider_error", mode: input.mode },
});
}
return unreadableDocument(err instanceof DocumentAssistError);
}
await settleAiRunBudget(userId, budget, budget.reserved, servedBy);
await auditLog("documents.inbound.suggest", {
userId,
...(origin.worker ? { actorUserId: userId } : {}),
ipAddress: origin.ipAddress,
details: { documentId: document.id, mode: input.mode },
});
annotate({
action: { name: "documents.assist.suggest" },
meta: {
documentId: document.id,
mode: input.mode,
hasTitle: suggestion.title !== null,
hasKind: suggestion.kind !== null,
hasDate: suggestion.documentDate !== null,
},
});
return { ok: true, data: { suggestions: suggestion } };
}
+250
View File
@@ -0,0 +1,250 @@
/**
* A document summary or transcription: the body both the synchronous summary
* route and the background run execute, so the two paths cannot drift.
*
* Vision mode decrypts the stored original and makes one provider call; text
* mode sends text the browser read on the device. The call runs against a
* reservation the caller already took, which is settled here. A summary the
* caller asked to keep is stored on the document (or its refusal recorded as
* a state); a transcription and an ordinary summary stay transient.
*
* Every failure comes back as the outcome the route has always answered with.
*/
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import type { OutboundReason } from "@/lib/ai/safety/outbound-screen";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
prepareVisionInput,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
DocumentDescribeError,
documentSummaryBlockedCopy,
runDocumentSummary,
transcribeDocument,
type DescribeInput,
} from "@/lib/documents/describe";
import { encryptDocumentSummary } from "@/lib/documents/store";
import type { Locale } from "@/lib/i18n/config";
import { annotate } from "@/lib/logging/context";
import {
unreadableDocument,
visionPreparationFailure,
type DocumentTextPick,
type DocumentVisionPick,
type RunOrigin,
} from "./run-support";
import { settleAiRunBudget } from "./store";
import type {
AiRunBudget,
AiRunOutcome,
DocumentSummaryRunResult,
} from "./types";
/** One provider call: the summary, or the transcription. */
export const DOCUMENT_SUMMARY_MODEL_CALLS = 1;
export type DocumentSummaryOutput = "summary" | "text";
export type DocumentSummaryInput =
| { mode: "text"; text: string; pick: DocumentTextPick; budget: AiRunBudget }
| { mode: "vision"; pick: DocumentVisionPick; budget: AiRunBudget };
export interface SummaryPersistence {
/** Replace a stored summary; without it only an empty slot is filled. */
replaceExisting: boolean;
}
/**
* 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 person can ask again.
*
* Storing is best-effort. The summary is already in the answer; a failed write
* must not turn that into an error.
*/
async function persistSummary(
userId: string,
documentId: string,
persist: SummaryPersistence,
summary: string,
blocked: OutboundReason | null,
): Promise<"stored" | "withheld" | "failed"> {
try {
if (blocked) {
await prisma.inboundDocument.updateMany({
where: {
id: documentId,
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: documentId,
userId,
deletedAt: null,
...(persist.replaceExisting ? {} : { summaryEncrypted: null }),
},
data: {
summaryEncrypted: encryptDocumentSummary(summary),
summaryGeneratedAt: new Date(),
summaryState: "READY",
aiReadDeferred: false,
},
});
annotate({
action: { name: "documents.summary.persisted" },
meta: { documentId, stored: written.count > 0 },
});
return written.count > 0 ? "stored" : "failed";
} catch {
annotate({
action: { name: "documents.summary.persistFailed" },
meta: { documentId },
});
return blocked ? "withheld" : "failed";
}
}
/**
* Mark a summary the background run was asked to store as not produced. Only
* the PENDING state the queuing route set is moved, so a summary that landed
* meanwhile (another tab, the automatic job) is never overwritten.
*/
export async function markQueuedSummaryUnavailable(
userId: string,
documentId: string,
): Promise<void> {
try {
await prisma.inboundDocument.updateMany({
where: { id: documentId, userId, summaryState: "PENDING" },
data: { summaryState: "UNAVAILABLE" },
});
} catch {
// The run's failure is already decided; the hourly summary reaper moves a
// PENDING state that outlives its job.
}
}
/**
* Mark a summary as being generated when a run that will store it is queued,
* so the detail view says so across reloads. Never over a stored summary: a
* replacement keeps showing the old one until the new one lands.
*/
export async function markSummaryQueued(
userId: string,
documentId: string,
): Promise<void> {
await prisma.inboundDocument.updateMany({
where: {
id: documentId,
userId,
deletedAt: null,
summaryEncrypted: null,
},
data: { summaryState: "PENDING" },
});
}
/** Summarise or transcribe one owned document. The caller authorised and charged it. */
export async function executeDocumentSummary(args: {
userId: string;
document: LoadedDocument;
input: DocumentSummaryInput;
output: DocumentSummaryOutput;
locale: Locale;
persist: SummaryPersistence | null;
origin: RunOrigin;
}): Promise<AiRunOutcome<DocumentSummaryRunResult>> {
const { userId, document, input, output, locale, origin } = args;
const { pick, budget } = input;
const servedBy = pick.entry.providerType as ProviderChainType;
let describeInput: DescribeInput;
if (input.mode === "text") {
describeInput = {
provider: pick.entry.instance,
providerType: pick.providerType,
ocrText: input.text,
};
} else {
const vision = await prepareVisionInput(document, input.pick.pdfSupported);
if (!vision.ok) {
// Preparation failed before any provider dispatch: the reservation and
// the slot go back.
await settleAiRunBudget(userId, budget, 0, null);
await refundDocumentAiSlot(userId);
return visionPreparationFailure(vision.reason);
}
describeInput = {
provider: pick.entry.instance,
providerType: pick.providerType,
images: vision.images,
documents: vision.documents,
};
}
let result: DocumentSummaryRunResult;
try {
if (output === "text") {
result = await transcribeDocument(describeInput);
} else {
const { summary, blocked } = await runDocumentSummary({
...describeInput,
locale,
});
const persistence = args.persist
? await persistSummary(
userId,
document.id,
args.persist,
summary,
blocked,
)
: undefined;
result = {
summary: blocked ? documentSummaryBlockedCopy(locale) : summary,
...(persistence ? { persistence } : {}),
};
}
} catch (err) {
await settleAiRunBudget(userId, budget, 0, null);
if (!(err instanceof DocumentDescribeError)) {
annotate({
action: { name: "documents.summary.failed" },
meta: { reason: "provider_error", mode: input.mode },
});
}
return unreadableDocument(err instanceof DocumentDescribeError);
}
await settleAiRunBudget(userId, budget, budget.reserved, servedBy);
await auditLog("documents.inbound.summary", {
userId,
// A background run has no request left to attribute the action from.
...(origin.worker ? { actorUserId: userId } : {}),
ipAddress: origin.ipAddress,
details: { documentId: document.id, mode: output, inputMode: input.mode },
});
annotate({
action: { name: "documents.summary.serve" },
meta: { documentId: document.id, mode: output, inputMode: input.mode },
});
return { ok: true, data: result };
}
+45 -1
View File
@@ -8,14 +8,33 @@
* `GET /api/ai-runs/{id}` until the run is terminal. The result lives in the
* row for an hour after it finishes and then the row is deleted.
*/
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
import type { Locale } from "@/lib/i18n/config";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import type { OcrExtractResponseDto } from "@/lib/validations/labs-ocr";
export const DOCUMENT_AI_RUN_KINDS = [
"DOCUMENT_INDEX",
"LABS_OCR_EXTRACT",
"DOCUMENT_SUMMARY",
"DOCUMENT_SUGGEST",
] as const;
export type DocumentAiRunKindValue = (typeof DOCUMENT_AI_RUN_KINDS)[number];
/**
* The capability each kind of run answers to: the one its queuing route asks,
* asked again by the worker for the record before anything leaves.
*/
export const DOCUMENT_AI_RUN_CAPABILITY: Record<
DocumentAiRunKindValue,
AiCapabilityKey
> = {
DOCUMENT_INDEX: "documentAi",
LABS_OCR_EXTRACT: "labsOcr",
DOCUMENT_SUMMARY: "documentAi",
DOCUMENT_SUGGEST: "documentAi",
};
export const DOCUMENT_AI_RUN_STATUSES = [
"QUEUED",
"RUNNING",
@@ -64,6 +83,13 @@ export interface AiRunParams {
mime?: "image/jpeg" | "image/png" | "image/webp" | "application/pdf";
/** Present when the enqueue reserved budget for a provider call. */
budget?: AiRunBudget;
/** A summary run: what to produce, whether to store it, and in which language. */
summary?: {
output: "summary" | "text";
persist: boolean;
replace: boolean;
locale: Locale;
};
}
/** "Read with AI": the same body the synchronous index route answers with. */
@@ -74,7 +100,25 @@ export interface DocumentIndexRunResult {
labFactsStaged: number;
}
export type AiRunResult = DocumentIndexRunResult | OcrExtractResponseDto;
/** A summary run: the summary route's body. */
export type DocumentSummaryRunResult =
| { summary: string; persistence?: "stored" | "withheld" | "failed" }
| { text: string };
/** A suggest run: the suggest route's body (drafts, nothing written). */
export interface DocumentSuggestRunResult {
suggestions: {
title: string | null;
kind: InboundDocumentKindValue | null;
documentDate: string | null;
};
}
export type AiRunResult =
| DocumentIndexRunResult
| OcrExtractResponseDto
| DocumentSummaryRunResult
| DocumentSuggestRunResult;
/**
* What one run body produced: the success body, or the failure the
+222 -1
View File
@@ -13,7 +13,11 @@
* in `assertRunMayEgress` turns "refuses when consent was withdrawn" red;
* deleting the `aiCapabilityForJob` check turns "refuses when the operator
* turned reading off" red; dropping the `ends > deadlineAt` comparison turns
* "refuses a read that cannot fit" red.
* "refuses a read that cannot fit" red. For the summary and suggest kinds:
* deleting `afterRunFailed` (or its `markQueuedSummaryUnavailable` call)
* turns "a stored summary that fails is marked UNAVAILABLE" red, and deleting
* the `assertRunMayEgress` call in `pickForDocumentRead`'s text branch turns
* "a text suggestion re-checks the wire" red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -38,9 +42,12 @@ vi.mock("@/lib/ai/capabilities/egress", () => ({
}));
const requireDocumentVisionProvider = vi.fn();
const requireDocumentTextProvider = vi.fn();
vi.mock("@/lib/documents/provider-order", () => ({
requireDocumentVisionProvider: (...a: unknown[]) =>
requireDocumentVisionProvider(...a),
requireDocumentTextProvider: (...a: unknown[]) =>
requireDocumentTextProvider(...a),
}));
const requireLabsOcrProvider = vi.fn();
vi.mock("@/lib/labs/ocr-capability", () => ({
@@ -58,6 +65,20 @@ vi.mock("@/lib/labs/ocr-run", () => ({
executeOcrExtraction: (...a: unknown[]) => executeOcrExtraction(...a),
}));
const executeDocumentSummary = vi.fn();
const markQueuedSummaryUnavailable = vi.fn();
vi.mock("@/lib/documents/ai-runs/summary-run", () => ({
DOCUMENT_SUMMARY_MODEL_CALLS: 1,
executeDocumentSummary: (...a: unknown[]) => executeDocumentSummary(...a),
markQueuedSummaryUnavailable: (...a: unknown[]) =>
markQueuedSummaryUnavailable(...a),
}));
const executeDocumentSuggest = vi.fn();
vi.mock("@/lib/documents/ai-runs/suggest-run", () => ({
DOCUMENT_SUGGEST_MODEL_CALLS: 1,
executeDocumentSuggest: (...a: unknown[]) => executeDocumentSuggest(...a),
}));
const loadOwnedDocument = vi.fn();
vi.mock("@/lib/documents/ai-route-support", () => ({
loadOwnedDocument: (...a: unknown[]) => loadOwnedDocument(...a),
@@ -333,3 +354,203 @@ describe("handleDocumentAiRunJobs", () => {
});
});
});
describe("summary and suggestion runs", () => {
const summaryRun = (overrides: Record<string, unknown> = {}) =>
claimed({
kind: "DOCUMENT_SUMMARY",
params: {
mode: "vision",
budget,
summary: {
output: "summary",
persist: true,
replace: false,
locale: "de",
},
},
...overrides,
});
beforeEach(() => {
executeDocumentSummary.mockResolvedValue({
ok: true,
data: { summary: "Ein Arztbrief.", persistence: "stored" },
});
executeDocumentSuggest.mockResolvedValue({
ok: true,
data: {
suggestions: { title: "Brief", kind: "OTHER", documentDate: null },
},
});
requireDocumentTextProvider.mockResolvedValue({
entry: {
providerType: "local",
instance: { responseTimeoutSeconds: 90 },
},
providerType: "local",
});
});
it("reads a summary after the re-check, with one call's deadline and its options", async () => {
claimAiRun.mockResolvedValue(summaryRun());
requireDocumentVisionProvider.mockResolvedValue(visionPick(300));
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe(
"succeeded",
);
expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "documentAi");
expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", [
"anthropic",
]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: "run1", status: "RUNNING" },
data: { expiresAt: new Date(NOW + 300_000 + 60_000) },
});
expect(executeDocumentSummary).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({ mode: "vision", budget }),
output: "summary",
locale: "de",
persist: { replaceExisting: false },
origin: { ipAddress: null, worker: true },
}),
);
expect(completeAiRun).toHaveBeenCalledWith(
"run1",
{ summary: "Ein Arztbrief.", persistence: "stored" },
new Date(NOW),
);
expect(markQueuedSummaryUnavailable).not.toHaveBeenCalled();
});
it("refuses a summary when consent was withdrawn while it waited, and marks it UNAVAILABLE", async () => {
claimAiRun.mockResolvedValue(summaryRun());
aiEgressRefusal.mockResolvedValue(
new AiUnavailableError("documentAi", "consent_required"),
);
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed");
expect(executeDocumentSummary).not.toHaveBeenCalled();
expect(settleAiRunBudget).toHaveBeenCalledWith("u1", budget, 0, null);
expect(markQueuedSummaryUnavailable).toHaveBeenCalledWith("u1", "d1");
});
it("a stored summary that fails is marked UNAVAILABLE", async () => {
claimAiRun.mockResolvedValue(summaryRun());
executeDocumentSummary.mockResolvedValue({
ok: false,
status: 502,
message: "Couldn't read the document. Try a clearer copy.",
errorCode: "documents.inbound.extractFailed",
});
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed");
expect(markQueuedSummaryUnavailable).toHaveBeenCalledWith("u1", "d1");
});
it("a stored summary that could not be written is marked UNAVAILABLE", async () => {
claimAiRun.mockResolvedValue(summaryRun());
executeDocumentSummary.mockResolvedValue({
ok: true,
data: { summary: "Ein Arztbrief.", persistence: "failed" },
});
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe(
"succeeded",
);
expect(markQueuedSummaryUnavailable).toHaveBeenCalledWith("u1", "d1");
});
it("a transient summary that fails leaves the document's state alone", async () => {
claimAiRun.mockResolvedValue(
summaryRun({
params: {
mode: "vision",
budget,
summary: {
output: "text",
persist: false,
replace: false,
locale: "en",
},
},
}),
);
executeDocumentSummary.mockResolvedValue({
ok: false,
status: 502,
message: "Couldn't read the document. Try a clearer copy.",
errorCode: "documents.inbound.extractFailed",
});
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed");
expect(markQueuedSummaryUnavailable).not.toHaveBeenCalled();
});
it("refuses a summary that cannot fit the job's expiry", async () => {
claimAiRun.mockResolvedValue(summaryRun());
requireDocumentVisionProvider.mockResolvedValue(visionPick(600));
// One call at ten minutes plus the margin does not fit in ten minutes.
expect(await runDocumentAiRun("run1", NOW + 10 * 60_000, now)).toBe(
"failed",
);
expect(executeDocumentSummary).not.toHaveBeenCalled();
expect(failAiRun).toHaveBeenCalledWith(
"run1",
expect.objectContaining({ errorCode: "aiRuns.timedOut", status: 504 }),
"RUNNING",
new Date(NOW),
);
});
it("fails a summary whose document is gone, settling the reservation once", async () => {
claimAiRun.mockResolvedValue(summaryRun());
loadOwnedDocument.mockResolvedValue(null);
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed");
expect(settleAiRunBudget).toHaveBeenCalledOnce();
expect(failAiRun).toHaveBeenCalledWith(
"run1",
expect.objectContaining({
status: 404,
errorCode: "documents.inbound.notFound",
}),
"RUNNING",
new Date(NOW),
);
});
it("a text suggestion re-checks the wire for the text provider and reads the sealed text", async () => {
claimAiRun.mockResolvedValue(
claimed({
kind: "DOCUMENT_SUGGEST",
params: { mode: "text", budget },
input: Buffer.from("Arbeitsunfähigkeitsbescheinigung"),
}),
);
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe(
"succeeded",
);
expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", ["local"]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: "run1", status: "RUNNING" },
data: { expiresAt: new Date(NOW + 90_000 + 60_000) },
});
expect(executeDocumentSuggest).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
mode: "text",
text: "Arbeitsunfähigkeitsbescheinigung",
}),
}),
);
});
it("refuses a suggestion when the operator turned reading off", async () => {
claimAiRun.mockResolvedValue(
claimed({ kind: "DOCUMENT_SUGGEST", params: { mode: "vision", budget } }),
);
aiCapabilityForJob.mockResolvedValue({
available: false,
reason: "operator_disabled",
});
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed");
expect(executeDocumentSuggest).not.toHaveBeenCalled();
expect(markQueuedSummaryUnavailable).not.toHaveBeenCalled();
});
});
+176 -9
View File
@@ -44,6 +44,15 @@ import {
DOCUMENT_INDEX_MODEL_CALLS,
executeDocumentIndex,
} from "@/lib/documents/ai-runs/index-run";
import {
DOCUMENT_SUGGEST_MODEL_CALLS,
executeDocumentSuggest,
} from "@/lib/documents/ai-runs/suggest-run";
import {
DOCUMENT_SUMMARY_MODEL_CALLS,
executeDocumentSummary,
markQueuedSummaryUnavailable,
} from "@/lib/documents/ai-runs/summary-run";
import {
claimAiRun,
completeAiRun,
@@ -55,11 +64,17 @@ import {
import {
AI_RUN_ERROR_CODES,
AI_RUN_MARGIN_MS,
DOCUMENT_AI_RUN_CAPABILITY,
type AiRunBudget,
type AiRunOutcome,
type AiRunResult,
type DocumentAiRunKindValue,
} from "@/lib/documents/ai-runs/types";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import {
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { coerceLocale } from "@/lib/i18n/config";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { jobDeadline } from "@/lib/jobs/job-budget";
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
@@ -88,10 +103,7 @@ export interface DocumentAiRunPayload {
}
/** The capability each kind of run answers to. */
const RUN_CAPABILITY: Record<DocumentAiRunKindValue, AiCapabilityKey> = {
DOCUMENT_INDEX: "documentAi",
LABS_OCR_EXTRACT: "labsOcr",
};
const RUN_CAPABILITY = DOCUMENT_AI_RUN_CAPABILITY;
/**
* Hand a created run to the worker. False when no queue is reachable; the
@@ -262,6 +274,155 @@ async function runLabsOcr(
});
}
/** The owned document a run reads, or the route's own 404 when it is gone. */
async function loadRunDocument(run: ClaimedAiRun) {
const document = run.documentId
? await loadOwnedDocument(run.userId, run.documentId)
: null;
if (document) return document;
// The reservation goes back in `runDocumentAiRun`'s catch.
throw new RunRefused({
status: 404,
message: "Document not found",
errorCode: "documents.inbound.notFound",
});
}
/** The reservation a provider read was queued with. */
function runBudget(run: ClaimedAiRun): AiRunBudget {
if (!run.params.budget)
throw new Error("document read without a reservation");
return run.params.budget;
}
/**
* Pick the provider for a document read the way its route does, re-check the
* capability and the wire for exactly that provider, and arm the deadline for
* the calls the read can make.
*/
async function pickForDocumentRead(
run: ClaimedAiRun,
modelCalls: number,
deadlineAt: number | undefined,
now: () => number,
) {
if (run.params.mode === "text") {
const pick = await requireDocumentTextProvider(run.userId);
await assertRunMayEgress("documentAi", run.userId, [pick.providerType]);
await armDeadline(
run.id,
callTimeoutMs({}, pick.entry.instance.responseTimeoutSeconds),
modelCalls,
deadlineAt,
now,
);
return { mode: "text" as const, pick };
}
const pick = await requireDocumentVisionProvider(run.userId);
await assertRunMayEgress("documentAi", run.userId, [pick.providerType]);
await armDeadline(
run.id,
callTimeoutMs({}, pick.entry.instance.responseTimeoutSeconds),
modelCalls,
deadlineAt,
now,
);
return { mode: "vision" as const, pick };
}
/** The text the browser read, sealed into the run by the queuing route. */
function runText(run: ClaimedAiRun): string {
if (!run.input) throw new Error("text read without an input");
return run.input.toString("utf8");
}
async function runDocumentSummaryRead(
run: ClaimedAiRun,
deadlineAt: number | undefined,
now: () => number,
): Promise<AiRunOutcome<AiRunResult>> {
const options = run.params.summary;
if (!options) throw new Error("summary run without its options");
const document = await loadRunDocument(run);
const read = await pickForDocumentRead(
run,
DOCUMENT_SUMMARY_MODEL_CALLS,
deadlineAt,
now,
);
const budget = runBudget(run);
return executeDocumentSummary({
userId: run.userId,
document,
input:
read.mode === "text"
? { mode: "text", text: runText(run), pick: read.pick, budget }
: { mode: "vision", pick: read.pick, budget },
output: options.output,
locale: coerceLocale(options.locale),
persist: options.persist ? { replaceExisting: options.replace } : null,
origin: { ipAddress: null, worker: true },
});
}
async function runDocumentSuggestRead(
run: ClaimedAiRun,
deadlineAt: number | undefined,
now: () => number,
): Promise<AiRunOutcome<AiRunResult>> {
const document = await loadRunDocument(run);
const read = await pickForDocumentRead(
run,
DOCUMENT_SUGGEST_MODEL_CALLS,
deadlineAt,
now,
);
const budget = runBudget(run);
return executeDocumentSuggest({
userId: run.userId,
document,
input:
read.mode === "text"
? { mode: "text", text: runText(run), pick: read.pick, budget }
: { mode: "vision", pick: read.pick, budget },
origin: { ipAddress: null, worker: true },
});
}
type RunDispatcher = (
run: ClaimedAiRun,
deadlineAt: number | undefined,
now: () => number,
) => Promise<AiRunOutcome<AiRunResult>>;
/**
* The body each kind of run executes. Every dispatcher that sends anything to
* a model re-checks the capability and the wire for its provider first
* (`assertRunMayEgress`); `ai-egress-capability-guard.test.ts` holds that.
*/
const RUN_DISPATCH: Record<DocumentAiRunKindValue, RunDispatcher> = {
DOCUMENT_INDEX: runDocumentIndex,
LABS_OCR_EXTRACT: runLabsOcr,
DOCUMENT_SUMMARY: runDocumentSummaryRead,
DOCUMENT_SUGGEST: runDocumentSuggestRead,
};
/**
* What a failed run leaves behind besides itself. A summary that was to be
* stored had its document marked PENDING at enqueue; a failure says it could
* not be produced rather than leaving "being prepared" until the hourly
* reaper notices.
*/
async function afterRunFailed(run: ClaimedAiRun): Promise<void> {
if (
run.kind === "DOCUMENT_SUMMARY" &&
run.params.summary?.persist &&
run.documentId
) {
await markQueuedSummaryUnavailable(run.userId, run.documentId);
}
}
export type DocumentAiRunResult = "succeeded" | "failed" | "skipped";
/**
@@ -281,10 +442,7 @@ export async function runDocumentAiRun(
let outcome: AiRunOutcome<AiRunResult>;
try {
outcome =
run.kind === "DOCUMENT_INDEX"
? await runDocumentIndex(run, deadlineAt, now)
: await runLabsOcr(run, deadlineAt, now);
outcome = await RUN_DISPATCH[run.kind](run, deadlineAt, now);
} catch (err) {
// Everything that throws here threw before the body took over the
// reservation, so it goes back here.
@@ -298,6 +456,7 @@ export async function runDocumentAiRun(
message: "The background read failed.",
};
await failAiRun(run.id, failure, "RUNNING", new Date(now()));
await afterRunFailed(run);
annotate({
action: { name: "ai_runs.failed" },
meta: {
@@ -311,6 +470,13 @@ export async function runDocumentAiRun(
if (outcome.ok) {
await completeAiRun(run.id, outcome.data, new Date(now()));
if (
"persistence" in outcome.data &&
outcome.data.persistence === "failed"
) {
// The summary was read but could not be stored on the document.
await afterRunFailed(run);
}
annotate({
action: { name: "ai_runs.succeeded" },
meta: { kind: run.kind },
@@ -327,6 +493,7 @@ export async function runDocumentAiRun(
"RUNNING",
new Date(now()),
);
await afterRunFailed(run);
annotate({
action: { name: "ai_runs.failed" },
meta: { kind: run.kind, errorCode: outcome.errorCode },
+21 -6
View File
@@ -6,8 +6,9 @@
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
*
* iOS coordination (v1.40): `POST /api/documents/inbound/{id}/index` keeps
* its 200 body when the request carries no `Prefer` header. A client that
* iOS coordination (v1.40): `POST /api/documents/inbound/{id}/index`,
* `…/summary` and `…/suggest` keep their 200 bodies when the request carries
* no `Prefer` header. A client that
* sends `Prefer: respond-async` gets 202 with a run id and polls the run here;
* the result is the same body the route would have answered with.
* `POST /api/labs/ocr/extract` (web only) always answers 202.
@@ -15,24 +16,38 @@
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { documentIndexResponse } from "./documents";
import {
documentIndexResponse,
documentSuggestResponse,
documentSummaryResponse,
} from "./documents";
import { ocrExtractResponse } from "./ocr";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
const aiRun = z
.object({
id: z.string(),
kind: z.enum(["DOCUMENT_INDEX", "LABS_OCR_EXTRACT"]),
kind: z.enum([
"DOCUMENT_INDEX",
"LABS_OCR_EXTRACT",
"DOCUMENT_SUMMARY",
"DOCUMENT_SUGGEST",
]),
documentId: z
.string()
.nullable()
.describe("The document a document run reads; null for a lab scan."),
status: z.enum(["QUEUED", "RUNNING", "SUCCEEDED", "FAILED"]),
result: z
.union([documentIndexResponse, ocrExtractResponse])
.union([
documentIndexResponse,
ocrExtractResponse,
documentSummaryResponse,
documentSuggestResponse,
])
.nullable()
.describe(
"Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`.",
"Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`. Decode by `kind`; treat an unknown `kind` as a run this client did not start.",
),
error: z
.object({
+28 -20
View File
@@ -287,6 +287,26 @@ const documentUploadEnvelope = dataEnvelope(
"DocumentUploadEnvelope",
);
/** The suggest route's body; also a `DOCUMENT_SUGGEST` run's result. */
export const documentSuggestResponse = z
.object({
suggestions: z.object({
title: z.string().nullable(),
kind: kindEnum.nullable(),
documentDate: z.string().nullable(),
}),
})
.meta({ id: "DocumentSuggestResponse" });
/** The summary route's body; also a `DOCUMENT_SUMMARY` run's result. */
export const documentSummaryResponse = z
.object({
summary: z.string().optional(),
text: z.string().optional(),
persistence: z.enum(["stored", "withheld", "failed"]).optional(),
})
.meta({ id: "DocumentSummaryResponse" });
/** The index route's body; also a `DOCUMENT_INDEX` run's result. */
export const documentIndexResponse = z
.object({
@@ -954,9 +974,10 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Documents"],
summary: "Suggest filing metadata (drafts only)",
description:
'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
"Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR'd text (TEXT, `application/json` `{ mode: \"text\", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label. With `Prefer: respond-async` (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, a second request while a suggestion read of this document over the same transport is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves this body as the run's `result`.",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "string" } },
preferRespondAsyncParameter,
],
requestBody: {
required: false,
@@ -976,20 +997,13 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
z
.object({
suggestions: z.object({
title: z.string().nullable(),
kind: kindEnum.nullable(),
documentDate: z.string().nullable(),
}),
})
.meta({ id: "DocumentSuggestResponse" }),
documentSuggestResponse,
"DocumentSuggestEnvelope",
),
},
},
},
...aiRunAccepted,
...aiExtractionRefusals("documentAi"),
...stdResponses,
},
@@ -1000,7 +1014,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Documents"],
summary: "Summarise or transcribe a document",
description:
'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. 422 `documents.inbound.providerUnsupported` with no provider.',
"On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. 422 `documents.inbound.providerUnsupported` with no provider. With `Prefer: respond-async` (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, and `GET /api/ai-runs/{id}` serves this body as the run's `result`. A second request while a read with the same `mode`, `persist` and `replace` is queued or running answers with that run. With `persist=true` the document's `summaryState` is PENDING while the run is queued or running (unless a summary is already stored) and becomes UNAVAILABLE when the run fails. `mode=text` over posted text calls no model and is answered synchronously either way (no `Preference-Applied`).",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "string" } },
{
@@ -1031,6 +1045,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"With `persist=true`, explicitly replace an existing stored summary.",
},
preferRespondAsyncParameter,
],
requestBody: {
required: false,
@@ -1051,20 +1066,13 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
z
.object({
summary: z.string().optional(),
text: z.string().optional(),
persistence: z
.enum(["stored", "withheld", "failed"])
.optional(),
})
.meta({ id: "DocumentSummaryResponse" }),
documentSummaryResponse,
"DocumentSummaryEnvelope",
),
},
},
},
...aiRunAccepted,
...aiExtractionRefusals("documentAi"),
...stdResponses,
},
+232 -1
View File
@@ -16,6 +16,10 @@
* anything reaches a model.
* 6. The reaper fails a run no worker took (and hands its reservation back)
* and deletes a finished run an hour after it ended.
* 7. Summary and suggestion runs (0370) round-trip the routes' own bodies; a
* summary that is to be stored marks the document PENDING while queued,
* READY when it lands and UNAVAILABLE when the run fails; a transient one
* leaves the state alone and never attaches to a stored one.
*/
import { Buffer } from "node:buffer";
@@ -68,6 +72,18 @@ vi.mock("@/lib/documents/describe", async (importOriginal) => ({
transcribeDocument: vi.fn(async () => ({
text: "Entlassbrief Leukozyten erhoeht",
})),
runDocumentSummary: vi.fn(async () => ({
summary: "A hospital discharge letter.",
blocked: null,
})),
}));
vi.mock("@/lib/documents/assist", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/documents/assist")>()),
runDocumentAssist: vi.fn(async () => ({
title: "Discharge letter",
kind: "DISCHARGE_LETTER",
documentDate: "2027-01-10",
})),
}));
vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/labs/ocr-extract")>()),
@@ -84,7 +100,11 @@ vi.mock("@/lib/documents/auto-stage-labs", () => ({
})),
}));
import { transcribeDocument } from "@/lib/documents/describe";
import { runDocumentAssist } from "@/lib/documents/assist";
import {
runDocumentSummary,
transcribeDocument,
} from "@/lib/documents/describe";
import { runOcrExtraction } from "@/lib/labs/ocr-extract";
const PNG = Buffer.from(
@@ -186,6 +206,40 @@ async function postLabScanText(text: string) {
return { status: res.status, body: (await res.json()) as Envelope };
}
async function postDocumentAi(
documentId: string,
path: string,
prefer: boolean,
) {
const route = path.startsWith("summary")
? await import("@/app/api/documents/inbound/[id]/summary/route")
: await import("@/app/api/documents/inbound/[id]/suggest/route");
const { NextRequest } = await import("next/server");
const res = await route.POST(
new NextRequest(
`http://localhost/api/documents/inbound/${documentId}/${path}`,
{
method: "POST",
headers: prefer ? { prefer: "respond-async" } : {},
},
) as never,
{ params: Promise.resolve({ id: documentId }) } as never,
);
return {
status: res.status,
headers: res.headers,
body: (await res.json()) as Envelope,
};
}
async function summaryState(documentId: string) {
const row = await getPrismaClient().inboundDocument.findUniqueOrThrow({
where: { id: documentId },
select: { summaryState: true, summaryEncrypted: true },
});
return { state: row.summaryState, stored: row.summaryEncrypted !== null };
}
async function poll(runId: string) {
const { GET } = await import("@/app/api/ai-runs/[id]/route");
const res = await GET(
@@ -211,6 +265,8 @@ beforeEach(async () => {
send.mockClear();
vi.mocked(transcribeDocument).mockClear();
vi.mocked(runOcrExtraction).mockClear();
vi.mocked(runDocumentSummary).mockClear();
vi.mocked(runDocumentAssist).mockClear();
});
describe("Read with AI in the background", () => {
@@ -449,3 +505,178 @@ describe("the reaper", () => {
).toBeNull();
});
});
describe("a summary in the background", () => {
it("marks a stored summary PENDING while queued and READY when it lands", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
const queued = await postDocumentAi(
document.id,
"summary?mode=summary&persist=true",
true,
);
expect(queued.status).toBe(202);
expect(queued.headers.get("preference-applied")).toBe("respond-async");
const { runId } = queued.body.data as { runId: string };
expect(runDocumentSummary).not.toHaveBeenCalled();
expect(await summaryState(document.id)).toEqual({
state: "PENDING",
stored: false,
});
// A second press for the same stored summary attaches; a transient
// summary of the same document is its own run.
const again = await postDocumentAi(
document.id,
"summary?mode=summary&persist=true",
true,
);
expect((again.body.data as { runId: string }).runId).toBe(runId);
const transient = await postDocumentAi(
document.id,
"summary?mode=summary",
true,
);
expect(transient.status).toBe(202);
expect((transient.body.data as { runId: string }).runId).not.toBe(runId);
expect(await work(runId)).toBe("succeeded");
expect(runDocumentSummary).toHaveBeenCalledOnce();
const done = await poll(runId);
expect(done.body.data).toMatchObject({
status: "SUCCEEDED",
kind: "DOCUMENT_SUMMARY",
documentId: document.id,
result: {
summary: "A hospital discharge letter.",
persistence: "stored",
},
});
expect(await summaryState(document.id)).toEqual({
state: "READY",
stored: true,
});
});
it("marks a stored summary UNAVAILABLE when its run fails", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
vi.mocked(runDocumentSummary).mockRejectedValueOnce(
new Error("provider down"),
);
const queued = await postDocumentAi(
document.id,
"summary?mode=summary&persist=true",
true,
);
const { runId } = queued.body.data as { runId: string };
expect(await work(runId)).toBe("failed");
const done = await poll(runId);
expect(done.body.data).toMatchObject({
status: "FAILED",
error: { code: "documents.inbound.extractFailed", status: 502 },
});
expect(await summaryState(document.id)).toEqual({
state: "UNAVAILABLE",
stored: false,
});
});
it("leaves the document's summary state alone for a transient summary", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
vi.mocked(runDocumentSummary).mockRejectedValueOnce(
new Error("provider down"),
);
const queued = await postDocumentAi(
document.id,
"summary?mode=summary",
true,
);
const { runId } = queued.body.data as { runId: string };
expect(await summaryState(document.id)).toEqual({
state: "NONE",
stored: false,
});
expect(await work(runId)).toBe("failed");
expect(await summaryState(document.id)).toEqual({
state: "NONE",
stored: false,
});
});
it("answers synchronously, exactly as before, without the header", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
const res = await postDocumentAi(document.id, "summary?mode=text", false);
expect(res.status).toBe(200);
expect(res.headers.get("preference-applied")).toBeNull();
expect(res.body.data).toEqual({ text: "Entlassbrief Leukozyten erhoeht" });
expect(send).not.toHaveBeenCalled();
expect(await getPrismaClient().documentAiRun.count()).toBe(0);
});
});
describe("filing suggestions in the background", () => {
it("queues, reads in the worker, writes nothing to the document", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
const queued = await postDocumentAi(document.id, "suggest", true);
expect(queued.status).toBe(202);
const { runId } = queued.body.data as { runId: string };
expect(await work(runId)).toBe("succeeded");
expect(runDocumentAssist).toHaveBeenCalledOnce();
const done = await poll(runId);
expect(done.body.data).toMatchObject({
status: "SUCCEEDED",
kind: "DOCUMENT_SUGGEST",
result: {
suggestions: {
title: "Discharge letter",
kind: "DISCHARGE_LETTER",
documentDate: "2027-01-10",
},
},
});
const after = await getPrismaClient().inboundDocument.findUniqueOrThrow({
where: { id: document.id },
});
expect(after.kind).toBe("OTHER");
expect(after.title).toBeNull();
});
it("is a 404 for another account's document and its run", async () => {
const owner = await makeUser();
await grantConsent(owner.id);
const document = await storeDocument(owner.id);
await signIn(owner.id);
const queued = await postDocumentAi(document.id, "suggest", true);
const { runId } = queued.body.data as { runId: string };
const other = await makeUser();
await grantConsent(other.id);
await signIn(other.id);
const foreignPost = await postDocumentAi(document.id, "suggest", true);
expect(foreignPost.status).toBe(404);
expect(foreignPost.body.meta?.errorCode).toBe("documents.inbound.notFound");
const foreignPoll = await poll(runId);
expect(foreignPoll.status).toBe(404);
expect(foreignPoll.body.meta?.errorCode).toBe("aiRuns.notFound");
});
});