← back to Norma

app/api/webhooks/pulse-topic/route.ts

314 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { auditLog } from '@/lib/audit';
import { getBrand } from '@/lib/brand';

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

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

/**
 * POST /api/webhooks/pulse-topic
 *
 * Receives a topic from the Pulse agent when it generates high-urgency topics.
 * Auto-generates a petition draft via Gemini AI and saves to pipeline.
 * For critical topics, also auto-generates an official Statement draft.
 *
 * Auth: X-Pulse-Webhook-Secret header
 * Body: { topic: { title, description, urgency, petition_suggestion, petition_target,
 *                   petition_tone, category, geo_states, article_count, avg_sentiment },
 *         topic_id?: string }
 */
export async function POST(request: NextRequest) {
  if (!WEBHOOK_SECRET) {
    return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 });
  }

  const secret = request.headers.get('x-pulse-webhook-secret');
  if (secret !== WEBHOOK_SECRET) {
    return NextResponse.json({ error: 'Invalid webhook secret' }, { status: 401 });
  }

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

    if (!topic || !topic.title) {
      return NextResponse.json({ error: 'topic.title is required' }, { status: 400 });
    }

    // Only auto-process critical or high urgency
    if (topic.urgency !== 'critical' && topic.urgency !== 'high') {
      return NextResponse.json({
        skipped: true,
        reason: `Urgency "${topic.urgency}" below auto-generation threshold`,
      });
    }

    // Check for duplicate — don't regenerate if we already have a draft from this topic
    const existing = await query(
      `SELECT id FROM petition_pipeline
       WHERE source_type = 'pulse_webhook'
         AND source_data->>'topic_title' = $1
         AND created_at > NOW() - INTERVAL '24 hours'
       LIMIT 1`,
      [topic.title],
    );

    if (existing.rows.length > 0) {
      return NextResponse.json({
        skipped: true,
        reason: 'Draft already exists for this topic within 24h',
        existing_id: existing.rows[0].id,
      });
    }

    // Load org brand for dynamic prompt context
    const brand = await getBrand();

    // Generate petition draft via Gemini
    const prompt = buildPetitionPrompt(topic, brand.name, brand.focusAreas);
    const geminiRes = 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 (!geminiRes.ok) {
      const errText = await geminiRes.text();
      console.error('[webhook/pulse-topic] Gemini error:', geminiRes.status, errText);
      return NextResponse.json(
        { error: 'AI generation failed', details: `Gemini ${geminiRes.status}` },
        { status: 502 },
      );
    }

    const geminiData = await geminiRes.json();
    const rawText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';

    if (!rawText) {
      return NextResponse.json({ error: 'AI returned empty response' }, { status: 502 });
    }

    let generated: { title: string; body: string; target?: string; talking_points?: string[]; tone?: string; confidence?: number };
    try {
      const cleaned = rawText.replace(/^```json?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
      generated = JSON.parse(cleaned);
    } catch {
      console.error('[webhook/pulse-topic] Parse error:', rawText.slice(0, 500));
      return NextResponse.json({ error: 'AI returned invalid JSON' }, { status: 502 });
    }

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

    // Normalize fields
    const geoStates = Array.isArray(topic.geo_states)
      ? topic.geo_states.map((s: string) => String(s).trim().toUpperCase()).filter(Boolean)
      : [];
    const talkingPoints = Array.isArray(generated.talking_points)
      ? 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 auto-generated 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, auto_generated)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'draft', TRUE)
       RETURNING *`,
      [
        generated.title.slice(0, 120).trim(),
        generated.body.trim(),
        generated.target || topic.petition_target || null,
        topic.category || null,
        'moveon',
        generated.tone || topic.petition_tone || null,
        talkingPoints,
        geoStates,
        'pulse_webhook',
        JSON.stringify({
          topic_title: topic.title,
          topic_description: topic.description || null,
          petition_suggestion: topic.petition_suggestion || null,
          urgency: topic.urgency,
          article_count: topic.article_count || null,
          avg_sentiment: topic.avg_sentiment || null,
          model: 'gemini-2.0-flash',
          generated_at: new Date().toISOString(),
          webhook_topic_id: data.topic_id || null,
        }),
        data.topic_id || null,
        confidence,
      ],
    );

    const pipelineItem = result.rows[0];

    await auditLog('pipeline.auto_generated', 'petition_pipeline', pipelineItem.id, {
      topic_title: topic.title,
      urgency: topic.urgency,
      ai_confidence: confidence,
      source: 'pulse_webhook',
    });

    // For critical urgency: also auto-generate a Statement draft
    let statementItem = null;
    if (topic.urgency === 'critical') {
      try {
        statementItem = await generateStatementDraft(topic);
      } catch (e) {
        console.error('[webhook/pulse-topic] Statement generation failed (non-blocking):', (e as Error).message);
      }
    }

    console.log(`[webhook/pulse-topic] Auto-generated draft "${generated.title.slice(0, 50)}" from Pulse topic (urgency: ${topic.urgency})`);

    return NextResponse.json({
      pipeline_item: pipelineItem,
      statement_item: statementItem,
      auto_generated: true,
    }, { status: 201 });
  } catch (err) {
    console.error('[webhook/pulse-topic] error:', (err as Error).message);
    return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
  }
}

// ─── Petition Prompt ──────────────────────────────────────────

function buildPetitionPrompt(topic: Record<string, unknown>, brandName?: string, focusAreas?: string[]): string {
  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: ${topic.title}
${topic.description ? `TOPIC DESCRIPTION: ${topic.description}` : ''}
${topic.petition_suggestion ? `PETITION SUGGESTION: ${topic.petition_suggestion}` : ''}
${topic.petition_target ? `The petition should be directed at: ${topic.petition_target}.` : 'Determine the most appropriate target.'}
${topic.urgency ? `The urgency level is: ${topic.urgency}. Adjust the tone accordingly.` : ''}
${Array.isArray(topic.geo_states) && (topic.geo_states as string[]).length > 0 ? `Geographically relevant to: ${(topic.geo_states as string[]).join(', ')}.` : ''}

Requirements:
- title: Compelling petition title, max 120 characters, action-oriented
- body: Full petition body, 300-500 words, petition format with clear problem statement, evidence, specific demands, strong CTA
- target: Who the petition is addressed to
- talking_points: 3-5 concise bullet points
- tone: One of "urgent", "measured", "empathetic", "assertive"
- confidence: Score 0.0-1.0 for petition suitability

Respond with valid JSON only: { title, body, target, talking_points, tone, confidence }`;
}

// ─── Statement Auto-Generation (critical topics only) ────────

async function generateStatementDraft(topic: Record<string, unknown>) {
  const brand = await getBrand();
  const staffName = brand.staff?.[0]?.name || 'the Executive Director';
  const staffRole = brand.staff?.[0]?.role || 'Executive Director';
  const staffAttribution = brand.staff?.[0] ? `${staffName}, ${staffRole}` : brand.name;

  const prompt = `You are the communications team for ${brand.name}, drafting an official public statement.

ORGANIZATION:
- Name: ${brand.name}
- ${staffRole}: ${staffName}
- Contact: ${brand.email || 'N/A'}

VOICE: Authoritative, accessible, people-first. Use "we" framing.

THE EVENT: ${topic.title}
${topic.description ? `Details: ${topic.description}` : ''}
Urgency: CRITICAL — Use urgent, decisive language.

GENERATE an official statement. Return valid JSON:
- "title": Statement headline (under 120 chars)
- "body_html": Full statement in clean HTML (<p>, <strong>, <em>, <blockquote>). Include FOR IMMEDIATE RELEASE header, date line, 3-4 paragraphs, pull quote from ${staffName}, closing CTA, ### ending, about section.
- "quote": 1-2 sentence pull quote from ${staffRole}
- "quote_attribution": "${staffAttribution}"
- "tone": One of "press_release", "advocacy", "urgent", "celebratory"
- "category": One of "policy", "legal", "community_impact", "legislative", "executive"
- "tags": Array of 3-5 tags
- "confidence": Score 0.0-1.0`;

  const geminiRes = await fetch(getGeminiUrl(), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      contents: [{ parts: [{ text: prompt }] }],
      generationConfig: { temperature: 0.6, maxOutputTokens: 3072, responseMimeType: 'application/json' },
    }),
  });

  if (!geminiRes.ok) return null;

  const geminiData = await geminiRes.json();
  const rawText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
  if (!rawText) return null;

  const cleaned = rawText.replace(/^```json?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
  const generated = JSON.parse(cleaned);
  if (!generated.title || !generated.body_html) return null;

  const bodyText = generated.body_html
    .replace(/<br\s*\/?>/gi, '\n')
    .replace(/<\/p>/gi, '\n\n')
    .replace(/<li>/gi, '- ')
    .replace(/<\/li>/gi, '\n')
    .replace(/<[^>]+>/g, '')
    .replace(/\n{3,}/g, '\n\n')
    .trim();

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

  const result = await query(
    `INSERT INTO statements
       (event_type, event_title, title, body_html, body_text, quote, quote_attribution,
        tone, category, urgency, tags, status, ai_model, ai_confidence)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'draft',$12,$13)
     RETURNING *`,
    [
      'news',
      String(topic.title),
      generated.title.slice(0, 200).trim(),
      generated.body_html,
      bodyText,
      generated.quote || null,
      generated.quote_attribution || staffAttribution,
      generated.tone || 'urgent',
      generated.category || null,
      'breaking',
      generated.tags || [],
      'gemini-2.0-flash',
      confidence,
    ],
  );

  await auditLog('statement.auto_generated', 'statement', result.rows[0].id, {
    topic_title: topic.title,
    source: 'pulse_webhook',
  });

  return result.rows[0];
}