Run document summaries and filing suggestions in the background

The summary and suggest routes accept `Prefer: respond-async`: after the
same quick refusals they answer 202 with a run id, and the background
document AI worker runs the read under two new run kinds
(DOCUMENT_SUMMARY, DOCUMENT_SUGGEST, migration 0370). Without the header
both routes answer synchronously as before; both paths run one shared
body, so they cannot drift.

The worker asks the capability for the record and re-checks the wire for
the provider it picks before every read. A summary that is to be stored
marks the document PENDING while queued, READY when it lands, and
UNAVAILABLE when the run fails or the write does not land. A second
request attaches to a live run only when it would produce the same thing.

The vault asks for both in the background and shows the same calm
waiting line the index uses, worded so a transient read never promises
that leaving the page keeps it. The egress guard and the route inventory
now hold every run kind to its dispatcher re-check and its capability.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 20:51:57 +02:00
1 parent 7cd323499e
commit eda5776886
24 files changed
+2061 -800

No files matched your search

@@ -254,3 +254,35 @@ describe("<DocumentAiSection> index outcome (#776)", () => {
expect(html).not.toContain('data-slot="document-index-outcome"');
});
});
describe("<DocumentAiSection> background reads (v1.40)", () => {
it("a running suggestion or summary says it reads in the background without promising it is kept", () => {
const html = render(base({ assistRunPhase: "background" }));
expect(html).toContain('data-slot="document-assist-run-phase"');
expect(html).toContain("With a slow model this can take a few minutes.");
expect(html).not.toContain("You can leave this page");
});
it("a read whose result lands on the document says the person may leave", () => {
const html = render(
base({
indexRunPhase: "background",
assistRunPhase: "background",
}),
);
expect(html).toContain('data-slot="document-index-run-phase"');
expect(html).toContain("You can leave this page");
// One line at a time, never two stacked notes.
expect(html).not.toContain('data-slot="document-assist-run-phase"');
});
it("says when no worker has picked the read up yet", () => {
const html = render(base({ assistRunPhase: "waitingForWorker" }));
expect(html).toContain("Waiting for the background worker.");
});
it("renders no note while nothing runs", () => {
const html = render(base());
expect(html).not.toContain("run-phase");
});
});
@@ -163,3 +163,15 @@ describe("DocumentSummaryBlock — the action", () => {
);
});
});
describe("DocumentSummaryBlock — a background read (v1.40)", () => {
it("says the read runs in the background and is stored with the document", () => {
const html = base({ isGenerating: true, runPhase: "background" });
expect(html).toContain('data-slot="document-detail-summary-run-phase"');
expect(html).toContain("You can leave this page");
});
it("renders no note while nothing runs", () => {
expect(base()).not.toContain("run-phase");
});
});
@@ -12,6 +12,8 @@
* user applies it (and the title lands in the editable field, not on disk).
* - `DocumentSummaryPanel` — the transient summary / extracted-text panel with
* the persistent "not saved · not a diagnosis" note.
* - `AiRunPhaseNote` — the calm line under an action while its read runs in
* the background (v1.40), shared by every document AI action.
*/
import { Sparkles } from "lucide-react";
import { Check, FileText, X } from "lucide-react";
@@ -19,6 +21,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import type { AiRunPhase } from "@/hooks/use-ai-run";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
@@ -28,6 +31,37 @@ import {
import type { DocumentDescribeResult } from "./use-document-assist";
/**
* The line under a document AI action while its read runs in the background,
* or nothing when no read is running.
*
* `outcome` keeps the sentence true: a read whose result lands on the document
* (the index, a stored summary, staged facts) tells the person they may leave;
* a read whose answer lives only on this screen (suggestions, a transient
* summary or transcription) does not promise that, because leaving drops it.
*/
export function AiRunPhaseNote({
phase,
outcome,
slot,
}: {
phase: AiRunPhase;
outcome: "savedWithDocument" | "shownHere";
slot: string;
}) {
const { t } = useTranslations();
if (phase === "idle") return null;
return (
<p role="status" data-slot={slot} className="text-muted-foreground text-xs">
{phase === "waitingForWorker"
? t("aiRuns.waitingForWorker")
: outcome === "savedWithDocument"
? t("aiRuns.backgroundDocument")
: t("aiRuns.backgroundScan")}
</p>
);
}
/**
* Calm pointer to the AI settings, shown when assist is unavailable. When the
* document is already searchable (auto-indexed locally) the copy stays honest —
@@ -45,6 +45,7 @@ import {
} from "@/lib/validations/inbound-documents";
import {
AiRunPhaseNote,
AiUnavailableHint,
AssistSuggestionReview,
DocumentSummaryPanel,
@@ -79,6 +80,7 @@ export function DocumentAiSection({
lastIndexOutcome = null,
indexPending,
indexRunPhase = "idle",
assistRunPhase = "idle",
onIndex,
}: {
aiEnabled: boolean;
@@ -124,6 +126,8 @@ export function DocumentAiSection({
indexPending: boolean;
/** v1.40 — where a background read stands, for the calm line under it. */
indexRunPhase?: AiRunPhase;
/** v1.40 — the same for a suggestion or transient summary read. */
assistRunPhase?: AiRunPhase;
onIndex: () => void;
}) {
const { t } = useTranslations();
@@ -253,16 +257,18 @@ export function DocumentAiSection({
) : null}
{indexRunPhase !== "idle" ? (
<p
role="status"
data-slot="document-index-run-phase"
className="text-muted-foreground text-xs"
>
{indexRunPhase === "waitingForWorker"
? t("aiRuns.waitingForWorker")
: t("aiRuns.backgroundDocument")}
</p>
) : null}
<AiRunPhaseNote
phase={indexRunPhase}
outcome="savedWithDocument"
slot="document-index-run-phase"
/>
) : (
<AiRunPhaseNote
phase={assistRunPhase}
outcome="shownHere"
slot="document-assist-run-phase"
/>
)}
{indexOutcomeText ? (
<p
@@ -894,6 +894,7 @@ export function DocumentDetailSheet({
}
aiEnabled={aiEnabled}
isGenerating={storedSummary.isPending}
runPhase={storedSummary.runPhase}
actionsDisabled={capability.isPending}
onGenerate={generateStoredSummary}
/>
@@ -938,6 +939,9 @@ export function DocumentDetailSheet({
lastIndexOutcome={doc.lastIndexOutcome}
indexPending={indexDoc.isPending}
indexRunPhase={indexDoc.runPhase}
assistRunPhase={
suggest.isPending ? suggest.runPhase : summary.runPhase
}
onIndex={runIndex}
/>
)}
@@ -30,9 +30,12 @@
import { FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { AiRunPhase } from "@/hooks/use-ai-run";
import { useTranslations } from "@/lib/i18n/context";
import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents";
import { AiRunPhaseNote } from "./document-ai-panels";
/** Copy key for a state with no summary to show. */
function absentCopyKey(state: DocumentSummaryStateValue): string {
if (state === "WITHHELD") return "documents.detail.summary.withheld";
@@ -46,6 +49,7 @@ export function DocumentSummaryBlock({
generatedAtLabel,
aiEnabled,
isGenerating,
runPhase = "idle",
actionsDisabled,
canGenerate = true,
onGenerate,
@@ -58,6 +62,11 @@ export function DocumentSummaryBlock({
/** Whether an AI provider is configured; false hides the action, not the state. */
aiEnabled: boolean;
isGenerating: boolean;
/**
* v1.40 — where the background read stands while one runs. The summary is
* stored on the document, so the line tells the person they may leave.
*/
runPhase?: AiRunPhase;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
/**
@@ -125,6 +134,11 @@ export function DocumentSummaryBlock({
: t("documents.detail.summary.generate")}
</Button>
) : null}
<AiRunPhaseNote
phase={runPhase}
outcome="savedWithDocument"
slot="document-detail-summary-run-phase"
/>
</section>
);
}
@@ -15,9 +15,10 @@
*/
import { useCallback } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import { useAiRunPhase } from "@/hooks/use-ai-run";
import { apiGet, ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -85,10 +86,15 @@ export function useDocumentsAutoAiRead(enabled: boolean) {
* Suggest filing metadata for a stored document. Returns DRAFTS only — the
* detail sheet prefills its edit fields and the user saves; this call writes
* nothing.
*
* The read runs in the background (v1.40), so no proxy in front of the server
* can cut a slow model; `runPhase` says so while it does.
*/
export function useSuggestDetails() {
const queryClient = useQueryClient();
const { responseTimeoutMs } = useAiProviderState();
return useMutation<
const { phase, onQueued, onProgress, reset } = useAiRunPhase();
const mutation = useMutation<
DocumentSuggestionDto,
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
@@ -100,19 +106,25 @@ export function useSuggestDetails() {
target,
responseTimeoutMs,
modelCalls: 1,
background: { queryClient, onQueued, onProgress },
});
return data.suggestions;
},
onSettled: reset,
});
return { ...mutation, runPhase: mutation.isPending ? phase : "idle" };
}
/**
* On-demand summary or extracted text. Both stay transient unless the document
* summary block explicitly requests persistence or replacement.
* summary block explicitly requests persistence or replacement. The read runs
* in the background (v1.40); `runPhase` says so while it does.
*/
export function useDocumentSummary() {
const queryClient = useQueryClient();
const { responseTimeoutMs } = useAiProviderState();
return useMutation<
const { phase, onQueued, onProgress, reset } = useAiRunPhase();
const mutation = useMutation<
DocumentDescribeResult,
Error,
{
@@ -130,8 +142,11 @@ export function useDocumentSummary() {
target,
responseTimeoutMs,
modelCalls: 1,
background: { queryClient, onQueued, onProgress },
}),
onSettled: reset,
});
return { ...mutation, runPhase: mutation.isPending ? phase : "idle" };
}
/**