feat(documents): review and extract lab values on the document itself

The server chain existed end to end (extract stages PENDING facts, per-fact
edit clears the low-confidence gate, confirm is the only write into the
structured stores) but the document detail offered no control over any of
it: it counted observation facts and linked to Labs. A user whose automatic
staging run was skipped had to re-upload the same PDF through Labs > Scan a
report.

- The detail sheet gains a staged-facts block with honest states: pending
  facts open an inline review (approve/reject per fact; a low-confidence
  fact is checked and saved first via the existing per-fact edit); a
  lab-filed, already-read document with nothing staged offers "Extract lab
  values" over its stored text; otherwise nothing renders.
- The extract route gains { mode: "stored" }: the same text-structuring
  pass the automatic worker runs, over the document's own content index.
  No re-upload, no second read of the original; full gauntlet including
  the shared document-AI bucket with charge-at-dispatch honesty; 422
  notIndexed when no stored text exists.
- A manual "Read with AI" / "Read again" now continues into the same
  lab auto-staging the background worker performs; the response reports
  labFactsStaged and the toast names the values waiting for review.
- Unchecked values are rejected and the copy says so; an unverified
  low-confidence fact receives no decision and stays pending rather than
  being silently discarded. Confirmation remains the only write into
  Labs, conditions, and medications; approved facts carry the
  committedRecordId link back to the source document (existing model, no
  schema change).
- The "lab values from this document" link now counts APPROVED facts
  only — it claims values that actually exist in Labs.
- Copy in all six locales; OpenAPI regenerated for the new extract mode
  and the index response field.
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 00:33:14 +02:00
1 parent 8e16df6894
commit fd104dd0ac
20 files changed
+1681 -64

No files matched your search

@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type {
ExtractedFactDto,
InboundDocumentDetailDto,
} from "@/lib/validations/inbound-documents";
import { DocumentFactsSection } from "../document-facts-review";
/**
* The staged-facts block's honest states, pinned by static render:
*
* - a document with PENDING facts shows the review action (the control the
* detail view never had — its absence forced a re-upload through Labs);
* - a lab-filed, already-read document with nothing staged offers the
* stored-text extract (recovery for a skipped automatic run);
* - a delegate inside somebody else's record sees neither (staging and
* confirming write to the owner's record);
* - a fully confirmed document renders nothing — the surface stays calm.
*/
function render(node: React.ReactNode) {
const queryClient = new QueryClient();
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
function fact(overrides: Partial<ExtractedFactDto> = {}): ExtractedFactDto {
return {
id: "fact-1",
factType: "OBSERVATION",
status: "PENDING",
confidence: 0.95,
needsReview: false,
data: {
label: "Hemoglobin",
code: null,
codeSystem: null,
value: 13.9,
valueText: null,
unit: "g/dL",
referenceLow: 12,
referenceHigh: 16,
referenceText: "12-16",
effectiveDate: "2026-08-01",
},
provenance: {
sourceText: "Hemoglobin 13.9 g/dL",
anchored: true,
sourceOffset: 10,
page: 0,
confidence: 0.95,
},
committedRecordId: null,
committedRecordType: null,
...overrides,
};
}
function doc(
overrides: Partial<InboundDocumentDetailDto> = {},
): InboundDocumentDetailDto {
return {
id: "doc-1",
kind: "LAB_RESULT",
title: "Blood panel",
filename: "panel.pdf",
mimeType: "application/pdf",
byteSize: 1000,
status: "EXTRACTED",
providerType: "anthropic",
reportDate: "2026-08-01",
documentDate: null,
errorReason: null,
factCount: 1,
pendingCount: 1,
conditionLinks: [],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: true,
contentIndexSource: "vision",
hasThumbnail: false,
createdAt: "2026-08-01T10:00:00.000Z",
updatedAt: "2026-08-01T10:00:00.000Z",
facts: [fact()],
summary: null,
summaryGeneratedAt: null,
summaryState: "NONE",
...overrides,
};
}
const baseProps = {
canManage: true,
aiEnabled: true,
labsModuleEnabled: true,
};
describe("<DocumentFactsSection>", () => {
it("shows the review action for a lab document with pending facts", () => {
const html = render(<DocumentFactsSection doc={doc()} {...baseProps} />);
expect(html).toContain('data-slot="document-facts-review-open"');
expect(html).toContain("Review extracted value");
});
it("offers the stored-text extract for a read lab document with nothing staged", () => {
const html = render(
<DocumentFactsSection
doc={doc({ status: "STORED", facts: [], factCount: 0 })}
{...baseProps}
/>,
);
expect(html).toContain('data-slot="document-facts-extract"');
expect(html).toContain("Extract lab values");
// Honest about the mechanism: stored text, review-first, no auto-save.
expect(html).toContain("nothing is saved automatically");
});
it("withdraws the extract offer once facts were approved", () => {
const html = render(
<DocumentFactsSection
doc={doc({
facts: [fact({ status: "APPROVED", committedRecordId: "lab-1" })],
})}
{...baseProps}
/>,
);
expect(html).not.toContain('data-slot="document-facts-extract"');
expect(html).not.toContain('data-slot="document-facts-review-open"');
});
it("renders nothing for a delegate inside somebody else's record", () => {
const html = render(
<DocumentFactsSection doc={doc()} {...baseProps} canManage={false} />,
);
expect(html).toBe("");
});
it("renders nothing for an unread non-lab document", () => {
const html = render(
<DocumentFactsSection
doc={doc({ kind: "DOCTOR_REPORT", facts: [], factCount: 0 })}
{...baseProps}
/>,
);
expect(html).toBe("");
});
});
@@ -98,12 +98,24 @@ export async function runDocumentAi<T>(opts: {
});
}
/** The index route's result, including the lab-staging continuation. */
export interface DocumentIndexResult {
indexed: boolean;
tokenCount: number;
/**
* How many lab facts the read continued into staging (PENDING, for the
* review step). 0 when the document is not a lab report or staging was not
* eligible. Confirmation stays the only write into Labs.
*/
labFactsStaged: number;
}
/** Populate / refresh one document's content index over the chosen transport. */
export function runDocumentIndex(opts: {
mode: DocumentAiMode;
target: DocumentAiTarget;
}): Promise<{ indexed: boolean; tokenCount: number }> {
return runDocumentAi<{ indexed: boolean; tokenCount: number }>({
}): Promise<DocumentIndexResult> {
return runDocumentAi<DocumentIndexResult>({
path: `/api/documents/inbound/${opts.target.documentId}/index`,
mode: opts.mode,
target: opts.target,
@@ -80,6 +80,7 @@ import {
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { DocumentFactsSection } from "./document-facts-review";
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
@@ -338,7 +339,7 @@ export function DocumentDetailSheet({
/** Whether content indexing is available (from `usage.contentIndex.enabled`). */
contentIndexEnabled?: boolean;
}) {
const { t, locale } = useTranslations();
const { t, tCount, locale } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
// v1.28.52 (Documents R3) — the vault "Ask the Coach" action opens the REAL
@@ -363,9 +364,13 @@ export function DocumentDetailSheet({
// deep-link — see the labs page's own "no per-value relation" comment),
// so this links to the labs list rather than a single biomarker; still a
// real jump where there was none before.
//
// APPROVED only: the link claims values that exist in Labs, and a PENDING
// fact is not there yet — it belongs to the review block above, which is
// the control that turns it into one.
const labFactCount =
doc?.facts.filter(
(fact) => fact.factType === "OBSERVATION" && fact.status !== "REJECTED",
(fact) => fact.factType === "OBSERVATION" && fact.status === "APPROVED",
).length ?? 0;
const { user } = useAuth();
const { canManage } = useRecordCapabilities();
@@ -606,7 +611,15 @@ export function DocumentDetailSheet({
indexDoc.mutate(
{ mode: aiMode, target: aiTarget },
{
onSuccess: () => toast.success(t("documents.ai.readDone")),
// A read that continued into lab staging says so — the staged values
// are waiting in the review block, not silently in a queue. Staged
// facts stay PENDING; confirmation remains the only write into Labs.
onSuccess: (result) =>
toast.success(
result.labFactsStaged > 0
? tCount("documents.ai.readStaged", result.labFactsStaged)
: t("documents.ai.readDone"),
),
onError: (error) => toast.error(aiErrorText(error)),
},
);
@@ -1090,6 +1103,18 @@ export function DocumentDetailSheet({
</div>
) : null}
{/* Staged-facts review + the stored-text extract recovery. The
existing extract/confirm chain finally gets its control on
the document itself: pending facts open the review, a read
lab document with nothing staged offers "Extract lab
values" over its stored text — never a re-upload. */}
<DocumentFactsSection
doc={doc}
canManage={canManage}
aiEnabled={aiEnabled}
labsModuleEnabled={labsModuleEnabled}
/>
{labsModuleEnabled && labFactCount > 0 ? (
<Link
href="/labs"
@@ -0,0 +1,663 @@
"use client";
/**
* Review-then-confirm for a document's staged facts, ON the document detail —
* the UI leg of a server chain that already existed end to end (extract →
* PENDING `ExtractedFact` → per-fact edit → confirm) but had no control on
* this surface: the sheet counted observation facts and offered nothing, so a
* skipped automatic run forced a re-upload through Labs → Scan a report.
*
* Three honest states, all docked onto the existing sheet (no new menu item):
*
* - PENDING facts exist → "Review extracted values (N)" opens the inline
* review: approve/reject per fact, a low-confidence fact is checked and
* saved first (the existing per-fact edit, which clears `needsReview`).
* - No facts yet, but the document is filed as a lab result and has stored
* extracted text → "Extract lab values" runs the SAME text-structuring
* pass the automatic worker uses ({ mode: "stored" }) — reusing the
* stored text, never asking for a re-upload — and opens the review.
* - Neither → nothing renders; the surface stays calm.
*
* HARD RULE: nothing here writes into Labs / conditions / medications on its
* own. The confirm mutation carries the person's explicit decisions and the
* server's confirm route remains the only write path. Unchecked values are
* rejected (stated in the copy); an unverified low-confidence fact gets NO
* decision and stays pending rather than being silently discarded.
*/
import { useState } from "react";
import { FlaskConical, Loader2, ScanSearch } from "lucide-react";
import { toast } from "sonner";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type {
ConditionFactData,
ExtractedFactDto,
InboundDocumentDetailDto,
InboundFactEdit,
MedicationStatementFactData,
ObservationFactData,
} from "@/lib/validations/inbound-documents";
import { useDocumentAiErrorText } from "./use-document-assist";
import {
useConfirmFacts,
useEditFact,
useStoredExtract,
} from "./use-document-facts";
/** Map a per-fact commit refusal to its translated sentence. */
function commitFailureKey(reason: string): string {
switch (reason) {
case "observation.unitRequired":
return "documents.review.failUnitRequired";
case "observation.unitMismatch":
return "documents.review.failUnitMismatch";
default:
return "documents.review.failGeneric";
}
}
/** The headline line of a fact (what the document stated). */
function factHeadline(fact: ExtractedFactDto): string {
if (fact.factType === "OBSERVATION") {
const data = fact.data as ObservationFactData;
const value =
typeof data.value === "number"
? `${data.value}${data.unit ? ` ${data.unit}` : ""}`
: (data.valueText ?? "");
return value ? `${data.label}: ${value}` : data.label;
}
if (fact.factType === "CONDITION") {
return (fact.data as ConditionFactData).label;
}
const data = fact.data as MedicationStatementFactData;
return data.dose ? `${data.name} · ${data.dose}` : data.name;
}
/** The date the fact states, if any (YYYY-MM-DD). */
function factDate(fact: ExtractedFactDto): string | null {
if (fact.factType === "OBSERVATION") {
return (fact.data as ObservationFactData).effectiveDate;
}
if (fact.factType === "CONDITION") {
return (fact.data as ConditionFactData).onsetDate;
}
return (fact.data as MedicationStatementFactData).effectiveDate;
}
/** Build the full edit payload for a fact from its data + the form draft. */
function buildEdit(
fact: ExtractedFactDto,
draft: VerifyDraft,
): InboundFactEdit | null {
if (fact.factType === "OBSERVATION") {
const data = fact.data as ObservationFactData;
const label = draft.label.trim();
if (!label) return null;
const numeric = draft.value.trim();
const qualitative = draft.valueText.trim();
// Numeric XOR qualitative, mirroring the server schema.
if (numeric && qualitative) return null;
const value = numeric ? Number(numeric.replace(",", ".")) : null;
if (numeric && !Number.isFinite(value)) return null;
return {
factType: "OBSERVATION",
label,
code: data.code,
codeSystem: data.codeSystem,
value,
valueText: qualitative || null,
unit: draft.unit.trim() || null,
referenceLow: data.referenceLow,
referenceHigh: data.referenceHigh,
referenceText: data.referenceText ?? null,
effectiveDate: draft.date || null,
};
}
if (fact.factType === "CONDITION") {
const data = fact.data as ConditionFactData;
const label = draft.label.trim();
if (!label) return null;
return {
factType: "CONDITION",
label,
code: data.code,
codeSystem: data.codeSystem,
clinicalStatus: data.clinicalStatus,
verificationStatus: data.verificationStatus,
onsetDate: draft.date || null,
};
}
const data = fact.data as MedicationStatementFactData;
const name = draft.label.trim();
if (!name) return null;
return {
factType: "MEDICATION_STATEMENT",
name,
dose: draft.unit.trim() || null,
rxNormCode: data.rxNormCode,
atcCode: data.atcCode,
statusStated: data.statusStated,
effectiveDate: draft.date || null,
};
}
interface VerifyDraft {
/** Observation/condition label, or the medication name. */
label: string;
/** Observation numeric value as typed; unused for the other types. */
value: string;
/** Observation qualitative result; unused for the other types. */
valueText: string;
/** Observation unit, or the medication dose. */
unit: string;
/** Effective/onset date (YYYY-MM-DD) or "". */
date: string;
}
function draftFor(fact: ExtractedFactDto): VerifyDraft {
if (fact.factType === "OBSERVATION") {
const data = fact.data as ObservationFactData;
return {
label: data.label,
value: typeof data.value === "number" ? String(data.value) : "",
valueText: data.valueText ?? "",
unit: data.unit ?? "",
date: data.effectiveDate ?? "",
};
}
if (fact.factType === "CONDITION") {
const data = fact.data as ConditionFactData;
return {
label: data.label,
value: "",
valueText: "",
unit: "",
date: data.onsetDate ?? "",
};
}
const data = fact.data as MedicationStatementFactData;
return {
label: data.name,
value: "",
valueText: "",
unit: data.dose ?? "",
date: data.effectiveDate ?? "",
};
}
/** One pending fact: include-toggle, stated values, verify-first when low-confidence. */
function FactRow({
documentId,
fact,
checked,
onCheckedChange,
failure,
}: {
documentId: string;
fact: ExtractedFactDto;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
/** A per-fact commit refusal from the last confirm attempt, or null. */
failure: string | null;
}) {
const { t } = useTranslations();
const format = useFormatters();
const editFact = useEditFact();
const [verifying, setVerifying] = useState(false);
const [draft, setDraft] = useState<VerifyDraft>(() => draftFor(fact));
const [draftInvalid, setDraftInvalid] = useState(false);
const date = factDate(fact);
const typeLabel = t(`documents.review.type.${fact.factType}`);
const isObservation = fact.factType === "OBSERVATION";
const saveVerification = () => {
const edit = buildEdit(fact, draft);
if (!edit) {
setDraftInvalid(true);
return;
}
setDraftInvalid(false);
editFact.mutate(
{ documentId, factId: fact.id, edit },
{
onSuccess: () => {
setVerifying(false);
// The refetched fact arrives with needsReview cleared; include it —
// the person just asserted these values.
onCheckedChange(true);
},
},
);
};
return (
<li
data-slot="document-fact-row"
className="border-border space-y-2 rounded-lg border p-3"
>
<div className="flex items-start gap-3">
<Checkbox
checked={checked}
onCheckedChange={(state) => onCheckedChange(state === true)}
disabled={fact.needsReview}
aria-label={t("documents.review.includeAria", {
value: factHeadline(fact),
})}
className="mt-0.5"
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium break-words">
{factHeadline(fact)}
</p>
<p className="text-muted-foreground text-xs">
{typeLabel}
{date ? ` · ${format.date(`${date}T12:00:00.000Z`)}` : ""}
{isObservation &&
(fact.data as ObservationFactData).referenceText ? (
<>
{" · "}
{t("documents.review.statedRange", {
range: (fact.data as ObservationFactData)
.referenceText as string,
})}
</>
) : null}
</p>
{fact.provenance.anchored && fact.provenance.sourceText ? (
<p className="text-muted-foreground text-xs break-words italic">
“{fact.provenance.sourceText}”
</p>
) : null}
{failure ? (
<p role="alert" className="text-destructive text-xs">
{t(commitFailureKey(failure))}
</p>
) : null}
</div>
{fact.needsReview && !verifying ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-8"
onClick={() => setVerifying(true)}
>
{t("documents.review.verify")}
</Button>
) : null}
</div>
{fact.needsReview && !verifying ? (
<p className="text-warning text-xs">
{t("documents.review.needsCheck")}
</p>
) : null}
{verifying ? (
<div className="space-y-3" data-slot="document-fact-verify">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`fact-label-${fact.id}`}>
{t("documents.review.fieldName")}
</Label>
<Input
id={`fact-label-${fact.id}`}
value={draft.label}
onChange={(e) =>
setDraft((d) => ({ ...d, label: e.target.value }))
}
maxLength={fact.factType === "MEDICATION_STATEMENT" ? 200 : 300}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`fact-date-${fact.id}`}>
{t("documents.review.fieldDate")}
</Label>
<DateField
id={`fact-date-${fact.id}`}
value={draft.date}
onChange={(value) => setDraft((d) => ({ ...d, date: value }))}
aria-label={t("documents.review.fieldDate")}
/>
</div>
{isObservation ? (
<>
<div className="space-y-1.5">
<Label htmlFor={`fact-value-${fact.id}`}>
{t("documents.review.fieldValue")}
</Label>
<Input
id={`fact-value-${fact.id}`}
inputMode="decimal"
value={draft.value}
onChange={(e) =>
setDraft((d) => ({
...d,
value: e.target.value,
// Numeric XOR qualitative — typing a number clears text.
valueText: e.target.value ? "" : d.valueText,
}))
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`fact-value-text-${fact.id}`}>
{t("documents.review.fieldValueText")}
</Label>
<Input
id={`fact-value-text-${fact.id}`}
value={draft.valueText}
onChange={(e) =>
setDraft((d) => ({
...d,
valueText: e.target.value,
value: e.target.value ? "" : d.value,
}))
}
maxLength={300}
/>
</div>
</>
) : null}
{isObservation || fact.factType === "MEDICATION_STATEMENT" ? (
<div className="space-y-1.5">
<Label htmlFor={`fact-unit-${fact.id}`}>
{isObservation
? t("documents.review.fieldUnit")
: t("documents.review.fieldDose")}
</Label>
<Input
id={`fact-unit-${fact.id}`}
value={draft.unit}
onChange={(e) =>
setDraft((d) => ({ ...d, unit: e.target.value }))
}
maxLength={isObservation ? 80 : 120}
/>
</div>
) : null}
</div>
{draftInvalid ? (
<p role="alert" className="text-destructive text-xs">
{t("documents.review.verifyInvalid")}
</p>
) : null}
{editFact.isError ? (
<p role="alert" className="text-destructive text-xs">
{t("documents.review.verifyFailed")}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-8"
onClick={() => {
setVerifying(false);
setDraftInvalid(false);
setDraft(draftFor(fact));
}}
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-8"
onClick={saveVerification}
disabled={editFact.isPending}
>
{editFact.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("documents.review.verifySave")}
</Button>
</div>
</div>
) : null}
</li>
);
}
/**
* The staged-facts block of the document detail. Renders the review (pending
* facts), the stored-text extract action (lab document, nothing staged yet),
* or nothing. Owner-only: staging and confirming write to the caller's own
* record, so a delegate never sees these controls.
*/
export function DocumentFactsSection({
doc,
canManage,
aiEnabled,
labsModuleEnabled,
}: {
doc: InboundDocumentDetailDto;
canManage: boolean;
aiEnabled: boolean;
labsModuleEnabled: boolean;
}) {
const { t, tCount } = useTranslations();
const storedExtract = useStoredExtract();
const confirmFacts = useConfirmFacts();
const aiErrorText = useDocumentAiErrorText();
const [open, setOpen] = useState(false);
// Approve-set as explicit state, seeded per fact on first sight: a
// confident fact starts included, a low-confidence one starts excluded
// until it is verified (which checks it).
const [included, setIncluded] = useState<Record<string, boolean>>({});
const [failures, setFailures] = useState<Record<string, string>>({});
const pending = doc.facts.filter((fact) => fact.status === "PENDING");
const approvedCount = doc.facts.filter(
(fact) => fact.status === "APPROVED",
).length;
if (!canManage) return null;
const isChecked = (fact: ExtractedFactDto): boolean =>
included[fact.id] ?? !fact.needsReview;
const setChecked = (factId: string, checked: boolean) =>
setIncluded((prev) => ({ ...prev, [factId]: checked }));
const finishReview = () => {
// A still-unverified low-confidence fact gets NO decision — it stays
// pending instead of being silently discarded.
const decidable = pending.filter((fact) => !fact.needsReview);
if (decidable.length === 0) return;
const decisions = decidable.map((fact) => ({
factId: fact.id,
action: isChecked(fact) ? ("approve" as const) : ("reject" as const),
}));
confirmFacts.mutate(
{ documentId: doc.id, decisions },
{
onSuccess: (result) => {
setFailures(
Object.fromEntries(
result.failed.map((entry) => [entry.factId, entry.reason]),
),
);
// The outcome module owns the affordance: green only for a run
// that actually wrote, neutral for an all-rejected review (an
// honest non-event), a warning when some values were refused.
if (result.approved.length > 0) {
toastWrittenOutcome(
result.failed.length > 0 ? "partial" : "success",
tCount("documents.review.savedToast", result.approved.length),
);
} else if (result.failed.length === 0) {
toastWrittenOutcome(
"empty",
t("documents.review.nothingSavedToast"),
);
}
if (result.failed.length === 0) setOpen(false);
},
},
);
};
// The stored-text extract offer: a lab-filed, already-read document with
// nothing staged and nothing confirmed yet. `alreadyPartlyConfirmed` is the
// server's refusal once anything is APPROVED, so the offer withdraws then.
const canExtract =
labsModuleEnabled &&
aiEnabled &&
doc.kind === "LAB_RESULT" &&
doc.hasContentIndex &&
doc.status !== "CONFIRMED" &&
pending.length === 0 &&
approvedCount === 0;
if (pending.length === 0 && !canExtract) return null;
const unverified = pending.filter((fact) => fact.needsReview).length;
return (
<div className="space-y-3" data-slot="document-facts-section">
{pending.length > 0 ? (
<>
{!open ? (
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-facts-review-open"
className="min-h-11 sm:min-h-9"
onClick={() => setOpen(true)}
>
<FlaskConical className="size-4" aria-hidden />
{tCount("documents.review.open", pending.length)}
</Button>
) : (
<div
data-slot="document-facts-review"
className="border-border bg-muted/30 space-y-3 rounded-lg border p-3 md:p-4"
>
<div className="space-y-1">
<p className="text-sm font-medium">
{t("documents.review.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.review.hint")}
</p>
</div>
<ul className="space-y-2">
{pending.map((fact) => (
<FactRow
key={fact.id}
documentId={doc.id}
fact={fact}
checked={isChecked(fact)}
onCheckedChange={(checked) => setChecked(fact.id, checked)}
failure={failures[fact.id] ?? null}
/>
))}
</ul>
{unverified > 0 ? (
<p className="text-muted-foreground text-xs">
{tCount("documents.review.unverifiedNote", unverified)}
</p>
) : null}
{confirmFacts.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.review.confirmFailed")}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-8"
onClick={() => setOpen(false)}
>
{t("common.close")}
</Button>
<Button
type="button"
size="sm"
data-slot="document-facts-confirm"
className="min-h-11 sm:min-h-8"
onClick={finishReview}
disabled={
confirmFacts.isPending ||
pending.every((fact) => fact.needsReview)
}
>
{confirmFacts.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("documents.review.finish")}
</Button>
</div>
</div>
)}
</>
) : (
<div className="space-y-1.5">
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-facts-extract"
className="min-h-11 sm:min-h-9"
onClick={() =>
storedExtract.mutate(
{ documentId: doc.id },
{
onSuccess: (detail) => {
setFailures({});
setIncluded({});
if (
detail.facts.some((fact) => fact.status === "PENDING")
) {
setOpen(true);
} else {
toast.info(t("documents.review.extractEmpty"));
}
},
onError: (error) => toast.error(aiErrorText(error)),
},
)
}
disabled={storedExtract.isPending}
>
<ScanSearch
className={cn(
"size-4",
storedExtract.isPending &&
"animate-pulse motion-reduce:animate-none",
)}
aria-hidden
/>
{storedExtract.isPending
? t("documents.review.extracting")
: t("documents.review.extract")}
</Button>
<p className="text-muted-foreground text-xs">
{t("documents.review.extractHint")}
</p>
</div>
)}
</div>
);
}
@@ -23,13 +23,14 @@ import {
runDocumentIndex,
type DocumentAiMode,
type DocumentAiTarget,
type DocumentIndexResult,
} from "./document-ai-transport";
/** Index / re-index one document for content search. */
export function useIndexDocument() {
const queryClient = useQueryClient();
return useMutation<
{ indexed: boolean; tokenCount: number },
DocumentIndexResult,
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
>({
@@ -198,6 +198,8 @@ export function documentAiErrorKey(err: unknown): string {
return "documents.assist.errorLocalOcrDisabled";
case "documents.inbound.providerUnsupported":
return "documents.assist.errorProvider";
case "documents.inbound.notIndexed":
return "documents.assist.errorNotIndexed";
default:
return "documents.assist.errorGeneric";
}
@@ -0,0 +1,113 @@
"use client";
/**
* Client hooks for the review-then-confirm chain on a stored document's
* staged facts — the missing UI leg of the server chain that already existed:
*
* extract (`POST …/extract`, incl. `{ mode: "stored" }` over the document's
* own indexed text) → PENDING `ExtractedFact` rows → optional per-fact edit
* (`PATCH …/facts/{factId}`, clears `needsReview`) → confirm
* (`POST …/confirm`) — the ONLY write into the structured stores.
*
* Nothing here commits on its own. The confirm mutation carries the explicit
* approve/reject decisions the person made; a fact without a decision stays
* pending on the server.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPatch, apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type {
ExtractedFactDto,
InboundDocumentDetailDto,
InboundFactEdit,
} from "@/lib/validations/inbound-documents";
/** The confirm route's per-batch outcome (per-fact misses never fail it). */
export interface ConfirmFactsResult {
approved: { factId: string; recordType: string; recordId: string }[];
rejected: string[];
needsReview: string[];
failed: { factId: string; reason: string }[];
}
/**
* 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.
*/
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) },
),
onSuccess: (_data, { documentId }) => {
void invalidateKeys(queryClient, [
queryKeys.inboundDocument(documentId),
queryKeys.documents(),
]);
},
});
}
/**
* Correct a staged fact before approval. A successful edit clears
* `needsReview` server-side — the values become user-asserted and the fact
* turns approvable. The fact's resource type can never change.
*/
export function useEditFact() {
const queryClient = useQueryClient();
return useMutation<
ExtractedFactDto,
Error,
{ documentId: string; factId: string; edit: InboundFactEdit }
>({
mutationFn: ({ documentId, factId, edit }) =>
apiPatch<ExtractedFactDto>(
`/api/documents/inbound/${documentId}/facts/${factId}`,
edit,
),
onSuccess: (_data, { documentId }) => {
void invalidateKeys(queryClient, [queryKeys.inboundDocument(documentId)]);
},
});
}
/**
* Commit the review decisions. Approved facts land in their structured
* stores (labs / conditions / medications) through the server's existing
* field-by-field commit; rejected facts are discarded. Invalidates every
* read the commit can touch, plus the document itself (status + fact states).
*/
export function useConfirmFacts() {
const queryClient = useQueryClient();
return useMutation<
ConfirmFactsResult,
Error,
{
documentId: string;
decisions: { factId: string; action: "approve" | "reject" }[];
}
>({
mutationFn: ({ documentId, decisions }) =>
apiPost<ConfirmFactsResult>(
`/api/documents/inbound/${documentId}/confirm`,
{ decisions },
),
onSuccess: (_data, { documentId }) => {
void invalidateKeys(queryClient, [
queryKeys.inboundDocument(documentId),
queryKeys.documents(),
queryKeys.labResults(),
queryKeys.biomarkers(),
queryKeys.illness(),
queryKeys.medications(),
]);
},
});
}