mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Tell the four document-summary outcomes apart
Opening a document said "the summary is being prepared" and kept saying it. The view had one fallback for a null summary column and used it for every outcome: a document nobody ever enqueued, a missing vision provider, a spent budget, a withdrawn consent, an unreadable file, a provider error, and a summary the outbound screen withheld all rendered as work in progress. The common case is not the screen. The background job is enqueued at exactly one place — a fresh upload with the auto-read opt-in already on — and no backfill reaches anything else. Every document uploaded before the toggle went on has a null column no job will ever fill, and the view promised all of them a summary that was never coming. Record the outcome as `summary_state` (migration 0254, additive, defaults to NONE which is truthful for every existing row) and branch on it. Only PENDING, set when a job genuinely exists, may claim generation is running. NONE, WITHHELD and UNAVAILABLE each say what happened and offer to generate one. The withhold keeps its rule and loses its dead end. Blocked prose is still never shown and never stored — only the fact of the refusal is kept, and it is no longer terminal, so asking again re-runs the gauntlet. That was the original intent; leaving the column null defeated it, because null read as "generating" and the guard meant nothing would ever overwrite it. The on-demand summary now persists what it produces. It is the only repair path for the documents the job never ran for, and re-deriving the same paragraph on every open costs a provider call for nothing. Generation is still only ever a click — nothing runs because a view rendered. Transcription stays session-only. Also drop the "read by AI" line from the detail footer.
This commit is contained in:
1 parent
e6e2210314
commit
d773f460e5
19 files changed
+713
-82
No files matched your search
+19
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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"");
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ─────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user