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:
Marc-André Bombeck committed 2026-07-19 10:01:06 +02:00
1 parent e6e2210314
commit d773f460e5
19 files changed
+713 -82

No files matched your search

@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents";
import { DocumentSummaryBlock } from "../document-summary-block";
/**
* The detail view used to read a null summary as "being generated" and say so
* forever — for a document nobody ever enqueued as readily as for one actually
* in flight. These pin the states apart: only PENDING may claim generation is
* running, a stored summary comes from storage, and every dead end offers a way
* out.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const noop = () => {};
function base(
overrides: Partial<React.ComponentProps<typeof DocumentSummaryBlock>> = {},
) {
const props: React.ComponentProps<typeof DocumentSummaryBlock> = {
summary: null,
summaryState: "NONE",
generatedAtLabel: null,
aiEnabled: true,
isGenerating: false,
actionsDisabled: false,
onGenerate: noop,
...overrides,
};
return render(<DocumentSummaryBlock {...props} />);
}
/** Every state that has no summary to show. */
const ABSENT_STATES: DocumentSummaryStateValue[] = [
"NONE",
"WITHHELD",
"UNAVAILABLE",
];
describe("DocumentSummaryBlock — a stored summary", () => {
it("renders the stored summary from storage, with its date", () => {
const html = base({
summary: "A discharge letter from a city hospital.",
summaryState: "READY",
generatedAtLabel: "14 Feb 2026",
});
expect(html).toContain("A discharge letter from a city hospital.");
expect(html).toContain("14 Feb 2026");
expect(html).toContain('data-slot="document-detail-summary"');
// Nothing claims it is being made — it already exists.
expect(html).not.toContain("being prepared");
expect(html).not.toContain("document-detail-summary-generate");
});
it("shows a stored summary even if the state disagrees", () => {
// The bytes are the truth. A stale state must never hide a real summary.
const html = base({ summary: "Stored prose.", summaryState: "PENDING" });
expect(html).toContain("Stored prose.");
expect(html).not.toContain("being prepared");
});
});
describe("DocumentSummaryBlock — honest absent states", () => {
it("claims generation is running ONLY for PENDING", () => {
const pending = base({ summaryState: "PENDING" });
expect(pending).toContain("being prepared");
expect(pending).toContain('data-slot="document-detail-summary-pending"');
// A job is running; offering a second one would double-spend.
expect(pending).not.toContain("document-detail-summary-generate");
});
it.each(ABSENT_STATES)(
"never says 'being generated' for %s",
(summaryState) => {
const html = base({ summaryState });
expect(html).not.toContain("being prepared");
expect(html).toContain('data-slot="document-detail-summary-absent"');
},
);
it("says a summary was never generated for NONE", () => {
expect(base({ summaryState: "NONE" })).toContain(
"No summary has been generated for this document yet.",
);
});
it("says the summary was withheld, and never shows blocked prose", () => {
const html = base({ summaryState: "WITHHELD" });
expect(html).toContain("did not pass the safety check");
// The withheld state carries no summary text — that is the whole contract.
expect(html).not.toContain("document-detail-summary&quot;");
});
it("says none could be produced for UNAVAILABLE", () => {
expect(base({ summaryState: "UNAVAILABLE" })).toContain(
"No summary could be generated for this document.",
);
});
it.each(ABSENT_STATES)("offers a way out of %s", (summaryState) => {
const html = base({ summaryState });
expect(html).toContain('data-slot="document-detail-summary-generate"');
expect(html).toContain("Generate summary");
});
});
describe("DocumentSummaryBlock — the action", () => {
it("states the outcome but offers no action without a provider", () => {
const html = base({ summaryState: "UNAVAILABLE", aiEnabled: false });
// The state is still honest; only the button that would 422 is gone.
expect(html).toContain("No summary could be generated for this document.");
expect(html).not.toContain("document-detail-summary-generate");
});
it("disables the action while generating, and says so", () => {
const html = base({ summaryState: "NONE", isGenerating: true });
expect(html).toContain("disabled");
expect(html).toContain("Generating");
});
it("disables the action while the capability probe resolves", () => {
expect(base({ summaryState: "NONE", actionsDisabled: true })).toContain(
"disabled",
);
});
});
@@ -66,13 +66,13 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
isAiReadSource,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
@@ -399,6 +399,26 @@ export function DocumentDetailSheet({
summary.mutate({ mode: aiMode, target: aiTarget, output });
};
// Generate the STORED summary from the detail block. Same endpoint as the
// action row, but the result is not routed into the transient panel: the
// route persists a screened-clean summary, so refetching the document shows
// it in place. This runs only on a click — nothing here generates on open.
const generateStoredSummary = () => {
if (!aiTarget || !documentId) return;
summary.reset();
summary.mutate(
{ mode: aiMode, target: aiTarget, output: "summary" },
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: queryKeys.inboundDocument(documentId),
});
},
onError: (error) => toast.error(t(documentAiErrorKey(error))),
},
);
};
const runIndex = () => {
if (!aiTarget) return;
indexDoc.mutate(
@@ -440,12 +460,6 @@ export function DocumentDetailSheet({
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
// AI-read provenance — surfaced here (the detail view) as calm muted meta
// since v1.28.38 dropped the vault-card glyph. Same condition the AI section
// uses: a content index exists AND its source is a provider vision/text read.
const aiRead = doc
? doc.hasContentIndex && isAiReadSource(doc.contentIndexSource)
: false;
return (
<>
@@ -588,34 +602,19 @@ export function DocumentDetailSheet({
</div>
)}
{doc.summary ? (
// Persisted background summary — generated once at upload when the
// auto-read opt-in is on. AI narrative body → foreground content;
// the generation date is muted meta (UI-STANDARDS §3).
<section
className="space-y-1.5"
data-slot="document-detail-summary"
>
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
<p className="text-foreground text-sm">{doc.summary}</p>
{doc.summaryGeneratedAt ? (
<p className="text-muted-foreground text-xs">
{format.date(doc.summaryGeneratedAt)}
</p>
) : null}
</section>
) : autoReadEnabled ? (
// Auto-read is on but no summary yet (still generating, or the
// provider could not read this file) — one calm muted hint.
<p
className="text-muted-foreground text-xs"
data-slot="document-detail-summary-pending"
>
{t("documents.detail.summary.pending")}
</p>
) : null}
<DocumentSummaryBlock
summary={doc.summary}
summaryState={doc.summaryState}
generatedAtLabel={
doc.summaryGeneratedAt
? format.date(doc.summaryGeneratedAt)
: null
}
aiEnabled={aiEnabled}
isGenerating={summary.isPending}
actionsDisabled={capability.isPending}
onGenerate={generateStoredSummary}
/>
<DocumentAiSection
aiEnabled={aiEnabled}
@@ -829,15 +828,6 @@ export function DocumentDetailSheet({
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
{aiRead ? (
<p
data-slot="document-detail-ai-read"
className="text-muted-foreground text-xs"
>
{t("documents.ai.statusAiRead")}
</p>
) : null}
</div>
</div>
) : null}
@@ -0,0 +1,117 @@
"use client";
/**
* The document detail sheet's summary block, lifted out of the sheet so its
* state machine renders without the dialog portal and is pinned by static-render
* tests.
*
* The block exists because a null summary is ambiguous and used to be read as
* one thing: "being generated". It was the fallback for every outcome — a
* document nobody ever enqueued (no backfill reaches uploads that predate the
* auto-read opt-in), a missing vision provider, a spent budget, a withdrawn
* consent, an unreadable file, a provider error, and a summary the outbound
* safety screen withheld. All of them left the column null, so the view told the
* user a job was running that in most cases never existed.
*
* It now branches on the stored `summaryState`:
*
* READY → the stored summary, served from storage, never regenerated
* PENDING → "being generated" — the ONLY state allowed to claim that, and
* it is set when a job was actually enqueued
* WITHHELD → says the summary was blocked, offers another attempt. The
* blocked prose is never shown or stored
* UNAVAILABLE → says none could be produced, offers another attempt
* NONE → says none exists yet, offers to generate one
*
* The action is a click, never an effect: opening a document must not spend a
* provider call. With no AI provider configured the block renders the state
* without an action rather than an button that would 422.
*/
import { FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents";
/** Copy key for a state with no summary to show. */
function absentCopyKey(state: DocumentSummaryStateValue): string {
if (state === "WITHHELD") return "documents.detail.summary.withheld";
if (state === "UNAVAILABLE") return "documents.detail.summary.unavailable";
return "documents.detail.summary.none";
}
export function DocumentSummaryBlock({
summary,
summaryState,
generatedAtLabel,
aiEnabled,
isGenerating,
actionsDisabled,
onGenerate,
}: {
/** The stored, decrypted summary, or null when there is none to show. */
summary: string | null;
summaryState: DocumentSummaryStateValue;
/** Pre-formatted generation date — the sheet owns locale formatting. */
generatedAtLabel: string | null;
/** Whether an AI provider is configured; false hides the action, not the state. */
aiEnabled: boolean;
isGenerating: boolean;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
onGenerate: () => void;
}) {
const { t } = useTranslations();
// A stored summary wins over every state: it is shown from storage.
if (summary) {
return (
<section className="space-y-1.5" data-slot="document-detail-summary">
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
<p className="text-foreground text-sm">{summary}</p>
{generatedAtLabel ? (
<p className="text-muted-foreground text-xs">{generatedAtLabel}</p>
) : null}
</section>
);
}
if (summaryState === "PENDING") {
return (
<p
className="text-muted-foreground text-xs"
data-slot="document-detail-summary-pending"
>
{t("documents.detail.summary.pending")}
</p>
);
}
return (
<section className="space-y-2" data-slot="document-detail-summary-absent">
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
<p className="text-muted-foreground text-xs">
{t(absentCopyKey(summaryState))}
</p>
{aiEnabled ? (
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-detail-summary-generate"
onClick={onGenerate}
disabled={isGenerating || actionsDisabled}
>
<FileText className="size-4" aria-hidden />
{isGenerating
? t("documents.detail.summary.generating")
: t("documents.detail.summary.generate")}
</Button>
) : null}
</section>
);
}