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