mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Resolve a vision-capable provider, gate with consent + a 6/hour rate bucket + a reserved per-day token budget, then transcribe an uploaded photo or PDF into structured proposed rows. The raw upload stays in memory only and is never persisted. The commit route writes only the rows a human confirmed, resolving or minting a biomarker per row with source OCR and skipping commit-time duplicates. The capability probe drives whether the UI shows the scan entry. Extracted text is treated as untrusted; the review screen is the safety boundary.
205 lines
7.2 KiB
TypeScript
205 lines
7.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;
|
|
/** Why scanning is unavailable, when it is. */
|
|
reason: "no-provider" | "text-only-model" | null;
|
|
/** Whether PDF uploads are accepted (Anthropic-only in v1). */
|
|
pdfSupported: boolean;
|
|
}
|