docs(api): describe the dimensionless lab unit on every lab unit field

The unit on lab results, biomarkers, the scanned-report commit and the
document fact edit now states that "1" means the value has no unit, that
it matches only itself, and that an empty unit stays refused. The lab CSV
import description names the 1 in the unit column.

Refs #1313
This commit is contained in:
Marc-André Bombeck committed 2026-11-02 20:28:35 +01:00
1 parent f9431da64f
commit e605fe0ae8
8 files changed
+79 -14

No files matched your search

+55 -4
View File
@@ -15875,10 +15875,11 @@ paths:
checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with
`module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt
`dryRun` never writes. Each row's name resolves to the account's marker the way a scanned report's rows do
(name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit;
one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise
the row is refused with `unit_mismatch`. Any other text in the value column is a qualitative result. A date
without a time is stored as that calendar date. At most 20 000 data rows."
(name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit:
an empty unit cell is refused with `missing_unit`, and `1` (the UCUM unity) states a value without a unit, such
as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed
value beside it, otherwise the row is refused with `unit_mismatch`. Any other text in the value column is a
qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows."
parameters:
- in: query
name: dryRun
@@ -34146,6 +34147,11 @@ components:
minLength: 1
maxLength: 120
unit:
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
never read as `"1"` and stays refused.'
type: string
minLength: 1
maxLength: 40
@@ -34215,6 +34221,11 @@ components:
minLength: 1
maxLength: 120
unit:
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
never read as `"1"` and stays refused.'
type: string
minLength: 1
maxLength: 40
@@ -34309,6 +34320,11 @@ components:
minLength: 1
maxLength: 120
unit:
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice
and a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker
kept in a real unit and the other way round. Clients show nothing beside such a value. An empty unit
on a numeric value is never read as `"1"` and stays refused.'
type: string
minLength: 1
maxLength: 40
@@ -34363,6 +34379,11 @@ components:
type: string
minLength: 1
maxLength: 40
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
never read as `"1"` and stays refused.'
lowerBound:
type: number
upperBound:
@@ -34392,6 +34413,11 @@ components:
minLength: 1
maxLength: 120
unit:
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
never read as `"1"` and stays refused.'
type: string
minLength: 1
maxLength: 40
@@ -35759,6 +35785,11 @@ components:
maxLength: 300
- type: "null"
unit:
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and
a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in
a real unit and the other way round. Clients show nothing beside such a value. An empty unit on a
numeric value is never read as `"1"` and stays refused.'
anyOf:
- type: string
maxLength: 80
@@ -75275,6 +75306,11 @@ components:
- "null"
unit:
type: string
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
never read as `"1"` and stays refused.'
converted:
type: boolean
sourceValue:
@@ -75524,6 +75560,11 @@ components:
- "null"
unit:
type: string
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
never read as `"1"` and stays refused.'
converted:
type: boolean
sourceValue:
@@ -76503,6 +76544,11 @@ components:
type: string
unit:
type: string
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
never read as `"1"` and stays refused.'
lowerBound:
type:
- number
@@ -76602,6 +76648,11 @@ components:
type: string
unit:
type: string
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
never read as `"1"` and stays refused.'
lowerBound:
type:
- number
+3 -1
View File
@@ -14,6 +14,8 @@
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
import {
createBiomarkerSchema,
updateBiomarkerSchema,
@@ -54,7 +56,7 @@ const biomarkerRow = z
.object({
id: z.string(),
name: z.string(),
unit: z.string(),
unit: z.string().describe(LAB_UNIT_DESCRIPTION),
lowerBound: z.number().nullable(),
upperBound: z.number().nullable(),
panel: z.string().nullable(),
+1 -1
View File
@@ -553,7 +553,7 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
post: {
tags: ["Import"],
summary: "Import lab results from CSV",
description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`,
description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit: an empty unit cell is refused with \`missing_unit\`, and \`1\` (the UCUM unity) states a value without a unit, such as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`,
requestParams: { query: labsCsvQuerySchema },
requestBody: {
required: true,
+3 -1
View File
@@ -11,6 +11,8 @@ import { withRelated } from "./relation-links";
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
import {
createLabResultSchema,
labOverviewQuerySchema,
@@ -71,7 +73,7 @@ const labResultRow = z
// exactly one of the two is non-null per row.
value: z.number().nullable(),
valueText: z.string().nullable(),
unit: z.string(),
unit: z.string().describe(LAB_UNIT_DESCRIPTION),
converted: z.boolean(),
sourceValue: z.number().nullable(),
sourceUnit: z.string().nullable(),
+4 -2
View File
@@ -15,6 +15,8 @@
*/
import { z } from "zod/v4";
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
const requiredText = (max: number) => z.string().trim().min(1).max(max);
const lowerBound = z.number().finite().optional();
@@ -40,7 +42,7 @@ const optionalPanel = z
export const createBiomarkerSchema = z
.object({
name: requiredText(120),
unit: requiredText(40),
unit: requiredText(40).describe(LAB_UNIT_DESCRIPTION),
lowerBound,
upperBound,
context: optionalContext,
@@ -67,7 +69,7 @@ export type CreateBiomarkerInput = z.infer<typeof createBiomarkerSchema>;
export const updateBiomarkerSchema = z
.object({
name: requiredText(120).optional(),
unit: requiredText(40).optional(),
unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION),
lowerBound: lowerBound.or(z.null()),
upperBound: upperBound.or(z.null()),
context: optionalContext.or(z.null()),
+2 -2
View File
@@ -25,6 +25,7 @@
*/
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
import {
DOCUMENT_BULK_MAX_IDS,
DOCUMENT_CONTENT_INDEX_SOURCES,
@@ -34,7 +35,6 @@ import {
type DocumentContentIndexSourceValue,
type InboundDocumentKindValue,
} from "@/lib/documents/inbound-constants";
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { OCR_MAX_ROWS } from "@/lib/validations/labs-ocr";
@@ -443,7 +443,7 @@ const observationEditSchema = z
codeSystem: optCode,
value: z.number().finite().nullable().optional(),
valueText: optText(300),
unit: optText(80),
unit: optText(80).describe(LAB_UNIT_DESCRIPTION),
referenceLow: z.number().finite().nullable().optional(),
referenceHigh: z.number().finite().nullable().optional(),
referenceText: optText(120),
+8 -1
View File
@@ -19,6 +19,7 @@
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { z } from "zod/v4";
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
@@ -257,7 +258,13 @@ export const ocrCommitRowSchema = z
.or(z.literal("").transform(() => undefined)),
value: z.number().finite().optional(),
valueText: z.string().trim().min(1).max(OCR_VALUE_TEXT_MAX).optional(),
unit: z.string().trim().min(1).max(OCR_UNIT_MAX).optional(),
unit: z
.string()
.trim()
.min(1)
.max(OCR_UNIT_MAX)
.optional()
.describe(LAB_UNIT_DESCRIPTION),
referenceLow,
referenceHigh,
/**
+3 -2
View File
@@ -14,6 +14,7 @@
*/
import { z } from "zod/v4";
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -123,7 +124,7 @@ export const createLabResultSchema = z
// (`value`) OR qualitative (`valueText`), never both, never neither.
value: z.number().finite().optional(),
valueText,
unit: requiredText(40).optional(),
unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION),
referenceLow,
referenceHigh,
sourceReferenceLow,
@@ -208,7 +209,7 @@ export const updateLabResultSchema = z
// row's type (numeric ↔ qualitative) is intentionally not supported here —
// delete and re-add — so an omitted key leaves the row's existing type.
valueText: requiredText(120).optional(),
unit: requiredText(40).optional(),
unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION),
referenceLow: referenceLow.or(z.null()),
referenceHigh: referenceHigh.or(z.null()),
sourceReferenceLow: sourceReferenceLow.or(z.null()),