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