Files
HealthLog/src/components/documents/use-document-assist.ts
T
Marc-André Bombeck fd104dd0ac 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.
2026-08-13 00:33:14 +02:00

209 lines
7.3 KiB
TypeScript

"use client";
/**
* v1.27.22 (Document vault P2) — client hooks for the review-first AI layer on
* a stored document: the shared capability probe, filing-metadata suggestions,
* and the session-only summary / extracted text.
*
* Every affordance is gated on `usage.assistAvailable` at the call site; these
* hooks only RUN the call once the surface has decided to offer it. The
* transport (VISION vs local-OCR TEXT) comes from `document-ai-transport`.
*
* NOTHING here writes a document row. Suggestions are drafts the detail sheet
* prefills; the summary is transient. The only mutation is the caller applying
* a reviewed draft through the existing edit-on-commit machinery.
*/
import { useCallback } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiGet, ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type {
DocumentAiCapabilityDto,
DocumentSuggestionDto,
DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import {
DocumentAssistClientError,
runDocumentAi,
type DocumentAiMode,
type DocumentAiTarget,
} from "./document-ai-transport";
/** The describe result: raw text, or a summary plus its storage outcome. */
export type DocumentDescribeResult =
| {
summary: string;
persistence?: "stored" | "withheld" | "failed";
}
| { text: string };
/**
* The document-scoped AI capability probe (`/api/documents/inbound/capability`).
* Resolved over the DOCUMENT provider order (local-first, codex last), so the
* transport `mode` + PDF support match what the document routes do, and it
* carries the vendor-blind `egress` class the detail sheet uses to warn before a
* read leaves the machine. Availability of the affordance itself is still gated
* on `usage.assistAvailable`.
*/
export function useDocumentAiCapability(enabled: boolean) {
return useQuery<DocumentAiCapabilityDto>({
queryKey: queryKeys.inboundDocumentAiCapability(),
queryFn: () =>
apiGet<DocumentAiCapabilityDto>("/api/documents/inbound/capability"),
enabled,
staleTime: 60_000,
});
}
/** The per-user "read documents automatically with AI" opt-in. */
export interface DocumentsAutoAiReadPref {
documentsAutoAiRead: boolean;
}
/**
* Read the per-user auto-AI-read flag (`GET /api/auth/me/documents-auto-ai-read`).
* Shares the cache key with the AI-settings toggle, so a flip there and this
* read stay in lockstep. When ON, reading happens automatically on upload and
* the detail sheet drops the manual per-document AI action row.
*/
export function useDocumentsAutoAiRead(enabled: boolean) {
return useQuery<DocumentsAutoAiReadPref>({
queryKey: queryKeys.documentsAutoAiRead(),
queryFn: () =>
apiGet<DocumentsAutoAiReadPref>("/api/auth/me/documents-auto-ai-read"),
enabled,
staleTime: 60_000,
});
}
/**
* 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.
*/
export function useSuggestDetails() {
return useMutation<
DocumentSuggestionDto,
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
>({
mutationFn: async ({ mode, target }) => {
const data = await runDocumentAi<{ suggestions: DocumentSuggestionDto }>({
path: `/api/documents/inbound/${target.documentId}/suggest`,
mode,
target,
});
return data.suggestions;
},
});
}
/**
* On-demand summary or extracted text. Both stay transient unless the document
* summary block explicitly requests persistence or replacement.
*/
export function useDocumentSummary() {
return useMutation<
DocumentDescribeResult,
Error,
{
mode: DocumentAiMode;
target: DocumentAiTarget;
output: DocumentSummaryMode;
persist?: boolean;
replace?: boolean;
}
>({
mutationFn: ({ mode, target, output, persist = false, replace = false }) =>
runDocumentAi<DocumentDescribeResult>({
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}${persist ? "&persist=true" : ""}${replace ? "&replace=true" : ""}`,
mode,
target,
}),
});
}
/**
* Map an AI error to a translation key. Server errors carry a stable
* `meta.errorCode`; client-side precondition failures carry a `reason`. The
* default is the calm "try again" message — never a raw provider string.
*/
/**
* The actual wait, in whole minutes, behind a document-AI 429. The routes
* mirror the bucket's reset instant into the error meta (`retryAt`, ISO 8601);
* the window is an hour, so a fixed "try again in a few minutes" understated
* the wait for most of it. Null when the error is not a document-AI rate-limit
* response or carries no usable instant — the caller falls back to the generic
* copy. Floored at one minute: a reset seconds away still reads as "about
* 1 minute", never "0 minutes".
*/
export function documentAiRetryMinutes(err: unknown): number | null {
if (!(err instanceof ApiError)) return null;
if (err.meta?.errorCode !== "documents.inbound.rateLimited") return null;
const raw = err.meta?.retryAt;
if (typeof raw !== "string") return null;
const at = Date.parse(raw);
if (!Number.isFinite(at)) return null;
return Math.max(1, Math.ceil((at - Date.now()) / 60_000));
}
/**
* Resolve a document-AI error to the user-facing sentence. Rate-limited
* responses derive their copy from the ACTUAL reset instant ("try again in
* about N minutes"); everything else maps through `documentAiErrorKey`. The
* result is final text — callers render or toast it as-is.
*/
export function useDocumentAiErrorText(): (err: unknown) => string {
const { t, tCount } = useTranslations();
return useCallback(
(err: unknown) => {
const minutes = documentAiRetryMinutes(err);
if (minutes !== null) {
return tCount("documents.assist.errorRateLimitedWait", minutes);
}
return t(documentAiErrorKey(err));
},
[t, tCount],
);
}
export function documentAiErrorKey(err: unknown): string {
if (err instanceof DocumentAssistClientError) {
switch (err.reason) {
case "textImageOnly":
return "documents.assist.errorTextImageOnly";
case "ocr":
return "documents.assist.errorOcr";
default:
return "documents.assist.errorGeneric";
}
}
if (err instanceof ApiError) {
const code =
typeof err.meta?.errorCode === "string" ? err.meta.errorCode : "";
switch (code) {
case "documents.inbound.rateLimited":
return "documents.assist.errorRateLimited";
case "documents.inbound.budgetExceeded":
return "documents.assist.errorBudget";
case "documents.inbound.pdfNeedsAnthropic":
return "documents.assist.errorPdfNeedsVision";
case "documents.inbound.fileType":
return "documents.assist.errorFileType";
case "documents.inbound.localOcrDisabled":
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";
}
}
return "documents.assist.errorGeneric";
}