mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
229 lines
8.2 KiB
TypeScript
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>;
|