feat: add space weather + AI watch feeds, fix data binding issues

- Add space_weather source (NOAA SWPC: Kp index, X-ray flux, alerts)
- Add ai_watch source (arXiv cs.AI/LG/CL, HuggingFace, lab trending)
- Fix RSS circuit breaker: per-feed source names prevent cascade trips
- Fix displacement field: internally_displaced (was reading idps)
- Fix energy prices: handle nested oil.brent/wti structure
- Fix climate anomalies: use temp_anomaly_c and precip_anomaly_pct
- Fix news ticker: add feed_name to source fallback chain
- Remove defunct Reuters RSS feed, update War Zone URL
- Disable HTML caching for dev-friendly reloads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Shade
2026-02-23 13:28:38 -05:00
co-authored by Claude Opus 4.6
parent affaf4b200
commit 561ed8e277
5 changed files with 460 additions and 25 deletions
+7 -9
View File
@@ -33,6 +33,8 @@ from world_intel_mcp.sources import (
climate,
conflict,
intelligence,
space_weather,
ai_watch,
)
logger = logging.getLogger(__name__)
@@ -87,6 +89,8 @@ async def _fetch_overview() -> dict:
"displacement": displacement.fetch_displacement_summary(fetcher),
"risk_scores": intelligence.fetch_risk_scores(fetcher),
"signal_convergence": intelligence.fetch_signal_convergence(fetcher),
"space_weather": space_weather.fetch_space_weather(fetcher),
"ai_watch": ai_watch.fetch_ai_watch(fetcher),
}
gathered = await asyncio.gather(
@@ -114,16 +118,10 @@ async def _fetch_overview() -> dict:
# Routes
# ---------------------------------------------------------------------------
_INDEX_HTML: str | None = None
async def index(request):
"""Serve the dashboard HTML page."""
global _INDEX_HTML
if _INDEX_HTML is None:
html_path = Path(__file__).parent / "index.html"
_INDEX_HTML = html_path.read_text()
return HTMLResponse(_INDEX_HTML)
"""Serve the dashboard HTML page (reloads on each request during dev)."""
html_path = Path(__file__).parent / "index.html"
return HTMLResponse(html_path.read_text())
async def api_overview(request):
+76 -12
View File
@@ -704,6 +704,13 @@ function updateHudStats(data) {
if (data.displacement && !data.displacement.error && data.displacement.global_totals) {
pills.push('<div class="stat-pill"><span class="v warn">' + fmtBigPlain(data.displacement.global_totals.grand_total || 0) + '</span><span class="l">Displaced</span></div>');
}
if (data.space_weather && !data.space_weather.error && data.space_weather.current_kp != null) {
var swKp = data.space_weather.current_kp;
pills.push('<div class="stat-pill"><span class="v' + (swKp >= 5 ? ' warn' : '') + '">' + swKp.toFixed(0) + '</span><span class="l">Kp</span></div>');
}
if (data.ai_watch && !data.ai_watch.error) {
pills.push('<div class="stat-pill"><span class="v">' + (data.ai_watch.count || 0) + '</span><span class="l">AI Papers</span></div>');
}
$('#hudStats').innerHTML = safe(pills.join(''));
}
@@ -776,13 +783,17 @@ function updateDrawer(data) {
}
}
if (data.energy_prices && !data.energy_prices.error) {
var prices = data.energy_prices.prices || data.energy_prices.data || data.energy_prices;
var entries = Array.isArray(prices) ? prices : Object.entries(prices).map(function(e) { return Object.assign({name: e[0]}, typeof e[1] === 'object' ? e[1] : {value: e[1]}); });
if (entries.length) {
h += '<div class="sub">Energy</div><table class="dtable"><tbody>';
entries.slice(0, 6).forEach(function(item) {
var v = item.value || item.price || item.last_value;
h += '<tr><td>' + esc((item.name || '?').replace(/_/g, ' ')) + '</td><td class="bright">' + (typeof v === 'number' ? fmtNum(v) : esc(String(v || '\u2014'))) + '</td></tr>';
var ep = data.energy_prices;
var eRows = [];
if (ep.oil) {
if (ep.oil.brent && ep.oil.brent.price != null) eRows.push({name: 'Brent Crude', price: ep.oil.brent.price, date: ep.oil.brent.date});
if (ep.oil.wti && ep.oil.wti.price != null) eRows.push({name: 'WTI Crude', price: ep.oil.wti.price, date: ep.oil.wti.date});
}
if (ep.natural_gas && ep.natural_gas.price != null) eRows.push({name: 'Natural Gas', price: ep.natural_gas.price, date: ep.natural_gas.date});
if (eRows.length) {
h += '<div class="sub">Energy</div><table class="dtable"><thead><tr><th>Commodity</th><th>Price</th><th>Date</th></tr></thead><tbody>';
eRows.forEach(function(item) {
h += '<tr><td>' + esc(item.name) + '</td><td class="bright">$' + fmtNum(item.price) + '</td><td class="dim">' + esc(item.date || '\u2014') + '</td></tr>';
});
h += '</tbody></table>';
}
@@ -860,6 +871,25 @@ function updateDrawer(data) {
}
}
if (data.space_weather && !data.space_weather.error) {
var sw = data.space_weather;
h += '<div class="sub">Space Weather</div><div class="mini-row">';
var kpVal = sw.current_kp;
var kpCls = kpVal >= 7 ? ' crit' : kpVal >= 5 ? ' warn' : '';
h += '<div class="mini-box"><div class="v' + kpCls + '">' + (kpVal != null ? fmtNum(kpVal, 1) : '\u2014') + '</div><div class="l">Kp Index</div></div>';
h += '<div class="mini-box"><div class="v">' + esc(sw.kp_level || '\u2014') + '</div><div class="l">Geo Level</div></div>';
h += '<div class="mini-box"><div class="v">' + esc(sw.latest_flare_class || '\u2014') + '</div><div class="l">X-Ray Flux</div></div>';
h += '</div>';
var swAlerts = sw.alerts || [];
if (swAlerts.length) {
h += '<table class="dtable"><thead><tr><th>Alert</th><th>Time</th></tr></thead><tbody>';
swAlerts.slice(0, 5).forEach(function(a) {
h += '<tr><td class="warn">' + esc(trunc(a.message || '?', 50)) + '</td><td class="dim">' + esc(ago(a.issue_datetime)) + '</td></tr>';
});
h += '</tbody></table>';
}
}
// ── INTELLIGENCE ──
h += '<div class="sh">INTELLIGENCE</div>';
if (data.trending_keywords && !data.trending_keywords.error) {
@@ -907,7 +937,7 @@ function updateDrawer(data) {
if (origins.length) {
h += '<table class="dtable"><thead><tr><th>Origin</th><th>Refugees</th><th>IDPs</th></tr></thead><tbody>';
origins.slice(0, 8).forEach(function(o) {
h += '<tr><td class="bright">' + esc(o.country_name || o.country || '?') + '</td><td>' + fmtBigPlain(o.refugees || 0) + '</td><td>' + fmtBigPlain(o.idps || 0) + '</td></tr>';
h += '<tr><td class="bright">' + esc(o.country_name || o.country || '?') + '</td><td>' + fmtBigPlain(o.refugees || 0) + '</td><td>' + fmtBigPlain(o.internally_displaced || o.idps || 0) + '</td></tr>';
});
h += '</tbody></table>';
}
@@ -931,14 +961,48 @@ function updateDrawer(data) {
if (anomalies.length) {
h += '<div class="sub">Climate Anomalies</div><table class="dtable"><thead><tr><th>Zone</th><th>Temp</th><th>Precip</th></tr></thead><tbody>';
anomalies.slice(0, 8).forEach(function(a) {
var temp = a.temperature_anomaly || a.temp_anomaly || a.temp_deviation;
var precip = a.precipitation_anomaly || a.precip_anomaly || a.precip_deviation;
h += '<tr><td>' + esc(a.zone || a.name || a.region || '?') + '</td><td class="' + (temp > 0 ? 'warn' : 'up') + '">' + (temp != null ? (temp > 0 ? '+' : '') + fmtNum(temp, 1) + '\u00B0C' : '\u2014') + '</td><td class="dim">' + (precip != null ? fmtNum(precip, 1) + 'mm' : '\u2014') + '</td></tr>';
var temp = a.temp_anomaly_c != null ? a.temp_anomaly_c : (a.temperature_anomaly || a.temp_anomaly || a.temp_deviation);
var precip = a.precip_anomaly_pct != null ? a.precip_anomaly_pct : (a.precipitation_anomaly || a.precip_anomaly || a.precip_deviation);
h += '<tr><td>' + esc(a.zone || a.name || a.region || '?') + '</td><td class="' + (temp > 0 ? 'warn' : 'up') + '">' + (temp != null ? (temp > 0 ? '+' : '') + fmtNum(temp, 1) + '\u00B0C' : '\u2014') + '</td><td class="' + (precip > 50 ? 'warn' : precip < -50 ? 'down' : 'dim') + '">' + (precip != null ? (precip > 0 ? '+' : '') + fmtNum(precip, 0) + '%' : '\u2014') + '</td></tr>';
});
h += '</tbody></table>';
}
}
// ── AGI WATCH ──
h += '<div class="sh">AGI WATCH</div>';
if (data.ai_watch && !data.ai_watch.error) {
var aiw = data.ai_watch;
var labTrend = aiw.lab_trending || [];
if (labTrend.length) {
h += '<div class="sub">Lab Activity</div><div class="tags">';
labTrend.slice(0, 12).forEach(function(l) {
h += '<span class="tag' + (l.mentions > 3 ? ' tag-hot' : '') + '">' + esc(l.lab) + ' (' + l.mentions + ')</span>';
});
h += '</div>';
}
var byCat = aiw.by_category || {};
if (Object.keys(byCat).length) {
h += '<div class="mini-row">';
for (var catKey in byCat) {
if (byCat.hasOwnProperty(catKey)) {
h += '<div class="mini-box"><div class="v">' + byCat[catKey] + '</div><div class="l">' + esc(catKey) + '</div></div>';
}
}
h += '</div>';
}
var aiItems = aiw.items || [];
if (aiItems.length) {
h += '<table class="dtable"><thead><tr><th>Paper/Post</th><th>Source</th><th>Age</th></tr></thead><tbody>';
aiItems.slice(0, 12).forEach(function(item) {
h += '<tr><td><a href="' + esc(item.link || '#') + '" target="_blank">' + esc(trunc(item.title || '?', 40)) + '</a></td><td class="dim">' + esc(item.feed_name || '\u2014') + '</td><td class="dim">' + ago(item.published) + '</td></tr>';
});
h += '</tbody></table>';
}
} else {
h += '<div class="dim" style="font-size:0.7rem;padding:4px 0">Loading AI feeds...</div>';
}
$('#drawerBody').innerHTML = safe(h);
}
@@ -949,7 +1013,7 @@ function updateTicker(data) {
var articles = data.news_feed.articles || data.news_feed.items || [];
if (!articles.length) return;
var items = articles.slice(0, 30).map(function(a) {
return '<span class="ticker-item">' + esc(trunc(a.title || '?', 70)) + '<span class="src">' + esc(a.source || a.feed || '') + '</span></span>';
return '<span class="ticker-item">' + esc(trunc(a.title || '?', 70)) + '<span class="src">' + esc(a.feed_name || a.source || a.feed || '') + '</span></span>';
});
// Duplicate for seamless loop
var all = items.join('<span class="ticker-item sep">\u2022</span>');
+189
View File
@@ -0,0 +1,189 @@
"""AI/AGI development tracking source for world-intel-mcp.
Monitors the latest AI research publications, model releases, and
industry developments via RSS feeds from arXiv, Hugging Face, and
major AI news outlets. No API keys required.
"""
import asyncio
import logging
from datetime import datetime, timezone
from ..fetcher import Fetcher
try:
import feedparser
except ImportError:
feedparser = None # type: ignore[assignment]
logger = logging.getLogger("world-intel-mcp.sources.ai_watch")
# ---------------------------------------------------------------------------
# Feed sources
# ---------------------------------------------------------------------------
_AI_FEEDS: list[tuple[str, str, str]] = [
# (name, url, category)
("arXiv cs.AI", "https://rss.arxiv.org/rss/cs.AI", "research"),
("arXiv cs.LG", "https://rss.arxiv.org/rss/cs.LG", "research"),
("arXiv cs.CL", "https://rss.arxiv.org/rss/cs.CL", "research"),
("HuggingFace Blog", "https://huggingface.co/blog/feed.xml", "industry"),
("The Gradient", "https://thegradient.pub/rss/", "analysis"),
("Import AI", "https://importai.substack.com/feed", "newsletter"),
]
# Key AI labs to track mentions of
_AI_LABS = [
"openai", "anthropic", "google", "deepmind", "meta", "mistral",
"xai", "cohere", "stability", "midjourney", "nvidia", "microsoft",
"apple", "hugging face", "databricks", "together", "groq",
]
_CACHE_TTL = 600 # 10 minutes
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_published(entry: dict) -> str | None:
"""Parse an RSS entry's published date to ISO 8601 UTC string."""
import time as _time
parsed_tuple = entry.get("published_parsed")
if parsed_tuple is not None:
try:
epoch = _time.mktime(parsed_tuple[:9])
dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
except (ValueError, TypeError, OverflowError):
pass
updated_tuple = entry.get("updated_parsed")
if updated_tuple is not None:
try:
epoch = _time.mktime(updated_tuple[:9])
dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
except (ValueError, TypeError, OverflowError):
pass
return entry.get("published") or entry.get("updated")
def _extract_lab_mentions(text: str) -> list[str]:
"""Extract AI lab names mentioned in text."""
lower = text.lower()
return [lab for lab in _AI_LABS if lab in lower]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def fetch_ai_watch(
fetcher: Fetcher,
limit: int = 50,
) -> dict:
"""Fetch latest AI/AGI developments from research and industry feeds.
Aggregates recent papers, blog posts, and announcements from key
AI sources, sorted by recency. Extracts lab mentions for trend
tracking.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
limit: Maximum number of items to return.
Returns:
Dict with items list, lab mention counts, source counts, and metadata.
"""
if feedparser is None:
return {
"error": "feedparser not installed — run: pip install feedparser",
"items": [],
"count": 0,
}
all_items: list[dict] = []
async def _fetch_feed(
name: str, url: str, category: str,
) -> list[dict]:
safe_name = name.lower().replace(" ", "_").replace(".", "_")
xml_text = await fetcher.get_xml(
url,
source=f"ai_watch:{safe_name}",
cache_key=f"ai_watch:rss:{safe_name}",
cache_ttl=_CACHE_TTL,
)
if xml_text is None:
logger.debug("No data from AI feed %s", name)
return []
parsed = feedparser.parse(xml_text)
items: list[dict] = []
for entry in parsed.get("entries", [])[:30]:
title = entry.get("title", "")
summary = entry.get("summary") or entry.get("description") or ""
combined_text = f"{title} {summary}"
items.append({
"title": title,
"link": entry.get("link", ""),
"published": _parse_published(entry),
"summary": summary[:200] if len(summary) > 200 else summary,
"feed_name": name,
"category": category,
"lab_mentions": _extract_lab_mentions(combined_text),
})
return items
# Fetch all feeds in parallel
tasks = [_fetch_feed(name, url, cat) for name, url, cat in _AI_FEEDS]
results = await asyncio.gather(*tasks)
for items in results:
all_items.extend(items)
# Sort by published date descending
all_items.sort(
key=lambda item: item.get("published") or "",
reverse=True,
)
all_items = all_items[:limit]
# Compute lab mention counts
lab_counts: dict[str, int] = {}
for item in all_items:
for lab in item.get("lab_mentions", []):
lab_counts[lab] = lab_counts.get(lab, 0) + 1
# Sort by count descending
lab_trending = sorted(
[{"lab": k, "mentions": v} for k, v in lab_counts.items()],
key=lambda x: x["mentions"],
reverse=True,
)
# Count by category
by_category: dict[str, int] = {}
for item in all_items:
cat = item.get("category", "other")
by_category[cat] = by_category.get(cat, 0) + 1
return {
"items": all_items,
"count": len(all_items),
"lab_trending": lab_trending,
"by_category": by_category,
"feeds_used": len(_AI_FEEDS),
"source": "ai-watch",
"timestamp": _utc_now_iso(),
}
+3 -4
View File
@@ -27,10 +27,9 @@ logger = logging.getLogger("world-intel-mcp.sources.news")
_RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
"geopolitics": [
("Reuters World", "https://feeds.reuters.com/Reuters/worldNews"),
("AP Top News", "https://rsshub.app/apnews/topics/apf-topnews"),
("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"),
("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"),
("AP Top News", "https://rsshub.app/apnews/topics/apf-topnews"),
],
"security": [
("BleepingComputer", "https://www.bleepingcomputer.com/feed/"),
@@ -51,7 +50,7 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
"military": [
("Defense One", "https://www.defenseone.com/rss/"),
("War on the Rocks", "https://warontherocks.com/feed/"),
("The War Zone", "https://www.thedrive.com/the-war-zone/feed"),
("The War Zone", "https://www.twz.com/feed"),
],
"science": [
("Nature", "https://www.nature.com/nature.rss"),
@@ -188,7 +187,7 @@ async def fetch_news_feed(
safe_name = feed_name.lower().replace(" ", "_")
xml_text = await fetcher.get_xml(
url,
source="rss",
source=f"rss:{safe_name}",
cache_key=f"news:rss:{safe_name}",
cache_ttl=300,
)
@@ -0,0 +1,185 @@
"""Space weather and solar activity source for world-intel-mcp.
Provides real-time solar activity monitoring via NOAA's Space Weather
Prediction Center (SWPC). No API key required.
Data includes:
- Solar flare activity (X-ray flux class)
- Geomagnetic storm indices (Kp, Dst)
- Solar wind speed and density
- Coronal mass ejection (CME) alerts
"""
import logging
from datetime import datetime, timezone
from ..fetcher import Fetcher
logger = logging.getLogger("world-intel-mcp.sources.space_weather")
# ---------------------------------------------------------------------------
# NOAA SWPC endpoints (all free, no API key)
# ---------------------------------------------------------------------------
_SWPC_BASE = "https://services.swpc.noaa.gov"
# 3-day solar/geomagnetic forecast
_FORECAST_URL = f"{_SWPC_BASE}/products/noaa-planetary-k-index-forecast.json"
# Current planetary K-index (geomagnetic disturbance, 0-9)
_KP_URL = f"{_SWPC_BASE}/products/noaa-planetary-k-index.json"
# Recent solar flares (R1-R5 scale)
_FLARE_URL = f"{_SWPC_BASE}/json/goes/primary/xrays-6-hour.json"
# Solar wind real-time plasma data
_PLASMA_URL = f"{_SWPC_BASE}/products/solar-wind/plasma-7-day.json"
# Alerts and warnings
_ALERTS_URL = f"{_SWPC_BASE}/products/alerts.json"
_CACHE_TTL = 600 # 10 minutes
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _classify_kp(kp: float) -> str:
"""Classify Kp index into storm level."""
if kp >= 9:
return "G5 Extreme"
elif kp >= 8:
return "G4 Severe"
elif kp >= 7:
return "G3 Strong"
elif kp >= 6:
return "G2 Moderate"
elif kp >= 5:
return "G1 Minor"
elif kp >= 4:
return "Active"
else:
return "Quiet"
def _classify_xray(flux: float) -> str:
"""Classify X-ray flux into flare class (A, B, C, M, X)."""
if flux >= 1e-4:
return f"X{flux / 1e-4:.1f}"
elif flux >= 1e-5:
return f"M{flux / 1e-5:.1f}"
elif flux >= 1e-6:
return f"C{flux / 1e-6:.1f}"
elif flux >= 1e-7:
return f"B{flux / 1e-7:.1f}"
else:
return "A"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def fetch_space_weather(fetcher: Fetcher) -> dict:
"""Fetch current space weather conditions from NOAA SWPC.
Returns a composite view of solar and geomagnetic activity including
current Kp index, latest X-ray flux class, solar wind speed, and
any active alerts/warnings.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
Returns:
Dict with current conditions, alerts, forecast, and metadata.
"""
import asyncio
# Fetch all sources in parallel
kp_data, flare_data, alerts_data = await asyncio.gather(
fetcher.get_json(
_KP_URL,
source="swpc",
cache_key="space:kp",
cache_ttl=_CACHE_TTL,
),
fetcher.get_json(
_FLARE_URL,
source="swpc",
cache_key="space:xray",
cache_ttl=_CACHE_TTL,
),
fetcher.get_json(
_ALERTS_URL,
source="swpc",
cache_key="space:alerts",
cache_ttl=_CACHE_TTL,
),
)
result: dict = {
"current_kp": None,
"kp_level": "Unknown",
"latest_flare_class": None,
"solar_wind_speed_km_s": None,
"alerts": [],
"kp_recent": [],
"source": "noaa-swpc",
"timestamp": _utc_now_iso(),
}
# --- Kp index ---
if kp_data and isinstance(kp_data, list) and len(kp_data) > 1:
# First row is header, rest are data [time_tag, Kp, ...]
try:
# Get most recent Kp reading
latest = kp_data[-1]
kp_val = float(latest[1])
result["current_kp"] = kp_val
result["kp_level"] = _classify_kp(kp_val)
# Last 8 readings (24 hours of 3-hourly data)
recent = []
for row in kp_data[-9:-1]: # skip header
if isinstance(row, list) and len(row) >= 2:
try:
recent.append({
"time": row[0],
"kp": float(row[1]),
})
except (ValueError, TypeError, IndexError):
pass
result["kp_recent"] = recent
except (ValueError, TypeError, IndexError) as exc:
logger.warning("Failed to parse Kp data: %s", exc)
# --- X-ray flux (flare activity) ---
if flare_data and isinstance(flare_data, list) and len(flare_data) > 1:
try:
# Last entry has the most recent flux reading
latest_flare = flare_data[-1]
if isinstance(latest_flare, dict):
flux = latest_flare.get("flux")
if flux is not None:
result["latest_flare_class"] = _classify_xray(float(flux))
except (ValueError, TypeError, KeyError) as exc:
logger.warning("Failed to parse X-ray flux: %s", exc)
# --- Alerts ---
if alerts_data and isinstance(alerts_data, list):
alerts = []
for alert in alerts_data[:10]:
if isinstance(alert, dict):
alerts.append({
"issue_datetime": alert.get("issue_datetime"),
"message": (alert.get("message") or "")[:200],
"product_id": alert.get("product_id"),
})
result["alerts"] = alerts
return result