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:
@@ -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(),
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
Reference in New Issue
Block a user