diff --git a/src/world_intel_mcp/analysis/situation.py b/src/world_intel_mcp/analysis/situation.py
new file mode 100644
index 0000000..8ff5a4a
--- /dev/null
+++ b/src/world_intel_mcp/analysis/situation.py
@@ -0,0 +1,180 @@
+"""AI-powered situational analysis for world-intel-mcp.
+
+Generates a real-time intelligence brief from all dashboard data
+using a local Ollama LLM. Falls back to a structured metrics summary
+when the LLM is unavailable.
+"""
+
+import logging
+import os
+from datetime import datetime, timezone
+
+import httpx
+
+logger = logging.getLogger("world-intel-mcp.analysis.situation")
+
+
+def _utc_now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _extract_metrics(data: dict) -> dict:
+ """Pull key numbers from the full overview data."""
+ eq = data.get("earthquakes", {})
+ quakes = eq.get("count", 0) if isinstance(eq, dict) else 0
+ eq_events = eq.get("events", []) if isinstance(eq, dict) else []
+ max_mag = max((e.get("magnitude", 0) for e in eq_events), default=0) if eq_events else 0
+
+ mil = data.get("military_flights", {})
+ mil_count = mil.get("count", 0) if isinstance(mil, dict) else 0
+
+ conflict_src = data.get("acled_events") or data.get("conflict_zones") or data.get("ucdp_events") or {}
+ conflict_count = conflict_src.get("count", 0) if isinstance(conflict_src, dict) else 0
+
+ fires = data.get("wildfires", {})
+ fire_regions = fires.get("fires_by_region", {}) if isinstance(fires, dict) else {}
+ fire_clusters = sum(
+ len(r.get("top_clusters", [])) for r in fire_regions.values() if isinstance(r, dict)
+ )
+
+ cyber = data.get("cyber_threats", {})
+ cyber_count = len(cyber.get("threats", [])) if isinstance(cyber, dict) else 0
+
+ posture = data.get("strategic_posture", {})
+ posture_score = posture.get("composite_score", 0) if isinstance(posture, dict) else 0
+ risk_level = posture.get("risk_level", "unknown") if isinstance(posture, dict) else "unknown"
+
+ alerts = data.get("alert_digest", {})
+ alert_count = alerts.get("alert_count", 0) if isinstance(alerts, dict) else 0
+
+ space = data.get("space_weather", {})
+ kp = space.get("current_kp", 0) if isinstance(space, dict) else 0
+
+ health = data.get("disease_outbreaks", {})
+ outbreaks = health.get("high_concern_count", 0) if isinstance(health, dict) else 0
+
+ news = data.get("news_feed", {})
+ headlines = []
+ if isinstance(news, dict):
+ for item in (news.get("items") or news.get("articles") or [])[:5]:
+ if isinstance(item, dict):
+ headlines.append(item.get("title", ""))
+
+ domestic = data.get("domestic_flights", {})
+ total_aircraft = domestic.get("total_aircraft", 0) if isinstance(domestic, dict) else 0
+
+ traffic = data.get("traffic_flow", {})
+ avg_congestion = traffic.get("global_avg_congestion", 0) if isinstance(traffic, dict) else 0
+
+ return {
+ "earthquakes": quakes,
+ "max_magnitude": round(max_mag, 1),
+ "military_aircraft": mil_count,
+ "conflicts": conflict_count,
+ "fire_clusters": fire_clusters,
+ "cyber_threats": cyber_count,
+ "posture_score": round(posture_score),
+ "risk_level": risk_level,
+ "alerts": alert_count,
+ "kp_index": round(kp, 1),
+ "outbreaks": outbreaks,
+ "total_aircraft": total_aircraft,
+ "avg_congestion": avg_congestion,
+ "top_headlines": headlines,
+ }
+
+
+def _build_prompt(m: dict) -> str:
+ """Build an LLM prompt from extracted metrics."""
+ headline_block = "\n".join(f" - {h}" for h in m["top_headlines"]) if m["top_headlines"] else " (no headlines available)"
+
+ return f"""You are a senior intelligence analyst. Generate a concise 3-paragraph situational awareness brief based on these real-time metrics:
+
+THREAT POSTURE: Score {m['posture_score']}/100 ({m['risk_level']}), {m['alerts']} active alerts
+MILITARY: {m['military_aircraft']} tracked aircraft
+CONFLICT: {m['conflicts']} active events
+SEISMIC: {m['earthquakes']} earthquakes (max M{m['max_magnitude']})
+FIRES: {m['fire_clusters']} active fire clusters
+CYBER: {m['cyber_threats']} tracked IOCs
+SPACE WEATHER: Kp {m['kp_index']}
+HEALTH: {m['outbreaks']} high-concern outbreaks
+AIR TRAFFIC: {m['total_aircraft']} aircraft airborne
+TRAFFIC: {m['avg_congestion']}% avg city congestion
+
+TOP HEADLINES:
+{headline_block}
+
+Write exactly 3 paragraphs:
+1. Overall threat assessment and most significant developments
+2. Regional hotspots and emerging patterns
+3. Recommended watch items for the next 12 hours
+
+Be specific, cite numbers. No preamble."""
+
+
+def _fallback_brief(m: dict) -> str:
+ """Generate a structured summary without LLM."""
+ lines = [
+ f"THREAT POSTURE: {m['risk_level'].upper()} (score {m['posture_score']}/100) with {m['alerts']} active alerts.",
+ f"MILITARY: {m['military_aircraft']} aircraft tracked. CONFLICT: {m['conflicts']} active events.",
+ f"SEISMIC: {m['earthquakes']} earthquakes (max M{m['max_magnitude']}). FIRES: {m['fire_clusters']} clusters.",
+ f"CYBER: {m['cyber_threats']} IOCs. HEALTH: {m['outbreaks']} high-concern outbreaks.",
+ f"SPACE: Kp {m['kp_index']}. AIR TRAFFIC: {m['total_aircraft']} airborne. CONGESTION: {m['avg_congestion']}%.",
+ ]
+ return "\n".join(lines)
+
+
+async def fetch_situation_brief(overview_data: dict) -> dict:
+ """Generate an AI situational analysis brief from dashboard data.
+
+ Uses local Ollama LLM to synthesize all intelligence domains into
+ an actionable 3-paragraph brief. Falls back to structured metrics
+ summary when Ollama is unavailable.
+
+ Args:
+ overview_data: Full dashboard overview dict from _fetch_overview().
+
+ Returns:
+ Dict with brief text, generation metadata, and key metrics.
+ """
+ metrics = _extract_metrics(overview_data)
+ prompt = _build_prompt(metrics)
+
+ ollama_url = os.environ.get("OLLAMA_API_URL", "http://mac-studio.local:11434")
+ model = os.environ.get("OLLAMA_MODEL", "llama3.2")
+
+ brief_text = ""
+ ai_generated = False
+ used_model = "fallback"
+
+ try:
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.post(
+ f"{ollama_url}/api/generate",
+ json={
+ "model": model,
+ "prompt": prompt,
+ "stream": False,
+ "options": {"temperature": 0.3, "num_predict": 500},
+ },
+ )
+ resp.raise_for_status()
+ result = resp.json()
+ brief_text = result.get("response", "").strip()
+ if brief_text:
+ ai_generated = True
+ used_model = model
+ except Exception as exc:
+ logger.debug("Ollama unavailable for situation brief: %s", exc)
+
+ if not brief_text:
+ brief_text = _fallback_brief(metrics)
+
+ return {
+ "brief": brief_text,
+ "ai_generated": ai_generated,
+ "model": used_model,
+ "metrics_snapshot": metrics,
+ "source": "situation-brief",
+ "timestamp": _utc_now_iso(),
+ }
diff --git a/src/world_intel_mcp/dashboard/app.py b/src/world_intel_mcp/dashboard/app.py
index 8d2b765..7dc2b21 100644
--- a/src/world_intel_mcp/dashboard/app.py
+++ b/src/world_intel_mcp/dashboard/app.py
@@ -41,10 +41,13 @@ from world_intel_mcp.sources import (
social,
nuclear,
service_status,
+ traffic,
+ webcams,
)
from world_intel_mcp.analysis.alerts import fetch_alert_digest, fetch_weekly_trends
from world_intel_mcp.analysis.posture import fetch_strategic_posture
from world_intel_mcp.analysis.exposure import fetch_population_exposure
+from world_intel_mcp.analysis.situation import fetch_situation_brief
from world_intel_mcp.sources.fleet import fetch_fleet_report
from world_intel_mcp.config.countries import INTEL_HOTSPOTS, STRATEGIC_WATERWAYS
from world_intel_mcp.config.geospatial import MILITARY_BASES, STRATEGIC_PORTS, PIPELINES, NUCLEAR_FACILITIES
@@ -115,6 +118,10 @@ async def _fetch_overview() -> dict:
"strategic_posture": fetch_strategic_posture(fetcher),
"fleet_report": fetch_fleet_report(fetcher),
"population_exposure": fetch_population_exposure(fetcher),
+ "domestic_flights": aviation.fetch_domestic_flights(fetcher),
+ "traffic_flow": traffic.fetch_traffic_flow(fetcher),
+ "traffic_incidents": traffic.fetch_traffic_incidents(fetcher),
+ "webcams": webcams.fetch_webcams(fetcher),
}
# Per-coro timeout so no single slow source blocks the entire dashboard.
@@ -183,6 +190,15 @@ async def _fetch_overview() -> dict:
"count": len(CABLE_CORRIDORS),
}
+ # AI situational brief (runs after main gather so it has all data)
+ try:
+ result["situation_brief"] = await asyncio.wait_for(
+ fetch_situation_brief(result), timeout=35.0,
+ )
+ except Exception as exc:
+ logger.warning("Situation brief failed: %s", exc)
+ result["situation_brief"] = {"error": str(exc)}
+
# Attach source health + timestamp
result["source_health"] = _breaker.status() if _breaker else {}
result["cache_stats"] = _cache.stats() if _cache else {}
diff --git a/src/world_intel_mcp/dashboard/index.html b/src/world_intel_mcp/dashboard/index.html
index cef2c31..05879f9 100644
--- a/src/world_intel_mcp/dashboard/index.html
+++ b/src/world_intel_mcp/dashboard/index.html
@@ -1314,6 +1314,16 @@ function updateHudStats(data) {
if (data.population_exposure && !data.population_exposure.error && data.population_exposure.exposed_city_count > 0) {
pills.push('
' + (data.population_exposure.total_exposed_population_formatted || '0') + 'Exposed
');
}
+ if (data.domestic_flights && !data.domestic_flights.error && data.domestic_flights.total_aircraft > 0) {
+ pills.push('' + fmtBigPlain(data.domestic_flights.total_aircraft) + 'Airborne
');
+ }
+ if (data.traffic_flow && !data.traffic_flow.error && data.traffic_flow.count > 0) {
+ var tAvg = data.traffic_flow.global_avg_congestion || 0;
+ pills.push('' + tAvg.toFixed(0) + '%Traffic
');
+ }
+ if (data.webcams && !data.webcams.error && data.webcams.count > 0) {
+ pills.push('' + data.webcams.count + 'Cams
');
+ }
$('#hudStats').innerHTML = safe(pills.join(''));
}
@@ -1931,6 +1941,68 @@ function updateDrawer(data) {
}
}
+ // ── DOMESTIC AIR TRAFFIC ──
+ if (data.domestic_flights && !data.domestic_flights.error && data.domestic_flights.total_aircraft > 0) {
+ var df = data.domestic_flights;
+ h += 'AIR TRAFFIC
';
+ h += '' + fmtBigPlain(df.total_aircraft) + '
Airborne
';
+ var regions = df.by_region || {};
+ h += '| Region | Total | Commercial | General |
';
+ var rKeys = Object.keys(regions).sort(function(a, b) { return (regions[b].count || 0) - (regions[a].count || 0); });
+ rKeys.forEach(function(rk) {
+ var rv = regions[rk];
+ h += '| ' + esc(rk.replace(/_/g, ' ')) + ' | ' + (rv.count || 0) + ' | ' + (rv.commercial || 0) + ' | ' + (rv.general || 0) + ' |
';
+ });
+ h += '
';
+ var busiest = df.busiest_origins || [];
+ if (busiest.length) {
+ h += 'Busiest Origins
| Country | Aircraft |
';
+ busiest.slice(0, 10).forEach(function(b) {
+ h += '| ' + esc(b.country) + ' | ' + b.count + ' |
';
+ });
+ h += '
';
+ }
+ }
+
+ // ── TRAFFIC ──
+ if (data.traffic_flow && !data.traffic_flow.error && data.traffic_flow.count > 0) {
+ var tf = data.traffic_flow;
+ h += 'ROAD TRAFFIC
';
+ h += '';
+ var avgCls = tf.global_avg_congestion >= 40 ? ' crit' : tf.global_avg_congestion >= 20 ? ' warn' : '';
+ h += '
' + fmtNum(tf.global_avg_congestion, 0) + '%
Avg Congestion
';
+ h += '
';
+ h += '
';
+ h += '| City | Cong% | Speed |
';
+ (tf.cities || []).forEach(function(c) {
+ var cls = c.congestion_pct >= 50 ? 'crit' : c.congestion_pct >= 25 ? 'warn' : 'dim';
+ h += '| ' + esc(c.name) + ' ' + c.country + ' | ' + c.congestion_pct + '% | ' + c.current_speed_kmh + ' |
';
+ });
+ h += '
';
+ }
+
+ // ── WEBCAMS ──
+ if (data.webcams && !data.webcams.error && data.webcams.count > 0) {
+ var wc = data.webcams;
+ h += 'CCTV / WEBCAMS
';
+ h += '' + wc.count + ' cameras (' + esc(wc.category || 'traffic') + ')
';
+ (wc.cameras || []).slice(0, 12).forEach(function(cam) {
+ h += '';
+ h += '
' + esc(cam.title || 'Camera') + '
';
+ h += '
' + esc(cam.city || '') + (cam.country ? ', ' + esc(cam.country) : '') + '
';
+ h += '
';
+ });
+ }
+
+ // ── AI SITUATION BRIEF ──
+ if (data.situation_brief && !data.situation_brief.error && data.situation_brief.brief) {
+ var sb = data.situation_brief;
+ h += 'AI SITUATION BRIEF
';
+ var aiTag = sb.ai_generated ? ' ' + esc(sb.model) + '' : ' metrics fallback';
+ h += 'Generated ' + ago(new Date(sb.timestamp).getTime()) + ' ago ' + aiTag + '
';
+ h += '' + esc(sb.brief) + '
';
+ }
+
$('#drawerBody').innerHTML = safe(h);
// Attach click delegation for data-click rows
diff --git a/src/world_intel_mcp/sources/aviation.py b/src/world_intel_mcp/sources/aviation.py
index 2af095e..66d0677 100644
--- a/src/world_intel_mcp/sources/aviation.py
+++ b/src/world_intel_mcp/sources/aviation.py
@@ -1,11 +1,14 @@
-"""FAA airport delay data source for world-intel-mcp.
+"""Aviation data sources for world-intel-mcp.
Provides real-time US airport delay information from the FAA Airport
-Status Web Service (ASWS) API. No API key required.
+Status Web Service (ASWS) API, and global domestic air traffic counts
+from OpenSky Network. No API key required for either.
"""
import asyncio
+import base64
import logging
+import os
from datetime import datetime, timezone
from ..fetcher import Fetcher
@@ -131,3 +134,119 @@ async def fetch_airport_delays(fetcher: Fetcher) -> dict:
"source": "faa",
"timestamp": now_iso,
}
+
+
+# ---------------------------------------------------------------------------
+# Domestic / commercial air traffic (OpenSky Network)
+# ---------------------------------------------------------------------------
+
+_OPENSKY_STATES_URL = "https://opensky-network.org/api/states/all"
+
+_AIR_REGIONS = {
+ "north_america": (15, -170, 72, -50),
+ "europe": (35, -25, 72, 45),
+ "east_asia": (15, 95, 55, 155),
+ "middle_east": (12, 25, 42, 65),
+ "south_asia": (5, 60, 40, 100),
+ "africa": (-35, -20, 37, 55),
+ "south_america": (-56, -82, 15, -34),
+ "oceania": (-50, 110, 0, 180),
+}
+
+_COMMERCIAL_PREFIXES = [
+ "UAL", "AAL", "DAL", "SWA", "JBU", "ASA", "NKS", "FFT", "SKW",
+ "BAW", "EZY", "RYR", "DLH", "AFR", "KLM", "SAS", "AUA", "TAP",
+ "QFA", "ANZ", "JST", "VOZ", "CPA", "SIA", "THA", "ANA", "JAL",
+ "CES", "CSN", "CCA", "HDA", "AIC", "UAE", "ETH", "SAA", "RAM",
+ "TAM", "GLO", "AZU", "AVA", "LAN", "THY", "TRK", "SHT",
+]
+
+
+def _opensky_auth_headers() -> dict[str, str] | None:
+ username = os.environ.get("OPENSKY_USERNAME")
+ password = os.environ.get("OPENSKY_PASSWORD")
+ if username and password:
+ cred = base64.b64encode(f"{username}:{password}".encode()).decode()
+ return {"Authorization": f"Basic {cred}"}
+ return None
+
+
+def _classify_region(lat: float | None, lon: float | None) -> str:
+ if lat is None or lon is None:
+ return "unknown"
+ for name, (lat_min, lon_min, lat_max, lon_max) in _AIR_REGIONS.items():
+ if lat_min <= lat <= lat_max and lon_min <= lon <= lon_max:
+ return name
+ return "other"
+
+
+def _is_commercial(callsign: str | None) -> bool:
+ if not callsign:
+ return False
+ cs = callsign.strip().upper()
+ return any(cs.startswith(p) for p in _COMMERCIAL_PREFIXES)
+
+
+async def fetch_domestic_flights(fetcher: Fetcher) -> dict:
+ """Fetch global air traffic counts from OpenSky Network.
+
+ Queries all airborne aircraft once, then buckets by region and type.
+ """
+ data = await fetcher.get_json(
+ _OPENSKY_STATES_URL,
+ source="opensky-domestic",
+ cache_key="aviation:opensky:all",
+ cache_ttl=120,
+ headers=_opensky_auth_headers(),
+ )
+
+ if data is None or not isinstance(data, dict):
+ return {
+ "total_aircraft": 0,
+ "by_region": {},
+ "busiest_origins": [],
+ "error": "OpenSky API unavailable",
+ "source": "opensky-domestic",
+ "timestamp": _utc_now_iso(),
+ }
+
+ states = data.get("states") or []
+
+ by_region: dict[str, dict] = {r: {"count": 0, "commercial": 0, "general": 0} for r in _AIR_REGIONS}
+ by_region["other"] = {"count": 0, "commercial": 0, "general": 0}
+ by_region["unknown"] = {"count": 0, "commercial": 0, "general": 0}
+ country_counts: dict[str, int] = {}
+ total = 0
+
+ for s in states:
+ if not isinstance(s, list) or len(s) < 15:
+ continue
+ if s[8]: # on_ground
+ continue
+
+ total += 1
+ lat, lon = s[6], s[5]
+ callsign = s[1]
+ origin = s[2] or "Unknown"
+
+ region = _classify_region(lat, lon)
+ by_region[region]["count"] += 1
+ if _is_commercial(callsign):
+ by_region[region]["commercial"] += 1
+ else:
+ by_region[region]["general"] += 1
+
+ country_counts[origin] = country_counts.get(origin, 0) + 1
+
+ # Remove empty regions
+ by_region = {k: v for k, v in by_region.items() if v["count"] > 0}
+
+ busiest = sorted(country_counts.items(), key=lambda x: -x[1])[:15]
+
+ return {
+ "total_aircraft": total,
+ "by_region": by_region,
+ "busiest_origins": [{"country": c, "count": n} for c, n in busiest],
+ "source": "opensky-domestic",
+ "timestamp": _utc_now_iso(),
+ }
diff --git a/src/world_intel_mcp/sources/traffic.py b/src/world_intel_mcp/sources/traffic.py
new file mode 100644
index 0000000..badbe7b
--- /dev/null
+++ b/src/world_intel_mcp/sources/traffic.py
@@ -0,0 +1,210 @@
+"""Road traffic intelligence for world-intel-mcp.
+
+Provides real-time city congestion levels and traffic incidents via
+the TomTom Traffic API (free tier: 2,500 requests/day).
+"""
+
+import asyncio
+import logging
+import os
+from datetime import datetime, timezone
+
+from ..fetcher import Fetcher
+
+logger = logging.getLogger("world-intel-mcp.sources.traffic")
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+_FLOW_URL = "https://api.tomtom.com/traffic/services/4/flowSegmentData/absolute/10/json"
+_INCIDENTS_URL = "https://api.tomtom.com/traffic/services/5/incidentDetails"
+
+_TRAFFIC_CITIES = [
+ {"name": "New York", "lat": 40.7580, "lon": -73.9855, "country": "US"},
+ {"name": "London", "lat": 51.5074, "lon": -0.1278, "country": "UK"},
+ {"name": "Tokyo", "lat": 35.6762, "lon": 139.6503, "country": "JP"},
+ {"name": "Beijing", "lat": 39.9042, "lon": 116.4074, "country": "CN"},
+ {"name": "Mumbai", "lat": 19.0760, "lon": 72.8777, "country": "IN"},
+ {"name": "São Paulo", "lat": -23.5505, "lon": -46.6333, "country": "BR"},
+ {"name": "Cairo", "lat": 30.0444, "lon": 31.2357, "country": "EG"},
+ {"name": "Lagos", "lat": 6.5244, "lon": 3.3792, "country": "NG"},
+ {"name": "Moscow", "lat": 55.7558, "lon": 37.6173, "country": "RU"},
+ {"name": "Istanbul", "lat": 41.0082, "lon": 28.9784, "country": "TR"},
+ {"name": "Los Angeles", "lat": 34.0522, "lon": -118.2437, "country": "US"},
+ {"name": "Paris", "lat": 48.8566, "lon": 2.3522, "country": "FR"},
+ {"name": "Berlin", "lat": 52.5200, "lon": 13.4050, "country": "DE"},
+ {"name": "Sydney", "lat": -33.8688, "lon": 151.2093, "country": "AU"},
+ {"name": "Dubai", "lat": 25.2048, "lon": 55.2708, "country": "AE"},
+ {"name": "Singapore", "lat": 1.3521, "lon": 103.8198, "country": "SG"},
+ {"name": "Seoul", "lat": 37.5665, "lon": 126.9780, "country": "KR"},
+ {"name": "Mexico City", "lat": 19.4326, "lon": -99.1332, "country": "MX"},
+ {"name": "Jakarta", "lat": -6.2088, "lon": 106.8456, "country": "ID"},
+ {"name": "Bangkok", "lat": 13.7563, "lon": 100.5018, "country": "TH"},
+]
+
+# Incident severity categories
+_INCIDENT_REGIONS = [
+ {"name": "US East", "bbox": "-82,25,-65,48"},
+ {"name": "US West", "bbox": "-125,30,-100,50"},
+ {"name": "Europe", "bbox": "-10,35,30,60"},
+ {"name": "Middle East", "bbox": "25,20,60,42"},
+ {"name": "East Asia", "bbox": "100,20,145,50"},
+]
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+async def fetch_traffic_flow(fetcher: Fetcher) -> dict:
+ """Fetch real-time traffic congestion for major world cities.
+
+ Uses TomTom Traffic Flow API. Requires TOMTOM_API_KEY env var.
+ """
+ api_key = os.environ.get("TOMTOM_API_KEY")
+ if not api_key:
+ return {
+ "error": "TOMTOM_API_KEY not configured",
+ "note": "Free at developer.tomtom.com (2500 req/day)",
+ }
+
+ async def _fetch_city(city: dict) -> dict:
+ data = await fetcher.get_json(
+ _FLOW_URL,
+ source="tomtom",
+ cache_key=f"traffic:flow:{city['name']}",
+ cache_ttl=300,
+ params={
+ "key": api_key,
+ "point": f"{city['lat']},{city['lon']}",
+ "unit": "KMPH",
+ },
+ )
+ if data is None or not isinstance(data, dict):
+ return {**city, "congestion_pct": -1, "error": True}
+
+ flow = data.get("flowSegmentData", {})
+ current = flow.get("currentSpeed", 0)
+ freeflow = flow.get("freeFlowSpeed", 1)
+ congestion = max(0, round((1 - current / freeflow) * 100)) if freeflow > 0 else 0
+
+ return {
+ "name": city["name"],
+ "country": city["country"],
+ "lat": city["lat"],
+ "lon": city["lon"],
+ "congestion_pct": congestion,
+ "current_speed_kmh": round(current, 1),
+ "free_flow_speed_kmh": round(freeflow, 1),
+ }
+
+ results = await asyncio.gather(
+ *[_fetch_city(c) for c in _TRAFFIC_CITIES],
+ return_exceptions=True,
+ )
+
+ cities = []
+ for r in results:
+ if isinstance(r, Exception):
+ logger.warning("Traffic flow fetch failed: %s", r)
+ continue
+ if r.get("error"):
+ continue
+ cities.append(r)
+
+ cities.sort(key=lambda c: c["congestion_pct"], reverse=True)
+
+ avg = round(sum(c["congestion_pct"] for c in cities) / max(len(cities), 1), 1)
+
+ return {
+ "cities": cities,
+ "global_avg_congestion": avg,
+ "most_congested": cities[0] if cities else None,
+ "count": len(cities),
+ "source": "tomtom",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+
+
+async def fetch_traffic_incidents(fetcher: Fetcher) -> dict:
+ """Fetch major traffic incidents from TomTom.
+
+ Queries strategic regions for severity 1-3 incidents.
+ Requires TOMTOM_API_KEY env var.
+ """
+ api_key = os.environ.get("TOMTOM_API_KEY")
+ if not api_key:
+ return {
+ "error": "TOMTOM_API_KEY not configured",
+ "note": "Free at developer.tomtom.com",
+ }
+
+ async def _fetch_region(region: dict) -> list[dict]:
+ data = await fetcher.get_json(
+ _INCIDENTS_URL,
+ source="tomtom-incidents",
+ cache_key=f"traffic:incidents:{region['name']}",
+ cache_ttl=300,
+ params={
+ "key": api_key,
+ "bbox": region["bbox"],
+ "fields": "{incidents{type,geometry{type,coordinates},properties{id,iconCategory,magnitudeOfDelay,events{description},startTime,endTime,from,to,length,delay,roadNumbers}}}",
+ "language": "en-US",
+ "categoryFilter": "0,1,2,3,4,5,6,7,8,9,10,11,14",
+ "timeValidityFilter": "present",
+ },
+ )
+ if data is None or not isinstance(data, dict):
+ return []
+
+ incidents = []
+ for inc in data.get("incidents", [])[:20]:
+ props = inc.get("properties", {})
+ geom = inc.get("geometry", {})
+ coords = geom.get("coordinates", [[]])
+ if coords and isinstance(coords[0], list) and len(coords[0]) >= 2:
+ lon, lat = coords[0][0], coords[0][1]
+ else:
+ lon, lat = None, None
+
+ events = props.get("events", [])
+ desc = events[0].get("description", "") if events else ""
+
+ incidents.append({
+ "region": region["name"],
+ "type": inc.get("type", ""),
+ "description": desc,
+ "from_road": props.get("from", ""),
+ "to_road": props.get("to", ""),
+ "delay_seconds": props.get("delay", 0),
+ "length_meters": props.get("length", 0),
+ "magnitude": props.get("magnitudeOfDelay", 0),
+ "lat": lat,
+ "lon": lon,
+ "road_numbers": props.get("roadNumbers", []),
+ })
+ return incidents
+
+ results = await asyncio.gather(
+ *[_fetch_region(r) for r in _INCIDENT_REGIONS],
+ return_exceptions=True,
+ )
+
+ all_incidents = []
+ for r in results:
+ if isinstance(r, Exception):
+ logger.warning("Traffic incidents fetch failed: %s", r)
+ continue
+ all_incidents.extend(r)
+
+ # Sort by delay severity
+ all_incidents.sort(key=lambda i: i.get("delay_seconds", 0), reverse=True)
+
+ return {
+ "incidents": all_incidents[:50],
+ "total_count": len(all_incidents),
+ "regions_checked": len(_INCIDENT_REGIONS),
+ "source": "tomtom-incidents",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
diff --git a/src/world_intel_mcp/sources/webcams.py b/src/world_intel_mcp/sources/webcams.py
new file mode 100644
index 0000000..7620aa5
--- /dev/null
+++ b/src/world_intel_mcp/sources/webcams.py
@@ -0,0 +1,89 @@
+"""Public webcam / CCTV data source for world-intel-mcp.
+
+Fetches worldwide public camera locations and previews via the
+Windy Webcams API (webcams.travel). Free tier: 100 requests/day.
+"""
+
+import logging
+import os
+from datetime import datetime, timezone
+
+from ..fetcher import Fetcher
+
+logger = logging.getLogger("world-intel-mcp.sources.webcams")
+
+_WEBCAMS_URL = "https://api.windy.com/webcams/api/v3/webcams"
+
+
+async def fetch_webcams(
+ fetcher: Fetcher,
+ category: str = "traffic",
+ limit: int = 50,
+) -> dict:
+ """Fetch public webcam locations from Windy Webcams API.
+
+ Args:
+ fetcher: Shared HTTP fetcher.
+ category: Webcam category filter (traffic, weather, landscape, etc).
+ limit: Max cameras to return.
+
+ Returns:
+ Dict with camera list, count, source, and timestamp.
+ """
+ api_key = os.environ.get("WINDY_API_KEY")
+ if not api_key:
+ return {
+ "error": "WINDY_API_KEY not configured",
+ "note": "Free at api.windy.com (100 req/day)",
+ }
+
+ data = await fetcher.get_json(
+ _WEBCAMS_URL,
+ source="windy-webcams",
+ cache_key=f"webcams:{category}:{limit}",
+ cache_ttl=1800,
+ headers={"x-windy-api-key": api_key},
+ params={
+ "limit": limit,
+ "offset": 0,
+ "include": "categories,location,images,player",
+ "categories": category,
+ },
+ )
+
+ if data is None or not isinstance(data, dict):
+ return {
+ "cameras": [],
+ "count": 0,
+ "error": "Windy API unavailable",
+ "source": "windy-webcams",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+
+ cameras = []
+ for cam in data.get("webcams", []):
+ loc = cam.get("location", {})
+ images = cam.get("images", {})
+ current = images.get("current", {})
+ player = cam.get("player", {})
+
+ cameras.append({
+ "id": cam.get("webcamId") or cam.get("id", ""),
+ "title": cam.get("title", "Unknown Camera"),
+ "lat": loc.get("latitude"),
+ "lon": loc.get("longitude"),
+ "city": loc.get("city", ""),
+ "country": loc.get("country", ""),
+ "preview_url": current.get("preview", ""),
+ "thumbnail_url": current.get("thumbnail", ""),
+ "player_url": player.get("day", {}).get("embed", "") if isinstance(player.get("day"), dict) else "",
+ "status": cam.get("status", "unknown"),
+ })
+
+ return {
+ "cameras": cameras,
+ "count": len(cameras),
+ "category": category,
+ "source": "windy-webcams",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }