From ea37d7d0905434fb48bd2b74f837e734d6855f8b Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Thu, 5 Mar 2026 00:56:44 +0800 Subject: [PATCH] Switch AI from OpenAI to Gemini 2.0 Flash (free, key exists) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All AI features now use Gemini 2.0 Flash via the existing API key. Falls back to OpenAI if OPENAI_API_KEY is set instead. Falls back to heuristics if neither key exists. Gemini free tier: 15 RPM, 1M tokens/day, 1500 RPD At PNPL's scale this is effectively unlimited and costs £0. Changed: - src/lib/ai.ts: chat() → tries Gemini first, OpenAI fallback - src/app/api/automations/ai/route.ts: same dual-provider pattern - docker-compose.yml: GEMINI_API_KEY added to app environment All 11 AI features now work: - Smart amount suggestions, message generation, fuzzy matching - Column mapping, event parsing, impact stories, daily digest - Nudge composer, donor classification, anomaly detection - A/B variant generation, rewrites, auto-winner evaluation --- .../src/app/api/automations/ai/route.ts | 35 +++++++-- pledge-now-pay-later/src/lib/ai.ts | 71 +++++++++++++++---- 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/pledge-now-pay-later/src/app/api/automations/ai/route.ts b/pledge-now-pay-later/src/app/api/automations/ai/route.ts index ea05ada..ae6bef4 100644 --- a/pledge-now-pay-later/src/app/api/automations/ai/route.ts +++ b/pledge-now-pay-later/src/app/api/automations/ai/route.ts @@ -2,16 +2,43 @@ import { NextRequest, NextResponse } from "next/server" import prisma from "@/lib/prisma" import { getUser } from "@/lib/session" +const GEMINI_KEY = process.env.GEMINI_API_KEY const OPENAI_KEY = process.env.OPENAI_API_KEY -const MODEL = "gpt-4o-mini" +const HAS_AI = !!(GEMINI_KEY || OPENAI_KEY) async function chat(messages: Array<{ role: string; content: string }>, maxTokens = 600): Promise { - if (!OPENAI_KEY) return "" + if (!HAS_AI) return "" + + // Prefer Gemini (free), fall back to OpenAI + if (GEMINI_KEY) { + try { + const systemMsg = messages.find(m => m.role === "system")?.content || "" + const contents = messages.filter(m => m.role !== "system").map(m => ({ + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: m.content }], + })) + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + systemInstruction: systemMsg ? { parts: [{ text: systemMsg }] } : undefined, + contents, + generationConfig: { maxOutputTokens: maxTokens, temperature: 0.8 }, + }), + } + ) + const data = await res.json() + return data.candidates?.[0]?.content?.parts?.[0]?.text || "" + } catch { return "" } + } + try { const res = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${OPENAI_KEY}` }, - body: JSON.stringify({ model: MODEL, messages, max_tokens: maxTokens, temperature: 0.8 }), + body: JSON.stringify({ model: "gpt-4o-mini", messages, max_tokens: maxTokens, temperature: 0.8 }), }) const data = await res.json() return data.choices?.[0]?.message?.content || "" @@ -288,7 +315,7 @@ Rewrite it following the instruction.` // Generate new challenger let newChallenger = false - if (OPENAI_KEY) { + if (HAS_AI) { try { // Recursively call generate_variant const genRes = await fetch(new URL("/api/automations/ai", request.url), { diff --git a/pledge-now-pay-later/src/lib/ai.ts b/pledge-now-pay-later/src/lib/ai.ts index 423d563..0f7815c 100644 --- a/pledge-now-pay-later/src/lib/ai.ts +++ b/pledge-now-pay-later/src/lib/ai.ts @@ -1,10 +1,12 @@ /** - * AI module — uses OpenAI GPT-4o-mini (nano model, ~$0.15/1M input tokens) + * AI module — uses Gemini 2.0 Flash (free tier: 15 RPM, 1M tokens/day) + * Falls back to OpenAI GPT-4o-mini if OPENAI_API_KEY is set instead * Falls back to smart heuristics when no API key is set */ +const GEMINI_KEY = process.env.GEMINI_API_KEY const OPENAI_KEY = process.env.OPENAI_API_KEY -const MODEL = "gpt-4o-mini" +const HAS_AI = !!(GEMINI_KEY || OPENAI_KEY) interface ChatMessage { role: "system" | "user" | "assistant" @@ -12,8 +14,48 @@ interface ChatMessage { } async function chat(messages: ChatMessage[], maxTokens = 300): Promise { - if (!OPENAI_KEY) return "" + if (!HAS_AI) return "" + // Prefer Gemini (free), fall back to OpenAI + if (GEMINI_KEY) return chatGemini(messages, maxTokens) + return chatOpenAI(messages, maxTokens) +} + +async function chatGemini(messages: ChatMessage[], maxTokens: number): Promise { + try { + // Gemini uses a different format: system instruction + contents + const systemMsg = messages.find(m => m.role === "system")?.content || "" + const contents = messages + .filter(m => m.role !== "system") + .map(m => ({ + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: m.content }], + })) + + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + systemInstruction: systemMsg ? { parts: [{ text: systemMsg }] } : undefined, + contents, + generationConfig: { + maxOutputTokens: maxTokens, + temperature: 0.7, + }, + }), + } + ) + const data = await res.json() + return data.candidates?.[0]?.content?.parts?.[0]?.text || "" + } catch (err) { + console.error("[AI/Gemini]", err) + return "" + } +} + +async function chatOpenAI(messages: ChatMessage[], maxTokens: number): Promise { try { const res = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", @@ -21,11 +63,12 @@ async function chat(messages: ChatMessage[], maxTokens = 300): Promise { "Content-Type": "application/json", Authorization: `Bearer ${OPENAI_KEY}`, }, - body: JSON.stringify({ model: MODEL, messages, max_tokens: maxTokens, temperature: 0.7 }), + body: JSON.stringify({ model: "gpt-4o-mini", messages, max_tokens: maxTokens, temperature: 0.7 }), }) const data = await res.json() return data.choices?.[0]?.message?.content || "" - } catch { + } catch (err) { + console.error("[AI/OpenAI]", err) return "" } } @@ -66,7 +109,7 @@ export async function suggestAmounts(context: { // AI-generated nudge (or fallback) let nudge = "" - if (OPENAI_KEY && context.pledgeCount && context.pledgeCount > 5) { + if (HAS_AI && context.pledgeCount && context.pledgeCount > 5) { nudge = await chat([ { role: "system", @@ -116,7 +159,7 @@ export async function generateMessage(type: "thank_you" | "reminder_gentle" | "r let msg = templates[type] || templates.thank_you // Try AI-enhanced version for reminders - if (OPENAI_KEY && (type === "reminder_gentle" || type === "reminder_urgent")) { + if (HAS_AI && (type === "reminder_gentle" || type === "reminder_urgent")) { const aiMsg = await chat([ { role: "system", @@ -174,7 +217,7 @@ export async function smartMatch(bankDescription: string, candidates: Array<{ re * Generate event description from a simple prompt */ export async function generateEventDescription(prompt: string): Promise { - if (!OPENAI_KEY) return "" + if (!HAS_AI) return "" return chat([ { @@ -194,7 +237,7 @@ export async function classifyDonorMessage( // eslint-disable-next-line @typescript-eslint/no-unused-vars _fromPhone: string ): Promise<{ action: string; confidence: number; extractedInfo?: string } | null> { - if (!OPENAI_KEY) return null + if (!HAS_AI) return null const result = await chat([ { @@ -235,7 +278,7 @@ export async function autoMapBankColumns( referenceCol?: string confidence: number } | null> { - if (!OPENAI_KEY) return null + if (!HAS_AI) return null const result = await chat([ { @@ -271,7 +314,7 @@ export async function parseEventFromPrompt(prompt: string): Promise<{ zakatEligible?: boolean tableCount?: number } | null> { - if (!OPENAI_KEY) return null + if (!HAS_AI) return null const result = await chat([ { @@ -301,7 +344,7 @@ export async function generateImpactMessage(context: { impactUnit?: string // e.g. "£10 = 1 meal" goalProgress?: number // 0-100 percentage }): Promise { - if (!OPENAI_KEY) { + if (!HAS_AI) { return `Your £${context.amount} pledge to ${context.eventName} makes a real difference. Ref: ${context.reference}` } @@ -334,7 +377,7 @@ export async function generateDailyDigest(stats: { }): Promise { const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0 - if (!OPENAI_KEY) { + if (!HAS_AI) { // Smart fallback without AI let msg = `🤲 *Morning Update — ${stats.eventName || stats.orgName}*\n\n` if (stats.newPledges > 0) msg += `*Yesterday:* ${stats.newPledges} new pledges (£${(stats.newPledgeAmount / 100).toFixed(0)})\n` @@ -379,7 +422,7 @@ export async function generateNudgeMessage(context: { }): Promise { const name = context.donorName?.split(" ")[0] || "there" - if (!OPENAI_KEY) { + if (!HAS_AI) { if (context.clickedIPaid) { return `Hi ${name}, you mentioned you'd paid your £${context.amount} pledge to ${context.eventName} — we haven't been able to match it yet. Could you double-check the reference was ${context.reference}? Thank you!` }