Files
HealthLog/src/lib/validations/labs-ocr.ts
T
Marc-André Bombeck 81be73fca8 feat(labs): merge markers, keep aliases and read lab series per marker
POST /api/biomarkers/{id}/merge folds markers into one in a transaction,
converting readings where the registry knows the units and refusing the
whole merge where it does not; a dry run answers the same result and
writes nothing. Aliases appear on the marker and are edited through the
marker's PUT. Scan and document review rows name the matched marker or
offer up to three, and a chosen marker is remembered as an alias. The
doctor report, FHIR and MCP group readings by marker. Aliases and the
merge ledger travel through backup and restore.
2026-10-15 22:22:40 +02:00

332 lines
13 KiB
TypeScript

/**
* v1.18.9 — Lab-OCR ingestion contract.
*
* A self-hoster with a vision-capable AI provider uploads a photo / PDF of a
* paper lab report; the provider transcribes the readings as structured JSON;
* the rows land on a MANDATORY human-review screen; only the user-confirmed
* rows write to `lab_results` (+ a biomarker create/link).
*
* This module holds:
* - `extractedLabsSchema` — the schema the provider's JSON is validated
* against in the extract route (UNTRUSTED model output).
* - `ocrCommitSchema` — the request body of the commit route (the rows the
* human confirmed). No `userId` field — it is always narrowed from the
* session.
*
* The DTO types the routes return + the UI consumes are derived here so the
* three layers agree on one shape.
*/
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { z } from "zod/v4";
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/** Max rows a single scan / commit may carry — a dense panel is ~30 analytes. */
export const OCR_MAX_ROWS = 100;
/**
* Far-past floor for a lab sample, mirroring `createLabResultSchema`'s
* `takenAtField` (a self-hoster legitimately backdates an old paper report).
*/
const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
/** Shared committed-row `takenAt`: ISO string → Date, plausibility-bounded. */
const takenAtField = validateEntryInstant(
z
.string()
.datetime({ message: "takenAt must be an ISO 8601 datetime" })
.transform((s) => new Date(s)),
{
maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS,
pastMessage: "Sample date is too far in the past",
},
);
/**
* Per-field extraction confidence (0..1). The model self-reports how legible
* each field was; the review screen pre-flags any field below the threshold
* so the human double-checks it. Bounded + coerced defensively because this is
* untrusted model output.
*/
const confidenceScore = z.number().min(0).max(1).catch(0);
const extractedConfidenceSchema = z
.object({
analyte: confidenceScore.default(0),
value: confidenceScore.default(0),
unit: confidenceScore.default(0),
range: confidenceScore.default(0),
})
// The model may omit the block entirely — default every field to 0 (treated
// as "flag for review") rather than rejecting the whole row.
.default({ analyte: 0, value: 0, unit: 0, range: 0 });
/**
* One row as the PROVIDER returns it (untrusted). Liberal on shape: a missing
* field is null, a date may be absent. The review step is the safety boundary —
* the server never acts on this beyond mapping it to the DTO.
*
* `value` XOR `valueText`: a reading is numeric OR qualitative. The model is
* instructed to set exactly one, but a malformed pair is tolerated here and
* resolved on the review screen rather than 422-ing the whole extraction.
*/
export const extractedRowSchema = z.object({
analyte: z.string().trim().min(1).max(200),
value: z.number().finite().nullable().catch(null),
valueText: z.string().trim().min(1).max(200).nullable().catch(null),
unit: z.string().trim().max(80).nullable().catch(null),
referenceLow: z.number().finite().nullable().catch(null),
referenceHigh: z.number().finite().nullable().catch(null),
/**
* The reference range EXACTLY as printed, verbatim. The two numeric bounds
* above can only carry a window that reduces to two numbers; a report that
* prints "bis 5,0" or "negativ" states a window all the same, and this is
* what keeps it instead of dropping it.
*/
referenceText: z.string().trim().max(120).nullable().catch(null),
takenAt: z.string().nullable().catch(null),
confidence: extractedConfidenceSchema,
});
/** The full JSON envelope the provider returns. */
export const extractedLabsSchema = z.object({
reportDate: z.string().nullable().catch(null),
rows: z.array(extractedRowSchema).max(OCR_MAX_ROWS),
});
export type ExtractedLabs = z.infer<typeof extractedLabsSchema>;
export type ExtractedRow = z.infer<typeof extractedRowSchema>;
/**
* The per-row DTO the EXTRACT route returns to the client. Carries the model's
* extracted fields plus server-computed annotations:
* - `biomarkerMatch`: whether the analyte links an existing catalog marker
* or will mint a new one.
* - `duplicateOf`: the id of an existing live `lab_results` row that already
* records this analyte+date+value (the review row defaults to unchecked).
* - `takenAt`: normalised to the row date ?? reportDate (ISO) when present.
*/
export interface OcrExtractedRowDto {
analyte: string;
value: number | null;
valueText: string | null;
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
/** The reference range as printed on the report, verbatim. */
referenceText: string | null;
takenAt: string | null;
confidence: {
analyte: number;
value: number;
unit: number;
range: number;
};
biomarkerMatch: "new" | "existing";
/**
* The unit the matched marker is tracked in, or null for a marker that does
* not exist yet (it will adopt this row's unit). Lets the review screen say
* so before Save when the scanned unit differs.
*/
markerUnit: string | null;
/**
* Which analyte the matched marker is (catalog slug or registry-only key), or null
* when the marker is new or none is known. With `markerUnit` it tells the
* review screen whether a row in another unit can be converted on Save.
*/
markerAnalyteKey: string | null;
duplicateOf: string | null;
/**
* v1.43 (D1223) — the marker the row was matched to automatically: by its
* name, an alias of the account, or a catalog abbreviation. Null when none.
*/
biomarkerId: string | null;
matchKind: "name" | "alias" | "catalog" | null;
/** Up to three markers to choose from when nothing matched; else empty. */
suggestions: Array<{
biomarkerId: string;
name: string;
unit: string;
matchKind: "catalog" | "similar";
}>;
}
export interface OcrExtractResponseDto {
reportDate: string | null;
providerType: string;
rows: OcrExtractedRowDto[];
/**
* Present only when a PDF was longer than the pages the scan reads
* (`RASTER_MAX_PAGES`): the rows come from the first `read` of `total`
* pages, and the review screen says so.
*/
pageCoverage?: { read: number; total: number };
}
/**
* One row the human CONFIRMED on the review screen, sent to the commit route.
* Reuses the `createLabResultSchema` field discipline: numeric XOR qualitative,
* a unit required for a numeric reading, sane reference bounds. `analyte` is
* always present (the review screen requires a name); the commit route always
* resolves-or-mints a biomarker by `(userId, lower(analyte))`.
*/
const requiredAnalyte = z.string().trim().min(1).max(120);
const referenceLow = z.number().finite().optional();
const referenceHigh = z.number().finite().optional();
export const ocrCommitRowSchema = z
.object({
analyte: requiredAnalyte,
panel: z
.string()
.trim()
.min(1)
.max(120)
.optional()
.or(z.literal("").transform(() => undefined)),
value: z.number().finite().optional(),
valueText: z.string().trim().min(1).max(120).optional(),
unit: z.string().trim().min(1).max(40).optional(),
referenceLow,
referenceHigh,
/**
* The window the scanned report printed for this row. Confirmed by the
* human on the review screen alongside everything else, and written onto
* the reading rather than only used to seed a new catalog marker.
*/
referenceText: z
.string()
.trim()
.max(120)
.optional()
.or(z.literal("").transform(() => undefined)),
takenAt: takenAtField,
/**
* v1.43 (D1223) — the marker the person chose for this row on the review
* screen. The row is written under it whatever `analyte` says; an id that
* is not one of the record's markers skips the row (`unknown_biomarker`).
*/
biomarkerId: z.string().trim().min(1).max(64).optional(),
/**
* With `biomarkerId`: remember `analyte` as another spelling of that
* marker, so the next report with the same name matches on its own.
* Default true. Ignored when the name is the marker's own.
*/
rememberAlias: z.boolean().optional(),
})
// Numeric XOR qualitative: exactly one of value / valueText.
.refine((d) => (d.value !== undefined) !== (d.valueText !== undefined), {
message:
"Provide exactly one of value (numeric) or valueText (qualitative)",
path: ["value"],
})
// A numeric reading needs a unit (the catalog otherwise has nothing to mint
// it with); a qualitative reading needs none.
.refine((d) => d.valueText !== undefined || d.unit !== undefined, {
message: "unit is required for a numeric reading",
path: ["unit"],
})
// Reference bounds apply to the numeric path only; reject a transposed range.
.refine(
(d) =>
d.valueText !== undefined ||
d.referenceLow === undefined ||
d.referenceHigh === undefined ||
d.referenceLow <= d.referenceHigh,
{
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
);
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(),
/**
* The visit this panel came out of, when the review step offered one.
*
* Optional, always. A panel commits with no visit named and the commit
* succeeds unchanged — the link is the by-the-way half of the flow, never
* a condition of the write. Ownership is verified server-side, and one link
* is written PER RESULT ROW rather than one for the panel: a re-run panel on
* a different day links to its own visit, which is why the join is m:n and
* not a scalar column on the result.
*/
encounterId: z.string().trim().min(1).max(40).optional(),
});
export type OcrCommitInput = z.infer<typeof ocrCommitSchema>;
/**
* A row the commit route did not write. `duplicate`: it matched a live reading
* at commit time. `unit_mismatch`: it states a unit other than the one its
* marker is tracked in. `unknown_biomarker`: the chosen `biomarkerId` is not
* one of the record's markers.
*/
export interface OcrSkippedRowDto {
analyte: string;
reason: "duplicate" | "unit_mismatch" | "unknown_biomarker";
}
export interface OcrCommitResponseDto {
inserted: unknown[];
skipped: OcrSkippedRowDto[];
/**
* How the commit reads, resolved server-side from the two lists. The dialog
* renders this rather than assuming a 200 means readings were saved — a
* re-scan in which every confirmed row turned out to be a duplicate writes
* nothing and must not report a green toast.
*/
outcome: WrittenOutcome;
}
/** The capability-probe response (drives whether the UI shows the scan entry). */
export interface OcrCapabilityDto {
available: boolean;
/**
* How the scan runs when available:
* - "vision": the provider reads the image directly (higher accuracy).
* - "text": the image is OCR'd in the browser (tesseract.js) and only the
* extracted text is sent to a text-only provider (less accurate; opt-in
* via `labsLocalOcrEnabled`). The UI loads the WASM only for this mode.
* - null: scanning is unavailable (see `reason`).
*/
mode: "vision" | "text" | null;
/** Why scanning is unavailable, when it is. */
reason: "no-provider" | "enable-local-ocr" | null;
/** Whether PDF uploads are accepted (Anthropic vision provider only). */
pdfSupported: boolean;
/**
* The `labsOcr` capability for this record. When it is closed by anything
* other than a missing provider or a missing consent receipt, `available`
* is false with a null `reason`, and this says why.
*/
ai: AiCapabilityState;
}
/**
* The body of a TEXT-mode extract call. The browser OCR's the image and POSTs
* the extracted text here (no image bytes ever reach the server). The route
* forwards the text to the text-only provider for structuring, reusing the
* exact review + commit flow the vision path uses.
*/
const OCR_TEXT_MAX_CHARS = 200_000;
export const ocrTextExtractSchema = z.object({
mode: z.literal("text"),
text: z.string().trim().min(1).max(OCR_TEXT_MAX_CHARS),
});
export type OcrTextExtractInput = z.infer<typeof ocrTextExtractSchema>;