← 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.`;
}