feat: Pluggable AI Agent Engine and Copilot config UI
Build Curio HMS / build-and-deploy (push) Successful in 1m12s
Details
Build Curio HMS / build-and-deploy (push) Successful in 1m12s
Details
This commit is contained in:
parent
8b27b68932
commit
8619ddbdcf
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 '[]';
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
116
settings.html
116
settings.html
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue