diff --git a/src/world_intel_mcp/dashboard/app.py b/src/world_intel_mcp/dashboard/app.py index 00d5577..6b1412d 100644 --- a/src/world_intel_mcp/dashboard/app.py +++ b/src/world_intel_mcp/dashboard/app.py @@ -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): diff --git a/src/world_intel_mcp/dashboard/index.html b/src/world_intel_mcp/dashboard/index.html index cee2785..90d29cf 100644 --- a/src/world_intel_mcp/dashboard/index.html +++ b/src/world_intel_mcp/dashboard/index.html @@ -704,6 +704,13 @@ function updateHudStats(data) { if (data.displacement && !data.displacement.error && data.displacement.global_totals) { pills.push('
' + fmtBigPlain(data.displacement.global_totals.grand_total || 0) + 'Displaced
'); } + if (data.space_weather && !data.space_weather.error && data.space_weather.current_kp != null) { + var swKp = data.space_weather.current_kp; + pills.push('
' + swKp.toFixed(0) + 'Kp
'); + } + if (data.ai_watch && !data.ai_watch.error) { + pills.push('
' + (data.ai_watch.count || 0) + 'AI Papers
'); + } $('#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 += '
Energy
'; - entries.slice(0, 6).forEach(function(item) { - var v = item.value || item.price || item.last_value; - h += ''; + 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 += '
Energy
' + esc((item.name || '?').replace(/_/g, ' ')) + '' + (typeof v === 'number' ? fmtNum(v) : esc(String(v || '\u2014'))) + '
'; + eRows.forEach(function(item) { + h += ''; }); h += '
CommodityPriceDate
' + esc(item.name) + '$' + fmtNum(item.price) + '' + esc(item.date || '\u2014') + '
'; } @@ -860,6 +871,25 @@ function updateDrawer(data) { } } + if (data.space_weather && !data.space_weather.error) { + var sw = data.space_weather; + h += '
Space Weather
'; + var kpVal = sw.current_kp; + var kpCls = kpVal >= 7 ? ' crit' : kpVal >= 5 ? ' warn' : ''; + h += '
' + (kpVal != null ? fmtNum(kpVal, 1) : '\u2014') + '
Kp Index
'; + h += '
' + esc(sw.kp_level || '\u2014') + '
Geo Level
'; + h += '
' + esc(sw.latest_flare_class || '\u2014') + '
X-Ray Flux
'; + h += '
'; + var swAlerts = sw.alerts || []; + if (swAlerts.length) { + h += ''; + swAlerts.slice(0, 5).forEach(function(a) { + h += ''; + }); + h += '
AlertTime
' + esc(trunc(a.message || '?', 50)) + '' + esc(ago(a.issue_datetime)) + '
'; + } + } + // ── INTELLIGENCE ── h += '
INTELLIGENCE
'; if (data.trending_keywords && !data.trending_keywords.error) { @@ -907,7 +937,7 @@ function updateDrawer(data) { if (origins.length) { h += ''; origins.slice(0, 8).forEach(function(o) { - h += ''; + h += ''; }); h += '
OriginRefugeesIDPs
' + esc(o.country_name || o.country || '?') + '' + fmtBigPlain(o.refugees || 0) + '' + fmtBigPlain(o.idps || 0) + '
' + esc(o.country_name || o.country || '?') + '' + fmtBigPlain(o.refugees || 0) + '' + fmtBigPlain(o.internally_displaced || o.idps || 0) + '
'; } @@ -931,14 +961,48 @@ function updateDrawer(data) { if (anomalies.length) { h += '
Climate Anomalies
'; 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 += ''; + 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 += ''; }); h += '
ZoneTempPrecip
' + esc(a.zone || a.name || a.region || '?') + '' + (temp != null ? (temp > 0 ? '+' : '') + fmtNum(temp, 1) + '\u00B0C' : '\u2014') + '' + (precip != null ? fmtNum(precip, 1) + 'mm' : '\u2014') + '
' + esc(a.zone || a.name || a.region || '?') + '' + (temp != null ? (temp > 0 ? '+' : '') + fmtNum(temp, 1) + '\u00B0C' : '\u2014') + '' + (precip != null ? (precip > 0 ? '+' : '') + fmtNum(precip, 0) + '%' : '\u2014') + '
'; } } + // ── AGI WATCH ── + h += '
AGI WATCH
'; + if (data.ai_watch && !data.ai_watch.error) { + var aiw = data.ai_watch; + var labTrend = aiw.lab_trending || []; + if (labTrend.length) { + h += '
Lab Activity
'; + labTrend.slice(0, 12).forEach(function(l) { + h += '' + esc(l.lab) + ' (' + l.mentions + ')'; + }); + h += '
'; + } + var byCat = aiw.by_category || {}; + if (Object.keys(byCat).length) { + h += '
'; + for (var catKey in byCat) { + if (byCat.hasOwnProperty(catKey)) { + h += '
' + byCat[catKey] + '
' + esc(catKey) + '
'; + } + } + h += '
'; + } + var aiItems = aiw.items || []; + if (aiItems.length) { + h += ''; + aiItems.slice(0, 12).forEach(function(item) { + h += ''; + }); + h += '
Paper/PostSourceAge
' + esc(trunc(item.title || '?', 40)) + '' + esc(item.feed_name || '\u2014') + '' + ago(item.published) + '
'; + } + } else { + h += '
Loading AI feeds...
'; + } + $('#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 '' + esc(trunc(a.title || '?', 70)) + '' + esc(a.source || a.feed || '') + ''; + return '' + esc(trunc(a.title || '?', 70)) + '' + esc(a.feed_name || a.source || a.feed || '') + ''; }); // Duplicate for seamless loop var all = items.join('\u2022'); diff --git a/src/world_intel_mcp/sources/ai_watch.py b/src/world_intel_mcp/sources/ai_watch.py new file mode 100644 index 0000000..4c51906 --- /dev/null +++ b/src/world_intel_mcp/sources/ai_watch.py @@ -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(), + } diff --git a/src/world_intel_mcp/sources/news.py b/src/world_intel_mcp/sources/news.py index 14e274d..bd31376 100644 --- a/src/world_intel_mcp/sources/news.py +++ b/src/world_intel_mcp/sources/news.py @@ -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, ) diff --git a/src/world_intel_mcp/sources/space_weather.py b/src/world_intel_mcp/sources/space_weather.py new file mode 100644 index 0000000..0f851f0 --- /dev/null +++ b/src/world_intel_mcp/sources/space_weather.py @@ -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