mirror of
https://github.com/temetro/temetro.git
synced 2026-08-24 08:46:27 +00:00
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:
@@ -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 });
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user