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