feat: AI-added records save with placeholders + "Added by AI" provenance

Stop blocking AI imports/proposals on missing non-critical fields. Records
the chat agent drafts now save with safe placeholders, auto-generated file
numbers, and a source="ai" marker that surfaces an "Added by AI" badge so a
clinician can review/edit them later.

Backend:
- add `source` (manual|ai) column to patients/appointments/prescriptions
  (migration 0014) + canonical types, services, validation schemas
- relax patient/appointment validation: empty file number allowed, demographic
  + type/provider/initials fall back to placeholders (initials derived from name)
- patients.generateFileNumber() auto-assigns an MRN when one is missing
- proposeAppointment accepts a name when no file number resolves; AI commits +
  /api/ai/import stamp source="ai"

Frontend:
- `source` on Appointment/Patient/Prescription types; AI commits send source="ai"
- reusable <AiBadge> shown on the Patients table/detail and prescriptions list

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-14 19:27:17 +03:00
parent 67249be67b
commit 929bec8f31
258 changed files with 28490 additions and 681 deletions
+27 -14
View File
@@ -1,20 +1,33 @@
import { z } from "zod";
import { initialsFromName } from "./initials.js";
// Payload accepted by POST/PUT /api/appointments. Mirrors the frontend
// `NewAppointment` shape; `status` defaults to "confirmed" on create.
export const appointmentInputSchema = z.object({
fileNumber: z.string().trim().default(""),
name: z.string().trim().min(1, "Patient name is required.").max(200),
initials: z.string().trim().min(1).max(4),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."),
type: z.string().trim().min(1, "Type is required.").max(120),
provider: z.string().trim().min(1, "Provider is required.").max(200),
status: z
.enum(["confirmed", "checked-in", "completed", "cancelled"])
.default("confirmed"),
});
//
// Soft fields (initials/type/provider) tolerate gaps so AI-drafted rows from a
// sparse import (e.g. just name/date/time) still validate: initials are derived
// from the name and type/provider fall back to placeholders. Such rows are
// stamped `source: "ai"` and flagged for clinician review in the UI.
export const appointmentInputSchema = z
.object({
fileNumber: z.string().trim().default(""),
name: z.string().trim().min(1, "Patient name is required.").max(200),
initials: z.string().trim().max(4).default(""),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."),
type: z.string().trim().max(120).default(""),
provider: z.string().trim().max(200).default(""),
status: z
.enum(["confirmed", "checked-in", "completed", "cancelled"])
.default("confirmed"),
source: z.enum(["manual", "ai"]).default("manual"),
})
.transform((v) => ({
...v,
initials: v.initials || initialsFromName(v.name),
type: v.type || "Unspecified",
provider: v.provider || "Unassigned",
}));
export type AppointmentInput = z.infer<typeof appointmentInputSchema>;
+10
View File
@@ -0,0 +1,10 @@
// Derive up-to-2-character initials from a patient name, for records (e.g. AI
// imports) that arrive without them. "Ahmed Ali" -> "AA"; "Ahmed" -> "AH".
export function initialsFromName(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "?";
if (words.length === 1) {
return (words[0] as string).slice(0, 2).toUpperCase();
}
return ((words[0]![0] ?? "") + (words.at(-1)![0] ?? "")).toUpperCase();
}
+44 -23
View File
@@ -1,7 +1,12 @@
import { z } from "zod";
import { initialsFromName } from "./initials.js";
const nonEmpty = z.string().trim().min(1);
const EMPTY_VITALS = { bp: "", hr: "", temp: "", spo2: "", takenAt: "" };
const EMPTY_TREND = { label: "", unit: "", points: [] as number[] };
export const allergySchema = z.object({
substance: nonEmpty,
reaction: nonEmpty,
@@ -49,28 +54,44 @@ export const trendSchema = z.object({
// A full patient payload — the frontend form sends the entire record on both
// create and edit, so the same schema covers both.
export const patientInputSchema = z.object({
fileNumber: z.string().trim().regex(/^\d+$/, "File number must be digits"),
name: nonEmpty,
age: z.number().int().min(0).max(150),
sex: z.enum(["M", "F"]),
pcp: z.string(),
// Optional link to the responsible clinician (user id). Empty string ⇒ null.
primaryProviderId: z.preprocess(
(v) => (v === "" ? null : v),
z.string().nullable().optional(),
),
status: z.enum(["active", "inpatient", "discharged"]),
initials: z.string().trim().min(1).max(4),
allergies: z.array(allergySchema).default([]),
alerts: z.array(z.string()).default([]),
medications: z.array(medicationSchema).default([]),
problems: z.array(problemSchema).default([]),
vitals: vitalsSchema,
vitalsTrend: trendSchema,
labs: z.array(labSchema).default([]),
labTrend: trendSchema,
encounters: z.array(encounterSchema).default([]),
});
//
// Tolerant by design: an AI import from a sparse export (e.g. just a name) still
// validates. The file number may be empty (the patient service auto-generates
// one), demographics fall back to safe placeholders, initials are derived from
// the name, and the clinical sections default to empty. Such rows are stamped
// `source: "ai"` and surfaced with an "Added by AI" badge for later editing.
export const patientInputSchema = z
.object({
fileNumber: z
.string()
.trim()
.regex(/^\d*$/, "File number must be digits")
.default(""),
name: nonEmpty,
age: z.coerce.number().int().min(0).max(150).default(0),
sex: z.enum(["M", "F"]).default("M"),
pcp: z.string().default(""),
// Optional link to the responsible clinician (user id). Empty string ⇒ null.
primaryProviderId: z.preprocess(
(v) => (v === "" ? null : v),
z.string().nullable().optional(),
),
status: z.enum(["active", "inpatient", "discharged"]).default("active"),
initials: z.string().trim().max(4).default(""),
allergies: z.array(allergySchema).default([]),
alerts: z.array(z.string()).default([]),
medications: z.array(medicationSchema).default([]),
problems: z.array(problemSchema).default([]),
vitals: vitalsSchema.default(EMPTY_VITALS),
vitalsTrend: trendSchema.default(EMPTY_TREND),
labs: z.array(labSchema).default([]),
labTrend: trendSchema.default(EMPTY_TREND),
encounters: z.array(encounterSchema).default([]),
source: z.enum(["manual", "ai"]).default("manual"),
})
.transform((v) => ({
...v,
initials: v.initials || initialsFromName(v.name),
}));
export type PatientInput = z.infer<typeof patientInputSchema>;
@@ -21,6 +21,7 @@ export const prescriptionInputSchema = z.object({
status: z.enum(["active", "completed", "expired"]).default("active"),
duration: z.string().trim().max(120).nullish(),
notes: z.string().max(5000).nullish(),
source: z.enum(["manual", "ai"]).default("manual"),
});
export type PrescriptionInput = z.infer<typeof prescriptionInputSchema>;