mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
A lab runs its own method on its own device, so the window a physician evaluates a value against is the one printed next to it on that report, not a catalog band averaged over every lab. Reading a report pulled the value and dropped that range: the extraction asked for it, the commit handed it to the biomarker resolver, and the resolver only uses it when it mints a NEW marker. For every analyte the user already had — the normal case after a first panel — the printed range was read and then thrown away. Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` to `LabResult` and carry the range from the report to the reading. The text is stored whether or not numbers can be read out of it, because "bis 5,0" and "negativ" state a window too; a range that could not be parsed is kept verbatim, never replaced with invented bounds. `resolveEffectiveReferenceRange` is the one place the precedence lives: the source window wins for that single reading, and the catalog stays the net for readings without one. A printed string with no derivable bound does not displace the catalog. Every surface that judges a reading routes through it — the API verdict, the doctor report, the insights blocks and per-marker assessment, the coach snapshot, the MCP read and the FHIR `referenceRange` — so a value cannot read in range on one surface and out on another. The API keeps `referenceLow` / `referenceHigh` as the window in force, so a client that reads only those two renders the right range and the right verdict with no change of its own; the catalog band, the source window, its origin and a divergence flag ride alongside for clients that want to show both. Read side: the chart draws the source window as a second, visually distinct band with the reading it came from named beneath it, the reading rows name the printed range and the catalog band where the two disagree, and the doctor-report PDF prints the report's own string with a footnote naming the saved range. Entry side: one optional field on the lab form, folded away until asked for, and the printed range is editable on the scan review screen. The range parser covers the notations German and English reports actually print — two-sided, one-sided, comma or dot decimals, thousands separators, trailing units — reads units without ever converting them, and refuses to guess on anything ambiguous.
226 lines
9.3 KiB
TypeScript
226 lines
9.3 KiB
TypeScript
/**
|
|
* v1.25 (W-DOCS-IN) — commit an APPROVED staged fact into a structured store.
|
|
*
|
|
* The only write path out of the staging area. Each approved fact is routed to
|
|
* its existing structured store through the same field-by-field create the
|
|
* manual / OCR paths use — no mass assignment, owner-scoped:
|
|
* - OBSERVATION → `LabResult` (resolve-or-mint the biomarker)
|
|
* - CONDITION → `IllnessEpisode` (condition journal)
|
|
* - MEDICATION_STATEMENT → `Medication` (as-needed record, no reminders)
|
|
*
|
|
* The app reproduces what the document stated; it never interprets. A Condition
|
|
* is stored with `type = OTHER` (mapping a free-text diagnosis to a clinical
|
|
* category would BE interpretation) and the stated status/code is transcribed
|
|
* verbatim into the encrypted note. A MedicationStatement is recorded as an
|
|
* as-needed medication with notifications off — a record of what the patient
|
|
* takes, never a prescription action. An Observation never carries a
|
|
* range-flag; the reference bounds ride along only as the document stated them.
|
|
*
|
|
* The window the report printed beside a value is carried onto the reading, not
|
|
* dropped. Until now it only seeded a freshly minted catalog marker, which meant
|
|
* that for every analyte the user already had, the range printed on the report
|
|
* was read and then thrown away. It now lands on the row's `sourceReference*`
|
|
* columns and governs that reading's verdict.
|
|
*/
|
|
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { prisma } from "@/lib/db";
|
|
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
|
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
|
import { decryptFactData } from "@/lib/documents/store";
|
|
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
|
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
|
|
import type { ExtractedFact } from "@/generated/prisma/client";
|
|
import type {
|
|
ConditionFactData,
|
|
MedicationStatementFactData,
|
|
ObservationFactData,
|
|
} from "@/lib/validations/inbound-documents";
|
|
|
|
/** A per-fact commit failure the caller maps to a per-fact 422 entry. */
|
|
export class FactCommitError extends Error {
|
|
code: string;
|
|
constructor(code: string, message: string) {
|
|
super(message);
|
|
this.name = "FactCommitError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export interface CommittedRecordRef {
|
|
recordType: "labResult" | "illnessEpisode" | "medication";
|
|
recordId: string;
|
|
}
|
|
|
|
/** Parse a stated YYYY-MM-DD into a UTC instant, or fall back to now. */
|
|
function statedDateOrNow(date: string | null): Date {
|
|
if (date && /^\d{4}-\d{2}-\d{2}$/u.test(date)) {
|
|
return new Date(`${date}T00:00:00.000Z`);
|
|
}
|
|
return new Date();
|
|
}
|
|
|
|
async function commitObservation(
|
|
userId: string,
|
|
data: ObservationFactData,
|
|
): Promise<CommittedRecordRef> {
|
|
const isQualitative = typeof data.value !== "number";
|
|
// A numeric reading needs a unit so the catalog has something to mint with —
|
|
// we never coerce or assume one (fail closed; the user adds it on the review
|
|
// screen).
|
|
if (!isQualitative && (!data.unit || !data.unit.trim())) {
|
|
throw new FactCommitError(
|
|
"observation.unitRequired",
|
|
"A numeric value needs a unit before it can be saved",
|
|
);
|
|
}
|
|
// The printed window, read once. `referenceText` is what the report wrote;
|
|
// the parser derives bounds from it where it can, and where it cannot the
|
|
// string still survives onto the row. A document staged before the field
|
|
// existed has no text, and the numeric bounds the model reported are then
|
|
// the whole of what it stated.
|
|
const printed = parseReferenceRange(data.referenceText, data.unit);
|
|
const sourceLow = printed?.low ?? data.referenceLow;
|
|
const sourceHigh = printed?.high ?? data.referenceHigh;
|
|
const sourceText = printed?.text ?? null;
|
|
|
|
const biomarker = await resolveOrMintBiomarker(userId, {
|
|
analyte: data.label,
|
|
unit: isQualitative ? (data.unit ?? "") : (data.unit as string),
|
|
referenceLow: isQualitative ? null : sourceLow,
|
|
referenceHigh: isQualitative ? null : sourceHigh,
|
|
panel: null,
|
|
});
|
|
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
|
|
// minted marker adopts the document's unit, so this only bites when the
|
|
// stated unit disagrees with the catalog's authoritative one (e.g. a
|
|
// document states "6.1 mmol/L" against a marker stored in mg/dL). Writing
|
|
// the stated number under the catalog's unit would corrupt the reading AND
|
|
// break the "transcribe verbatim" contract — so reject the fact and let the
|
|
// user reconcile on the review screen, exactly like the `unitRequired` gate.
|
|
if (!isQualitative) {
|
|
const stated = (data.unit as string).trim().toLowerCase();
|
|
const target = biomarker.unit.trim().toLowerCase();
|
|
if (stated !== target) {
|
|
throw new FactCommitError(
|
|
"observation.unitMismatch",
|
|
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
|
|
);
|
|
}
|
|
}
|
|
const created = await prisma.labResult.create({
|
|
data: {
|
|
userId,
|
|
biomarkerId: biomarker.id,
|
|
panel: biomarker.panel,
|
|
analyte: biomarker.name,
|
|
value: isQualitative ? null : (data.value as number),
|
|
valueText: isQualitative ? data.valueText : null,
|
|
unit: biomarker.unit,
|
|
referenceLow: biomarker.lowerBound,
|
|
referenceHigh: biomarker.upperBound,
|
|
// The report's own window for THIS reading. A qualitative result has no
|
|
// window to record.
|
|
sourceReferenceLow: isQualitative ? null : sourceLow,
|
|
sourceReferenceHigh: isQualitative ? null : sourceHigh,
|
|
sourceReferenceText: isQualitative ? null : sourceText,
|
|
takenAt: statedDateOrNow(data.effectiveDate),
|
|
source: "DOCUMENT",
|
|
noteEncrypted: null,
|
|
},
|
|
});
|
|
invalidateUserHealthScore(userId);
|
|
|
|
// v1.31.0 — the labs arm of the data-arrival spine. A "panel" has no
|
|
// first-class entity in the schema (it is a nullable label plus a `takenAt`),
|
|
// so the day-scoped singleton key IS the panel grouping: pasting twelve
|
|
// markers from one draw fires twelve emits that collapse into one arrival.
|
|
// Hooked HERE rather than in the confirm route because the route's
|
|
// `CommittedRecordRef` carries only the id — the draw date and panel label
|
|
// are lost by the time it returns.
|
|
void emitDataArrival({
|
|
userId,
|
|
kind: "labs_panel",
|
|
newestSampleAt: created.takenAt,
|
|
insertedCount: 1,
|
|
refId: created.panel ?? undefined,
|
|
source: "document",
|
|
}).catch(() => {});
|
|
|
|
return { recordType: "labResult", recordId: created.id };
|
|
}
|
|
|
|
async function commitCondition(
|
|
userId: string,
|
|
data: ConditionFactData,
|
|
): Promise<CommittedRecordRef> {
|
|
// Reproduce the stated status + code verbatim in the note — never interpret
|
|
// it into the `type` / `lifecycle` enums (that would assign meaning). The
|
|
// type stays OTHER so the diagnosis is recorded, not categorised.
|
|
const noteParts: string[] = [];
|
|
if (data.clinicalStatus) noteParts.push(`Status: ${data.clinicalStatus}`);
|
|
if (data.verificationStatus) {
|
|
noteParts.push(`Verification: ${data.verificationStatus}`);
|
|
}
|
|
if (data.code && data.codeSystem) {
|
|
noteParts.push(`Code: ${data.codeSystem} ${data.code}`);
|
|
}
|
|
const note = noteParts.length > 0 ? noteParts.join(" · ") : null;
|
|
|
|
const created = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId,
|
|
label: data.label,
|
|
type: "OTHER",
|
|
lifecycle: "ACUTE",
|
|
onsetAt: statedDateOrNow(data.onsetDate),
|
|
resolvedAt: null,
|
|
parentConditionId: null,
|
|
noteEncrypted: note ? encryptToBytes(note) : null,
|
|
},
|
|
});
|
|
return { recordType: "illnessEpisode", recordId: created.id };
|
|
}
|
|
|
|
async function commitMedication(
|
|
userId: string,
|
|
data: MedicationStatementFactData,
|
|
): Promise<CommittedRecordRef> {
|
|
const created = await prisma.medication.create({
|
|
data: {
|
|
userId,
|
|
name: data.name,
|
|
// The document may not state a dose; record it as unspecified rather
|
|
// than invent a number. A blank dose is not allowed by the column.
|
|
dose: data.dose && data.dose.trim() ? data.dose.trim() : "unspecified",
|
|
// A MedicationStatement is a RECORD of what the patient takes, not a
|
|
// prescription action: as-needed (no schedule) with reminders off.
|
|
asNeeded: true,
|
|
notificationsEnabled: false,
|
|
...(data.atcCode ? { atcCode: data.atcCode } : {}),
|
|
...(data.rxNormCode ? { rxNormCode: data.rxNormCode } : {}),
|
|
},
|
|
});
|
|
return { recordType: "medication", recordId: created.id };
|
|
}
|
|
|
|
/**
|
|
* Commit one approved fact into its structured store. Throws `FactCommitError`
|
|
* on a per-fact validation miss (e.g. a numeric observation with no unit) so
|
|
* the confirm route can report it without failing the whole batch.
|
|
*/
|
|
export async function commitApprovedFact(
|
|
userId: string,
|
|
fact: ExtractedFact,
|
|
): Promise<CommittedRecordRef> {
|
|
// Decrypt the staged payload at confirm time to write it into the normal
|
|
// structured store. Fail-closed: a bad key id throws and aborts the commit.
|
|
const data = decryptFactData(fact.dataEncrypted);
|
|
if (fact.factType === "OBSERVATION") {
|
|
return commitObservation(userId, data as ObservationFactData);
|
|
}
|
|
if (fact.factType === "CONDITION") {
|
|
return commitCondition(userId, data as ConditionFactData);
|
|
}
|
|
return commitMedication(userId, data as MedicationStatementFactData);
|
|
}
|