From 73de8f6a528ee6f79dcdd6730a50b72516b67428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 00:01:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(documents):=20bidirectional=20labs=20?= =?UTF-8?q?=E2=86=94=20documents=20cross-link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two directions, folding the vault and labs pipelines into one linking path without weakening either manual flow. Vault document → lab values (auto-stage). After the per-document auto-index job transcribes a stored document, a lab-looking document is run through the same inbound extraction the manual "Extract" button uses, but the facts land PENDING for the existing human review — nothing is committed. Gated on both the documents and labs modules plus the auto-AI-read consent gate the auto-index external path already uses; a text-structuring pass over the transcription the index just produced, so no second vision call is spent. The staging is a guarded STORED→EXTRACTED transition, and a keyword heuristic (or an explicit LAB_RESULT kind) keeps an arbitrary photo from burning a call. Everyone without the opt-in keeps the manual extract button. Lab scan → vault. A lab report scanned in the labs area is now also filed into the Documents vault (kind LAB_RESULT) through the existing upload — encrypted at rest, EXIF-stripped, thumbnailed, sha256-deduped — and the committed labs are cross-linked back to it with the same APPROVED-observation provenance the manual vault → labs path produces. Text-mode scans keep the image on-device and file nothing. Idempotent: a re-commit inserts no rows and links nothing new, and the vault dedupe means no duplicate document. Owner-scoped and module-gated on both sides; the cross-link is best-effort so it never fails the authoritative write. --- docs/api/openapi.yaml | 4 + src/app/api/labs/ocr/commit/route.ts | 29 +++ src/components/labs/ocr-review-dialog.tsx | 28 ++- src/components/labs/use-ocr-extract.ts | 48 +++- .../__tests__/auto-stage-labs.test.ts | 217 ++++++++++++++++++ src/lib/documents/auto-stage-labs.ts | 215 +++++++++++++++++ src/lib/jobs/document-index.ts | 10 + src/lib/labs/__tests__/vault-link.test.ts | 124 ++++++++++ src/lib/labs/vault-link.ts | 120 ++++++++++ src/lib/validations/labs-ocr.ts | 8 + 10 files changed, 790 insertions(+), 13 deletions(-) create mode 100644 src/lib/documents/__tests__/auto-stage-labs.test.ts create mode 100644 src/lib/documents/auto-stage-labs.ts create mode 100644 src/lib/labs/__tests__/vault-link.test.ts create mode 100644 src/lib/labs/vault-link.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 021da0b27..8c8b32d4a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8029,6 +8029,10 @@ paths: required: - analyte - takenAt + documentId: + type: string + minLength: 1 + maxLength: 64 required: - rows responses: diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 1607186c0..50a3dae50 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -26,6 +26,10 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; import { serialiseLabResult } from "@/lib/labs/serialise"; +import { + linkOcrLabsToVaultDocument, + type InsertedLabForLink, +} from "@/lib/labs/vault-link"; import { annotate } from "@/lib/logging/context"; import { ocrCommitSchema, @@ -121,6 +125,9 @@ async function commitOcrRows(request: NextRequest) { const inserted: ReturnType[] = []; const skipped: OcrSkippedRowDto[] = []; + // S9 — the rows actually written, kept for the vault cross-link so a re-commit + // (every row a duplicate → nothing inserted) links nothing new. + const linkable: InsertedLabForLink[] = []; // Tracks the keys already written in THIS request so an in-document duplicate // is caught even before the prior row is visible to a live query. The // mint-then-create on a mid-row failure can leave an orphan biomarker, which @@ -165,6 +172,28 @@ async function commitOcrRows(request: NextRequest) { writtenInBatch.add(key); inserted.push(serialiseLabResult(created, biomarker)); + linkable.push({ + labResultId: created.id, + analyte: created.analyte, + value: created.value, + valueText: created.valueText, + unit: created.unit, + referenceLow: created.referenceLow, + referenceHigh: created.referenceHigh, + takenAt: created.takenAt, + }); + } + + // S9 — cross-link the freshly inserted labs to the vault document the client + // filed the scanned bytes into. Best-effort: the labs are the authoritative + // write, so a link failure (module off, foreign / missing document) never + // fails the commit. Owner + module checks live inside the linker. + if (parsed.data.documentId && linkable.length > 0) { + await linkOcrLabsToVaultDocument( + user.id, + parsed.data.documentId, + linkable, + ).catch(() => {}); } await auditLog("labs.ocr.commit", { diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index 842bd959f..bae6b6961 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -108,6 +108,10 @@ export function OcrReviewDialog({ const inputRef = useRef(null); const [stage, setStage] = useState("pick"); const [rows, setRows] = useState([]); + // S9 — in vision mode the picked file is retained so, on commit, it can be + // filed into the Documents vault and cross-linked to the committed labs. Text + // mode keeps the image on-device, so nothing is retained there. + const [pickedFile, setPickedFile] = useState(null); // Text mode OCR's the image in the browser then POSTs the text; vision mode // uploads the image. Both resolve with the same proposed-rows DTO. @@ -126,6 +130,7 @@ export function OcrReviewDialog({ function reset() { setStage("pick"); setRows([]); + setPickedFile(null); extract.reset(); commit.reset(); } @@ -136,6 +141,8 @@ export function OcrReviewDialog({ } function onFilePicked(file: File) { + // Retain the source only for vision mode (text mode never sends the image). + setPickedFile(mode === "vision" ? file : null); extract.mutate(file, { onSuccess: (data) => { const seeded = seedReviewRows(data.rows, data.reportDate); @@ -159,16 +166,19 @@ export function OcrReviewDialog({ toast.error(t("labs.ocr.nothingToSave")); return; } - commit.mutate(payload, { - onSuccess: (result) => { - toast.success( - t("labs.ocr.savedToast", { count: result.inserted.length }), - ); - onCommitted(); - handleClose(false); + commit.mutate( + { rows: payload, file: pickedFile }, + { + onSuccess: (result) => { + toast.success( + t("labs.ocr.savedToast", { count: result.inserted.length }), + ); + onCommitted(); + handleClose(false); + }, + onError: () => toast.error(t("labs.ocr.saveFailed")), }, - onError: () => toast.error(t("labs.ocr.saveFailed")), - }); + ); } const busy = extract.isPending || commit.isPending; diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts index 60cd70c9a..8f05ebcb1 100644 --- a/src/components/labs/use-ocr-extract.ts +++ b/src/components/labs/use-ocr-extract.ts @@ -115,15 +115,55 @@ export function useUpdateLabsLocalOcr() { }); } +/** The commit payload: the confirmed rows plus, in vision mode, the source file. */ +export interface OcrCommitInput { + rows: OcrCommitRowInput[]; + /** + * S9 — the scanned file, threaded from the vision-mode extract. When present + * (and the documents module is on) it is filed into the Documents vault and + * the committed labs are cross-linked to it. Absent in text mode, where the + * image stays on-device. + */ + file?: File | null; +} + +/** + * S9 — file the scanned bytes into the Documents vault (kind LAB_RESULT) via the + * existing upload endpoint (encrypted at rest, EXIF-stripped, thumbnailed, + * sha256-deduped). Best-effort: a module-off account 403s and a re-scan dedupes, + * so any failure resolves to `undefined` and the commit proceeds unlinked. + */ +async function fileScanToVault(file: File): Promise { + const form = new FormData(); + form.append("file", file); + form.append("kind", "LAB_RESULT"); + const doc = await apiFetch<{ id: string }>("/api/documents/inbound", { + method: "POST", + body: form, + }); + return doc?.id; +} + /** Commit the confirmed rows and invalidate the dependent reads. */ export function useOcrCommit() { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (rows: OcrCommitRowInput[]) => - apiPost("/api/labs/ocr/commit", { rows }), - onSuccess: () => { + return useMutation({ + mutationFn: async ({ rows, file }: OcrCommitInput) => { + const documentId = file + ? await fileScanToVault(file).catch(() => undefined) + : undefined; + return apiPost("/api/labs/ocr/commit", { + rows, + ...(documentId ? { documentId } : {}), + }); + }, + onSuccess: (_result, { file }) => { queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + // A scan filed into the vault adds a document — refresh its lists. + if (file) { + queryClient.invalidateQueries({ queryKey: queryKeys.documents() }); + } }, }); } diff --git a/src/lib/documents/__tests__/auto-stage-labs.test.ts b/src/lib/documents/__tests__/auto-stage-labs.test.ts new file mode 100644 index 000000000..566fcc826 --- /dev/null +++ b/src/lib/documents/__tests__/auto-stage-labs.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * S8 — auto-stage lab facts after the auto-index job. Pins Marc's sign-off + * (auto-STAGE, never auto-COMMIT), the double module gate, the reuse of the + * auto-index consent gate, the "looks like a lab" heuristic, idempotency (a + * non-STORED or already-staged document is left alone), and the guarded + * STORED→EXTRACTED transition that makes the S9 cross-link race-safe. No + * LabResult is ever written here — the facts land PENDING for human review. + */ + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(), +})); +vi.mock("@/lib/documents/index-document", () => ({ + resolveIndexProvider: vi.fn(), +})); +vi.mock("@/lib/documents/content-index", () => ({ + loadDocumentChatText: vi.fn(), +})); +vi.mock("@/lib/documents/extract", async () => { + const actual = await vi.importActual< + typeof import("@/lib/documents/extract") + >("@/lib/documents/extract"); + return { ...actual, runInboundExtraction: vi.fn() }; +}); +vi.mock("@/lib/documents/store", () => ({ + encryptFactData: vi.fn(() => new Uint8Array([1])), + encryptFactProvenance: vi.fn(() => new Uint8Array([2])), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-07-16"), + reserveBudget: vi.fn(), + reconcileSpend: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/ai/ai-budgets", () => ({ + AI_BUDGETS: { ocrExtractText: { temperature: 0, maxTokens: 2000 } }, +})); + +const updateMany = vi.fn(); +const createMany = vi.fn(); +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { + findFirst: (...a: unknown[]) => findFirst(...a), + }, + $transaction: (fn: (tx: unknown) => unknown) => + fn({ + inboundDocument: { updateMany: (...a: unknown[]) => updateMany(...a) }, + extractedFact: { createMany: (...a: unknown[]) => createMany(...a) }, + }), + }, +})); + +import { + maybeAutoStageLabFacts, + looksLikeLabDocument, +} from "../auto-stage-labs"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { resolveIndexProvider } from "@/lib/documents/index-document"; +import { loadDocumentChatText } from "@/lib/documents/content-index"; +import { runInboundExtraction } from "@/lib/documents/extract"; +import { reserveBudget } from "@/lib/ai/coach/budget"; + +const mockModule = vi.mocked(isModuleEnabled); +const mockProvider = vi.mocked(resolveIndexProvider); +const mockText = vi.mocked(loadDocumentChatText); +const mockExtract = vi.mocked(runInboundExtraction); +const mockReserve = vi.mocked(reserveBudget); + +const LAB_TEXT = + "Laborbefund. Hämoglobin 14.2 g/dl. Referenzbereich 13-17. LDL 120 mg/dl."; + +function eligibleProvider() { + return { + chain: [], + pick: { + providerType: "anthropic", + entry: { providerType: "anthropic", instance: {} }, + pdfSupported: true, + }, + consentOk: true, + dailyCap: 1000, + } as unknown as Awaited>; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockModule.mockResolvedValue(true); + findFirst.mockResolvedValue({ + kind: "OTHER", + status: "STORED", + _count: { facts: 0 }, + }); + mockProvider.mockResolvedValue(eligibleProvider()); + mockText.mockResolvedValue({ text: LAB_TEXT, source: "verbatim" }); + mockReserve.mockResolvedValue({ + allowed: true, + reserved: 10, + totalAfter: 10, + } as Awaited>); + mockExtract.mockResolvedValue({ + reportDate: "2026-07-10", + kind: "OTHER", + providerType: "anthropic", + facts: [ + { + factType: "OBSERVATION", + confidence: 0.9, + needsReview: false, + data: { + label: "Hämoglobin", + code: null, + codeSystem: null, + value: 14.2, + valueText: null, + unit: "g/dl", + referenceLow: 13, + referenceHigh: 17, + effectiveDate: "2026-07-10", + }, + provenance: { sourceText: "Hämoglobin 14.2", page: 0, confidence: 0.9 }, + }, + ], + }); + updateMany.mockResolvedValue({ count: 1 }); + createMany.mockResolvedValue({ count: 1 }); +}); + +describe("looksLikeLabDocument", () => { + it("matches a lab report with ≥2 signals", () => { + expect(looksLikeLabDocument(LAB_TEXT)).toBe(true); + }); + it("rejects prose with at most one incidental signal", () => { + expect( + looksLikeLabDocument("Dear patient, please take 500 mg twice daily."), + ).toBe(false); + }); +}); + +describe("maybeAutoStageLabFacts", () => { + it("stages facts PENDING and never commits when gated in", async () => { + const res = await maybeAutoStageLabFacts("u1", "d1"); + expect(res).toEqual({ staged: true, facts: 1 }); + // Guarded STORED→EXTRACTED, then PENDING facts — no commit path touched. + expect(updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: "STORED" }), + data: expect.objectContaining({ status: "EXTRACTED" }), + }), + ); + const created = createMany.mock.calls[0]![0] as { + data: { status: string }[]; + }; + expect(created.data.every((f) => f.status === "PENDING")).toBe(true); + }); + + it("skips (manual path only) when a module is off", async () => { + mockModule.mockImplementation(async (_u, key) => key !== "labs"); + const res = await maybeAutoStageLabFacts("u1", "d1"); + expect(res).toEqual({ staged: false, reason: "modules-off" }); + expect(mockExtract).not.toHaveBeenCalled(); + }); + + it("skips when the auto-read consent gate is not eligible", async () => { + mockProvider.mockResolvedValue({ + ...eligibleProvider(), + consentOk: false, + } as Awaited>); + const res = await maybeAutoStageLabFacts("u1", "d1"); + expect(res).toEqual({ staged: false, reason: "not-eligible" }); + expect(mockExtract).not.toHaveBeenCalled(); + }); + + it("skips a non-lab document (no extraction call spent)", async () => { + mockText.mockResolvedValue({ + text: "A short personal note with no clinical content.", + source: "verbatim", + }); + const res = await maybeAutoStageLabFacts("u1", "d1"); + expect(res).toEqual({ staged: false, reason: "not-lab" }); + expect(mockExtract).not.toHaveBeenCalled(); + }); + + it("auto-stages a LAB_RESULT-kind document even without keyword signals", async () => { + findFirst.mockResolvedValue({ + kind: "LAB_RESULT", + status: "STORED", + _count: { facts: 0 }, + }); + mockText.mockResolvedValue({ + text: "opaque scan text", + source: "verbatim", + }); + const res = await maybeAutoStageLabFacts("u1", "d1"); + expect(res).toEqual({ staged: true, facts: 1 }); + }); + + it("is idempotent — a non-STORED / already-staged document is left alone", async () => { + findFirst.mockResolvedValue({ + kind: "OTHER", + status: "EXTRACTED", + _count: { facts: 1 }, + }); + const res = await maybeAutoStageLabFacts("u1", "d1"); + expect(res).toEqual({ staged: false, reason: "already-handled" }); + expect(mockExtract).not.toHaveBeenCalled(); + }); + + it("aborts without touching facts when another writer moved the row (race)", async () => { + updateMany.mockResolvedValue({ count: 0 }); + const res = await maybeAutoStageLabFacts("u1", "d1"); + expect(res).toEqual({ staged: false, reason: "raced" }); + expect(createMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts new file mode 100644 index 000000000..1c815b7d7 --- /dev/null +++ b/src/lib/documents/auto-stage-labs.ts @@ -0,0 +1,215 @@ +/** + * S8 — auto-stage lab facts from a freshly indexed vault document. + * + * After the per-document auto-index job transcribes a stored document, a + * document that LOOKS LIKE a lab result is run through the SAME inbound + * extraction the manual "Extract" button uses — but the facts land PENDING for + * the existing human-review screen. Nothing is committed here: the user still + * taps one confirm before a value becomes a `LabResult`. Marc's sign-off is + * explicit — auto-STAGE, never auto-COMMIT. + * + * Gates (ALL must hold, otherwise the manual extract button is left untouched): + * - both the `inboundDocuments` AND `labs` modules are on for the user; + * - a usable document-order provider whose egress the `documentsAutoAiRead` + * consent gate permits — reuses `resolveIndexProvider`, so a local pick is + * always eligible and an external pick needs the opt-in, IDENTICAL to the + * auto-index external-egress gate; + * - the document is still STORED with no staged facts — so a re-index, the + * manual path, or the labs→vault cross-link (S9) never double-stages; + * - the transcribed text reads like a lab report (a cheap keyword heuristic, + * or the user filed it under kind LAB_RESULT), so an arbitrary photo never + * burns an extraction call. + * + * It reuses the transcription the auto-index already produced (the encrypted + * content index) and runs the extraction in TEXT mode, so no second vision call + * is spent. The staging transition is a guarded `STORED → EXTRACTED` update: + * if the labs-filing cross-link (S9) or the manual path moved the document + * first, the guard matches zero rows and the auto-stage aborts without touching + * any fact — the two writers can never corrupt each other's facts. + */ +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { + buildDateKey, + reconcileSpend, + reserveBudget, +} from "@/lib/ai/coach/budget"; +import { loadDocumentChatText } from "@/lib/documents/content-index"; +import { resolveIndexProvider } from "@/lib/documents/index-document"; +import { + InboundExtractError, + runInboundExtraction, + type InboundExtractionResult, +} from "@/lib/documents/extract"; +import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; + +/** + * Lab-report signals used to decide whether a transcribed document is worth an + * extraction call. Each pattern is one class of evidence (a reference-range + * label, a lab unit, a common analyte name, a report header); requiring TWO + * distinct hits keeps a stray "mg" in a prose letter from tripping the auto + * extraction while still catching a real panel in German or English. + */ +const LAB_SIGNALS: readonly RegExp[] = [ + /reference range|referenzbereich|normbereich|normal range|ref\.?-?bereich/i, + /\b(mg\/dl|mmol\/l|nmol\/l|µmol\/l|umol\/l|g\/dl|µg\/l|ug\/l|ng\/ml|pg\/ml|mmol\/mol|u\/l|iu\/l|mg\/l|\/µl|\/ul)\b/i, + /h(?:ä|ae)moglobin|hemoglobin|glu[ck]ose|cholesterin|cholesterol|kreatinin|creatinine|hba1c|leuko|erythro|thrombo|ferritin|triglycerid|\btsh\b|\bldl\b|\bhdl\b|\bcrp\b|vitamin\s?d/i, + /labor(?:befund|wert)?|laboratory|blutbild|\bbefund\b/i, +]; + +/** True when the transcribed text carries at least two lab-report signals. */ +export function looksLikeLabDocument(text: string): boolean { + let hits = 0; + for (const signal of LAB_SIGNALS) { + if (signal.test(text) && ++hits >= 2) return true; + } + return false; +} + +/** Why an auto-stage attempt did not stage — every branch leaves the manual path intact. */ +export type AutoStageOutcome = + | { staged: true; facts: number } + | { + staged: false; + reason: + | "modules-off" + | "not-eligible" + | "already-handled" + | "no-text" + | "not-lab" + | "budget" + | "extract-failed" + | "raced"; + }; + +/** + * Guarded `STORED → EXTRACTED` transition + PENDING fact creation in one + * transaction. Returns the staged count, or `null` when the document was no + * longer STORED (another writer moved it first) — the caller treats `null` as a + * clean no-op, never an error. + */ +async function stageFactsIfStored( + userId: string, + documentId: string, + result: InboundExtractionResult, +): Promise { + return prisma.$transaction(async (tx) => { + const moved = await tx.inboundDocument.updateMany({ + where: { id: documentId, userId, status: "STORED", deletedAt: null }, + data: { + status: "EXTRACTED", + providerType: result.providerType, + reportDate: result.reportDate + ? new Date(`${result.reportDate}T00:00:00.000Z`) + : null, + }, + }); + if (moved.count === 0) return null; + if (result.facts.length === 0) return 0; + await tx.extractedFact.createMany({ + data: result.facts.map((f) => ({ + documentId, + userId, + factType: f.factType, + status: "PENDING" as const, + confidence: f.confidence, + needsReview: f.needsReview, + dataEncrypted: encryptFactData(f.data), + provenanceEncrypted: encryptFactProvenance(f.provenance), + })), + }); + return result.facts.length; + }); +} + +/** + * Auto-stage lab facts for one freshly indexed document (owner-scoped). Called + * fire-and-forget by the auto-index job after a successful index; every guard + * miss returns a tagged no-op so the manual extract button stays the fallback. + */ +export async function maybeAutoStageLabFacts( + userId: string, + documentId: string, +): Promise { + const [inboundOn, labsOn] = await Promise.all([ + isModuleEnabled(userId, "inboundDocuments"), + isModuleEnabled(userId, "labs"), + ]); + if (!inboundOn || !labsOn) return { staged: false, reason: "modules-off" }; + + // Idempotency floor: only a still-STORED document with no facts is a + // candidate. A re-index, the manual extract path, or the S9 cross-link all + // leave a non-STORED / already-staged document alone. + const doc = await prisma.inboundDocument.findFirst({ + where: { id: documentId, userId, deletedAt: null }, + select: { kind: true, status: true, _count: { select: { facts: true } } }, + }); + if (!doc || doc.status !== "STORED" || doc._count.facts > 0) { + return { staged: false, reason: "already-handled" }; + } + + // Same provider + consent gate the auto-index external path uses. + const provider = await resolveIndexProvider(userId); + if (!provider.pick || !provider.consentOk) { + return { staged: false, reason: "not-eligible" }; + } + + // Reuse the transcription the auto-index just produced — no second vision call. + const chat = await loadDocumentChatText(userId, documentId); + if (!chat || !chat.text.trim()) return { staged: false, reason: "no-text" }; + + if (doc.kind !== "LAB_RESULT" && !looksLikeLabDocument(chat.text)) { + return { staged: false, reason: "not-lab" }; + } + + // A text-structuring pass, not a vision call — reserve the proportionate + // ceiling under the same daily cap the auto-index resolved. + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + AI_BUDGETS.ocrExtractText.maxTokens, + dateKey, + provider.dailyCap, + ); + if (!reservation.allowed) return { staged: false, reason: "budget" }; + + let result: InboundExtractionResult; + try { + result = await runInboundExtraction({ + provider: provider.pick.entry.instance, + providerType: provider.pick.providerType, + ocrText: chat.text, + }); + await reconcileSpend( + userId, + reservation.reserved, + reservation.reserved, + dateKey, + ); + } catch (err) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (!(err instanceof InboundExtractError)) { + annotate({ + action: { name: "documents.autoStage.failed" }, + meta: { documentId, reason: "provider_error" }, + }); + } + return { staged: false, reason: "extract-failed" }; + } + + const staged = await stageFactsIfStored(userId, documentId, result); + if (staged === null) return { staged: false, reason: "raced" }; + + annotate({ + action: { name: "documents.autoStage.labs" }, + meta: { + documentId, + facts: staged, + provider: provider.pick.providerType, + byKind: doc.kind === "LAB_RESULT", + }, + }); + return { staged: true, facts: staged }; +} diff --git a/src/lib/jobs/document-index.ts b/src/lib/jobs/document-index.ts index 88f042ff7..d6cbf4b4a 100644 --- a/src/lib/jobs/document-index.ts +++ b/src/lib/jobs/document-index.ts @@ -12,6 +12,7 @@ * The queue MUST be registered in the maintenance registrar * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. */ +import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs"; import { indexDocumentContent } from "@/lib/documents/index-document"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; @@ -44,6 +45,15 @@ export async function runDocumentIndex( ? { documentId, indexed: true, source: outcome.source } : { documentId, indexed: false, reason: outcome.reason }, }); + + // S8 — once the document is indexed, auto-stage lab facts for a lab-looking + // document (both modules on + the auto-AI-read consent gate). Facts land + // PENDING for the existing human review; nothing is committed. Fully gated + + // idempotent inside; a failure here must never fail the index job, so it is + // swallowed to a no-op (the manual extract button remains the fallback). + if (outcome.indexed) { + await maybeAutoStageLabFacts(userId, documentId).catch(() => {}); + } } /** diff --git a/src/lib/labs/__tests__/vault-link.test.ts b/src/lib/labs/__tests__/vault-link.test.ts new file mode 100644 index 000000000..b520c4b61 --- /dev/null +++ b/src/lib/labs/__tests__/vault-link.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * S9 — cross-link OCR-committed labs to their source vault document. Pins the + * module gate + ownership fail-closed (both directions), the APPROVED + * OBSERVATION provenance fact per inserted lab (committedRecordId/Type), the + * supersede-PENDING + mark-CONFIRMED transition, and idempotency (an empty + * insert links nothing). + */ + +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); +vi.mock("@/lib/documents/store", () => ({ + encryptFactData: vi.fn(() => new Uint8Array([1])), + encryptFactProvenance: vi.fn(() => new Uint8Array([2])), +})); + +const findFirst = vi.fn(); +const deleteMany = vi.fn(); +const createMany = vi.fn(); +const update = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { + findFirst: (...a: unknown[]) => findFirst(...a), + }, + $transaction: (fn: (tx: unknown) => unknown) => + fn({ + extractedFact: { + deleteMany: (...a: unknown[]) => deleteMany(...a), + createMany: (...a: unknown[]) => createMany(...a), + }, + inboundDocument: { update: (...a: unknown[]) => update(...a) }, + }), + }, +})); + +import { linkOcrLabsToVaultDocument } from "../vault-link"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { encryptFactData } from "@/lib/documents/store"; + +const mockModule = vi.mocked(isModuleEnabled); +const mockEncrypt = vi.mocked(encryptFactData); + +const LABS = [ + { + labResultId: "lab1", + analyte: "Hämoglobin", + value: 14.2, + valueText: null, + unit: "g/dl", + referenceLow: 13, + referenceHigh: 17, + takenAt: new Date("2026-07-10T12:00:00.000Z"), + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + mockModule.mockResolvedValue(true); + findFirst.mockResolvedValue({ id: "doc1" }); + deleteMany.mockResolvedValue({ count: 0 }); + createMany.mockResolvedValue({ count: 1 }); + update.mockResolvedValue({}); +}); + +describe("linkOcrLabsToVaultDocument", () => { + it("links each inserted lab as an APPROVED observation fact and confirms the doc", async () => { + const res = await linkOcrLabsToVaultDocument("u1", "doc1", LABS); + expect(res).toEqual({ linked: 1 }); + // PENDING cross-fire facts superseded, then APPROVED provenance facts. + expect(deleteMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: "PENDING" }), + }), + ); + const created = createMany.mock.calls[0]![0] as { + data: { + status: string; + factType: string; + committedRecordId: string; + committedRecordType: string; + }[]; + }; + expect(created.data[0]).toMatchObject({ + status: "APPROVED", + factType: "OBSERVATION", + committedRecordId: "lab1", + committedRecordType: "labResult", + }); + // The Observation payload carries the stated value verbatim. + expect(mockEncrypt).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Hämoglobin", + value: 14.2, + unit: "g/dl", + }), + ); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: "CONFIRMED" } }), + ); + }); + + it("links nothing when the documents module is off", async () => { + mockModule.mockResolvedValue(false); + const res = await linkOcrLabsToVaultDocument("u1", "doc1", LABS); + expect(res).toEqual({ linked: 0 }); + expect(findFirst).not.toHaveBeenCalled(); + expect(createMany).not.toHaveBeenCalled(); + }); + + it("fails closed on a foreign / missing document", async () => { + findFirst.mockResolvedValue(null); + const res = await linkOcrLabsToVaultDocument("u1", "doc1", LABS); + expect(res).toEqual({ linked: 0 }); + expect(createMany).not.toHaveBeenCalled(); + }); + + it("is a no-op with no inserted labs (idempotent re-commit)", async () => { + const res = await linkOcrLabsToVaultDocument("u1", "doc1", []); + expect(res).toEqual({ linked: 0 }); + expect(mockModule).not.toHaveBeenCalled(); + expect(createMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/labs/vault-link.ts b/src/lib/labs/vault-link.ts new file mode 100644 index 000000000..9d6df818c --- /dev/null +++ b/src/lib/labs/vault-link.ts @@ -0,0 +1,120 @@ +/** + * S9 — link labs committed from an OCR scan back to the vault document filed + * for that scan. + * + * When a user scans a lab report in the labs area with the `inboundDocuments` + * module on, the client files the uploaded bytes into the Documents vault + * (through the existing `POST /api/documents/inbound` upload — encrypted at + * rest, EXIF-stripped, thumbnailed, sha256-deduped) and threads the resulting + * document id to the OCR commit. This function makes the cross-reference: each + * committed `LabResult` gets an APPROVED `ExtractedFact` whose + * `committedRecordId` / `committedRecordType` point at it — the SAME provenance + * artefact the manual vault → labs path produces, so the two directions + * converge on one representation (a document with confirmed observation facts). + * + * Owner-scoped and module-gated: a document that is not the caller's, or a + * caller with the module off, links nothing. Idempotent by construction — the + * OCR commit only passes the rows it actually inserted (re-committing the same + * scan inserts zero rows, so zero facts are created). Any PENDING facts the + * auto-index cross-fire (S8) may have staged on the same row are superseded: + * the user just committed these labs, so the document's facts become exactly + * the committed set and the document is marked CONFIRMED. + * + * Best-effort: the OCR commit is the authoritative write; a failure to file the + * cross-link never fails the commit (the caller swallows it). + */ +import { prisma } from "@/lib/db"; +import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store"; +import { annotate } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import type { ObservationFactData } from "@/lib/validations/inbound-documents"; + +/** One inserted lab row to cross-link back to its source vault document. */ +export interface InsertedLabForLink { + labResultId: string; + analyte: string; + value: number | null; + valueText: string | null; + unit: string | null; + referenceLow: number | null; + referenceHigh: number | null; + takenAt: Date; +} + +/** Build the FHIR-staged Observation payload for a committed lab row. */ +function observationFromLab(lab: InsertedLabForLink): ObservationFactData { + return { + label: lab.analyte, + // An OCR-scanned reading carries no coded system; the vault fact records the + // stated value only, never an inferred LOINC. + code: null, + codeSystem: null, + value: lab.value, + valueText: lab.valueText, + unit: lab.unit, + referenceLow: lab.referenceLow, + referenceHigh: lab.referenceHigh, + effectiveDate: lab.takenAt.toISOString().slice(0, 10), + }; +} + +/** + * Cross-link the inserted OCR labs to their source vault document. Returns the + * number of facts written (0 when the module is off, the document is not owned, + * or there is nothing to link). + */ +export async function linkOcrLabsToVaultDocument( + userId: string, + documentId: string, + labs: InsertedLabForLink[], +): Promise<{ linked: number }> { + if (labs.length === 0) return { linked: 0 }; + + // The cross-link fires only when the documents module is on for the user. + if (!(await isModuleEnabled(userId, "inboundDocuments"))) { + return { linked: 0 }; + } + + // Ownership fail-closed — the document must be a live row of the caller. + const doc = await prisma.inboundDocument.findFirst({ + where: { id: documentId, userId, deletedAt: null }, + select: { id: true }, + }); + if (!doc) return { linked: 0 }; + + await prisma.$transaction(async (tx) => { + // Supersede any PENDING facts the auto-index cross-fire staged on this row: + // the user just confirmed these exact labs, so they are the document's facts. + await tx.extractedFact.deleteMany({ + where: { documentId, userId, status: "PENDING" }, + }); + await tx.extractedFact.createMany({ + data: labs.map((lab) => ({ + documentId, + userId, + factType: "OBSERVATION" as const, + status: "APPROVED" as const, + confidence: 1, + needsReview: false, + dataEncrypted: encryptFactData(observationFromLab(lab)), + provenanceEncrypted: encryptFactProvenance({ + sourceText: "", + page: null, + confidence: 1, + }), + committedRecordId: lab.labResultId, + committedRecordType: "labResult", + })), + }); + await tx.inboundDocument.update({ + where: { id: documentId }, + data: { status: "CONFIRMED" }, + }); + }); + + annotate({ + action: { name: "labs.ocr.vaultLinked" }, + meta: { documentId, linked: labs.length }, + }); + return { linked: labs.length }; +} diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 74a6584df..13eab118d 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -179,6 +179,14 @@ export type OcrCommitRow = z.infer; export const ocrCommitSchema = z.object({ rows: z.array(ocrCommitRowSchema).min(1).max(OCR_MAX_ROWS), + /** + * S9 — the vault document the client filed the scanned bytes into (kind + * LAB_RESULT), so the committed labs can be cross-linked back to their source + * (`inboundDocuments` module on). Optional: text-mode scans keep the image + * on-device and file nothing, and a module-off account never files. Ownership + * is verified server-side before any link is written. + */ + documentId: z.string().trim().min(1).max(64).optional(), }); export type OcrCommitInput = z.infer;