Merge branch 'worktree-agent-a908bda23e0007c88' into release/v1.30.31

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 10:07:42 +02:00
commit cd502d5aef
20 files changed
+818 -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,