import { GoogleGenerativeAI } from "@google/generative-ai"; import type { Permission } from "@/lib/permissions"; import { PERMISSIONS_LIST } from "@/lib/permissions"; // Only initialize Gemini if API key is available let genAI: GoogleGenerativeAI | null = null; let geminiModel: any = null; if (process.env.GEMINI_API_KEY) { try { genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); geminiModel = genAI.getGenerativeModel({ model: "gemini-2.0-flash", }); } catch (error) { console.warn("Failed to initialize Gemini AI:", error); } } else { console.warn( "GEMINI_API_KEY is not defined in environment variables. AI features will be disabled.", ); } /** * Gemini 2.0 Flash model instance. * Used for financial narratives, anomaly alerts, and natural language reports. */ export const gemini = geminiModel; // ── Financial Narrative (Dashboard) ────────────────────────── /** * Helper to generate a summary of financial data. */ export async function generateFinancialNarrative(data: any): Promise { // If Gemini is not available, return a default narrative if (!gemini) { return "AI features are currently disabled. Add your GEMINI_API_KEY to enable financial insights."; } try { const prompt = ` You are a senior financial analyst for a small business. Analyze the following business data and provide a concise, 2-3 sentence narrative of the business's current state. Focus on cash flow, outstanding revenue, and headcount. Keep tone professional, encouraging, and clear. Data: - Cash on hand: $${(data.metrics.cash_on_hand / 100).toLocaleString()} - Outstanding Invoices: $${(data.metrics.outstanding_invoices / 100).toLocaleString()} - Total Revenue (6 months): $${data.revenueData.reduce((sum: number, d: any) => sum + d.revenue, 0) / 100} - Headcount: ${data.metrics.headcount} Narrative: `; const result = await gemini.generateContent(prompt); const response = await result.response; return response.text().trim(); } catch (error) { console.error("Gemini narrative generation failed:", error); return "Your business remains steady this month. Focus on converting outstanding invoices to further strengthen your cash position."; } } // ── Payroll Anomaly Alerts ─────────────────────────────────── export interface PayslipDataForAI { employee_name: string; job_title: string | null; gross_pay: number; net_pay: number; hours_worked: number | null; hourly_rate: number | null; } export interface PayrollAnomalyResult { hasAnomalies: boolean; summary: string; anomalies: { employee: string; severity: "low" | "medium" | "high"; description: string }[]; } export async function analyzePayrollAnomalies( payslips: PayslipDataForAI[], period: string ): Promise { if (!gemini) { return { hasAnomalies: false, summary: "AI payroll analysis is currently unavailable.", anomalies: [], }; } try { const payslipSummary = payslips .map( (p) => `- ${p.employee_name} (${p.job_title || "N/A"}): Gross $${p.gross_pay.toFixed(2)}, Net $${p.net_pay.toFixed(2)}, Hours: ${p.hours_worked ?? "N/A"}, Rate: ${p.hourly_rate ? "$" + p.hourly_rate : "N/A"}` ) .join("\n"); const avgGross = payslips.reduce((s, p) => s + p.gross_pay, 0) / (payslips.length || 1); const avgHours = payslips.filter((p) => p.hours_worked).reduce((s, p) => s + (p.hours_worked || 0), 0) / (payslips.filter((p) => p.hours_worked).length || 1); const prompt = ` You are a payroll auditor reviewing a payroll run for period: ${period}. Analyze these payslips for anomalies such as: - Unusually high or low hours compared to the average (~${avgHours.toFixed(1)} hours) - Gross pay that significantly deviates from the average (~$${avgGross.toFixed(2)}) - Any patterns that seem suspicious (e.g., identical amounts, round numbers that seem fabricated) Payslips: ${payslipSummary} Respond ONLY with a JSON object in this exact format (no markdown, no backticks): { "hasAnomalies": boolean, "summary": "1-2 sentence overall assessment", "anomalies": [ { "employee": "name", "severity": "low|medium|high", "description": "what's unusual" } ] } If no anomalies are found, set hasAnomalies to false and anomalies to an empty array. `; const result = await gemini.generateContent(prompt); const response = await result.response; const text = response.text().trim(); // Parse JSON from response — strip any markdown code blocks const jsonStr = text.replace(/^```(?:json)?\s*|\s*```$/g, "").trim(); const parsed = JSON.parse(jsonStr) as PayrollAnomalyResult; return parsed; } catch (error) { console.error("Gemini payroll anomaly analysis failed:", error); return { hasAnomalies: false, summary: "Unable to analyze payroll — please review payslips manually.", anomalies: [], }; } } // ── Leave Pattern Insights ─────────────────────────────────── export interface LeaveRequestForAI { employee_name: string; leave_type_name: string; start_date: string; end_date: string; days_count: number; status: string; reason: string | null; } export interface LeavePatternResult { hasInsights: boolean; insights: string[]; } export async function analyzeLeavePatterns( leaveRequests: LeaveRequestForAI[], monthLabel: string ): Promise { if (!gemini) { return { hasInsights: false, insights: ["AI leave analysis is currently unavailable."] }; } try { const leaveData = leaveRequests .map( (r) => `- ${r.employee_name}: ${r.leave_type_name}, ${r.start_date} to ${r.end_date} (${r.days_count} days), Status: ${r.status}${r.reason ? `, Reason: ${r.reason}` : ""}` ) .join("\n"); const prompt = ` You are an HR analyst reviewing leave patterns for ${monthLabel}. Analyze these leave requests for interesting patterns such as: - Sick leave clustering (e.g., frequent Mondays/Fridays) - Employees taking excessive leave in a short period - Leave types that are unusually popular - Any correlations between leave reasons and timing - Employees who haven't taken any leave at all Leave Requests: ${leaveData} Respond ONLY with a JSON object in this exact format (no markdown, no backticks): { "hasInsights": boolean, "insights": [ "insight 1", "insight 2" ] } Provide 0-5 concise, actionable insights. If no interesting patterns exist, set hasInsights to false and insights to an empty array. `; const result = await gemini.generateContent(prompt); const response = await result.response; const text = response.text().trim(); const jsonStr = text.replace(/^```(?:json)?\s*|\s*```$/g, "").trim(); const parsed = JSON.parse(jsonStr) as LeavePatternResult; return parsed; } catch (error) { console.error("Gemini leave pattern analysis failed:", error); return { hasInsights: false, insights: [] }; } } // ── Invoice Chase Drafts ───────────────────────────────────── export interface OverdueInvoiceForAI { invoice_number: string; client_name: string; client_email: string | null; total_cents: number; due_date: string; days_overdue: number; } export interface InvoiceChaseDraft { invoice_number: string; subject: string; body: string; } export async function generateInvoiceChaseEmails( invoices: OverdueInvoiceForAI[], orgName: string ): Promise { if (!gemini) { return invoices.map((inv) => ({ invoice_number: inv.invoice_number, subject: `Payment Reminder: Invoice ${inv.invoice_number}`, body: "AI email generation is currently unavailable. Please send a manual follow-up.", })); } try { const invoiceList = invoices .map( (inv) => `- Invoice ${inv.invoice_number} for ${inv.client_name}, Amount: $${(inv.total_cents / 100).toFixed(2)}, Due: ${inv.due_date} (${inv.days_overdue} days overdue)` ) .join("\n"); const prompt = ` You are a professional accounts receivable clerk for ${orgName}. Draft polite but firm payment reminder emails for these overdue invoices: ${invoiceList} Rules: - Escalate tone based on days overdue (1-14 days: friendly reminder, 15-30 days: firmer, 30+ days: urgent) - Each email should be professional and concise - Include the invoice number, amount, and original due date - Add a call to action (pay now, contact us if there's an issue) Respond ONLY with a JSON array in this exact format (no markdown, no backticks): [ { "invoice_number": "INV-001", "subject": "Payment Reminder: Invoice INV-001", "body": "Dear [Client],\n\n..." } ] `; const result = await gemini.generateContent(prompt); const response = await result.response; const text = response.text().trim(); const jsonStr = text.replace(/^```(?:json)?\s*|\s*```$/g, "").trim(); const parsed = JSON.parse(jsonStr) as InvoiceChaseDraft[]; return parsed; } catch (error) { console.error("Gemini invoice chase generation failed:", error); return invoices.map((inv) => ({ invoice_number: inv.invoice_number, subject: `Payment Reminder: Invoice ${inv.invoice_number}`, body: `Dear ${inv.client_name},\n\nThis is a reminder that invoice ${inv.invoice_number} for $${(inv.total_cents / 100).toFixed(2)} was due on ${inv.due_date} and is now ${inv.days_overdue} days overdue.\n\nPlease arrange payment at your earliest convenience.\n\nRegards,\n${orgName}`, })); } } // ── Natural Language Reports ───────────────────────────────── export interface NLReportResult { query: string; answer: string; hasError: boolean; } export async function generateNaturalLanguageReport( query: string, context: { employeeCount: number; activeLeaveRequests: number; pendingInvoices: number; overdueInvoices: number; totalRevenue: number; recentPayrollRuns: number; } ): Promise { if (!gemini) { return { query, answer: "AI reports are currently unavailable.", hasError: false, }; } try { const prompt = ` You are a business intelligence assistant for a small-to-medium business using the Vela platform. The user asked: "${query}" Current business context: - Total employees: ${context.employeeCount} - Active leave requests: ${context.activeLeaveRequests} - Pending invoices: ${context.pendingInvoices} - Overdue invoices: ${context.overdueInvoices} - Total revenue (last 6 months): $${context.totalRevenue} - Recent payroll runs: ${context.recentPayrollRuns} Provide a helpful, specific answer to the user's query using this context. If the query is not answerable with the available data, explain what's needed. Keep the response concise (2-4 sentences) and professional. Answer: `; const result = await gemini.generateContent(prompt); const response = await result.response; return { query, answer: response.text().trim(), hasError: false, }; } catch (error) { console.error("Gemini natural language report failed:", error); return { query, answer: "Sorry, I couldn't process that query. Please try rephrasing.", hasError: true, }; } } // ── AI Permission Suggestion ───────────────────────────────── export interface AIPermissionSuggestionResult { /** Suggested permission keys. Empty if more info needed. */ permissions: Permission[]; /** If set, the AI needs more info from the user. Show this as a popup question. */ askForInfo: string | null; } export async function suggestPermissionsForRole( roleName: string, additionalContext?: string ): Promise { if (!gemini) { return { permissions: [], askForInfo: "AI is currently unavailable." }; } const availablePerms = PERMISSIONS_LIST.map((p) => ({ key: p.key, label: p.label, category: p.category })).join("\n"); const prompt = `You are a permissions advisor for the Vela business platform. Your ONLY job: Given a role name, return a JSON object with the exact permission keys that role should have. Available permissions (key — label — category): ${availablePerms} Role name: "${roleName}" ${additionalContext ? `Additional context: ${additionalContext}` : ""} RULES: 1. Respond ONLY with a JSON object. No markdown, no backticks, no explanation. 2. The JSON must have exactly two fields: - "permissions": array of permission key strings from the list above - "askForInfo": null, OR a short question if you need more info to decide 3. If you need more info, set "permissions" to [] and "askForInfo" to a specific question (e.g., "Should this role be able to manage payroll?"). 4. Do NOT invent permission keys. Only use keys from the list above. 5. Be conservative: only include permissions the role truly needs. Examples: - Role: "Bookkeeper" → includes can_view_invoices, can_manage_invoices, can_view_expenses, can_manage_expenses, can_view_reports, can_view_bank_feeds, etc. - Role: "Receptionist" → includes can_use_comms, can_view_noticeboard, can_view_directory, etc. - Role: "Intern" → includes can_view_portal, can_use_comms, can_submit_resignation - Role: "CFO" → includes can_manage_admin (grants all) JSON:`; try { const result = await gemini.generateContent(prompt); const response = await result.response; const text = response.text().trim(); const jsonStr = text.replace(/^```(?:json)?\s*|\s*```$/g, "").trim(); const parsed = JSON.parse(jsonStr) as AIPermissionSuggestionResult; // Validate: filter out any keys not in the permission list const validKeys = new Set(PERMISSIONS_LIST.map((p) => p.key)); parsed.permissions = parsed.permissions.filter((p) => validKeys.has(p as Permission)) as Permission[]; return parsed; } catch (error) { console.error("Gemini permission suggestion failed:", error); return { permissions: [], askForInfo: null }; } }