backend: AI chat agent with Veil PHI safeguard (providers, /chat, import)

Real LLM chat replacing the mock, backend-centric per the plan:

- Multi-provider API-key mode (OpenAI / Anthropic / Gemini via the AI SDK) plus
  local Ollama (OpenAI-compatible endpoint). Provider is derived from the
  picked model id; the matching stored key is used. New user_ai_settings table
  holds per-user config with provider API keys encrypted at rest (AES-256-GCM,
  src/lib/crypto.ts, keyed by AI_CREDENTIALS_KEY).
- POST /api/ai/config (get/put, secrets never returned), POST /api/ai/test
  (Ollama ping / key presence), POST /api/ai/import (approved migration commit,
  re-validated server-side, reuses the audited patient service).
- POST /api/chat: streamText agent with tools (getPatient, getPatientLabs,
  searchPatients, previewImport). Real record data streams to the clinician as
  custom data parts (cards) while the model sees only Veil-redacted results.
- Veil (src/services/ai/veil.ts): de-identifies patient identifiers to tokens
  before external calls, resolves tokens on tool args, and rehydrates the final
  answer. Bypassed for local Ollama. External mode runs non-streamed so the
  rehydrated text is correct. Every call is audited (provider + Veil level).
- Shared role-scoping helpers extracted to src/lib/role-scope.ts (reused by the
  patient routes and chat tools so visibility rules match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-13 18:35:16 +03:00
parent c6a1b98427
commit 3a7378e00d
21 changed files with 3955 additions and 31 deletions
+109
View File
@@ -0,0 +1,109 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/index.js";
import { userAiSettings } from "../../db/schema/ai.js";
import { decryptSecret, encryptSecret } from "../../lib/crypto.js";
import type { AiConfigInput } from "../../lib/ai-validation.js";
import {
type AiConfig,
type ApiProvider,
DEFAULT_OLLAMA_BASE_URL,
} from "../../types/ai.js";
type AiSettingsRow = typeof userAiSettings.$inferSelect;
const DEFAULTS: Omit<AiSettingsRow, "userId" | "updatedAt"> = {
mode: "local",
provider: "anthropic",
ollamaBaseUrl: DEFAULT_OLLAMA_BASE_URL,
ollamaModel: "llama3.1",
defaultModel: "claude-sonnet-4-6",
defaultEffort: "medium",
veilLevel: "full",
apiKeysCipher: {},
};
// The full row for a user (including the encrypted key map), with defaults when
// the user has never saved AI settings. Internal — never returned to clients.
export async function getAiSettings(userId: string): Promise<AiSettingsRow> {
const [row] = await db
.select()
.from(userAiSettings)
.where(eq(userAiSettings.userId, userId))
.limit(1);
return row ?? { userId, updatedAt: new Date(), ...DEFAULTS };
}
const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
// Strips secrets and reports which providers have a stored key.
export function toAiConfig(row: AiSettingsRow): AiConfig {
const apiKeySet = Object.fromEntries(
PROVIDERS.map((p) => [p, Boolean(row.apiKeysCipher[p])]),
) as Record<ApiProvider, boolean>;
return {
mode: row.mode,
provider: row.provider,
ollamaBaseUrl: row.ollamaBaseUrl,
ollamaModel: row.ollamaModel,
defaultModel: row.defaultModel,
defaultEffort: row.defaultEffort,
veilLevel: row.veilLevel,
apiKeySet,
};
}
// Decrypts the stored key for a provider, or null if none/undecryptable.
export function getApiKey(
row: AiSettingsRow,
provider: ApiProvider,
): string | null {
const cipher = row.apiKeysCipher[provider];
if (!cipher) return null;
try {
return decryptSecret(cipher);
} catch {
return null;
}
}
// Upserts a user's AI config. A provided `apiKey` is encrypted and stored for
// the *currently selected* provider (the one in `input.provider`, else the
// existing provider); an empty string clears it. The key is never persisted in
// plaintext and never returned.
export async function saveAiConfig(
userId: string,
input: AiConfigInput,
): Promise<AiConfig> {
const current = await getAiSettings(userId);
const next = {
mode: input.mode ?? current.mode,
provider: input.provider ?? current.provider,
ollamaBaseUrl: input.ollamaBaseUrl ?? current.ollamaBaseUrl,
ollamaModel: input.ollamaModel ?? current.ollamaModel,
defaultModel: input.defaultModel ?? current.defaultModel,
defaultEffort: input.defaultEffort ?? current.defaultEffort,
veilLevel: input.veilLevel ?? current.veilLevel,
apiKeysCipher: { ...current.apiKeysCipher },
};
if (input.apiKey !== undefined) {
const target = input.provider ?? current.provider;
if (input.apiKey === "") {
delete next.apiKeysCipher[target];
} else {
next.apiKeysCipher[target] = encryptSecret(input.apiKey);
}
}
await db
.insert(userAiSettings)
.values({ userId, ...next })
.onConflictDoUpdate({
target: userAiSettings.userId,
set: { ...next, updatedAt: new Date() },
});
return toAiConfig({ userId, updatedAt: new Date(), ...next });
}
+82
View File
@@ -0,0 +1,82 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";
import { HttpError } from "../../lib/http-error.js";
import type { ApiProvider } from "../../types/ai.js";
import { getApiKey } from "./config.js";
import type { userAiSettings } from "../../db/schema/ai.js";
type AiSettingsRow = typeof userAiSettings.$inferSelect;
export type ResolvedModel = {
model: LanguageModel;
// True for external cloud providers — Veil de-identification applies. False
// for local Ollama (data never leaves the clinic).
isExternal: boolean;
providerLabel: string;
};
// The "ollama" sentinel id from the frontend catalog means "use my local
// model" regardless of the model field.
const OLLAMA_SENTINEL = "ollama";
// Derive the cloud provider from a catalog model id, so the picker drives which
// provider/key is used. Returns null for the local sentinel.
function providerForModel(modelId: string): ApiProvider | null {
if (modelId === OLLAMA_SENTINEL) return null;
if (modelId.startsWith("claude")) return "anthropic";
if (modelId.startsWith("gemini")) return "gemini";
if (modelId.startsWith("gpt") || /^o\d/.test(modelId)) return "openai";
return null;
}
const PROVIDER_LABELS: Record<ApiProvider, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
gemini: "Google Gemini",
};
// Resolve a concrete LanguageModel for a request. `requestedModelId` is the id
// the user picked in the chat input; when it maps to a cloud provider we use
// that provider's stored key, otherwise we fall back to local Ollama (also used
// when mode === "local" or the picked model is the local sentinel).
export function resolveModel(
settings: AiSettingsRow,
requestedModelId: string,
): ResolvedModel {
const provider =
settings.mode === "local" ? null : providerForModel(requestedModelId);
if (!provider) {
// Local mode via Ollama's OpenAI-compatible endpoint. No key required.
const ollama = createOpenAICompatible({
name: "ollama",
baseURL: `${settings.ollamaBaseUrl.replace(/\/$/, "")}/v1`,
});
return {
model: ollama(settings.ollamaModel),
isExternal: false,
providerLabel: "Local (Ollama)",
};
}
const apiKey = getApiKey(settings, provider);
if (!apiKey) {
throw new HttpError(
400,
`No API key configured for ${PROVIDER_LABELS[provider]}. Add one in Settings → AI.`,
);
}
const model: LanguageModel =
provider === "anthropic"
? createAnthropic({ apiKey })(requestedModelId)
: provider === "gemini"
? createGoogleGenerativeAI({ apiKey })(requestedModelId)
: createOpenAI({ apiKey })(requestedModelId);
return { model, isExternal: true, providerLabel: PROVIDER_LABELS[provider] };
}
+182
View File
@@ -0,0 +1,182 @@
import { tool } from "ai";
import type { UIMessageStreamWriter } from "ai";
import { z } from "zod";
import { patientInputSchema } from "../../lib/patient-validation.js";
import * as patients from "../patients.js";
import type { Patient } from "../../types/patient.js";
import type { Veil } from "./veil.js";
// Context every tool closes over: the caller's clinic + role-derived scoping,
// the Veil safeguard, and the UI stream writer used to push REAL (un-redacted)
// record data to the trusted clinician's screen as custom data parts, while the
// value returned to the model stays Veil-redacted on external providers.
export type ToolContext = {
orgId: string;
demographicsOnly: boolean;
scopeProviderId?: string;
veil: Veil;
writer: UIMessageStreamWriter;
};
// Compact, model-facing projection of a patient (Veil-redacted upstream). Keeps
// clinical signal, drops bulky arrays the model rarely needs verbatim.
function forModel(p: Patient) {
return {
fileNumber: p.fileNumber,
name: p.name,
age: p.age,
sex: p.sex,
status: p.status,
pcp: p.pcp,
allergies: p.allergies,
alerts: p.alerts,
problems: p.problems,
medications: p.medications,
vitals: p.vitals,
labs: p.labs,
};
}
export function createChatTools(ctx: ToolContext) {
const { orgId, demographicsOnly, scopeProviderId, veil, writer } = ctx;
return {
// Look up one patient by file number (MRN) and show their record cards.
getPatient: tool({
description:
"Retrieve a patient's full record by file number (MRN) and display it as record cards. Use when the clinician asks about a specific patient.",
inputSchema: z.object({
fileNumber: z
.string()
.describe("The patient's file number / MRN, e.g. 10293"),
}),
execute: async ({ fileNumber }) => {
const real = veil.resolveFileNumber(fileNumber);
const patient = await patients.getPatient(
orgId,
real,
demographicsOnly,
scopeProviderId,
);
if (!patient) return { found: false as const, fileNumber };
// Real data → clinician UI (cards). Redacted data → model.
writer.write({ type: "data-patientCard", data: patient });
return { found: true as const, patient: forModel(veil.redactPatient(patient)) };
},
}),
// Pull a patient's labs (with high/low flags + trend) and chart them.
getPatientLabs: tool({
description:
"Retrieve a patient's lab results and trend for charting. Use when the clinician asks about labs, results, or values over time.",
inputSchema: z.object({
fileNumber: z.string().describe("The patient's file number / MRN"),
}),
execute: async ({ fileNumber }) => {
const real = veil.resolveFileNumber(fileNumber);
const patient = await patients.getPatient(
orgId,
real,
demographicsOnly,
scopeProviderId,
);
if (!patient) return { found: false as const, fileNumber };
if (demographicsOnly) {
return { found: false as const, reason: "not_authorized" as const };
}
writer.write({
type: "data-labCard",
data: {
fileNumber: patient.fileNumber,
name: patient.name,
labs: patient.labs,
labTrend: patient.labTrend,
},
});
const redacted = veil.redactPatient(patient);
return {
found: true as const,
name: redacted.name,
labs: patient.labs,
labTrend: patient.labTrend,
};
},
}),
// Search the clinic's patients by name or file number.
searchPatients: tool({
description:
"Search the clinic's patients by name fragment. Returns matches with file numbers so you can then call getPatient.",
inputSchema: z.object({
query: z.string().describe("Name or file-number fragment to match"),
}),
execute: async ({ query }) => {
const all = await patients.listPatients(
orgId,
demographicsOnly,
scopeProviderId,
);
const q = query.trim().toLowerCase();
const matches = all
.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.fileNumber.toLowerCase().includes(q),
)
.slice(0, 10)
.map((p) => {
const r = veil.redactPatient(p);
return { fileNumber: r.fileNumber, name: r.name, status: p.status };
});
return { count: matches.length, matches };
},
}),
// 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.",
inputSchema: z.object({
records: z
.array(z.unknown())
.describe(
"Patient records mapped to temetro's shape (fileNumber, name, age, sex, vitals, labs, medications, problems, allergies, encounters).",
),
}),
execute: async ({ records }) => {
const valid: unknown[] = [];
const invalid: { index: number; errors: string[] }[] = [];
records.forEach((rec, index) => {
const parsed = patientInputSchema.safeParse(rec);
if (parsed.success) {
valid.push(parsed.data);
} else {
invalid.push({
index,
errors: parsed.error.issues.map(
(i) => `${i.path.join(".") || "(root)"}: ${i.message}`,
),
});
}
});
// Hand the validated, ready-to-commit set to the UI for an approval
// card. The client posts these back to /api/ai/import on approval.
writer.write({
type: "data-importPreview",
data: { valid, invalid, total: records.length },
});
return {
total: records.length,
validCount: valid.length,
invalidCount: invalid.length,
invalid,
note: "Preview only — awaiting clinician approval before any write.",
};
},
}),
};
}
+107
View File
@@ -0,0 +1,107 @@
import type { Patient } from "../../types/patient.js";
import type { VeilLevel } from "../../types/ai.js";
// Veil — temetro's PHI de-identification safeguard. When the chat runs against
// an external cloud model, Veil sits between the patient data and the model:
//
// • tool RESULTS are redacted — direct identifiers (name, MRN, provider) are
// swapped for stable tokens like [PATIENT_1] / [MRN_1] before the model
// sees them. Clinical values (labs, vitals, problems, meds) pass through —
// they're what the model needs to reason.
// • tool ARGUMENTS are resolved — when the model calls a tool with a token
// (e.g. getPatientLabs("[MRN_1]")) Veil maps it back to the real file
// number server-side, so the external model never needs the real MRN.
// • the final OUTPUT is rehydrated — tokens are swapped back to real values
// before the answer reaches the clinician.
//
// Local Ollama mode never leaves the clinic, so Veil is created inactive there
// (level "off") and every method is a pass-through.
type TokenClass = "PATIENT" | "MRN" | "PROVIDER";
export type Veil = {
active: boolean;
level: VeilLevel;
/** De-identify a patient record for sending to an external model. */
redactPatient: (patient: Patient) => Patient;
/** Map a possibly-tokenized file number from a tool call back to the real one. */
resolveFileNumber: (input: string) => string;
/** Swap any tokens in model output back to real identifiers. */
rehydrate: (text: string) => string;
/** Token classes actually emitted — for the audit log. */
usedClasses: () => TokenClass[];
};
export function createVeil(level: VeilLevel, active: boolean): Veil {
// Real value → token, and token → real value, plus a reverse map keyed by
// token for fast file-number resolution.
const toToken = new Map<string, string>();
const fromToken = new Map<string, string>();
const mrnByToken = new Map<string, string>();
const counters: Record<TokenClass, number> = {
PATIENT: 0,
MRN: 0,
PROVIDER: 0,
};
function tokenFor(cls: TokenClass, value: string): string {
const key = `${cls}:${value}`;
const existing = toToken.get(key);
if (existing) return existing;
counters[cls] += 1;
const token = `[${cls}_${counters[cls]}]`;
toToken.set(key, token);
fromToken.set(token, value);
if (cls === "MRN") mrnByToken.set(token, value);
return token;
}
const isActive = active && level !== "off";
function redactPatient(patient: Patient): Patient {
if (!isActive) return patient;
const provider = patient.pcp
? tokenFor("PROVIDER", patient.pcp)
: patient.pcp;
return {
...patient,
name: tokenFor("PATIENT", patient.name),
fileNumber: tokenFor("MRN", patient.fileNumber),
initials: "··",
pcp: provider,
encounters: patient.encounters.map((e) => ({
...e,
provider: e.provider ? tokenFor("PROVIDER", e.provider) : e.provider,
})),
};
}
function resolveFileNumber(input: string): string {
if (!isActive) return input;
return mrnByToken.get(input.trim()) ?? input;
}
function rehydrate(text: string): string {
if (!isActive || fromToken.size === 0) return text;
let out = text;
for (const [token, real] of fromToken) {
out = out.split(token).join(real);
}
return out;
}
function usedClasses(): TokenClass[] {
return (Object.keys(counters) as TokenClass[]).filter(
(c) => counters[c] > 0,
);
}
return {
active: isActive,
level,
redactPatient,
resolveFileNumber,
rehydrate,
usedClasses,
};
}