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