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 ? (