AI-native A/B testing: auto-generate variants, auto-promote winners
THE AUTOMATION ENGINE IS NOW SELF-IMPROVING. ## Core: AI generates challenger variants Click '✨ AI: Test a new approach' → GPT-4o-mini analyzes variant A and creates variant B using a fundamentally DIFFERENT psychological approach. Not a rephrase — a different strategy: - Social proof ('47 others have already paid') - Urgency (deadline framing) - Impact storytelling ('£50 = 3 weeks of food') - Personal connection (heavy name usage) - Brevity (strip to minimum) - Gratitude-first (lead with thanks) - Loss framing ('pledge at risk of being unfulfilled') - Community ('join 23 others who completed this week') AI explains WHY: 'This variant uses social proof instead of a gentle reminder — peer pressure converts better for step 2.' ## Core: Automatic winner promotion Click '🏆 Pick winners' → system evaluates ALL running A/B tests: 1. Checks minimum sample size (20 sends per variant) 2. Runs z-test for statistical significance (90% confidence) 3. Promotes winner to variant A (resets counters) 4. Deletes loser 5. AUTOMATICALLY generates a NEW AI challenger The cycle never stops. Messages continuously evolve. ## Core: AI rewrite toolbar 8 one-click AI rewrites for any template: ✂️ Make shorter · 💛 Make warmer · ⏰ Add urgency 👥 Add social proof · 💚 Add impact story · 🎯 Strip to essentials 🇵🇰 Translate to Urdu · 🇸🇦 Translate to Arabic All rewrites preserve {{variable}} placeholders. All use GPT-4o-mini (~/usr/bin/bash.15/1M tokens). ## UI: A/B Stats Card (below phone mockup) - Side-by-side conversion rates with trophy icon on winner - Progress bar to verdict (% of minimum sample collected) - Lift calculation: 'Variant B converts 63% better' - Real-time during test: 'A: 33% → B: 54% ★' ## UI: Winner Results Banner After 'Pick winners' runs: - Green banner: '🏆 Winners promoted — Gentle reminder · WhatsApp → Variant B wins (54% vs 33%) ✨ New AI challenger created' - Gray banner if not enough data: 'Need 20+ sends per variant' ## API: /api/automations/ai (POST) Actions: - generate_variant: AI creates challenger B with strategy reasoning - rewrite: AI rewrites template with specific instruction - check_winners: evaluate all tests, promote, regenerate ## Architecture The system is a GENETIC ALGORITHM for messaging: 1. Start with default templates (generation 0) 2. AI creates a challenger (mutation) 3. Traffic splits 50/50 (fitness test) 4. Winner survives, loser dies (selection) 5. AI creates new challenger (next generation) 6. Repeat forever → messages get better over time
This commit is contained in:
320
pledge-now-pay-later/src/app/api/automations/ai/route.ts
Normal file
320
pledge-now-pay-later/src/app/api/automations/ai/route.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import prisma from "@/lib/prisma"
|
||||||
|
import { getUser } from "@/lib/session"
|
||||||
|
|
||||||
|
const OPENAI_KEY = process.env.OPENAI_API_KEY
|
||||||
|
const MODEL = "gpt-4o-mini"
|
||||||
|
|
||||||
|
async function chat(messages: Array<{ role: string; content: string }>, maxTokens = 600): Promise<string> {
|
||||||
|
if (!OPENAI_KEY) 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 }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
return data.choices?.[0]?.message?.content || ""
|
||||||
|
} catch { return "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/automations/ai
|
||||||
|
*
|
||||||
|
* Actions:
|
||||||
|
* - generate_variant: AI creates a challenger variant B
|
||||||
|
* - rewrite: AI rewrites a template with a specific instruction
|
||||||
|
* - check_winners: Evaluate all A/B tests and auto-promote winners
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const user = await getUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
|
||||||
|
if (!prisma) return NextResponse.json({ error: "No DB" }, { status: 503 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { action } = body
|
||||||
|
const orgId = user.orgId
|
||||||
|
|
||||||
|
// ─── GENERATE VARIANT ───────────────────────────
|
||||||
|
if (action === "generate_variant") {
|
||||||
|
const { step, channel } = body
|
||||||
|
|
||||||
|
const existing = await prisma.messageTemplate.findFirst({
|
||||||
|
where: { organizationId: orgId, step, channel, variant: "A" },
|
||||||
|
})
|
||||||
|
if (!existing) return NextResponse.json({ error: "No variant A found" }, { status: 404 })
|
||||||
|
|
||||||
|
// Get org context
|
||||||
|
const org = await prisma.organization.findUnique({
|
||||||
|
where: { id: orgId },
|
||||||
|
select: { name: true, orgType: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const stepLabels: Record<number, string> = {
|
||||||
|
0: "pledge receipt (instant confirmation with bank details)",
|
||||||
|
1: "gentle reminder (day 2, donor hasn't paid yet)",
|
||||||
|
2: "impact nudge (day 7, building urgency with purpose)",
|
||||||
|
3: "final reminder (day 14, last message before marking overdue)",
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelRules: Record<string, string> = {
|
||||||
|
whatsapp: "WhatsApp message. Use WhatsApp formatting: *bold*, _italic_, `code` for references. Emojis are good. Keep conversational. Can use ━━━ dividers. Reply keywords: PAID, HELP, CANCEL.",
|
||||||
|
email: "Email body (plain text, will be formatted). Can be slightly longer. Include {{pledge_url}} and {{cancel_url}} links.",
|
||||||
|
sms: "SMS message. MUST be under 160 characters. No formatting. Be extremely concise. Every character counts.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await chat([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `You are an A/B testing expert for UK charity fundraising messages. You generate CHALLENGER variants that take a fundamentally different psychological approach.
|
||||||
|
|
||||||
|
Your job: given an existing message (variant A), create a variant B that tests a DIFFERENT strategy. Don't just rephrase — change the APPROACH.
|
||||||
|
|
||||||
|
Strategies to try (pick ONE that's different from variant A):
|
||||||
|
- SOCIAL PROOF: "47 others have already paid their pledge"
|
||||||
|
- URGENCY: deadline framing, countdown language
|
||||||
|
- IMPACT STORYTELLING: specific impact ("£50 = 3 weeks of food for a family")
|
||||||
|
- PERSONAL CONNECTION: use donor's name heavily, feel like a 1-to-1 conversation
|
||||||
|
- BREVITY: strip to absolute minimum — sometimes less is more
|
||||||
|
- GRATITUDE-FIRST: lead with thanks, make the ask secondary
|
||||||
|
- LOSS FRAMING: "Your pledge is at risk of being marked unfulfilled"
|
||||||
|
- COMMUNITY: "Join 23 others who've completed their pledge this week"
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Use the SAME template variables: {{name}}, {{amount}}, {{event}}, {{reference}}, {{bank_name}}, {{sort_code}}, {{account_no}}, {{org_name}}, {{days}}, {{cancel_url}}, {{pledge_url}}
|
||||||
|
- ${channelRules[channel] || "Keep appropriate for the channel."}
|
||||||
|
- UK English. Warm but professional. Charity context: ${org?.orgType || "charity"}.
|
||||||
|
- Org name: ${org?.name || "the charity"}
|
||||||
|
|
||||||
|
Return ONLY valid JSON:
|
||||||
|
{
|
||||||
|
"body": "the new message template with {{variables}}",
|
||||||
|
"subject": "email subject line with {{variables}} (null if not email)",
|
||||||
|
"name": "short name for this variant (e.g. 'Social proof nudge')",
|
||||||
|
"strategy": "which strategy you used (e.g. 'social_proof')",
|
||||||
|
"reasoning": "1-2 sentences explaining WHY this approach might outperform variant A"
|
||||||
|
}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Step: ${stepLabels[step] || `step ${step}`}
|
||||||
|
Channel: ${channel}
|
||||||
|
Current variant A:
|
||||||
|
---
|
||||||
|
${existing.body}
|
||||||
|
---
|
||||||
|
|
||||||
|
Generate a challenger variant B using a DIFFERENT psychological approach.`
|
||||||
|
}
|
||||||
|
], 800)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return NextResponse.json({ error: "AI unavailable — no API key configured" }, { status: 503 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(result)
|
||||||
|
|
||||||
|
// Save as variant B
|
||||||
|
await prisma.messageTemplate.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId_step_channel_variant: {
|
||||||
|
organizationId: orgId, step, channel, variant: "B",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
body: parsed.body,
|
||||||
|
subject: parsed.subject || null,
|
||||||
|
name: parsed.name || "AI Challenger",
|
||||||
|
isActive: true,
|
||||||
|
splitPercent: 50,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organizationId: orgId, step, channel, variant: "B",
|
||||||
|
body: parsed.body,
|
||||||
|
subject: parsed.subject || null,
|
||||||
|
name: parsed.name || "AI Challenger",
|
||||||
|
isActive: true,
|
||||||
|
splitPercent: 50,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set variant A to 50%
|
||||||
|
await prisma.messageTemplate.updateMany({
|
||||||
|
where: { organizationId: orgId, step, channel, variant: "A" },
|
||||||
|
data: { splitPercent: 50 },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
variant: {
|
||||||
|
body: parsed.body,
|
||||||
|
subject: parsed.subject,
|
||||||
|
name: parsed.name,
|
||||||
|
strategy: parsed.strategy,
|
||||||
|
reasoning: parsed.reasoning,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "AI returned invalid response" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── REWRITE ────────────────────────────────────
|
||||||
|
if (action === "rewrite") {
|
||||||
|
const { instruction, currentBody } = body
|
||||||
|
const channel = body.channel
|
||||||
|
|
||||||
|
const channelHints: Record<string, string> = {
|
||||||
|
whatsapp: "WhatsApp formatting (*bold*, _italic_, `code`). Emojis OK. Conversational.",
|
||||||
|
email: "Email body (plain text). Can include links like {{pledge_url}}.",
|
||||||
|
sms: "SMS — MUST be under 160 characters. No formatting.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await chat([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `You rewrite UK charity fundraising messages. Follow the instruction exactly. Keep ALL {{variable}} placeholders. ${channelHints[channel] || ""}
|
||||||
|
Return ONLY the rewritten message text — nothing else. No JSON, no explanation.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Instruction: ${instruction}
|
||||||
|
|
||||||
|
Current message:
|
||||||
|
---
|
||||||
|
${currentBody}
|
||||||
|
---
|
||||||
|
|
||||||
|
Rewrite it following the instruction.`
|
||||||
|
}
|
||||||
|
], 600)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return NextResponse.json({ error: "AI unavailable" }, { status: 503 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, body: result.trim() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CHECK WINNERS ──────────────────────────────
|
||||||
|
if (action === "check_winners") {
|
||||||
|
const MIN_SAMPLE = body.minSample || 20 // minimum sends per variant before judging
|
||||||
|
|
||||||
|
// Find all A/B pairs for this org
|
||||||
|
const templates = await prisma.messageTemplate.findMany({
|
||||||
|
where: { organizationId: orgId },
|
||||||
|
orderBy: [{ step: "asc" }, { channel: "asc" }, { variant: "asc" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group by step+channel
|
||||||
|
const groups = new Map<string, typeof templates>()
|
||||||
|
for (const t of templates) {
|
||||||
|
const key = `${t.step}:${t.channel}`
|
||||||
|
if (!groups.has(key)) groups.set(key, [])
|
||||||
|
groups.get(key)!.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
step: number; channel: string
|
||||||
|
winner: string; loser: string
|
||||||
|
winnerRate: number; loserRate: number
|
||||||
|
action: "promoted" | "too_early" | "no_ab"
|
||||||
|
newChallenger?: boolean
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const group of Array.from(groups.values())) {
|
||||||
|
if (group.length < 2) {
|
||||||
|
results.push({ step: group[0].step, channel: group[0].channel, winner: "A", loser: "-", winnerRate: 0, loserRate: 0, action: "no_ab" })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = group.find(t => t.variant === "A")
|
||||||
|
const b = group.find(t => t.variant === "B")
|
||||||
|
if (!a || !b) continue
|
||||||
|
|
||||||
|
// Not enough data yet
|
||||||
|
if (a.sentCount < MIN_SAMPLE || b.sentCount < MIN_SAMPLE) {
|
||||||
|
results.push({
|
||||||
|
step: a.step, channel: a.channel,
|
||||||
|
winner: "-", loser: "-",
|
||||||
|
winnerRate: a.sentCount > 0 ? Math.round((a.convertedCount / a.sentCount) * 100) : 0,
|
||||||
|
loserRate: b.sentCount > 0 ? Math.round((b.convertedCount / b.sentCount) * 100) : 0,
|
||||||
|
action: "too_early",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateA = a.convertedCount / a.sentCount
|
||||||
|
const rateB = b.convertedCount / b.sentCount
|
||||||
|
|
||||||
|
// Statistical significance: simplified z-test
|
||||||
|
const pooledRate = (a.convertedCount + b.convertedCount) / (a.sentCount + b.sentCount)
|
||||||
|
const se = Math.sqrt(pooledRate * (1 - pooledRate) * (1 / a.sentCount + 1 / b.sentCount))
|
||||||
|
const z = se > 0 ? Math.abs(rateA - rateB) / se : 0
|
||||||
|
|
||||||
|
// z > 1.65 ≈ 90% confidence
|
||||||
|
if (z < 1.65) {
|
||||||
|
results.push({
|
||||||
|
step: a.step, channel: a.channel,
|
||||||
|
winner: "-", loser: "-",
|
||||||
|
winnerRate: Math.round(rateA * 100),
|
||||||
|
loserRate: Math.round(rateB * 100),
|
||||||
|
action: "too_early",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have a winner!
|
||||||
|
const winnerIs = rateB > rateA ? "B" : "A"
|
||||||
|
const winner = winnerIs === "B" ? b : a
|
||||||
|
const loser = winnerIs === "B" ? a : b
|
||||||
|
|
||||||
|
// Promote winner: winner becomes A, loser gets deleted
|
||||||
|
await prisma.messageTemplate.update({
|
||||||
|
where: { id: a.id },
|
||||||
|
data: {
|
||||||
|
body: winner.body,
|
||||||
|
subject: winner.subject,
|
||||||
|
name: winner.name,
|
||||||
|
splitPercent: 100,
|
||||||
|
sentCount: 0,
|
||||||
|
convertedCount: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete B
|
||||||
|
await prisma.messageTemplate.delete({ where: { id: b.id } })
|
||||||
|
|
||||||
|
// Generate new challenger
|
||||||
|
let newChallenger = false
|
||||||
|
if (OPENAI_KEY) {
|
||||||
|
try {
|
||||||
|
// Recursively call generate_variant
|
||||||
|
const genRes = await fetch(new URL("/api/automations/ai", request.url), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
cookie: request.headers.get("cookie") || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ action: "generate_variant", step: winner.step, channel: winner.channel }),
|
||||||
|
})
|
||||||
|
if (genRes.ok) newChallenger = true
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
step: winner.step, channel: winner.channel,
|
||||||
|
winner: winnerIs, loser: winnerIs === "B" ? "A" : "B",
|
||||||
|
winnerRate: Math.round((winner.convertedCount / winner.sentCount) * 100),
|
||||||
|
loserRate: Math.round((loser.convertedCount / loser.sentCount) * 100),
|
||||||
|
action: "promoted",
|
||||||
|
newChallenger,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, results })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "Unknown action" }, { status: 400 })
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user