From f9431da64f7798ed44a43ca24e81d54e667c9933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:28:34 +0100 Subject: [PATCH] feat(labs): show a value without a unit as the number alone Every place a lab unit is printed beside a value now goes through one helper, so a reading stored in "1" reads "3.6" and not "3.6 1": the list, history and detail, the chart and its tooltips, the reference bar and range notes, the trend card, the timeline, the day view, the clinician page, the doctor-report PDF and the changes card. Where the unit stands alone (a marker's subtitle, a picker row, the merge preview) it reads "No unit". The Coach's lab block, the get_labs history and the visit-prep summary hand the model an empty unit for such a value, so it never quotes the "1" either. Refs #1313 --- src/components/clinician/history-sections.tsx | 3 +- src/components/insights/labs-changes-card.tsx | 5 +-- src/components/labs/biomarker-manager.tsx | 6 ++-- src/components/labs/biomarker-merge-sheet.tsx | 11 ++++--- src/components/labs/lab-biomarker-detail.tsx | 9 +++--- src/components/labs/lab-form.tsx | 19 +++++++---- src/components/labs/lab-history-list.tsx | 3 +- src/components/labs/lab-list.tsx | 13 +++++--- .../labs/lab-reference-range-bar.tsx | 16 ++++++---- src/components/labs/lab-trend-card.tsx | 32 ++++++++++++------- src/components/labs/labs-settings.tsx | 3 +- src/components/labs/marker-match-field.tsx | 3 +- src/components/labs/source-range-note.tsx | 16 ++++++---- src/components/timeline/lab-lines.tsx | 5 +-- src/lib/ai/coach/labs-snapshot.ts | 7 ++-- src/lib/day/records.ts | 6 ++-- .../clinical-records-notes-section.ts | 11 ++++--- src/lib/insights/biomarker-status.ts | 3 +- src/lib/jobs/reaction-line-labs.ts | 4 ++- src/lib/mcp/doctor-visit-summary.ts | 3 +- src/lib/mcp/prompts.ts | 5 +-- src/lib/mcp/tools/get-labs.ts | 22 +++++++++++-- src/lib/mcp/tools/render/citation-text.ts | 5 ++- 23 files changed, 139 insertions(+), 71 deletions(-) diff --git a/src/components/clinician/history-sections.tsx b/src/components/clinician/history-sections.tsx index 308f74ea4..dd6a25138 100644 --- a/src/components/clinician/history-sections.tsx +++ b/src/components/clinician/history-sections.tsx @@ -26,6 +26,7 @@ import { type LeafScope, type Translate, } from "./report-sections"; +import { withLabUnit } from "@/lib/labs/dimensionless"; /** Join the parts of a composed value, dropping the ones with nothing in. */ function compose(parts: Array): string { @@ -73,7 +74,7 @@ export function LabResultsSection({ const qualitative = lab.value === null; const reading = qualitative ? lab.valueText - : `${fmtLab(lab.value as number, lab.sourceValue)} ${lab.unit}`.trim(); + : withLabUnit(fmtLab(lab.value as number, lab.sourceValue), lab.unit); const range = ( low: number | null, high: number | null, diff --git a/src/components/insights/labs-changes-card.tsx b/src/components/insights/labs-changes-card.tsx index 8ed2a69dc..30a8e146a 100644 --- a/src/components/insights/labs-changes-card.tsx +++ b/src/components/insights/labs-changes-card.tsx @@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys"; import { SectionHeading } from "@/components/ui/section-heading"; import type { LabChange } from "@/lib/insights/labs-changes"; import { InsightSectionCard } from "./insight-section-card"; +import { withLabUnit } from "@/lib/labs/dimensionless"; /** * v1.25 — "what changed since your last panel" card. @@ -81,7 +82,7 @@ export function LabsChangesCard({ const signedDelta = c.direction === "flat" ? t("insights.labsChanges.directionFlat") - : `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim(); + : withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit); return (

- {`${c.latest} ${c.unit}`.trim()} + {withLabUnit(String(c.latest), c.unit)} {labelKey ? ` · ${t(labelKey)}` : ""}

diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index f204f0842..c69d11aba 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -24,6 +24,8 @@ import { BiomarkerForm } from "./biomarker-form"; import { BiomarkerMergeSheet } from "./biomarker-merge-sheet"; import type { BiomarkerDto, BiomarkerListResponse } from "./types"; import { useLabNumber } from "./use-lab-format"; +import { withLabUnit } from "@/lib/labs/dimensionless"; +import { LabUnitName } from "./lab-unit-input"; /** * v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows. @@ -124,11 +126,11 @@ export function BiomarkerManager() {
{marker.name} - {marker.unit} +

{range - ? `${t("labs.referenceLabel")} ${range} ${marker.unit}` + ? `${t("labs.referenceLabel")} ${withLabUnit(range, marker.unit)}` : t("labs.biomarker.noRange")} {marker.panel ? ` · ${marker.panel}` : ""}

diff --git a/src/components/labs/biomarker-merge-sheet.tsx b/src/components/labs/biomarker-merge-sheet.tsx index 0853b410b..721af3157 100644 --- a/src/components/labs/biomarker-merge-sheet.tsx +++ b/src/components/labs/biomarker-merge-sheet.tsx @@ -28,6 +28,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types"; +import { labUnitLabel } from "@/lib/labs/dimensionless"; /** The most sources one merge takes, matching the server. */ const MAX_SOURCES = 10; @@ -166,7 +167,7 @@ export function BiomarkerMergeSheet({

{tCount("labs.merge.previewSummary", preview.movedReadings, { name: preview.target.name, - unit: preview.target.unit, + unit: labUnitLabel(preview.target.unit, t), })}

    @@ -178,7 +179,7 @@ export function BiomarkerMergeSheet({

    {source.name}{" "} - {source.unit} + {labUnitLabel(source.unit, t)}

    {source.conversion === "refused" ? t("labs.merge.refused", { - unit: source.unit, - targetUnit: preview.target.unit, + unit: labUnitLabel(source.unit, t), + targetUnit: labUnitLabel(preview.target.unit, t), }) : source.conversion === "converted" ? tCount("labs.merge.converted", source.readingCount, { @@ -247,7 +248,7 @@ export function BiomarkerMergeSheet({ {marker.name} - {marker.unit} + {labUnitLabel(marker.unit, t)} diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 67978895b..cfff0826d 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -61,6 +61,7 @@ import type { LabResultDto, LabResultListResponse, } from "./types"; +import { labUnitLabel, labUnitText } from "@/lib/labs/dimensionless"; // v1.18.11 — defer the recharts biomarker chart through // `next/dynamic` so recharts is off `/labs/[biomarkerId]`'s first-load JS. @@ -426,7 +427,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { one description line every page header carries. */} {marker?.unit ? (

    - {marker.unit} + {labUnitLabel(marker.unit, t)} {marker.panel ? ` · ${marker.panel}` : ""}

    ) : null} @@ -492,7 +493,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { numeric readings, mirroring the metric sub-pages. */} @@ -500,7 +501,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { @@ -508,7 +509,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { )} diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index b75b4f90b..83652c303 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -39,6 +39,11 @@ import type { BiomarkerListResponse, LabResultDto, } from "./types"; +import { + labUnitLabel, + labUnitText, + withLabUnit, +} from "@/lib/labs/dimensionless"; const NOTE_MAX_LENGTH = 2000; const DEFINE_NEW = "__define_new__"; @@ -330,9 +335,8 @@ export function LabForm({ ? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber) : ""; - // What the typed string resolved to, shown back so the user can see whether - // a window was read out of it before the reading is saved. When none was, - // the hint says the text is kept as written rather than staying silent. + // What the typed string resolved to, shown back before the save. When no + // window was read out of it, the hint says the text stays as written. const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit); const sourceRangeHint = parsedSourceRange ? isUnreadableRange(parsedSourceRange) @@ -341,7 +345,7 @@ export function LabForm({ parsedSourceRange.low, parsedSourceRange.high, labNumber, - )} ${selected?.unit ?? ""}`.trim() + )} ${labUnitText(selected?.unit)}`.trim() : ""; const footerNode = ( @@ -406,7 +410,7 @@ export function LabForm({ {markers.map((m) => ( - {m.name} · {m.unit} + {m.name} · {labUnitLabel(m.unit, t)} ))} {!lockedBiomarkerId ? ( @@ -457,7 +461,7 @@ export function LabForm({ {selected ? ( {" "} - ({selected.unit}) + ({labUnitLabel(selected.unit, t)}) ) : null} @@ -515,7 +519,8 @@ export function LabForm({ {resultType === "numeric" && selected && referenceText ? (

    - {t("labs.referenceLabel")} {referenceText} {selected.unit} + {t("labs.referenceLabel")}{" "} + {withLabUnit(referenceText, selected.unit)}

    ) : null} diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index cd794a430..130e85faa 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -46,6 +46,7 @@ import { } from "./lab-result-related-links"; import type { LabResultDto } from "./types"; import { useLabDate, useLabNumber } from "./use-lab-format"; +import { labUnitText } from "@/lib/labs/dimensionless"; const NOTE_MAX_LENGTH = 2000; @@ -103,7 +104,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { reading.value === null ? (reading.valueText ?? "") : labNumber(reading.value, reading.sourceValue), - unit: reading.value === null ? undefined : reading.unit, + unit: reading.value === null ? undefined : labUnitText(reading.unit), compare: before ? { label: t("day.previousReading", { date: labDate(before.takenAt) }), diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index d3219e0cd..43ddc437a 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -31,6 +31,7 @@ import type { LabOverviewResponse, LabResultDto, } from "./types"; +import { labUnitLabel } from "@/lib/labs/dimensionless"; interface MarkerGroup { /** Stable group key: `bm:`, or `analyte:` for legacy rows. */ @@ -39,6 +40,7 @@ interface MarkerGroup { biomarkerId: string | null; analyte: string; panel: string | null; + /** The marker's unit as the card names it ("No unit" for a ratio). */ unit: string; /** Every live reading of the marker, counted by the server. */ count: number; @@ -53,13 +55,16 @@ interface MarkerGroup { * inert until the backfill links them. The server already orders by the * newest reading. */ -function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] { +function toGroups( + markers: LabOverviewMarkerDto[], + t: (key: string) => string, +): MarkerGroup[] { return markers.map((marker) => ({ key: marker.key, biomarkerId: marker.latest.biomarkerId, analyte: marker.latest.analyte, panel: marker.latest.panel, - unit: marker.latest.unit, + unit: labUnitLabel(marker.latest.unit, t), count: marker.count, trend: marker.trend, latest: marker.latest, @@ -152,7 +157,7 @@ export function LabList({ }); const groups = useMemo(() => { - const base = toGroups(data?.markers ?? []); + const base = toGroups(data?.markers ?? [], t); // v1.18.6 — honour the user's Labs sort choice. The server // order is most-recent-first; `recentAsc` reverses, the alpha // options sort by analyte name (#43), and `manual` applies the persisted @@ -173,7 +178,7 @@ export function LabList({ return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key); } return base; - }, [data?.markers, prefs.sortDir, prefs.order]); + }, [data?.markers, prefs.sortDir, prefs.order, t]); const listEmpty = !isLoading && !isError && groups.length === 0; useEffect(() => { diff --git a/src/components/labs/lab-reference-range-bar.tsx b/src/components/labs/lab-reference-range-bar.tsx index 0fc0e1079..443855020 100644 --- a/src/components/labs/lab-reference-range-bar.tsx +++ b/src/components/labs/lab-reference-range-bar.tsx @@ -4,6 +4,7 @@ import { RangeBar } from "@/components/targets/range-bar"; import { cn } from "@/lib/utils"; import { useLabNumber } from "./use-lab-format"; +import { labUnitText, withLabUnit } from "@/lib/labs/dimensionless"; /** * v1.37.34 — compact reference-range visualization for saved lab readings. @@ -43,11 +44,14 @@ export function LabReferenceRangeBar({ const scale = Math.max(Math.abs(referenceLow ?? referenceHigh!), 1); const min = referenceLow ?? referenceHigh! - scale; const max = referenceHigh ?? referenceLow! + scale; - const rangeLabel = !hasLow - ? `≤${labNumber(referenceHigh!)} ${unit}` - : !hasHigh - ? `≥${labNumber(referenceLow!)} ${unit}` - : `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)} ${unit}`; + const rangeLabel = withLabUnit( + !hasLow + ? `≤${labNumber(referenceHigh!)}` + : !hasHigh + ? `≥${labNumber(referenceLow!)}` + : `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)}`, + unit, + ); return (
    + shownUnit ? text : text.replace(/ +([.,])/g, "$1").replace(/ {2,}/g, " "); + const slopeLine = tidy( + t(`labs.trend.slope.${direction}`, { + amount: per(trend.slopePerYear), + unit: shownUnit, + n: trend.n, + since: fmt.dateShortSmartCalendar(trend.firstAt), + }), + ); const projectionLine = trend.projection === "exits" && trend.exitDate ? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, { @@ -85,11 +93,13 @@ export function LabTrendCard({

    ) : null}

    - {t("labs.trend.interval", { - lo: fmt.number(trend.slopeCi95[0], 2), - hi: fmt.number(trend.slopeCi95[1], 2), - unit, - })} + {tidy( + t("labs.trend.interval", { + lo: fmt.number(trend.slopeCi95[0], 2), + hi: fmt.number(trend.slopeCi95[1], 2), + unit: shownUnit, + }), + )}

    diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index 12ca02e37..709f550b5 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -46,6 +46,7 @@ import { import { BiomarkerManager } from "./biomarker-manager"; import type { BiomarkerDto, BiomarkerListResponse } from "./types"; import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract"; +import { labUnitLabel } from "@/lib/labs/dimensionless"; export function LabsSettings() { const { t } = useTranslations(); @@ -61,7 +62,7 @@ export function LabsSettings() { const reorderItems: ReorderItem[] = ordered.map((m) => ({ id: m.id, name: m.name, - secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit, + secondary: [labUnitLabel(m.unit, t), m.panel].filter(Boolean).join(" · "), })); const manual = prefs.sortDir === "manual"; diff --git a/src/components/labs/marker-match-field.tsx b/src/components/labs/marker-match-field.tsx index 399bdac2a..cd1521bd0 100644 --- a/src/components/labs/marker-match-field.tsx +++ b/src/components/labs/marker-match-field.tsx @@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useTranslations } from "@/lib/i18n/context"; +import { labUnitLabel } from "@/lib/labs/dimensionless"; export interface MarkerSuggestionDto { biomarkerId: string; @@ -110,7 +111,7 @@ export function MarkerMatchField({ ))} diff --git a/src/components/labs/source-range-note.tsx b/src/components/labs/source-range-note.tsx index d5e3dde2c..b5a7f195e 100644 --- a/src/components/labs/source-range-note.tsx +++ b/src/components/labs/source-range-note.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "@/lib/i18n/context"; import type { LabResultDto } from "./types"; import { useLabNumber } from "./use-lab-format"; +import { withLabUnit } from "@/lib/labs/dimensionless"; /** * The reference window a reading's own report printed, shown wherever that @@ -54,11 +55,14 @@ export function SourceRangeNote({ // then stated from the converted bounds, in the marker's unit. const inForce = reading.sourceReferenceText ?? - `${formatReferenceRange( - reading.referenceLow, - reading.referenceHigh, - labNumber, - )} ${reading.unit}`.trim(); + withLabUnit( + formatReferenceRange( + reading.referenceLow, + reading.referenceHigh, + labNumber, + ), + reading.unit, + ); const catalog = formatReferenceRange( reading.catalogReferenceLow, @@ -77,7 +81,7 @@ export function SourceRangeNote({ : null} {reading.referenceDivergesFromCatalog && catalog ? ` · ${t("labs.sourceRange.catalogDiffers", { - range: `${catalog} ${reading.unit}`.trim(), + range: withLabUnit(catalog, reading.unit), })}` : null} diff --git a/src/components/timeline/lab-lines.tsx b/src/components/timeline/lab-lines.tsx index 619f627bc..833a4a7bc 100644 --- a/src/components/timeline/lab-lines.tsx +++ b/src/components/timeline/lab-lines.tsx @@ -38,6 +38,7 @@ import { formatDayMonthYear, } from "./timeline-dates"; import { seriesNameLines, type TimelineLayout } from "./timeline-geometry"; +import { withLabUnit } from "@/lib/labs/dimensionless"; /** "18 ng/mL", every digit the reading holds, in the reader's locale. */ export type LabValueText = ( @@ -50,7 +51,7 @@ export function useLabValueText(): LabValueText { const labNumber = useLabNumber(); return useCallback( (value, sourceValue, unit) => - `${labNumber(value, sourceValue)} ${unit}`.trim(), + withLabUnit(labNumber(value, sourceValue), unit), [labNumber], ); } @@ -84,7 +85,7 @@ export function useLabReference(): ( (v) => labNumber(v), ); return range - ? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() }) + ? t("timeline.labs.reference", { range: withLabUnit(range, unit) }) : null; }, [t, labNumber], diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index 85e102617..5407b90aa 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -67,6 +67,7 @@ import { resolveLabFields } from "@/lib/labs/serialise"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { prisma } from "@/lib/db"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Cap on distinct biomarkers in the block (newest reading first). */ const MAX_BIOMARKERS = 24; @@ -120,7 +121,7 @@ export interface CoachLabReading { value: number | null; /** Qualitative result text ("negativ" / …); null for a numeric row. */ valueText: string | null; - /** Resolved unit; empty for a qualitative marker. */ + /** Resolved unit; empty for a qualitative marker and for a ratio. */ unit: string; referenceLow: number | null; referenceHigh: number | null; @@ -359,7 +360,9 @@ export async function buildLabsSnapshotBlock( : null, value: row.value, valueText: row.valueText ? sanitizeValueText(row.valueText) : null, - unit: sanitizeForPrompt(resolved.unit, MAX_UNIT_CHARS), + // A value without a unit is stored as "1"; the model is shown none, + // so it never writes "3.6 1". + unit: sanitizeForPrompt(labUnitText(resolved.unit), MAX_UNIT_CHARS), referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, // A qualitative row has nothing to compare against the bounds → diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 31be1a0bb..ec2073c8e 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -47,6 +47,7 @@ import { readWorkoutsAroundRange, WORKOUT_PICK_SELECT, } from "@/lib/workouts/canonical-read"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Everything a section reader needs about the day. */ export interface DayFrame { @@ -717,10 +718,9 @@ async function labsPart(frame: DayFrame): Promise { title: row.analyte, meta: row.value !== null - ? // Every recorded digit, without the float noise a converted - // reading carries (the labs page prints the same figure). + ? // Every recorded digit, minus a converted reading's float noise. // eslint-disable-next-line healthlog/no-unlocalised-decimal -- the day contract's meta is wire text; the lab figure keeps the notation it was stored in - `${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim() + `${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${labUnitText(row.unit)}`.trim() : (row.valueText ?? null), note: openText(row.noteEncrypted, "lab note"), href: row.biomarkerId ? `/labs/${row.biomarkerId}` : "/labs", diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 02538766d..294399161 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -16,6 +16,7 @@ import { type DoctorReportPdfCursorState, type DoctorReportPdfRenderContext, } from "./render-context"; +import { withLabUnit } from "@/lib/labs/dimensionless"; export function buildClinicalRecordsNotesSection( context: DoctorReportPdfRenderContext, @@ -109,7 +110,7 @@ export function buildClinicalRecordsNotesSection( lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte, isQualitative ? (lr.valueText ?? "") - : `${labNum(lr.value as number, lr.sourceValue)} ${lr.unit}`.trim(), + : withLabUnit(labNum(lr.value as number, lr.sourceValue), lr.unit), isQualitative ? "—" : referenceCell(lr), isQualitative ? "" @@ -166,10 +167,10 @@ export function buildClinicalRecordsNotesSection( y += 4; for (const lr of diverging) { doc.text( - `* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${rangeText( - lr.catalogReferenceLow, - lr.catalogReferenceHigh, - )} ${lr.unit}`.trim(), + `* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${withLabUnit( + rangeText(lr.catalogReferenceLow, lr.catalogReferenceHigh), + lr.unit, + )}`.trim(), margin, y, ); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 75c4c7f8b..0306ccf37 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -53,6 +53,7 @@ import { import { annotate } from "@/lib/logging/context"; import { userDayKey } from "@/lib/tz/resolver"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; +import { labUnitText } from "@/lib/labs/dimensionless"; export type BiomarkerStatusResult = { hasProvider: boolean; @@ -321,7 +322,7 @@ function biomarkerSnapshot(args: { // position below, so both are scrubbed exactly like the coach // labs-snapshot scrubs the same fields. const safeMarkerName = sanitizeForPrompt(marker.name, 120); - const safeMarkerUnit = sanitizeForPrompt(marker.unit, 40); + const safeMarkerUnit = sanitizeForPrompt(labUnitText(marker.unit), 40); const snapshot = { locale, diff --git a/src/lib/jobs/reaction-line-labs.ts b/src/lib/jobs/reaction-line-labs.ts index 8d71e8a37..d164e0c5a 100644 --- a/src/lib/jobs/reaction-line-labs.ts +++ b/src/lib/jobs/reaction-line-labs.ts @@ -8,6 +8,7 @@ import { fenceUserText } from "@/lib/ai/coach/data-fence"; import { prisma } from "@/lib/db"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Up to eight results of the panel, fenced; null when there is nothing to say. */ export async function labsPanelEvidence( @@ -33,7 +34,8 @@ export async function labsPanelEvidence( lab.value === null ? fenceUserText(lab.valueText ?? "not reported") : String(lab.value); - const unit = lab.unit ? ` ${fenceUserText(lab.unit)}` : ""; + const shown = labUnitText(lab.unit); + const unit = shown ? ` ${fenceUserText(shown)}` : ""; return `- Newly arrived lab result: ${fenceUserText(lab.analyte)} ${value}${unit}.`; }) .join("\n"); diff --git a/src/lib/mcp/doctor-visit-summary.ts b/src/lib/mcp/doctor-visit-summary.ts index 6b00444d6..41c3aaaf1 100644 --- a/src/lib/mcp/doctor-visit-summary.ts +++ b/src/lib/mcp/doctor-visit-summary.ts @@ -19,6 +19,7 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** * Fallback units for the headline specialised metric types the generic @@ -91,7 +92,7 @@ export function summariseForVisit( panel: l.panel, value: l.value, valueText: l.valueText, - unit: l.unit, + unit: labUnitText(l.unit), referenceLow: l.referenceLow, referenceHigh: l.referenceHigh, takenAt: l.takenAt, diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 9507775c2..cdba743fd 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -34,6 +34,7 @@ import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; import { buildBriefingIllnessCycleContext } from "@/lib/insights/illness-cycle-briefing"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import type { McpAuthContext } from "./auth"; +import { labUnitText } from "@/lib/labs/dimensionless"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -254,7 +255,7 @@ async function collectLabTrajectories( const history = readings.slice(0, 12).map((r) => ({ value: r.value, valueText: r.valueText, - unit: r.unit, + unit: labUnitText(r.unit), referenceLow: r.referenceLow, referenceHigh: r.referenceHigh, takenAt: r.takenAt.toISOString(), @@ -263,7 +264,7 @@ async function collectLabTrajectories( return { analyte: latest.analyte, panel: latest.panel, - unit: latest.unit, + unit: labUnitText(latest.unit), readingsOnFile: readings.length, latest: history[0], history, diff --git a/src/lib/mcp/tools/get-labs.ts b/src/lib/mcp/tools/get-labs.ts index f9c3dec33..7c233a298 100644 --- a/src/lib/mcp/tools/get-labs.ts +++ b/src/lib/mcp/tools/get-labs.ts @@ -2,12 +2,17 @@ import { z } from "zod/v4"; import { fenceUserText } from "@/lib/ai/coach/data-fence"; import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve"; -import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads"; +import { + getLabHistory, + LAB_HISTORY_MAX_LIMIT, + type LabHistoryResult, +} from "@/lib/mcp/rich-reads"; import { decodeOffsetCursor } from "@/lib/mcp/pagination"; import type { McpAuthContext } from "../auth"; import { coachReadOutput } from "./output-shapes"; import { runCoachTool, whenModuleOn } from "./shared"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */ const getLabsOutput: z.ZodRawShape = { @@ -59,6 +64,19 @@ function fenceLabText(value: unknown): unknown { ); } +/** + * A value without a unit (a ratio, INR) is stored as the UCUM unity "1". The + * model is shown no unit for it, as the Coach's lab block does, so it never + * writes "3.6 1". + */ +function withShownUnits(result: LabHistoryResult): LabHistoryResult { + if (!result.readings) return result; + return { + ...result, + readings: result.readings.map((r) => ({ ...r, unit: labUnitText(r.unit) })), + }; +} + /** `get_labs` behind its module gate: latest-per-biomarker or a history page. */ function readLabs( ctx: McpAuthContext, @@ -78,7 +96,7 @@ function readLabs( analyte, offset: decodeOffsetCursor(args.cursor), limit: LAB_HISTORY_MAX_LIMIT, - }); + }).then(withShownUnits); } // Default: latest reading per biomarker via the Coach labs read. A name that // resolves to one of the user's markers (an alias or a catalog abbreviation diff --git a/src/lib/mcp/tools/render/citation-text.ts b/src/lib/mcp/tools/render/citation-text.ts index 9326d5f52..89059d6f1 100644 --- a/src/lib/mcp/tools/render/citation-text.ts +++ b/src/lib/mcp/tools/render/citation-text.ts @@ -6,6 +6,7 @@ import { fenceUserText } from "@/lib/ai/coach/data-fence"; import type { NutrientsDailyResult } from "@/lib/mcp/nutrients-read"; import type { NutrientUpperLimit } from "@/lib/nutrients/catalog"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Finite-number guard for the citation summarisers. */ function num(v: unknown): number | null { @@ -66,7 +67,9 @@ export function plainLabText(rid: string, result: unknown): string { : typeof reading.valueText === "string" ? reading.valueText : "—"; - const unit = typeof reading.unit === "string" ? reading.unit : ""; + // A value without a unit (a ratio, INR) is stored as "1"; it reads as none. + const unit = + typeof reading.unit === "string" ? labUnitText(reading.unit) : ""; const status = typeof reading.rangeStatus === "string" ? reading.rangeStatus : ""; const taken =