mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
/**
|
|
* v1.25 (W-DOCS-IN) — inbound clinical-document ingestion contract.
|
|
*
|
|
* A self-hoster uploads a doctor report / discharge letter they received. The
|
|
* dedicated OCR/vision provider transcribes STRUCTURED FACTS that land on a
|
|
* MANDATORY review-then-confirm screen; only the user-approved facts reach the
|
|
* structured stores (labs / conditions / medications) through their normal
|
|
* create paths.
|
|
*
|
|
* The safety line this module encodes: EXTRACT, NEVER INTERPRET. The provider
|
|
* is asked to reproduce what is written (text, value, code, date) with
|
|
* provenance and a confidence gate — it never infers a code, never flags a
|
|
* value high/low, never links a condition to a medication, never assigns
|
|
* meaning, severity, or a diagnosis. Diagnoses are stored as the document's
|
|
* STATED assertion about the patient, never as the app's conclusion.
|
|
*
|
|
* This module holds:
|
|
* - `inboundExtractionSchema` — the schema the provider's JSON is validated
|
|
* against (UNTRUSTED model output). Liberal on shape; the review screen is
|
|
* the safety boundary.
|
|
* - the staged-fact DTO shapes the routes return + the UI consumes.
|
|
* - `inboundFactEditSchema` — the per-fact correction the review screen sends
|
|
* before approval (fixes OCR / units / dates / codes).
|
|
* - `inboundConfirmSchema` — the approve/reject decisions. No `userId` field;
|
|
* it is always narrowed from the session.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
|
|
/** Max facts a single document may stage / confirm. A dense letter is ~40. */
|
|
export const INBOUND_MAX_FACTS = 120;
|
|
|
|
/**
|
|
* Confidence floor. A fact scoring below this fails closed: it is staged with
|
|
* `needsReview = true` and cannot be approved until the user edits it (the
|
|
* values then become user-asserted). When unsure, the app surfaces raw text
|
|
* for manual entry rather than guessing a value.
|
|
*/
|
|
export const INBOUND_CONFIDENCE_FLOOR = 0.6;
|
|
|
|
/** The three FHIR resources a fact maps to (stated status only). */
|
|
export const INBOUND_FACT_TYPES = [
|
|
"CONDITION",
|
|
"OBSERVATION",
|
|
"MEDICATION_STATEMENT",
|
|
] as const;
|
|
export type InboundFactType = (typeof INBOUND_FACT_TYPES)[number];
|
|
|
|
/** The document-kind labels (no interpretation — a label only). */
|
|
export const INBOUND_DOCUMENT_KINDS = [
|
|
"DOCTOR_REPORT",
|
|
"DISCHARGE_LETTER",
|
|
"OTHER",
|
|
] as const;
|
|
export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number];
|
|
|
|
/**
|
|
* The coding systems a fact's STATED code may belong to. The provider emits a
|
|
* code ONLY when the document writes one — it never machine-guesses. Mirrors
|
|
* the systems the existing FHIR mappers already speak (SNOMED/ICD-10 for a
|
|
* Condition, LOINC for an Observation, RxNorm/ATC for a MedicationStatement).
|
|
*/
|
|
export const INBOUND_CODE_SYSTEMS = [
|
|
"SNOMED",
|
|
"ICD10",
|
|
"LOINC",
|
|
"RXNORM",
|
|
"ATC",
|
|
] as const;
|
|
export type InboundCodeSystem = (typeof INBOUND_CODE_SYSTEMS)[number];
|
|
|
|
const confidenceScore = z.number().min(0).max(1).catch(0);
|
|
const nullableText = (max: number) =>
|
|
z.string().trim().min(1).max(max).nullable().catch(null);
|
|
|
|
/**
|
|
* One fact as the PROVIDER returns it (untrusted). A single flat shape across
|
|
* the three fact types so the wire stays simple; the route maps it to the
|
|
* per-type staged payload. Everything is `.catch`-guarded so a malformed field
|
|
* degrades to null + low confidence rather than 422-ing the whole extraction.
|
|
*/
|
|
export const extractedFactRawSchema = z.object({
|
|
type: z.enum(INBOUND_FACT_TYPES),
|
|
/** Verbatim label / diagnosis text / medication name as written. */
|
|
label: z.string().trim().min(1).max(300).catch(""),
|
|
/** STATED code, present only when the document writes one. */
|
|
code: nullableText(64),
|
|
codeSystem: z.enum(INBOUND_CODE_SYSTEMS).nullable().catch(null),
|
|
/** Condition: stated clinical / verification status (verbatim). */
|
|
clinicalStatus: nullableText(64),
|
|
verificationStatus: nullableText(64),
|
|
/** Observation: numeric XOR qualitative value, unit, stated ref bounds. */
|
|
value: z.number().finite().nullable().catch(null),
|
|
valueText: nullableText(300),
|
|
unit: nullableText(80),
|
|
referenceLow: z.number().finite().nullable().catch(null),
|
|
referenceHigh: z.number().finite().nullable().catch(null),
|
|
/** MedicationStatement: dose + stated status (verbatim). */
|
|
dose: nullableText(120),
|
|
medicationStatus: nullableText(64),
|
|
/** A stated date for the fact (onset / effective / report), ISO or null. */
|
|
effectiveDate: z.string().nullable().catch(null),
|
|
/** Provenance: the verbatim source span this fact came from. */
|
|
sourceText: z.string().trim().max(2000).catch(""),
|
|
/** Optional 0-based page index the fact was read from. */
|
|
page: z.number().int().min(0).max(10000).nullable().catch(null),
|
|
/** The model's self-reported overall confidence for this fact (0..1). */
|
|
confidence: confidenceScore.default(0),
|
|
});
|
|
|
|
export type ExtractedFactRaw = z.infer<typeof extractedFactRawSchema>;
|
|
|
|
/** The full JSON envelope the provider returns. */
|
|
export const inboundExtractionSchema = z.object({
|
|
reportDate: z.string().nullable().catch(null),
|
|
kind: z.enum(INBOUND_DOCUMENT_KINDS).nullable().catch(null),
|
|
facts: z.array(extractedFactRawSchema).max(INBOUND_MAX_FACTS).catch([]),
|
|
});
|
|
|
|
export type InboundExtraction = z.infer<typeof inboundExtractionSchema>;
|
|
|
|
// ─── Staged-fact payloads (FHIR-staged, stated status only) ────────────────
|
|
|
|
/** Per-field provenance carried on every staged fact. */
|
|
export interface FactProvenance {
|
|
/** The verbatim source span the value was transcribed from. */
|
|
sourceText: string;
|
|
/** Optional 0-based page index. */
|
|
page: number | null;
|
|
/** The model's self-reported confidence (0..1). */
|
|
confidence: number;
|
|
}
|
|
|
|
/** Condition staging payload — maps to FHIR `Condition` (stated status). */
|
|
export interface ConditionFactData {
|
|
label: string;
|
|
code: string | null;
|
|
codeSystem: InboundCodeSystem | null;
|
|
clinicalStatus: string | null;
|
|
verificationStatus: string | null;
|
|
onsetDate: string | null;
|
|
}
|
|
|
|
/** Observation staging payload — maps to FHIR `Observation` (no range-flag). */
|
|
export interface ObservationFactData {
|
|
label: string;
|
|
code: string | null;
|
|
codeSystem: InboundCodeSystem | null;
|
|
value: number | null;
|
|
valueText: string | null;
|
|
unit: string | null;
|
|
referenceLow: number | null;
|
|
referenceHigh: number | null;
|
|
effectiveDate: string | null;
|
|
}
|
|
|
|
/** MedicationStatement staging payload — maps to FHIR `MedicationStatement`. */
|
|
export interface MedicationStatementFactData {
|
|
name: string;
|
|
dose: string | null;
|
|
rxNormCode: string | null;
|
|
atcCode: string | null;
|
|
statusStated: string | null;
|
|
effectiveDate: string | null;
|
|
}
|
|
|
|
export type FactData =
|
|
ConditionFactData | ObservationFactData | MedicationStatementFactData;
|
|
|
|
/** The staged-fact DTO the routes return + the review UI consumes. */
|
|
export interface ExtractedFactDto {
|
|
id: string;
|
|
factType: InboundFactType;
|
|
status: "PENDING" | "APPROVED" | "REJECTED";
|
|
confidence: number;
|
|
needsReview: boolean;
|
|
data: FactData;
|
|
provenance: FactProvenance;
|
|
committedRecordId: string | null;
|
|
committedRecordType: string | null;
|
|
}
|
|
|
|
/** The document DTO (list + detail). */
|
|
export interface InboundDocumentDto {
|
|
id: string;
|
|
kind: InboundDocumentKindValue;
|
|
filename: string | null;
|
|
mimeType: string;
|
|
byteSize: number;
|
|
status: "EXTRACTING" | "EXTRACTED" | "FAILED" | "CONFIRMED" | "DISCARDED";
|
|
providerType: string | null;
|
|
reportDate: string | null;
|
|
errorReason: string | null;
|
|
factCount: number;
|
|
pendingCount: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface InboundDocumentDetailDto extends InboundDocumentDto {
|
|
facts: ExtractedFactDto[];
|
|
}
|
|
|
|
// ─── Edit (correction before approval) ─────────────────────────────────────
|
|
|
|
const reqText = (max: number) => z.string().trim().min(1).max(max);
|
|
const optText = (max: number) =>
|
|
z
|
|
.string()
|
|
.trim()
|
|
.max(max)
|
|
.nullable()
|
|
.optional()
|
|
.transform((v) => (v === "" ? null : v));
|
|
const optCode = z.enum(INBOUND_CODE_SYSTEMS).nullable().optional();
|
|
const isoDate = z
|
|
.string()
|
|
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected a YYYY-MM-DD date")
|
|
.nullable()
|
|
.optional();
|
|
|
|
const conditionEditSchema = z.object({
|
|
factType: z.literal("CONDITION"),
|
|
label: reqText(300),
|
|
code: optText(64),
|
|
codeSystem: optCode,
|
|
clinicalStatus: optText(64),
|
|
verificationStatus: optText(64),
|
|
onsetDate: isoDate,
|
|
});
|
|
|
|
const observationEditSchema = z
|
|
.object({
|
|
factType: z.literal("OBSERVATION"),
|
|
label: reqText(300),
|
|
code: optText(64),
|
|
codeSystem: optCode,
|
|
value: z.number().finite().nullable().optional(),
|
|
valueText: optText(300),
|
|
unit: optText(80),
|
|
referenceLow: z.number().finite().nullable().optional(),
|
|
referenceHigh: z.number().finite().nullable().optional(),
|
|
effectiveDate: isoDate,
|
|
})
|
|
// Numeric XOR qualitative, mirroring the lab-result discipline. A null/absent
|
|
// value with a null/absent valueText is allowed (the user may still be
|
|
// entering it) but not BOTH set.
|
|
.refine(
|
|
(d) => !(typeof d.value === "number" && typeof d.valueText === "string"),
|
|
{
|
|
message: "Provide a numeric value OR qualitative text, not both",
|
|
path: ["value"],
|
|
},
|
|
);
|
|
|
|
const medicationEditSchema = z.object({
|
|
factType: z.literal("MEDICATION_STATEMENT"),
|
|
name: reqText(200),
|
|
dose: optText(120),
|
|
rxNormCode: optText(20),
|
|
atcCode: optText(16),
|
|
statusStated: optText(64),
|
|
effectiveDate: isoDate,
|
|
});
|
|
|
|
/**
|
|
* A correction to a staged fact (the review screen edits OCR / units / dates /
|
|
* codes before approval). Discriminated by `factType` so the edit can never
|
|
* change a fact's resource type. A successful edit clears `needsReview` — the
|
|
* values become user-asserted.
|
|
*/
|
|
export const inboundFactEditSchema = z.discriminatedUnion("factType", [
|
|
conditionEditSchema,
|
|
observationEditSchema,
|
|
medicationEditSchema,
|
|
]);
|
|
|
|
export type InboundFactEdit = z.infer<typeof inboundFactEditSchema>;
|
|
|
|
// ─── Confirm (approve / reject) ────────────────────────────────────────────
|
|
|
|
/**
|
|
* The approve/reject decisions the user made on the review screen. Each
|
|
* decision names a staged fact by id. Approved facts are committed to the
|
|
* structured stores; rejected facts are discarded. No `userId` field — it is
|
|
* always narrowed from the session; the route also re-scopes every fact id to
|
|
* the document + the caller.
|
|
*/
|
|
export const inboundConfirmSchema = z.object({
|
|
decisions: z
|
|
.array(
|
|
z.object({
|
|
factId: z.string().trim().min(1).max(40),
|
|
action: z.enum(["approve", "reject"]),
|
|
}),
|
|
)
|
|
.min(1)
|
|
.max(INBOUND_MAX_FACTS),
|
|
});
|
|
|
|
export type InboundConfirmInput = z.infer<typeof inboundConfirmSchema>;
|
|
|
|
/** The text-mode (local-OCR) extract body, mirroring the Lab-OCR text mode. */
|
|
export const INBOUND_TEXT_MAX_CHARS = 200_000;
|
|
|
|
export const inboundTextExtractSchema = z.object({
|
|
mode: z.literal("text"),
|
|
text: z.string().trim().min(1).max(INBOUND_TEXT_MAX_CHARS),
|
|
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
|
});
|
|
|
|
export type InboundTextExtractInput = z.infer<typeof inboundTextExtractSchema>;
|