Run fact extraction in the background, re-checking under a row lock

The extract route accepts `Prefer: respond-async` for all three inputs
(text, stored, vision) and queues a DOCUMENT_EXTRACT run (added to
migration 0370). The run's result is the count of staged facts and the
document status; the facts themselves are read from the document.
Without the header the route answers with the document detail as
before; both paths run one shared body.

A background read can take minutes, and a review the person finishes in
the meantime must not have its approved facts replaced. Staging now
locks the document row and asks again whether it may stage, refusing
with the route's own alreadyConfirmed / alreadyPartlyConfirmed codes.
The synchronous path gets the same re-check.

The vault's stored-text extract uses the background path and shows the
shared waiting line in place of its hint while the read runs.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 21:14:47 +02:00
1 parent eda5776886
commit bb58b40b2a
16 files changed
+1003 -352

No files matched your search

@@ -125,6 +125,8 @@ describe("<DocumentFactsSection>", () => {
expect(html).toContain("Extract lab values");
// Honest about the mechanism: stored text, review-first, no auto-save.
expect(html).toContain("nothing is saved automatically");
// No background read is running, so no run line takes the hint's place.
expect(html).not.toContain("document-facts-extract-run-phase");
});
it("withdraws the extract offer once facts were approved", () => {
@@ -46,6 +46,7 @@ import type {
ObservationFactData,
} from "@/lib/validations/inbound-documents";
import { AiRunPhaseNote } from "./document-ai-panels";
import { useDocumentAiErrorText } from "./use-document-assist";
import {
useConfirmFacts,
@@ -625,12 +626,12 @@ export function DocumentFactsSection({
storedExtract.mutate(
{ documentId: doc.id },
{
onSuccess: (detail) => {
onSuccess: (result) => {
setFailures({});
setIncluded({});
if (
detail.facts.some((fact) => fact.status === "PENDING")
) {
// The staged facts arrive with the document refetch the
// hook starts; the review opens onto them.
if (result.factsStaged > 0) {
setOpen(true);
} else {
toast.info(t("documents.review.extractEmpty"));
@@ -654,9 +655,19 @@ export function DocumentFactsSection({
? t("documents.review.extracting")
: t("documents.review.extract")}
</Button>
<p className="text-muted-foreground text-xs">
{t("documents.review.extractHint")}
</p>
{storedExtract.runPhase === "idle" ? (
<p className="text-muted-foreground text-xs">
{t("documents.review.extractHint")}
</p>
) : (
// While the read runs, the hint gives way to where it stands:
// one muted line, the same one every document read shows.
<AiRunPhaseNote
phase={storedExtract.runPhase}
outcome="savedWithDocument"
slot="document-facts-extract-run-phase"
/>
)}
</div>
)}
</div>
+31 -8
View File
@@ -15,11 +15,15 @@
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { resolveAiRun, useAiRunPhase } from "@/hooks/use-ai-run";
import { apiPatch, apiPost } from "@/lib/api/api-fetch";
import type {
AiRunAccepted,
DocumentExtractRunResult,
} from "@/lib/documents/ai-runs/types";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type {
ExtractedFactDto,
InboundDocumentDetailDto,
InboundFactEdit,
} from "@/lib/validations/inbound-documents";
@@ -35,17 +39,35 @@ export interface ConfirmFactsResult {
* Structure the document's own stored extracted text into staged facts —
* the manual recovery when the automatic staging run was skipped or failed.
* Stages PENDING facts only; the review + confirm steps stay mandatory.
* A provider call is slow, so the request opts out of the 15 s default.
*
* The extraction runs in the background (v1.40): the request only queues it,
* so no proxy can cut a slow model, and `runPhase` says so while it runs. The
* run reports how many facts it staged; the facts themselves are read from
* the document, which is refetched when the run ends.
*/
export function useStoredExtract() {
const queryClient = useQueryClient();
return useMutation<InboundDocumentDetailDto, Error, { documentId: string }>({
mutationFn: ({ documentId }) =>
apiPost<InboundDocumentDetailDto>(
`/api/documents/inbound/${documentId}/extract`,
{ mode: "stored" },
{ signal: AbortSignal.timeout(120_000) },
const { phase, onQueued, onProgress, reset } = useAiRunPhase();
const mutation = useMutation<
DocumentExtractRunResult,
Error,
{ documentId: string }
>({
mutationFn: async ({ documentId }) =>
resolveAiRun<DocumentExtractRunResult>(
queryClient,
await apiPost<DocumentExtractRunResult | AiRunAccepted>(
`/api/documents/inbound/${documentId}/extract`,
{ mode: "stored" },
{
// The request only queues the read: the enqueue, no model.
signal: AbortSignal.timeout(60_000),
headers: { Prefer: "respond-async" },
},
),
{ onQueued, onProgress },
),
onSettled: reset,
onSuccess: (_data, { documentId }) => {
void invalidateKeys(queryClient, [
queryKeys.inboundDocument(documentId),
@@ -53,6 +75,7 @@ export function useStoredExtract() {
]);
},
});
return { ...mutation, runPhase: mutation.isPending ? phase : "idle" };
}
/**