From ef61c7d5d1f62b47b17d9a796a235d3b7076bf01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 5 Nov 2026 10:59:52 +0100 Subject: [PATCH] fix(labs): keep the printed range on a marker minted from a text result A report that prints a text result such as "SEE NOTE" with a numeric range beside it minted the marker without bounds: the document commit, the scan commit, the free-text lab write and the CSV import each passed null bounds for a qualitative reading, and the CSV row reader and the scan review payload dropped the range before that. The marker's first number then had nothing to be judged against. Every mint path now passes the printed window. A range that is text itself yields no bounds, an existing marker keeps its own, a transposed window is not minted, and the reading itself still records no window. The scan review shows the range on a text row that mints a new marker. Refs #1313 --- CHANGELOG.md | 8 + .../baselines/source-function-lines.json | 10 +- src/app/api/labs/ocr/commit/route.ts | 6 +- src/app/api/labs/route.ts | 9 +- .../__tests__/ocr-review-validation.test.ts | 21 ++ .../labs/__tests__/ocr-row-editor.test.tsx | 38 ++ src/components/labs/ocr-review-validation.ts | 14 +- src/components/labs/ocr-row-editor.tsx | 45 ++- src/lib/documents/commit.ts | 6 +- .../__tests__/record-import-rows.test.ts | 20 + src/lib/import/labs-csv.ts | 16 +- src/lib/labs/biomarker-store.ts | 12 +- .../lab-text-marker-keeps-range.test.ts | 354 ++++++++++++++++++ 13 files changed, 520 insertions(+), 39 deletions(-) create mode 100644 tests/integration/lab-text-marker-keeps-range.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc13e6c5..289543a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,14 @@ on the phone to an account pending deletion now carries the date the account is erased, as the web sign-in already did, so the app can say until when an admin can still undo it. (#1341) +- **A marker started from a text result keeps its range.** When a report + printed a text result such as "SEE NOTE" with a numeric range beside it, + the new marker was saved without that range, so its first number later + had nothing to be judged against. The range now goes onto the new marker, + from a document, a lab scan, a CSV import or a typed reading, and the + scan review shows it for such a row. A range that is text itself, like + "NEGATIVE", is not turned into bounds, and a marker that already exists + keeps its own. (#1313) ### Changed diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 453a901c5..8490f21f6 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 345, - "total": 111902, + "total": 111889, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -24,7 +24,7 @@ "app/api/integrations/status/route.ts :: apiHandler(arg)": 476, "app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239, "app/api/labs/ocr/commit/route.ts :: commitOcrRows": 280, - "app/api/labs/route.ts :: postLabResult": 242, + "app/api/labs/route.ts :: postLabResult": 241, "app/api/mcp/oauth/token/route.ts :: POST": 250, "app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248, "app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239, @@ -139,7 +139,7 @@ "components/labs/lab-history-list.tsx :: LabHistoryList": 534, "components/labs/lab-list.tsx :: LabList": 308, "components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359, - "components/labs/ocr-row-editor.tsx :: OcrRowEditor": 386, + "components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378, "components/layout/auth-shell.tsx :: AuthShell": 518, "components/layout/bottom-nav.tsx :: BottomNav": 262, "components/layout/capture-picker.tsx :: CapturePicker": 235, @@ -286,8 +286,8 @@ "lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223, "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452, "lib/illness/correlation.ts :: computeIllnessCorrelation": 233, - "lib/import/labs-csv.ts :: importLabsCsv": 309, - "lib/import/labs-csv.ts :: importLabsCsv > flush": 231, + "lib/import/labs-csv.ts :: importLabsCsv": 307, + "lib/import/labs-csv.ts :: importLabsCsv > flush": 229, "lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 217, "lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876, "lib/insights/features.ts :: extractFeatures": 796, diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index e3a2bce86..7a2891dab 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -249,10 +249,10 @@ async function commitOcrRows(request: NextRequest) { user.id, { analyte: row.analyte, - // A qualitative reading has no numeric unit / range. + // A text result has no unit; its printed window still seeds. unit: isQualitative ? (row.unit ?? "") : (row.unit as string), - referenceLow: isQualitative ? null : sourceLow, - referenceHigh: isQualitative ? null : sourceHigh, + referenceLow: sourceLow, + referenceHigh: sourceHigh, panel: row.panel ?? null, }, { index: markerIndex }, diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 32fb3924e..2010c4281 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -234,12 +234,11 @@ async function postLabResult(request: NextRequest) { } else { biomarker = await resolveOrMintBiomarker(user.id, { analyte: analyte as string, - // A qualitative reading has no numeric unit / range — mint the catalog - // marker with an empty unit label and no bounds. A numeric reading - // supplies its unit (Zod requires it on the free-text numeric path). + // A qualitative reading mints with an empty unit but the printed range; + // a numeric one supplies its unit (Zod requires it on this path). unit: isQualitative ? (unit ?? "") : (unit as string), - referenceLow: isQualitative ? null : (referenceLow ?? null), - referenceHigh: isQualitative ? null : (referenceHigh ?? null), + referenceLow: referenceLow ?? null, + referenceHigh: referenceHigh ?? null, panel: panel ?? null, }); } diff --git a/src/components/labs/__tests__/ocr-review-validation.test.ts b/src/components/labs/__tests__/ocr-review-validation.test.ts index 0919b24e3..65b9f4e6f 100644 --- a/src/components/labs/__tests__/ocr-review-validation.test.ts +++ b/src/components/labs/__tests__/ocr-review-validation.test.ts @@ -115,6 +115,27 @@ describe("toCommitRow agrees with validateReviewRow", () => { it("anchors the date at noon UTC", () => { expect(toCommitRow(row())?.takenAt).toBe("2026-06-10T12:00:00.000Z"); }); + + it("sends the range printed beside a text result", () => { + expect( + toCommitRow( + row({ + value: null, + valueText: "SEE NOTE", + unit: null, + referenceLow: 10, + referenceHigh: 20, + referenceText: "10 - 20", + biomarkerMatch: "new", + }), + ), + ).toMatchObject({ + valueText: "SEE NOTE", + referenceLow: 10, + referenceHigh: 20, + referenceText: "10 - 20", + }); + }); }); describe("collectRowErrors", () => { diff --git a/src/components/labs/__tests__/ocr-row-editor.test.tsx b/src/components/labs/__tests__/ocr-row-editor.test.tsx index 4c9544922..87d7b1260 100644 --- a/src/components/labs/__tests__/ocr-row-editor.test.tsx +++ b/src/components/labs/__tests__/ocr-row-editor.test.tsx @@ -125,6 +125,44 @@ describe(" layout", () => { }); }); +describe(" range beside a text result", () => { + const text: OcrReviewRow = { + ...baseRow, + value: null, + valueText: "SEE NOTE", + unit: null, + referenceLow: 10, + referenceHigh: 20, + referenceText: "10 - 20", + }; + + it("offers the range for review when the row mints a new marker", () => { + const html = render(undefined, "en", { ...text, biomarkerMatch: "new" }); + for (const id of ["-lo", "-hi", "-refText"]) { + expect(html, `field ${id}`).toContain(`${id}"`); + } + expect(html).toContain("Reference range taken from the scan"); + }); + + it("does not ask to verify a range a new text marker never had", () => { + const html = render(undefined, "en", { + ...text, + biomarkerMatch: "new", + referenceLow: null, + referenceHigh: null, + referenceText: null, + }); + expect(html).not.toContain("Verify the reference range"); + }); + + it("leaves it out when the row goes to an existing marker", () => { + const html = render(undefined, "en", text); + for (const id of ["-lo", "-hi", "-refText"]) { + expect(html, `field ${id}`).not.toContain(`${id}"`); + } + }); +}); + describe(" unit differs from the marker's", () => { const mismatched: OcrReviewRow = { ...baseRow, diff --git a/src/components/labs/ocr-review-validation.ts b/src/components/labs/ocr-review-validation.ts index 3d5eaa00a..3d7d5e1c7 100644 --- a/src/components/labs/ocr-review-validation.ts +++ b/src/components/labs/ocr-review-validation.ts @@ -157,6 +157,9 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null { analyte, valueText, takenAt: takenAt.toISOString(), + // A text result has no window of its own, but a new marker minted from + // it takes the one printed beside it. + ...printedRange(row), ...chosen(row), }; } @@ -170,10 +173,19 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null { value: row.value, unit, takenAt: takenAt.toISOString(), + ...printedRange(row), + ...chosen(row), + }; +} + +/** The reviewed window: the two bounds and the string they were read from. */ +function printedRange( + row: OcrReviewRow, +): Pick { + return { ...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}), ...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}), ...(row.referenceText ? { referenceText: row.referenceText } : {}), - ...chosen(row), }; } diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index 942d93d90..8a50bb3af 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -75,6 +75,33 @@ function unitMismatchMessage( : ["labs.ocr.errorUnitMismatch", { markerUnit: row.markerUnit ?? "" }]; } +/** + * v1.18.10 (#5) — a NEW biomarker mints its catalog reference range from THIS + * extracted row, so the range is load-bearing and worth a second look. Surface + * the range confidence for a new numeric marker, and call it out when the + * model was unsure (or set no bounds at all) so the user verifies the range it + * will be judged against. A text result ("SEE NOTE") records no window of its + * own, but a new marker minted from it takes the one printed beside it, so the + * range is shown there too, and only called out when there is one: a text + * result often has no numeric range, and that is not a gap. + */ +function rangeReview(row: OcrReviewRow): { + showRange: boolean; + showRangeBadge: boolean; + flagRange: boolean; +} { + const isQualitative = row.valueText !== null && row.valueText !== undefined; + const isNew = row.biomarkerMatch === "new"; + const lowRangeConfidence = row.confidence.range < CONFIDENCE_THRESHOLD; + const hasBounds = row.referenceLow !== null || row.referenceHigh !== null; + const showRangeBadge = isNew && (!isQualitative || hasBounds); + return { + showRange: !isQualitative || isNew, + showRangeBadge, + flagRange: showRangeBadge && (lowRangeConfidence || !hasBounds), + }; +} + export function OcrRowEditor({ row, onChange, @@ -138,15 +165,7 @@ export function OcrRowEditor({ ? row.confidence.value < CONFIDENCE_THRESHOLD : lowValueConfidence); - // v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range - // from THIS extracted row. The range is therefore load-bearing and worth a - // second look. Surface the range confidence prominently for new numeric - // markers, and call it out explicitly when the model was unsure (or set no - // bounds at all) so the user verifies the range it will be judged against. - const isNewNumericMarker = !isQualitative && row.biomarkerMatch === "new"; - const lowRangeConfidence = row.confidence.range < CONFIDENCE_THRESHOLD; - const hasBounds = row.referenceLow !== null || row.referenceHigh !== null; - const flagRange = isNewNumericMarker && (lowRangeConfidence || !hasBounds); + const { showRange, showRangeBadge, flagRange } = rangeReview(row); return ( // `@container` makes the row follow the width of the dialog, not the @@ -353,11 +372,11 @@ export function OcrRowEditor({ - {/* v1.18.10 (#5) — for a NEW numeric biomarker the reference range below + {/* v1.18.10 (#5) — for a NEW biomarker the reference range below becomes the marker's catalog range. Make that consequence visible, and flag low range-confidence / a missing range so the user verifies it before saving. A calm `secondary` badge, not an alarm. */} - {isNewNumericMarker ? ( + {showRangeBadge ? (
@@ -368,7 +387,7 @@ export function OcrRowEditor({
) : null} - {!isQualitative ? ( + {showRange ? (