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

+16
View File
@@ -33,6 +33,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 { sameLabUnit } from "@/lib/labs/unit-normalise";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { serialiseLabResult } from "@/lib/labs/serialise";
import {
@@ -178,6 +179,21 @@ async function commitOcrRows(request: NextRequest) {
panel: row.panel ?? null,
});
// The marker's unit is the one the reading is stored under, so a row that
// states another unit cannot be written: the number would be kept under a
// unit it is not in. The review screen marks such a row before Save; this
// is the backstop for a client that did not, and for a marker whose unit
// changed between the scan and the commit. A freshly minted marker adopted
// this row's unit, so it cannot disagree.
if (
!isQualitative &&
row.unit !== undefined &&
!sameLabUnit(row.unit, biomarker.unit)
) {
skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" });
continue;
}
// Field-by-field — never spread the parsed row. The row stamps the resolved
// catalog name/unit/range as historical truth and keeps the FK.
const created = await prisma.labResult.create({
+27
View File
@@ -18,6 +18,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 { sameLabUnit } from "@/lib/labs/unit-normalise";
import {
type ResolvedBiomarker,
serialiseLabResult,
@@ -252,6 +253,32 @@ async function postLabResult(request: NextRequest) {
});
}
// The marker's unit is the one every reading is stored under, so a reading
// that states another unit cannot be written: the number would be kept and
// the label swapped, and nothing downstream could tell. Compared only when a
// unit was actually sent: on the `biomarkerId` path it may be omitted, and
// then the marker's unit is the stated one. A freshly minted marker adopted
// the reading's own unit, so it cannot disagree with it.
if (
!isQualitative &&
unit !== undefined &&
!sameLabUnit(unit, biomarker.unit)
) {
annotate({
action: { name: "labs.create.unit-mismatch" },
meta: { biomarkerId: biomarker.id },
});
return apiError(
`The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`,
422,
{
errorCode: "labs.unit.mismatch",
markerUnit: biomarker.unit,
readingUnit: unit,
},
);
}
// Field-by-field assignment — never spread `parsed.data`. The row stamps the
// resolved name/unit/range as historical truth (so a later catalog edit does
// not silently rewrite a past reading) AND keeps the FK; reads resolve the
@@ -12,6 +12,7 @@ import {
handleFilePickerChange,
OcrPageCoverageNote,
OcrReviewDialog,
unitSkippedAnalytes,
} from "../ocr-review-dialog";
const hookState = vi.hoisted(() => ({ extractPending: false }));
@@ -223,3 +224,40 @@ describe("<OcrPageCoverageNote> — a long PDF read from its first pages", () =>
expect(html).toBe("");
});
});
describe("unitSkippedAnalytes", () => {
const unit = (analyte: string) => ({
analyte,
reason: "unit_mismatch" as const,
});
it("is null when nothing was skipped for a unit", () => {
expect(unitSkippedAnalytes([])).toBeNull();
expect(
unitSkippedAnalytes([{ analyte: "LDL", reason: "duplicate" }]),
).toBeNull();
});
it("names the analytes skipped for a unit, ignoring duplicates", () => {
expect(
unitSkippedAnalytes([
{ analyte: "LDL", reason: "duplicate" },
unit("Glucose"),
unit("Creatinine"),
]),
).toBe("Glucose, Creatinine");
});
it("lists three and counts the rest, once each", () => {
expect(
unitSkippedAnalytes([
unit("A"),
unit("A"),
unit("B"),
unit("C"),
unit("D"),
unit("E"),
]),
).toBe("A, B, C +2");
});
});
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import type { OcrReviewRow } from "../ocr-review-types";
import { seedReviewRows } from "../ocr-review-types";
import {
collectRowErrors,
planSave,
readingUnitDiffers,
toCommitRow,
validateReviewRow,
} from "../ocr-review-validation";
@@ -21,6 +23,7 @@ function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
takenAt: "2026-06-10",
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
markerUnit: "mmol/L",
duplicateOf: null,
confirmed: true,
...overrides,
@@ -140,3 +143,72 @@ describe("planSave", () => {
}
});
});
describe("a reading in another unit than its marker's", () => {
it.each([
["the same unit", { unit: "mmol/L" }, false],
["the same unit, spelled differently", { unit: "mmol/l" }, false],
["a different unit", { unit: "mg/dL" }, true],
["a different magnitude of the same substance", { unit: "µmol/L" }, true],
["a new marker (no unit yet)", { unit: "mg/dL", markerUnit: null }, false],
["no unit stated", { unit: "", markerUnit: "mmol/L" }, false],
[
"a qualitative result",
{ value: null, valueText: "negative", unit: "mg/dL" },
false,
],
] as const)("%s → differs: %s", (_name, overrides, differs) => {
expect(readingUnitDiffers(row(overrides))).toBe(differs);
});
it("is a blocking error on the unit field, distinct from a missing unit", () => {
expect(validateReviewRow(row({ unit: "mg/dL" }))).toEqual({
unitMismatch: true,
});
expect(validateReviewRow(row({ unit: "" }))).toEqual({ unit: true });
});
it("cannot be turned into a payload, whatever its other fields say", () => {
expect(toCommitRow(row({ unit: "mg/dL" }))).toBeNull();
expect(toCommitRow(row({ unit: "mmol/l" }))?.unit).toBe("mmol/l");
});
it("blocks the whole save when it is selected, and not when it is not", () => {
expect(
planSave([row({ key: "a" }), row({ key: "b", unit: "mg/dL" })]),
).toEqual({ kind: "blocked" });
const plan = planSave([
row({ key: "a" }),
row({ key: "b", unit: "mg/dL", confirmed: false }),
]);
expect(plan.kind).toBe("ready");
expect(
collectRowErrors([row({ unit: "mg/dL", confirmed: false })]).size,
).toBe(0);
});
it("starts unselected on the review screen, like a duplicate, so it is a choice", () => {
const dto = (unit: string, markerUnit: string | null) => ({
analyte: "Glucose",
value: 5.6,
valueText: null,
unit,
referenceLow: null,
referenceHigh: null,
referenceText: null,
takenAt: "2026-06-10",
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing" as const,
markerUnit,
duplicateOf: null,
});
const [same, other, fresh] = seedReviewRows(
[dto("mmol/L", "mmol/L"), dto("mmol/L", "mg/dL"), dto("mmol/L", null)],
null,
);
expect(same.confirmed).toBe(true);
expect(other.confirmed).toBe(false);
expect(other.markerUnit).toBe("mg/dL");
expect(fresh.confirmed).toBe(true);
});
});
@@ -18,6 +18,7 @@ const baseRow: OcrReviewRow = {
takenAt: null,
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
markerUnit: "mmol/L",
duplicateOf: null,
confirmed: true,
};
@@ -115,3 +116,42 @@ describe("<OcrRowEditor> layout", () => {
}
});
});
describe("<OcrRowEditor> unit differs from the marker's", () => {
const mismatched: OcrReviewRow = {
...baseRow,
takenAt: "2026-06-10",
unit: "mg/dL",
markerUnit: "mmol/L",
};
it("says so before any save attempt, in calm wording", () => {
const html = render(undefined, "en", mismatched);
expect(html).toContain("Tracked in mmol/L; this report says mg/dL");
expect(html).not.toContain('aria-invalid="true"');
expect(html).not.toContain('role="alert"');
});
it("marks the unit field and names the marker's unit once a save was refused", () => {
const html = render({ unitMismatch: true }, "en", mismatched);
const invalid = html.match(/<input[^>]*aria-invalid="true"[^>]*>/g) ?? [];
expect(invalid).toHaveLength(1);
expect(invalid[0]).toMatch(/id="[^"]*-unit"/);
expect(html).toContain("This marker is tracked in mmol/L.");
});
it("shows nothing when the units agree, however they are spelled", () => {
const html = render(undefined, "en", { ...mismatched, unit: "mmol/l" });
expect(html).not.toContain("Tracked in");
});
it("shows nothing for a new marker, which adopts the row's unit", () => {
const html = render(undefined, "en", { ...mismatched, markerUnit: null });
expect(html).not.toContain("Tracked in");
});
it("speaks German too", () => {
const html = render(undefined, "de", mismatched);
expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL");
});
});
+39 -6
View File
@@ -19,6 +19,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError } from "@/lib/api/api-fetch";
import type { OcrSkippedRowDto } from "@/lib/validations/labs-ocr";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
@@ -53,6 +54,25 @@ export function handleFilePickerChange(
if (file) onFilePicked(file);
}
/**
* The analytes the commit skipped for a unit other than their marker's, as one
* short phrase for a toast (the first three, then a count), or null when none.
*/
export function unitSkippedAnalytes(
skipped: readonly OcrSkippedRowDto[],
): string | null {
const names = [
...new Set(
skipped
.filter((row) => row.reason === "unit_mismatch")
.map((row) => row.analyte),
),
];
if (names.length === 0) return null;
const shown = names.slice(0, 3).join(", ");
return names.length > 3 ? `${shown} +${names.length - 3}` : shown;
}
/**
* Translate an extract failure into a friendly, error-code-aware message.
* Refusals are read by `meta.errorCode`: a 403 is a missing consent only when
@@ -241,19 +261,32 @@ export function OcrReviewDialog({
// used to be green on any 200 and print the count regardless; the
// server resolves the outcome from the two lists and the dialog
// renders it.
// A row skipped for its unit is the one the person has to act on, so
// when there is one the message names it; the plain "already
// recorded" wording would be wrong for it.
const unitSkipped = unitSkippedAnalytes(result.skipped);
toastWrittenOutcome(
result.outcome,
result.outcome === "success"
? t("labs.ocr.savedToast", { count: result.inserted.length })
: result.outcome === "partial"
? t("labs.ocr.savedPartialToast", {
count: result.inserted.length,
skipped: result.skipped.length,
})
: result.outcome === "failed"
? t("labs.ocr.savedNothingToast", {
? unitSkipped
? t("labs.ocr.savedPartialUnitToast", {
count: result.inserted.length,
analytes: unitSkipped,
})
: t("labs.ocr.savedPartialToast", {
count: result.inserted.length,
skipped: result.skipped.length,
})
: result.outcome === "failed"
? unitSkipped
? t("labs.ocr.savedNothingUnitToast", {
analytes: unitSkipped,
})
: t("labs.ocr.savedNothingToast", {
skipped: result.skipped.length,
})
: t("labs.ocr.savedEmptyToast"),
);
onCommitted();
+19 -2
View File
@@ -7,6 +7,8 @@
*/
import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr";
import { readingUnitDiffers } from "./ocr-review-validation";
export interface OcrReviewRow {
/** Stable client key for the list (the analyte+index at seed time). */
key: string;
@@ -22,6 +24,11 @@ export interface OcrReviewRow {
takenAt: string | null;
confidence: OcrExtractedRowDto["confidence"];
biomarkerMatch: "new" | "existing";
/**
* The unit the matched marker is tracked in; null for a marker that does not
* exist yet. A numeric row whose `unit` differs from it cannot be saved.
*/
markerUnit: string | null;
/** Non-null when the server flagged a likely duplicate of a live reading. */
duplicateOf: string | null;
/** Whether the row is selected for saving. Defaults false for duplicates. */
@@ -45,8 +52,18 @@ export function seedReviewRows(
takenAt: row.takenAt ?? fallbackDate,
confidence: row.confidence,
biomarkerMatch: row.biomarkerMatch,
markerUnit: row.markerUnit,
duplicateOf: row.duplicateOf,
// A flagged duplicate starts unchecked; everything else starts confirmed.
confirmed: row.duplicateOf === null,
// A flagged duplicate starts unchecked, and so does a reading in another
// unit than its marker's (it carries a badge saying why, and ticking it
// marks the unit field until it is fixed); everything else starts
// confirmed.
confirmed:
row.duplicateOf === null &&
!readingUnitDiffers({
valueText: row.valueText,
unit: row.unit,
markerUnit: row.markerUnit,
}),
}));
}
+23 -1
View File
@@ -7,10 +7,13 @@
* build the payload: it returns null exactly when `validateReviewRow` reports
* at least one field.
*/
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import type { OcrReviewRow } from "./ocr-review-types";
import type { OcrCommitRowInput } from "./use-ocr-extract";
export type OcrRowField = "analyte" | "value" | "valueText" | "unit" | "date";
export type OcrRowField =
"analyte" | "value" | "valueText" | "unit" | "unitMismatch" | "date";
/** The fields of one row that block saving; empty when the row is writable. */
export type OcrRowErrors = Partial<Record<OcrRowField, true>>;
@@ -26,6 +29,23 @@ function isQualitativeRow(row: OcrReviewRow): boolean {
return row.valueText !== null && row.valueText !== undefined;
}
/**
* Whether a numeric reading states a unit other than the one its marker is
* tracked in. A qualitative reading has no unit, a new marker adopts the row's
* own, and a row with no unit yet is a different problem (`unit`), so none of
* those count. The same rule the commit applies when it skips such a row.
*/
export function readingUnitDiffers(row: {
valueText: string | null | undefined;
unit: string | null | undefined;
markerUnit: string | null | undefined;
}): boolean {
if (row.valueText !== null && row.valueText !== undefined) return false;
const stated = (row.unit ?? "").trim();
if (!row.markerUnit || !stated) return false;
return !sameLabUnit(stated, row.markerUnit);
}
export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
const errors: OcrRowErrors = {};
if (!row.analyte.trim()) errors.analyte = true;
@@ -36,6 +56,7 @@ export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
} else {
if (row.value === null || !Number.isFinite(row.value)) errors.value = true;
if (!(row.unit ?? "").trim()) errors.unit = true;
if (readingUnitDiffers(row)) errors.unitMismatch = true;
}
return errors;
}
@@ -59,6 +80,7 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
if (row.value === null || !Number.isFinite(row.value)) return null;
const unit = (row.unit ?? "").trim();
if (!unit) return null;
if (readingUnitDiffers(row)) return null;
return {
analyte,
value: row.value,
+31 -4
View File
@@ -32,7 +32,7 @@ import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import type { OcrReviewRow } from "./ocr-review-types";
import type { OcrRowErrors } from "./ocr-review-validation";
import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation";
/** Below this per-field confidence the field is flagged for a second look. */
const CONFIDENCE_THRESHOLD = 0.6;
@@ -65,10 +65,21 @@ export function OcrRowEditor({
"aria-describedby": `${fieldId}-${field}-error`,
}
: {};
const fieldError = (field: keyof OcrRowErrors, messageKey: string) =>
const fieldError = (
field: keyof OcrRowErrors,
messageKey: string,
params?: Record<string, string>,
) =>
errors?.[field] ? (
<FieldError id={`${fieldId}-${field}-error`} message={t(messageKey)} />
<FieldError
id={`${fieldId}-${field}-error`}
message={t(messageKey, params)}
/>
) : null;
// Stated before Save, not only after a failed attempt: a reading in another
// unit than its marker's cannot be written, and the person should see why
// the row is unticked (or will not save) while they are still reading it.
const unitDiffers = readingUnitDiffers(row);
const isQualitative = row.valueText !== null && row.valueText !== undefined;
const lowValueConfidence =
@@ -158,6 +169,17 @@ export function OcrRowEditor({
{t("labs.ocr.duplicateWarning")}
</Badge>
) : null}
{unitDiffers ? (
<Badge variant="secondary" className="max-w-full">
<Ruler aria-hidden />
<span className="truncate">
{t("labs.ocr.unitDiffersBadge", {
markerUnit: row.markerUnit ?? "",
unit: (row.unit ?? "").trim(),
})}
</span>
</Badge>
) : null}
{lowRowConfidence ? (
<Badge variant="secondary" className="text-muted-foreground">
<AlertCircle aria-hidden />
@@ -224,9 +246,14 @@ export function OcrRowEditor({
id={`${fieldId}-unit`}
value={row.unit ?? ""}
onChange={(e) => onChange({ ...row, unit: e.target.value })}
{...invalidProps("unit")}
{...invalidProps(
errors?.unitMismatch ? "unitMismatch" : "unit",
)}
/>
{fieldError("unit", "labs.ocr.errorUnit")}
{fieldError("unitMismatch", "labs.ocr.errorUnitMismatch", {
markerUnit: row.markerUnit ?? "",
})}
</div>
</>
)}
@@ -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 {