mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'worktree-agent-a908bda23e0007c88' into release/v1.30.31
This commit is contained in:
commit
cd502d5aef
20 files changed
+818
-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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests } from "@/lib/crypto";
|
||||
import {
|
||||
encryptDocumentSummary,
|
||||
serialiseDocumentDetail,
|
||||
type SerialisableDocument,
|
||||
} from "@/lib/documents/store";
|
||||
|
||||
/**
|
||||
* What the detail DTO says about a summary has to match what the user can
|
||||
* actually see. The state column is the source of truth for the four outcomes,
|
||||
* with one exception the view depends on: a READY row whose ciphertext will not
|
||||
* open has no summary to render, so reporting READY would leave a heading over
|
||||
* nothing.
|
||||
*/
|
||||
|
||||
const KEY_V1 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", KEY_V1);
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function row(overrides: Partial<SerialisableDocument> = {}) {
|
||||
return {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
kind: "OTHER",
|
||||
title: "Discharge letter",
|
||||
filename: "letter.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1024,
|
||||
contentCodec: "binary2",
|
||||
contentSha256: null,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: null,
|
||||
errorReason: null,
|
||||
summaryEncrypted: null,
|
||||
summaryGeneratedAt: null,
|
||||
summaryState: "NONE",
|
||||
createdAt: new Date("2026-02-14T00:00:00Z"),
|
||||
updatedAt: new Date("2026-02-14T00:00:00Z"),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
} as unknown as SerialisableDocument;
|
||||
}
|
||||
|
||||
describe("serialiseDocumentDetail — summary state", () => {
|
||||
it("returns the stored summary as READY", () => {
|
||||
const dto = serialiseDocumentDetail(
|
||||
row({
|
||||
summaryEncrypted: encryptDocumentSummary("A discharge letter."),
|
||||
summaryGeneratedAt: new Date("2026-02-15T09:00:00Z"),
|
||||
summaryState: "READY",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(dto.summary).toBe("A discharge letter.");
|
||||
expect(dto.summaryState).toBe("READY");
|
||||
expect(dto.summaryGeneratedAt).toBe("2026-02-15T09:00:00.000Z");
|
||||
});
|
||||
|
||||
it.each(["NONE", "PENDING", "WITHHELD", "UNAVAILABLE"] as const)(
|
||||
"passes %s through with no summary",
|
||||
(summaryState) => {
|
||||
const dto = serialiseDocumentDetail(row({ summaryState }), []);
|
||||
|
||||
expect(dto.summary).toBeNull();
|
||||
expect(dto.summaryState).toBe(summaryState);
|
||||
},
|
||||
);
|
||||
|
||||
it("degrades an undecryptable READY row to UNAVAILABLE", () => {
|
||||
// A rotated-away key. There is no summary to show, so the DTO must not
|
||||
// keep promising one — and the ciphertext is never returned either.
|
||||
const dto = serialiseDocumentDetail(
|
||||
row({
|
||||
summaryEncrypted: new TextEncoder().encode("not-ciphertext"),
|
||||
summaryState: "READY",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(dto.summary).toBeNull();
|
||||
expect(dto.summaryState).toBe("UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("degrades a READY row with no ciphertext at all to UNAVAILABLE", () => {
|
||||
const dto = serialiseDocumentDetail(
|
||||
row({ summaryEncrypted: null, summaryState: "READY" }),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(dto.summary).toBeNull();
|
||||
expect(dto.summaryState).toBe("UNAVAILABLE");
|
||||
});
|
||||
});
|
||||
@@ -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