feat: Pluggable AI Agent Engine and Copilot config UI
Build Curio HMS / build-and-deploy (push) Successful in 1m12s Details

This commit is contained in:
Deep Koluguri 2026-05-26 22:36:15 -04:00
parent 8b27b68932
commit 8619ddbdcf
10 changed files with 447 additions and 0 deletions

View File

@ -0,0 +1,63 @@
const { query } = require('../db');
const BillingAgent = require('./BillingAgent');
const TriageAgent = require('./TriageAgent');
const FollowUpAgent = require('./FollowUpAgent');
const InventoryAgent = require('./InventoryAgent');
const CDSSAgent = require('./CDSSAgent');
class AgentManager {
constructor() {
this.agents = {};
this.registerAgent('BILLING', BillingAgent);
this.registerAgent('TRIAGE', TriageAgent);
this.registerAgent('FOLLOWUP', FollowUpAgent);
this.registerAgent('INVENTORY', InventoryAgent);
this.registerAgent('CDSS', CDSSAgent);
}
/**
* Register an agent instance mapped to its type enum.
*/
registerAgent(agentType, agentInstance) {
this.agents[agentType] = agentInstance;
console.log(`[AgentManager] Registered: ${agentType}`);
}
/**
* The core event dispatcher.
* E.g. dispatch('sharma-clinic', 'BILLING', { clinicalNotes: '...' })
*/
async dispatch(tenantId, agentType, payload) {
if (!this.agents[agentType]) {
console.error(`[AgentManager] Agent type ${agentType} is not registered in the system.`);
return null;
}
try {
// Check if clinic has this agent enabled
const dbCheck = await query(
'SELECT is_active, config FROM agent_configs WHERE tenant_id = $1 AND agent_type = $2',
[tenantId, agentType]
);
if (dbCheck.rows.length === 0 || !dbCheck.rows[0].is_active) {
console.log(`[AgentManager] Skipped ${agentType} for tenant ${tenantId} (Not enabled)`);
return null;
}
const config = dbCheck.rows[0].config;
console.log(`[AgentManager] Dispatched ${agentType} for tenant ${tenantId}`);
// Execute the agent asynchronously so it doesn't block the main thread
return this.agents[agentType].execute(config, payload).catch(err => {
console.error(`[AgentManager] ${agentType} execution error:`, err);
});
} catch (err) {
console.error(`[AgentManager] Dispatch error for ${agentType}:`, err);
}
}
}
// Singleton instance
module.exports = new AgentManager();

View File

@ -0,0 +1,35 @@
class BaseAgent {
constructor(name) {
if (this.constructor === BaseAgent) {
throw new Error("Cannot instantiate abstract class BaseAgent");
}
this.name = name;
}
/**
* The core execution method that all agents must implement.
* @param {Object} config - The JSON configuration from the database.
* @param {Object} payload - Event specific data.
*/
async execute(config, payload) {
throw new Error(`Agent ${this.name} must implement execute() method.`);
}
/**
* Helper to mock LLM calls. In production, this would call OpenAI/Gemini.
*/
async mockLLMCall(prompt, responseMocks) {
console.log(`[${this.name}] Sending prompt to LLM...`);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 800));
// Pick a mock response based on keywords or return default
const text = prompt.toLowerCase();
for (const [keyword, mockResponse] of Object.entries(responseMocks)) {
if (text.includes(keyword)) return mockResponse;
}
return responseMocks.default || "I cannot process this right now.";
}
}
module.exports = BaseAgent;

View File

@ -0,0 +1,38 @@
const BaseAgent = require('./BaseAgent');
class BillingAgent extends BaseAgent {
constructor() {
super('BillingAgent');
this.responseMocks = {
'headache': JSON.stringify([{ code: 'R51.9', desc: 'Headache, unspecified', type: 'ICD-10' }, { code: '99213', desc: 'Office Visit (Level 3)', type: 'CPT' }]),
'fever': JSON.stringify([{ code: 'R50.9', desc: 'Fever, unspecified', type: 'ICD-10' }, { code: '99214', desc: 'Office Visit (Level 4)', type: 'CPT' }]),
'default': JSON.stringify([{ code: 'Z00.00', desc: 'Encounter for general adult medical examination without abnormal findings', type: 'ICD-10' }, { code: '99212', desc: 'Office Visit (Level 2)', type: 'CPT' }])
};
}
async execute(config, payload) {
const { clinicalSummary } = payload;
console.log(`[BillingAgent] Analyzing clinical summary for ICD-10/CPT codes...`);
const prompt = `
Extract the most likely ICD-10 diagnosis codes and CPT procedure codes from this summary:
${clinicalSummary}
Return ONLY a JSON array of objects with { code, desc, type }.
`;
const llmResponse = await this.mockLLMCall(prompt, this.responseMocks);
try {
const codes = JSON.parse(llmResponse);
console.log(`[BillingAgent] Suggested Codes: `, codes);
return codes;
} catch (e) {
console.error('[BillingAgent] Failed to parse LLM response', e);
return [];
}
}
}
module.exports = new BillingAgent();

View File

@ -0,0 +1,41 @@
const BaseAgent = require('./BaseAgent');
class CDSSAgent extends BaseAgent {
constructor() {
super('CDSSAgent');
this.responseMocks = {
'warfarin': JSON.stringify({ hasWarning: true, warningLevel: 'HIGH', message: 'Potential drug interaction: Aspirin and Warfarin increase bleeding risk.' }),
'penicillin': JSON.stringify({ hasWarning: true, warningLevel: 'CRITICAL', message: 'Patient has a documented allergy to Penicillin!' }),
'default': JSON.stringify({ hasWarning: false })
};
}
async execute(config, payload) {
const { currentPrescription, patientHistory } = payload;
console.log(`[CDSSAgent] Checking prescription against patient history for interactions...`);
const prompt = `
Check this new prescription: "${currentPrescription}"
Against patient history: "${patientHistory}"
Return a JSON object: { "hasWarning": boolean, "warningLevel": "LOW/MEDIUM/HIGH/CRITICAL", "message": "..." }
`;
const llmResponse = await this.mockLLMCall(prompt, this.responseMocks);
try {
const result = JSON.parse(llmResponse);
if (result.hasWarning) {
console.log(`[CDSSAgent] ⚠️ ${result.warningLevel} WARNING: ${result.message}`);
} else {
console.log(`[CDSSAgent] Safe to prescribe.`);
}
return result;
} catch (e) {
console.error('[CDSSAgent] Failed to parse LLM response', e);
return { hasWarning: false };
}
}
}
module.exports = new CDSSAgent();

View File

@ -0,0 +1,36 @@
const BaseAgent = require('./BaseAgent');
class FollowUpAgent extends BaseAgent {
constructor() {
super('FollowUpAgent');
this.responseMocks = {
'default': JSON.stringify({ message: "Hello! It has been a few days since your visit. How are you feeling today?" })
};
}
async execute(config, payload) {
const { patientName, phone, daysSinceVisit, context } = payload;
console.log(`[FollowUpAgent] Generating follow-up for ${patientName} (${daysSinceVisit} days post-visit)`);
const prompt = `
Draft a friendly, empathetic WhatsApp follow-up message for patient ${patientName}.
Context of last visit: "${context}"
Return a JSON object: { "message": "..." }
`;
const llmResponse = await this.mockLLMCall(prompt, this.responseMocks);
try {
const result = JSON.parse(llmResponse);
console.log(`[FollowUpAgent] Message to send: ${result.message}`);
// Here we would integrate with WhatsApp API to actually send it
return result;
} catch (e) {
console.error('[FollowUpAgent] Failed to parse LLM response', e);
return { message: "Hi! Just checking in on your health. Let us know if you need anything." };
}
}
}
module.exports = new FollowUpAgent();

View File

@ -0,0 +1,34 @@
const BaseAgent = require('./BaseAgent');
class InventoryAgent extends BaseAgent {
constructor() {
super('InventoryAgent');
}
async execute(config, payload) {
const { itemName, currentStock, salesVelocity } = payload;
// config can hold the threshold
const threshold = config.low_stock_threshold || 10;
console.log(`[InventoryAgent] Checking stock for ${itemName} (Current: ${currentStock}, Threshold: ${threshold})`);
if (currentStock < threshold) {
console.log(`[InventoryAgent] 🚨 Stock is below threshold! Generating purchase order...`);
// In a real scenario, this would draft an email or WhatsApp to the vendor
const orderQty = (config.restock_amount || 50) + (salesVelocity || 0);
return {
action: 'DRAFT_PO',
item: itemName,
quantity: orderQty,
urgency: currentStock === 0 ? 'CRITICAL' : 'HIGH'
};
}
return { action: 'NONE', status: 'Healthy' };
}
}
module.exports = new InventoryAgent();

View File

@ -0,0 +1,37 @@
const BaseAgent = require('./BaseAgent');
class TriageAgent extends BaseAgent {
constructor() {
super('TriageAgent');
this.responseMocks = {
'chest pain': JSON.stringify({ category: "URGENT", followUp: "Are you also experiencing shortness of breath or arm pain?" }),
'fever': JSON.stringify({ category: "MODERATE", followUp: "What is your current temperature?" }),
'default': JSON.stringify({ category: "ROUTINE", followUp: "How long have you been feeling this way?" })
};
}
async execute(config, payload) {
const { symptoms } = payload;
console.log(`[TriageAgent] Analyzing symptoms: "${symptoms}"`);
const prompt = `
Analyze these symptoms: "${symptoms}"
Determine the urgency (URGENT, MODERATE, ROUTINE) and suggest a follow-up question.
Return a JSON object: { "category": "...", "followUp": "..." }
`;
const llmResponse = await this.mockLLMCall(prompt, this.responseMocks);
try {
const result = JSON.parse(llmResponse);
console.log(`[TriageAgent] Triage Result: `, result);
return result;
} catch (e) {
console.error('[TriageAgent] Failed to parse LLM response', e);
return { category: 'ROUTINE', followUp: 'Please describe your symptoms in more detail.' };
}
}
}
module.exports = new TriageAgent();

View File

@ -74,10 +74,22 @@ CREATE TABLE IF NOT EXISTS transactions (
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS agent_configs (
id SERIAL PRIMARY KEY,
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
agent_type TEXT NOT NULL CHECK (agent_type IN ('BILLING', 'TRIAGE', 'FOLLOWUP', 'INVENTORY', 'CDSS')),
is_active BOOLEAN DEFAULT false,
config JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(tenant_id, agent_type)
);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_pending_status ON pending_registrations(status);
CREATE INDEX IF NOT EXISTS idx_transactions_order ON transactions(order_id);
CREATE INDEX IF NOT EXISTS idx_agent_configs_tenant ON agent_configs(tenant_id);
-- Migrations for existing DBs
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS services_catalog JSONB DEFAULT '[]';

View File

@ -13,6 +13,7 @@ const { query, initSchema } = require('./db');
const { signToken, requireAuth } = require('./authMiddleware');
const Razorpay = require('razorpay');
const { upload, initStorage, uploadFile } = require('./storage');
const AgentManager = require('./agents/AgentManager');
const razorpayInstance = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID || 'rzp_test_dummy_key_id',
@ -463,6 +464,40 @@ app.post('/api/super/pending/:id/reject', requireAuth('SUPER_ADMIN'), async (req
}
});
// ── AI AGENTS CONFIGURATION ───────────────────────────────────────────────────
app.get('/api/clinic/agents', requireAuth('OWNER'), async (req, res) => {
try {
const result = await query(
'SELECT agent_type, is_active, config FROM agent_configs WHERE tenant_id = $1',
[req.user.tenantId]
);
res.json(result.rows);
} catch (err) {
console.error('[Agents API]', err);
res.status(500).json({ error: 'Failed to fetch agent configs' });
}
});
app.patch('/api/clinic/agents/:type', requireAuth('OWNER'), async (req, res) => {
const { type } = req.params;
const { isActive, config } = req.body;
try {
await query(
`INSERT INTO agent_configs (tenant_id, agent_type, is_active, config)
VALUES ($1, $2, $3, $4::jsonb)
ON CONFLICT (tenant_id, agent_type)
DO UPDATE SET is_active = EXCLUDED.is_active, config = EXCLUDED.config`,
[req.user.tenantId, type.toUpperCase(), isActive, JSON.stringify(config || {})]
);
res.json({ success: true });
} catch (err) {
console.error('[Agents API Update]', err);
res.status(500).json({ error: 'Failed to update agent config' });
}
});
// ── CLINIC OWNER ROUTES ───────────────────────────────────────────────────────
/**

View File

@ -106,6 +106,69 @@
<button class="btn-secondary" onclick="addService()">+ Add</button>
</div>
</div>
<div class="section-card" style="grid-column: 1 / -1;">
<h2>AI Copilots & Automation</h2>
<p>Enable and configure autonomous agents to assist with your clinic operations.</p>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-top: 1rem;">
<!-- Copilot Cards -->
<div class="bill-card" style="flex-direction:column; align-items:flex-start;">
<div style="display:flex; justify-content:space-between; width:100%; margin-bottom:0.5rem;">
<strong>🧾 Automated Medical Billing</strong>
<label class="switch">
<input type="checkbox" id="agent-BILLING" onchange="toggleAgent('BILLING', this.checked)">
<span class="slider round"></span>
</label>
</div>
<p class="text-muted" style="font-size:0.875rem;">Automatically extracts ICD-10 and CPT codes from Zen Scribe clinical summaries.</p>
</div>
<div class="bill-card" style="flex-direction:column; align-items:flex-start;">
<div style="display:flex; justify-content:space-between; width:100%; margin-bottom:0.5rem;">
<strong>💬 Patient Triage Agent</strong>
<label class="switch">
<input type="checkbox" id="agent-TRIAGE" onchange="toggleAgent('TRIAGE', this.checked)">
<span class="slider round"></span>
</label>
</div>
<p class="text-muted" style="font-size:0.875rem;">Listens to WhatsApp messages and categorizes patient symptoms by urgency.</p>
</div>
<div class="bill-card" style="flex-direction:column; align-items:flex-start;">
<div style="display:flex; justify-content:space-between; width:100%; margin-bottom:0.5rem;">
<strong>🔄 Post-Visit Follow-Up</strong>
<label class="switch">
<input type="checkbox" id="agent-FOLLOWUP" onchange="toggleAgent('FOLLOWUP', this.checked)">
<span class="slider round"></span>
</label>
</div>
<p class="text-muted" style="font-size:0.875rem;">Automatically sends empathetic check-in messages to patients a few days after their visit.</p>
</div>
<div class="bill-card" style="flex-direction:column; align-items:flex-start;">
<div style="display:flex; justify-content:space-between; width:100%; margin-bottom:0.5rem;">
<strong>📦 Pharmacy Inventory Agent</strong>
<label class="switch">
<input type="checkbox" id="agent-INVENTORY" onchange="toggleAgent('INVENTORY', this.checked)">
<span class="slider round"></span>
</label>
</div>
<p class="text-muted" style="font-size:0.875rem;">Monitors pharmacy stock and automatically drafts purchase orders when supplies run low.</p>
</div>
<div class="bill-card" style="flex-direction:column; align-items:flex-start;">
<div style="display:flex; justify-content:space-between; width:100%; margin-bottom:0.5rem;">
<strong>🧠 Clinical Decision Support</strong>
<label class="switch">
<input type="checkbox" id="agent-CDSS" onchange="toggleAgent('CDSS', this.checked)">
<span class="slider round"></span>
</label>
</div>
<p class="text-muted" style="font-size:0.875rem;">Analyzes prescriptions against patient history to warn doctors of drug-drug interactions.</p>
</div>
</div>
</div>
</section>
<section class="settings-grid doctor-only hidden">
@ -179,7 +242,60 @@
</main>
<script>
async function fetchSettings() {
try {
const [settingsRes, agentsRes] = await Promise.all([
fetch('/api/clinic/settings', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('curio_token')}` }
}),
fetch('/api/clinic/agents', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('curio_token')}` }
})
]);
if (settingsRes.ok) {
const data = await settingsRes.json();
document.getElementById('clinicName').value = data.name;
document.getElementById('consultationFee').value = data.consultation_fee;
document.getElementById('primaryColor').value = data.primary_color || '#0F172A';
document.getElementById('secondaryColor').value = data.secondary_color || '#38BDF8';
}
if (agentsRes.ok) {
const agents = await agentsRes.json();
agents.forEach(a => {
const toggle = document.getElementById(`agent-${a.agent_type}`);
if (toggle) toggle.checked = a.is_active;
});
}
} catch (err) {
console.error(err);
}
}
async function toggleAgent(type, isActive) {
try {
const res = await fetch(`/api/clinic/agents/${type}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('curio_token')}`
},
body: JSON.stringify({ isActive })
});
if (!res.ok) {
alert('Failed to update agent config');
document.getElementById(`agent-${type}`).checked = !isActive;
}
} catch(e) {
console.error(e);
alert('Network Error');
document.getElementById(`agent-${type}`).checked = !isActive;
}
}
document.addEventListener('DOMContentLoaded', () => {
fetchSettings();
const role = localStorage.getItem('userRole') || 'OWNER';
const name = localStorage.getItem('userName') || 'Admin';