diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2f3b39b2c..7f107916c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 12a12c74c..d4f4b44ab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3f4f70771..0b38e5136 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 8d7e0c8fb..4709a36ed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index cd66a28a8..f7555ad87 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 303022426..9f9cdf118 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 22cdd6c6f..a094e9fac 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0254_document_summary_state/migration.sql b/prisma/migrations/0254_document_summary_state/migration.sql new file mode 100644 index 000000000..3c3a87462 --- /dev/null +++ b/prisma/migrations/0254_document_summary_state/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f62e1a639..f525464f7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts index 80607b9f1..9efa2b15a 100644 --- a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts @@ -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", diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 71cfd311d..480cc93c8 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -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 { + 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, diff --git a/src/components/documents/__tests__/document-summary-block.test.tsx b/src/components/documents/__tests__/document-summary-block.test.tsx new file mode 100644 index 000000000..8a9b2143b --- /dev/null +++ b/src/components/documents/__tests__/document-summary-block.test.tsx @@ -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( + {node}, + ); +} + +const noop = () => {}; + +function base( + overrides: Partial> = {}, +) { + const props: React.ComponentProps = { + summary: null, + summaryState: "NONE", + generatedAtLabel: null, + aiEnabled: true, + isGenerating: false, + actionsDisabled: false, + onGenerate: noop, + ...overrides, + }; + return render(); +} + +/** 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""); + }); + + 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", + ); + }); +}); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 8f963f3f0..0270d523e 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -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({ )} - {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). -
-

- {t("documents.detail.summary.title")} -

-

{doc.summary}

- {doc.summaryGeneratedAt ? ( -

- {format.date(doc.summaryGeneratedAt)} -

- ) : null} -
- ) : autoReadEnabled ? ( - // Auto-read is on but no summary yet (still generating, or the - // provider could not read this file) — one calm muted hint. -

- {t("documents.detail.summary.pending")} -

- ) : null} + - - {aiRead ? ( -

- {t("documents.ai.statusAiRead")} -

- ) : null} ) : null} diff --git a/src/components/documents/document-summary-block.tsx b/src/components/documents/document-summary-block.tsx new file mode 100644 index 000000000..f42b2be87 --- /dev/null +++ b/src/components/documents/document-summary-block.tsx @@ -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 ( +
+

+ {t("documents.detail.summary.title")} +

+

{summary}

+ {generatedAtLabel ? ( +

{generatedAtLabel}

+ ) : null} +
+ ); + } + + if (summaryState === "PENDING") { + return ( +

+ {t("documents.detail.summary.pending")} +

+ ); + } + + return ( +
+

+ {t("documents.detail.summary.title")} +

+

+ {t(absentCopyKey(summaryState))} +

+ {aiEnabled ? ( + + ) : null} +
+ ); +} diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 7f063122a..3b999754b 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -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, }; } diff --git a/src/lib/jobs/__tests__/document-summary.test.ts b/src/lib/jobs/__tests__/document-summary.test.ts index 2e0d66f8c..c6115bc19 100644 --- a/src/lib/jobs/__tests__/document-summary.test.ts +++ b/src/lib/jobs/__tests__/document-summary.test.ts @@ -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" }), + }), + ); }); }); diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 602b561b0..83bd6e022 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -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, +): Promise { + 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({ diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 2915f0170..06a4bc989 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -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 diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 0b7d381da..db4c89c16 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -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) ─────────────────────────────────────