Files
world/api/country-intel.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

192 lines
8.1 KiB
JavaScript

/**
* Country Intelligence Brief Endpoint
* Generates AI-powered country situation briefs using Groq
* Redis cached (2h TTL) 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 = 7200; // 2 hours
const CACHE_VERSION = 'ci-v2';
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({ intel: 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: { 'Content-Type': 'application/json' },
});
}
try {
const { country, code, context } = await request.json();
if (!country || !code) {
return new Response(JSON.stringify({ error: 'country and code required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Cache key includes country code + context hash (context changes as data updates)
const contextHash = context ? hashString(JSON.stringify(context)).slice(0, 8) : 'no-ctx';
const cacheKey = `${CACHE_VERSION}:${code}:${contextHash}`;
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.brief) {
console.log('[CountryIntel] Cache hit:', code);
return new Response(JSON.stringify({ ...cached, cached: true }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
// Build data context section
const dataLines = [];
if (context?.score != null) {
const changeStr = context.change24h ? ` (${context.change24h > 0 ? '+' : ''}${context.change24h} in 24h)` : '';
dataLines.push(`Instability Score: ${context.score}/100 (${context.level || 'unknown'}) — trend: ${context.trend || 'unknown'}${changeStr}`);
}
if (context?.components) {
const c = context.components;
dataLines.push(`Score Components: Unrest ${c.unrest ?? '?'}/100, Security ${c.security ?? '?'}/100, Information ${c.information ?? '?'}/100`);
}
if (context?.protests != null) dataLines.push(`Active protests in/near country (7d): ${context.protests}`);
if (context?.militaryFlights != null) dataLines.push(`Military aircraft detected in/near country: ${context.militaryFlights}`);
if (context?.militaryVessels != null) dataLines.push(`Military vessels detected in/near country: ${context.militaryVessels}`);
if (context?.outages != null) dataLines.push(`Internet outages: ${context.outages}`);
if (context?.earthquakes != null) dataLines.push(`Recent earthquakes: ${context.earthquakes}`);
if (context?.stockIndex) dataLines.push(`Stock Market Index: ${context.stockIndex}`);
if (context?.convergenceScore != null) {
dataLines.push(`Signal convergence score: ${context.convergenceScore}/100 (multiple signal types detected: ${(context.signalTypes || []).join(', ')})`);
}
if (context?.regionalConvergence?.length > 0) {
dataLines.push(`\nRegional convergence alerts:`);
context.regionalConvergence.forEach(r => dataLines.push(`- ${r}`));
}
if (context?.headlines?.length > 0) {
dataLines.push(`\nRecent headlines mentioning ${country} (${context.headlines.length} found):`);
context.headlines.slice(0, 15).forEach((h, i) => dataLines.push(`${i + 1}. ${h}`));
}
const dataSection = dataLines.length > 0
? `\nCURRENT SENSOR DATA:\n${dataLines.join('\n')}`
: '\nNo real-time sensor data available for this country.';
const dateStr = new Date().toISOString().split('T')[0];
const systemPrompt = `You are a senior intelligence analyst providing comprehensive country situation briefs. Current date: ${dateStr}. Donald Trump is the current US President (second term, inaugurated Jan 2025).
Write a thorough, data-driven intelligence brief for the requested country. Structure:
1. **Current Situation** — What is happening right now. Reference specific data: instability scores, protest counts, military presence, outages. Explain what the numbers mean in context.
2. **Military & Security Posture** — Analyze military activity in/near the country. What forces are present? What does the positioning suggest? What are foreign nations doing in this theater?
3. **Key Risk Factors** — What drives instability or stability. Connect the dots between different signals (protests + outages = potential crackdown? military buildup + diplomatic tensions = escalation risk?). Reference specific headlines.
4. **Regional Context** — How does this country's situation affect or relate to its neighbors and the broader region? Reference any convergence alerts.
5. **Outlook & Watch Items** — What to monitor in the near term. Be specific about indicators that would signal escalation or de-escalation.
Rules:
- Be specific and analytical. Reference the data provided (scores, counts, headlines, convergence).
- If data shows low activity, say so — don't manufacture threats.
- Connect signals: explain what combinations of data points suggest.
- 5-6 paragraphs, 300-400 words.
- No speculation beyond what the data supports.
- Use plain language, not jargon.
- If military assets are 0, don't speculate about military presence — say monitoring shows no current military activity.
- When referencing a specific headline from the numbered list, cite it as [N] where N is the headline number (e.g. "tensions escalated [3]"). Only cite headlines you directly reference.`;
const userPrompt = `Country: ${country} (${code})${dataSection}`;
const aiRes = 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.4,
max_tokens: 900,
}),
});
if (!aiRes.ok) {
const errText = await aiRes.text();
console.error('[CountryIntel] model error:', aiRes.status, errText);
return new Response(JSON.stringify({ error: 'AI service error', fallback: true }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
const aiData = await aiRes.json();
const brief = aiData.choices?.[0]?.message?.content || '';
const result = {
brief,
country,
code,
model: MODEL,
generatedAt: new Date().toISOString(),
};
if (brief) {
await setCachedJson(cacheKey, result, CACHE_TTL_SECONDS);
}
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
} catch (err) {
console.error('[CountryIntel] Error:', err);
return new Response(JSON.stringify({ error: 'Internal error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}