Tell the four document-summary outcomes apart

Opening a document said "the summary is being prepared" and kept saying it.
The view had one fallback for a null summary column and used it for every
outcome: a document nobody ever enqueued, a missing vision provider, a spent
budget, a withdrawn consent, an unreadable file, a provider error, and a
summary the outbound screen withheld all rendered as work in progress.

The common case is not the screen. The background job is enqueued at exactly
one place — a fresh upload with the auto-read opt-in already on — and no
backfill reaches anything else. Every document uploaded before the toggle went
on has a null column no job will ever fill, and the view promised all of them a
summary that was never coming.

Record the outcome as `summary_state` (migration 0254, additive, defaults to
NONE which is truthful for every existing row) and branch on it. Only PENDING,
set when a job genuinely exists, may claim generation is running. NONE,
WITHHELD and UNAVAILABLE each say what happened and offer to generate one.

The withhold keeps its rule and loses its dead end. Blocked prose is still
never shown and never stored — only the fact of the refusal is kept, and it is
no longer terminal, so asking again re-runs the gauntlet. That was the original
intent; leaving the column null defeated it, because null read as "generating"
and the guard meant nothing would ever overwrite it.

The on-demand summary now persists what it produces. It is the only repair path
for the documents the job never ran for, and re-deriving the same paragraph on
every open costs a provider call for nothing. Generation is still only ever a
click — nothing runs because a view rendered. Transcription stays session-only.

Also drop the "read by AI" line from the detail footer.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 10:01:06 +02:00
1 parent e6e2210314
commit d773f460e5
19 files changed
+713 -82

No files matched your search

@@ -2,14 +2,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* On-demand summary / extracted text (Document vault P2). Pins P2-D4 / A3:
* session-only — the ONLY persistent side effects are the AI-budget ledger and
* the audit log; no row, index, or fact is written or mutated.
* On-demand summary / extracted text (Document vault P2).
*
* `mode=text` stays session-only (P2-D4 / A3): the only persistent side effects
* are the AI-budget ledger and the audit log. `mode=summary` additionally stores
* the summary on the document since v1.30.31 — the user asked for it, and it is
* the repair path for documents the background job never ran for. Nothing else
* (index, facts, rows) is ever written, and a WITHHELD summary is never stored
* as text.
*/
vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
inboundDocument: {
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
extractedFact: { create: vi.fn(), deleteMany: vi.fn() },
documentContentIndex: { upsert: vi.fn() },
user: { findUnique: vi.fn() },
@@ -18,6 +27,9 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/documents/store", () => ({
decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
encryptDocumentSummary: vi.fn((text: string) =>
new TextEncoder().encode(`enc:${text}`),
),
}));
vi.mock("@/lib/labs/ocr-upload", () => ({
detectOcrMimeType: vi.fn(() => "image/png"),
@@ -25,6 +37,7 @@ vi.mock("@/lib/labs/ocr-upload", () => ({
vi.mock("@/lib/documents/describe", () => ({
runDocumentSummary: vi.fn(),
transcribeDocument: vi.fn(),
documentSummaryBlockedCopy: () => "The summary was withheld.",
DocumentDescribeError: class DocumentDescribeError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", () => ({
@@ -88,6 +101,11 @@ const req = (id: string, mode?: string) =>
{ method: "POST" },
);
/**
* Nothing beyond the summary column may be written. `mode=summary` DOES persist
* onto the document since v1.30.31 (see the route header) — that leg is asserted
* explicitly below; everything else stays session-only.
*/
function assertNoPersistence(): void {
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
expect(prisma.documentContentIndex.upsert).not.toHaveBeenCalled();
@@ -117,9 +135,10 @@ beforeEach(() => {
});
describe("POST /api/documents/inbound/[id]/summary", () => {
it("mode=summary returns { summary } and persists only budget + audit", async () => {
it("mode=summary returns { summary } and STORES it, so a second open is free", async () => {
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "A blood panel from a lab.",
blocked: null,
} as never);
const res = await POST(
@@ -133,9 +152,76 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
"documents.inbound.summary",
expect.anything(),
);
// Persisted READY, owner-scoped, and only onto a document without one —
// an explicit re-run must not replace a summary already on screen.
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
where: {
id: "doc-1",
userId: "user-1",
deletedAt: null,
summaryEncrypted: null,
},
data: expect.objectContaining({ summaryState: "READY" }),
});
assertNoPersistence();
});
it("records WITHHELD without ever storing the blocked prose", async () => {
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "raise the dose to 10 mg",
blocked: "dose_directive",
} as never);
const res = await POST(
req("doc-1", "summary") as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
// The user gets the honest statement, never the blocked text.
const body = await res.json();
expect(body.data.summary).toBe("The summary was withheld.");
expect(body.data.summary).not.toContain("10 mg");
// Only the state lands. No ciphertext column is touched on this path.
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
where: {
id: "doc-1",
userId: "user-1",
deletedAt: null,
summaryState: { not: "READY" },
},
data: { summaryState: "WITHHELD" },
});
const calls = vi.mocked(prisma.inboundDocument.updateMany).mock.calls;
for (const [arg] of calls) {
expect(arg.data).not.toHaveProperty("summaryEncrypted");
}
assertNoPersistence();
});
it("still answers when the summary write fails", async () => {
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "A blood panel from a lab.",
blocked: null,
} as never);
vi.mocked(prisma.inboundDocument.updateMany).mockRejectedValueOnce(
new Error("db down"),
);
// The user is waiting on a summary they can already read; a failed write
// must not turn that into an error.
const res = await POST(
req("doc-1", "summary") as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual({
summary: "A blood panel from a lab.",
});
});
it("mode=text returns { text } session-only", async () => {
vi.mocked(transcribeDocument).mockResolvedValue({
text: "raw transcribed body",
@@ -3,11 +3,17 @@
* extracted text.
*
* `?mode=summary` (default) returns a short plain-language summary of WHAT the
* document is; `?mode=text` returns its raw transcribed text. Both are transient
* (P2-D4): the result is returned once and NOTHING is persisted except the
* AI-budget ledger and the audit log — never coach memory, snapshots, the
* structured stores, or the search index. The summary is descriptive only and
* is forbidden from diagnosing (interpretation boundary G7).
* document is; `?mode=text` returns its raw transcribed text. The summary is
* descriptive only and is forbidden from diagnosing (interpretation boundary
* G7).
*
* `mode=text` stays transient (P2-D4) — a transcription is a read-through of the
* user's own file and there is nothing to keep. `mode=summary` PERSISTS onto the
* document row since v1.30.31: the user asked for it explicitly, it is the same
* artefact the background job stores, and keeping it means a second open shows
* the paragraph instead of buying it again. Beyond that the old rule holds —
* nothing reaches coach memory, snapshots, the structured stores, or the search
* index; a summary the safety screen blocked is never stored as text.
*
* Same VISION/TEXT dispatch and gauntlet as the extract route. The document is
* UNTRUSTED (prompt-injection): the server never acts on an instruction inside
@@ -47,6 +53,8 @@ import {
transcribeDocument,
type DescribeInput,
} from "@/lib/documents/describe";
import { encryptDocumentSummary } from "@/lib/documents/store";
import type { OutboundReason } from "@/lib/ai/safety/outbound-screen";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
@@ -85,20 +93,90 @@ function resolveMode(request: NextRequest): DocumentSummaryMode {
: "summary";
}
/** Run the requested describe call (summary or raw text) over the input. */
/**
* Run the requested describe call (summary or raw text) over the input.
*
* `mode=summary` PERSISTS a clean result onto the document (v1.30.31). The
* route was session-only by design (P2-D4), and the transcription leg still is
* — but a summary that the user explicitly asked for and that passed the screen
* is exactly the artefact the background job stores, and re-deriving it on every
* open would mean paying a provider call to show the same paragraph twice. This
* is a deliberate user action, not a warm-on-mount: nothing generates because a
* view rendered. It is also the only repair path for the documents the
* background job never ran for — those uploaded before the auto-read opt-in
* went on, which no backfill ever reaches.
*/
async function describe(
mode: DocumentSummaryMode,
input: DescribeInput,
locale: Locale,
persist: { userId: string; documentId: string } | null,
): Promise<{ summary: string } | { text: string }> {
if (mode === "text") return transcribeDocument(input);
// REPLACE policy: the user clicked "summarise" and is waiting, so a screened
// summary becomes a short honest statement rather than an empty panel. The
// document itself and the extracted text remain reachable and unaltered.
const { summary, blocked } = await runDocumentSummary({ ...input, locale });
if (persist) await persistSummary(persist, summary, blocked);
return { summary: blocked ? documentSummaryBlockedCopy(locale) : summary };
}
/**
* 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: { userId: string; documentId: string },
summary: string,
blocked: OutboundReason | null,
): Promise<void> {
try {
if (blocked) {
await prisma.inboundDocument.updateMany({
where: {
id: target.documentId,
userId: target.userId,
deletedAt: null,
summaryState: { not: "READY" },
},
data: { summaryState: "WITHHELD" },
});
return;
}
// Scoped to a document without a stored summary: an explicit re-run must
// not silently replace a summary the user already has on screen.
const written = await prisma.inboundDocument.updateMany({
where: {
id: target.documentId,
userId: target.userId,
deletedAt: null,
summaryEncrypted: null,
},
data: {
summaryEncrypted: encryptDocumentSummary(summary),
summaryGeneratedAt: new Date(),
summaryState: "READY",
},
});
annotate({
action: { name: "documents.summary.persisted" },
meta: { documentId: target.documentId, stored: written.count > 0 },
});
} catch {
annotate({
action: { name: "documents.summary.persistFailed" },
meta: { documentId: target.documentId },
});
}
}
/** The budget the requested mode charges. */
function budgetFor(mode: DocumentSummaryMode): number {
return mode === "text"
@@ -237,6 +315,7 @@ async function handleTextSummary(
ocrText: parsed.data.text,
},
locale,
{ userId, documentId: document.id },
);
await reconcileSpend(
userId,
@@ -322,6 +401,7 @@ async function handleVisionSummary(
documents: vision.documents,
},
locale,
{ userId, documentId: document.id },
);
await reconcileSpend(
userId,