← back to Patty

app/api/orbit/generate/route.ts

237 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuth } from '@/lib/auth';
import { auditLog } from '@/lib/audit';
import { callGemini } from '@/lib/gemini';

/**
 * POST /api/orbit/generate
 * AI-generate a petition from a Kalshi prediction market OR a Pulse topic title.
 * Accepts either { market_ticker } or { topic_title } in the body.
 */
export async function POST(request: NextRequest) {
  const user = verifyAuth(request);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  try {
    const body = await request.json();
    const { market_ticker, topic_title, tone, category } = body;

    if (!market_ticker && !topic_title) {
      return NextResponse.json({ error: 'market_ticker or topic_title is required' }, { status: 400 });
    }

    const toneDesc = tone || 'urgent';
    let prompt: string;
    let market: any = null;
    let aiSource: string;
    let categoryDesc: string;
    let aiTopicData: Record<string, unknown>;

    if (market_ticker) {
      // ── Market-based generation (original path) ──
      const marketRes = await query(`SELECT * FROM orbit_markets WHERE ticker = $1`, [market_ticker]);
      if (marketRes.rows.length === 0) {
        return NextResponse.json({ error: 'Market not found. Sync markets first via GET /api/orbit' }, { status: 404 });
      }
      market = marketRes.rows[0];
      categoryDesc = category || market.category || 'policy';
      aiSource = 'kalshi';
      aiTopicData = {
        market_ticker: market.ticker,
        market_title: market.title,
        market_category: market.category,
        yes_bid: market.yes_bid,
        last_price: market.last_price,
        volume: market.volume,
        generated_at: new Date().toISOString(),
      };

      prompt = `You are a professional petition writer for a civic engagement platform. Your task is to create a compelling petition inspired by a Kalshi prediction market.

PREDICTION MARKET DATA:
- Ticker: ${market.ticker}
- Title: ${market.title}
- Subtitle: ${market.subtitle || 'N/A'}
- Category: ${market.category || 'General'}
- Current YES price: ${market.yes_bid || 'N/A'}c (bid) / ${market.yes_ask || 'N/A'}c (ask)
- Current NO price: ${market.no_bid || 'N/A'}c (bid) / ${market.no_ask || 'N/A'}c (ask)
- Last trade price: ${market.last_price || 'N/A'}c
- Volume: ${market.volume || 'N/A'} contracts
- Open Interest: ${market.open_interest || 'N/A'}

The prediction market prices represent the market's assessment of the probability of this event occurring (price in cents = probability percentage).

PETITION REQUIREMENTS:
- Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented' : toneDesc === 'formal' ? 'professional, data-driven' : toneDesc === 'passionate' ? 'emotional, empathetic' : 'practical, solution-focused'})
- Category: ${categoryDesc}
- The petition should advocate for a specific policy action or demand related to the market's subject
- Use the market probability to frame urgency (e.g., if market says 80% chance of something bad, that urgency should be reflected)
- Do NOT mention Kalshi, prediction markets, or betting in the petition text itself
- Make it feel like a real grassroots petition

Return a JSON object with EXACTLY these fields:
{
  "title": "Compelling petition title (max 100 chars)",
  "summary": "2-3 sentence hook that motivates signatures (max 300 chars)",
  "body_html": "<p>Full petition body in HTML. 4-6 paragraphs. Include specific demands, context, urgency, and a call to action. Use <strong>, <em>, <ul>, <li> tags.</p>",
  "body_text": "Plain text version of the body",
  "suggested_target": "The specific person, office, or body to address this petition to",
  "suggested_tags": ["array", "of", "3-5", "relevant", "tags"],
  "suggested_goal": 1000,
  "influence_analysis": "2-3 sentences explaining how this petition, if successful, could influence the outcome that the prediction market is tracking. Be specific about the causal chain."
}`;
    } else {
      // ── Topic-based generation (from Pulse of America) ──
      categoryDesc = category || 'policy';
      aiSource = 'pulse';
      aiTopicData = {
        topic_title: topic_title,
        source: 'pulse-agent',
        generated_at: new Date().toISOString(),
      };

      prompt = `You are a professional petition writer for a civic engagement platform. Your task is to create a compelling petition based on a trending news topic.

TRENDING TOPIC:
"${topic_title}"

This topic was identified by our Pulse of America news intelligence system as a high-interest issue currently in the public discourse.

PETITION REQUIREMENTS:
- Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented' : toneDesc === 'formal' ? 'professional, data-driven' : toneDesc === 'passionate' ? 'emotional, empathetic' : 'practical, solution-focused'})
- Category: ${categoryDesc}
- The petition should advocate for a specific policy action or demand related to this topic
- Frame urgency based on the current news cycle — this is trending NOW
- Make it feel like a real grassroots petition that a concerned citizen would write
- Include specific, actionable demands (not vague calls for "awareness")

Return a JSON object with EXACTLY these fields:
{
  "title": "Compelling petition title (max 100 chars)",
  "summary": "2-3 sentence hook that motivates signatures (max 300 chars)",
  "body_html": "<p>Full petition body in HTML. 4-6 paragraphs. Include specific demands, context, urgency, and a call to action. Use <strong>, <em>, <ul>, <li> tags.</p>",
  "body_text": "Plain text version of the body",
  "suggested_target": "The specific person, office, or body to address this petition to",
  "suggested_tags": ["array", "of", "3-5", "relevant", "tags"],
  "suggested_goal": 5000,
  "influence_analysis": "2-3 sentences explaining the potential impact of this petition on the issue. Be specific about what policy change or public outcome it could drive."
}`;
    }

    // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
    const r = await callGemini<Record<string, unknown>>({ prompt, maxTokens: 4096, temperature: 0.8, timeoutMs: 30_000 });
    if (!r.ok) {
      console.error('[orbit/generate] gemini failed:', r.reason, r.detail);
      return NextResponse.json(
        { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI generation failed' },
        { status: r.status },
      );
    }
    const parsed = r.data as Record<string, unknown>;

    // Create the petition in the petitions table
    const fallbackTitle = market ? market.title : topic_title;
    const title = String(parsed.title || `Petition: ${fallbackTitle}`);
    let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');

    // Check for duplicate slug
    const existingSlug = await query(`SELECT id FROM petitions WHERE slug = $1`, [slug]);
    if (existingSlug.rows.length > 0) {
      slug = `${slug}-${Date.now().toString(36)}`;
    }

    // Get user_id
    const userResult = await query(`SELECT id FROM users LIMIT 1`);
    const userId = userResult.rows[0]?.id;
    if (!userId) {
      return NextResponse.json({ error: 'No user found in system' }, { status: 500 });
    }

    const petitionResult = await query(
      `INSERT INTO petitions (user_id, title, slug, summary, body_html, body_text, target, category, tags,
        signature_goal, ai_source, ai_topic_data, status)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
       RETURNING *`,
      [
        userId,
        title,
        slug,
        parsed.summary || null,
        parsed.body_html || '<p>Generated petition content</p>',
        parsed.body_text || null,
        parsed.suggested_target || null,
        categoryDesc,
        // Normalize tags: ensure clean string array for TEXT[] column
        Array.isArray(parsed.suggested_tags)
          ? parsed.suggested_tags.map((t: string) => String(t).replace(/^["'{]+|["'}]+$/g, '').trim()).filter(Boolean)
          : typeof parsed.suggested_tags === 'string'
            ? parsed.suggested_tags.replace(/^\[|\]$/g, '').split(',').map((t: string) => t.replace(/^["'{]+|["'}]+$/g, '').trim()).filter(Boolean)
            : null,
        parsed.suggested_goal || 5000,
        aiSource,
        JSON.stringify(aiTopicData),
        'draft',
      ],
    );
    const petition = petitionResult.rows[0];

    // Create orbit_link if we have a market
    let link = null;
    if (market) {
      const linkResult = await query(
        `INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
         VALUES ($1, $2, $3, $4, $5)
         ON CONFLICT (market_id, petition_id) DO UPDATE SET
           ai_reasoning = EXCLUDED.ai_reasoning,
           strength = EXCLUDED.strength
         RETURNING *`,
        [
          market.id,
          petition.id,
          'influence',
          0.8,
          parsed.influence_analysis || 'AI-generated petition from prediction market data.',
        ],
      );
      link = linkResult.rows[0];

      // Update market petition_relevance and suggested_petition
      await query(
        `UPDATE orbit_markets SET
          petition_relevance = GREATEST(petition_relevance, 75),
          suggested_petition = $2,
          suggested_target = $3
         WHERE id = $1`,
        [market.id, title, parsed.suggested_target || null],
      );
    }

    await auditLog('orbit.petition_generated', 'petition', petition.id, {
      source: aiSource,
      market_ticker: market?.ticker || null,
      topic_title: topic_title || null,
      tone: toneDesc,
      category: categoryDesc,
    });

    return NextResponse.json({
      petition,
      link,
      influence_analysis: parsed.influence_analysis || '',
      source: aiSource,
      ...(market ? {
        market: {
          ticker: market.ticker,
          title: market.title,
          category: market.category,
          last_price: market.last_price,
        },
      } : {}),
    });
  } catch (err) {
    console.error('[api/orbit/generate] Error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to generate petition from market' }, { status: 500 });
  }
}