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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-02 20:52:18 +01:00
1 parent 6a8ac98613
commit 49a0eb92ff
9 files changed
+48 -18

No files matched your search

@@ -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,
+18 -1
View File
@@ -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}.`;
}
@@ -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);
},
},
@@ -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(
<I18nProvider initialLocale={locale}>
<LabTrendCard trend={trend} unit="mg/dL" />
<LabTrendCard trend={trend} unit={unit} />
</I18nProvider>,
);
}
@@ -48,6 +49,16 @@ describe("<LabTrendCard>", () => {
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");
+7 -5
View File
@@ -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,
}),
)}
</p>
+2 -1
View File
@@ -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(),
@@ -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": {
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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