← 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 });
  }
}