mirror of
https://github.com/temetro/temetro.git
synced 2026-08-16 21:38:26 +00:00
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:
@@ -22,6 +22,9 @@ export const appointments = pgTable(
|
||||
type: text("type").notNull(),
|
||||
provider: text("provider").notNull(),
|
||||
status: text("status").$type<AppointmentStatus>().notNull(),
|
||||
// Provenance: "ai" rows were drafted by the chat agent (possibly with
|
||||
// placeholder fields) and are flagged for clinician review/edit.
|
||||
source: text("source").$type<"manual" | "ai">().notNull().default("manual"),
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -50,6 +50,10 @@ export const patients = pgTable(
|
||||
primaryProviderId: text("primary_provider_id").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Provenance: "ai" rows were imported/drafted by the chat agent (possibly
|
||||
// with auto-generated file numbers or placeholder fields) and are flagged
|
||||
// for clinician review/edit.
|
||||
source: text("source").$type<"manual" | "ai">().notNull().default("manual"),
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -31,6 +31,8 @@ export const prescriptions = pgTable(
|
||||
status: text("status").$type<PrescriptionStatus>().notNull(),
|
||||
duration: text("duration"),
|
||||
notes: text("notes"),
|
||||
// Provenance: "ai" rows were drafted by the chat agent and flagged for review.
|
||||
source: text("source").$type<"manual" | "ai">().notNull().default("manual"),
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -117,7 +117,7 @@ aiRouter.post(
|
||||
const patient = await patients.createPatient(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
parsed.data,
|
||||
{ ...parsed.data, source: "ai" },
|
||||
demographicsOnly,
|
||||
);
|
||||
created.push(patient.fileNumber);
|
||||
|
||||
@@ -248,28 +248,41 @@ export function createChatTools(ctx: ToolContext) {
|
||||
|
||||
proposeAppointment: tool({
|
||||
description:
|
||||
"Propose a new appointment for the clinician to approve. Does NOT save — it shows an approval card; the clinician confirms before anything is written. Provide the patient's file number (MRN); name/initials are filled from the record.",
|
||||
"Propose a new appointment for the clinician to approve. Does NOT save — it shows an approval card; the clinician confirms before anything is written. Prefer the patient's file number (MRN), which fills name/initials from the record. If the file number is unknown (e.g. parsing a schedule export), pass the patient's name instead; type/provider may be omitted and will be filled with placeholders for the clinician to edit.",
|
||||
inputSchema: z.object({
|
||||
fileNumber: z.string().describe("Patient file number / MRN (may be a token)"),
|
||||
fileNumber: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Patient file number / MRN (may be a token); omit if unknown"),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Patient name — use when no file number is known"),
|
||||
date: z.string().describe("Appointment date, YYYY-MM-DD"),
|
||||
time: z.string().describe("Appointment time, HH:mm (24h)"),
|
||||
type: z.string().describe("Visit type, e.g. Follow-up, Consultation"),
|
||||
provider: z.string().describe("Provider/clinician name"),
|
||||
type: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Visit type, e.g. Follow-up, Consultation"),
|
||||
provider: z.string().optional().describe("Provider/clinician name"),
|
||||
}),
|
||||
execute: async ({ fileNumber, date, time, type, provider }) => {
|
||||
step(`Drafting appointment for patient ${fileNumber}`);
|
||||
const patient = await resolvePatient(fileNumber);
|
||||
if (!patient) {
|
||||
execute: async ({ fileNumber, name, date, time, type, provider }) => {
|
||||
step(`Drafting appointment for ${fileNumber ?? name ?? "patient"}`);
|
||||
const patient = fileNumber ? await resolvePatient(fileNumber) : null;
|
||||
// A name (resolved or supplied) is the minimum needed to draft a row.
|
||||
const resolvedName = patient?.name ?? (name ? veil.rehydrate(name) : undefined);
|
||||
if (!resolvedName) {
|
||||
return { ok: false as const, reason: "patient_not_found" as const };
|
||||
}
|
||||
const candidate = {
|
||||
fileNumber: patient.fileNumber,
|
||||
name: patient.name,
|
||||
initials: patient.initials,
|
||||
fileNumber: patient?.fileNumber ?? "",
|
||||
name: resolvedName,
|
||||
initials: patient?.initials ?? "",
|
||||
date,
|
||||
time,
|
||||
type,
|
||||
provider,
|
||||
type: type ?? "",
|
||||
provider: provider ?? "",
|
||||
source: "ai" as const,
|
||||
};
|
||||
const parsed = appointmentInputSchema.safeParse(candidate);
|
||||
const issues = parsed.success
|
||||
@@ -369,6 +382,7 @@ export function createChatTools(ctx: ToolContext) {
|
||||
frequency,
|
||||
duration: duration ?? null,
|
||||
notes: notes ?? null,
|
||||
source: "ai" as const,
|
||||
};
|
||||
const parsed = prescriptionInputSchema.safeParse(candidate);
|
||||
const issues = parsed.success
|
||||
|
||||
@@ -22,6 +22,7 @@ function toAppointment(row: AppointmentRow): Appointment {
|
||||
type: row.type,
|
||||
provider: row.provider,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -38,6 +39,7 @@ function columns(orgId: string, input: AppointmentInput, createdBy?: string) {
|
||||
type: input.type,
|
||||
provider: input.provider,
|
||||
status: input.status,
|
||||
source: input.source,
|
||||
...(createdBy ? { createdBy } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ function toPatient(row: PatientRow, children: Children): Patient {
|
||||
labs: children.labs,
|
||||
labTrend: row.labTrend,
|
||||
encounters: children.encounters,
|
||||
source: row.source,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +120,7 @@ function patientColumns(orgId: string, input: PatientInput, createdBy?: string)
|
||||
vitalsTakenAt: input.vitals.takenAt,
|
||||
vitalsTrend: input.vitalsTrend,
|
||||
labTrend: input.labTrend,
|
||||
source: input.source,
|
||||
...(createdBy ? { createdBy } : {}),
|
||||
};
|
||||
}
|
||||
@@ -141,6 +143,7 @@ function demographicColumns(
|
||||
primaryProviderId: input.primaryProviderId ?? null,
|
||||
status: input.status,
|
||||
initials: input.initials,
|
||||
source: input.source,
|
||||
alerts: [] as string[],
|
||||
vitalsBp: "",
|
||||
vitalsHr: "",
|
||||
@@ -293,6 +296,24 @@ function isUniqueViolation(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// Pick the next free numeric file number for an org (max existing digit-only
|
||||
// file number + 1, floored at 10000). Used when an AI import omits one. The
|
||||
// unique index still guards against a race, surfacing a 409.
|
||||
export async function generateFileNumber(orgId: string): Promise<string> {
|
||||
const [r] = await db
|
||||
.select({
|
||||
max: sql<number>`coalesce(max((${patients.fileNumber})::bigint), 9999)`,
|
||||
})
|
||||
.from(patients)
|
||||
.where(
|
||||
and(
|
||||
eq(patients.organizationId, orgId),
|
||||
sql`${patients.fileNumber} ~ '^[0-9]+$'`,
|
||||
),
|
||||
);
|
||||
return String(Number(r?.max ?? 9999) + 1);
|
||||
}
|
||||
|
||||
export async function listPatients(
|
||||
orgId: string,
|
||||
demographicsOnly = false,
|
||||
@@ -366,9 +387,13 @@ export async function transferPatient(
|
||||
export async function createPatient(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
input: PatientInput,
|
||||
rawInput: PatientInput,
|
||||
demographicsOnly = false,
|
||||
): Promise<Patient> {
|
||||
// Auto-assign a file number when one wasn't supplied (e.g. AI imports).
|
||||
const input: PatientInput = rawInput.fileNumber
|
||||
? rawInput
|
||||
: { ...rawInput, fileNumber: await generateFileNumber(orgId) };
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
// Reception registers demographics only — clinical input is ignored and
|
||||
|
||||
@@ -25,6 +25,7 @@ function toPrescription(row: PrescriptionRow): Prescription {
|
||||
status: row.status,
|
||||
duration: row.duration,
|
||||
notes: row.notes,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -43,6 +44,7 @@ function columns(orgId: string, input: PrescriptionInput, createdBy?: string) {
|
||||
status: input.status,
|
||||
duration: input.duration ?? null,
|
||||
notes: input.notes ?? null,
|
||||
source: input.source,
|
||||
// Only set prescribedAt when supplied; otherwise the column default (today).
|
||||
...(input.prescribedAt ? { prescribedAt: input.prescribedAt } : {}),
|
||||
...(createdBy ? { createdBy } : {}),
|
||||
|
||||
@@ -17,6 +17,7 @@ export type Appointment = {
|
||||
type: string;
|
||||
provider: string;
|
||||
status: AppointmentStatus;
|
||||
source: "manual" | "ai";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -70,4 +70,5 @@ export type Patient = {
|
||||
labs: Lab[];
|
||||
labTrend: Trend; // headline lab plotted as a sparkline
|
||||
encounters: Encounter[];
|
||||
source?: "manual" | "ai"; // "ai" = imported/drafted by the chat agent
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export type Prescription = {
|
||||
status: PrescriptionStatus;
|
||||
duration: string | null;
|
||||
notes: string | null;
|
||||
source: "manual" | "ai";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user