feat: live AI chat (thinking + inline Veil) and display/add actions

Make the chat feel alive and let the agent act on the clinic — safely.

UX (frontend):
- Stream `data-step` parts from each tool into an inline Chain-of-Thought
  trace, plus a "Thinking…" shimmer while a request is in flight (works even
  on the non-streamed external+Veil path).
- Replace the modal Veil consent Dialog with an inline, once-per-session
  "Veil" confirmation above the input (with a "Use local model" option).
- Render new list + action-preview cards; chat-input now uses COSS tokens.

Agent (backend):
- Add display tools (listAppointments / listTasks / listPrescriptions) and
  propose tools (proposeAppointment / proposeTask / proposePrescription) that
  validate as a dry run and stream an approval card — nothing is written until
  the clinician approves, via the existing RBAC-gated create endpoints.
- previewImport now also covers single-patient add + migration.
- System prompt: display + add only, never edit/delete or alter the schema;
  stronger migration guidance. ToolContext carries the viewer for task scoping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-13 23:49:18 +03:00
parent ddf4b49d82
commit fa2e499440
9 changed files with 874 additions and 110 deletions
+264 -2
View File
@@ -2,8 +2,14 @@ import { tool } from "ai";
import type { UIMessageStreamWriter } from "ai";
import { z } from "zod";
import { appointmentInputSchema } from "../../lib/appointment-validation.js";
import { patientInputSchema } from "../../lib/patient-validation.js";
import { prescriptionInputSchema } from "../../lib/prescription-validation.js";
import { taskInputSchema } from "../../lib/task-validation.js";
import * as appointments from "../appointments.js";
import * as patients from "../patients.js";
import * as prescriptions from "../prescriptions.js";
import * as tasks from "../tasks.js";
import type { Patient } from "../../types/patient.js";
import type { Veil } from "./veil.js";
@@ -15,6 +21,9 @@ export type ToolContext = {
orgId: string;
demographicsOnly: boolean;
scopeProviderId?: string;
// The signed-in clinician — needed to scope task visibility (and to stamp the
// creator when an add is committed via the REST endpoints on approval).
viewer: { userId: string; userName: string; memberRole: string };
veil: Veil;
writer: UIMessageStreamWriter;
};
@@ -39,7 +48,30 @@ function forModel(p: Patient) {
}
export function createChatTools(ctx: ToolContext) {
const { orgId, demographicsOnly, scopeProviderId, veil, writer } = ctx;
const { orgId, demographicsOnly, scopeProviderId, viewer, veil, writer } =
ctx;
// Emit a Chain-of-Thought step to the UI as the agent works. Steps stream live
// (writer.write flushes immediately) even on the non-streamed external+Veil
// path, so the clinician always sees progress. Labels must be Veil-safe — use
// the tokenized identifiers the model passed, never resolved real names.
let stepSeq = 0;
function step(label: string) {
stepSeq += 1;
writer.write({
type: "data-step",
data: { id: `step-${stepSeq}`, label, status: "complete" as const },
});
}
// Resolve a possibly-tokenized file number to the real patient record, so an
// add proposal carries real name/initials into the approval card (the model
// only ever saw Veil tokens). Returns null when the patient isn't found / is
// out of scope.
async function resolvePatient(fileNumber: string): Promise<Patient | null> {
const real = veil.resolveFileNumber(fileNumber);
return patients.getPatient(orgId, real, demographicsOnly, scopeProviderId);
}
return {
// Look up one patient by file number (MRN) and show their record cards.
@@ -52,6 +84,7 @@ export function createChatTools(ctx: ToolContext) {
.describe("The patient's file number / MRN, e.g. 10293"),
}),
execute: async ({ fileNumber }) => {
step(`Looking up patient ${fileNumber}`);
const real = veil.resolveFileNumber(fileNumber);
const patient = await patients.getPatient(
orgId,
@@ -74,6 +107,7 @@ export function createChatTools(ctx: ToolContext) {
fileNumber: z.string().describe("The patient's file number / MRN"),
}),
execute: async ({ fileNumber }) => {
step(`Reading labs for patient ${fileNumber}`);
const real = veil.resolveFileNumber(fileNumber);
const patient = await patients.getPatient(
orgId,
@@ -112,6 +146,7 @@ export function createChatTools(ctx: ToolContext) {
query: z.string().describe("Name or file-number fragment to match"),
}),
execute: async ({ query }) => {
step(`Searching patients for "${query}"`);
const all = await patients.listPatients(
orgId,
demographicsOnly,
@@ -129,17 +164,242 @@ export function createChatTools(ctx: ToolContext) {
const r = veil.redactPatient(p);
return { fileNumber: r.fileNumber, name: r.name, status: p.status };
});
step(`Found ${matches.length} match(es)`);
return { count: matches.length, matches };
},
}),
// --- Display the clinic's schedule / work queues (read-only) -------------
listAppointments: tool({
description:
"Display the clinic's appointments. Use when the clinician asks to see the schedule, upcoming visits, or today's appointments.",
inputSchema: z.object({}),
execute: async () => {
step("Loading appointments");
const all = await appointments.listAppointments(orgId);
writer.write({ type: "data-appointmentList", data: { appointments: all } });
// Model-facing rows are Veil-safe: redact patient names to tokens.
const rows = all.map((a) => ({
date: a.date,
time: a.time,
type: a.type,
provider: a.provider,
status: a.status,
patient: veil.active ? "[PATIENT]" : a.name,
}));
return { count: rows.length, appointments: rows };
},
}),
listTasks: tool({
description:
"Display the care-team task list. Use when the clinician asks to see open tasks, to-dos, or what's assigned.",
inputSchema: z.object({}),
execute: async () => {
step("Loading tasks");
const all = await tasks.listTasks(orgId, {
userId: viewer.userId,
role: viewer.memberRole,
});
writer.write({ type: "data-taskList", data: { tasks: all } });
const rows = all.map((tk) => ({
title: tk.title,
assignee: tk.assignee,
due: tk.due,
priority: tk.priority,
done: tk.done,
}));
return { count: rows.length, tasks: rows };
},
}),
listPrescriptions: tool({
description:
"Display prescriptions for the clinic. Use when the clinician asks to see prescriptions or medications prescribed.",
inputSchema: z.object({}),
execute: async () => {
if (demographicsOnly) {
return { found: false as const, reason: "not_authorized" as const };
}
step("Loading prescriptions");
const all = await prescriptions.listPrescriptions(orgId);
writer.write({
type: "data-prescriptionList",
data: { prescriptions: all },
});
const rows = all.map((rx) => ({
medication: rx.medication,
dose: rx.dose,
frequency: rx.frequency,
status: rx.status,
prescribedAt: rx.prescribedAt,
patient: veil.active ? "[PATIENT]" : rx.name,
}));
return { count: rows.length, prescriptions: rows };
},
}),
// --- Propose an addition (dry-run, NEVER writes) ------------------------
// Each propose tool validates the record and streams an approval card. The
// clinician approves in the UI, which commits via the existing RBAC-gated
// REST endpoint (POST /api/appointments | /tasks | /prescriptions). Nothing
// is written here.
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.",
inputSchema: z.object({
fileNumber: z.string().describe("Patient file number / MRN (may be a token)"),
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"),
}),
execute: async ({ fileNumber, date, time, type, provider }) => {
step(`Drafting appointment for patient ${fileNumber}`);
const patient = await resolvePatient(fileNumber);
if (!patient) {
return { ok: false as const, reason: "patient_not_found" as const };
}
const candidate = {
fileNumber: patient.fileNumber,
name: patient.name,
initials: patient.initials,
date,
time,
type,
provider,
};
const parsed = appointmentInputSchema.safeParse(candidate);
const issues = parsed.success
? []
: parsed.error.issues.map(
(i) => `${i.path.join(".") || "(root)"}: ${i.message}`,
);
writer.write({
type: "data-actionPreview",
data: {
token: `appt-${stepSeq}`,
kind: "appointment" as const,
// Real, ready-to-commit values for the approval card.
record: parsed.success ? parsed.data : candidate,
issues,
},
});
return {
ok: parsed.success,
issues,
note: "Preview only — awaiting clinician approval before any write.",
};
},
}),
proposeTask: tool({
description:
"Propose a new care-team task for the clinician to approve. Does NOT save — it shows an approval card the clinician confirms before anything is written.",
inputSchema: z.object({
title: z.string().describe("What needs doing"),
assignee: z.string().optional().describe("Who it's assigned to (free text)"),
assigneeRole: z
.enum(["admin", "doctor", "reception", "pharmacy", "lab"])
.nullish()
.describe("Department the task belongs to; omit for a personal task"),
due: z.string().optional().describe("Due date / timeframe (free text)"),
priority: z.enum(["high", "medium", "low"]).optional(),
patient: z.string().nullish().describe("Related patient (free text)"),
notes: z.string().nullish(),
}),
execute: async (input) => {
step(`Drafting task "${input.title}"`);
// Patient free-text may contain Veil tokens — rehydrate for the card.
const candidate = {
...input,
patient: input.patient ? veil.rehydrate(input.patient) : input.patient,
};
const parsed = taskInputSchema.safeParse(candidate);
const issues = parsed.success
? []
: parsed.error.issues.map(
(i) => `${i.path.join(".") || "(root)"}: ${i.message}`,
);
writer.write({
type: "data-actionPreview",
data: {
token: `task-${stepSeq}`,
kind: "task" as const,
record: parsed.success ? parsed.data : candidate,
issues,
},
});
return {
ok: parsed.success,
issues,
note: "Preview only — awaiting clinician approval before any write.",
};
},
}),
proposePrescription: tool({
description:
"Propose a new prescription 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).",
inputSchema: z.object({
fileNumber: z.string().describe("Patient file number / MRN (may be a token)"),
medication: z.string().describe("Medication name"),
dose: z.string().optional().describe("Dose, e.g. 500mg"),
frequency: z.string().describe("Frequency, e.g. twice daily"),
duration: z.string().nullish().describe("Duration, e.g. 7 days"),
notes: z.string().nullish(),
}),
execute: async ({ fileNumber, medication, dose, frequency, duration, notes }) => {
if (demographicsOnly) {
return { ok: false as const, reason: "not_authorized" as const };
}
step(`Drafting prescription for patient ${fileNumber}`);
const patient = await resolvePatient(fileNumber);
if (!patient) {
return { ok: false as const, reason: "patient_not_found" as const };
}
const candidate = {
fileNumber: patient.fileNumber,
name: patient.name,
initials: patient.initials,
medication,
dose: dose ?? "",
frequency,
duration: duration ?? null,
notes: notes ?? null,
};
const parsed = prescriptionInputSchema.safeParse(candidate);
const issues = parsed.success
? []
: parsed.error.issues.map(
(i) => `${i.path.join(".") || "(root)"}: ${i.message}`,
);
writer.write({
type: "data-actionPreview",
data: {
token: `rx-${stepSeq}`,
kind: "prescription" as const,
record: parsed.success ? parsed.data : candidate,
issues,
},
});
return {
ok: parsed.success,
issues,
note: "Preview only — awaiting clinician approval before any write.",
};
},
}),
// Migration: validate parsed records WITHOUT writing. The model parses an
// uploaded export into our patient shape and calls this; the result drives
// an approval card. Nothing is inserted until the clinician approves and the
// client posts to POST /api/ai/import (which re-validates + writes).
previewImport: tool({
description:
"Validate patient records parsed from an uploaded database export, as a dry run. Does NOT save anything. Call this when the clinician wants to import/migrate an existing patient database; parse the file into our patient shape first. The clinician must approve before any data is written.",
"Validate patient records parsed from an uploaded database export, as a dry run. Does NOT save anything. Call this when the clinician wants to import/migrate an existing patient database OR add a single patient; parse the file into our patient shape first. The clinician must approve before any data is written.",
inputSchema: z.object({
records: z
.array(z.unknown())
@@ -148,6 +408,7 @@ export function createChatTools(ctx: ToolContext) {
),
}),
execute: async ({ records }) => {
step(`Validating ${records.length} record(s)`);
const valid: unknown[] = [];
const invalid: { index: number; errors: string[] }[] = [];
records.forEach((rec, index) => {
@@ -169,6 +430,7 @@ export function createChatTools(ctx: ToolContext) {
type: "data-importPreview",
data: { valid, invalid, total: records.length },
});
step(`${valid.length} ready, ${invalid.length} skipped`);
return {
total: records.length,
validCount: valid.length,