mirror of
https://github.com/hanzo-apps/world.git
synced 2026-10-30 14:53:09 +00:00
The fork still wore upstream's brand throughout: "World Monitor" in 754 places of prose and metadata, WORLD MONITOR in the blog logo and footer, and a byline reading "by Someone.ceo" — another company's name signing our pages. Blog assets, OG images and post directories were named for it too, and the OpenAPI schema types carried it in every identifier. Files are renamed, references follow them, and the localStorage and slug names match. Deleted .claudedocs/sentry-triage, an AI-written summary file of the kind that belongs in LLM.md if anywhere. NOTICE and LICENSE are untouched by design. NOTICE names the upstream project, the fork point and the original author; LICENSE keeps his copyright line, which MIT requires. That is where credit belongs — not in our product's chrome.
295 lines
11 KiB
JavaScript
295 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 { HANZO_API_URL, FAST_MODEL, hanzoKey } from './_hanzo-ai.js';
|
|
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
|
|
|
export const config = {
|
|
runtime: 'edge',
|
|
};
|
|
|
|
const MODEL = FAST_MODEL;
|
|
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 normalizedVariant = typeof variant === 'string' && variant ? variant.toLowerCase() : 'full';
|
|
const normalizedLang = typeof lang === 'string' && lang ? lang.toLowerCase() : 'en';
|
|
|
|
if (mode === 'translate') {
|
|
const targetLang = normalizedVariant || normalizedLang;
|
|
return `summary:${CACHE_VERSION}:${mode}:${targetLang}:${hash}${geoHash}`;
|
|
}
|
|
|
|
return `summary:${CACHE_VERSION}:${mode}:${normalizedVariant}:${normalizedLang}:${hash}${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 = hanzoKey();
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ summary: null, fallback: true, skipped: true, reason: 'HANZO_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(HANZO_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
'HTTP-Referer': 'https://world.hanzo.ai',
|
|
'X-Title': 'HanzoWorld',
|
|
},
|
|
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' },
|
|
});
|
|
}
|
|
}
|