← back to Patty
app/api/trending/discover/route.ts
202 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuth } from '@/lib/auth';
import { callGemini } from '@/lib/gemini';
/**
* POST /api/trending/discover
* Scans real Kalshi prediction markets + recent news articles from the DB,
* then uses Gemini AI to derive trending topics with auto-generated tags.
*/
export async function POST(request: NextRequest) {
const user = verifyAuth(request);
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
// 1. Gather REAL data from our sources
const [marketsRes, articlesRes] = await Promise.all([
// Top prediction markets by volume — real Kalshi data
query(`SELECT DISTINCT ON (title) title, category, volume, last_price, yes_bid, no_bid
FROM orbit_markets
WHERE volume > 10000
ORDER BY title, volume DESC
LIMIT 30`),
// Most recent news articles
query(`SELECT title, url, summary, sentiment, tags, published_at
FROM orbit_articles
ORDER BY published_at DESC
LIMIT 40`),
]);
const markets = marketsRes.rows;
const articles = articlesRes.rows;
if (markets.length === 0 && articles.length === 0) {
return NextResponse.json({ error: 'No source data available. Sync markets first.' }, { status: 400 });
}
// 2. Build context from real data
const marketSummary = markets.map(m =>
`- "${m.title}" [${m.category}] volume=${m.volume} last_price=${m.last_price}¢`
).join('\n');
const articleSummary = articles.slice(0, 25).map(a =>
`- "${a.title}" (${a.published_at ? new Date(a.published_at).toLocaleDateString() : 'recent'})`
).join('\n');
// Get the actual categories from our market data
const categorySet = new Set(markets.map(m => m.category).filter(Boolean));
const realCategories = [...categorySet];
const prompt = `You are an AI trend analyst. Analyze the following REAL prediction market data and news articles to identify 8-12 compelling trending topics that would make strong petitions or advocacy campaigns.
## REAL PREDICTION MARKETS (Kalshi):
${marketSummary}
## RECENT NEWS HEADLINES:
${articleSummary}
## INSTRUCTIONS:
1. Derive topics ONLY from the real data above — do NOT invent topics
2. Combine related markets + articles into unified trending themes
3. Calculate engagement_score based on market volume + news coverage (40-95 scale)
4. Generate 3-5 descriptive tags for each topic by analyzing the content
5. Assign sentiment based on whether the topic is opportunity (positive), crisis (negative), debate (mixed), or informational (neutral)
## REAL CATEGORIES to use: ${realCategories.join(', ')}
You may also use: Economy, National Security, Technology, Environment, Healthcare, Social Issues, Foreign Policy
Return a JSON array of objects with EXACTLY these fields:
{
"title": "Short, compelling petition-ready title (max 80 chars)",
"content": "2-3 sentence description connecting market data + news context, explaining why this matters for advocacy",
"source": "Kalshi Markets" or "News" or "Kalshi + News" (based on where the data came from),
"source_url": null,
"engagement_score": 75,
"sentiment": "positive" | "negative" | "neutral" | "mixed",
"category": "one of the real categories listed above",
"tags": ["specific", "relevant", "descriptive", "tags", "from-content"]
}
IMPORTANT: Tags should be specific and derived from the actual content (e.g., "fed-chair-nomination", "greenland-acquisition", "iran-nuclear"), NOT generic (e.g., "politics", "trending").`;
type TrendingTopic = {
title: string;
content: string;
source: string;
source_url?: string;
engagement_score: number;
sentiment: string;
category: string;
tags: string[];
};
// 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
const r = await callGemini<TrendingTopic[]>({ prompt, maxTokens: 4096, temperature: 0.7 });
if (!r.ok) {
console.error('[trending/discover] gemini failed:', r.reason, r.detail);
return NextResponse.json(
{ error: r.reason === 'no_key' ? 'AI service not configured' : 'AI discovery failed' },
{ status: r.status },
);
}
const topics = r.data;
if (!Array.isArray(topics)) {
return NextResponse.json({ error: 'AI returned non-array data' }, { status: 502 });
}
// 3. Clear expired/old trending topics before inserting new ones
await query(`DELETE FROM trending_topics WHERE expires_at < NOW() OR created_at < NOW() - INTERVAL '7 days'`);
// 4. Insert each AI-derived topic
const inserted = [];
for (const topic of topics) {
try {
const result = await query(
`INSERT INTO trending_topics (source, source_url, title, content, engagement_score, sentiment, category, tags, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() + INTERVAL '7 days')
RETURNING *`,
[
topic.source || 'Kalshi + News',
topic.source_url || null,
topic.title,
topic.content || null,
topic.engagement_score || 50,
topic.sentiment || 'neutral',
topic.category || 'Politics',
topic.tags || null,
]
);
inserted.push(result.rows[0]);
} catch (insertErr) {
console.error('[api/trending/discover] Insert error:', (insertErr as Error).message);
}
}
// 5. Also scan articles and tag them using AI (async, fire-and-forget)
tagArticlesInBackground().catch(err => {
console.error('[api/trending/discover] Background tag scan error:', err.message);
});
return NextResponse.json({
rows: inserted,
count: inserted.length,
sources: { markets: markets.length, articles: articles.length },
});
} catch (err) {
console.error('[api/trending/discover] Error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to discover trends' }, { status: 500 });
}
}
/**
* Background task: scan untagged articles and generate AI tags.
* Updates orbit_articles.tags for articles that have no tags.
*/
async function tagArticlesInBackground() {
const untagged = await query(
`SELECT id, title, summary FROM orbit_articles
WHERE tags IS NULL OR array_length(tags, 1) IS NULL
ORDER BY published_at DESC LIMIT 50`
);
if (untagged.rows.length === 0) return;
const articleList = untagged.rows.map(a =>
`ID:${a.id} | "${a.title}"${a.summary ? ' | ' + a.summary.slice(0, 100) : ''}`
).join('\n');
const tagPrompt = `Analyze these news articles and generate 2-4 specific, descriptive tags for each one. Tags should be lowercase, hyphenated, and content-specific (e.g., "fed-chair-nomination", "iran-nuclear-deal", "greenland-sovereignty").
Articles:
${articleList}
Return a JSON object where keys are article IDs and values are arrays of tags:
{"uuid-here": ["tag1", "tag2", "tag3"], ...}`;
// 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
const tagR = await callGemini<Record<string, string[]>>({ prompt: tagPrompt, maxTokens: 4096, temperature: 0.3 });
if (!tagR.ok) {
console.error('[tag-scan] gemini failed:', tagR.reason);
return;
}
const tagMap = tagR.data;
let taggedCount = 0;
for (const [articleId, tags] of Object.entries(tagMap)) {
if (!Array.isArray(tags) || tags.length === 0) continue;
try {
await query(
`UPDATE orbit_articles SET tags = $1 WHERE id = $2`,
[tags, articleId]
);
taggedCount++;
} catch {
// Skip invalid UUIDs or missing articles
}
}
console.log(`[tag-scan] Tagged ${taggedCount}/${untagged.rows.length} articles`);
}