← back to Norma
app/api/email-analyzer/score/route.ts
225 lines
/**
* POST /api/email-analyzer/score
* Returns 5 parallel scores (0-100) for an email: Letter Strength,
* Donation Persuasiveness, Polling/Policy Alignment, Emotional Resonance,
* Call-to-Action. Each score has a short rationale.
*
* Optional body fields `previousEmail` and `previousScores` enable iterative
* feedback: the model returns `whatChanged`, `improvements[]` (categories that
* moved up) and `nextSteps[]` (concrete edits to push toward 100).
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/require-role';
import { checkRateLimit } from '@/lib/rate-limit';
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
export interface ScoreItem {
key: string;
label: string;
score: number;
rationale: string;
}
export interface Improvement {
key: string;
label: string;
delta: number;
note: string;
}
export interface NextStep {
key: string;
label: string;
suggestion: string;
expectedGain: number;
}
const CATEGORIES: Array<{ key: string; label: string; prompt: string }> = [
{ key: 'letter', label: 'Letter Strength', prompt: 'overall persuasive strength and craft of the letter' },
{ key: 'donation', label: 'Donation Persuasiveness', prompt: 'likelihood to motivate a reader to donate money' },
{ key: 'policy', label: 'Polling / Policy Alignment', prompt: 'alignment with current public polling and mainstream policy positions' },
{ key: 'emotion', label: 'Emotional Resonance', prompt: 'emotional impact and storytelling' },
{ key: 'cta', label: 'Call-to-Action Clarity', prompt: 'clarity and urgency of the call-to-action' },
];
const LABELS = Object.fromEntries(CATEGORIES.map((c) => [c.key, c.label]));
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const rl = checkRateLimit(request, 10, 60_000);
if (rl.limited) {
return NextResponse.json({ error: 'Too many AI requests.' }, { status: 429 });
}
try {
const body = await request.json();
const {
email,
previousEmail,
previousScores,
} = body as {
email?: string;
previousEmail?: string;
previousScores?: Array<{ key: string; score: number }>;
};
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'email is required' }, { status: 400 });
}
if (!GEMINI_API_KEY) {
return NextResponse.json({ error: 'Gemini API key not configured' }, { status: 500 });
}
const categoryLines = CATEGORIES.map((c) => `- ${c.key}: ${c.prompt}`).join('\n');
const hasPrevious = !!previousEmail && Array.isArray(previousScores) && previousScores.length > 0;
const previousBlock = hasPrevious
? `
PREVIOUS VERSION:
---
${(previousEmail || '').slice(0, 4000)}
---
PREVIOUS SCORES:
${(previousScores || []).map((s) => `- ${s.key}: ${s.score}`).join('\n')}
`
: '';
const extraFields = hasPrevious
? `,
"whatChanged": "<one sentence: what the author changed from the previous version>",
"improvements": [
{ "key": "<category>", "note": "<one sentence: why this category improved>" }
],
"nextSteps": [
{ "key": "<category>", "suggestion": "<a specific, concrete edit the author can make>", "expectedGain": <number 5-25> }
]`
: `,
"nextSteps": [
{ "key": "<category>", "suggestion": "<a specific, concrete edit the author can make>", "expectedGain": <number 5-25> }
]`;
const prompt = `You are an expert political fundraising and advocacy email evaluator for nonprofits.
Score this email on 5 independent dimensions, each 0-100. Give brutally honest scores — do not be generous.
${hasPrevious ? 'Compare it to the previous version and explain what moved the scores.' : ''}
Also recommend 3-5 **concrete, specific** next-version edits that would push the weakest dimensions toward 100.
Return ONLY valid JSON with this exact shape:
{
"scores": [
{ "key": "letter", "score": <0-100>, "rationale": "<one sentence>" },
{ "key": "donation", "score": <0-100>, "rationale": "<one sentence>" },
{ "key": "policy", "score": <0-100>, "rationale": "<one sentence>" },
{ "key": "emotion", "score": <0-100>, "rationale": "<one sentence>" },
{ "key": "cta", "score": <0-100>, "rationale": "<one sentence>" }
]${extraFields}
}
CATEGORIES:
${categoryLines}
${previousBlock}
CURRENT EMAIL:
---
${email.slice(0, 8000)}
---
Return only the JSON, no markdown fences, no explanation.`;
const res = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3, maxOutputTokens: 2048, responseMimeType: 'application/json' },
}),
});
if (!res.ok) {
const errText = await res.text();
console.error('[email-analyzer/score] Gemini error:', res.status, errText);
return NextResponse.json({ error: `AI score failed (${res.status})` }, { status: 502 });
}
const data = await res.json();
const raw = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';
const cleaned = raw.replace(/^```json?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
let parsed: {
scores?: Array<{ key: string; score: number; rationale: string }>;
whatChanged?: string;
improvements?: Array<{ key: string; note: string }>;
nextSteps?: Array<{ key: string; suggestion: string; expectedGain: number }>;
} = {};
try {
parsed = JSON.parse(cleaned);
} catch (e) {
console.error('[email-analyzer/score] JSON parse error:', (e as Error).message, 'raw:', raw.slice(0, 200));
return NextResponse.json({ error: 'Model returned invalid JSON' }, { status: 502 });
}
const byKey = new Map((parsed.scores || []).map((s) => [s.key, s]));
const scores: ScoreItem[] = CATEGORIES.map((c) => {
const s = byKey.get(c.key);
const score = Math.max(0, Math.min(100, Math.round(Number(s?.score ?? 0))));
return {
key: c.key,
label: c.label,
score,
rationale: (s?.rationale || '').slice(0, 280),
};
});
const overall = Math.round(scores.reduce((sum, s) => sum + s.score, 0) / scores.length);
// Derive improvements objectively from delta (LLM note provides the "why")
const prevByKey = new Map((previousScores || []).map((s) => [s.key, s.score]));
const improvementsByKey = new Map(
(parsed.improvements || []).map((i) => [i.key, i.note]),
);
const improvements: Improvement[] = scores
.map((s) => {
const prev = prevByKey.get(s.key);
if (prev === undefined) return null;
const delta = s.score - prev;
if (delta <= 0) return null;
return {
key: s.key,
label: s.label,
delta,
note: (improvementsByKey.get(s.key) || '').slice(0, 280),
};
})
.filter((i): i is Improvement => i !== null)
.sort((a, b) => b.delta - a.delta);
const nextSteps: NextStep[] = (parsed.nextSteps || [])
.map((n) => ({
key: n.key,
label: LABELS[n.key] || n.key,
suggestion: (n.suggestion || '').slice(0, 320),
expectedGain: Math.max(0, Math.min(50, Math.round(Number(n.expectedGain ?? 10)))),
}))
.filter((n) => n.suggestion)
.slice(0, 5);
return NextResponse.json({
scores,
overall,
whatChanged: (parsed.whatChanged || '').slice(0, 320),
improvements,
nextSteps,
});
} catch (err) {
console.error('[email-analyzer/score] error:', (err as Error).message);
return NextResponse.json({ error: 'Score failed' }, { status: 500 });
}
}