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