| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createHash } from 'node:crypto'; |
|
|
| import { extractFirstJsonObject, cleanJsonText } from '../_llm-json.mjs'; |
| import { withRetry, httpRetryError, createLlmBudgetError, isLlmBudgetError } from '../_seed-utils.mjs'; |
| import { buildLlmCallEvent, emitLlmEvents } from '../lib/llm-telemetry.cjs'; |
|
|
| const CHROME_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; |
|
|
| const NARRATIVE_MAX_TOKENS = 900; |
| const NARRATIVE_TEMPERATURE = 0.3; |
| |
| |
| |
| |
| const NARRATIVE_LLM_MAX_RETRIES = 2; |
| const NARRATIVE_LLM_RETRY_BASE_MS = 1_000; |
| const NARRATIVE_LLM_RETRY_AFTER_MAX_MS = 10_000; |
| const NARRATIVE_LLM_CALL_BUDGET_MS = 45_000; |
| const NARRATIVE_LLM_CALL_BUDGET_GUARD_MS = 5_000; |
|
|
| let narrativeFetchForTests = null; |
| export function __setNarrativeTransportForTests(overrides = null) { |
| narrativeFetchForTests = typeof overrides?.fetch === 'function' ? overrides.fetch : null; |
| } |
| const MAX_ACTORS_IN_PROMPT = 5; |
| const MAX_EVIDENCE_IN_PROMPT = 15; |
| const MAX_TRANSMISSIONS_IN_PROMPT = 5; |
| const MAX_WATCH_ITEMS = 3; |
|
|
| |
| |
| |
| const DEFAULT_PROVIDERS = [ |
| { |
| name: 'openrouter', |
| envKey: 'OPENROUTER_API_KEY', |
| apiUrl: 'https://openrouter.ai/api/v1/chat/completions', |
| model: 'deepseek/deepseek-v4-flash', |
| timeout: 30_000, |
| headers: (key) => ({ |
| Authorization: `Bearer ${key}`, |
| 'Content-Type': 'application/json', |
| 'HTTP-Referer': 'https://worldmonitor.app', |
| 'X-Title': 'World Monitor', |
| 'User-Agent': CHROME_UA, |
| }), |
| extraBody: { reasoning: { enabled: false } }, |
| }, |
| { |
| name: 'groq', |
| envKey: 'GROQ_API_KEY', |
| apiUrl: 'https://api.groq.com/openai/v1/chat/completions', |
| model: 'llama-3.3-70b-versatile', |
| timeout: 20_000, |
| headers: (key) => ({ |
| Authorization: `Bearer ${key}`, |
| 'Content-Type': 'application/json', |
| 'User-Agent': CHROME_UA, |
| }), |
| }, |
| ]; |
|
|
| |
| |
| |
| |
| export function emptyNarrative() { |
| return { |
| situation: { text: '', evidence_ids: [] }, |
| balance_assessment: { text: '', evidence_ids: [] }, |
| outlook_24h: { text: '', evidence_ids: [] }, |
| outlook_7d: { text: '', evidence_ids: [] }, |
| outlook_30d: { text: '', evidence_ids: [] }, |
| watch_items: [], |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function selectPromptEvidence(evidence) { |
| if (!Array.isArray(evidence)) return []; |
| return evidence.slice(0, MAX_EVIDENCE_IN_PROMPT); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function buildNarrativePrompt(region, snapshot, evidence) { |
| const topActors = (snapshot.actors ?? []) |
| .slice(0, MAX_ACTORS_IN_PROMPT) |
| .map((a) => `${a.name} (${a.role}, leverage=${a.leverage_score.toFixed(2)})`) |
| .join(', '); |
|
|
| const horizonSummary = (snapshot.scenario_sets ?? []) |
| .map((set) => { |
| const dominant = [...(set.lanes ?? [])].sort((a, b) => b.probability - a.probability)[0]; |
| return dominant |
| ? `${set.horizon}: ${dominant.name} (${Math.round(dominant.probability * 100)}%)` |
| : `${set.horizon}: (no lanes)`; |
| }) |
| .join(' | '); |
|
|
| const topTransmissions = (snapshot.transmission_paths ?? []) |
| .slice(0, MAX_TRANSMISSIONS_IN_PROMPT) |
| .map((t) => `${t.mechanism} via ${t.corridor_id || t.start} (conf=${t.confidence.toFixed(2)})`) |
| .join('; '); |
|
|
| const activeTriggers = (snapshot.triggers?.active ?? []) |
| .map((t) => t.id) |
| .join(', '); |
|
|
| const evidenceLines = (evidence ?? []).map((e) => { |
| const summary = (e.summary ?? '').slice(0, 180); |
| const conf = typeof e.confidence === 'number' ? e.confidence.toFixed(2) : '0.00'; |
| return `- ${e.id} [${e.type}, conf=${conf}]: ${summary}`; |
| }); |
| const evidenceBlock = evidenceLines.length > 0 |
| ? evidenceLines.join('\n') |
| : '(no evidence available β reason over the balance vector alone)'; |
|
|
| const balance = snapshot.balance; |
| const balanceLine = [ |
| `coercive=${balance.coercive_pressure.toFixed(2)}`, |
| `fragility=${balance.domestic_fragility.toFixed(2)}`, |
| `capital=${balance.capital_stress.toFixed(2)}`, |
| `energy_vuln=${balance.energy_vulnerability.toFixed(2)}`, |
| `alliance=${balance.alliance_cohesion.toFixed(2)}`, |
| `maritime=${balance.maritime_access.toFixed(2)}`, |
| `energy_lev=${balance.energy_leverage.toFixed(2)}`, |
| `net=${balance.net_balance.toFixed(2)}`, |
| ].join(' '); |
|
|
| const systemPrompt = [ |
| `You are a senior geopolitical analyst producing a regional intelligence brief.`, |
| `Today is ${new Date().toISOString().split('T')[0]}.`, |
| ``, |
| `HARD RULES:`, |
| `- Output ONLY a single JSON object matching the schema below. No prose, no markdown, no code fences.`, |
| `- Each text field: 1β2 concise sentences, under 280 characters, no bullet points.`, |
| `- Every evidence_ids entry MUST be one of the IDs listed in the EVIDENCE block. Never invent IDs.`, |
| `- Ground claims in the evidence and the balance vector. Do not speculate beyond them.`, |
| `- Use present tense for situation/balance_assessment. Use hedged language for outlooks.`, |
| `- Neutral, analytical tone. No dramatization, no policy prescriptions.`, |
| ``, |
| `SCHEMA:`, |
| `{`, |
| ` "situation": { "text": "...", "evidence_ids": ["..."] },`, |
| ` "balance_assessment": { "text": "...", "evidence_ids": ["..."] },`, |
| ` "outlook_24h": { "text": "...", "evidence_ids": ["..."] },`, |
| ` "outlook_7d": { "text": "...", "evidence_ids": ["..."] },`, |
| ` "outlook_30d": { "text": "...", "evidence_ids": ["..."] },`, |
| ` "watch_items": [ { "text": "...", "evidence_ids": ["..."] } ]`, |
| `}`, |
| ``, |
| `watch_items: up to ${MAX_WATCH_ITEMS} specific indicators the analyst should monitor.`, |
| ].join('\n'); |
|
|
| const userPrompt = [ |
| `REGION: ${region.label} (${region.id})`, |
| ``, |
| `REGIME: ${snapshot.regime?.label ?? 'unknown'}`, |
| `BALANCE: ${balanceLine}`, |
| `TOP ACTORS: ${topActors || '(none)'}`, |
| `SCENARIO LEADS: ${horizonSummary || '(none)'}`, |
| `TOP TRANSMISSIONS: ${topTransmissions || '(none)'}`, |
| `ACTIVE TRIGGERS: ${activeTriggers || '(none)'}`, |
| ``, |
| `EVIDENCE:`, |
| evidenceBlock, |
| ``, |
| `Produce the JSON object now.`, |
| ].join('\n'); |
|
|
| return { systemPrompt, userPrompt }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function coerceSection(raw, validEvidenceIds) { |
| if (!raw || typeof raw !== 'object') return { text: '', evidence_ids: [] }; |
| const r = (raw); |
| const text = typeof r.text === 'string' ? r.text.trim() : ''; |
| const evidenceIds = Array.isArray(r.evidence_ids) |
| ? r.evidence_ids |
| .filter((id) => typeof id === 'string' && validEvidenceIds.has(id)) |
| : []; |
| return { text, evidence_ids: evidenceIds }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function parseNarrativeJson(text, validEvidenceIds) { |
| const validSet = new Set(validEvidenceIds); |
| if (!text || typeof text !== 'string') { |
| return { narrative: emptyNarrative(), valid: false }; |
| } |
|
|
| let parsed; |
| try { |
| |
| parsed = JSON.parse(cleanJsonText(text)); |
| } catch { |
| const extracted = extractFirstJsonObject(text); |
| if (!extracted) return { narrative: emptyNarrative(), valid: false }; |
| try { |
| parsed = JSON.parse(extracted); |
| } catch { |
| return { narrative: emptyNarrative(), valid: false }; |
| } |
| } |
|
|
| if (!parsed || typeof parsed !== 'object') { |
| return { narrative: emptyNarrative(), valid: false }; |
| } |
|
|
| const p = (parsed); |
| const watch = Array.isArray(p.watch_items) |
| ? p.watch_items.slice(0, MAX_WATCH_ITEMS).map((w) => coerceSection(w, validSet)) |
| : []; |
|
|
| const narrative = { |
| situation: coerceSection(p.situation, validSet), |
| balance_assessment: coerceSection(p.balance_assessment, validSet), |
| outlook_24h: coerceSection(p.outlook_24h, validSet), |
| outlook_7d: coerceSection(p.outlook_7d, validSet), |
| outlook_30d: coerceSection(p.outlook_30d, validSet), |
| watch_items: watch, |
| }; |
|
|
| |
| |
| const hasAnyText = |
| narrative.situation.text.length > 0 || |
| narrative.balance_assessment.text.length > 0 || |
| narrative.outlook_24h.text.length > 0 || |
| narrative.outlook_7d.text.length > 0 || |
| narrative.outlook_30d.text.length > 0 || |
| narrative.watch_items.some((w) => w.text.length > 0); |
|
|
| return { narrative, valid: hasAnyText }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function callLlmDefault({ systemPrompt, userPrompt }, opts = {}) { |
| const validate = opts.validate; |
| const narrativeFetch = narrativeFetchForTests || ((...args) => globalThis.fetch(...args)); |
| const callBudgetMs = Number.isFinite(opts.callBudgetMs) |
| ? Math.max(0, Math.floor(opts.callBudgetMs)) |
| : NARRATIVE_LLM_CALL_BUDGET_MS; |
| const retryDelayMs = Number.isFinite(opts.retryDelayMs) |
| ? Math.max(0, Math.floor(opts.retryDelayMs)) |
| : NARRATIVE_LLM_RETRY_BASE_MS; |
| const budgetStartedAtMs = Date.now(); |
| const usableBudgetMs = () => Math.max(0, budgetStartedAtMs + callBudgetMs - Date.now() - NARRATIVE_LLM_CALL_BUDGET_GUARD_MS); |
|
|
| |
| |
| |
| const promptChars = (systemPrompt?.length ?? 0) + (userPrompt?.length ?? 0); |
| const events = []; |
| let attemptIndex = 0; |
|
|
| for (const provider of DEFAULT_PROVIDERS) { |
| const envVal = process.env[provider.envKey]; |
| if (!envVal) continue; |
| const t0 = Date.now(); |
| const record = (ok, extra = {}) => { |
| events.push(buildLlmCallEvent({ |
| provider: provider.name, model: provider.model, stage: 'regional-narrative', ok, |
| durationMs: Date.now() - t0, promptChars, maxTokens: NARRATIVE_MAX_TOKENS, |
| fallbackIndex: attemptIndex++, |
| ...extra, |
| })); |
| }; |
| try { |
| const resp = await withRetry(async () => { |
| const usable = usableBudgetMs(); |
| if (usable <= 0) throw createLlmBudgetError('narrative llm budget exhausted'); |
| const response = await narrativeFetch(provider.apiUrl, { |
| method: 'POST', |
| headers: provider.headers(envVal), |
| body: JSON.stringify({ |
| model: provider.model, |
| messages: [ |
| { role: 'system', content: systemPrompt }, |
| { role: 'user', content: userPrompt }, |
| ], |
| max_tokens: NARRATIVE_MAX_TOKENS, |
| temperature: NARRATIVE_TEMPERATURE, |
| response_format: { type: 'json_object' }, |
| ...(provider.extraBody || {}), |
| }), |
| signal: AbortSignal.timeout(Math.max(1, Math.min(provider.timeout, usable))), |
| }); |
| if (!response.ok) { |
| throw httpRetryError(response, { maxRetryAfterMs: NARRATIVE_LLM_RETRY_AFTER_MAX_MS, capMs: usableBudgetMs() }); |
| } |
| return response; |
| }, NARRATIVE_LLM_MAX_RETRIES, retryDelayMs); |
|
|
| const json = (await resp.json()); |
| const usage = { |
| tokensTotal: json?.usage?.total_tokens ?? 0, |
| tokensPrompt: json?.usage?.prompt_tokens ?? 0, |
| tokensCompletion: json?.usage?.completion_tokens ?? 0, |
| }; |
| const text = json?.choices?.[0]?.message?.content; |
| if (typeof text !== 'string' || text.trim().length === 0) { |
| console.warn(`[narrative] ${provider.name}: empty response`); |
| record(false, { ...usage, reason: 'empty' }); |
| continue; |
| } |
|
|
| const trimmed = text.trim(); |
| if (validate && !validate(trimmed)) { |
| console.warn(`[narrative] ${provider.name}: response failed validation, trying next provider`); |
| record(false, { ...usage, reason: 'validate_reject' }); |
| continue; |
| } |
|
|
| |
| const actualModel = typeof json?.model === 'string' && json.model.length > 0 |
| ? json.model |
| : provider.model; |
|
|
| record(true, { ...usage, model: actualModel }); |
| void emitLlmEvents(events); |
| return { text: trimmed, provider: provider.name, model: actualModel }; |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| console.warn(`[narrative] ${provider.name}: ${msg}`); |
| const httpMatch = /HTTP (\d{3})/.exec(msg); |
| record(false, { |
| reason: isLlmBudgetError(err) ? 'budget_exhausted' |
| : err?.name === 'TimeoutError' || err?.name === 'AbortError' ? 'timeout' |
| : httpMatch ? `http_${httpMatch[1]}` |
| : 'fetch_error', |
| }); |
| |
| if (isLlmBudgetError(err)) { |
| void emitLlmEvents(events); |
| return null; |
| } |
| } |
| } |
| void emitLlmEvents(events); |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function generateRegionalNarrative(region, snapshot, evidence, opts = {}) { |
| |
| if (region.id === 'global') { |
| return { narrative: emptyNarrative(), provider: '', model: '' }; |
| } |
|
|
| const callLlm = opts.callLlm ?? callLlmDefault; |
| const cache = opts.cache ?? defaultNarrativeCache(); |
| |
| |
| const promptEvidence = selectPromptEvidence(evidence); |
| const prompt = buildNarrativePrompt(region, snapshot, promptEvidence); |
| const validEvidenceIds = promptEvidence.map((e) => e.id); |
|
|
| |
| |
| |
| |
| |
| |
| const promptText = typeof prompt === 'string' ? prompt : JSON.stringify(prompt); |
| const cacheKey = `${NARRATIVE_CACHE_PREFIX}${region.id}:${createHash('sha256').update(promptText).digest('hex').slice(0, 16)}`; |
| try { |
| const hit = await cache.get(cacheKey); |
| if (hit && typeof hit === 'object' && hit.narrative && typeof hit.narrative === 'object') { |
| console.log(`[narrative] ${region.id}: prompt-hash cache hit, skipping LLM`); |
| return { narrative: hit.narrative, provider: 'cache', model: typeof hit.model === 'string' ? hit.model : '' }; |
| } |
| } catch { } |
|
|
| |
| |
| |
| const validate = (text) => parseNarrativeJson(text, validEvidenceIds).valid; |
|
|
| let result; |
| try { |
| result = await callLlm(prompt, { validate }); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| console.warn(`[narrative] ${region.id}: callLlm threw: ${msg}`); |
| return { narrative: emptyNarrative(), provider: '', model: '' }; |
| } |
|
|
| if (!result) { |
| console.warn(`[narrative] ${region.id}: all providers failed, shipping empty narrative`); |
| return { narrative: emptyNarrative(), provider: '', model: '' }; |
| } |
|
|
| const { narrative, valid } = parseNarrativeJson(result.text, validEvidenceIds); |
| if (!valid) { |
| console.warn(`[narrative] ${region.id}: JSON parse invalid, shipping empty narrative`); |
| return { narrative: emptyNarrative(), provider: '', model: '' }; |
| } |
|
|
| |
| |
| try { |
| await cache.set(cacheKey, { narrative, model: result.model }, NARRATIVE_CACHE_TTL_SEC); |
| } catch { } |
|
|
| return { narrative, provider: result.provider, model: result.model }; |
| } |
|
|
| |
|
|
| |
| |
| const NARRATIVE_CACHE_PREFIX = 'intelligence:narrative-cache:v2:'; |
| const NARRATIVE_CACHE_TTL_SEC = 86_400; |
|
|
| function defaultNarrativeCache() { |
| |
| |
| |
| |
| return { |
| async get(key) { |
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) return null; |
| const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, { |
| headers: { Authorization: `Bearer ${token}` }, |
| signal: AbortSignal.timeout(3_000), |
| }); |
| if (!resp.ok) return null; |
| const data = await resp.json(); |
| return data?.result ? JSON.parse(data.result) : null; |
| }, |
| async set(key, value, ttlSeconds) { |
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) return; |
| await fetch(url, { |
| method: 'POST', |
| headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, |
| body: JSON.stringify(['SET', key, JSON.stringify(value), 'EX', String(ttlSeconds)]), |
| signal: AbortSignal.timeout(3_000), |
| }); |
| }, |
| }; |
| } |
|
|