← back to Norma

app/api/pipeline/generate/route.ts

267 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
import { getBrand } from '@/lib/brand';
import { getOrgId } from '@/lib/orgId';

function getGeminiUrl(): string {
  const geminiApiKey = process.env.GEMINI_API_KEY;
  if (!geminiApiKey) {
    throw new Error('[pipeline/generate] GEMINI_API_KEY env var is required');
  }
  return `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiApiKey}`;
}

/**
 * POST /api/pipeline/generate
 * AI-generate a petition draft from a topic/suggestion.
 * Body: { topic_title, topic_description?, petition_suggestion?, petition_target?,
 *         urgency?, geo_states?, category?, pulse_topic_id? }
 * Returns the saved draft pipeline item.
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const data = await request.json();

    if (!data.topic_title || typeof data.topic_title !== 'string' || data.topic_title.trim() === '') {
      return NextResponse.json({ error: 'topic_title is required' }, { status: 400 });
    }

    // Get org brand for dynamic prompt
    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
    const brand = await getBrand(orgId);

    // Build the generation prompt
    const prompt = buildPrompt(data, brand.name, brand.focusAreas);

    // Call Gemini 2.0 Flash
    const geminiResponse = await fetch(getGeminiUrl(), {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [
          {
            parts: [{ text: prompt }],
          },
        ],
        generationConfig: {
          temperature: 0.7,
          maxOutputTokens: 2048,
          responseMimeType: 'application/json',
        },
      }),
    });

    if (!geminiResponse.ok) {
      const errText = await geminiResponse.text();
      console.error('[api/pipeline/generate] Gemini API error:', geminiResponse.status, errText);
      return NextResponse.json(
        { error: 'AI generation failed', details: `Gemini returned ${geminiResponse.status}` },
        { status: 502 }
      );
    }

    const geminiData = await geminiResponse.json();

    // Extract the text from Gemini response
    const rawText =
      geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';

    if (!rawText) {
      console.error('[api/pipeline/generate] Empty Gemini response:', JSON.stringify(geminiData));
      return NextResponse.json({ error: 'AI returned empty response' }, { status: 502 });
    }

    // Parse the JSON response from Gemini
    let generated: GeneratedPetition;
    try {
      // Strip markdown code fences if present
      const cleaned = rawText.replace(/^```json?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
      generated = JSON.parse(cleaned);
    } catch (parseErr) {
      console.error('[api/pipeline/generate] Failed to parse Gemini JSON:', rawText.slice(0, 500));
      return NextResponse.json(
        { error: 'AI returned invalid JSON', raw: rawText.slice(0, 1000) },
        { status: 502 }
      );
    }

    // Validate required fields from AI
    if (!generated.title || !generated.body) {
      return NextResponse.json(
        { error: 'AI response missing title or body', generated },
        { status: 502 }
      );
    }

    // Normalize geo_states
    let geoStates: string[] = [];
    if (Array.isArray(data.geo_states)) {
      geoStates = data.geo_states.map((s: string) => String(s).trim().toUpperCase()).filter(Boolean);
    }

    // Normalize talking_points from AI
    let talkingPoints: string[] = [];
    if (Array.isArray(generated.talking_points)) {
      talkingPoints = generated.talking_points.map((t: string) => String(t).trim()).filter(Boolean);
    }

    const confidence = typeof generated.confidence === 'number'
      ? Math.min(1, Math.max(0, generated.confidence))
      : null;

    // Save to petition_pipeline as draft
    const result = await query(
      `INSERT INTO petition_pipeline
         (title, body, target, category, platform, tone, talking_points, geo_states,
          source_type, source_data, pulse_topic_id, ai_confidence, status)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'draft')
       RETURNING *`,
      [
        generated.title.slice(0, 120).trim(),
        generated.body.trim(),
        generated.target || data.petition_target || null,
        data.category || null,
        'moveon',
        generated.tone || null,
        talkingPoints,
        geoStates,
        'ai',
        JSON.stringify({
          topic_title: data.topic_title,
          topic_description: data.topic_description || null,
          petition_suggestion: data.petition_suggestion || null,
          urgency: data.urgency || null,
          model: 'gemini-2.0-flash',
          generated_at: new Date().toISOString(),
          generation_params: {
            paragraphs: data.paragraphs || 4,
            aggressiveness: data.aggressiveness || 5,
            complexity: data.complexity || 5,
          },
          viability_score: typeof generated.viability_score === 'number' ? generated.viability_score : null,
          viability_reasons: Array.isArray(generated.viability_reasons) ? generated.viability_reasons : [],
        }),
        data.pulse_topic_id || null,
        confidence,
      ]
    );

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const dbItem = result.rows[0] as Record<string, any>;
    const item = {
      ...dbItem,
      viability_score: typeof generated.viability_score === 'number' ? generated.viability_score : null,
      viability_reasons: Array.isArray(generated.viability_reasons) ? generated.viability_reasons : [],
    };

    const ip =
      request.headers.get('x-forwarded-for') ||
      request.headers.get('x-real-ip') ||
      undefined;
    await auditLog(
      'pipeline.generated',
      'petition_pipeline',
      dbItem.id,
      {
        topic_title: data.topic_title,
        ai_confidence: confidence,
        model: 'gemini-2.0-flash',
      },
      ip
    );

    return NextResponse.json({ item }, { status: 201 });
  } catch (err) {
    console.error('[api/pipeline/generate] error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to generate petition' }, { status: 500 });
  }
}

// -------------------------------------------------------------------
// Types and helpers
// -------------------------------------------------------------------

interface GeneratedPetition {
  title: string;
  body: string;
  target?: string;
  talking_points?: string[];
  tone?: string;
  confidence?: number;
  viability_score?: number;
  viability_reasons?: string[];
}

function buildPrompt(data: Record<string, unknown>, brandName?: string, focusAreas?: string[]): string {
  const urgencyNote = data.urgency
    ? `The urgency level is: ${data.urgency}. Adjust the tone accordingly.`
    : '';

  const geoNote =
    Array.isArray(data.geo_states) && data.geo_states.length > 0
      ? `This petition is geographically relevant to: ${(data.geo_states as string[]).join(', ')}.`
      : '';

  const targetNote = data.petition_target
    ? `The petition should be directed at: ${data.petition_target}.`
    : 'Determine the most appropriate target (e.g., a government agency, elected official, or institution).';

  // Writing style parameters
  const paragraphs = typeof data.paragraphs === 'number' ? Math.min(10, Math.max(2, data.paragraphs as number)) : 4;
  const aggressiveness = typeof data.aggressiveness === 'number' ? Math.min(10, Math.max(1, data.aggressiveness as number)) : 5;
  const complexity = typeof data.complexity === 'number' ? Math.min(10, Math.max(1, data.complexity as number)) : 5;

  const aggressivenessLabel =
    aggressiveness <= 2 ? 'very passive and gentle' :
    aggressiveness <= 4 ? 'measured and diplomatic' :
    aggressiveness <= 6 ? 'moderately assertive' :
    aggressiveness <= 8 ? 'forceful and demanding' :
    'very aggressive and confrontational';

  const complexityLabel =
    complexity <= 2 ? 'very simple, 6th-grade reading level, short sentences' :
    complexity <= 4 ? 'plain language, accessible to general public' :
    complexity <= 6 ? 'moderate complexity, some policy terminology' :
    complexity <= 8 ? 'sophisticated vocabulary, detailed policy language' :
    'verbose, academic, extensive legal and policy terminology';

  const focus = focusAreas && focusAreas.length > 0 ? focusAreas.join(', ') : 'civic advocacy and public policy';
  return `You are an expert petition writer for ${brandName || 'nonprofit'} advocacy, focused on ${focus}.

Generate a compelling petition based on this topic:

TOPIC TITLE: ${data.topic_title}
${data.topic_description ? `TOPIC DESCRIPTION: ${data.topic_description}` : ''}
${data.petition_suggestion ? `PETITION SUGGESTION: ${data.petition_suggestion}` : ''}
${targetNote}
${urgencyNote}
${geoNote}

WRITING STYLE INSTRUCTIONS:
- Write the body in exactly ${paragraphs} paragraphs.
- Aggressiveness level: ${aggressiveness}/10 — the tone should be ${aggressivenessLabel}.
- Language complexity: ${complexity}/10 — use ${complexityLabel}.

Requirements:
- title: A compelling petition title, maximum 120 characters. Action-oriented and specific.
- body: The full petition body in exactly ${paragraphs} paragraphs. Written in petition format with:
  * A clear opening that states the problem
  * Evidence and context for why this matters
  * Specific asks/demands
  * A strong closing call to action
  * Written in first-person plural ("We, the undersigned...")
- target: Who the petition is addressed to (person, agency, or institution)
- talking_points: 3-5 concise bullet points summarizing key arguments
- tone: One of "urgent", "measured", "empathetic", "assertive"
- confidence: A score from 0.0 to 1.0 indicating how well-suited this topic is for a petition
- viability_score: A score from 0 to 100 predicting how likely this petition is to gain traction and succeed. Consider factors like: public interest, media coverage potential, specificity of the ask, political feasibility, target accessibility, emotional resonance.
- viability_reasons: An array of 3-5 strings explaining WHY this petition has the viability_score you gave it. Each reason should start with a + (positive factor) or - (negative factor). Example: ["+High public awareness of the issue", "+Specific and actionable demand", "-Target official has limited authority over this issue"]

Respond with valid JSON only, using exactly these field names: title, body, target, talking_points, tone, confidence, viability_score, viability_reasons.`;
}