Files
world/api/groq-summarize.js
zooqueenandHanzo Dev 9e6ef253b9 world: the AI is ours — one summarize route, no competitor in the product
You asked why we were using Groq. We were not — and that is the bug.

`internal/world/ai.go` has always pointed at api.hanzo.ai/v1 with model "best",
and `handleSummarize` backs BOTH /v1/world/groq-summarize and
/v1/world/openrouter-summarize with that one Hanzo-backed handler. CI ships the
Go operator Service CR; vercel.json declares no api routes, so the Vercel
`api/*.js` edge functions that really did call api.groq.com and openrouter.ai
are not deployed at all.

So production ran on our stack the whole time. What shipped was the NAMING, and
it lied in every direction:

- the client logged `[Summarization] Groq success` — chosen from the ROUTE NAME,
  not from anything Groq did. That console line is what made a security review
  report a competing AI stack running inside a Hanzo surface.
- the progress UI told users "Connecting to Groq AI..." and "Trying OpenRouter..."
  while Hanzo served the request.
- Settings offered two features, "Groq summarization" and "OpenRouter
  summarization", each asking for a provider key, with signup links to
  console.groq.com and openrouter.ai — sending our users to competitors to buy
  keys for calls we were already making and paying for.
- 13 locales told users to "configure GROQ_API_KEY in Settings", a setting that
  does nothing.

And because both endpoints were one handler, `tryGroq` and `tryOpenRouter` were
never two providers: the "fallback chain" was the identical request twice, a
retry wearing a second vendor's name. Same for the translate path.

Now: one `/v1/world/summarize`, one `tryHanzo`, one `aiSummary` feature, and no
per-user provider key — the gateway takes the signed-in user's bearer, so the
inference meters to the org the user is acting in. The two old routes stay as
aliases so a cached bundle cannot 404 mid-rollout; delete them once nothing asks.

The `api/*.js` handlers are repointed at the Hanzo gateway through one shared
`api/_hanzo-ai.js` rather than left calling Groq. They are dead in this
deployment, but dead code that still holds a third-party endpoint and reads
GROQ_API_KEY is exactly what gets revived by accident.

Groq and OpenRouter still appear in `src/config/ai-datacenters.ts` and
`tech-companies.ts`. That is deliberate: world MAPS real companies and their
datacenters, and Groq is one. Data about a competitor is not a dependency on one.

Not done: 12 non-English locales had the vendor token swapped out to keep them
truthful, but their sentences still imply a setting that no longer exists
("configure Hanzo AI in Settings"). They want a real copy pass by someone who
speaks them; I will not fabricate translations.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 03:24:52 -07:00

297 lines
11 KiB
JavaScript

/**
* Groq API Summarization Endpoint with Redis Caching
* Uses Llama 3.1 8B Instant for high-throughput summarization
* Free tier: 14,400 requests/day (14x more than 70B model)
* 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
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.summary) {
console.log('[Groq] 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 multiple 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 (e.g., 'fr', 'es')
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 (e.g., "Here is the translation").
- Output ONLY the translated text.
- If the text is already in ${targetLang}, return it as is.`;
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',
},
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('[Groq] 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: 'Groq 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
await setCachedJson(cacheKey, {
summary,
model: MODEL,
timestamp: Date.now(),
}, CACHE_TTL_SECONDS);
return new Response(JSON.stringify({
summary,
model: MODEL,
provider: 'groq',
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('[Groq] Error:', error.name, error.message, error.stack?.split('\n')[1]);
return new Response(JSON.stringify({
error: error.message,
errorType: error.name,
fallback: true
}), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}