feat: domestic flights, road traffic, CCTV webcams, AI situation brief

Four new intelligence domains for the dashboard:

1. Domestic flights (OpenSky) — global airborne aircraft count by
   region with commercial/general breakdown. No API key needed.

2. Road traffic (TomTom) — real-time congestion % for 20 major world
   cities + traffic incidents in 5 strategic regions. Needs
   TOMTOM_API_KEY (free 2500 req/day at developer.tomtom.com).

3. CCTV webcams (Windy) — public traffic camera locations worldwide.
   Needs WINDY_API_KEY (free 100 req/day at api.windy.com).

4. AI situation brief (Ollama) — LLM-generated 3-paragraph
   intelligence brief synthesizing all dashboard data. Uses local
   Ollama (llama3.2). Falls back to structured metrics summary.

New files: sources/traffic.py, sources/webcams.py, analysis/situation.py
Modified: sources/aviation.py (+fetch_domestic_flights), dashboard/app.py,
dashboard/index.html (drawer sections + HUD pills for all 4 domains).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Shade
2026-02-24 11:46:19 -05:00
parent fc105872e2
commit fcc702b093
6 changed files with 688 additions and 2 deletions
+180
View File
@@ -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(),
}
+16
View File
@@ -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 {}
+72
View File
@@ -1314,6 +1314,16 @@ function updateHudStats(data) {
if (data.population_exposure && !data.population_exposure.error && data.population_exposure.exposed_city_count > 0) {
pills.push('<div class="stat-pill"><span class="v warn">' + (data.population_exposure.total_exposed_population_formatted || '0') + '</span><span class="l">Exposed</span></div>');
}
if (data.domestic_flights && !data.domestic_flights.error && data.domestic_flights.total_aircraft > 0) {
pills.push('<div class="stat-pill"><span class="v">' + fmtBigPlain(data.domestic_flights.total_aircraft) + '</span><span class="l">Airborne</span></div>');
}
if (data.traffic_flow && !data.traffic_flow.error && data.traffic_flow.count > 0) {
var tAvg = data.traffic_flow.global_avg_congestion || 0;
pills.push('<div class="stat-pill"><span class="v' + (tAvg >= 40 ? ' crit' : tAvg >= 20 ? ' warn' : '') + '">' + tAvg.toFixed(0) + '%</span><span class="l">Traffic</span></div>');
}
if (data.webcams && !data.webcams.error && data.webcams.count > 0) {
pills.push('<div class="stat-pill"><span class="v">' + data.webcams.count + '</span><span class="l">Cams</span></div>');
}
$('#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 += '<div class="sh">AIR TRAFFIC</div>';
h += '<div class="mini-row"><div class="mini-box"><div class="v">' + fmtBigPlain(df.total_aircraft) + '</div><div class="l">Airborne</div></div></div>';
var regions = df.by_region || {};
h += '<table class="dtable"><thead><tr><th>Region</th><th>Total</th><th>Commercial</th><th>General</th></tr></thead><tbody>';
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 += '<tr><td class="bright">' + esc(rk.replace(/_/g, ' ')) + '</td><td>' + (rv.count || 0) + '</td><td class="dim">' + (rv.commercial || 0) + '</td><td class="dim">' + (rv.general || 0) + '</td></tr>';
});
h += '</tbody></table>';
var busiest = df.busiest_origins || [];
if (busiest.length) {
h += '<div class="sub">Busiest Origins</div><table class="dtable"><thead><tr><th>Country</th><th>Aircraft</th></tr></thead><tbody>';
busiest.slice(0, 10).forEach(function(b) {
h += '<tr><td class="bright">' + esc(b.country) + '</td><td>' + b.count + '</td></tr>';
});
h += '</tbody></table>';
}
}
// ── TRAFFIC ──
if (data.traffic_flow && !data.traffic_flow.error && data.traffic_flow.count > 0) {
var tf = data.traffic_flow;
h += '<div class="sh">ROAD TRAFFIC</div>';
h += '<div class="mini-row">';
var avgCls = tf.global_avg_congestion >= 40 ? ' crit' : tf.global_avg_congestion >= 20 ? ' warn' : '';
h += '<div class="mini-box"><div class="v' + avgCls + '">' + fmtNum(tf.global_avg_congestion, 0) + '%</div><div class="l">Avg Congestion</div></div>';
h += '<div class="mini-box"><div class="v">' + tf.count + '</div><div class="l">Cities</div></div>';
h += '</div>';
h += '<table class="dtable"><thead><tr><th>City</th><th>Cong%</th><th>Speed</th></tr></thead><tbody>';
(tf.cities || []).forEach(function(c) {
var cls = c.congestion_pct >= 50 ? 'crit' : c.congestion_pct >= 25 ? 'warn' : 'dim';
h += '<tr><td class="bright">' + esc(c.name) + ' <span class="dim">' + c.country + '</span></td><td class="' + cls + '">' + c.congestion_pct + '%</td><td class="dim">' + c.current_speed_kmh + '</td></tr>';
});
h += '</tbody></table>';
}
// ── WEBCAMS ──
if (data.webcams && !data.webcams.error && data.webcams.count > 0) {
var wc = data.webcams;
h += '<div class="sh">CCTV / WEBCAMS</div>';
h += '<div class="dim" style="font-size:0.65rem;padding:2px 0">' + wc.count + ' cameras (' + esc(wc.category || 'traffic') + ')</div>';
(wc.cameras || []).slice(0, 12).forEach(function(cam) {
h += '<div style="padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04)">';
h += '<div class="bright" style="font-size:0.7rem">' + esc(cam.title || 'Camera') + '</div>';
h += '<div class="dim" style="font-size:0.6rem">' + esc(cam.city || '') + (cam.country ? ', ' + esc(cam.country) : '') + '</div>';
h += '</div>';
});
}
// ── AI SITUATION BRIEF ──
if (data.situation_brief && !data.situation_brief.error && data.situation_brief.brief) {
var sb = data.situation_brief;
h += '<div class="sh">AI SITUATION BRIEF</div>';
var aiTag = sb.ai_generated ? '<span style="color:var(--purple);font-size:0.55rem;font-weight:600;letter-spacing:0.5px"> ' + esc(sb.model) + '</span>' : '<span class="dim" style="font-size:0.55rem"> metrics fallback</span>';
h += '<div style="font-size:0.6rem;padding:2px 0;opacity:0.5">Generated ' + ago(new Date(sb.timestamp).getTime()) + ' ago ' + aiTag + '</div>';
h += '<div style="font-size:0.7rem;line-height:1.5;padding:4px 0;white-space:pre-wrap;color:rgba(255,255,255,0.85)">' + esc(sb.brief) + '</div>';
}
$('#drawerBody').innerHTML = safe(h);
// Attach click delegation for data-click rows
+121 -2
View File
@@ -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(),
}
+210
View File
@@ -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(),
}
+89
View File
@@ -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(),
}