Files
HealthLog/src/lib/validations/labs-ocr.ts
T
Marc-André Bombeck e023571a08 chore(labs): tidy OCR capability enum, Fitbit key delimiter, Coach docblock
Drop the unreachable text-only-model reason from the OCR capability
enum and its DTO type. Replace the literal NUL delimiter in the Fitbit
dedup map keys with a named constant so text tooling stops treating the
file as binary (behaviour unchanged). Correct the Coach tool-executor
docblock, which overstated cross-tool snapshot-cache sharing.
2026-06-26 09:27:05 +02:00

229 lines
8.2 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 { z } from "zod/v4";
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),
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;
takenAt: string | null;
confidence: {
analyte: number;
value: number;
unit: number;
range: number;
};
biomarkerMatch: "new" | "existing";
duplicateOf: string | null;
}
export interface OcrExtractResponseDto {
reportDate: string | null;
providerType: string;
rows: OcrExtractedRowDto[];
}
/**
* 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,
takenAt: takenAtField,
})
// 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),
});
export type OcrCommitInput = z.infer<typeof ocrCommitSchema>;
/** A row the commit route skipped (a re-checked duplicate at commit time). */
export interface OcrSkippedRowDto {
analyte: string;
reason: "duplicate";
}
export interface OcrCommitResponseDto {
inserted: unknown[];
skipped: OcrSkippedRowDto[];
}
/** 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 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>;