feat: add health, sanctions, elections, shipping, social, nuclear intelligence — Phase 7 (+10 = 55 tools)

New source modules: disease outbreaks (WHO/ProMED/CIDRAP), OFAC sanctions
search, election calendar with risk scoring, shipping stress index,
Reddit social signals, nuclear test site seismic monitor. Cross-domain
alert digest and weekly trend analysis. Registers orphaned space_weather
and ai_watch modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Shade
2026-02-23 20:06:05 -05:00
co-authored by Claude Opus 4.6
parent 9f7a81812b
commit d5bf09f342
9 changed files with 1406 additions and 2 deletions
+314
View File
@@ -0,0 +1,314 @@
"""Alert digest and weekly trends analysis for world-intel-mcp.
Provides cross-domain alert aggregation (intel_alert_digest) and
temporal trend analysis (intel_weekly_trends). Both use lazy imports
to avoid circular dependencies with source modules.
"""
import asyncio
import logging
import math
import sqlite3
from datetime import datetime, timezone
logger = logging.getLogger("world-intel-mcp.analysis.alerts")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
async def _safe_fetch(coro, label: str) -> dict:
"""Run a coroutine safely, returning empty dict on failure."""
try:
return await coro
except Exception as exc:
logger.warning("Alert digest: %s failed: %s", label, exc)
return {}
# ---------------------------------------------------------------------------
# Alert Digest
# ---------------------------------------------------------------------------
_ALERT_THRESHOLDS: dict[str, dict] = {
"space_weather": {
"field": "current_kp",
"threshold": 5.0,
"priority": "high",
"domain": "space",
"message_template": "Geomagnetic storm: Kp={value} ({level})",
},
"instability": {
"field": "countries",
"threshold": 70,
"priority": "critical",
"domain": "political",
"message_template": "{count} countries above instability threshold",
},
"military_surge": {
"field": "surge_count",
"threshold": 1,
"priority": "high",
"domain": "military",
"message_template": "{count} military surge anomalies detected",
},
"cable_health": {
"field": "corridors",
"threshold": 2,
"priority": "high",
"domain": "infrastructure",
"message_template": "{count} cable corridors at risk",
},
"hotspot_escalation": {
"field": "hotspots",
"threshold": 60,
"priority": "critical",
"domain": "security",
"message_template": "{count} hotspots above escalation threshold",
},
"internet_outages": {
"field": "outage_count",
"threshold": 5,
"priority": "medium",
"domain": "infrastructure",
"message_template": "{count} internet outages active",
},
"shipping_stress": {
"field": "stress_score",
"threshold": 30.0,
"priority": "medium",
"domain": "economic",
"message_template": "Shipping stress index at {value}",
},
}
async def fetch_alert_digest(fetcher) -> dict:
"""Aggregate alerts from 7 intelligence domains.
Calls existing source functions in parallel, applies threshold-based
alerting, and returns a prioritized alert list.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
Returns:
Dict with alerts, alert_count, by_priority, domains_checked, source.
"""
# Lazy imports to avoid circular deps
from ..sources import space_weather, infrastructure
from ..sources import intelligence
from ..sources import shipping
# Fetch all sources in parallel
(
sw_data,
instability_data,
surge_data,
cable_data,
hotspot_data,
outage_data,
shipping_data,
) = await asyncio.gather(
_safe_fetch(space_weather.fetch_space_weather(fetcher), "space_weather"),
_safe_fetch(intelligence.fetch_instability_index(fetcher), "instability"),
_safe_fetch(intelligence.fetch_military_surge(fetcher), "military_surge"),
_safe_fetch(infrastructure.fetch_cable_health(fetcher), "cable_health"),
_safe_fetch(intelligence.fetch_hotspot_escalation(fetcher), "hotspot_escalation"),
_safe_fetch(infrastructure.fetch_internet_outages(fetcher), "internet_outages"),
_safe_fetch(shipping.fetch_shipping_index(fetcher), "shipping"),
)
alerts: list[dict] = []
# Space weather: Kp >= 5
kp = sw_data.get("current_kp")
if kp is not None and kp >= _ALERT_THRESHOLDS["space_weather"]["threshold"]:
alerts.append({
"domain": "space",
"priority": "high",
"message": f"Geomagnetic storm: Kp={kp} ({sw_data.get('kp_level', 'Unknown')})",
"value": kp,
})
# Instability: countries above threshold
countries = instability_data.get("countries", [])
high_instability = [c for c in countries if c.get("instability_index", 0) >= 70]
if high_instability:
alerts.append({
"domain": "political",
"priority": "critical",
"message": f"{len(high_instability)} countries above instability threshold (>=70)",
"countries": [c.get("country_name", c.get("country_code")) for c in high_instability[:5]],
"value": len(high_instability),
})
# Military surge
surge_count = surge_data.get("surge_count", 0)
if surge_count >= 1:
alerts.append({
"domain": "military",
"priority": "high",
"message": f"{surge_count} military surge anomalies detected",
"surges": surge_data.get("surges", [])[:3],
"value": surge_count,
})
# Cable health: corridors with status_score >= 2
corridors = cable_data.get("corridors", {})
at_risk = [
name for name, info in corridors.items()
if isinstance(info, dict) and info.get("status_score", 0) >= 2
]
if at_risk:
alerts.append({
"domain": "infrastructure",
"priority": "high",
"message": f"{len(at_risk)} cable corridors at elevated risk: {', '.join(at_risk[:3])}",
"corridors": at_risk,
"value": len(at_risk),
})
# Hotspot escalation: hotspots with score >= 60
hotspots = hotspot_data.get("hotspots", [])
hot = [h for h in hotspots if h.get("score", 0) >= 60]
if hot:
alerts.append({
"domain": "security",
"priority": "critical",
"message": f"{len(hot)} hotspots above escalation threshold",
"hotspots": [h.get("name") for h in hot[:5]],
"value": len(hot),
})
# Internet outages
outage_count = outage_data.get("outage_count", 0)
if outage_count >= 5:
alerts.append({
"domain": "infrastructure",
"priority": "medium",
"message": f"{outage_count} internet outages active",
"value": outage_count,
})
# Shipping stress
stress = shipping_data.get("stress_score", 0)
if stress >= 30:
alerts.append({
"domain": "economic",
"priority": "medium" if stress < 60 else "high",
"message": f"Shipping stress index at {stress} ({shipping_data.get('assessment', 'unknown')})",
"value": stress,
})
# Sort: critical > high > medium
priority_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
alerts.sort(key=lambda a: priority_order.get(a.get("priority", "low"), 4))
# Group by priority
by_priority: dict[str, int] = {}
for a in alerts:
p = a.get("priority", "unknown")
by_priority[p] = by_priority.get(p, 0) + 1
return {
"alerts": alerts,
"alert_count": len(alerts),
"by_priority": by_priority,
"domains_checked": [
"space_weather", "instability", "military_surge",
"cable_health", "hotspot_escalation", "internet_outages",
"shipping_stress",
],
"source": "alert-digest",
"timestamp": _utc_now_iso(),
}
# ---------------------------------------------------------------------------
# Weekly Trends
# ---------------------------------------------------------------------------
async def fetch_weekly_trends(fetcher) -> dict:
"""Analyze weekly trends from temporal baselines.
Reads the TemporalBaseline SQLite database to compute volatility
(coefficient of variation) for each tracked metric, and calls
temporal_anomalies to get current deviations.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
Returns:
Dict with trends, trend_count, analysis_period, source.
"""
from ..analysis.temporal import TemporalBaseline, _DB_PATH
from ..sources import intelligence
now = datetime.now(timezone.utc)
# Get current anomalies
anomaly_data = await _safe_fetch(
intelligence.fetch_temporal_anomalies(fetcher), "temporal_anomalies"
)
# Read baselines from SQLite
trends: list[dict] = []
try:
conn = sqlite3.connect(_DB_PATH)
rows = conn.execute(
"SELECT key, count, mean, m2, updated_at FROM baselines WHERE count >= 5"
).fetchall()
conn.close()
for key, n, mean, m2, updated_at in rows:
parts = key.split(":")
if len(parts) < 2:
continue
event_type = parts[0]
region = parts[1]
weekday = parts[2] if len(parts) > 2 else ""
month = parts[3] if len(parts) > 3 else ""
# Compute coefficient of variation (volatility)
variance = m2 / (n - 1) if n > 1 else 0.0
std = math.sqrt(variance) if variance > 0 else 0.0
cv = (std / mean * 100) if mean > 0 else 0.0
trends.append({
"metric": event_type,
"region": region,
"weekday": weekday,
"month": month,
"observations": n,
"mean": round(mean, 2),
"std_dev": round(std, 2),
"volatility_cv": round(cv, 1),
"last_updated": updated_at,
})
except (sqlite3.Error, OSError) as exc:
logger.warning("Failed to read temporal baselines: %s", exc)
# Sort by volatility descending
trends.sort(key=lambda t: t.get("volatility_cv", 0), reverse=True)
# Attach current anomalies
current_anomalies = anomaly_data.get("anomalies", [])
return {
"trends": trends[:50],
"trend_count": len(trends),
"current_anomalies": current_anomalies,
"current_anomaly_count": len(current_anomalies),
"analysis_period": "weekly (by weekday+month seasonality)",
"source": "temporal-weekly-trends",
"timestamp": _utc_now_iso(),
}
+63
View File
@@ -108,3 +108,66 @@ def match_country_by_name(name: str) -> str | None:
if keyword in lower or lower in keyword:
return iso3
return None
# ---------------------------------------------------------------------------
# Election calendar
# ---------------------------------------------------------------------------
UPCOMING_ELECTIONS: list[dict] = [
{"country": "Germany", "iso3": "DEU", "election_type": "federal", "date": "2025-02-23", "description": "Federal election (Bundestag)", "instability_impact": "low"},
{"country": "Ecuador", "iso3": "ECU", "election_type": "presidential", "date": "2025-02-09", "description": "Presidential runoff", "instability_impact": "medium"},
{"country": "Belarus", "iso3": "BLR", "election_type": "presidential", "date": "2025-01-26", "description": "Presidential election", "instability_impact": "high"},
{"country": "Canada", "iso3": "CAN", "election_type": "federal", "date": "2025-10-20", "description": "Federal election", "instability_impact": "low"},
{"country": "Iraq", "iso3": "IRQ", "election_type": "parliamentary", "date": "2025-10-01", "description": "Parliamentary election", "instability_impact": "high"},
{"country": "Chile", "iso3": "CHL", "election_type": "presidential", "date": "2025-11-16", "description": "Presidential election", "instability_impact": "medium"},
{"country": "Poland", "iso3": "POL", "election_type": "presidential", "date": "2025-05-18", "description": "Presidential election", "instability_impact": "low"},
{"country": "Philippines", "iso3": "PHL", "election_type": "midterm", "date": "2025-05-12", "description": "Midterm elections", "instability_impact": "medium"},
{"country": "Singapore", "iso3": "SGP", "election_type": "general", "date": "2025-05-03", "description": "General election", "instability_impact": "low"},
{"country": "Australia", "iso3": "AUS", "election_type": "federal", "date": "2025-05-17", "description": "Federal election", "instability_impact": "low"},
{"country": "South Korea", "iso3": "KOR", "election_type": "presidential", "date": "2025-06-03", "description": "Snap presidential election", "instability_impact": "medium"},
{"country": "Ivory Coast", "iso3": "CIV", "election_type": "presidential", "date": "2025-10-01", "description": "Presidential election", "instability_impact": "high"},
{"country": "Norway", "iso3": "NOR", "election_type": "parliamentary", "date": "2025-09-08", "description": "Parliamentary election", "instability_impact": "low"},
{"country": "United States", "iso3": "USA", "election_type": "midterm", "date": "2026-11-03", "description": "Midterm elections", "instability_impact": "medium"},
{"country": "Brazil", "iso3": "BRA", "election_type": "municipal", "date": "2026-10-04", "description": "Municipal elections", "instability_impact": "medium"},
{"country": "Mexico", "iso3": "MEX", "election_type": "midterm", "date": "2027-06-06", "description": "Midterm elections", "instability_impact": "medium"},
{"country": "France", "iso3": "FRA", "election_type": "presidential", "date": "2027-04-10", "description": "Presidential election", "instability_impact": "medium"},
{"country": "India", "iso3": "IND", "election_type": "general", "date": "2029-04-01", "description": "General election (projected)", "instability_impact": "medium"},
]
def get_election_risk(iso3: str) -> dict | None:
"""Return the next upcoming election for a country with days_until."""
from datetime import date
today = date.today()
upper = iso3.upper()
best: dict | None = None
best_days: int = 999999
for entry in UPCOMING_ELECTIONS:
if entry["iso3"] != upper:
continue
try:
election_date = date.fromisoformat(entry["date"])
except ValueError:
continue
days_until = (election_date - today).days
if days_until < best_days:
best_days = days_until
best = {**entry, "days_until": days_until}
return best
# ---------------------------------------------------------------------------
# Nuclear test sites
# ---------------------------------------------------------------------------
NUCLEAR_TEST_SITES: list[dict] = [
{"name": "Punggye-ri", "country": "North Korea", "iso3": "PRK", "lat": 41.28, "lon": 129.08, "status": "active", "last_test": "2017-09-03", "notes": "DPRK primary test site, 6 tests conducted"},
{"name": "Lop Nur", "country": "China", "iso3": "CHN", "lat": 41.75, "lon": 88.35, "status": "dormant", "last_test": "1996-07-29", "notes": "Chinese test site, 45 tests"},
{"name": "Novaya Zemlya", "country": "Russia", "iso3": "RUS", "lat": 73.37, "lon": 54.78, "status": "dormant", "last_test": "1990-10-24", "notes": "Soviet/Russian arctic test site, Tsar Bomba"},
{"name": "Nevada NTS", "country": "United States", "iso3": "USA", "lat": 37.07, "lon": -116.05, "status": "dormant_reference", "last_test": "1992-09-23", "notes": "US primary test site, 928 tests"},
{"name": "Semipalatinsk", "country": "Kazakhstan", "iso3": "KAZ", "lat": 50.07, "lon": 78.43, "status": "closed", "last_test": "1989-10-19", "notes": "Soviet test site, 456 tests, closed 1991"},
]
+158 -2
View File
@@ -3,7 +3,7 @@
World Intelligence MCP Server
==============================
Real-time global intelligence across 17 domains:
Real-time global intelligence across 23 domains:
financial markets, economic indicators, earthquakes, wildfires,
conflict, military flights, infrastructure, and more.
@@ -13,6 +13,7 @@ Phase 3: News, Intelligence, Prediction, Displacement, Aviation, Cyber (+9 = 33
Phase 4: Reports — daily brief, country dossier, threat landscape (+3 = 36 tools).
Phase 5: Analysis — focal points, signal summary, temporal anomalies, CII v2 (+3 = 39 tools).
Phase 6: Military & infrastructure intelligence (+6 = 45 tools).
Phase 7: Health, sanctions, elections, shipping, social, nuclear, alerts, trends (+10 = 55 tools).
"""
import asyncio
@@ -28,7 +29,7 @@ from mcp.types import Tool, TextContent
from .cache import Cache
from .circuit_breaker import CircuitBreaker
from .fetcher import Fetcher
from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber
from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear
from .reports import generator as report_gen
logging.basicConfig(
@@ -480,6 +481,99 @@ TOOLS: list[Tool] = [
},
},
),
# --- Space Weather (1 tool) ---
Tool(
name="intel_space_weather",
description="Get solar activity: Kp geomagnetic index, X-ray flare class, solar wind, and SWPC alerts from NOAA.",
inputSchema={"type": "object", "properties": {}},
),
# --- AI Watch (1 tool) ---
Tool(
name="intel_ai_releases",
description="Track AI/AGI developments from arXiv, HuggingFace, and AI news feeds. Lab mention trending.",
inputSchema={
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Max items (default 50)", "default": 50},
},
},
),
# --- Health (1 tool) ---
Tool(
name="intel_disease_outbreaks",
description="Aggregate disease outbreak alerts from WHO DON, ProMED, and CIDRAP. Flags high-concern pathogens (Ebola, H5N1, mpox, etc.).",
inputSchema={
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Max items (default 50)", "default": 50},
},
},
),
# --- Sanctions (1 tool) ---
Tool(
name="intel_sanctions_search",
description="Search the US Treasury OFAC Specially Designated Nationals (SDN) sanctions list. Substring match on name, country, program.",
inputSchema={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Name substring to search"},
"country": {"type": "string", "description": "Country filter"},
"program": {"type": "string", "description": "Sanctions program filter"},
"limit": {"type": "integer", "description": "Max results (default 50)", "default": 50},
},
},
),
# --- Elections (1 tool) ---
Tool(
name="intel_election_calendar",
description="Get upcoming global election calendar with proximity-based instability risk scoring. Covers 2025-2029.",
inputSchema={
"type": "object",
"properties": {
"country": {"type": "string", "description": "ISO-3 code or country name filter"},
},
},
),
# --- Shipping (1 tool) ---
Tool(
name="intel_shipping_index",
description="Compute shipping stress index from dry bulk ETFs (BDRY, SBLK, EGLE, ZIM). Stress score 0-100.",
inputSchema={"type": "object", "properties": {}},
),
# --- Social (1 tool) ---
Tool(
name="intel_social_signals",
description="Monitor geopolitical discussion velocity on Reddit (r/worldnews, r/geopolitics). Engagement metrics and trending posts.",
inputSchema={
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Max posts per subreddit (default 25)", "default": 25},
},
},
),
# --- Nuclear (1 tool) ---
Tool(
name="intel_nuclear_monitor",
description="Monitor seismic activity near 5 known nuclear test sites (Punggye-ri, Lop Nur, Novaya Zemlya, Nevada NTS, Semipalatinsk). Concern scoring based on depth, magnitude, distance.",
inputSchema={
"type": "object",
"properties": {
"hours": {"type": "integer", "description": "Lookback hours (default 72)", "default": 72},
},
},
),
# --- Alert Digest (1 tool) ---
Tool(
name="intel_alert_digest",
description="Cross-domain alert aggregation from 7 intelligence sources: space weather, instability, military surge, cable health, hotspot escalation, internet outages, shipping stress. Threshold-based prioritized alerts.",
inputSchema={"type": "object", "properties": {}},
),
# --- Weekly Trends (1 tool) ---
Tool(
name="intel_weekly_trends",
description="Analyze weekly trends from temporal baselines. Reports volatility (coefficient of variation) and current anomalies across all tracked metrics.",
inputSchema={"type": "object", "properties": {}},
),
# --- System (1 tool) ---
Tool(
name="intel_status",
@@ -651,6 +745,60 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
fetcher, corridor=arguments.get("corridor"),
)
# Space Weather
case "intel_space_weather":
return await space_weather.fetch_space_weather(fetcher)
# AI Watch
case "intel_ai_releases":
return await ai_watch.fetch_ai_watch(fetcher, limit=arguments.get("limit", 50))
# Health
case "intel_disease_outbreaks":
return await health.fetch_disease_outbreaks(fetcher, limit=arguments.get("limit", 50))
# Sanctions
case "intel_sanctions_search":
return await sanctions.fetch_sanctions_search(
fetcher,
query=arguments.get("query", ""),
country=arguments.get("country"),
program=arguments.get("program"),
limit=arguments.get("limit", 50),
)
# Elections
case "intel_election_calendar":
return await elections.fetch_election_calendar(
fetcher, country=arguments.get("country"),
)
# Shipping
case "intel_shipping_index":
return await shipping.fetch_shipping_index(fetcher)
# Social
case "intel_social_signals":
return await social.fetch_social_signals(
fetcher, limit=arguments.get("limit", 25),
)
# Nuclear
case "intel_nuclear_monitor":
return await nuclear.fetch_nuclear_monitor(
fetcher, hours=arguments.get("hours", 72),
)
# Alert Digest
case "intel_alert_digest":
from .analysis.alerts import fetch_alert_digest
return await fetch_alert_digest(fetcher)
# Weekly Trends
case "intel_weekly_trends":
from .analysis.alerts import fetch_weekly_trends
return await fetch_weekly_trends(fetcher)
# Reports
case "intel_daily_brief":
return await report_gen.generate_daily_brief(output_dir=arguments.get("output_dir"))
@@ -682,6 +830,14 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
"displacement": ["unhcr"],
"aviation": ["faa"],
"cyber": ["feodo-tracker", "cisa-kev", "sans-dshield", "urlhaus"],
"space_weather": ["noaa-swpc"],
"ai_watch": ["arxiv", "huggingface", "ai-news-rss"],
"health": ["who-don", "promed", "cidrap"],
"sanctions": ["ofac-sdn"],
"elections": ["election-calendar"],
"shipping": ["yahoo-finance"],
"social": ["reddit-public"],
"nuclear": ["usgs-nuclear-monitor"],
},
}
+112
View File
@@ -0,0 +1,112 @@
"""Election calendar and risk scoring source for world-intel-mcp.
Pure data module — reads from config.countries.UPCOMING_ELECTIONS and
computes proximity-based risk scores. No I/O, no API keys.
"""
import logging
from datetime import date, datetime, timezone
from ..config.countries import UPCOMING_ELECTIONS
logger = logging.getLogger("world-intel-mcp.sources.elections")
# ---------------------------------------------------------------------------
# Risk scoring
# ---------------------------------------------------------------------------
_PROXIMITY_SCORES: list[tuple[int, int]] = [
(30, 100),
(90, 70),
(180, 40),
(365, 20),
]
_FALLBACK_PROXIMITY = 5
_IMPACT_MULTIPLIERS: dict[str, float] = {
"high": 1.5,
"medium": 1.0,
"low": 0.6,
}
def _proximity_score(days_until: int) -> int:
"""Score based on how close the election is."""
abs_days = abs(days_until)
for threshold, score in _PROXIMITY_SCORES:
if abs_days <= threshold:
return score
return _FALLBACK_PROXIMITY
def _compute_risk(days_until: int, impact: str) -> float:
"""Compute election risk: proximity_score * impact_multiplier."""
base = _proximity_score(days_until)
multiplier = _IMPACT_MULTIPLIERS.get(impact, 1.0)
return round(base * multiplier, 1)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def fetch_election_calendar(
fetcher: object, # noqa: ARG001 — kept for API consistency
country: str | None = None,
) -> dict:
"""Return the election calendar with proximity risk scoring.
Args:
fetcher: Unused (pure data), kept for dispatch consistency.
country: Optional ISO-3 code or country name filter.
Returns:
Dict with elections list, count, highest_risk entry, source.
"""
today = date.today()
now = datetime.now(timezone.utc)
elections: list[dict] = []
country_lower = country.lower().strip() if country else ""
for entry in UPCOMING_ELECTIONS:
# Filter
if country_lower:
if (
country_lower not in entry["country"].lower()
and country_lower != entry["iso3"].lower()
):
continue
try:
election_date = date.fromisoformat(entry["date"])
except ValueError:
continue
days_until = (election_date - today).days
risk_score = _compute_risk(days_until, entry["instability_impact"])
elections.append({
"country": entry["country"],
"iso3": entry["iso3"],
"election_type": entry["election_type"],
"date": entry["date"],
"description": entry["description"],
"days_until": days_until,
"status": "past" if days_until < 0 else "upcoming",
"instability_impact": entry["instability_impact"],
"risk_score": risk_score,
})
# Sort by risk_score descending
elections.sort(key=lambda e: e["risk_score"], reverse=True)
highest_risk = elections[0] if elections else None
return {
"elections": elections,
"count": len(elections),
"highest_risk": highest_risk,
"source": "election-calendar",
"timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
+154
View File
@@ -0,0 +1,154 @@
"""Disease outbreak monitoring source for world-intel-mcp.
Aggregates disease outbreak alerts from WHO Disease Outbreak News (DON),
ProMED-mail, and CIDRAP. Uses RSS/Atom feeds via feedparser. 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.health")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_HEALTH_FEEDS: list[tuple[str, str]] = [
("WHO DON", "https://www.who.int/feeds/entity/don/en/rss.xml"),
("ProMED", "https://promedmail.org/feed/"),
("CIDRAP", "https://www.cidrap.umn.edu/infectious-disease-topics/rss.xml"),
]
HIGH_CONCERN_PATHOGENS: set[str] = {
"ebola", "marburg", "mpox", "h5n1", "avian influenza", "bird flu",
"nipah", "mers", "sars", "cholera", "plague", "anthrax",
"polio", "yellow fever", "hantavirus", "lassa", "rift valley",
"dengue", "zika", "chikungunya",
}
_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 RSS entry published date to ISO 8601."""
import time as _time
for field in ("published_parsed", "updated_parsed"):
parsed_tuple = entry.get(field)
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
return entry.get("published") or entry.get("updated")
def _flag_severity(title: str, summary: str) -> tuple[bool, list[str]]:
"""Check if title/summary mentions high-concern pathogens."""
combined = f"{title} {summary}".lower()
matched = [p for p in HIGH_CONCERN_PATHOGENS if p in combined]
return bool(matched), matched
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def fetch_disease_outbreaks(
fetcher: Fetcher,
limit: int = 50,
) -> dict:
"""Aggregate disease outbreak alerts from WHO, ProMED, and CIDRAP.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
limit: Maximum items to return.
Returns:
Dict with items, count, high_concern_count, by_organization, source.
"""
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) -> list[dict]:
safe_name = name.lower().replace(" ", "_")
xml_text = await fetcher.get_xml(
url,
source=f"health:{safe_name}",
cache_key=f"health:rss:{safe_name}",
cache_ttl=_CACHE_TTL,
)
if xml_text is None:
logger.debug("No data from health 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 ""
is_high_concern, pathogens = _flag_severity(title, summary)
items.append({
"title": title,
"link": entry.get("link", ""),
"published": _parse_published(entry),
"summary": summary[:200] if len(summary) > 200 else summary,
"organization": name,
"is_high_concern": is_high_concern,
"pathogens_mentioned": pathogens,
})
return items
tasks = [_fetch_feed(name, url) for name, url in _HEALTH_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]
# Counts
high_concern_count = sum(1 for i in all_items if i.get("is_high_concern"))
by_organization: dict[str, int] = {}
for item in all_items:
org = item.get("organization", "unknown")
by_organization[org] = by_organization.get(org, 0) + 1
return {
"items": all_items,
"count": len(all_items),
"high_concern_count": high_concern_count,
"by_organization": by_organization,
"source": "health-outbreak-monitor",
"timestamp": _utc_now_iso(),
}
+239
View File
@@ -0,0 +1,239 @@
"""Nuclear test site seismic monitoring source for world-intel-mcp.
Monitors seismic activity near known nuclear test sites using USGS
GeoJSON API. Applies Haversine distance filtering and concern scoring
based on depth, magnitude, distance, and site status.
No API keys required.
"""
import logging
import math
from datetime import datetime, timezone, timedelta
from ..fetcher import Fetcher
from ..config.countries import NUCLEAR_TEST_SITES
logger = logging.getLogger("world-intel-mcp.sources.nuclear")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_USGS_ENDPOINT = "https://earthquake.usgs.gov/fdsnws/event/1/query"
_BBOX_PADDING_DEG = 1.5 # degrees around each site for initial USGS query
_MAX_DISTANCE_KM = 100.0 # Haversine filter radius
_CACHE_TTL = 600 # 10 minutes
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance between two points in km."""
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1))
* math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2
)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def _concern_score(
magnitude: float,
depth_km: float,
distance_km: float,
site_status: str,
) -> tuple[float, str]:
"""Score concern level for a seismic event near a test site.
Returns (score 0-100, level string).
"""
score = 0.0
# Magnitude contribution (0-40)
score += min(40.0, magnitude * 8.0)
# Shallow depth bonus (nuclear tests are <5km depth) — 0-25
if depth_km <= 2.0:
score += 25.0
elif depth_km <= 5.0:
score += 20.0
elif depth_km <= 10.0:
score += 10.0
elif depth_km <= 30.0:
score += 5.0
# Proximity bonus (0-20)
if distance_km <= 10.0:
score += 20.0
elif distance_km <= 30.0:
score += 15.0
elif distance_km <= 50.0:
score += 10.0
elif distance_km <= 100.0:
score += 5.0
# Active site multiplier (0-15)
if site_status == "active":
score += 15.0
elif site_status == "dormant":
score += 5.0
score = min(100.0, score)
if score >= 70:
level = "critical"
elif score >= 50:
level = "high"
elif score >= 30:
level = "elevated"
else:
level = "low"
return round(score, 1), level
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def fetch_nuclear_monitor(
fetcher: Fetcher,
hours: int = 72,
) -> dict:
"""Monitor seismic activity near known nuclear test sites.
For each NUCLEAR_TEST_SITE, queries USGS within a bounding box,
applies Haversine distance filter (<=100km), and scores concern.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
hours: Lookback period in hours (default 72).
Returns:
Dict with sites, total_flagged_events, critical_flags,
flagged_events, source.
"""
import asyncio
now = datetime.now(timezone.utc)
starttime = (now - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%S")
flagged_events: list[dict] = []
sites: list[dict] = []
async def _check_site(site: dict) -> dict:
lat = site["lat"]
lon = site["lon"]
data = await fetcher.get_json(
_USGS_ENDPOINT,
source="usgs",
cache_key=f"nuclear:usgs:{site['name']}:{hours}",
cache_ttl=_CACHE_TTL,
params={
"format": "geojson",
"minmagnitude": 1.0,
"starttime": starttime,
"minlatitude": lat - _BBOX_PADDING_DEG,
"maxlatitude": lat + _BBOX_PADDING_DEG,
"minlongitude": lon - _BBOX_PADDING_DEG,
"maxlongitude": lon + _BBOX_PADDING_DEG,
"limit": 50,
},
)
nearby_events: list[dict] = []
if data is not None:
for feature in data.get("features", []):
props = feature.get("properties", {})
geom = feature.get("geometry", {})
coords = geom.get("coordinates", [0, 0, 0])
eq_lon = coords[0] if len(coords) > 0 else 0
eq_lat = coords[1] if len(coords) > 1 else 0
eq_depth = coords[2] if len(coords) > 2 else 0
distance = _haversine_km(lat, lon, eq_lat, eq_lon)
if distance > _MAX_DISTANCE_KM:
continue
magnitude = props.get("mag", 0) or 0
score, level = _concern_score(
magnitude=magnitude,
depth_km=eq_depth,
distance_km=distance,
site_status=site["status"],
)
# Convert time
epoch_ms = props.get("time")
eq_time = None
if epoch_ms is not None:
try:
eq_time = datetime.fromtimestamp(
epoch_ms / 1000, tz=timezone.utc
).strftime("%Y-%m-%dT%H:%M:%SZ")
except (ValueError, TypeError, OSError):
pass
event = {
"site": site["name"],
"site_country": site["country"],
"magnitude": magnitude,
"depth_km": round(eq_depth, 1),
"distance_km": round(distance, 1),
"latitude": eq_lat,
"longitude": eq_lon,
"time": eq_time,
"place": props.get("place"),
"concern_score": score,
"concern_level": level,
}
nearby_events.append(event)
# Sort by concern_score descending
nearby_events.sort(key=lambda e: e["concern_score"], reverse=True)
return {
"name": site["name"],
"country": site["country"],
"iso3": site["iso3"],
"lat": lat,
"lon": lon,
"status": site["status"],
"last_test": site["last_test"],
"events_detected": len(nearby_events),
"highest_concern": nearby_events[0] if nearby_events else None,
"events": nearby_events[:5],
}
tasks = [_check_site(site) for site in NUCLEAR_TEST_SITES]
sites = await asyncio.gather(*tasks)
# Collect all flagged events
for site_result in sites:
for ev in site_result.get("events", []):
flagged_events.append(ev)
flagged_events.sort(key=lambda e: e["concern_score"], reverse=True)
critical_flags = sum(1 for e in flagged_events if e["concern_level"] == "critical")
return {
"sites": list(sites),
"total_flagged_events": len(flagged_events),
"critical_flags": critical_flags,
"flagged_events": flagged_events[:20],
"query": {"hours": hours, "max_distance_km": _MAX_DISTANCE_KM},
"source": "usgs-nuclear-monitor",
"timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
+126
View File
@@ -0,0 +1,126 @@
"""OFAC sanctions search source for world-intel-mcp.
Searches the US Treasury OFAC Specially Designated Nationals (SDN) list
via the consolidated CSV download. No API key required.
"""
import csv
import io
import logging
from datetime import datetime, timezone
from ..fetcher import Fetcher
logger = logging.getLogger("world-intel-mcp.sources.sanctions")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_OFAC_CSV_URL = "https://www.treasury.gov/ofac/downloads/sdn.csv"
_CACHE_TTL = 86400 # 24 hours
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def fetch_sanctions_search(
fetcher: Fetcher,
query: str = "",
country: str | None = None,
program: str | None = None,
limit: int = 50,
) -> dict:
"""Search the OFAC SDN consolidated list.
Downloads the SDN CSV, caches for 24h, and performs substring matching
on name, country, and program fields.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
query: Substring to match against entity names.
country: Country filter (substring match).
program: Sanctions program filter (substring match).
limit: Maximum results to return.
Returns:
Dict with matches, count, total_entities, query info, source.
"""
now = datetime.now(timezone.utc)
csv_text = await fetcher.get_text(
_OFAC_CSV_URL,
source="ofac-sdn",
cache_key="sanctions:ofac:sdn_csv",
cache_ttl=_CACHE_TTL,
timeout=30.0,
)
if csv_text is None:
logger.warning("Failed to download OFAC SDN list")
return {
"matches": [],
"count": 0,
"total_entities": 0,
"query": {"query": query, "country": country, "program": program},
"source": "ofac-sdn",
"timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
# Parse CSV — SDN format: ent_num, SDN_Name, SDN_Type, Program, Title,
# Call_Sign, Vess_type, Tonnage, GRT, Vess_flag, Vess_owner, Remarks
reader = csv.reader(io.StringIO(csv_text))
query_lower = query.lower().strip()
country_lower = country.lower().strip() if country else ""
program_lower = program.lower().strip() if program else ""
matches: list[dict] = []
total_entities = 0
for row in reader:
if len(row) < 4:
continue
total_entities += 1
name = row[1].strip()
sdn_type = row[2].strip()
programs = row[3].strip()
remarks = row[11].strip() if len(row) > 11 else ""
# Extract country from remarks if present
entry_country = ""
if remarks:
for part in remarks.split(";"):
part = part.strip()
if part.startswith("nationality") or part.startswith("country"):
entry_country = part
# Apply filters
if query_lower and query_lower not in name.lower():
continue
if country_lower and country_lower not in entry_country.lower() and country_lower not in remarks.lower():
continue
if program_lower and program_lower not in programs.lower():
continue
matches.append({
"name": name,
"type": sdn_type,
"programs": programs,
"remarks": remarks[:300] if len(remarks) > 300 else remarks,
})
if len(matches) >= limit:
break
return {
"matches": matches,
"count": len(matches),
"total_entities": total_entities,
"query": {"query": query, "country": country, "program": program},
"source": "ofac-sdn",
"timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
+108
View File
@@ -0,0 +1,108 @@
"""Shipping stress index source for world-intel-mcp.
Tracks dry bulk shipping ETFs via Yahoo Finance to compute a freight
stress index. Reuses _fetch_yahoo_quote from markets.py.
No additional API keys required.
"""
import asyncio
import logging
from datetime import datetime, timezone
from ..fetcher import Fetcher
from .markets import _fetch_yahoo_quote
logger = logging.getLogger("world-intel-mcp.sources.shipping")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_SHIPPING_SYMBOLS: dict[str, str] = {
"BDRY": "Breakwave Dry Bulk Shipping ETF",
"SBLK": "Star Bulk Carriers",
"EGLE": "Eagle Bulk Shipping",
"ZIM": "ZIM Integrated Shipping",
}
# Stress thresholds: if average daily change exceeds these, flag stress
_STRESS_THRESHOLDS: list[tuple[float, str]] = [
(5.0, "extreme"),
(3.0, "high"),
(1.5, "elevated"),
(0.5, "moderate"),
]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def fetch_shipping_index(fetcher: Fetcher) -> dict:
"""Compute a shipping stress index from dry bulk ETF quotes.
Fetches BDRY, SBLK, EGLE, ZIM from Yahoo Finance, computes an
aggregate stress score (0-100) based on price volatility.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
Returns:
Dict with quotes, stress_score, assessment, signals, source.
"""
now = datetime.now(timezone.utc)
tasks = [
_fetch_yahoo_quote(fetcher, sym, f"shipping:quote:{sym}", 300)
for sym in _SHIPPING_SYMBOLS
]
results = await asyncio.gather(*tasks)
quotes: list[dict] = []
change_pcts: list[float] = []
for sym, quote in zip(_SHIPPING_SYMBOLS, results):
if quote is None:
continue
change_pct = quote.get("change_pct")
quotes.append({
"symbol": sym,
"name": _SHIPPING_SYMBOLS[sym],
"price": quote.get("price"),
"change_pct": change_pct,
})
if change_pct is not None:
change_pcts.append(abs(change_pct))
# Compute stress score (0-100)
if change_pcts:
avg_change = sum(change_pcts) / len(change_pcts)
# Scale: 0% change = 0 stress, 10%+ change = 100 stress
stress_score = min(100, round(avg_change * 10, 1))
else:
avg_change = 0.0
stress_score = 0.0
# Assessment
assessment = "low"
for threshold, label in _STRESS_THRESHOLDS:
if avg_change >= threshold:
assessment = label
break
# Signals
signals: list[str] = []
for q in quotes:
pct = q.get("change_pct")
if pct is not None and abs(pct) > 3.0:
direction = "up" if pct > 0 else "down"
signals.append(f"{q['symbol']} {direction} {abs(pct):.1f}%")
return {
"quotes": quotes,
"stress_score": stress_score,
"assessment": assessment,
"signals": signals,
"source": "yahoo-finance",
"timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
+132
View File
@@ -0,0 +1,132 @@
"""Social signals source for world-intel-mcp.
Monitors Reddit public JSON endpoints for geopolitical discussion
velocity across r/worldnews and r/geopolitics. No API keys required.
"""
import asyncio
import logging
from datetime import datetime, timezone
from ..fetcher import Fetcher
logger = logging.getLogger("world-intel-mcp.sources.social")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_SUBREDDITS: list[str] = ["worldnews", "geopolitics"]
_REDDIT_HOT_URL = "https://www.reddit.com/r/{subreddit}/hot.json"
_CACHE_TTL = 300 # 5 minutes
_HEADERS = {
"User-Agent": "PhoenixAGI-WorldIntel/0.1 (intelligence monitoring)",
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def fetch_social_signals(
fetcher: Fetcher,
limit: int = 25,
) -> dict:
"""Fetch hot posts from geopolitical subreddits for velocity analysis.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
limit: Max posts per subreddit.
Returns:
Dict with posts, velocity_metrics, subreddits_queried, source.
"""
now = datetime.now(timezone.utc)
all_posts: list[dict] = []
async def _fetch_subreddit(subreddit: str) -> list[dict]:
url = _REDDIT_HOT_URL.format(subreddit=subreddit)
data = await fetcher.get_json(
url,
source="reddit",
cache_key=f"social:reddit:{subreddit}:hot",
cache_ttl=_CACHE_TTL,
headers=_HEADERS,
params={"limit": str(limit), "raw_json": "1"},
)
if data is None:
logger.debug("No data from r/%s", subreddit)
return []
posts: list[dict] = []
children = data.get("data", {}).get("children", [])
for child in children:
post_data = child.get("data", {})
if not post_data:
continue
created_utc = post_data.get("created_utc")
created_iso = None
if created_utc is not None:
try:
created_iso = datetime.fromtimestamp(
float(created_utc), tz=timezone.utc
).strftime("%Y-%m-%dT%H:%M:%SZ")
except (ValueError, TypeError, OSError):
pass
posts.append({
"title": post_data.get("title", ""),
"subreddit": subreddit,
"score": post_data.get("score", 0),
"num_comments": post_data.get("num_comments", 0),
"upvote_ratio": post_data.get("upvote_ratio"),
"created": created_iso,
"url": f"https://reddit.com{post_data.get('permalink', '')}",
"is_self": post_data.get("is_self", False),
})
return posts
tasks = [_fetch_subreddit(sub) for sub in _SUBREDDITS]
results = await asyncio.gather(*tasks)
for posts in results:
all_posts.extend(posts)
# Sort by score descending
all_posts.sort(key=lambda p: p.get("score", 0), reverse=True)
# Velocity metrics
total_score = sum(p.get("score", 0) for p in all_posts)
total_comments = sum(p.get("num_comments", 0) for p in all_posts)
avg_score = round(total_score / len(all_posts), 1) if all_posts else 0
avg_comments = round(total_comments / len(all_posts), 1) if all_posts else 0
# High engagement threshold
high_engagement = [
p for p in all_posts
if p.get("score", 0) > 1000 or p.get("num_comments", 0) > 200
]
velocity_metrics = {
"total_posts": len(all_posts),
"total_score": total_score,
"total_comments": total_comments,
"avg_score": avg_score,
"avg_comments": avg_comments,
"high_engagement_count": len(high_engagement),
}
return {
"posts": all_posts,
"velocity_metrics": velocity_metrics,
"subreddits_queried": _SUBREDDITS,
"source": "reddit-public",
"timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
}