From 49a0eb92ff7e56a7092831cdcfe53d7d8702eb68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:52:18 +0100 Subject: [PATCH] fix(labs): word a value without a unit the same on every remaining surface The briefing's lab input passed the stored "1" to the model, the unit mismatch refusal read "is in 1", and the trend card left a gap where a language puts the unit before a particle. get_labs says that a value without a unit has an empty unit, and a lab link id is scrubbed like its label. The review row is back at its baseline length. --- .../baselines/source-function-lines.json | 4 ++-- src/app/api/labs/route.ts | 19 ++++++++++++++++++- .../documents/document-facts-review.tsx | 5 ++--- .../labs/__tests__/lab-trend-card.test.tsx | 17 ++++++++++++++--- src/components/labs/lab-trend-card.tsx | 12 +++++++----- src/lib/insights/feature-blocks.ts | 3 ++- .../tools-list.write-session.snap | 2 +- src/lib/mcp/record-links.ts | 2 +- src/lib/mcp/tools/get-labs.ts | 2 +- 9 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index a23121b84..a9e9c0346 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": 111930, + "total": 111929, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -93,7 +93,7 @@ "components/documents/document-card.tsx :: DocumentCard": 354, "components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325, "components/documents/document-facts-review.tsx :: DocumentFactsSection": 242, - "components/documents/document-facts-review.tsx :: FactRow": 263, + "components/documents/document-facts-review.tsx :: FactRow": 262, "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, "components/documents/document-record-links.tsx :: DocumentRecordLinks": 202, "components/documents/document-timeline.tsx :: DocumentTimeline": 314, diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 65373f80c..32fb3924e 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -17,6 +17,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import { isDimensionlessUnit } from "@/lib/labs/dimensionless"; import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { type ResolvedBiomarker, @@ -270,7 +271,7 @@ async function postLabResult(request: NextRequest) { meta: { biomarkerId: biomarker.id, reason: reading.reason }, }); return apiError( - `The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`, + unitMismatchMessage(unit, biomarker.name, biomarker.unit), 422, { errorCode: "labs.unit.mismatch", @@ -392,3 +393,19 @@ async function postLabResult(request: NextRequest) { return apiSuccess(dto, 201); } + +/** The refusal for a reading whose unit differs from its marker's. */ +function unitMismatchMessage( + readingUnit: string | undefined, + markerName: string, + markerUnit: string, +): string { + const readingSide = + !readingUnit || isDimensionlessUnit(readingUnit) + ? "The reading has no unit" + : `The reading is in ${readingUnit}`; + const markerSide = isDimensionlessUnit(markerUnit) + ? "tracked without a unit" + : `tracked in ${markerUnit}`; + return `${readingSide}, but ${markerName} is ${markerSide}.`; +} diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index 10de24b49..00745a89c 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -223,7 +223,7 @@ export function FactRow({ onCheckedChange: (checked: boolean) => void; /** A per-fact commit refusal from the last confirm attempt, or null. */ failure: string | null; - /** v1.43 — the suggestion the person picked for a lab value, or null. */ + /** The suggestion the person picked for a lab value, or null. */ chosenBiomarkerId: string | null; onChooseBiomarker: (biomarkerId: string | null) => void; /** The account's marker name by id, for the matched marker. */ @@ -252,8 +252,7 @@ export function FactRow({ { onSuccess: () => { setVerifying(false); - // The refetched fact arrives with needsReview cleared; include it — - // the person just asserted these values. + // Include it: the refetched fact clears needsReview, as asserted. onCheckedChange(true); }, }, diff --git a/src/components/labs/__tests__/lab-trend-card.test.tsx b/src/components/labs/__tests__/lab-trend-card.test.tsx index 0a30bd0e2..35c7d45ac 100644 --- a/src/components/labs/__tests__/lab-trend-card.test.tsx +++ b/src/components/labs/__tests__/lab-trend-card.test.tsx @@ -7,7 +7,7 @@ import { primeLocales } from "@/__tests__/helpers/prime-locales"; import { LabTrendCard } from "../lab-trend-card"; import type { BiomarkerTrendDto } from "../types"; -primeLocales("de"); +primeLocales("de", "ko"); const OK: BiomarkerTrendDto = { status: "ok", @@ -29,11 +29,12 @@ const OK: BiomarkerTrendDto = { function render( trend: BiomarkerTrendDto | undefined, - locale: "en" | "de" = "en", + locale: "en" | "de" | "ko" = "en", + unit = "mg/dL", ) { return renderToStaticMarkup( - + , ); } @@ -48,6 +49,16 @@ describe("", () => { expect(html).toContain("4.25 to 20.75 mg/dL"); }); + it("leaves no gap where a value without a unit has none, in any language", () => { + const en = render(OK, "en", "1"); + expect(en).toContain("Rising by about 13 a year across 4 readings"); + expect(en).toContain("4.25 to 20.75."); + const ko = render(OK, "ko", "1"); + expect(ko).toContain("약 13씩"); + expect(ko).not.toMatch(/ 씩/); + expect(ko).not.toContain("\u0000"); + }); + it("speaks German with du", () => { const html = render(OK, "de"); expect(html).toContain("deines Referenzbereichs"); diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx index c44e01897..e88b129f6 100644 --- a/src/components/labs/lab-trend-card.tsx +++ b/src/components/labs/lab-trend-card.tsx @@ -37,14 +37,16 @@ export function LabTrendCard({ ? "falling" : "flat"; const shownUnit = labUnitText(unit); - // A value without a unit leaves the {unit} slot empty; close the gap it - // leaves rather than print a space before the full stop. + // A value without a unit fills the {unit} slot with a marker and drops it + // together with the space before it, so no language keeps the gap ("0.3 .", + // "0.3 씩"). + const SLOT = "\u0000"; const tidy = (text: string) => - shownUnit ? text : text.replace(/ +([.,])/g, "$1").replace(/ {2,}/g, " "); + shownUnit ? text : text.replace(new RegExp(` ?${SLOT}`, "g"), ""); const slopeLine = tidy( t(`labs.trend.slope.${direction}`, { amount: per(trend.slopePerYear), - unit: shownUnit, + unit: shownUnit || SLOT, n: trend.n, since: fmt.dateShortSmartCalendar(trend.firstAt), }), @@ -97,7 +99,7 @@ export function LabTrendCard({ t("labs.trend.interval", { lo: fmt.number(trend.slopeCi95[0], 2), hi: fmt.number(trend.slopeCi95[1], 2), - unit: shownUnit, + unit: shownUnit || SLOT, }), )}

diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index de67e5d73..52184829c 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -29,6 +29,7 @@ import { canonicalWorkoutsInRange, readWorkoutsAroundRange, } from "@/lib/workouts/canonical-read"; +import { labUnitText } from "@/lib/labs/dimensionless"; import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; import type { AggregatedFeatures, @@ -434,7 +435,7 @@ export async function readLabsBriefingBlock( analyte: resolved.analyte, value: latest.value, valueText: latest.valueText ? sanitizeLabel(latest.valueText, 60) : null, - unit: resolved.unit, + unit: labUnitText(resolved.unit), rangeStatus, trend, takenAt: latest.takenAt.toISOString(), diff --git a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap index 0abc8fcce..0cc087401 100644 --- a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap +++ b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap @@ -378,7 +378,7 @@ { "name": "get_labs", "title": "Get lab results", - "description": "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", + "description": "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status; a ratio or an index recorded without a unit has an empty unit. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", "inputSchema": { "type": "object", "properties": { diff --git a/src/lib/mcp/record-links.ts b/src/lib/mcp/record-links.ts index d06542b49..38a1a01ae 100644 --- a/src/lib/mcp/record-links.ts +++ b/src/lib/mcp/record-links.ts @@ -104,7 +104,7 @@ async function labLinks( const name = analyte.get(target.id); if (!name) continue; out.set(target.id, { - id: `lab:${name}`, + id: `lab:${scrubFenceMarkers(name)}`, kind: "lab", label: scrubFenceMarkers(target.label), date: linkDate(target.date), diff --git a/src/lib/mcp/tools/get-labs.ts b/src/lib/mcp/tools/get-labs.ts index a570e8da2..e20757a00 100644 --- a/src/lib/mcp/tools/get-labs.ts +++ b/src/lib/mcp/tools/get-labs.ts @@ -140,7 +140,7 @@ export const getLabsTool: McpToolDefinition = { name: "get_labs", title: "Get lab results", description: - "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", + "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status; a ratio or an index recorded without a unit has an empty unit. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", inputShape: { analyte: z.string().min(1).max(80).optional(), history: z