mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge remote-tracking branch 'origin/main' into wip/v140-E
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user