mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(documents): bidirectional labs ↔ documents cross-link
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.
This commit is contained in:
1 parent
502783a3ea
commit
73de8f6a52
10 files changed
+790
-13
No files matched your search
@@ -8029,6 +8029,10 @@ paths:
|
||||
required:
|
||||
- analyte
|
||||
- takenAt
|
||||
documentId:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
required:
|
||||
- rows
|
||||
responses:
|
||||
|
||||
@@ -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<typeof serialiseLabResult>[] = [];
|
||||
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", {
|
||||
|
||||
@@ -108,6 +108,10 @@ export function OcrReviewDialog({
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [stage, setStage] = useState<Stage>("pick");
|
||||
const [rows, setRows] = useState<OcrReviewRow[]>([]);
|
||||
// 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<File | null>(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;
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<OcrCommitResult, Error, OcrCommitRowInput[]>({
|
||||
mutationFn: (rows: OcrCommitRowInput[]) =>
|
||||
apiPost<OcrCommitResult>("/api/labs/ocr/commit", { rows }),
|
||||
onSuccess: () => {
|
||||
return useMutation<OcrCommitResult, Error, OcrCommitInput>({
|
||||
mutationFn: async ({ rows, file }: OcrCommitInput) => {
|
||||
const documentId = file
|
||||
? await fileScanToVault(file).catch(() => undefined)
|
||||
: undefined;
|
||||
return apiPost<OcrCommitResult>("/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() });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<ReturnType<typeof resolveIndexProvider>>;
|
||||
}
|
||||
|
||||
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<ReturnType<typeof reserveBudget>>);
|
||||
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<ReturnType<typeof resolveIndexProvider>>);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<number | null> {
|
||||
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<AutoStageOutcome> {
|
||||
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 };
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -179,6 +179,14 @@ export type OcrCommitRow = z.infer<typeof ocrCommitRowSchema>;
|
||||
|
||||
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<typeof ocrCommitSchema>;
|
||||
|
||||
Reference in new issue
Block a user