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 += ''; + 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 += ''; + }); + h += '
RegionTotalCommercialGeneral
' + esc(rk.replace(/_/g, ' ')) + '' + (rv.count || 0) + '' + (rv.commercial || 0) + '' + (rv.general || 0) + '
'; + var busiest = df.busiest_origins || []; + if (busiest.length) { + h += '
Busiest Origins
'; + busiest.slice(0, 10).forEach(function(b) { + h += ''; + }); + h += '
CountryAircraft
' + esc(b.country) + '' + b.count + '
'; + } + } + + // ── 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 += '
' + tf.count + '
Cities
'; + h += '
'; + h += ''; + (tf.cities || []).forEach(function(c) { + var cls = c.congestion_pct >= 50 ? 'crit' : c.congestion_pct >= 25 ? 'warn' : 'dim'; + h += ''; + }); + h += '
CityCong%Speed
' + esc(c.name) + ' ' + c.country + '' + c.congestion_pct + '%' + c.current_speed_kmh + '
'; + } + + // ── 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(), + }