Merge remote-tracking branch 'origin/main' into wip/v140-I

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 22:16:29 +02:00
commit ef0028c020
45 files changed
+3161 -1010

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");
});
});
@@ -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", () => {
@@ -0,0 +1,59 @@
/**
* Every document kind the database knows is one the vault can file, show and
* name. A kind reaches the screen through a dynamic key
* (`documents.kind.${kind}`), which the call-site coverage guard cannot see,
* so a kind added to the schema without its label would render as a raw key
* in the filter, the bulk bar and the detail sheet.
*
* Mutation check: removing `SICK_NOTE` from any locale's `documents.kind`
* turns the label case red for that locale; dropping it from
* `INBOUND_DOCUMENT_KINDS` turns the parity case red.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { InboundDocumentKind } from "@/generated/prisma/enums";
import { locales } from "@/lib/i18n/config";
import { INBOUND_DOCUMENT_KINDS } from "@/lib/validations/inbound-documents";
import {
DOCUMENT_KIND_ICONS,
DOCUMENT_KIND_ORDER,
} from "../document-kind-meta";
function kindLabels(locale: string): Record<string, unknown> {
const bundle = JSON.parse(
readFileSync(join(process.cwd(), "messages", `${locale}.json`), "utf8"),
) as { documents?: { kind?: Record<string, unknown> } };
return bundle.documents?.kind ?? {};
}
describe("document kinds", () => {
it("the validation list is exactly the database enum", () => {
expect([...INBOUND_DOCUMENT_KINDS].sort()).toEqual(
Object.values(InboundDocumentKind).sort(),
);
});
it("every kind has a place in the rail and a glyph", () => {
expect([...DOCUMENT_KIND_ORDER].sort()).toEqual(
[...INBOUND_DOCUMENT_KINDS].sort(),
);
for (const kind of INBOUND_DOCUMENT_KINDS) {
expect(DOCUMENT_KIND_ICONS[kind], kind).toBeDefined();
}
expect(DOCUMENT_KIND_ORDER.at(-1)).toBe("OTHER");
});
it.each([...locales])("every kind has a label in %s", (locale) => {
const labels = kindLabels(locale);
const missing = INBOUND_DOCUMENT_KINDS.filter(
(kind) =>
typeof labels[kind] !== "string" ||
(labels[kind] as string).trim() === "",
);
expect(missing).toEqual([]);
});
});
@@ -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}
/>
)}
@@ -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>
@@ -15,6 +15,7 @@ import {
ShieldCheck,
Stethoscope,
Syringe,
Thermometer,
} from "lucide-react";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
@@ -26,6 +27,7 @@ export const DOCUMENT_KIND_ORDER: readonly InboundDocumentKindValue[] = [
"IMAGING",
"DISCHARGE_LETTER",
"PRESCRIPTION",
"SICK_NOTE",
"REFERRAL",
"VACCINATION",
"INSURANCE",
@@ -44,5 +46,6 @@ export const DOCUMENT_KIND_ICONS: Record<
REFERRAL: ArrowRightLeft,
INSURANCE: ShieldCheck,
VACCINATION: Syringe,
SICK_NOTE: Thermometer,
OTHER: File,
};
@@ -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" };
}
/**
+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" };
}
/**