feat: add strategic synthesis layer — Phase 11 (+4 = 68 tools)
- intel_strategic_posture: composite risk from 9 weighted domains (military, political, conflict, infrastructure, economic, cyber, health, climate, space) with per-domain scoring and top threats - intel_world_brief: structured daily intelligence summary aggregating posture, focal points, news clusters, anomalies, trending threats - intel_fleet_report: naval fleet activity combining theater posture, waterway status, military surge, and readiness scoring - intel_population_exposure: population at risk near active events (earthquakes, wildfires, conflict) using 105-city dataset (1B pop) New files: analysis/posture.py, analysis/world_brief.py, analysis/exposure.py, sources/fleet.py, config/population.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7359c03275
commit
2969dfc950
@@ -0,0 +1,179 @@
|
||||
"""Population exposure analysis near active events.
|
||||
|
||||
Estimates population at risk by finding major cities within a radius of
|
||||
active earthquakes, wildfires, and conflict events. Uses Haversine formula
|
||||
for distance calculation and a static dataset of ~120 major cities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.analysis.exposure")
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Haversine distance in kilometers."""
|
||||
R = 6371.0
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
|
||||
return R * 2 * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
async def _safe(coro, label: str) -> dict:
|
||||
try:
|
||||
return await coro
|
||||
except Exception as exc:
|
||||
logger.warning("Exposure: %s failed: %s", label, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _find_exposed_cities(
|
||||
events: list[dict],
|
||||
cities: list[dict],
|
||||
radius_km: float,
|
||||
) -> list[dict]:
|
||||
"""Find cities within radius_km of any event. Returns unique cities with nearest event."""
|
||||
exposed: dict[str, dict] = {} # city_name -> info
|
||||
|
||||
for event in events:
|
||||
elat = event.get("lat")
|
||||
elon = event.get("lon")
|
||||
if elat is None or elon is None:
|
||||
continue
|
||||
|
||||
for city in cities:
|
||||
dist = _haversine_km(elat, elon, city["lat"], city["lon"])
|
||||
if dist <= radius_km:
|
||||
cname = city["name"]
|
||||
if cname not in exposed or dist < exposed[cname]["distance_km"]:
|
||||
exposed[cname] = {
|
||||
"city": cname,
|
||||
"country": city["country"],
|
||||
"population": city["pop"],
|
||||
"distance_km": round(dist, 1),
|
||||
"nearest_event": event.get("type", "unknown"),
|
||||
"event_detail": event.get("detail", ""),
|
||||
}
|
||||
|
||||
return sorted(exposed.values(), key=lambda c: c["distance_km"])
|
||||
|
||||
|
||||
async def fetch_population_exposure(
|
||||
fetcher,
|
||||
radius_km: float = 200.0,
|
||||
event_types: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Estimate population exposure near active events.
|
||||
|
||||
Gathers active earthquakes (M4.5+), wildfires, and conflict events,
|
||||
then finds major cities within radius_km of each event.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher.
|
||||
radius_km: Search radius in km (default 200).
|
||||
event_types: Filter to specific types: earthquake, wildfire, conflict.
|
||||
Default: all three.
|
||||
"""
|
||||
from ..config.population import MAJOR_CITIES
|
||||
from ..sources import seismology, wildfire, conflict
|
||||
|
||||
types = set(event_types or ["earthquake", "wildfire", "conflict"])
|
||||
|
||||
coros = {}
|
||||
if "earthquake" in types:
|
||||
coros["earthquake"] = seismology.fetch_earthquakes(fetcher, min_magnitude=4.5, hours=48, limit=50)
|
||||
if "wildfire" in types:
|
||||
coros["wildfire"] = wildfire.fetch_wildfires(fetcher)
|
||||
if "conflict" in types:
|
||||
coros["conflict"] = conflict.fetch_acled_events(fetcher, days=7, limit=200)
|
||||
|
||||
results = {}
|
||||
if coros:
|
||||
fetched = await asyncio.gather(
|
||||
*[_safe(c, k) for k, c in coros.items()]
|
||||
)
|
||||
for key, data in zip(coros.keys(), fetched):
|
||||
results[key] = data
|
||||
|
||||
# Normalize events to [{lat, lon, type, detail}]
|
||||
events: list[dict] = []
|
||||
|
||||
# Earthquakes
|
||||
for eq in results.get("earthquake", {}).get("earthquakes", []):
|
||||
lat = eq.get("latitude") or eq.get("lat")
|
||||
lon = eq.get("longitude") or eq.get("lon")
|
||||
if lat is not None and lon is not None:
|
||||
events.append({
|
||||
"lat": float(lat),
|
||||
"lon": float(lon),
|
||||
"type": "earthquake",
|
||||
"detail": f"M{eq.get('magnitude', '?')} {eq.get('place', '')}",
|
||||
})
|
||||
|
||||
# Wildfires
|
||||
for region_data in results.get("wildfire", {}).get("regions", []):
|
||||
for fire in region_data.get("detections", []):
|
||||
lat = fire.get("latitude") or fire.get("lat")
|
||||
lon = fire.get("longitude") or fire.get("lon")
|
||||
if lat is not None and lon is not None:
|
||||
events.append({
|
||||
"lat": float(lat),
|
||||
"lon": float(lon),
|
||||
"type": "wildfire",
|
||||
"detail": f"FRP {fire.get('frp', '?')} in {region_data.get('region', '?')}",
|
||||
})
|
||||
|
||||
# Conflict
|
||||
for ev in results.get("conflict", {}).get("events", []):
|
||||
lat = ev.get("latitude") or ev.get("lat")
|
||||
lon = ev.get("longitude") or ev.get("lon")
|
||||
if lat is not None and lon is not None:
|
||||
events.append({
|
||||
"lat": float(lat),
|
||||
"lon": float(lon),
|
||||
"type": "conflict",
|
||||
"detail": f"{ev.get('event_type', 'conflict')}: {ev.get('location', ev.get('admin1', ''))}",
|
||||
})
|
||||
|
||||
# Find exposed cities
|
||||
exposed_cities = _find_exposed_cities(events, MAJOR_CITIES, radius_km)
|
||||
total_exposed_pop = sum(c["population"] for c in exposed_cities)
|
||||
|
||||
# Group by event type
|
||||
by_type: dict[str, int] = {}
|
||||
for c in exposed_cities:
|
||||
t = c["nearest_event"]
|
||||
by_type[t] = by_type.get(t, 0) + c["population"]
|
||||
|
||||
# Group by country
|
||||
by_country: dict[str, int] = {}
|
||||
for c in exposed_cities:
|
||||
country = c["country"]
|
||||
by_country[country] = by_country.get(country, 0) + c["population"]
|
||||
|
||||
return {
|
||||
"exposed_cities": exposed_cities,
|
||||
"exposed_city_count": len(exposed_cities),
|
||||
"total_exposed_population": total_exposed_pop,
|
||||
"total_exposed_population_formatted": _format_pop(total_exposed_pop),
|
||||
"by_event_type": {k: _format_pop(v) for k, v in sorted(by_type.items(), key=lambda x: x[1], reverse=True)},
|
||||
"by_country": {k: _format_pop(v) for k, v in sorted(by_country.items(), key=lambda x: x[1], reverse=True)[:10]},
|
||||
"events_analyzed": len(events),
|
||||
"radius_km": radius_km,
|
||||
"event_types": sorted(types),
|
||||
"source": "population-exposure-analysis",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _format_pop(pop: int) -> str:
|
||||
if pop >= 1_000_000:
|
||||
return f"{pop / 1_000_000:.1f}M"
|
||||
elif pop >= 1_000:
|
||||
return f"{pop / 1_000:.0f}K"
|
||||
return str(pop)
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Strategic posture assessment — composite risk from all intelligence domains.
|
||||
|
||||
Aggregates scores from 9 domains into an overall global risk assessment.
|
||||
Each domain is scored 0-100 with a weight, producing a weighted composite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.analysis.posture")
|
||||
|
||||
|
||||
# Domain weights (sum to 1.0)
|
||||
DOMAIN_WEIGHTS: dict[str, float] = {
|
||||
"military": 0.18,
|
||||
"political": 0.16,
|
||||
"conflict": 0.16,
|
||||
"infrastructure": 0.10,
|
||||
"economic": 0.10,
|
||||
"cyber": 0.08,
|
||||
"health": 0.07,
|
||||
"climate": 0.08,
|
||||
"space": 0.07,
|
||||
}
|
||||
|
||||
|
||||
async def _safe(coro, label: str) -> dict:
|
||||
try:
|
||||
return await coro
|
||||
except Exception as exc:
|
||||
logger.warning("Posture: %s failed: %s", label, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _risk_level(score: float) -> str:
|
||||
if score >= 75:
|
||||
return "CRITICAL"
|
||||
elif score >= 55:
|
||||
return "HIGH"
|
||||
elif score >= 35:
|
||||
return "ELEVATED"
|
||||
elif score >= 20:
|
||||
return "GUARDED"
|
||||
return "LOW"
|
||||
|
||||
|
||||
def _score_military(surge_data: dict, posture_data: dict) -> tuple[float, list[str]]:
|
||||
"""Score military domain 0-100 from surge + theater posture."""
|
||||
signals: list[str] = []
|
||||
score = 0.0
|
||||
|
||||
surge_count = surge_data.get("surge_count", 0)
|
||||
if surge_count > 0:
|
||||
score += min(50.0, surge_count * 20.0)
|
||||
surges = surge_data.get("surges", [])
|
||||
for s in surges[:3]:
|
||||
signals.append(f"Surge: {s.get('region', 'unknown')} ({s.get('aircraft_count', '?')} aircraft)")
|
||||
|
||||
theaters = posture_data.get("theaters", [])
|
||||
active_theaters = [t for t in theaters if t.get("aircraft_count", 0) > 10]
|
||||
score += min(50.0, len(active_theaters) * 12.0)
|
||||
for t in active_theaters[:3]:
|
||||
signals.append(f"{t.get('name', '?')}: {t.get('aircraft_count', 0)} aircraft")
|
||||
|
||||
return min(100.0, score), signals
|
||||
|
||||
|
||||
def _score_political(instability_data: dict) -> tuple[float, list[str]]:
|
||||
"""Score political domain 0-100 from instability index."""
|
||||
signals: list[str] = []
|
||||
countries = instability_data.get("countries", [])
|
||||
if not countries:
|
||||
return 0.0, signals
|
||||
|
||||
# Average of top-5 CII scores
|
||||
top5 = sorted(countries, key=lambda c: c.get("instability_index", 0), reverse=True)[:5]
|
||||
avg = sum(c.get("instability_index", 0) for c in top5) / len(top5)
|
||||
for c in top5[:3]:
|
||||
signals.append(f"{c.get('country_name', c.get('country_code', '?'))}: CII {c.get('instability_index', 0)}")
|
||||
|
||||
return min(100.0, avg), signals
|
||||
|
||||
|
||||
def _score_conflict(hotspot_data: dict) -> tuple[float, list[str]]:
|
||||
"""Score conflict domain 0-100 from hotspot escalation."""
|
||||
signals: list[str] = []
|
||||
hotspots = hotspot_data.get("hotspots", [])
|
||||
if not hotspots:
|
||||
return 0.0, signals
|
||||
|
||||
top5 = sorted(hotspots, key=lambda h: h.get("score", 0), reverse=True)[:5]
|
||||
avg = sum(h.get("score", 0) for h in top5) / len(top5)
|
||||
for h in top5[:3]:
|
||||
signals.append(f"{h.get('name', '?')}: {h.get('score', 0)}/100")
|
||||
|
||||
return min(100.0, avg), signals
|
||||
|
||||
|
||||
def _score_infrastructure(cable_data: dict, outage_data: dict) -> tuple[float, list[str]]:
|
||||
"""Score infrastructure 0-100 from cable health + outages."""
|
||||
signals: list[str] = []
|
||||
score = 0.0
|
||||
|
||||
corridors = cable_data.get("corridors", {})
|
||||
at_risk = [n for n, c in corridors.items() if isinstance(c, dict) and c.get("status_score", 0) >= 2]
|
||||
score += min(50.0, len(at_risk) * 15.0)
|
||||
for c in at_risk[:2]:
|
||||
signals.append(f"Cable: {c} at risk")
|
||||
|
||||
outage_count = outage_data.get("outage_count", 0)
|
||||
score += min(50.0, outage_count * 5.0)
|
||||
if outage_count > 0:
|
||||
signals.append(f"{outage_count} internet outages")
|
||||
|
||||
return min(100.0, score), signals
|
||||
|
||||
|
||||
def _score_economic(shipping_data: dict) -> tuple[float, list[str]]:
|
||||
"""Score economic 0-100 from shipping stress."""
|
||||
signals: list[str] = []
|
||||
stress = shipping_data.get("stress_score", 0)
|
||||
assessment = shipping_data.get("assessment", "unknown")
|
||||
if stress > 0:
|
||||
signals.append(f"Shipping stress: {stress} ({assessment})")
|
||||
return min(100.0, stress), signals
|
||||
|
||||
|
||||
def _score_cyber(cyber_data: dict) -> tuple[float, list[str]]:
|
||||
"""Score cyber 0-100 from threat intelligence."""
|
||||
signals: list[str] = []
|
||||
threat_count = cyber_data.get("threat_count", 0)
|
||||
score = min(100.0, threat_count * 2.0)
|
||||
if threat_count > 0:
|
||||
signals.append(f"{threat_count} active threats")
|
||||
by_source = cyber_data.get("by_source", {})
|
||||
for src, count in sorted(by_source.items(), key=lambda x: x[1], reverse=True)[:2]:
|
||||
signals.append(f"{src}: {count}")
|
||||
return score, signals
|
||||
|
||||
|
||||
def _score_health(health_data: dict) -> tuple[float, list[str]]:
|
||||
"""Score health 0-100 from disease outbreaks."""
|
||||
signals: list[str] = []
|
||||
count = health_data.get("count", 0)
|
||||
high_concern = health_data.get("high_concern_count", 0)
|
||||
score = min(100.0, high_concern * 25.0 + count * 3.0)
|
||||
if high_concern > 0:
|
||||
signals.append(f"{high_concern} high-concern pathogen alerts")
|
||||
if count > 0:
|
||||
signals.append(f"{count} outbreak reports")
|
||||
return score, signals
|
||||
|
||||
|
||||
def _score_climate(climate_data: dict) -> tuple[float, list[str]]:
|
||||
"""Score climate 0-100 from anomalies."""
|
||||
signals: list[str] = []
|
||||
anomalies = climate_data.get("anomalies", [])
|
||||
extreme = [a for a in anomalies if abs(a.get("temp_deviation_c", 0)) > 3.0]
|
||||
score = min(100.0, len(extreme) * 15.0 + len(anomalies) * 3.0)
|
||||
for a in extreme[:2]:
|
||||
signals.append(f"{a.get('zone', '?')}: {a.get('temp_deviation_c', 0):+.1f}°C")
|
||||
return score, signals
|
||||
|
||||
|
||||
def _score_space(sw_data: dict) -> tuple[float, list[str]]:
|
||||
"""Score space weather 0-100 from Kp index."""
|
||||
signals: list[str] = []
|
||||
kp = sw_data.get("current_kp")
|
||||
if kp is None:
|
||||
return 0.0, signals
|
||||
# Kp 0-9 mapped to 0-100
|
||||
score = min(100.0, kp * 11.0)
|
||||
signals.append(f"Kp={kp} ({sw_data.get('kp_level', 'Unknown')})")
|
||||
return score, signals
|
||||
|
||||
|
||||
async def fetch_strategic_posture(fetcher) -> dict:
|
||||
"""Compute composite strategic posture from all intelligence domains.
|
||||
|
||||
Calls 9 existing source functions in parallel, scores each domain 0-100,
|
||||
and produces a weighted composite risk assessment.
|
||||
"""
|
||||
from ..sources import space_weather, infrastructure, shipping, cyber, health, climate
|
||||
from ..sources import intelligence, military
|
||||
|
||||
(
|
||||
surge_data,
|
||||
posture_data,
|
||||
instability_data,
|
||||
hotspot_data,
|
||||
cable_data,
|
||||
outage_data,
|
||||
shipping_data,
|
||||
cyber_data,
|
||||
health_data,
|
||||
climate_data,
|
||||
sw_data,
|
||||
) = await asyncio.gather(
|
||||
_safe(intelligence.fetch_military_surge(fetcher), "military_surge"),
|
||||
_safe(military.fetch_theater_posture(fetcher), "theater_posture"),
|
||||
_safe(intelligence.fetch_instability_index(fetcher), "instability"),
|
||||
_safe(intelligence.fetch_hotspot_escalation(fetcher), "hotspot_escalation"),
|
||||
_safe(infrastructure.fetch_cable_health(fetcher), "cable_health"),
|
||||
_safe(infrastructure.fetch_internet_outages(fetcher), "internet_outages"),
|
||||
_safe(shipping.fetch_shipping_index(fetcher), "shipping"),
|
||||
_safe(cyber.fetch_cyber_threats(fetcher), "cyber"),
|
||||
_safe(health.fetch_disease_outbreaks(fetcher), "health"),
|
||||
_safe(climate.fetch_climate_anomalies(fetcher), "climate"),
|
||||
_safe(space_weather.fetch_space_weather(fetcher), "space_weather"),
|
||||
)
|
||||
|
||||
# Score each domain
|
||||
mil_score, mil_signals = _score_military(surge_data, posture_data)
|
||||
pol_score, pol_signals = _score_political(instability_data)
|
||||
con_score, con_signals = _score_conflict(hotspot_data)
|
||||
inf_score, inf_signals = _score_infrastructure(cable_data, outage_data)
|
||||
eco_score, eco_signals = _score_economic(shipping_data)
|
||||
cyb_score, cyb_signals = _score_cyber(cyber_data)
|
||||
hlt_score, hlt_signals = _score_health(health_data)
|
||||
clm_score, clm_signals = _score_climate(climate_data)
|
||||
spc_score, spc_signals = _score_space(sw_data)
|
||||
|
||||
domain_scores = {
|
||||
"military": {"score": round(mil_score, 1), "level": _risk_level(mil_score), "signals": mil_signals},
|
||||
"political": {"score": round(pol_score, 1), "level": _risk_level(pol_score), "signals": pol_signals},
|
||||
"conflict": {"score": round(con_score, 1), "level": _risk_level(con_score), "signals": con_signals},
|
||||
"infrastructure": {"score": round(inf_score, 1), "level": _risk_level(inf_score), "signals": inf_signals},
|
||||
"economic": {"score": round(eco_score, 1), "level": _risk_level(eco_score), "signals": eco_signals},
|
||||
"cyber": {"score": round(cyb_score, 1), "level": _risk_level(cyb_score), "signals": cyb_signals},
|
||||
"health": {"score": round(hlt_score, 1), "level": _risk_level(hlt_score), "signals": hlt_signals},
|
||||
"climate": {"score": round(clm_score, 1), "level": _risk_level(clm_score), "signals": clm_signals},
|
||||
"space": {"score": round(spc_score, 1), "level": _risk_level(spc_score), "signals": spc_signals},
|
||||
}
|
||||
|
||||
# Weighted composite
|
||||
composite = sum(
|
||||
domain_scores[domain]["score"] * weight
|
||||
for domain, weight in DOMAIN_WEIGHTS.items()
|
||||
)
|
||||
composite = min(100.0, max(0.0, composite))
|
||||
|
||||
# Top threats: highest-scored signals across all domains
|
||||
all_signals = []
|
||||
for domain, info in domain_scores.items():
|
||||
for sig in info["signals"]:
|
||||
all_signals.append({"domain": domain, "signal": sig, "domain_score": info["score"]})
|
||||
all_signals.sort(key=lambda s: s["domain_score"], reverse=True)
|
||||
|
||||
return {
|
||||
"composite_score": round(composite, 1),
|
||||
"risk_level": _risk_level(composite),
|
||||
"domain_scores": domain_scores,
|
||||
"weights": DOMAIN_WEIGHTS,
|
||||
"top_threats": all_signals[:10],
|
||||
"domains_assessed": len(DOMAIN_WEIGHTS),
|
||||
"source": "strategic-posture-assessment",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Structured daily intelligence summary.
|
||||
|
||||
Aggregates strategic posture, focal points, news clusters, temporal anomalies,
|
||||
and keyword spikes into a structured briefing document. Pure data aggregation
|
||||
with no LLM dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.analysis.world_brief")
|
||||
|
||||
|
||||
async def _safe(coro, label: str) -> dict:
|
||||
try:
|
||||
return await coro
|
||||
except Exception as exc:
|
||||
logger.warning("World brief: %s failed: %s", label, exc)
|
||||
return {}
|
||||
|
||||
|
||||
async def fetch_world_brief(fetcher) -> dict:
|
||||
"""Generate a structured daily intelligence summary.
|
||||
|
||||
Calls 5 analysis functions in parallel and assembles a comprehensive
|
||||
briefing with sections: risk overview, focal areas, top stories,
|
||||
anomalies, and trending threats.
|
||||
"""
|
||||
from .posture import fetch_strategic_posture
|
||||
from ..sources import intelligence
|
||||
from .clustering import fetch_news_clusters
|
||||
from .spikes import fetch_keyword_spikes
|
||||
|
||||
(
|
||||
posture_data,
|
||||
focal_data,
|
||||
cluster_data,
|
||||
anomaly_data,
|
||||
spike_data,
|
||||
) = await asyncio.gather(
|
||||
_safe(fetch_strategic_posture(fetcher), "strategic_posture"),
|
||||
_safe(intelligence.fetch_focal_points(fetcher), "focal_points"),
|
||||
_safe(fetch_news_clusters(fetcher), "news_clusters"),
|
||||
_safe(intelligence.fetch_temporal_anomalies(fetcher), "temporal_anomalies"),
|
||||
_safe(fetch_keyword_spikes(fetcher), "keyword_spikes"),
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Section 1: Risk Overview (from strategic posture)
|
||||
risk_overview = {
|
||||
"composite_score": posture_data.get("composite_score", 0),
|
||||
"risk_level": posture_data.get("risk_level", "UNKNOWN"),
|
||||
"domain_summary": {},
|
||||
}
|
||||
for domain, info in posture_data.get("domain_scores", {}).items():
|
||||
risk_overview["domain_summary"][domain] = {
|
||||
"score": info.get("score", 0),
|
||||
"level": info.get("level", "UNKNOWN"),
|
||||
}
|
||||
|
||||
# Section 2: Focal Areas (where attention should be)
|
||||
focal_areas = []
|
||||
for fp in (focal_data.get("focal_points") or [])[:8]:
|
||||
focal_areas.append({
|
||||
"entity": fp.get("entity", "unknown"),
|
||||
"entity_type": fp.get("entity_type", "unknown"),
|
||||
"signal_count": fp.get("signal_count", 0),
|
||||
"domains": fp.get("domains", []),
|
||||
})
|
||||
|
||||
# Section 3: Top Stories (from news clusters)
|
||||
top_stories = []
|
||||
for cluster in (cluster_data.get("clusters") or [])[:6]:
|
||||
top_stories.append({
|
||||
"topic_keywords": cluster.get("keywords", [])[:5],
|
||||
"article_count": cluster.get("article_count", 0),
|
||||
"sources": cluster.get("sources", [])[:3],
|
||||
"headline": (cluster.get("items") or [{}])[0].get("title", "") if cluster.get("items") else "",
|
||||
})
|
||||
|
||||
# Section 4: Anomalies (what's unusual today)
|
||||
anomalies = []
|
||||
for a in (anomaly_data.get("anomalies") or [])[:6]:
|
||||
anomalies.append({
|
||||
"metric": a.get("key", "unknown"),
|
||||
"z_score": a.get("z_score", 0),
|
||||
"current_value": a.get("current_value", 0),
|
||||
"baseline_mean": a.get("baseline_mean", 0),
|
||||
"description": a.get("description", ""),
|
||||
})
|
||||
|
||||
# Section 5: Trending Threats (from keyword spikes + CVE/APT extraction)
|
||||
trending = {
|
||||
"spikes": (spike_data.get("spikes") or [])[:8],
|
||||
"spike_count": spike_data.get("spike_count", 0),
|
||||
"cve_mentions": spike_data.get("cve_mentions", []),
|
||||
"apt_mentions": spike_data.get("apt_mentions", []),
|
||||
}
|
||||
|
||||
# Top threats from posture
|
||||
top_threats = posture_data.get("top_threats", [])[:5]
|
||||
|
||||
return {
|
||||
"date": now.strftime("%Y-%m-%d"),
|
||||
"generated_at": now.isoformat(),
|
||||
"risk_overview": risk_overview,
|
||||
"top_threats": top_threats,
|
||||
"focal_areas": focal_areas,
|
||||
"focal_area_count": len(focal_areas),
|
||||
"top_stories": top_stories,
|
||||
"top_story_count": len(top_stories),
|
||||
"anomalies": anomalies,
|
||||
"anomaly_count": len(anomalies),
|
||||
"trending": trending,
|
||||
"source": "world-intelligence-brief",
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Major world cities dataset for population exposure analysis.
|
||||
|
||||
~120 cities with metro population > 2M. Approximate UN 2024 estimates.
|
||||
Used for exposure estimation near conflict/disaster zones, not precise demographics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MAJOR_CITIES: list[dict] = [
|
||||
# East Asia
|
||||
{"name": "Tokyo", "country": "JPN", "lat": 35.68, "lon": 139.69, "pop": 37400000},
|
||||
{"name": "Shanghai", "country": "CHN", "lat": 31.23, "lon": 121.47, "pop": 29200000},
|
||||
{"name": "Beijing", "country": "CHN", "lat": 39.91, "lon": 116.40, "pop": 21500000},
|
||||
{"name": "Chongqing", "country": "CHN", "lat": 29.56, "lon": 106.55, "pop": 17600000},
|
||||
{"name": "Guangzhou", "country": "CHN", "lat": 23.13, "lon": 113.26, "pop": 14000000},
|
||||
{"name": "Tianjin", "country": "CHN", "lat": 39.14, "lon": 117.18, "pop": 13900000},
|
||||
{"name": "Shenzhen", "country": "CHN", "lat": 22.54, "lon": 114.06, "pop": 13400000},
|
||||
{"name": "Wuhan", "country": "CHN", "lat": 30.59, "lon": 114.31, "pop": 11100000},
|
||||
{"name": "Chengdu", "country": "CHN", "lat": 30.57, "lon": 104.07, "pop": 10900000},
|
||||
{"name": "Nanjing", "country": "CHN", "lat": 32.06, "lon": 118.80, "pop": 9400000},
|
||||
{"name": "Hangzhou", "country": "CHN", "lat": 30.27, "lon": 120.15, "pop": 8300000},
|
||||
{"name": "Xi'an", "country": "CHN", "lat": 34.26, "lon": 108.94, "pop": 7700000},
|
||||
{"name": "Shenyang", "country": "CHN", "lat": 41.80, "lon": 123.43, "pop": 7200000},
|
||||
{"name": "Hong Kong", "country": "CHN", "lat": 22.32, "lon": 114.17, "pop": 7500000},
|
||||
{"name": "Osaka", "country": "JPN", "lat": 34.69, "lon": 135.50, "pop": 19100000},
|
||||
{"name": "Seoul", "country": "KOR", "lat": 37.57, "lon": 126.98, "pop": 9800000},
|
||||
{"name": "Busan", "country": "KOR", "lat": 35.18, "lon": 129.08, "pop": 3400000},
|
||||
{"name": "Taipei", "country": "TWN", "lat": 25.03, "lon": 121.57, "pop": 7000000},
|
||||
{"name": "Pyongyang", "country": "PRK", "lat": 39.02, "lon": 125.75, "pop": 3200000},
|
||||
# South Asia
|
||||
{"name": "Delhi", "country": "IND", "lat": 28.64, "lon": 77.22, "pop": 32900000},
|
||||
{"name": "Mumbai", "country": "IND", "lat": 19.08, "lon": 72.88, "pop": 21700000},
|
||||
{"name": "Kolkata", "country": "IND", "lat": 22.57, "lon": 88.36, "pop": 15100000},
|
||||
{"name": "Bangalore", "country": "IND", "lat": 12.97, "lon": 77.59, "pop": 13200000},
|
||||
{"name": "Chennai", "country": "IND", "lat": 13.08, "lon": 80.27, "pop": 11500000},
|
||||
{"name": "Hyderabad", "country": "IND", "lat": 17.38, "lon": 78.49, "pop": 10500000},
|
||||
{"name": "Ahmedabad", "country": "IND", "lat": 23.02, "lon": 72.57, "pop": 8600000},
|
||||
{"name": "Pune", "country": "IND", "lat": 18.52, "lon": 73.86, "pop": 7800000},
|
||||
{"name": "Dhaka", "country": "BGD", "lat": 23.81, "lon": 90.41, "pop": 23900000},
|
||||
{"name": "Karachi", "country": "PAK", "lat": 24.86, "lon": 67.01, "pop": 17100000},
|
||||
{"name": "Lahore", "country": "PAK", "lat": 31.55, "lon": 74.35, "pop": 13500000},
|
||||
{"name": "Islamabad", "country": "PAK", "lat": 33.69, "lon": 73.04, "pop": 3600000},
|
||||
{"name": "Colombo", "country": "LKA", "lat": 6.93, "lon": 79.85, "pop": 2800000},
|
||||
{"name": "Kabul", "country": "AFG", "lat": 34.53, "lon": 69.17, "pop": 4600000},
|
||||
# Southeast Asia
|
||||
{"name": "Manila", "country": "PHL", "lat": 14.60, "lon": 120.98, "pop": 14400000},
|
||||
{"name": "Jakarta", "country": "IDN", "lat": -6.21, "lon": 106.85, "pop": 11200000},
|
||||
{"name": "Bangkok", "country": "THA", "lat": 13.76, "lon": 100.50, "pop": 11000000},
|
||||
{"name": "Ho Chi Minh City", "country": "VNM", "lat": 10.82, "lon": 106.63, "pop": 9300000},
|
||||
{"name": "Hanoi", "country": "VNM", "lat": 21.03, "lon": 105.85, "pop": 5100000},
|
||||
{"name": "Yangon", "country": "MMR", "lat": 16.87, "lon": 96.20, "pop": 5800000},
|
||||
{"name": "Singapore", "country": "SGP", "lat": 1.35, "lon": 103.82, "pop": 6000000},
|
||||
{"name": "Kuala Lumpur", "country": "MYS", "lat": 3.14, "lon": 101.69, "pop": 8400000},
|
||||
# Middle East
|
||||
{"name": "Cairo", "country": "EGY", "lat": 30.04, "lon": 31.24, "pop": 22600000},
|
||||
{"name": "Istanbul", "country": "TUR", "lat": 41.01, "lon": 28.98, "pop": 16000000},
|
||||
{"name": "Tehran", "country": "IRN", "lat": 35.69, "lon": 51.39, "pop": 9400000},
|
||||
{"name": "Baghdad", "country": "IRQ", "lat": 33.31, "lon": 44.37, "pop": 7500000},
|
||||
{"name": "Riyadh", "country": "SAU", "lat": 24.69, "lon": 46.72, "pop": 7700000},
|
||||
{"name": "Jeddah", "country": "SAU", "lat": 21.49, "lon": 39.19, "pop": 4700000},
|
||||
{"name": "Ankara", "country": "TUR", "lat": 39.93, "lon": 32.85, "pop": 5700000},
|
||||
{"name": "Tel Aviv", "country": "ISR", "lat": 32.09, "lon": 34.78, "pop": 4300000},
|
||||
{"name": "Amman", "country": "JOR", "lat": 31.95, "lon": 35.93, "pop": 4200000},
|
||||
{"name": "Beirut", "country": "LBN", "lat": 33.89, "lon": 35.50, "pop": 2400000},
|
||||
{"name": "Damascus", "country": "SYR", "lat": 33.51, "lon": 36.29, "pop": 2600000},
|
||||
{"name": "Aleppo", "country": "SYR", "lat": 36.20, "lon": 37.16, "pop": 2100000},
|
||||
{"name": "Sanaa", "country": "YEM", "lat": 15.37, "lon": 44.19, "pop": 3200000},
|
||||
{"name": "Dubai", "country": "ARE", "lat": 25.20, "lon": 55.27, "pop": 3600000},
|
||||
{"name": "Doha", "country": "QAT", "lat": 25.29, "lon": 51.53, "pop": 2400000},
|
||||
{"name": "Kuwait City", "country": "KWT", "lat": 29.38, "lon": 47.99, "pop": 3100000},
|
||||
# Africa
|
||||
{"name": "Lagos", "country": "NGA", "lat": 6.52, "lon": 3.38, "pop": 15900000},
|
||||
{"name": "Kinshasa", "country": "COD", "lat": -4.32, "lon": 15.31, "pop": 17000000},
|
||||
{"name": "Luanda", "country": "AGO", "lat": -8.84, "lon": 13.23, "pop": 9000000},
|
||||
{"name": "Dar es Salaam", "country": "TZA", "lat": -6.79, "lon": 39.28, "pop": 7400000},
|
||||
{"name": "Nairobi", "country": "KEN", "lat": -1.29, "lon": 36.82, "pop": 5100000},
|
||||
{"name": "Addis Ababa", "country": "ETH", "lat": 9.02, "lon": 38.75, "pop": 5500000},
|
||||
{"name": "Abidjan", "country": "CIV", "lat": 5.36, "lon": -4.01, "pop": 5600000},
|
||||
{"name": "Khartoum", "country": "SDN", "lat": 15.59, "lon": 32.53, "pop": 6200000},
|
||||
{"name": "Johannesburg", "country": "ZAF", "lat": -26.20, "lon": 28.05, "pop": 6100000},
|
||||
{"name": "Cape Town", "country": "ZAF", "lat": -33.93, "lon": 18.42, "pop": 4800000},
|
||||
{"name": "Accra", "country": "GHA", "lat": 5.56, "lon": -0.19, "pop": 4500000},
|
||||
{"name": "Casablanca", "country": "MAR", "lat": 33.57, "lon": -7.59, "pop": 3800000},
|
||||
{"name": "Algiers", "country": "DZA", "lat": 36.75, "lon": 3.04, "pop": 3900000},
|
||||
{"name": "Mogadishu", "country": "SOM", "lat": 2.05, "lon": 45.32, "pop": 2600000},
|
||||
{"name": "Kampala", "country": "UGA", "lat": 0.31, "lon": 32.58, "pop": 3700000},
|
||||
{"name": "Dakar", "country": "SEN", "lat": 14.69, "lon": -17.44, "pop": 3900000},
|
||||
{"name": "Bamako", "country": "MLI", "lat": 12.64, "lon": -8.00, "pop": 2800000},
|
||||
{"name": "Ouagadougou", "country": "BFA", "lat": 12.37, "lon": -1.52, "pop": 3000000},
|
||||
# Europe
|
||||
{"name": "Moscow", "country": "RUS", "lat": 55.76, "lon": 37.62, "pop": 12700000},
|
||||
{"name": "London", "country": "GBR", "lat": 51.51, "lon": -0.13, "pop": 9500000},
|
||||
{"name": "Paris", "country": "FRA", "lat": 48.86, "lon": 2.35, "pop": 11200000},
|
||||
{"name": "Berlin", "country": "DEU", "lat": 52.52, "lon": 13.41, "pop": 3700000},
|
||||
{"name": "Madrid", "country": "ESP", "lat": 40.42, "lon": -3.70, "pop": 6800000},
|
||||
{"name": "Rome", "country": "ITA", "lat": 41.90, "lon": 12.50, "pop": 4300000},
|
||||
{"name": "Kyiv", "country": "UKR", "lat": 50.45, "lon": 30.52, "pop": 3000000},
|
||||
{"name": "Kharkiv", "country": "UKR", "lat": 49.99, "lon": 36.23, "pop": 1400000},
|
||||
{"name": "Warsaw", "country": "POL", "lat": 52.23, "lon": 21.01, "pop": 3100000},
|
||||
{"name": "Bucharest", "country": "ROU", "lat": 44.43, "lon": 26.10, "pop": 2200000},
|
||||
{"name": "St. Petersburg", "country": "RUS", "lat": 59.93, "lon": 30.32, "pop": 5600000},
|
||||
# Americas
|
||||
{"name": "Mexico City", "country": "MEX", "lat": 19.43, "lon": -99.13, "pop": 22300000},
|
||||
{"name": "São Paulo", "country": "BRA", "lat": -23.55, "lon": -46.63, "pop": 22200000},
|
||||
{"name": "Buenos Aires", "country": "ARG", "lat": -34.60, "lon": -58.38, "pop": 15500000},
|
||||
{"name": "Rio de Janeiro", "country": "BRA", "lat": -22.91, "lon": -43.17, "pop": 13600000},
|
||||
{"name": "Bogotá", "country": "COL", "lat": 4.71, "lon": -74.07, "pop": 11400000},
|
||||
{"name": "Lima", "country": "PER", "lat": -12.05, "lon": -77.04, "pop": 11200000},
|
||||
{"name": "Santiago", "country": "CHL", "lat": -33.45, "lon": -70.67, "pop": 7000000},
|
||||
{"name": "New York", "country": "USA", "lat": 40.71, "lon": -74.01, "pop": 18800000},
|
||||
{"name": "Los Angeles", "country": "USA", "lat": 34.05, "lon": -118.24, "pop": 12500000},
|
||||
{"name": "Chicago", "country": "USA", "lat": 41.88, "lon": -87.63, "pop": 8600000},
|
||||
{"name": "Houston", "country": "USA", "lat": 29.76, "lon": -95.37, "pop": 7100000},
|
||||
{"name": "Washington DC", "country": "USA", "lat": 38.91, "lon": -77.04, "pop": 6300000},
|
||||
{"name": "Toronto", "country": "CAN", "lat": 43.65, "lon": -79.38, "pop": 6700000},
|
||||
{"name": "Caracas", "country": "VEN", "lat": 10.49, "lon": -66.88, "pop": 3000000},
|
||||
{"name": "Havana", "country": "CUB", "lat": 23.11, "lon": -82.37, "pop": 2100000},
|
||||
{"name": "Quito", "country": "ECU", "lat": -0.18, "lon": -78.47, "pop": 2800000},
|
||||
{"name": "Guadalajara", "country": "MEX", "lat": 20.67, "lon": -103.35, "pop": 5300000},
|
||||
# Oceania
|
||||
{"name": "Sydney", "country": "AUS", "lat": -33.87, "lon": 151.21, "pop": 5400000},
|
||||
{"name": "Melbourne", "country": "AUS", "lat": -37.81, "lon": 144.96, "pop": 5200000},
|
||||
]
|
||||
@@ -17,6 +17,7 @@ Phase 7: Health, sanctions, elections, shipping, social, nuclear, alerts, trends
|
||||
Phase 8: Service status monitoring, RSS expansion (80+ feeds, 14 categories) (+1 = 56 tools).
|
||||
Phase 9: Geospatial datasets — military bases, ports, pipelines, nuclear facilities (+4 = 60 tools).
|
||||
Phase 10: NLP intelligence — entity extraction, event classification, news clustering, keyword spikes (+4 = 64 tools).
|
||||
Phase 11: Strategic synthesis — strategic posture, world brief, fleet report, population exposure (+4 = 68 tools).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -681,6 +682,37 @@ TOOLS: list[Tool] = [
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- Strategic Synthesis (4 tools) ---
|
||||
Tool(
|
||||
name="intel_strategic_posture",
|
||||
description="Composite global risk assessment from 9 intelligence domains: military, political, conflict, infrastructure, economic, cyber, health, climate, space. Weighted composite score 0-100 with per-domain breakdown and top threats.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
name="intel_world_brief",
|
||||
description="Structured daily intelligence summary: risk overview, focal areas, top story clusters, temporal anomalies, and trending threats. Comprehensive situational awareness in one call.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
name="intel_fleet_report",
|
||||
description="Naval fleet activity report aggregating theater posture (5 theaters), vessel snapshot (9 waterways), military surge detections, and naval base count. Readiness scoring.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
name="intel_population_exposure",
|
||||
description="Estimate population at risk near active events (earthquakes, wildfires, conflict). Finds major cities within radius and sums exposed population.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"radius_km": {"type": "number", "description": "Search radius in km (default: 200)", "default": 200},
|
||||
"event_types": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["earthquake", "wildfire", "conflict"]},
|
||||
"description": "Event types to include (default: all three)",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- System (1 tool) ---
|
||||
Tool(
|
||||
name="intel_status",
|
||||
@@ -948,6 +980,24 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
|
||||
status=arguments.get("status"),
|
||||
)
|
||||
|
||||
# Strategic Synthesis
|
||||
case "intel_strategic_posture":
|
||||
from .analysis.posture import fetch_strategic_posture
|
||||
return await fetch_strategic_posture(fetcher)
|
||||
case "intel_world_brief":
|
||||
from .analysis.world_brief import fetch_world_brief
|
||||
return await fetch_world_brief(fetcher)
|
||||
case "intel_fleet_report":
|
||||
from .sources.fleet import fetch_fleet_report
|
||||
return await fetch_fleet_report(fetcher)
|
||||
case "intel_population_exposure":
|
||||
from .analysis.exposure import fetch_population_exposure
|
||||
return await fetch_population_exposure(
|
||||
fetcher,
|
||||
radius_km=arguments.get("radius_km", 200),
|
||||
event_types=arguments.get("event_types"),
|
||||
)
|
||||
|
||||
# NLP Intelligence
|
||||
case "intel_extract_entities":
|
||||
from .analysis.entities import fetch_entity_extraction
|
||||
@@ -1002,6 +1052,7 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
|
||||
"service_status": ["aws", "azure", "gcp", "cloudflare", "github"],
|
||||
"geospatial": ["static-datasets (bases, ports, pipelines, nuclear)"],
|
||||
"nlp": ["regex-ner", "keyword-classifier", "jaccard-clustering", "keyword-spike-detector"],
|
||||
"synthesis": ["strategic-posture", "world-brief", "fleet-report", "population-exposure"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Naval fleet activity report.
|
||||
|
||||
Aggregates theater posture, vessel snapshot at strategic waterways,
|
||||
military surge detections, and military base data into a fleet-focused
|
||||
intelligence report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.sources.fleet")
|
||||
|
||||
|
||||
async def _safe(coro, label: str) -> dict:
|
||||
try:
|
||||
return await coro
|
||||
except Exception as exc:
|
||||
logger.warning("Fleet report: %s failed: %s", label, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _fleet_readiness(theaters: list, waterways: list, surges: list) -> tuple[str, int]:
|
||||
"""Assess overall fleet readiness from component data.
|
||||
|
||||
Returns (level, score 0-100).
|
||||
"""
|
||||
score = 0.0
|
||||
|
||||
# Theater activity: more aircraft = higher activity
|
||||
total_aircraft = sum(t.get("aircraft_count", 0) for t in theaters)
|
||||
score += min(30.0, total_aircraft * 0.5)
|
||||
|
||||
# Waterway status: elevated/critical waterways raise score
|
||||
status_map = {"clear": 0, "advisory": 10, "elevated": 20, "critical": 30}
|
||||
for ww in waterways:
|
||||
score += status_map.get(ww.get("status", "clear"), 0) / max(len(waterways), 1)
|
||||
|
||||
# Active surges
|
||||
score += min(30.0, len(surges) * 15.0)
|
||||
|
||||
score = min(100.0, score)
|
||||
|
||||
if score >= 70:
|
||||
level = "HIGH_ACTIVITY"
|
||||
elif score >= 40:
|
||||
level = "ELEVATED_ACTIVITY"
|
||||
elif score >= 15:
|
||||
level = "NORMAL_OPERATIONS"
|
||||
else:
|
||||
level = "LOW_ACTIVITY"
|
||||
|
||||
return level, round(score)
|
||||
|
||||
|
||||
async def fetch_fleet_report(fetcher) -> dict:
|
||||
"""Generate naval fleet activity report.
|
||||
|
||||
Aggregates:
|
||||
- Theater posture (5 theaters, aircraft counts)
|
||||
- Vessel snapshot (9 strategic waterways, naval status)
|
||||
- Military surge detections (anomalous foreign aircraft concentration)
|
||||
- Naval bases (filtered from static dataset)
|
||||
"""
|
||||
from . import intelligence, military, geospatial
|
||||
|
||||
(
|
||||
posture_data,
|
||||
vessel_data,
|
||||
surge_data,
|
||||
) = await asyncio.gather(
|
||||
_safe(military.fetch_theater_posture(fetcher), "theater_posture"),
|
||||
_safe(intelligence.fetch_vessel_snapshot(fetcher), "vessel_snapshot"),
|
||||
_safe(intelligence.fetch_military_surge(fetcher), "military_surge"),
|
||||
)
|
||||
|
||||
# Get naval bases (sync, no fetcher needed)
|
||||
naval_bases = await geospatial.fetch_military_bases(base_type="naval_base")
|
||||
naval_base_count = naval_bases.get("count", 0)
|
||||
|
||||
# Extract key data
|
||||
theaters = posture_data.get("theaters", [])
|
||||
waterways = vessel_data.get("waterways", [])
|
||||
surges = surge_data.get("surges", [])
|
||||
|
||||
# Compute fleet readiness
|
||||
readiness_level, readiness_score = _fleet_readiness(theaters, waterways, surges)
|
||||
|
||||
# Theater summary
|
||||
theater_summary = []
|
||||
for t in theaters:
|
||||
theater_summary.append({
|
||||
"name": t.get("name", "Unknown"),
|
||||
"aircraft_count": t.get("aircraft_count", 0),
|
||||
"top_types": t.get("top_types", [])[:3],
|
||||
})
|
||||
|
||||
# Waterway summary
|
||||
waterway_summary = []
|
||||
for ww in waterways:
|
||||
waterway_summary.append({
|
||||
"name": ww.get("name", "Unknown"),
|
||||
"status": ww.get("status", "unknown"),
|
||||
"warning_count": ww.get("warning_count", 0),
|
||||
})
|
||||
|
||||
# Active surges
|
||||
active_surges = []
|
||||
for s in surges:
|
||||
active_surges.append({
|
||||
"region": s.get("region", "Unknown"),
|
||||
"aircraft_count": s.get("aircraft_count", 0),
|
||||
"baseline": s.get("baseline", 0),
|
||||
"ratio": s.get("ratio", 0),
|
||||
})
|
||||
|
||||
return {
|
||||
"readiness_level": readiness_level,
|
||||
"readiness_score": readiness_score,
|
||||
"theater_summary": theater_summary,
|
||||
"theater_count": len(theater_summary),
|
||||
"waterway_summary": waterway_summary,
|
||||
"waterway_count": len(waterway_summary),
|
||||
"active_surges": active_surges,
|
||||
"surge_count": len(active_surges),
|
||||
"naval_base_count": naval_base_count,
|
||||
"total_tracked_aircraft": sum(t.get("aircraft_count", 0) for t in theaters),
|
||||
"source": "fleet-activity-report",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
Reference in New Issue
Block a user