mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
289 lines
11 KiB
JavaScript
289 lines
11 KiB
JavaScript
/**
|
|
* OpenRouter API Summarization Endpoint with Redis Caching
|
|
* Fallback when Groq is rate-limited
|
|
* Uses OpenRouter auto-routed free model
|
|
* Free tier: 50 requests/day (20/min)
|
|
* Server-side Redis cache for cross-user deduplication
|
|
*/
|
|
|
|
import { getCachedJson, setCachedJson, hashString } from './_upstash-cache.js';
|
|
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
|
|
|
export const config = {
|
|
runtime: 'edge',
|
|
};
|
|
|
|
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
const MODEL = 'openrouter/free';
|
|
const CACHE_TTL_SECONDS = 86400; // 24 hours
|
|
|
|
const CACHE_VERSION = 'v3';
|
|
|
|
function getCacheKey(headlines, mode, geoContext = '', variant = 'full', lang = 'en') {
|
|
const sorted = headlines.slice(0, 8).sort().join('|');
|
|
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
|
|
const hash = hashString(`${mode}:${sorted}`);
|
|
const targetLangHash = (mode === 'translate' && variant) ? `:${variant}` : '';
|
|
const langHash = (mode !== 'translate' && lang && lang !== 'en') ? `:${lang}` : '';
|
|
return `summary:${CACHE_VERSION}:${hash}${targetLangHash}${langHash}${geoHash}`;
|
|
}
|
|
|
|
// Deduplicate similar headlines (same story from different sources)
|
|
function deduplicateHeadlines(headlines) {
|
|
const seen = new Set();
|
|
const unique = [];
|
|
|
|
for (const headline of headlines) {
|
|
// Normalize: lowercase, remove punctuation, collapse whitespace
|
|
const normalized = headline.toLowerCase()
|
|
.replace(/[^\w\s]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
// Extract key words (4+ chars) for similarity check
|
|
const words = new Set(normalized.split(' ').filter(w => w.length >= 4));
|
|
|
|
// Check if this headline is too similar to any we've seen
|
|
let isDuplicate = false;
|
|
for (const seenWords of seen) {
|
|
const intersection = [...words].filter(w => seenWords.has(w));
|
|
const similarity = intersection.length / Math.min(words.size, seenWords.size);
|
|
if (similarity > 0.6) {
|
|
isDuplicate = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!isDuplicate) {
|
|
seen.add(words);
|
|
unique.push(headline);
|
|
}
|
|
}
|
|
|
|
return unique;
|
|
}
|
|
|
|
export default async function handler(request) {
|
|
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
|
|
|
|
if (request.method === 'OPTIONS') {
|
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
}
|
|
|
|
if (request.method !== 'POST') {
|
|
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
|
status: 405,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (isDisallowedOrigin(request)) {
|
|
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ summary: null, fallback: true, skipped: true, reason: 'OPENROUTER_API_KEY not configured' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
|
|
if (contentLength > 51200) {
|
|
return new Response(JSON.stringify({ error: 'Payload too large' }), {
|
|
status: 413,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const { headlines, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = await request.json();
|
|
|
|
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
|
|
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Check cache first (shared with Groq endpoint)
|
|
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
|
|
const cached = await getCachedJson(cacheKey);
|
|
if (cached && typeof cached === 'object' && cached.summary) {
|
|
console.log('[OpenRouter] Cache hit:', cacheKey);
|
|
return new Response(JSON.stringify({
|
|
summary: cached.summary,
|
|
model: cached.model || MODEL,
|
|
provider: 'cache',
|
|
cached: true,
|
|
}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Deduplicate similar headlines (same story from different sources)
|
|
const uniqueHeadlines = deduplicateHeadlines(headlines.slice(0, 8));
|
|
const headlineText = uniqueHeadlines.map((h, i) => `${i + 1}. ${h}`).join('\n');
|
|
|
|
let systemPrompt, userPrompt;
|
|
|
|
// Include intelligence synthesis context in prompt if available
|
|
const intelSection = geoContext ? `\n\n${geoContext}` : '';
|
|
|
|
// Current date context for LLM (models may have outdated knowledge)
|
|
const isTechVariant = variant === 'tech';
|
|
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
|
|
|
|
// Language instruction
|
|
const langInstruction = lang && lang !== 'en' ? `\nIMPORTANT: Output the summary in ${lang.toUpperCase()} language.` : '';
|
|
|
|
if (mode === 'brief') {
|
|
if (isTechVariant) {
|
|
// Tech variant: focus on startups, AI, funding, product launches
|
|
systemPrompt = `${dateContext}
|
|
|
|
Summarize the key tech/startup development in 2-3 sentences.
|
|
Rules:
|
|
- Focus ONLY on technology, startups, AI, funding, product launches, or developer news
|
|
- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation
|
|
- Lead with the company/product/technology name
|
|
- Start directly: "OpenAI announced...", "A new $50M Series B...", "GitHub released..."
|
|
- No bullet points, no meta-commentary${langInstruction}`;
|
|
} else {
|
|
// Full variant: geopolitical focus
|
|
systemPrompt = `${dateContext}
|
|
|
|
Summarize the key development in 2-3 sentences.
|
|
Rules:
|
|
- Lead with WHAT happened and WHERE - be specific
|
|
- NEVER start with "Breaking news", "Good evening", "Tonight", or TV-style openings
|
|
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
|
|
- CRITICAL FOCAL POINTS are the main actors - mention them by name
|
|
- If focal points show news + signals convergence, that's the lead
|
|
- No bullet points, no meta-commentary${langInstruction}`;
|
|
}
|
|
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
|
|
} else if (mode === 'analysis') {
|
|
if (isTechVariant) {
|
|
systemPrompt = `${dateContext}
|
|
|
|
Analyze the tech/startup trend in 2-3 sentences.
|
|
Rules:
|
|
- Focus ONLY on technology implications: funding trends, AI developments, market shifts, product strategy
|
|
- IGNORE political implications, trade wars, government unless directly about tech policy
|
|
- Lead with the insight for tech industry
|
|
- Connect to startup ecosystem, VC trends, or technical implications`;
|
|
} else {
|
|
systemPrompt = `${dateContext}
|
|
|
|
Provide analysis in 2-3 sentences. Be direct and specific.
|
|
Rules:
|
|
- Lead with the insight - what's significant and why
|
|
- NEVER start with "Breaking news", "Tonight", "The key/dominant narrative is"
|
|
- Start with substance: "Iran faces...", "The escalation in...", "Multiple signals suggest..."
|
|
- CRITICAL FOCAL POINTS are your main actors - explain WHY they matter
|
|
- If focal points show news-signal correlation, flag as escalation
|
|
- Connect dots, be specific about implications`;
|
|
}
|
|
userPrompt = isTechVariant
|
|
? `What's the key tech trend or development?\n${headlineText}${intelSection}`
|
|
: `What's the key pattern or risk?\n${headlineText}${intelSection}`;
|
|
} else if (mode === 'translate') {
|
|
const targetLang = variant; // In translate mode, variant param holds the target language code
|
|
systemPrompt = `You are a professional news translator. Translate the following news headlines/summaries into ${targetLang}.
|
|
Rules:
|
|
- Maintain the original tone and journalistic style.
|
|
- Do NOT add any conversational filler.
|
|
- Output ONLY the translated text.`;
|
|
userPrompt = `Translate to ${targetLang}:\n${headlines[0]}`;
|
|
} else {
|
|
systemPrompt = isTechVariant
|
|
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}`
|
|
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.${langInstruction}`;
|
|
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
|
|
}
|
|
|
|
const response = await fetch(OPENROUTER_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
'HTTP-Referer': 'https://worldmonitor.app',
|
|
'X-Title': 'WorldMonitor',
|
|
},
|
|
body: JSON.stringify({
|
|
model: MODEL,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
temperature: 0.3,
|
|
max_tokens: 150,
|
|
top_p: 0.9,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('[OpenRouter] API error:', response.status, errorText);
|
|
|
|
// Return fallback signal for rate limiting
|
|
if (response.status === 429) {
|
|
return new Response(JSON.stringify({ error: 'Rate limited', fallback: true }), {
|
|
status: 429,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
return new Response(JSON.stringify({ error: 'OpenRouter API error', fallback: true }), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const data = await response.json();
|
|
const summary = data.choices?.[0]?.message?.content?.trim();
|
|
|
|
if (!summary) {
|
|
return new Response(JSON.stringify({ error: 'Empty response', fallback: true }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Store in cache (shared with Groq endpoint)
|
|
await setCachedJson(cacheKey, {
|
|
summary,
|
|
model: MODEL,
|
|
timestamp: Date.now(),
|
|
}, CACHE_TTL_SECONDS);
|
|
|
|
return new Response(JSON.stringify({
|
|
summary,
|
|
model: MODEL,
|
|
provider: 'openrouter',
|
|
cached: false,
|
|
tokens: data.usage?.total_tokens || 0,
|
|
}), {
|
|
status: 200,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
|
|
},
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[OpenRouter] Error:', error);
|
|
return new Response(JSON.stringify({ error: error.message, fallback: true }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|