Files
worldmonitor/src/components/ForecastPanel.ts
Elie Habib 45f5e5a457 feat(forecast): AI Forecasts prediction module (#1579)
* feat(forecast): add AI Forecasts prediction module (Pro-tier)

MiroFish-inspired prediction engine that generates structured forecasts
across 6 domains (conflict, market, supply chain, political, military,
infrastructure) using existing WorldMonitor data streams.

- Proto definitions for ForecastService with GetForecasts RPC
- Dedicated seed script (seed-forecasts.mjs) with 6 domain detectors,
  cross-domain cascade resolver, prediction market calibration, and
  trend detection via prior snapshot comparison
- Premium-gated RPC handler (PREMIUM_RPC_PATHS enforcement)
- Lazy-loaded ForecastPanel with domain filters, probability bars,
  trend arrows, signal evidence, and cascade links
- Health monitoring integration (seed-meta freshness tracking)
- Refresh scheduler with API key guard

* test(forecast): add 47 unit tests for forecast detectors and utilities

Covers forecastId, normalize, resolveCascades, calibrateWithMarkets,
computeTrends, and smoke tests for all 6 domain detectors. Exports
testable functions from seed script with direct-run guard.

* fix(forecast): domain mismatch 'infra' vs 'infrastructure', add panel category

- Seed script used 'infra' but ForecastPanel filtered on 'infrastructure',
  causing Infra tab to show zero results
- Added 'forecast' to intelligence category in PANEL_CATEGORY_MAP

* fix(forecast): move CSS to one-time injection, improve type safety

- P2: Move style block from setContent to one-time document.head injection
  to prevent CSS accumulation on repeated renders
- P3: Replace +toFixed(3) with Math.round for readability in seed script
- P3: Use Forecast type instead of any[] in RPC handler filter

* fix(forecast): handle sebuf proto data shapes from Redis

Detectors now normalize CII scores from server-side proto format
(combinedScore, TREND_DIRECTION_RISING, region) to uniform shape.
Outage severity handles proto enum format (SEVERITY_LEVEL_HIGH).
Added confidence floor of 0.3 for single-source predictions.

Verified against live Redis: 2 predictions generated (Iran infra
shutdown, IL political instability).

* feat(forecast): unlock AI Forecasts on web, lock desktop only (trial)

- Remove forecast RPC from PREMIUM_RPC_PATHS (web access is free)
- Panel locked on desktop only (same as oref-sirens/telegram-intel)
- Remove API key guards from data-loader and refresh scheduler
- Web users get full access during trial period

* chore: regenerate proto types with make generate

Re-ran make generate after rebasing on main. Plugin v0.7.0 dropped
@ts-nocheck from output, added it back to all 50 generated files.
Fixed 4 type errors from proto codegen changes:
- MarketSource enum -> string union type
- TemporalAnomalyProto -> TemporalAnomaly rename
- webcam lastUpdated number -> string

* fix(forecast): use chokepoints v4 key, include ciiContribution in unrest

- P1: Switch chokepoints input from stale v2 to active v4 Redis key,
  matching bootstrap.js and cache-keys.ts
- P2: Add ciiContribution to unrest component fallback chain in
  normalizeCiiEntry so political detector reads the correct sebuf field

* feat(forecast): Phase 2 LLM scenario enrichment + confidence model

MiroFish-inspired enhancements:
- LLM scenario narratives via Groq/OpenRouter (narrative-only, no numeric
  adjustment). Evidence-grounded prompts with mandatory signal citation
  and few-shot examples from MiroFish's SECTION_SYSTEM_PROMPT_TEMPLATE.
- Top-4 predictions batched into single LLM call for cost efficiency.
- News context from newsInsights attached to all predictions for LLM
  prompt grounding (NOT in signals, cannot affect confidence).
- Deterministic confidence model: source diversity via SIGNAL_TO_SOURCE
  mapping (deduplicates cii+cii_delta, theater+indicators) + calibration
  agreement from prediction market drift. Floor 0.2, ceiling 1.0.
- Output validation: rejects scenarios without signal references.
- Truncated JSON repair for small model output.
- Structured JSON logging for LLM calls.
- Redis cache for LLM scenarios (1h TTL).
- 23 new tests (70 total), all passing.
- Live-tested: OpenRouter gemini-2.5-flash produces evidence-grounded
  scenario narratives from real WorldMonitor data.

* feat(forecast): Phase 3 multi-perspective scenarios, projections, data-driven cascades

MiroFish-inspired enhancements:
- Multi-perspective LLM analysis: top-2 predictions get strategic,
  regional, and contrarian viewpoints via combined LLM call
- Probability projections: domain-specific decay curves (h24/d7/d30)
  anchored to timeHorizon so probability equals projections[timeHorizon]
- Data-driven cascade rules: moved from hardcoded array to JSON config
  (scripts/data/cascade-rules.json) with schema validation, named
  predicate evaluators, unknown key rejection, and fallback to defaults
- 4 new cascade paths: infrastructure->supply_chain, infrastructure->market
  (both requiresSeverity:total), conflict->political, political->market
- Proto: added Perspectives and Projections messages to Forecast
- ForecastPanel: renders projections row and conditional perspectives toggle
- 89 tests (19 new), all passing
- Live-tested: OpenRouter produces perspectives from real data

* feat(forecast): Phase 4 data utilization + entity graph

Fixes data gaps that prevented 4 of 6 detectors from firing:
- Input normalizers: chokepoint v4 shape + GPS hexes-to-zones mapping
- Chokepoint warm-ping (production-only, requires WM_API_BASE_URL)
- Lowered CII conflict threshold from 70 to 60, gated on level=high|critical

4 new standalone detectors:
- UCDP conflict zones (10+ events per country)
- Cyber threat concentration (5+ threats per country)
- GPS jamming in maritime shipping zones (5 regions)
- Prediction markets as signals (60-90% probability markets)

Entity-relationship graph (file-based, 38 nodes):
- Countries, theaters, commodities, chokepoints, alliances
- Alias table resolves both ISO codes and display names
- Graph cascade discovery links predictions across entities

Result: 51 predictions (up from 1-2), spanning conflict, infrastructure,
and supply chain domains. 112 tests, all passing.

* fix(forecast): redis cache format, signal source mapping, type safety

Fresh-eyes audit fixes:
- BUG: redisSet used wrong Upstash API format (POST body with {value,ex}
  instead of command array ['SET',key,value,'EX',ttl]). LLM cache writes
  were silently failing, causing fresh LLM calls every run.
- BUG: prediction_market signal type missing from SIGNAL_TO_SOURCE,
  inflating confidence for market-derived predictions.
- CLEANUP: Remove unnecessary (f as any) casts in ForecastPanel since
  generated Forecast type already has projections/perspectives fields.
- CLEANUP: Bump health maxStaleMin from 60 to 90 to avoid false STALE
  alerts when LLM calls add latency to seed runs.

* feat(forecast): headline-entity matching with news corroboration signals

Uses entity graph aliases to match headlines to predictions by
country/theater (excludes commodity/infrastructure nodes to prevent
false positives). Predictions with matching headlines get a
news_corroboration signal visible in the panel.

Also fixes buildUserPrompt to merge unique headlines from ALL
predictions in the LLM batch (was only reading preds[0].newsContext).

Live-tested: 13 of 51 predictions now have corroborating headlines
(Iran, Israel, Syria, Ukraine, etc). 116 tests, all passing.

* feat(forecast): add country-codes.json for headline-entity matching

56 countries with ISO codes, full names, and scoring keywords (extracted
from src/config/countries.ts + UCDP-relevant additions). Used by
attachNewsContext for richer headline matching via getSearchTermsForRegion
which combines country-codes + entity graph + keyword aliases.

14/57 predictions now have news corroboration (limited by headline
coverage, not matching quality: only 8 headlines currently available).

* feat(forecast): read 300 headlines from news digest instead of 8

Read news:digest:v1:full:en (300 headlines across 16 categories) instead
of just news:insights:v1 topStories (8 headlines). Fallback to topStories
if digest is unavailable.

Result: news corroboration jumped from 25% to 64% (38/59 predictions).

* fix(forecast): handle parenthetical country names in headline matching

Strip suffixes like '(Zaire)', '(Burma)', '(Soviet Union)' from UCDP
region names before matching against country-codes.json. Also use
includes() for reverse name lookup to catch partial matches.

Corroboration: 64% -> 69% (41/59). Remaining 18 unmatched are countries
with no current English-language news coverage.

* fix(forecast): cache validated LLM output, add digest test, log cache errors

Fresh-eyes audit fixes:
- Combined LLM cache now stores only validated items (was caching raw
  unvalidated output, serving potentially invalid scenarios on cache hit)
- redisSet logs warnings on failure (was silently swallowing all errors)
- Added digest-based test for attachNewsContext (primary path was untested)
- Fixed test arity: attachNewsContext(preds, news, digest) with 3 params

* fix(forecast): remove dead confidenceFromSources, reduce warm-ping timeout

- P2: Remove confidenceFromSources (dead code, computeConfidence overwrites
  all initial confidence values). Inline the formula in original detectors.
- P3: Reduce warm-ping timeout from 30s to 15s (non-critical step)
- P3: Add trial status comment on forecast panel config

* fix(forecast): resolve ISO codes to country names, fix market detector, safe pre-push

P1 fixes from code review:
- CII ISO codes (IL, IR) now resolved to full country names (Israel, Iran)
  via country-codes.json. Prevents substring false positives (IL matching
  Chile) in event correlation. Uses word-boundary regex for matching.
- Market detector CII-to-theater mapping now uses entity graph traversal
  instead of broken theater-name substring matching. Iran correctly maps
  to Middle East theater via graph links.
- Pre-push hook no longer runs destructive git checkout on proto freshness
  failure. Reports mismatch and exits without modifying worktree.
2026-03-15 01:42:04 +04:00

178 lines
7.7 KiB
TypeScript

import { Panel } from './Panel';
import { escapeHtml } from '@/services/forecast';
import type { Forecast } from '@/services/forecast';
const DOMAINS = ['all', 'conflict', 'market', 'supply_chain', 'political', 'military', 'infrastructure'] as const;
const DOMAIN_LABELS: Record<string, string> = {
all: 'All',
conflict: 'Conflict',
market: 'Market',
supply_chain: 'Supply Chain',
political: 'Political',
military: 'Military',
infrastructure: 'Infra',
};
let _styleInjected = false;
function injectStyles(): void {
if (_styleInjected) return;
_styleInjected = true;
const style = document.createElement('style');
style.textContent = `
.fc-panel { font-size: 12px; }
.fc-filters { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--border-color, #333); }
.fc-filter { background: transparent; border: 1px solid var(--border-color, #444); color: var(--text-secondary, #aaa); padding: 2px 8px; border-radius: 3px; cursor: pointer; font-size: 11px; }
.fc-filter.fc-active { background: var(--accent-color, #3b82f6); color: #fff; border-color: var(--accent-color, #3b82f6); }
.fc-list { padding: 4px 0; }
.fc-card { padding: 6px 10px; border-bottom: 1px solid var(--border-color, #222); }
.fc-card:hover { background: var(--hover-bg, rgba(255,255,255,0.03)); }
.fc-header { display: flex; justify-content: space-between; align-items: center; }
.fc-title { font-weight: 600; color: var(--text-primary, #eee); }
.fc-prob { font-weight: 700; font-size: 14px; }
.fc-prob.high { color: #ef4444; }
.fc-prob.medium { color: #f59e0b; }
.fc-prob.low { color: #22c55e; }
.fc-meta { color: var(--text-secondary, #888); font-size: 11px; margin-top: 2px; }
.fc-trend-rising { color: #ef4444; }
.fc-trend-falling { color: #22c55e; }
.fc-trend-stable { color: var(--text-secondary, #888); }
.fc-signals { margin-top: 4px; }
.fc-signal { color: var(--text-secondary, #999); font-size: 11px; padding: 1px 0; }
.fc-signal::before { content: ''; display: inline-block; width: 6px; height: 1px; background: var(--text-secondary, #666); margin-right: 6px; vertical-align: middle; }
.fc-cascade { font-size: 11px; color: var(--accent-color, #3b82f6); margin-top: 3px; }
.fc-scenario { font-size: 11px; color: var(--text-primary, #ccc); margin: 4px 0; font-style: italic; }
.fc-hidden { display: none; }
.fc-toggle { cursor: pointer; color: var(--text-secondary, #888); font-size: 11px; }
.fc-toggle:hover { color: var(--text-primary, #eee); }
.fc-calibration { font-size: 10px; color: var(--text-secondary, #777); margin-top: 2px; }
.fc-bar { height: 3px; border-radius: 1.5px; margin-top: 3px; background: var(--border-color, #333); }
.fc-bar-fill { height: 100%; border-radius: 1.5px; }
.fc-empty { padding: 20px; text-align: center; color: var(--text-secondary, #888); }
.fc-projections { font-size: 10px; color: var(--text-secondary, #777); margin-top: 3px; font-variant-numeric: tabular-nums; }
.fc-perspectives { margin-top: 4px; }
.fc-perspective { font-size: 11px; color: var(--text-secondary, #999); padding: 2px 0; line-height: 1.4; }
.fc-perspective strong { color: var(--text-primary, #ccc); font-weight: 600; }
`;
document.head.appendChild(style);
}
export class ForecastPanel extends Panel {
private forecasts: Forecast[] = [];
private activeDomain: string = 'all';
constructor() {
super({ id: 'forecast', title: 'AI Forecasts', showCount: true });
injectStyles();
this.content.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const filterBtn = target.closest('[data-fc-domain]') as HTMLElement;
if (filterBtn) {
this.activeDomain = filterBtn.dataset.fcDomain || 'all';
this.render();
return;
}
const toggle = target.closest('[data-fc-toggle]') as HTMLElement;
if (toggle) {
const details = toggle.nextElementSibling as HTMLElement;
if (details) details.classList.toggle('fc-hidden');
return;
}
});
}
updateForecasts(forecasts: Forecast[]): void {
this.forecasts = forecasts;
this.setCount(forecasts.length);
this.setDataBadge(forecasts.length > 0 ? 'live' : 'unavailable');
this.render();
}
private render(): void {
if (this.forecasts.length === 0) {
this.setContent('<div class="fc-empty">No forecasts available</div>');
return;
}
const filtered = this.activeDomain === 'all'
? this.forecasts
: this.forecasts.filter(f => f.domain === this.activeDomain);
const sorted = [...filtered].sort((a, b) =>
(b.probability * b.confidence) - (a.probability * a.confidence)
);
const filtersHtml = DOMAINS.map(d =>
`<button class="fc-filter${d === this.activeDomain ? ' fc-active' : ''}" data-fc-domain="${d}">${DOMAIN_LABELS[d]}</button>`
).join('');
const cardsHtml = sorted.map(f => this.renderCard(f)).join('');
this.setContent(`
<div class="fc-panel">
<div class="fc-filters">${filtersHtml}</div>
<div class="fc-list">${cardsHtml}</div>
</div>
`);
}
private renderCard(f: Forecast): string {
const pct = Math.round((f.probability || 0) * 100);
const probClass = pct > 60 ? 'high' : pct > 35 ? 'medium' : 'low';
const probColor = pct > 60 ? '#ef4444' : pct > 35 ? '#f59e0b' : '#22c55e';
const trendIcon = f.trend === 'rising' ? '&#x25B2;' : f.trend === 'falling' ? '&#x25BC;' : '&#x2500;';
const trendClass = `fc-trend-${f.trend || 'stable'}`;
const signalsHtml = (f.signals || []).map(s =>
`<div class="fc-signal">${escapeHtml(s.value)}</div>`
).join('');
const cascadesHtml = (f.cascades || []).length > 0
? `<div class="fc-cascade">Cascades: ${f.cascades.map(c => escapeHtml(c.domain)).join(', ')}</div>`
: '';
const scenarioHtml = f.scenario
? `<div class="fc-scenario">${escapeHtml(f.scenario)}</div>`
: '';
const calibrationHtml = f.calibration?.marketTitle
? `<div class="fc-calibration">Market: ${escapeHtml(f.calibration.marketTitle)} (${Math.round((f.calibration.marketPrice || 0) * 100)}%)</div>`
: '';
const proj = f.projections;
const projectionsHtml = proj
? `<div class="fc-projections">24h: ${Math.round(proj.h24 * 100)}% | 7d: ${Math.round(proj.d7 * 100)}% | 30d: ${Math.round(proj.d30 * 100)}%</div>`
: '';
const persp = f.perspectives;
const perspectivesHtml = persp?.strategic
? `<span class="fc-toggle" data-fc-toggle>Perspectives</span>
<div class="fc-perspectives fc-hidden">
<div class="fc-perspective"><strong>Strategic:</strong> ${escapeHtml(persp.strategic)}</div>
<div class="fc-perspective"><strong>Regional:</strong> ${escapeHtml(persp.regional || '')}</div>
<div class="fc-perspective"><strong>Contrarian:</strong> ${escapeHtml(persp.contrarian || '')}</div>
</div>`
: '';
return `
<div class="fc-card">
<div class="fc-header">
<span class="fc-title"><span class="${trendClass}">${trendIcon}</span> ${escapeHtml(f.title)}</span>
<span class="fc-prob ${probClass}">${pct}%</span>
</div>
<div class="fc-bar"><div class="fc-bar-fill" style="width:${pct}%;background:${probColor}"></div></div>
${projectionsHtml}
<div class="fc-meta">${escapeHtml(f.region)} | ${escapeHtml(f.timeHorizon || '7d')} | <span class="${trendClass}">${f.trend || 'stable'}</span></div>
${scenarioHtml}
${perspectivesHtml}
<span class="fc-toggle" data-fc-toggle>Signals (${(f.signals || []).length})</span>
<div class="fc-signals fc-hidden">${signalsHtml}</div>
${cascadesHtml}
${calibrationHtml}
</div>
`;
}
}