mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -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() });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user