mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #882 from MBombeck/fix/transposed-reference-window-v2
Drop a reference window whose floor sits above its ceiling
This commit is contained in:
4 files changed
+256
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof extractedFactRawSchema>;
|
||||
|
||||
/** 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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user