diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 202e7ec9d..4785a7a6a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index fc850c598..893724720 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -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(), diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index c22baff64..053cb10a8 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -553,7 +553,7 @@ export const importPaths: NonNullable = { 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, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 8e7527928..279011802 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -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(), diff --git a/src/lib/validations/biomarkers.ts b/src/lib/validations/biomarkers.ts index fa42d8de9..2841b3f9e 100644 --- a/src/lib/validations/biomarkers.ts +++ b/src/lib/validations/biomarkers.ts @@ -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; 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()), diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index a66239531..d6c336c26 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -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), diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 9e65d9a1c..8d92cafbc 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -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, /** diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index d19a4bfe5..8879d2032 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -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()),