/** * 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; /** 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; // ─── 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; // ─── 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; /** 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;