mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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" };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user