← back to Norma
app/api/statements/generate/route.ts
299 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('[statements/generate] GEMINI_API_KEY env var is required');
}
return `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiApiKey}`;
}
/**
* POST /api/statements/generate
* AI-generate an official statement from a news event for the active org.
*
* Body: { event_title, event_summary?, event_url?, event_type?, urgency?,
* news_item_id?, tone?, category? }
*/
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.event_title || typeof data.event_title !== 'string' || !data.event_title.trim()) {
return NextResponse.json({ error: 'event_title is required' }, { status: 400 });
}
const orgId = getOrgId(request) || (typeof data.org_id === 'string' ? data.org_id : undefined);
// Fetch recent context for grounding
const [newsRes, xRes] = await Promise.all([
query<{ headline: string; outlet: string; url: string; summary: string }>(
orgId
? `SELECT headline, outlet, url, summary FROM news_items WHERE org_id = $1 ORDER BY published_at DESC NULLS LAST LIMIT 8`
: `SELECT headline, outlet, url, summary FROM news_items ORDER BY published_at DESC NULLS LAST LIMIT 8`,
orgId ? [orgId] : [],
),
query<{ content: string; posted_at: string }>(
`SELECT content, posted_at FROM x_posts
ORDER BY posted_at DESC NULLS LAST LIMIT 10`,
),
]);
const brand = await getBrand(orgId);
const prompt = buildStatementPrompt(data, newsRes.rows, xRes.rows, brand);
const geminiResponse = 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',
responseSchema: {
type: 'OBJECT',
properties: {
title: { type: 'STRING', description: 'Statement headline under 120 chars — must be non-empty' },
body_html: { type: 'STRING', description: 'Full statement body in clean HTML — must be non-empty' },
quote: { type: 'STRING', description: 'Pull quote 1-2 sentences' },
quote_attribution: { type: 'STRING', description: 'Name and title of person quoted' },
tone: { type: 'STRING', enum: ['press_release', 'advocacy', 'urgent', 'celebratory'] },
category: { type: 'STRING', enum: ['policy', 'legal', 'community_impact', 'legislative', 'executive'] },
tags: { type: 'ARRAY', items: { type: 'STRING' }, description: '3-5 relevant tags' },
confidence: { type: 'NUMBER', description: 'Score 0.0-1.0' },
},
required: ['title', 'body_html', 'quote', 'quote_attribution', 'tone', 'category', 'tags', 'confidence'],
propertyOrdering: ['title', 'body_html', 'quote', 'quote_attribution', 'tone', 'category', 'tags', 'confidence'],
},
},
}),
});
if (!geminiResponse.ok) {
const errText = await geminiResponse.text();
console.error('[api/statements/generate] Gemini error:', geminiResponse.status, errText);
return NextResponse.json(
{ error: 'AI generation failed', details: `Gemini returned ${geminiResponse.status}` },
{ status: 502 },
);
}
const geminiData = await geminiResponse.json();
// Check for non-STOP finish reasons (MAX_TOKENS, SAFETY, RECITATION, etc.)
const finishReason = geminiData?.candidates?.[0]?.finishReason;
if (finishReason && finishReason !== 'STOP') {
console.error('[api/statements/generate] Gemini non-STOP finish:', finishReason, JSON.stringify(geminiData).slice(0, 500));
return NextResponse.json(
{ error: 'AI generation incomplete', details: `Gemini finishReason: ${finishReason}` },
{ status: 502 },
);
}
const rawText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
if (!rawText) {
console.error('[api/statements/generate] Empty Gemini text. Full response:', JSON.stringify(geminiData).slice(0, 500));
return NextResponse.json({ error: 'AI returned empty response' }, { status: 502 });
}
let generated: GeneratedStatement;
try {
const cleaned = rawText.replace(/^```json?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
generated = JSON.parse(cleaned);
} catch {
console.error('[api/statements/generate] Parse error. Raw text:', rawText.slice(0, 500));
return NextResponse.json({ error: 'AI returned invalid JSON', raw: rawText.slice(0, 1000) }, { status: 502 });
}
// Validate required fields — use trim() to catch empty-string values
// If body_html is truly missing, we cannot recover — return error.
// If only title is missing, generate a fallback from the event_title.
if (!generated.body_html?.trim()) {
console.error('[api/statements/generate] Missing body_html. Generated:', JSON.stringify(generated).slice(0, 500));
return NextResponse.json(
{ error: 'AI response missing body_html', generated },
{ status: 502 },
);
}
if (!generated.title?.trim()) {
const eventTitle = (data.event_title as string).trim();
generated.title = `${brand.name} Responds to ${eventTitle}`.slice(0, 200);
console.warn(`[api/statements/generate] Empty title from AI — using fallback: "${generated.title}"`);
}
// Plain text version
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;
// Save to DB
const result = await query(
`INSERT INTO statements
(event_type, event_title, event_summary, event_url, event_date,
title, body_html, body_text, quote, quote_attribution,
tone, category, urgency, tags, status,
ai_model, ai_confidence, generation_prompt, news_item_id, org_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'draft',$15,$16,$17,$18,$19)
RETURNING *`,
[
data.event_type || 'news',
data.event_title,
data.event_summary || null,
data.event_url || null,
data.event_date || null,
generated.title.slice(0, 200).trim(),
generated.body_html,
bodyText,
generated.quote || null,
generated.quote_attribution || (brand.staff[0] ? `${brand.staff[0].name}, ${brand.staff[0].role}` : 'Executive Director'),
generated.tone || data.tone || 'press_release',
generated.category || data.category || null,
data.urgency || 'standard',
generated.tags || [],
'gemini-2.0-flash',
confidence,
prompt.slice(0, 2000),
data.news_item_id || null,
orgId || null,
],
);
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
await auditLog('statement.generated', 'statement', result.rows[0].id, {
event_title: data.event_title,
ai_confidence: confidence,
}, ip);
return NextResponse.json({ statement: result.rows[0] }, { status: 201 });
} catch (err) {
console.error('[api/statements/generate] error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to generate statement' }, { status: 500 });
}
}
// ─── Types ──────────────────────────────────────────────────
interface GeneratedStatement {
title: string;
body_html: string;
quote?: string;
quote_attribution?: string;
tone?: string;
category?: string;
tags?: string[];
confidence?: number;
}
// ─── Prompt Builder ─────────────────────────────────────────
function buildStatementPrompt(
data: Record<string, unknown>,
news: Array<{ headline: string; outlet: string; url: string; summary: string }>,
xPosts: Array<{ content: string; posted_at: string }>,
brand: { name: string; email: string; phone: string; website: string; social: { x: string }; staff: Array<{ name: string; role: string }>; mission: string; focusAreas: string[] },
): string {
const newsContext = news.length > 0
? `\n\nRECENT NEWS CONTEXT:\n${news.map(n => `- [${n.outlet || 'News'}] ${n.headline}: ${n.summary || ''}`).join('\n')}`
: '';
const xHandle = brand.social.x || '@' + brand.name.replace(/\s+/g, '');
const xContext = xPosts.length > 0
? `\n\nRECENT ${xHandle} POSTS:\n${xPosts.slice(0, 5).map(p => `- ${p.content}`).join('\n')}`
: '';
const leader = brand.staff[0] || { name: 'Executive Director', role: 'Executive Director' };
const focusDesc = brand.focusAreas.length > 0
? brand.focusAreas.join(', ')
: 'advocacy and community impact';
const urgencyGuidance: Record<string, string> = {
breaking: 'This is a BREAKING development. Use urgent, decisive language. Lead with the most impactful element.',
urgent: 'This requires a swift response. Be direct and action-oriented.',
standard: 'This is a standard policy response. Be measured but clear.',
informational: 'This is informational. Be thorough and educational.',
};
const urgency = (data.urgency as string) || 'standard';
return `You are the communications team for ${brand.name}, drafting an official public statement.
ORGANIZATION:
- Name: ${brand.name}
- Leader: ${leader.name} (${leader.role})
${brand.staff.length > 1 ? `- Staff: ${brand.staff.slice(1).map(s => `${s.name} (${s.role})`).join(', ')}` : ''}
- Contact: ${brand.email}${brand.phone ? ` | ${brand.phone}` : ''}
- Website: ${brand.website}
- Social: ${xHandle}
- Mission: ${brand.mission}
- Focus: ${focusDesc}
VOICE GUIDELINES:
- Speak on behalf of the communities ${brand.name} serves
- Use "we" framing — "we stand with...", "we call on..."
- Be authoritative but accessible — not academic, not preachy
- Include specific numbers and facts when available
- Always ground claims in the source material provided
URGENCY: ${urgencyGuidance[urgency] || urgencyGuidance.standard}
THE EVENT TO RESPOND TO:
${data.topic ? `Topic/Subject: ${data.topic}` : ''}
Title: ${data.event_title}
${data.event_summary ? `Summary: ${data.event_summary}` : ''}
${data.event_url ? `Source URL: ${data.event_url}` : ''}
${data.event_type ? `Type: ${data.event_type}` : ''}
${newsContext}${xContext}
GENERATE an official statement for ${brand.name}. You MUST return a JSON object with exactly these keys.
IMPORTANT: "title" and "body_html" are REQUIRED — never return empty strings for these fields.
{
"title": "REQUIRED: Statement headline under 120 chars — specific, non-empty",
"body_html": "REQUIRED: <p>Full statement in clean HTML...</p> — must be non-empty",
"quote": "A powerful 1-2 sentence pull quote",
"quote_attribution": "${leader.name}, ${leader.role}",
"tone": "press_release",
"category": "policy",
"tags": ["tag1", "tag2", "tag3"],
"confidence": 0.85
}
Field details:
- "title": Clear, specific headline under 120 characters.
- "body_html": Full statement body (400-700 words) in clean HTML using <p>, <strong>, <em>, <ul>/<li>, <blockquote> tags. Structure it as:
* FOR IMMEDIATE RELEASE header (bold, red)
* Date line
* Opening paragraph: What happened and the organization's position
* 2-3 body paragraphs: Context, impact, what it means
* Pull quote from ${leader.name}, ${leader.role} (in <blockquote>)
* Closing: Call to action for supporters
* "###" press release ending
* About section: Brief ${brand.name} description
- "quote": A powerful 1-2 sentence pull quote attributed to ${leader.name}.
- "quote_attribution": "${leader.name}, ${leader.role}"
- "tone": One of "press_release", "advocacy", "urgent", "celebratory"
- "category": One of "policy", "legal", "community_impact", "legislative", "executive"
- "tags": Array of 3-5 relevant string tags
- "confidence": Number between 0.0 and 1.0 for how well-suited this event is for a public statement`;
}