mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge remote-tracking branch 'origin/main' into wip/v140-I
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
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" };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user