diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index 0cecca25a..e22f3d349 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -83,8 +83,16 @@ async function commitObservation( // existed has no text, and the numeric bounds the model reported are then // the whole of what it stated. const printed = parseReferenceRange(data.referenceText, data.unit); - const sourceLow = printed?.low ?? data.referenceLow; - const sourceHigh = printed?.high ?? data.referenceHigh; + const rawLow = printed?.low ?? data.referenceLow; + const rawHigh = printed?.high ?? data.referenceHigh; + // Last gate on an impossible window, and the one that matters for facts + // already sitting in the table: a document staged before the extraction + // schema learned this rule still carries whatever the model reported, and it + // arrives here on confirmation. Dropping beats swapping — the pair says the + // transcription is wrong, not which of the two numbers is. + const transposed = rawLow !== null && rawHigh !== null && rawLow > rawHigh; + const sourceLow = transposed ? null : rawLow; + const sourceHigh = transposed ? null : rawHigh; const sourceText = printed?.text ?? null; // A window the report printed and the parser could not read is the case that // used to pass without a trace: the reading is then judged against the @@ -99,6 +107,15 @@ async function commitObservation( meta: { surface: "document.commit", length: printed?.text.length ?? 0 }, }); } + // The same reasoning one step further along: a window that WAS readable and + // cannot be true. Counted rather than merely dropped, because a rising count + // says something about the extraction that no individual row does. + if (transposed) { + annotate({ + action: { name: "labs.referenceRange.transposed" }, + meta: { surface: "document.commit" }, + }); + } const biomarker = await resolveOrMintBiomarker(userId, { analyte: data.label, diff --git a/src/lib/validations/__tests__/inbound-documents-reference-window.test.ts b/src/lib/validations/__tests__/inbound-documents-reference-window.test.ts new file mode 100644 index 000000000..e10059228 --- /dev/null +++ b/src/lib/validations/__tests__/inbound-documents-reference-window.test.ts @@ -0,0 +1,115 @@ +/** + * The reference window a document states, at the two points it enters: the + * model's extraction and a person's edit on the review screen. + * + * The two answer a transposed pair differently on purpose. Extraction cannot + * reject — every field in that schema is `.catch()`ed so one bad value does + * not sink a 120-fact document — so it drops the pair and keeps the verbatim + * string. An edit is a person looking at both numbers, so it refuses and says + * why, exactly as the three lab schemas do. + */ +import { describe, expect, it } from "vitest"; + +import { + extractedFactRawSchema, + inboundFactEditSchema, +} from "@/lib/validations/inbound-documents"; + +const baseRaw = { + type: "OBSERVATION" as const, + label: "Ferritin", + code: null, + codeSystem: null, + clinicalStatus: null, + verificationStatus: null, + value: 150, + valueText: null, + unit: "ng/mL", + referenceText: null, + dose: null, + medicationStatus: null, + effectiveDate: "2026-07-01", + sourceText: "Ferritin 150 ng/mL", + page: 0, + confidence: 0.9, +}; + +const baseEdit = { + factType: "OBSERVATION" as const, + label: "Ferritin", + value: 150, + unit: "ng/mL", +}; + +describe("a stated reference window, on the way in", () => { + it("keeps an ordered pair as stated", () => { + const parsed = extractedFactRawSchema.parse({ + ...baseRaw, + referenceLow: 30, + referenceHigh: 100, + }); + expect(parsed.referenceLow).toBe(30); + expect(parsed.referenceHigh).toBe(100); + }); + + it("drops a transposed pair rather than swapping it", () => { + const parsed = extractedFactRawSchema.parse({ + ...baseRaw, + referenceLow: 100, + referenceHigh: 30, + referenceText: "30 - 100", + }); + expect(parsed.referenceLow).toBeNull(); + expect(parsed.referenceHigh).toBeNull(); + // What the report printed still survives for a person to read. + expect(parsed.referenceText).toBe("30 - 100"); + // And the reading itself is untouched — the window is the only casualty. + expect(parsed.value).toBe(150); + }); + + it("leaves a one-sided window alone", () => { + const floorOnly = extractedFactRawSchema.parse({ + ...baseRaw, + referenceLow: 30, + referenceHigh: null, + }); + expect(floorOnly.referenceLow).toBe(30); + const ceilingOnly = extractedFactRawSchema.parse({ + ...baseRaw, + referenceLow: null, + referenceHigh: 100, + }); + expect(ceilingOnly.referenceHigh).toBe(100); + }); + + it("treats an equal pair as a real window", () => { + const parsed = extractedFactRawSchema.parse({ + ...baseRaw, + referenceLow: 30, + referenceHigh: 30, + }); + expect(parsed.referenceLow).toBe(30); + expect(parsed.referenceHigh).toBe(30); + }); + + it("refuses a transposed pair from the review screen", () => { + const result = inboundFactEditSchema.safeParse({ + ...baseEdit, + referenceLow: 100, + referenceHigh: 30, + }); + expect(result.success).toBe(false); + expect(result.error?.issues.map((i) => i.message)).toContain( + "referenceLow must not exceed referenceHigh", + ); + }); + + it("accepts an ordered edit", () => { + const result = inboundFactEditSchema.safeParse({ + ...baseEdit, + referenceLow: 30, + referenceHigh: 100, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 27a03ed73..00b44f940 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -179,7 +179,7 @@ const nullableText = (max: number) => * per-type staged payload. Everything is `.catch`-guarded so a malformed field * degrades to null + low confidence rather than 422-ing the whole extraction. */ -export const extractedFactRawSchema = z.object({ +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(""), @@ -217,6 +217,34 @@ export const extractedFactRawSchema = z.object({ 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; /** The full JSON envelope the provider returns. */ @@ -468,6 +496,22 @@ const observationEditSchema = z message: "Provide a numeric value OR qualitative text, not both", path: ["value"], }, + ) + // The same ordering rule the three lab schemas enforce. This is a person + // typing on the review screen, not a model guessing, so it refuses rather + // than dropping the pair silently — they can see the two numbers and fix + // which one is which. + .refine( + (d) => + d.referenceLow === undefined || + d.referenceLow === null || + d.referenceHigh === undefined || + d.referenceHigh === null || + d.referenceLow <= d.referenceHigh, + { + message: "referenceLow must not exceed referenceHigh", + path: ["referenceLow"], + }, ); const medicationEditSchema = z.object({ diff --git a/tests/integration/lab-source-reference-range.test.ts b/tests/integration/lab-source-reference-range.test.ts index 34c20123a..b4aaaaad6 100644 --- a/tests/integration/lab-source-reference-range.test.ts +++ b/tests/integration/lab-source-reference-range.test.ts @@ -295,4 +295,81 @@ describe("a printed reference range, end to end", () => { sourceReferenceText: PRINTED, }); }); + + /** + * A window whose floor sits above its ceiling. The document path is the one + * place these numbers arrive unvalidated: the three lab schemas enforce the + * ordering, the extraction schema did not, and the catalog store takes what + * it is handed. Before this guard the same run wrote 100/30 onto the row AND + * onto the freshly minted marker, so every later reading of that analyte + * inherited a range that cannot be true. + * + * The marker is deliberately absent so it gets minted from this fact — the + * mint is the half that outlives the single row. + */ + it("drops a transposed window instead of storing it", async () => { + const prisma = getPrismaClient(); + const { commitApprovedFact } = await import("@/lib/documents/commit"); + const { encryptFactData, encryptFactProvenance } = + await import("@/lib/documents/store"); + const doc = await prisma.inboundDocument.create({ + data: { + userId: USER_ID, + filename: "befund.pdf", + mimeType: "application/pdf", + byteSize: 1024, + contentEncrypted: Buffer.from("stand-in for the stored bytes"), + status: "EXTRACTED", + }, + }); + const fact = await prisma.extractedFact.create({ + data: { + documentId: doc.id, + userId: USER_ID, + factType: "OBSERVATION", + status: "APPROVED", + confidence: 0.9, + needsReview: false, + // No printed text, so the parser never sees the window and these + // numbers are the whole of what the fact states. They are transposed. + dataEncrypted: encryptFactData({ + label: "Ferritin", + code: null, + codeSystem: null, + value: 150, + valueText: null, + unit: "ng/mL", + referenceLow: 100, + referenceHigh: 30, + referenceText: null, + effectiveDate: "2026-07-01", + }), + provenanceEncrypted: encryptFactProvenance({ + sourceText: "Ferritin 150 ng/mL 30 - 100", + anchored: true, + sourceOffset: 0, + page: 0, + confidence: 0.9, + }), + }, + }); + + // The reading itself still lands — the value is the information, the + // window is not, so an impossible window costs the range and nothing else. + const ref = await commitApprovedFact(USER_ID, fact); + const row = await prisma.labResult.findUniqueOrThrow({ + where: { id: ref.recordId }, + }); + expect(row.value).toBe(150); + expect(row.sourceReferenceLow).toBeNull(); + expect(row.sourceReferenceHigh).toBeNull(); + expect(row.referenceLow).toBeNull(); + expect(row.referenceHigh).toBeNull(); + + const minted = await prisma.biomarker.findFirstOrThrow({ + where: { userId: USER_ID, name: "Ferritin" }, + }); + expect(minted.lowerBound).toBeNull(); + expect(minted.upperBound).toBeNull(); + }); });