const express = require('express'); const { google } = require('googleapis'); const { GoogleGenerativeAI } = require('@google/generative-ai'); const cors = require('cors'); const path = require('path'); require('dotenv').config(); const app = express(); app.use(express.json()); const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; app.use(cors({ origin: frontendUrl })); const PORT = process.env.PORT || 7860; const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); let processedStaffCache = null; let rawSheetCache = null; const CACHE_DURATION_MS = 300000; const isValidName = (name) => name && name.trim() !== '' && name.trim() !== '-'; function getGoogleAuth() { if (process.env.GOOGLE_CREDENTIALS_JSON) { const credentials = JSON.parse(process.env.GOOGLE_CREDENTIALS_JSON); return new google.auth.GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'], }); } return new google.auth.GoogleAuth({ keyFile: 'google-credentials.json', scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'], }); } async function updateSheetCache() { try { console.log('Refreshing spreadsheet data cache...'); const auth = getGoogleAuth(); const sheets = google.sheets({ version: 'v4', auth }); const response = await sheets.spreadsheets.values.get({ spreadsheetId: process.env.GOOGLE_SHEET_ID, range: 'Sheet1!A:H', }); const rows = response.data.values; if (!rows || rows.length === 0) { console.log('No data found in spreadsheet.'); return; } rawSheetCache = rows; const headers = rows[0]; const dataRows = rows.slice(1); let currentLocation = ''; let currentOfficeExt = 'N/A'; const allStaff = []; dataRows.forEach((row, rowIndex) => { const rowData = {}; headers.forEach((header, index) => rowData[header] = row[index] || ''); if (isValidName(rowData.Location)) { currentLocation = rowData.Location; if (currentLocation.toLowerCase().includes('liveoak')) { currentOfficeExt = 'Main Branch'; } else { const extMatch = currentLocation.match(/Ext\s*(\d+)/i); currentOfficeExt = extMatch ? extMatch[1] : 'N/A'; } } rowData.Location = currentLocation; rowData.OfficeExtension = currentOfficeExt; const baseId = `r${rowIndex}`; if (isValidName(rowData.Provider)) allStaff.push({ ...rowData, id: `${baseId}-p`, role: 'Provider' }); if (isValidName(rowData.MA)) allStaff.push({ ...rowData, id: `${baseId}-ma`, role: 'Medical Assistant', Extension: rowData.Extension }); if (isValidName(rowData.VA)) allStaff.push({ ...rowData, id: `${baseId}-va`, role: 'Virtual Assistant', Extension: rowData.Ext }); if (isValidName(rowData['Team Leads/Manager'])) allStaff.push({ ...rowData, id: `${baseId}-tl`, role: 'Team Lead / Manager', Extension: rowData['Ext/Phone#'] }); if (isValidName(rowData['Ext/Phone#']) && /^[a-zA-Z]+-\s*\d+$/.test(rowData['Ext/Phone#'])) { const [name, ext] = rowData['Ext/Phone#'].split('-'); allStaff.push({ ...rowData, id: `${baseId}-other`, role: 'Other Staff', OtherStaffName: name.trim(), Extension: ext.trim() }); } }); processedStaffCache = allStaff; console.log('Cache updated. Processed records:', processedStaffCache.length); } catch (error) { console.error('Error refreshing spreadsheet cache:', error.message); } } const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); async function generateWithRetry(model, prompt, maxRetries = 3) { let attempt = 0; while (attempt < maxRetries) { try { return await model.generateContentStream(prompt); } catch (error) { if (error.status === 503 && attempt < maxRetries - 1) { const delayTime = Math.pow(2, attempt) * 1000 + Math.random() * 1000; await delay(delayTime); attempt++; } else { throw error; } } } } app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'index.html'))); app.get('/api/data', (req, res) => { if (!processedStaffCache) return res.status(503).json({ message: 'Data is being fetched.' }); res.json(processedStaffCache); }); app.get('/api/raw-data', (req, res) => { if (!rawSheetCache) return res.status(503).json({ message: 'Data is being fetched.' }); res.json(rawSheetCache); }); app.get('/api/ask-luis', async (req, res) => { const { question } = req.query; if (!question || !processedStaffCache) { return res.status(400).send('Bad request.'); } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const prompt = ` You are "Luis", an AI data analyst for the Hillside Medical Group staff directory. Your primary function is to answer questions about staff based ONLY on the JSON data provided. **Your Core Instructions:** 1. **Data Relationships:** - The 'Location' key groups all staff at a specific clinic. - 'MA' (Medical Assistant) is directly associated with the 'Provider' in the same row. - 'VA' (Virtual Assistant) is associated with the 'Location' of its row. Their extension is in the 'Ext' column. - The 'Team Leads/Manager' column lists supervisors for a 'Location'. Their extension is in 'Ext/Phone#'. 2. **Structured Output:** When asked to list multiple people (e.g., "list the VAs for LiveOak"), you MUST format the response as a clean Markdown table. Include relevant columns like Name and Extension. 3. **Highlighting:** To make important data like names, locations, or extensions stand out in your text or tables, enclose them in single backticks. For example: "The main office for \`LiveOak\` is the \`Main Branch\`." 4. **Aggregations (Counts):** If asked for a count ("how many MAs?", "VA count"), calculate the total from the data and give a precise number. 5. **Handling Ambiguity:** If a question is ambiguous (e.g., "give me the list of MAs" without a location), you MUST ask a clarifying question (e.g., "Of which location would you like the list of MAs? You can also ask for all of them."). **Directory Data:** ${JSON.stringify(processedStaffCache)} **User's Question:** "${question}" `; const AI_TIMEOUT = 20000; // 20 seconds const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('AI_TIMEOUT')), AI_TIMEOUT) ); try { const resultPromise = generateWithRetry(model, prompt); const result = await Promise.race([resultPromise, timeoutPromise]); for await (const chunk of result.stream) { if (chunk.text()) { res.write(`data: ${JSON.stringify({ chunk: chunk.text() })}\n\n`); } } } catch (error) { let errorMessage = "I'm having a bit of trouble connecting to my brain right now. Please try again in a moment."; if (error.message === 'AI_TIMEOUT') { errorMessage = "Sorry, my connection to the AI service timed out. The service may be busy. Please try your question again in a few moments."; } else if (error.status === 503) { errorMessage = "The directory AI service is currently busy. Please try again."; } res.write(`data: ${JSON.stringify({ error: errorMessage })}\n\n`); } finally { res.write('data: [DONE]\n\n'); res.end(); } }); app.listen(PORT, () => { console.log(`Backend server is running and listening on port ${PORT}`); updateSheetCache(); setInterval(updateSheetCache, CACHE_DURATION_MS); });