Merge remote-tracking branch 'origin/main' into wip/v140-E

This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:56:39 +02:00
commit f3ccc809a8
28 files changed
+891 -38

No files matched your search

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { normaliseLabUnit, sameLabUnit } from "../unit-normalise";
describe("sameLabUnit — spellings of one unit", () => {
it.each([
["mmol/L", "mmol/l"],
["mg/dL", "mg/dl"],
["mg/dL", "mg/DL"],
["mg/dL", " mg / dL "],
["mg/dL", "mg/ dL"],
["µg/L", "ug/L"],
["µg/L", "μg/L"],
["µg/L", "mcg/L"],
["µmol/L", "umol/l"],
["µIU/mL", "uIU/mL"],
["x10^3/µL", "x10^3/uL"],
["x10^3/µL", "x10^3/µl"],
["mg/dL", "MG/DL"],
["mmol/L", "MMOL/L"],
["µmol/L", "UMOL/L"],
["ng/mL", "NG/ML"],
["IU/L", "IU/l"],
["IU/L", "IU/L"],
])("%s = %s", (a, b) => {
expect(sameLabUnit(a, b)).toBe(true);
expect(sameLabUnit(b, a)).toBe(true);
});
});
describe("sameLabUnit — never merges units of different magnitude or kind", () => {
it.each([
// The point of the module: nothing that changes the number.
["mg/dL", "mmol/L"],
["mg/dL", "mg/L"],
["mg/L", "µg/L"],
["g/dL", "g/L"],
["ng/mL", "ng/dL"],
["µg/L", "mg/L"],
["%", "mmol/mol"],
["mmol/L", "µmol/L"],
["mmol/L", "mol/L"],
// Case that carries meaning stays significant.
["mIU/L", "MIU/L"],
["g/L", "G/L"],
["u/L", "U/L"],
["mmol/L", "Mmol/L"],
["pmol/L", "Pmol/L"],
// A unit the module does not know compares exactly, so it cannot be guessed.
["mg/dL", "mg/dl."],
["mg/dL", "mg per dL"],
["mg/dL", ""],
])("%s ≠ %s", (a, b) => {
expect(sameLabUnit(a, b)).toBe(false);
expect(sameLabUnit(b, a)).toBe(false);
});
it("does not turn a unit printed in capitals into one that is not on the list", () => {
// `G/L` shouted is the blood-count unit, not grams per litre.
expect(normaliseLabUnit("G/L")).toBe("G/L");
expect(normaliseLabUnit("MIU/L")).toBe("MIU/L");
});
it("keeps enzyme units apart from the micro prefix", () => {
expect(normaliseLabUnit("u/L")).toBe("u/L");
expect(normaliseLabUnit("U/L")).toBe("U/L");
});
});
describe("normaliseLabUnit", () => {
it("is idempotent", () => {
for (const unit of [
"ug/l",
"MG/DL",
"mmol/l",
" x10^3 / uL ",
"mIU/L",
"G/L",
]) {
const once = normaliseLabUnit(unit);
expect(normaliseLabUnit(once)).toBe(once);
}
});
it("names the canonical spelling", () => {
expect(normaliseLabUnit("mmol/l")).toBe("mmol/L");
expect(normaliseLabUnit("ug/l")).toBe("µg/L");
expect(normaliseLabUnit("MG/DL")).toBe("mg/dL");
});
});
+3 -2
View File
@@ -131,7 +131,7 @@ export class OcrExtractError extends Error {
* server-computed biomarker match + duplicate id. `reportDate` fills a row's
* date when the row itself carries none.
*/
async function annotateRow(
export async function annotateRow(
userId: string,
row: ExtractedRow,
reportDate: string | null,
@@ -144,7 +144,7 @@ async function annotateRow(
// Biomarker match — case-insensitive on the catalog identity, presence-only.
const existingBiomarker = await prisma.biomarker.findFirst({
where: { userId, name: { equals: analyte, mode: "insensitive" } },
select: { id: true },
select: { id: true, unit: true },
});
// Duplicate check: a live lab_results row with the same analyte (fuzzy,
@@ -198,6 +198,7 @@ async function annotateRow(
takenAt,
confidence: row.confidence,
biomarkerMatch: existingBiomarker ? "existing" : "new",
markerUnit: existingBiomarker?.unit ?? null,
duplicateOf,
};
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Spelling-level comparison of lab units.
*
* A reading may only be stored under a marker whose unit it actually is in, so
* the write paths compare the unit a reading arrives with against the unit the
* marker is tracked in. This module answers one narrow question: are these two
* strings the same unit written two ways?
*
* It resolves spelling, never magnitude. Two spellings are treated as equal
* only where no reading of either could mean a different quantity:
*
* - whitespace around the slash (`mg / dL`),
* - the micro prefix (`µg/L`, `μg/L` with the Greek mu, `ug/L`, `mcg/L`),
* - the case of the litre in the denominator (`mmol/l`, `mg/dl`, `mg/DL`),
* - a unit printed entirely in capitals (`MG/DL`, `MMOL/L`), but only for the
* short list of units where capitals cannot denote another real unit.
*
* Case is otherwise significant, on purpose. `mIU/L` and `MIU/L` differ by a
* factor of a billion (a lone capital M is mega, a lowercase m is milli), and
* `g/L` (grams) is not `G/L` (the 10^9/L of a blood count). A unit this module
* does not recognise is compared exactly, so an unfamiliar spelling surfaces as
* a mismatch rather than being guessed at.
*
* Pure and dependency-free.
*/
/** Units that may be recognised when printed entirely in capitals. */
const SHOUTED_UNITS: ReadonlyMap<string, string> = new Map(
[
"mg/dL",
"g/dL",
"ng/dL",
"µg/dL",
"mmol/L",
"µmol/L",
"nmol/L",
"pmol/L",
"ng/mL",
"pg/mL",
"µg/L",
"mEq/L",
"IU/L",
"U/L",
"fL",
].map((unit) => [unit.toLowerCase(), unit]),
);
const MICRO_SIGNS = /[µμ]/g;
/** What may follow a spelled-out micro prefix: g, mol, IU, U, L, kat, Eq. */
const MICRO_BEFORE = "(?=g|mol|IU|U|L|l|kat|Eq|eq)";
const MICRO_BEFORE_LOWER = "(?=g|mol|iu|u|l|kat|eq)";
function foldSpacing(raw: string): string {
return raw
.trim()
.replace(/\s+/g, " ")
.replace(/\s*\/\s*/g, "/");
}
function foldMicro(text: string, lookahead: string): string {
const unified = text.replace(MICRO_SIGNS, "µ");
// A spelled-out prefix counts only at the start of the unit or straight after
// a slash, and only in front of a unit that takes it. `u/L` (enzyme units)
// and `U/L` therefore stay as they are.
const prefix = new RegExp(`(^|/)(?:u|mc)${lookahead}`, "g");
return unified.replace(prefix, "$1µ");
}
/** `/l`, `/dl`, `/ml`, `/µl` in any case → `/L`, `/dL`, `/mL`, `/µL`. */
function foldLitre(text: string): string {
return text.replace(
/\/([dmµ]?)l$/i,
(_, prefix: string) => `/${prefix.toLowerCase()}L`,
);
}
/**
* The comparison form of a lab unit: the same string for every spelling that
* means the same thing, and the (trimmed) string itself otherwise.
*/
export function normaliseLabUnit(raw: string): string {
const spaced = foldSpacing(raw);
const hasLowercase = /[a-zµμ]/.test(spaced);
const hasUppercase = /[A-Z]/.test(spaced);
if (hasUppercase && !hasLowercase) {
const lowered = foldMicro(spaced.toLowerCase(), MICRO_BEFORE_LOWER);
const known = SHOUTED_UNITS.get(lowered);
if (known) return known;
}
return foldLitre(foldMicro(spaced, MICRO_BEFORE));
}
/** Whether two strings name the same lab unit (see the module comment). */
export function sameLabUnit(a: string, b: string): boolean {
return normaliseLabUnit(a) === normaliseLabUnit(b);
}
+1
View File
@@ -213,6 +213,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"labs.ocr.rateLimited",
"labs.restore.invalid",
"labs.result.notFound",
"labs.unit.mismatch",
"labs.update.invalid",
"labs.update.linkedFieldsImmutable",
"labs.update.numericExpected",
+5
View File
@@ -162,6 +162,11 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
"422": {
description:
"Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled.",
content: { "application/json": { schema: errorEnvelope } },
},
...recordWriteRateLimitResponse,
},
},
+10 -3
View File
@@ -72,6 +72,10 @@ const extractedRow = z
takenAt: z.string().nullable(),
confidence: extractConfidence,
biomarkerMatch: z.enum(["new", "existing"]),
markerUnit: z.string().nullable().meta({
description:
"The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason: unit_mismatch`, and the review screen marks it before Save.",
}),
duplicateOf: z.string().nullable(),
})
.meta({
@@ -138,7 +142,10 @@ const commitResponse = z
skipped: z.array(
z.object({
analyte: z.string(),
reason: z.literal("duplicate"),
reason: z.enum(["duplicate", "unit_mismatch"]).meta({
description:
"`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in, so it was not written.",
}),
}),
),
outcome: z.enum(["empty", "failed", "partial", "success"]).meta({
@@ -149,7 +156,7 @@ const commitResponse = z
.meta({
id: "OcrCommitResponse",
description:
'The rows written (`source: "OCR"`), those skipped as commit-time duplicates, and the resolved outcome.',
'The rows written (`source: "OCR"`), those skipped (commit-time duplicates, or a unit that differs from that of the marker), and the resolved outcome.',
});
export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
@@ -223,7 +230,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Labs"],
summary: "Commit confirmed Lab-OCR rows",
description:
'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading is skipped. Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.',
'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading, or states a unit other than that of its marker, is skipped (`skipped[].reason`). Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.',
requestBody: {
required: true,
content: { "application/json": { schema: ocrCommitRequest } },
+12 -2
View File
@@ -125,6 +125,12 @@ export interface OcrExtractedRowDto {
range: number;
};
biomarkerMatch: "new" | "existing";
/**
* The unit the matched marker is tracked in, or null for a marker that does
* not exist yet (it will adopt this row's unit). Lets the review screen say
* so before Save when the scanned unit differs.
*/
markerUnit: string | null;
duplicateOf: string | null;
}
@@ -231,10 +237,14 @@ export const ocrCommitSchema = z.object({
export type OcrCommitInput = z.infer<typeof ocrCommitSchema>;
/** A row the commit route skipped (a re-checked duplicate at commit time). */
/**
* A row the commit route did not write. `duplicate`: it matched a live reading
* at commit time. `unit_mismatch`: it states a unit other than the one its
* marker is tracked in.
*/
export interface OcrSkippedRowDto {
analyte: string;
reason: "duplicate";
reason: "duplicate" | "unit_mismatch";
}
export interface OcrCommitResponseDto {