mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Extracting lab values from a stored document still sent the whole text in one call. A multi-page report produced more JSON than one call's output ceiling, the answer was cut mid-fact, the parse failed twice, and the read came back as 422 "Couldn't read the document. Try a clearer copy." The lab scan had already learned to read the same report a page or a part at a time, but each surface carried its own copy of the read, so the document path never got it. It also ran under the shared 60 s timeout, dropped every fact past 120 (or every fact when one did not match the schema), and charged a flat reservation. The read now lives once, in lib/documents/ocr-read.ts and ocr-plan.ts: the per-read limits, the corrective retry, the salvage of a cut answer's complete rows, the wire re-check and budget reservation before each read, and the planning of a PDF a page at a time and text in parts at line boundaries. The lab scan and the document extraction (the extract route, its background run and the lab auto-staging) both read through it, and the background run plans its reads on the same deadline as a lab scan. The per-read budget moves to a shared metered budget. The document path validates the answer fact by fact, keeps the lab scan's row cap (400), and says why nothing came back: 422 documents.inbound.outputLimit when the reads stopped at the output limit, 429 when the budget refused them, extractFailed only for answers that were not the schema. A run whose reads were cut or not reached reports `incomplete` on its result. A guard holds the class: the output ceiling is set only in the core, both surfaces read through runOcrReads, both runs share the deadline, and a lab read and a document read go out under the same limits. Refs #1257
136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
/**
|
|
* The provider's answer to a document extraction: UNTRUSTED model output,
|
|
* validated fact by fact. Liberal on shape; the review screen is the safety
|
|
* boundary. Split from `inbound-documents.ts`, which holds the staged-fact
|
|
* DTOs, the edit and the confirm contracts.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
|
|
import {
|
|
INBOUND_DOCUMENT_KINDS,
|
|
type InboundDocumentKindValue,
|
|
} from "@/lib/documents/inbound-constants";
|
|
import {
|
|
INBOUND_CODE_SYSTEMS,
|
|
INBOUND_FACT_TYPES,
|
|
} from "@/lib/validations/inbound-documents";
|
|
|
|
const confidenceScore = z.number().min(0).max(1).catch(0);
|
|
const nullableText = (max: number) =>
|
|
z.string().trim().min(1).max(max).nullable().catch(null);
|
|
|
|
/**
|
|
* One fact as the PROVIDER returns it (untrusted). A single flat shape across
|
|
* the three fact types so the wire stays simple; the route maps it to the
|
|
* per-type staged payload. Everything is `.catch`-guarded so a malformed field
|
|
* degrades to null + low confidence rather than 422-ing the whole extraction.
|
|
*/
|
|
const extractedFactRawFields = z.object({
|
|
type: z.enum(INBOUND_FACT_TYPES),
|
|
/** Verbatim label / diagnosis text / medication name as written. */
|
|
label: z.string().trim().min(1).max(300).catch(""),
|
|
/** STATED code, present only when the document writes one. */
|
|
code: nullableText(64),
|
|
codeSystem: z.enum(INBOUND_CODE_SYSTEMS).nullable().catch(null),
|
|
/** Condition: stated clinical / verification status (verbatim). */
|
|
clinicalStatus: nullableText(64),
|
|
verificationStatus: nullableText(64),
|
|
/** Observation: numeric XOR qualitative value, unit, stated ref bounds. */
|
|
value: z.number().finite().nullable().catch(null),
|
|
valueText: nullableText(300),
|
|
unit: nullableText(80),
|
|
referenceLow: z.number().finite().nullable().catch(null),
|
|
referenceHigh: z.number().finite().nullable().catch(null),
|
|
/**
|
|
* Observation: the reference range EXACTLY as printed, verbatim. A report
|
|
* writes its window in prose ("3,5 - 5,0", "< 5", "bis 5,0", "negativ"), and
|
|
* `referenceLow` / `referenceHigh` above can only carry the cases that
|
|
* reduce to two numbers. This field is what stops every other case from
|
|
* being dropped on the floor — the string is kept whether or not the parser
|
|
* gets bounds out of it.
|
|
*/
|
|
referenceText: nullableText(120),
|
|
/** MedicationStatement: dose + stated status (verbatim). */
|
|
dose: nullableText(120),
|
|
medicationStatus: nullableText(64),
|
|
/** A stated date for the fact (onset / effective / report), ISO or null. */
|
|
effectiveDate: z.string().nullable().catch(null),
|
|
/** Provenance: the verbatim source span this fact came from. */
|
|
sourceText: z.string().trim().max(2000).catch(""),
|
|
/** Optional 0-based page index the fact was read from. */
|
|
page: z.number().int().min(0).max(10000).nullable().catch(null),
|
|
/** The model's self-reported overall confidence for this fact (0..1). */
|
|
confidence: confidenceScore.default(0),
|
|
});
|
|
|
|
/**
|
|
* A window whose floor sits above its ceiling cannot be true, so it is dropped
|
|
* rather than repaired. Swapping the pair would invent a range the report never
|
|
* printed, and this schema cannot reject the fact the way `labs.ts` does —
|
|
* every field above is `.catch()`ed precisely so one bad value does not sink a
|
|
* whole extraction. `referenceText` survives either way, so nothing the
|
|
* document actually stated is lost: the verbatim string is still there for a
|
|
* person to read on the review screen.
|
|
*
|
|
* `parseReferenceRange` already answers a transposed PRINTED window this way.
|
|
* These are the numbers the model reported instead, and they travelled
|
|
* unchecked: an integration run against a real Postgres wrote a 100/30 pair
|
|
* onto the row AND onto the freshly minted catalog marker, which then handed
|
|
* the impossible window to every later reading of that analyte.
|
|
*/
|
|
export const extractedFactRawSchema = extractedFactRawFields.transform(
|
|
(fact) => {
|
|
if (
|
|
fact.referenceLow !== null &&
|
|
fact.referenceHigh !== null &&
|
|
fact.referenceLow > fact.referenceHigh
|
|
) {
|
|
return { ...fact, referenceLow: null, referenceHigh: null };
|
|
}
|
|
return fact;
|
|
},
|
|
);
|
|
|
|
export type ExtractedFactRaw = z.infer<typeof extractedFactRawSchema>;
|
|
|
|
/**
|
|
* The JSON envelope the provider returns. Its facts are validated one by one
|
|
* (`parseInboundExtraction`), so one malformed fact is dropped on its own
|
|
* instead of taking every other fact of the answer with it.
|
|
*/
|
|
const inboundEnvelopeSchema = z.object({
|
|
reportDate: z.string().nullable().catch(null),
|
|
kind: z.enum(INBOUND_DOCUMENT_KINDS).nullable().catch(null),
|
|
facts: z.array(z.unknown()).catch([]),
|
|
});
|
|
|
|
/** A provider answer, its facts validated. */
|
|
export interface InboundExtraction {
|
|
reportDate: string | null;
|
|
kind: InboundDocumentKindValue | null;
|
|
facts: ExtractedFactRaw[];
|
|
/** Facts in the answer that did not match the schema and were dropped. */
|
|
droppedFacts: number;
|
|
}
|
|
|
|
/** Validate one provider answer fact by fact; null when it is not an envelope at all. */
|
|
export function parseInboundExtraction(
|
|
json: unknown,
|
|
): InboundExtraction | null {
|
|
const envelope = inboundEnvelopeSchema.safeParse(json);
|
|
if (!envelope.success) return null;
|
|
const facts: ExtractedFactRaw[] = [];
|
|
let droppedFacts = 0;
|
|
for (const raw of envelope.data.facts) {
|
|
const fact = extractedFactRawSchema.safeParse(raw);
|
|
if (fact.success) facts.push(fact.data);
|
|
else droppedFacts += 1;
|
|
}
|
|
return {
|
|
reportDate: envelope.data.reportDate,
|
|
kind: envelope.data.kind,
|
|
facts,
|
|
droppedFacts,
|
|
};
|
|
}
|