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

+19 -5
View File
@@ -29359,6 +29359,14 @@ components:
anyOf:
- type: string
- type: "null"
summaryState:
type: string
enum:
- NONE
- PENDING
- READY
- WITHHELD
- UNAVAILABLE
required:
- id
- kind
@@ -29383,12 +29391,18 @@ components:
- facts
- summary
- summaryGeneratedAt
- summaryState
additionalProperties: false
description: A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of
WHAT the document is, generated once in the background right after upload when the `documentsAutoAiRead` opt-in
is ON (that flag is also the standing AI-egress consent); it is descriptive only, never a diagnosis, and is null
when auto-read is OFF, no provider is configured, or the background summary has not run yet.
`summaryGeneratedAt` is when it was generated (null until then).
description: 'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of
WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis.
`summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because
a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at
upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY
= stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore
never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget,
withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated";
WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an
unknown value as UNAVAILABLE when decoding.'
ExtractedFact:
type: object
properties:
+6 -2
View File
@@ -8476,7 +8476,12 @@
"title": "Dokumentdetails",
"summary": {
"title": "Zusammenfassung",
"pending": "Die Zusammenfassung wird erstellt."
"pending": "Die Zusammenfassung wird erstellt.",
"none": "Für dieses Dokument wurde noch keine Zusammenfassung erstellt.",
"withheld": "Eine Zusammenfassung wurde erstellt, hat die Sicherheitsprüfung aber nicht bestanden und wird deshalb nicht angezeigt. Du kannst es erneut versuchen oder das Dokument selbst lesen.",
"unavailable": "Für dieses Dokument konnte keine Zusammenfassung erstellt werden.",
"generate": "Zusammenfassung erstellen",
"generating": "Wird erstellt…"
},
"loadError": "Dieses Dokument konnte nicht geladen werden.",
"saveError": "Die Änderung konnte nicht gespeichert werden.",
@@ -8558,7 +8563,6 @@
"close": "Ausblenden"
},
"ai": {
"statusAiRead": "Von KI gelesen",
"readDone": "Die KI hat dein Dokument gelesen.",
"read": "Mit KI auslesen",
"reread": "Erneut lesen",
+6 -2
View File
@@ -8476,7 +8476,12 @@
"title": "Document details",
"summary": {
"title": "Summary",
"pending": "The summary is being prepared."
"pending": "The summary is being prepared.",
"none": "No summary has been generated for this document yet.",
"withheld": "A summary was generated but did not pass the safety check, so it is not shown. You can try again, or read the document itself.",
"unavailable": "No summary could be generated for this document.",
"generate": "Generate summary",
"generating": "Generating…"
},
"loadError": "Couldn't load this document.",
"saveError": "Couldn't save the change.",
@@ -8558,7 +8563,6 @@
"close": "Hide"
},
"ai": {
"statusAiRead": "Read by AI",
"readDone": "The AI read your document.",
"read": "Read with AI",
"reread": "Read again",
+6 -2
View File
@@ -8476,7 +8476,12 @@
"title": "Detalles del documento",
"summary": {
"title": "Resumen",
"pending": "Se está preparando el resumen."
"pending": "Se está preparando el resumen.",
"none": "Todavía no se ha generado ningún resumen para este documento.",
"withheld": "Se generó un resumen, pero no superó la comprobación de seguridad, así que no se muestra. Puedes intentarlo de nuevo o leer el documento.",
"unavailable": "No se pudo generar ningún resumen para este documento.",
"generate": "Generar resumen",
"generating": "Generando…"
},
"loadError": "No se pudo cargar este documento.",
"saveError": "No se pudo guardar el cambio.",
@@ -8558,7 +8563,6 @@
"close": "Ocultar"
},
"ai": {
"statusAiRead": "Leído por IA",
"readDone": "La IA leyó tu documento.",
"read": "Leer con IA",
"reread": "Leer de nuevo",
+6 -2
View File
@@ -8476,7 +8476,12 @@
"title": "Détails du document",
"summary": {
"title": "Résumé",
"pending": "Le résumé est en cours de préparation."
"pending": "Le résumé est en cours de préparation.",
"none": "Aucun résumé n’a encore été généré pour ce document.",
"withheld": "Un résumé a été généré mais n’a pas passé le contrôle de sécurité, il n’est donc pas affiché. Vous pouvez réessayer ou lire le document lui-même.",
"unavailable": "Aucun résumé n’a pu être généré pour ce document.",
"generate": "Générer le résumé",
"generating": "Génération…"
},
"loadError": "Impossible de charger ce document.",
"saveError": "Impossible d'enregistrer la modification.",
@@ -8558,7 +8563,6 @@
"close": "Masquer"
},
"ai": {
"statusAiRead": "Lu par l’IA",
"readDone": "L’IA a lu votre document.",
"read": "Lire avec l’IA",
"reread": "Relire",
+6 -2
View File
@@ -8476,7 +8476,12 @@
"title": "Dettagli del documento",
"summary": {
"title": "Riepilogo",
"pending": "Il riepilogo è in preparazione."
"pending": "Il riepilogo è in preparazione.",
"none": "Non è ancora stato generato alcun riepilogo per questo documento.",
"withheld": "Un riepilogo è stato generato ma non ha superato il controllo di sicurezza, quindi non viene mostrato. Puoi riprovare o leggere il documento stesso.",
"unavailable": "Non è stato possibile generare un riepilogo per questo documento.",
"generate": "Genera riepilogo",
"generating": "Generazione…"
},
"loadError": "Impossibile caricare questo documento.",
"saveError": "Impossibile salvare la modifica.",
@@ -8558,7 +8563,6 @@
"close": "Nascondi"
},
"ai": {
"statusAiRead": "Letto dall’IA",
"readDone": "L’IA ha letto il tuo documento.",
"read": "Leggi con l’IA",
"reread": "Leggi di nuovo",
+6 -2
View File
@@ -8476,7 +8476,12 @@
"title": "Szczegóły dokumentu",
"summary": {
"title": "Podsumowanie",
"pending": "Podsumowanie jest przygotowywane."
"pending": "Podsumowanie jest przygotowywane.",
"none": "Dla tego dokumentu nie wygenerowano jeszcze podsumowania.",
"withheld": "Podsumowanie zostało wygenerowane, ale nie przeszło kontroli bezpieczeństwa, więc nie jest wyświetlane. Możesz spróbować ponownie lub przeczytać sam dokument.",
"unavailable": "Nie udało się wygenerować podsumowania dla tego dokumentu.",
"generate": "Wygeneruj podsumowanie",
"generating": "Generowanie…"
},
"loadError": "Nie udało się wczytać tego dokumentu.",
"saveError": "Nie udało się zapisać zmiany.",
@@ -8558,7 +8563,6 @@
"close": "Ukryj"
},
"ai": {
"statusAiRead": "Odczytane przez AI",
"readDone": "AI odczytało Twój dokument.",
"read": "Odczytaj z AI",
"reread": "Odczytaj ponownie",
@@ -0,0 +1,26 @@
-- Record what happened to a document's summary.
--
-- `summary_encrypted` alone cannot tell four outcomes apart: a document nobody
-- ever enqueued, one whose provider is missing, one the safety screen withheld,
-- and one still in flight all leave it NULL. The detail view read that NULL as
-- "still generating" and said so forever.
--
-- Additive and backfill-free: NONE is the default and is the truthful value for
-- every pre-existing row, because none of them were ever attempted. Rows that
-- already carry a summary are moved to READY below.
CREATE TYPE "document_summary_state" AS ENUM (
'NONE',
'PENDING',
'READY',
'WITHHELD',
'UNAVAILABLE'
);
ALTER TABLE "inbound_documents"
ADD COLUMN "summary_state" "document_summary_state" NOT NULL DEFAULT 'NONE';
-- A document that already holds a summary is READY, not NONE.
UPDATE "inbound_documents"
SET "summary_state" = 'READY'
WHERE "summary_encrypted" IS NOT NULL;
+32
View File
@@ -6080,6 +6080,31 @@ enum InboundDocumentStatus {
@@map("inbound_document_status")
}
/// Outcome of a document's plain-language summary. Recorded so the detail view
/// states what happened rather than claiming generation is in progress for a
/// job that was never enqueued.
///
/// WITHHELD deserves a note: the outbound safety screen tripped, so the model's
/// prose is discarded and NEVER shown. Only the fact of the refusal is kept, and
/// it is deliberately not terminal — the user can ask again, which is the whole
/// reason this is a state and not a stored refusal string.
enum DocumentSummaryState {
/// Never attempted — the opt-in was off at upload, or the document predates
/// the feature. The default, so every existing row is correct.
NONE
/// Enqueued or running.
PENDING
/// A summary was produced and is stored in `summaryEncrypted`.
READY
/// Produced, then blocked by the outbound safety screen. Text discarded.
WITHHELD
/// Attempted and could not produce one — no vision provider, spent budget,
/// withdrawn consent, an unreadable file, or a provider error.
UNAVAILABLE
@@map("document_summary_state")
}
/// The FHIR resource an extracted fact maps to (STATED status only): a
/// diagnosis → Condition, a measured value → Observation, a medication the
/// patient is/was taking → MedicationStatement.
@@ -6166,6 +6191,13 @@ model InboundDocument {
/// When the background summary was generated (null until it runs). Surfaced
/// as meta beside the summary in the document detail view.
summaryGeneratedAt DateTime? @map("summary_generated_at")
/// What actually happened to this document's summary, so the detail view can
/// tell the four outcomes apart instead of reading a null column as "still
/// generating". `summaryEncrypted` alone cannot: a document nobody ever
/// enqueued, one whose provider is missing, one the safety screen withheld,
/// and one mid-flight all leave it null. NONE is the default, which keeps
/// every pre-existing row correct — none of them were ever attempted.
summaryState DocumentSummaryState @default(NONE) @map("summary_state")
facts ExtractedFact[]
conditionLinks DocumentConditionLink[]
@@ -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,
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents";
import { DocumentSummaryBlock } from "../document-summary-block";
/**
* The detail view used to read a null summary as "being generated" and say so
* forever — for a document nobody ever enqueued as readily as for one actually
* in flight. These pin the states apart: only PENDING may claim generation is
* running, a stored summary comes from storage, and every dead end offers a way
* out.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const noop = () => {};
function base(
overrides: Partial<React.ComponentProps<typeof DocumentSummaryBlock>> = {},
) {
const props: React.ComponentProps<typeof DocumentSummaryBlock> = {
summary: null,
summaryState: "NONE",
generatedAtLabel: null,
aiEnabled: true,
isGenerating: false,
actionsDisabled: false,
onGenerate: noop,
...overrides,
};
return render(<DocumentSummaryBlock {...props} />);
}
/** Every state that has no summary to show. */
const ABSENT_STATES: DocumentSummaryStateValue[] = [
"NONE",
"WITHHELD",
"UNAVAILABLE",
];
describe("DocumentSummaryBlock — a stored summary", () => {
it("renders the stored summary from storage, with its date", () => {
const html = base({
summary: "A discharge letter from a city hospital.",
summaryState: "READY",
generatedAtLabel: "14 Feb 2026",
});
expect(html).toContain("A discharge letter from a city hospital.");
expect(html).toContain("14 Feb 2026");
expect(html).toContain('data-slot="document-detail-summary"');
// Nothing claims it is being made — it already exists.
expect(html).not.toContain("being prepared");
expect(html).not.toContain("document-detail-summary-generate");
});
it("shows a stored summary even if the state disagrees", () => {
// The bytes are the truth. A stale state must never hide a real summary.
const html = base({ summary: "Stored prose.", summaryState: "PENDING" });
expect(html).toContain("Stored prose.");
expect(html).not.toContain("being prepared");
});
});
describe("DocumentSummaryBlock — honest absent states", () => {
it("claims generation is running ONLY for PENDING", () => {
const pending = base({ summaryState: "PENDING" });
expect(pending).toContain("being prepared");
expect(pending).toContain('data-slot="document-detail-summary-pending"');
// A job is running; offering a second one would double-spend.
expect(pending).not.toContain("document-detail-summary-generate");
});
it.each(ABSENT_STATES)(
"never says 'being generated' for %s",
(summaryState) => {
const html = base({ summaryState });
expect(html).not.toContain("being prepared");
expect(html).toContain('data-slot="document-detail-summary-absent"');
},
);
it("says a summary was never generated for NONE", () => {
expect(base({ summaryState: "NONE" })).toContain(
"No summary has been generated for this document yet.",
);
});
it("says the summary was withheld, and never shows blocked prose", () => {
const html = base({ summaryState: "WITHHELD" });
expect(html).toContain("did not pass the safety check");
// The withheld state carries no summary text — that is the whole contract.
expect(html).not.toContain("document-detail-summary&quot;");
});
it("says none could be produced for UNAVAILABLE", () => {
expect(base({ summaryState: "UNAVAILABLE" })).toContain(
"No summary could be generated for this document.",
);
});
it.each(ABSENT_STATES)("offers a way out of %s", (summaryState) => {
const html = base({ summaryState });
expect(html).toContain('data-slot="document-detail-summary-generate"');
expect(html).toContain("Generate summary");
});
});
describe("DocumentSummaryBlock — the action", () => {
it("states the outcome but offers no action without a provider", () => {
const html = base({ summaryState: "UNAVAILABLE", aiEnabled: false });
// The state is still honest; only the button that would 422 is gone.
expect(html).toContain("No summary could be generated for this document.");
expect(html).not.toContain("document-detail-summary-generate");
});
it("disables the action while generating, and says so", () => {
const html = base({ summaryState: "NONE", isGenerating: true });
expect(html).toContain("disabled");
expect(html).toContain("Generating");
});
it("disables the action while the capability probe resolves", () => {
expect(base({ summaryState: "NONE", actionsDisabled: true })).toContain(
"disabled",
);
});
});
@@ -66,13 +66,13 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
isAiReadSource,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
@@ -399,6 +399,26 @@ export function DocumentDetailSheet({
summary.mutate({ mode: aiMode, target: aiTarget, output });
};
// Generate the STORED summary from the detail block. Same endpoint as the
// action row, but the result is not routed into the transient panel: the
// route persists a screened-clean summary, so refetching the document shows
// it in place. This runs only on a click — nothing here generates on open.
const generateStoredSummary = () => {
if (!aiTarget || !documentId) return;
summary.reset();
summary.mutate(
{ mode: aiMode, target: aiTarget, output: "summary" },
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: queryKeys.inboundDocument(documentId),
});
},
onError: (error) => toast.error(t(documentAiErrorKey(error))),
},
);
};
const runIndex = () => {
if (!aiTarget) return;
indexDoc.mutate(
@@ -440,12 +460,6 @@ export function DocumentDetailSheet({
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
// AI-read provenance — surfaced here (the detail view) as calm muted meta
// since v1.28.38 dropped the vault-card glyph. Same condition the AI section
// uses: a content index exists AND its source is a provider vision/text read.
const aiRead = doc
? doc.hasContentIndex && isAiReadSource(doc.contentIndexSource)
: false;
return (
<>
@@ -588,34 +602,19 @@ export function DocumentDetailSheet({
</div>
)}
{doc.summary ? (
// Persisted background summary — generated once at upload when the
// auto-read opt-in is on. AI narrative body → foreground content;
// the generation date is muted meta (UI-STANDARDS §3).
<section
className="space-y-1.5"
data-slot="document-detail-summary"
>
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
<p className="text-foreground text-sm">{doc.summary}</p>
{doc.summaryGeneratedAt ? (
<p className="text-muted-foreground text-xs">
{format.date(doc.summaryGeneratedAt)}
</p>
) : null}
</section>
) : autoReadEnabled ? (
// Auto-read is on but no summary yet (still generating, or the
// provider could not read this file) — one calm muted hint.
<p
className="text-muted-foreground text-xs"
data-slot="document-detail-summary-pending"
>
{t("documents.detail.summary.pending")}
</p>
) : null}
<DocumentSummaryBlock
summary={doc.summary}
summaryState={doc.summaryState}
generatedAtLabel={
doc.summaryGeneratedAt
? format.date(doc.summaryGeneratedAt)
: null
}
aiEnabled={aiEnabled}
isGenerating={summary.isPending}
actionsDisabled={capability.isPending}
onGenerate={generateStoredSummary}
/>
<DocumentAiSection
aiEnabled={aiEnabled}
@@ -829,15 +828,6 @@ export function DocumentDetailSheet({
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
{aiRead ? (
<p
data-slot="document-detail-ai-read"
className="text-muted-foreground text-xs"
>
{t("documents.ai.statusAiRead")}
</p>
) : null}
</div>
</div>
) : null}
@@ -0,0 +1,117 @@
"use client";
/**
* The document detail sheet's summary block, lifted out of the sheet so its
* state machine renders without the dialog portal and is pinned by static-render
* tests.
*
* The block exists because a null summary is ambiguous and used to be read as
* one thing: "being generated". It was the fallback for every outcome — a
* document nobody ever enqueued (no backfill reaches uploads that predate the
* auto-read opt-in), a missing vision provider, a spent budget, a withdrawn
* consent, an unreadable file, a provider error, and a summary the outbound
* safety screen withheld. All of them left the column null, so the view told the
* user a job was running that in most cases never existed.
*
* It now branches on the stored `summaryState`:
*
* READY → the stored summary, served from storage, never regenerated
* PENDING → "being generated" — the ONLY state allowed to claim that, and
* it is set when a job was actually enqueued
* WITHHELD → says the summary was blocked, offers another attempt. The
* blocked prose is never shown or stored
* UNAVAILABLE → says none could be produced, offers another attempt
* NONE → says none exists yet, offers to generate one
*
* The action is a click, never an effect: opening a document must not spend a
* provider call. With no AI provider configured the block renders the state
* without an action rather than an button that would 422.
*/
import { FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents";
/** Copy key for a state with no summary to show. */
function absentCopyKey(state: DocumentSummaryStateValue): string {
if (state === "WITHHELD") return "documents.detail.summary.withheld";
if (state === "UNAVAILABLE") return "documents.detail.summary.unavailable";
return "documents.detail.summary.none";
}
export function DocumentSummaryBlock({
summary,
summaryState,
generatedAtLabel,
aiEnabled,
isGenerating,
actionsDisabled,
onGenerate,
}: {
/** The stored, decrypted summary, or null when there is none to show. */
summary: string | null;
summaryState: DocumentSummaryStateValue;
/** Pre-formatted generation date — the sheet owns locale formatting. */
generatedAtLabel: string | null;
/** Whether an AI provider is configured; false hides the action, not the state. */
aiEnabled: boolean;
isGenerating: boolean;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
onGenerate: () => void;
}) {
const { t } = useTranslations();
// A stored summary wins over every state: it is shown from storage.
if (summary) {
return (
<section className="space-y-1.5" data-slot="document-detail-summary">
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
<p className="text-foreground text-sm">{summary}</p>
{generatedAtLabel ? (
<p className="text-muted-foreground text-xs">{generatedAtLabel}</p>
) : null}
</section>
);
}
if (summaryState === "PENDING") {
return (
<p
className="text-muted-foreground text-xs"
data-slot="document-detail-summary-pending"
>
{t("documents.detail.summary.pending")}
</p>
);
}
return (
<section className="space-y-2" data-slot="document-detail-summary-absent">
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
<p className="text-muted-foreground text-xs">
{t(absentCopyKey(summaryState))}
</p>
{aiEnabled ? (
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-detail-summary-generate"
onClick={onGenerate}
disabled={isGenerating || actionsDisabled}
>
<FileText className="size-4" aria-hidden />
{isGenerating
? t("documents.detail.summary.generating")
: t("documents.detail.summary.generate")}
</Button>
) : null}
</section>
);
}
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import {
encryptDocumentSummary,
serialiseDocumentDetail,
type SerialisableDocument,
} from "@/lib/documents/store";
/**
* What the detail DTO says about a summary has to match what the user can
* actually see. The state column is the source of truth for the four outcomes,
* with one exception the view depends on: a READY row whose ciphertext will not
* open has no summary to render, so reporting READY would leave a heading over
* nothing.
*/
const KEY_V1 =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY_V1);
_resetCryptoCacheForTests();
});
function row(overrides: Partial<SerialisableDocument> = {}) {
return {
id: "doc-1",
userId: "user-1",
kind: "OTHER",
title: "Discharge letter",
filename: "letter.pdf",
mimeType: "application/pdf",
byteSize: 1024,
contentCodec: "binary2",
contentSha256: null,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: null,
errorReason: null,
summaryEncrypted: null,
summaryGeneratedAt: null,
summaryState: "NONE",
createdAt: new Date("2026-02-14T00:00:00Z"),
updatedAt: new Date("2026-02-14T00:00:00Z"),
deletedAt: null,
...overrides,
} as unknown as SerialisableDocument;
}
describe("serialiseDocumentDetail — summary state", () => {
it("returns the stored summary as READY", () => {
const dto = serialiseDocumentDetail(
row({
summaryEncrypted: encryptDocumentSummary("A discharge letter."),
summaryGeneratedAt: new Date("2026-02-15T09:00:00Z"),
summaryState: "READY",
}),
[],
);
expect(dto.summary).toBe("A discharge letter.");
expect(dto.summaryState).toBe("READY");
expect(dto.summaryGeneratedAt).toBe("2026-02-15T09:00:00.000Z");
});
it.each(["NONE", "PENDING", "WITHHELD", "UNAVAILABLE"] as const)(
"passes %s through with no summary",
(summaryState) => {
const dto = serialiseDocumentDetail(row({ summaryState }), []);
expect(dto.summary).toBeNull();
expect(dto.summaryState).toBe(summaryState);
},
);
it("degrades an undecryptable READY row to UNAVAILABLE", () => {
// A rotated-away key. There is no summary to show, so the DTO must not
// keep promising one — and the ciphertext is never returned either.
const dto = serialiseDocumentDetail(
row({
summaryEncrypted: new TextEncoder().encode("not-ciphertext"),
summaryState: "READY",
}),
[],
);
expect(dto.summary).toBeNull();
expect(dto.summaryState).toBe("UNAVAILABLE");
});
it("degrades a READY row with no ciphertext at all to UNAVAILABLE", () => {
const dto = serialiseDocumentDetail(
row({ summaryEncrypted: null, summaryState: "READY" }),
[],
);
expect(dto.summary).toBeNull();
expect(dto.summaryState).toBe("UNAVAILABLE");
});
});
+12
View File
@@ -20,6 +20,7 @@ import { servingClassFor } from "@/lib/documents/upload-policy";
import type {
DocumentConditionLinkDto,
DocumentContentIndexSourceValue,
DocumentSummaryStateValue,
ExtractedFactDto,
FactData,
FactProvenance,
@@ -241,12 +242,22 @@ export function serialiseDocumentDetail(
// failing the whole detail load — the field is descriptive, not load-bearing,
// and the ciphertext is never returned (fail-closed to null).
let summary: string | null = null;
// The stored state is the truth EXCEPT when the ciphertext will not open: a
// READY row we cannot decrypt has no summary to show, and reporting READY
// would leave the view rendering a heading over nothing. Degrade to
// UNAVAILABLE so what the user is told matches what they can see.
let summaryState: DocumentSummaryStateValue = doc.summaryState;
if (doc.summaryEncrypted && doc.summaryEncrypted.byteLength > 0) {
try {
summary = decryptDocumentSummary(doc.summaryEncrypted);
} catch {
summary = null;
summaryState = "UNAVAILABLE";
}
} else if (summaryState === "READY") {
// READY with no ciphertext should be unreachable; trust the bytes, not the
// flag, rather than promising a summary that is not there.
summaryState = "UNAVAILABLE";
}
return {
...serialiseDocument(
@@ -262,5 +273,6 @@ export function serialiseDocumentDetail(
summaryGeneratedAt: doc.summaryGeneratedAt
? doc.summaryGeneratedAt.toISOString()
: null,
summaryState,
};
}
@@ -182,16 +182,58 @@ describe("runDocumentSummaryJob — gating", () => {
expect(reserveBudget).not.toHaveBeenCalled();
expect(runDocumentSummary).not.toHaveBeenCalled();
expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled();
// No summary is written — but the attempt is RECORDED. Leaving the row
// untouched is what made the detail view claim "being generated" forever.
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }),
);
});
it("refunds the budget and persists nothing when the provider call throws", async () => {
it("refunds the budget and records UNAVAILABLE when the provider call throws", async () => {
vi.mocked(runDocumentSummary).mockRejectedValueOnce(new Error("boom"));
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
// Reservation refunded to zero spend; no write.
// Reservation refunded to zero spend; no summary written, state recorded.
expect(reconcileSpend).toHaveBeenCalledWith("user-1", 5, 0, "2026-07-17");
expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled();
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }),
);
});
it("records WITHHELD — not silence — when the safety screen blocks the summary", async () => {
vi.mocked(runDocumentSummary).mockResolvedValueOnce({
summary: "",
blocked: "dose_directive",
} as never);
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
// The blocked prose NEVER lands. Only the state does, and it is not
// terminal — the guard refuses to overwrite READY, nothing else.
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");
}
});
it("marks a stored summary READY so the view serves it from storage", async () => {
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ summaryEncrypted: null }),
data: expect.objectContaining({ summaryState: "READY" }),
}),
);
});
});
+47 -5
View File
@@ -44,9 +44,31 @@ import { encryptDocumentSummary } from "@/lib/documents/store";
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import type { DocumentSummaryState } from "@/generated/prisma/client";
export const DOCUMENT_SUMMARY_QUEUE = "document-summary";
/**
* Record what became of this document's summary. Never touches a READY row —
* a stored summary is the terminal state and a later run must not downgrade it.
* Owner-scoped like every other write here.
*/
async function markSummaryState(
userId: string,
documentId: string,
state: Exclude<DocumentSummaryState, "READY">,
): Promise<void> {
await prisma.inboundDocument.updateMany({
where: {
id: documentId,
userId,
deletedAt: null,
summaryState: { not: "READY" },
},
data: { summaryState: state },
});
}
/** Serial concurrency — provider calls, kept off the request pool. */
export const DOCUMENT_SUMMARY_CONCURRENCY = 1;
@@ -87,6 +109,8 @@ export async function runDocumentSummaryJob(
// on-demand route stays the only way to summarise). Fail-closed on a missing
// row.
if (!(await documentAutoReadEnabled(userId))) {
// Opting out is not a failed attempt — leave the state alone so the view
// keeps offering the manual action rather than claiming we tried.
annotate({
action: { name: "documents.summary.autoSkipped" },
meta: { documentId, reason: "opt-out" },
@@ -99,6 +123,7 @@ export async function runDocumentSummaryJob(
// there is no local text-layer fallback for a descriptive summary).
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
await markSummaryState(userId, documentId, "UNAVAILABLE");
annotate({
action: { name: "documents.summary.autoSkipped" },
meta: { documentId, reason: "no-provider" },
@@ -117,6 +142,7 @@ export async function runDocumentSummaryJob(
surface: "insights",
});
} catch (err) {
await markSummaryState(userId, documentId, "UNAVAILABLE");
annotate({
action: { name: "documents.summary.autoSkipped" },
meta: {
@@ -135,6 +161,7 @@ export async function runDocumentSummaryJob(
if (!vision.ok) {
// Cannot read this file with the picked provider (decrypt miss, unsupported
// type, or a PDF needing an Anthropic provider). Graceful no-op.
await markSummaryState(userId, documentId, "UNAVAILABLE");
annotate({
action: { name: "documents.summary.autoSkipped" },
meta: { documentId, reason: vision.reason },
@@ -151,6 +178,7 @@ export async function runDocumentSummaryJob(
);
// Budget exhausted → skip (no local fallback for a summary).
if (!reservation.allowed) {
await markSummaryState(userId, documentId, "UNAVAILABLE");
annotate({
action: { name: "documents.summary.autoSkipped" },
meta: { documentId, reason: "budget" },
@@ -185,12 +213,19 @@ export async function runDocumentSummaryJob(
reservation.reserved,
dateKey,
);
// WITHHOLD policy. Persisting the refusal copy would be worse than no
// summary: `updateMany` below is guarded on `summaryEncrypted: null`, so
// the first write wins and a stored refusal would stamp the document
// permanently with no path to regenerate. Leaving the column null keeps the
// on-demand route as the manual fallback, exactly as a provider miss does.
// WITHHOLD policy, v1.30.31. The model's prose still never lands — a
// summary that tripped the outbound screen must never be shown as if it
// were fine, and that rule is unchanged.
//
// What changed is the consequence. Withholding was chosen so a refusal
// could not stamp the document permanently, but leaving the column null
// achieved exactly that anyway: the detail view read null as "still
// generating" and said so forever, with no signal and no way out. The fact
// of the refusal is now recorded as a STATE (never the text), the view says
// so honestly, and WITHHELD is not terminal — `markSummaryState` only
// refuses to overwrite READY, so asking again re-runs the whole gauntlet.
if (blocked) {
await markSummaryState(userId, documentId, "WITHHELD");
annotate({
action: { name: "documents.summary.outbound_blocked" },
meta: { documentId, reason: blocked, providerType: pick.providerType },
@@ -210,6 +245,7 @@ export async function runDocumentSummaryJob(
data: {
summaryEncrypted: encryptDocumentSummary(summary),
summaryGeneratedAt: new Date(),
summaryState: "READY",
},
});
annotate({
@@ -226,6 +262,7 @@ export async function runDocumentSummaryJob(
// (the on-demand route remains the manual fallback). Never rethrow — a
// transient provider error must not retry-loop or fail the queue.
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await markSummaryState(userId, documentId, "UNAVAILABLE");
annotate({
action: { name: "documents.summary.autoFailed" },
meta: { documentId, reason: "provider_error" },
@@ -259,6 +296,11 @@ export async function enqueueDocumentSummary(
retryBackoff: true,
singletonKey: `document-summary|${documentId}`,
});
// PENDING is claimed only once a job genuinely exists. That is the whole
// point: the detail view may say "being generated" for this state, so
// nothing may set it speculatively — a dropped send (no boss, DB hiccup)
// leaves the state alone and the view offers the manual action instead.
if (jobId) await markSummaryState(userId, documentId, "PENDING");
return { enqueued: Boolean(jobId) };
} catch (err) {
annotate({
+3 -1
View File
@@ -23,6 +23,7 @@ import {
documentUpdateSchema,
inboundConfirmSchema,
inboundFactEditSchema,
DOCUMENT_SUMMARY_STATES,
INBOUND_DOCUMENT_KINDS,
INBOUND_DOCUMENT_STATUSES,
} from "@/lib/validations/inbound-documents";
@@ -165,11 +166,12 @@ const inboundDocumentDetail = inboundDocument
facts: z.array(extractedFact),
summary: z.string().nullable(),
summaryGeneratedAt: z.string().nullable(),
summaryState: z.enum(DOCUMENT_SUMMARY_STATES),
})
.meta({
id: "InboundDocumentDetail",
description:
"A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of WHAT the document is, generated once in the background right after upload when the `documentsAutoAiRead` opt-in is ON (that flag is also the standing AI-egress consent); it is descriptive only, never a diagnosis, and is null when auto-read is OFF, no provider is configured, or the background summary has not run yet. `summaryGeneratedAt` is when it was generated (null until then).",
'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis. `summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY = stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget, withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated"; WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an unknown value as UNAVAILABLE when decoding.',
});
const listResponse = z
+23
View File
@@ -98,6 +98,22 @@ export function toContentIndexSource(
: null;
}
/**
* What became of a document's plain-language summary. Mirrors the Prisma
* `DocumentSummaryState` enum. The detail view branches on this rather than on
* a null summary, which cannot tell "never attempted" from "in flight" from
* "withheld" from "could not produce".
*/
export const DOCUMENT_SUMMARY_STATES = [
"NONE",
"PENDING",
"READY",
"WITHHELD",
"UNAVAILABLE",
] as const;
export type DocumentSummaryStateValue =
(typeof DOCUMENT_SUMMARY_STATES)[number];
/** The document lifecycle states. STORED is the library default. */
export const INBOUND_DOCUMENT_STATUSES = [
"STORED",
@@ -301,6 +317,13 @@ export interface InboundDocumentDetailDto extends InboundDocumentDto {
summary: string | null;
/** When the background summary was generated (ISO 8601), or null. */
summaryGeneratedAt: string | null;
/**
* What became of the summary. A null `summary` is ambiguous on its own —
* never attempted, mid-flight, withheld by the safety screen and could-not-
* produce all look identical — so the view reads this instead of guessing
* "still generating".
*/
summaryState: DocumentSummaryStateValue;
}
// ─── Edit (correction before approval) ─────────────────────────────────────