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
+40
View File
@@ -0,0 +1,40 @@
import { jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import type {
AiMode,
ApiProvider,
Effort,
VeilLevel,
} from "../../types/ai.js";
import { user } from "./auth.js";
// Per-user AI configuration. One row per user (keyed by the Better Auth user
// id). Non-secret fields are plain columns; provider API keys are stored
// encrypted (src/lib/crypto.ts) in `apiKeysCipher` as a map keyed by provider,
// so a user can save more than one provider's key and switch without re-entry.
// The encrypted blob is never returned to the client.
export const userAiSettings = pgTable("user_ai_settings", {
userId: text("user_id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
mode: text("mode").$type<AiMode>().notNull().default("local"),
provider: text("provider").$type<ApiProvider>().notNull().default("anthropic"),
ollamaBaseUrl: text("ollama_base_url")
.notNull()
.default("http://localhost:11434"),
// Local model tag served by Ollama (e.g. "llama3.1"); used in local mode.
ollamaModel: text("ollama_model").notNull().default("llama3.1"),
defaultModel: text("default_model").notNull().default("claude-sonnet-4-6"),
defaultEffort: text("default_effort").$type<Effort>().notNull().default("medium"),
veilLevel: text("veil_level").$type<VeilLevel>().notNull().default("full"),
// Encrypted per-provider keys: { openai?, anthropic?, gemini? }, each value
// an opaque ciphertext string from encryptSecret().
apiKeysCipher: jsonb("api_keys_cipher")
.$type<Partial<Record<ApiProvider, string>>>()
.notNull()
.default({}),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+1
View File
@@ -9,3 +9,4 @@ export * from "./activity.js";
export * from "./messaging.js";
export * from "./notifications.js";
export * from "./settings.js";
export * from "./ai.js";