acadflow / services /aiService.js
skandaedutech's picture
Upload 14 files
0d3ef02 verified
Raw
History Blame
14.6 kB
const express = require('express');
const router = express.Router();
const { requireAuth, getTenantDb } = require('./auth');
const eventBus = require('./eventBus');
const { calculateLeadScore, generateAdvancedInsights } = require('./aiEngine');
const dbAdmin = require('../db');
// Helper to inject counselor-specific insights and pipeline statuses dynamically
const enrichPersonalRecommendation = async (db, user, payload) => {
try {
const userEmail = user?.email || '';
const { data: counselor } = await db
.from('counselors')
.select('*')
.eq('email', userEmail)
.maybeSingle();
let counselorName = user?.role || 'Counselor';
let personalMsg = '';
let personalMetrics = { activeLeads: 0, overdueLeads: 0, bestCourse: 'None' };
if (counselor) {
counselorName = counselor.name;
const { data: cLeads } = await db
.from('leads')
.select('*')
.eq('counselor_id', counselor.id);
const activeLeads = (cLeads || []).filter(l => !['Converted', 'Not Interested', 'Lost'].includes(l.status));
let overdueLeads = 0;
activeLeads.forEach(l => {
if (l.status === 'Pending') {
const date = new Date(l.created_at);
let days = 1;
switch (l.followup_time) {
case 'Today': case 'Immediate': days = 0; break;
case 'One Day': days = 1; break;
case 'Two Days': days = 2; break;
case '3 Days': days = 3; break;
case 'Within a Week': days = 7; break;
case 'Within a Month': days = 30; break;
}
date.setDate(date.getDate() + days);
if (new Date() > date) overdueLeads++;
}
});
const courseCounts = {};
activeLeads.forEach(l => {
if (l.course_interested) {
courseCounts[l.course_interested] = (courseCounts[l.course_interested] || 0) + 1;
}
});
let bestCourse = 'None';
let maxCount = 0;
for (const course in courseCounts) {
if (courseCounts[course] > maxCount) {
maxCount = courseCounts[course];
bestCourse = course;
}
}
personalMetrics = {
activeLeads: activeLeads.length,
overdueLeads,
bestCourse
};
if (overdueLeads > 0) {
personalMsg = `Hi ${counselorName}, you have ${activeLeads.length} active leads assigned, but ${overdueLeads} follow-up tasks are currently overdue. We recommend prioritizing contacts to these students today to prevent lead drop-off.`;
} else if (activeLeads.length > 0) {
personalMsg = `Hi ${counselorName}, your pipeline is looking strong with ${activeLeads.length} active leads. Your assigned students are most interested in "${bestCourse}". Focusing conversion efforts on these leads could yield the best outcomes this week!`;
} else {
personalMsg = `Hi ${counselorName}, you have no active leads assigned currently. Please coordinate with the admissions director to assign new prospective student inquiries.`;
}
} else {
// Fallback for Admin
const { data: allLeads } = await db.from('leads').select('status');
const activeCount = (allLeads || []).filter(l => !['Converted', 'Not Interested', 'Lost'].includes(l.status)).length;
personalMsg = `Hi Admin, AcadFlow overall enrollment pipelines have ${activeCount} active leads. Ensure counselor workloads are balanced to maintain optimal response times.`;
}
payload.personalRecommendation = {
counselorName,
message: personalMsg,
metrics: personalMetrics
};
} catch (personErr) {
console.error('Failed to generate personal recommendation:', personErr);
}
return payload;
};
// ===================================================================
// 📥 EVENT BUS SUBSCRIBERS (Decoupled Background AI Calculations)
// ===================================================================
// Process AI Lead predictions asynchronously in the background
eventBus.subscribe('lead.status_changed', async (payload) => {
const { lead_id, student_name, new_status, organization_id, branch_id } = payload;
if (!lead_id) return;
console.log(`[AIService] 📥 Background AI score task queued for student: ${student_name}`);
setImmediate(async () => {
try {
const score = calculateLeadScore({
followup_status: new_status,
lead_source: 'Referral'
});
const { error } = await dbAdmin
.from('leads')
.update({ lead_score: score })
.eq('id', lead_id);
if (error) throw error;
console.log(`[AIService] ✅ Completed background AI score calculation: ${score} for ${student_name}`);
eventBus.publish('ai.calculated', {
lead_id,
lead_score: score,
organization_id,
branch_id
});
} catch (err) {
console.error('[AIService] Failed background AI score task:', err);
}
});
});
// Throttled insights recalculation setup to prevent NVIDIA API spamming
let isCalculatingInsights = false;
let pendingInsightsCalculation = false;
const runAIInsightsCalculation = async (organizationId, branchId) => {
try {
console.log(`[AIService] Running background AI Insights compilation for org: ${organizationId}`);
const [leadsRes, admissionsRes, counselorsRes] = await Promise.all([
dbAdmin.from('leads').select('*').eq('organization_id', organizationId),
dbAdmin.from('admissions').select('*').eq('organization_id', organizationId),
dbAdmin.from('counselors').select('*').eq('organization_id', organizationId)
]);
if (leadsRes.error) throw leadsRes.error;
if (admissionsRes.error) throw admissionsRes.error;
if (counselorsRes.error) throw counselorsRes.error;
const leads = leadsRes.data || [];
const admissions = admissionsRes.data || [];
const counselors = counselorsRes.data || [];
// Async trigger of LLM context compilation
const insights = await generateAdvancedInsights(leads, admissions, counselors);
// Course trend forecast stats
const uniqueCourses = Array.from(new Set([
...leads.map(l => l.course_interested).filter(Boolean),
...admissions.map(a => a.course).filter(Boolean)
]));
const courseTrendData = uniqueCourses.map(course => {
const courseLeads = leads.filter(l => l.course_interested === course);
const courseAdmissions = admissions.filter(a => a.course === course);
// Deterministic growth calculation based on recent lead volume (last 14 days vs prior 14 days)
const now = new Date();
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const twentyEightDaysAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000);
const recentLeads = courseLeads.filter(l => new Date(l.created_at) >= fourteenDaysAgo).length;
const priorLeads = courseLeads.filter(l => {
const d = new Date(l.created_at);
return d >= twentyEightDaysAgo && d < fourteenDaysAgo;
}).length;
let growth = 0;
if (priorLeads > 0) {
growth = Math.round(((recentLeads - priorLeads) / priorLeads) * 100);
} else if (recentLeads > 0) {
growth = Math.min(recentLeads * 12, 50);
} else {
growth = Math.min(Math.max((courseAdmissions.length * 8) + (courseLeads.length * 2), 5), 45);
}
growth = Math.min(Math.max(growth, -15), 75);
return {
name: course,
leads: courseLeads.length,
admissions: courseAdmissions.length,
growth
};
});
const payload = {
...insights,
trendAnalysis: {
courses: courseTrendData,
uncontactedBottleneckCount: leads.filter(l => l.status === 'Demo Attended').length || 0,
counselorDelayFlag: true
}
};
// Save compiled insights as a JSON string in the 'message' column
const { error: insertError } = await dbAdmin
.from('ai_insights')
.insert([{
type: 'COMPILED_DASHBOARD',
message: JSON.stringify(payload),
priority: 'Normal',
organization_id: organizationId,
branch_id: branchId || '00000000-0000-0000-0000-000000000002'
}]);
if (insertError) throw insertError;
console.log(`[AIService] Background AI Insights compilation successful for org: ${organizationId}`);
} catch (err) {
console.error('[AIService] Failed background AI Insights compilation:', err);
}
};
const throttledInsightsCalculation = (organizationId, branchId) => {
if (isCalculatingInsights) {
pendingInsightsCalculation = true;
return;
}
isCalculatingInsights = true;
runAIInsightsCalculation(organizationId, branchId).finally(() => {
isCalculatingInsights = false;
if (pendingInsightsCalculation) {
pendingInsightsCalculation = false;
// Trigger one more calculation to catch latest updates
setTimeout(() => throttledInsightsCalculation(organizationId, branchId), 10000);
}
});
};
// Event bus subscriptions for background insights recalculations
eventBus.subscribe('lead.status_changed', (payload) => {
const { organization_id, branch_id } = payload;
setImmediate(() => throttledInsightsCalculation(organization_id, branch_id));
});
eventBus.subscribe('manual_admission.completed', (payload) => {
const { organization_id, branch_id } = payload;
setImmediate(() => throttledInsightsCalculation(organization_id, branch_id));
});
// ===================================================================
// REST API Routes
// ===================================================================
// GET /api/ai-insights - Serve the latest compiled NVIDIA LLM business intelligence reports instantly
router.get('/', requireAuth, async (req, res) => {
try {
const db = getTenantDb(req);
const orgId = req.user?.organization_id || '00000000-0000-0000-0000-000000000001';
// Return the dashboard insights array if hit from /api/insights or /server-api/insights
if (req.baseUrl === '/api/insights' || req.baseUrl === '/server-api/insights') {
const { data: leads, error: lErr } = await db.from('leads').select('*');
if (lErr) throw lErr;
const { generateDashboardInsights } = require('./aiEngine');
const dashboardInsights = generateDashboardInsights(leads || []);
return res.json(dashboardInsights);
}
// Fetch the latest compiled dashboard row from database
const { data, error } = await db
.from('ai_insights')
.select('*')
.eq('type', 'COMPILED_DASHBOARD')
.order('created_at', { ascending: false })
.limit(1);
if (error) throw error;
if (data && data.length > 0) {
try {
const payload = JSON.parse(data[0].message);
// Dynamic, on-the-fly personalization based on current requesting user
const enriched = await enrichPersonalRecommendation(db, req.user, payload);
return res.json(enriched);
} catch (parseErr) {
console.error('Failed to parse compiled dashboard JSON:', parseErr);
}
}
// Fallback: If no compiled row is in the database, calculate synchronously once and return it
console.log('[AIService] No pre-compiled AI Insights found. Running synchronous fallback...');
const [leadsRes, admissionsRes, counselorsRes] = await Promise.all([
db.from('leads').select('*'),
db.from('admissions').select('*'),
db.from('counselors').select('*')
]);
if (leadsRes.error) throw leadsRes.error;
if (admissionsRes.error) throw admissionsRes.error;
if (counselorsRes.error) throw counselorsRes.error;
const leads = leadsRes.data || [];
const admissions = admissionsRes.data || [];
const counselors = counselorsRes.data || [];
const insights = await generateAdvancedInsights(leads, admissions, counselors);
const uniqueCourses = Array.from(new Set([
...leads.map(l => l.course_interested).filter(Boolean),
...admissions.map(a => a.course).filter(Boolean)
]));
const courseTrendData = uniqueCourses.map(course => {
const courseLeads = leads.filter(l => l.course_interested === course);
const courseAdmissions = admissions.filter(a => a.course === course);
// Deterministic growth calculation based on recent lead volume
const now = new Date();
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const twentyEightDaysAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000);
const recentLeads = courseLeads.filter(l => new Date(l.created_at) >= fourteenDaysAgo).length;
const priorLeads = courseLeads.filter(l => {
const d = new Date(l.created_at);
return d >= twentyEightDaysAgo && d < fourteenDaysAgo;
}).length;
let growth = 0;
if (priorLeads > 0) {
growth = Math.round(((recentLeads - priorLeads) / priorLeads) * 100);
} else if (recentLeads > 0) {
growth = Math.min(recentLeads * 12, 50);
} else {
growth = Math.min(Math.max((courseAdmissions.length * 8) + (courseLeads.length * 2), 5), 45);
}
growth = Math.min(Math.max(growth, -15), 75);
return {
name: course,
leads: courseLeads.length,
admissions: courseAdmissions.length,
growth
};
});
const payload = {
...insights,
trendAnalysis: {
courses: courseTrendData,
uncontactedBottleneckCount: leads.filter(l => l.status === 'Demo Attended').length || 25,
counselorDelayFlag: true
}
};
// Save it so next hits are fast
await db.from('ai_insights').insert([{
type: 'COMPILED_DASHBOARD',
message: JSON.stringify(payload),
priority: 'Normal',
organization_id: orgId,
branch_id: req.user?.branch_id || '00000000-0000-0000-0000-000000000002'
}]);
const enriched = await enrichPersonalRecommendation(db, req.user, payload);
res.json(enriched);
} catch (error) {
console.error('Error compiling advanced AI reports:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;