feat: phase 16 — country dossier, 89 tools, 119 RSS feeds, 53 tests

- Add intel_country_dossier: comprehensive 6-source country analysis
  (economy, markets, elections, sanctions, news, security) in parallel
- Expose 4 hidden sources as MCP tools: intel_traffic_flow,
  intel_traffic_incidents, intel_aviation_domestic, intel_webcams
- RSS feeds expanded from 100 to 119 across 24 categories (+6 new:
  central_asia, arctic, maritime, space, nuclear, climate)
- CLI expanded from 44 to 49 commands (dossier, traffic, incidents,
  air-traffic, webcams)
- Tests expanded from 45 to 53 covering all new Phase 16 tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Shade
2026-02-26 17:55:47 -05:00
co-authored by Claude Opus 4.6
parent a5a1e207a7
commit d075bbaf1a
7 changed files with 770 additions and 18 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What This Is ## What This Is
World Intelligence MCP Server — 84 tools across 30+ domains providing real-time global intelligence from free public APIs. Serves three interfaces: MCP stdio (for Claude Code/Cursor), a live Starlette dashboard with SSE, and a Click CLI with Rich output. Python 3.11+, built with hatchling. World Intelligence MCP Server — 89 tools across 30+ domains providing real-time global intelligence from free public APIs. Serves three interfaces: MCP stdio (for Claude Code/Cursor), a live Starlette dashboard with SSE, and a Click CLI with Rich output. Python 3.11+, built with hatchling.
## Commands ## Commands
+32 -11
View File
@@ -2,7 +2,7 @@
**Benchmark**: [koala73/worldmonitor](https://github.com/koala73/worldmonitor) **Benchmark**: [koala73/worldmonitor](https://github.com/koala73/worldmonitor)
**Updated**: 2026-02-26 **Updated**: 2026-02-26
**Current tools**: 84 (83 intel + 1 status) **Current tools**: 89 (88 intel + 1 status)
--- ---
@@ -19,6 +19,7 @@
## 1. Data Sources — Complete Inventory ## 1. Data Sources — Complete Inventory
### Markets & Economics (13 tools) ### Markets & Economics (13 tools)
| Tool | WM Equivalent | Status | | Tool | WM Equivalent | Status |
|------|---------------|--------| |------|---------------|--------|
| `intel_market_quotes` | `list-market-quotes` | :white_check_mark: | | `intel_market_quotes` | `list-market-quotes` | :white_check_mark: |
@@ -90,14 +91,22 @@
| `intel_gdelt_search` | `search-gdelt-documents` | :white_check_mark: | | `intel_gdelt_search` | `search-gdelt-documents` | :white_check_mark: |
| `intel_ai_releases` | AI model/paper tracker | :white_check_mark: | | `intel_ai_releases` | AI model/paper tracker | :white_check_mark: |
### Transport (3 tools) ### Transport & Traffic (6 tools)
| Tool | WM Equivalent | Status | | Tool | WM Equivalent | Status |
|------|---------------|--------| |------|---------------|--------|
| `intel_prediction_markets` | `list-prediction-markets` | :white_check_mark: | | `intel_prediction_markets` | `list-prediction-markets` | :white_check_mark: |
| `intel_airport_delays` | `list-airport-delays` | :white_check_mark: | | `intel_airport_delays` | `list-airport-delays` | :white_check_mark: |
| `intel_shipping_index` | Yahoo Finance shipping ETFs | :white_check_mark: | | `intel_shipping_index` | Yahoo Finance shipping ETFs | :white_check_mark: |
| `intel_traffic_flow` | Real-time congestion in 20 cities (TomTom) | :white_check_mark: |
| `intel_traffic_incidents` | Major incidents in 5 strategic regions (TomTom) | :white_check_mark: |
| `intel_aviation_domestic` | Global air traffic snapshot (OpenSky) | :white_check_mark: |
### Analysis & Intelligence (9 tools) ### Webcams (1 tool)
| Tool | WM Equivalent | Status |
|------|---------------|--------|
| `intel_webcams` | Public webcam locations worldwide (Windy) | :white_check_mark: |
### Analysis & Intelligence (10 tools)
| Tool | WM Equivalent | Status | | Tool | WM Equivalent | Status |
|------|---------------|--------| |------|---------------|--------|
| `intel_risk_scores` | `get-risk-scores` | :white_check_mark: | | `intel_risk_scores` | `get-risk-scores` | :white_check_mark: |
@@ -110,10 +119,11 @@
| `intel_alert_digest` | Cross-domain alert synthesis | :white_check_mark: | | `intel_alert_digest` | Cross-domain alert synthesis | :white_check_mark: |
| `intel_weekly_trends` | Temporal trend analysis | :white_check_mark: | | `intel_weekly_trends` | Temporal trend analysis | :white_check_mark: |
### Country & Geopolitical (3 tools) ### Country & Geopolitical (4 tools)
| Tool | WM Equivalent | Status | | Tool | WM Equivalent | Status |
|------|---------------|--------| |------|---------------|--------|
| `intel_country_brief` | `get-country-intel-brief` | :white_check_mark: | | `intel_country_brief` | `get-country-intel-brief` | :white_check_mark: |
| `intel_country_dossier` | Comprehensive 6-source country analysis | :white_check_mark: |
| `intel_election_calendar` | Election proximity risk | :white_check_mark: | | `intel_election_calendar` | Election proximity risk | :white_check_mark: |
| `intel_sanctions_search` | OFAC SDN search | :white_check_mark: | | `intel_sanctions_search` | OFAC SDN search | :white_check_mark: |
@@ -186,7 +196,7 @@
## 3. RSS Feed Coverage ## 3. RSS Feed Coverage
Expanded to **100 feeds** across **18 categories** with 4-tier source ranking (wire/major/specialty/aggregator) and propaganda risk labels. Expanded to **119 feeds** across **24 categories** with 4-tier source ranking (wire/major/specialty/aggregator) and propaganda risk labels.
| Category | Count | Status | | Category | Count | Status |
|----------|-------|--------| |----------|-------|--------|
@@ -207,7 +217,13 @@ Expanded to **100 feeds** across **18 categories** with 4-tier source ranking (w
| Crisis/Intl Orgs | 4 | :white_check_mark: | | Crisis/Intl Orgs | 4 | :white_check_mark: |
| Europe | 4 | :white_check_mark: | | Europe | 4 | :white_check_mark: |
| South Asia | 3 | :white_check_mark: | | South Asia | 3 | :white_check_mark: |
| Health | 3 | :white_check_mark: | | Health | 4 | :white_check_mark: |
| Central Asia | 3 | :white_check_mark: |
| Arctic | 3 | :white_check_mark: |
| Maritime | 3 | :white_check_mark: |
| Space | 3 | :white_check_mark: |
| Nuclear | 3 | :white_check_mark: |
| Climate | 3 | :white_check_mark: |
--- ---
@@ -280,16 +296,21 @@ Static datasets completed: 34 undersea cables, 48 AI datacenters, 27 spaceports,
BTC technical analysis with SMA-50/200, Mayer Multiple, golden/death cross signals, and ATH distance via CoinGecko historical data. Central bank policy rates for 15 major banks (Fed, ECB, BoE, BoJ, PBoC, RBI, RBA, BoC, SNB, BCB, BoK, CBRT, SARB, Banxico, BI) — live FRED data when API key set, curated fallback otherwise. Static geospatial datasets: 19 maritime trade routes/chokepoints with oil flow and vessel transit data, 28 cloud provider regions (AWS/Azure/GCP), 20 GFCI-ranked financial centers. Trade route markers added to dashboard infrastructure map layer. BTC technicals and central bank rates added to dashboard drawer. BTC technical analysis with SMA-50/200, Mayer Multiple, golden/death cross signals, and ATH distance via CoinGecko historical data. Central bank policy rates for 15 major banks (Fed, ECB, BoE, BoJ, PBoC, RBI, RBA, BoC, SNB, BCB, BoK, CBRT, SARB, Banxico, BI) — live FRED data when API key set, curated fallback otherwise. Static geospatial datasets: 19 maritime trade routes/chokepoints with oil flow and vessel transit data, 28 cloud provider regions (AWS/Azure/GCP), 20 GFCI-ranked financial centers. Trade route markers added to dashboard infrastructure map layer. BTC technicals and central bank rates added to dashboard drawer.
### Phase 13: Country Dossier, Full Tool Exposure & Feed Expansion (+5 = 89 tools)
`intel_country_dossier`, `intel_traffic_flow`, `intel_traffic_incidents`, `intel_aviation_domestic`, `intel_webcams`
Comprehensive country intelligence dossier aggregating 6 sources in parallel (economy, markets, elections, sanctions, news, security). Exposed 4 previously hidden source functions: TomTom traffic flow (20 cities) and incidents (5 regions), OpenSky global air traffic snapshot, Windy public webcams. RSS feeds expanded from 100 to 119 across 24 categories (+6 new categories: central_asia, arctic, maritime, space, nuclear, climate). 49 CLI commands, 53 tests.
--- ---
## Summary ## Summary
| Category | Have | Benchmark | Coverage | | Category | Have | Benchmark | Coverage |
|----------|------|-----------|----------| |----------|------|-----------|----------|
| Data source tools | 84 | 42 | **200%** | | Data source tools | 89 | 42 | **212%** |
| Analysis engines | 19 | 15 | **127%** | | Analysis engines | 20 | 15 | **133%** |
| Static datasets | 18 | 12 | **150%** | | Static datasets | 18 | 12 | **150%** |
| RSS feeds | 100 | 150+ | **67%** | | RSS feeds | 119 | 150+ | **79%** |
| Strategic synthesis | Posture + brief + fleet + exposure + USNI | Dashboard-only | **Exceeds** | | Strategic synthesis | Posture + brief + fleet + dossier + exposure + USNI | Dashboard-only | **Exceeds** |
**Bottom line**: 84 tools across 30+ domains, exactly 2x WorldMonitor benchmark in tool count, 27% more analysis engines, and 50% more static datasets. 100 RSS feeds across 18 categories. All phases 1-12 complete. Live Starlette dashboard with 39 SSE streams, 14 map layers (with trade route markers), and data freshness monitoring. **Bottom line**: 89 tools across 30+ domains, over 2x WorldMonitor benchmark in tool count, 33% more analysis engines, and 50% more static datasets. 119 RSS feeds across 24 categories. All phases 1-13 complete. Live Starlette dashboard with 39 SSE streams, 14 map layers (with trade route markers), and data freshness monitoring.
+232
View File
@@ -0,0 +1,232 @@
"""Comprehensive country intelligence dossier.
Aggregates data from multiple sources into a single country profile:
country brief (World Bank + ACLED), stock index, election calendar,
sanctions exposure, news mentions, and associated hotspots/conflict zones.
All sub-queries are run in parallel for speed.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
logger = logging.getLogger("world-intel-mcp.analysis.dossier")
# Minimal ISO-2 <-> ISO-3 mapping for the 22 tier-1 countries + major extras.
_ISO2_TO_ISO3: dict[str, str] = {
"US": "USA", "CN": "CHN", "RU": "RUS", "UA": "UKR", "SY": "SYR",
"YE": "YEM", "MM": "MMR", "SD": "SDN", "NG": "NGA", "AF": "AFG",
"IQ": "IRQ", "IR": "IRN", "TW": "TWN", "KP": "PRK", "IL": "ISR",
"PS": "PSE", "LB": "LBN", "ET": "ETH", "CD": "COD", "PK": "PAK",
"IN": "IND", "MX": "MEX", "GB": "GBR", "DE": "DEU", "FR": "FRA",
"JP": "JPN", "KR": "KOR", "BR": "BRA", "AU": "AUS", "CA": "CAN",
"SA": "SAU", "TR": "TUR", "EG": "EGY", "ZA": "ZAF", "ID": "IDN",
"TH": "THA", "VN": "VNM", "PH": "PHL", "PL": "POL", "IT": "ITA",
"ES": "ESP", "SE": "SWE", "NO": "NOR", "CH": "CHE", "NL": "NLD",
"BE": "BEL", "AR": "ARG", "CL": "CHL", "CO": "COL", "PE": "PER",
}
_ISO3_TO_ISO2: dict[str, str] = {v: k for k, v in _ISO2_TO_ISO3.items()}
def _normalize_country(code: str) -> tuple[str, str]:
"""Return (iso2, iso3) from either format. Raises ValueError if unknown."""
code = code.upper().strip()
if len(code) == 2:
iso2 = code
iso3 = _ISO2_TO_ISO3.get(code)
if iso3 is None:
raise ValueError(f"Unknown ISO-2 code: {code}")
return iso2, iso3
if len(code) == 3:
iso3 = code
iso2 = _ISO3_TO_ISO2.get(code)
if iso2 is None:
raise ValueError(f"Unknown ISO-3 code: {code}")
return iso2, iso3
raise ValueError(f"Country code must be 2 or 3 characters: {code}")
async def _safe(coro, label: str) -> dict:
"""Run a coroutine, catching exceptions."""
try:
return await coro
except Exception as exc:
logger.warning("Dossier: %s failed: %s", label, exc)
return {"_error": str(exc)}
async def fetch_country_dossier(
fetcher,
country: str = "US",
) -> dict:
"""Build a comprehensive country intelligence dossier.
Pulls from 6 sources in parallel:
1. Country brief (World Bank GDP/inflation + ACLED conflict)
2. Stock market index (Yahoo Finance)
3. Election calendar (curated dataset)
4. Sanctions exposure (OFAC SDN search)
5. Recent news mentions (RSS feeds)
6. Country config (baseline risk, hotspots, conflict zones)
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
country: ISO-2 or ISO-3 country code (e.g. "US", "USA", "UA", "UKR").
Returns:
Dict with sections: overview, economy, markets, elections, sanctions,
news, security, and metadata.
"""
now = datetime.now(timezone.utc)
try:
iso2, iso3 = _normalize_country(country)
except ValueError as exc:
return {
"error": str(exc),
"hint": "Use ISO-2 (US, UA) or ISO-3 (USA, UKR) codes",
"source": "country-dossier",
"timestamp": now.isoformat(),
}
# Lazy imports to avoid circular deps
from ..sources.intelligence import fetch_country_brief
from ..sources.markets import fetch_country_stocks
from ..sources.elections import fetch_election_calendar
from ..sources.sanctions import fetch_sanctions_search
from ..sources.news import fetch_news_feed
from ..config.countries import TIER1_COUNTRIES, INTEL_HOTSPOTS, CONFLICT_ZONES
# Run all data fetches in parallel
(
brief_data,
stock_data,
election_data,
sanctions_data,
news_data,
) = await asyncio.gather(
_safe(fetch_country_brief(fetcher, country_code=iso2), "country_brief"),
_safe(fetch_country_stocks(fetcher, country=iso3), "country_stocks"),
_safe(fetch_election_calendar(fetcher, country=iso3), "elections"),
_safe(fetch_sanctions_search(fetcher, query=iso3, limit=10), "sanctions"),
_safe(fetch_news_feed(fetcher, category="all", limit=200), "news_feed"),
)
# --- Section 1: Overview ---
country_config = TIER1_COUNTRIES.get(iso3, {})
country_name = country_config.get("name", iso3)
overview = {
"country": country_name,
"iso2": iso2,
"iso3": iso3,
"baseline_risk": country_config.get("baseline_risk"),
"event_multiplier": country_config.get("event_multiplier"),
}
# --- Section 2: Economy (from country brief) ---
economy = {}
if "_error" not in brief_data:
supporting = brief_data.get("supporting_data", {})
economy = {
"gdp": supporting.get("gdp", []),
"inflation": supporting.get("inflation", []),
"conflict_events_30d": supporting.get("conflict_events_count", 0),
"llm_available": brief_data.get("llm_available", False),
"brief_text": brief_data.get("brief", ""),
}
else:
economy = {"_error": brief_data["_error"]}
# --- Section 3: Markets ---
markets = {}
if "_error" not in stock_data and "error" not in stock_data:
markets = {
"ticker": stock_data.get("ticker"),
"exchange": stock_data.get("exchange"),
"quote": stock_data.get("quote"),
}
else:
markets = {"note": stock_data.get("error", stock_data.get("_error", "unavailable"))}
# --- Section 4: Elections ---
elections = {}
if "_error" not in election_data:
country_elections = election_data.get("elections", [])
elections = {
"upcoming": [e for e in country_elections if e.get("status") == "upcoming"],
"past": [e for e in country_elections if e.get("status") == "past"],
"count": len(country_elections),
}
else:
elections = {"_error": election_data["_error"]}
# --- Section 5: Sanctions ---
sanctions = {}
if "_error" not in sanctions_data:
sanctions = {
"matches": sanctions_data.get("results", []),
"match_count": sanctions_data.get("count", 0),
}
else:
sanctions = {"_error": sanctions_data["_error"]}
# --- Section 6: News ---
news_mentions = []
if "_error" not in news_data:
country_keywords = country_config.get("keywords", [country_name.lower()])
for article in news_data.get("articles", []):
title = (article.get("title") or "").lower()
if any(kw in title for kw in country_keywords):
news_mentions.append({
"title": article.get("title"),
"source": article.get("source"),
"published": article.get("published"),
"link": article.get("link"),
"category": article.get("category"),
})
if len(news_mentions) >= 15:
break
# --- Section 7: Security (hotspots + conflict zones) ---
associated_hotspots = []
for name, hs in INTEL_HOTSPOTS.items():
if iso3 in hs.get("associated_countries", []):
associated_hotspots.append({
"name": name,
"lat": hs["lat"],
"lon": hs["lon"],
"baseline_escalation": hs["baseline_escalation"],
})
active_conflicts = []
for cz in CONFLICT_ZONES:
cz_name = cz["name"].lower()
if any(kw in cz_name for kw in country_config.get("keywords", [country_name.lower()])):
active_conflicts.append(cz)
security = {
"hotspots": associated_hotspots,
"hotspot_count": len(associated_hotspots),
"active_conflicts": active_conflicts,
"conflict_count": len(active_conflicts),
}
return {
"overview": overview,
"economy": economy,
"markets": markets,
"elections": elections,
"sanctions": sanctions,
"news": {
"mentions": news_mentions,
"mention_count": len(news_mentions),
},
"security": security,
"sections": ["overview", "economy", "markets", "elections", "sanctions", "news", "security"],
"source": "country-dossier",
"timestamp": now.isoformat(),
}
+190 -2
View File
@@ -528,12 +528,13 @@ def climate_cmd(ctx: click.Context) -> None:
@click.option("--category", "-c", default=None, @click.option("--category", "-c", default=None,
type=click.Choice(["geopolitics", "security", "technology", "finance", "military", "science", type=click.Choice(["geopolitics", "security", "technology", "finance", "military", "science",
"think_tanks", "middle_east", "asia_pacific", "africa", "latin_america", "think_tanks", "middle_east", "asia_pacific", "africa", "latin_america",
"multilingual", "energy", "government", "crisis", "europe", "south_asia", "health"]), "multilingual", "energy", "government", "crisis", "europe", "south_asia",
"health", "central_asia", "arctic", "maritime", "space", "nuclear", "climate"]),
help="Category filter") help="Category filter")
@click.option("--limit", "-n", default=30, help="Max items") @click.option("--limit", "-n", default=30, help="Max items")
@click.pass_context @click.pass_context
def news_cmd(ctx: click.Context, category: str | None, limit: int) -> None: def news_cmd(ctx: click.Context, category: str | None, limit: int) -> None:
"""Intelligence news from 100 RSS feeds across 18 categories.""" """Intelligence news from 119 RSS feeds across 24 categories."""
f = _get_fetcher() f = _get_fetcher()
data = _run(news.fetch_news_feed(f, category=category, limit=limit)) data = _run(news.fetch_news_feed(f, category=category, limit=limit))
@@ -795,6 +796,70 @@ def brief(ctx: click.Context, country_code: str) -> None:
f"Recent conflict events: {d.get('recent_events', 0)}[/dim]") f"Recent conflict events: {d.get('recent_events', 0)}[/dim]")
@main.command()
@click.option("--country", "-c", default="US", help="ISO-2 or ISO-3 country code")
@click.pass_context
def dossier(ctx: click.Context, country: str) -> None:
"""Comprehensive country intelligence dossier."""
from .analysis.dossier import fetch_country_dossier
f = _get_fetcher()
data = _run(fetch_country_dossier(f, country=country))
if ctx.obj.get("json") or "error" in data:
_print_json(data)
return
overview = data.get("overview", {})
console.print(f"[bold]Country Dossier: {overview.get('country', country)}[/bold] "
f"({overview.get('iso2')}/{overview.get('iso3')})\n")
# Economy
econ = data.get("economy", {})
if "_error" not in econ:
gdp = econ.get("gdp", [])
if gdp:
latest = gdp[0]
console.print(f"[cyan]Economy:[/cyan] GDP {latest.get('year')}: ${latest.get('value', 0)/1e9:,.1f}B")
if econ.get("conflict_events_30d"):
console.print(f" Conflict events (30d): {econ['conflict_events_30d']}")
# Markets
mkt = data.get("markets", {})
if "ticker" in mkt:
q = mkt.get("quote", {})
console.print(f"[green]Markets:[/green] {mkt['ticker']} = {q.get('price', 'N/A')} ({q.get('change_pct', 'N/A')}%)")
# Elections
elec = data.get("elections", {})
upcoming = elec.get("upcoming", [])
if upcoming:
next_e = upcoming[0]
console.print(f"[yellow]Elections:[/yellow] {next_e.get('election_type')} on {next_e.get('date')} "
f"(risk: {next_e.get('risk_score', 0):.0f})")
# Sanctions
sanc = data.get("sanctions", {})
if sanc.get("match_count", 0) > 0:
console.print(f"[red]Sanctions:[/red] {sanc['match_count']} OFAC matches")
# News
news = data.get("news", {})
console.print(f"[blue]News:[/blue] {news.get('mention_count', 0)} recent mentions")
for art in news.get("mentions", [])[:3]:
console.print(f" - {art.get('title', 'N/A')[:80]}")
# Security
sec = data.get("security", {})
if sec.get("hotspot_count", 0):
console.print(f"[red]Hotspots:[/red] {sec['hotspot_count']} associated")
if sec.get("conflict_count", 0):
console.print(f"[red]Conflicts:[/red] {sec['conflict_count']} active")
br = overview.get("baseline_risk")
if br is not None:
console.print(f"\n[dim]Baseline risk: {br}/100[/dim]")
@main.command() @main.command()
@click.option("--limit", "-n", default=20, help="Top N countries") @click.option("--limit", "-n", default=20, help="Top N countries")
@click.pass_context @click.pass_context
@@ -1297,6 +1362,129 @@ def exchanges_cmd(ctx: click.Context, tier: str | None, country: str | None) ->
console.print(table) console.print(table)
# ---------------------------------------------------------------------------
# Traffic, Aviation, Webcams
# ---------------------------------------------------------------------------
@main.command()
@click.pass_context
def traffic(ctx: click.Context) -> None:
"""Real-time traffic congestion for 20 major cities (TomTom)."""
from .sources.traffic import fetch_traffic_flow
f = _get_fetcher()
data = _run(fetch_traffic_flow(f))
if ctx.obj.get("json") or "error" in data:
_print_json(data)
return
console.print(f"[bold]Global Traffic[/bold] — {data.get('count', 0)} cities, "
f"avg congestion {data.get('global_avg_congestion', 0):.0f}%\n")
table = Table(box=box.SIMPLE_HEAVY)
table.add_column("City", style="bold")
table.add_column("Country")
table.add_column("Congestion %", justify="right")
table.add_column("Speed (km/h)", justify="right")
for c in data.get("cities", [])[:20]:
cong = c.get("congestion_pct", 0)
style = "red" if cong > 60 else "yellow" if cong > 30 else "green"
table.add_row(c.get("name", ""), c.get("country", ""),
f"[{style}]{cong}%[/{style}]",
str(c.get("current_speed_kmh", "")))
console.print(table)
@main.command()
@click.pass_context
def incidents(ctx: click.Context) -> None:
"""Major traffic incidents across strategic regions (TomTom)."""
from .sources.traffic import fetch_traffic_incidents
f = _get_fetcher()
data = _run(fetch_traffic_incidents(f))
if ctx.obj.get("json") or "error" in data:
_print_json(data)
return
console.print(f"[bold]Traffic Incidents[/bold] — {data.get('total_count', 0)} across "
f"{data.get('regions_checked', 0)} regions\n")
table = Table(box=box.SIMPLE_HEAVY)
table.add_column("Region")
table.add_column("Description", max_width=40)
table.add_column("Delay (min)", justify="right")
table.add_column("Road")
for inc in data.get("incidents", [])[:20]:
delay_min = round(inc.get("delay_seconds", 0) / 60)
table.add_row(
inc.get("region", ""),
inc.get("description", "")[:40],
str(delay_min) if delay_min else "-",
inc.get("from_road", "")[:30],
)
console.print(table)
@main.command(name="air-traffic")
@click.pass_context
def air_traffic_cmd(ctx: click.Context) -> None:
"""Global air traffic snapshot (OpenSky)."""
f = _get_fetcher()
data = _run(aviation.fetch_domestic_flights(f))
if ctx.obj.get("json") or "error" in data:
_print_json(data)
return
console.print(f"[bold]Air Traffic[/bold] — {data.get('total_aircraft', 0)} airborne\n")
table = Table(box=box.SIMPLE_HEAVY, title="By Region")
table.add_column("Region", style="bold")
table.add_column("Count", justify="right")
table.add_column("Commercial", justify="right")
table.add_column("General", justify="right")
for region, stats in sorted(data.get("by_region", {}).items(), key=lambda x: -x[1]["count"]):
table.add_row(region, str(stats["count"]), str(stats["commercial"]), str(stats["general"]))
console.print(table)
if data.get("busiest_origins"):
console.print("\n[bold]Busiest Origins:[/bold]")
for o in data["busiest_origins"][:10]:
console.print(f" {o['country']}: {o['count']}")
@main.command()
@click.option("--category", "-c", default="traffic", help="Webcam category")
@click.option("--limit", "-n", default=20, help="Max cameras")
@click.pass_context
def webcams_cmd(ctx: click.Context, category: str, limit: int) -> None:
"""Public webcam locations worldwide (Windy)."""
from .sources.webcams import fetch_webcams
f = _get_fetcher()
data = _run(fetch_webcams(f, category=category, limit=limit))
if ctx.obj.get("json") or "error" in data:
_print_json(data)
return
console.print(f"[bold]Webcams[/bold] — {data.get('count', 0)} cameras ({category})\n")
table = Table(box=box.SIMPLE_HEAVY)
table.add_column("Title", style="bold", max_width=30)
table.add_column("City")
table.add_column("Country")
table.add_column("Status")
for cam in data.get("cameras", []):
table.add_row(cam.get("title", "")[:30], cam.get("city", ""),
cam.get("country", ""), cam.get("status", ""))
console.print(table)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# System # System
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+70 -4
View File
@@ -309,14 +309,17 @@ TOOLS: list[Tool] = [
# --- News (3 tools) --- # --- News (3 tools) ---
Tool( Tool(
name="intel_news_feed", name="intel_news_feed",
description="Get aggregated intelligence news from 20+ RSS feeds across 6 categories (geopolitics, security, tech, finance, military, science).", description="Get aggregated intelligence news from 119 RSS feeds across 24 categories. Covers geopolitics, security, tech, finance, military, science, think tanks, regional, energy, space, nuclear, climate, maritime, arctic, and more.",
inputSchema={ inputSchema={
"type": "object", "type": "object",
"properties": { "properties": {
"category": { "category": {
"type": "string", "type": "string",
"description": "Category filter", "description": "Category filter (24 categories available)",
"enum": ["geopolitics", "security", "technology", "finance", "military", "science"], "enum": ["geopolitics", "security", "technology", "finance", "military", "science",
"think_tanks", "middle_east", "asia_pacific", "africa", "latin_america",
"multilingual", "energy", "government", "crisis", "europe", "south_asia",
"health", "central_asia", "arctic", "maritime", "space", "nuclear", "climate"],
}, },
"limit": {"type": "integer", "description": "Max items (default 50)", "default": 50}, "limit": {"type": "integer", "description": "Max items (default 50)", "default": 50},
}, },
@@ -349,7 +352,7 @@ TOOLS: list[Tool] = [
}, },
}, },
), ),
# --- Intelligence (12 tools) --- # --- Intelligence (13 tools) ---
Tool( Tool(
name="intel_country_brief", name="intel_country_brief",
description="Generate a country intelligence brief using Ollama LLM + World Bank + ACLED data. Falls back to data-only if LLM unavailable.", description="Generate a country intelligence brief using Ollama LLM + World Bank + ACLED data. Falls back to data-only if LLM unavailable.",
@@ -360,6 +363,16 @@ TOOLS: list[Tool] = [
}, },
}, },
), ),
Tool(
name="intel_country_dossier",
description="Comprehensive country intelligence dossier: economy (GDP/inflation), stock market, elections, sanctions, news mentions, hotspots, and conflict zones. Aggregates 6 sources in parallel.",
inputSchema={
"type": "object",
"properties": {
"country": {"type": "string", "description": "ISO-2 or ISO-3 country code (e.g. US, USA, UA, UKR)", "default": "US"},
},
},
),
Tool( Tool(
name="intel_risk_scores", name="intel_risk_scores",
description="Get country risk scores computed from ACLED conflict data vs historical baselines. Requires ACLED_ACCESS_TOKEN.", description="Get country risk scores computed from ACLED conflict data vs historical baselines. Requires ACLED_ACCESS_TOKEN.",
@@ -901,6 +914,35 @@ TOOLS: list[Tool] = [
}, },
}, },
), ),
# --- Traffic (2 tools) ---
Tool(
name="intel_traffic_flow",
description="Real-time traffic congestion for 20 major world cities via TomTom API. Congestion percentage, speeds, global average. Requires TOMTOM_API_KEY.",
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="intel_traffic_incidents",
description="Major traffic incidents across 5 strategic regions (US East/West, Europe, Middle East, East Asia) via TomTom API. Requires TOMTOM_API_KEY.",
inputSchema={"type": "object", "properties": {}},
),
# --- Aviation domestic (1 tool) ---
Tool(
name="intel_aviation_domestic",
description="Global air traffic snapshot from OpenSky Network: total airborne aircraft, regional breakdown, busiest origin countries, and sampled positions for mapping.",
inputSchema={"type": "object", "properties": {}},
),
# --- Webcams (1 tool) ---
Tool(
name="intel_webcams",
description="Public webcam locations and live previews worldwide from Windy Webcams API. Filter by category (traffic, weather, landscape). Requires WINDY_API_KEY.",
inputSchema={
"type": "object",
"properties": {
"category": {"type": "string", "description": "Webcam category (traffic, weather, landscape, etc.)", "default": "traffic"},
"limit": {"type": "integer", "description": "Max cameras to return (default 50)", "default": 50},
},
},
),
# --- System (1 tool) --- # --- System (1 tool) ---
Tool( Tool(
name="intel_status", name="intel_status",
@@ -1037,6 +1079,9 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
# Intelligence # Intelligence
case "intel_country_brief": case "intel_country_brief":
return await intelligence.fetch_country_brief(fetcher, country_code=arguments.get("country_code", "US")) return await intelligence.fetch_country_brief(fetcher, country_code=arguments.get("country_code", "US"))
case "intel_country_dossier":
from .analysis.dossier import fetch_country_dossier
return await fetch_country_dossier(fetcher, country=arguments.get("country", "US"))
case "intel_risk_scores": case "intel_risk_scores":
return await intelligence.fetch_risk_scores(fetcher, limit=arguments.get("limit", 20)) return await intelligence.fetch_risk_scores(fetcher, limit=arguments.get("limit", 20))
case "intel_instability_index": case "intel_instability_index":
@@ -1323,6 +1368,27 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
z_threshold=arguments.get("z_threshold", 2.0), z_threshold=arguments.get("z_threshold", 2.0),
) )
# Traffic
case "intel_traffic_flow":
from .sources.traffic import fetch_traffic_flow
return await fetch_traffic_flow(fetcher)
case "intel_traffic_incidents":
from .sources.traffic import fetch_traffic_incidents
return await fetch_traffic_incidents(fetcher)
# Aviation domestic
case "intel_aviation_domestic":
return await aviation.fetch_domestic_flights(fetcher)
# Webcams
case "intel_webcams":
from .sources.webcams import fetch_webcams
return await fetch_webcams(
fetcher,
category=arguments.get("category", "traffic"),
limit=arguments.get("limit", 50),
)
# System # System
case "intel_status": case "intel_status":
return { return {
+51
View File
@@ -161,6 +161,37 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
("STAT News", "https://www.statnews.com/feed/"), ("STAT News", "https://www.statnews.com/feed/"),
("WHO News", "https://www.who.int/rss-feeds/news-english.xml"), ("WHO News", "https://www.who.int/rss-feeds/news-english.xml"),
("Medical Xpress", "https://medicalxpress.com/rss-feed/"), ("Medical Xpress", "https://medicalxpress.com/rss-feed/"),
("The Lancet", "https://www.thelancet.com/rssfeed/lancet_current.xml"),
],
"central_asia": [
("Eurasianet", "https://eurasianet.org/feed"),
("The Astana Times", "https://astanatimes.com/feed/"),
("Radio Free Europe", "https://www.rferl.org/api/zyrttemnuq"),
],
"arctic": [
("The Barents Observer", "https://thebarentsobserver.com/en/rss.xml"),
("Arctic Today", "https://www.arctictoday.com/feed/"),
("High North News", "https://www.highnorthnews.com/en/rss.xml"),
],
"maritime": [
("Maritime Executive", "https://maritime-executive.com/feed"),
("gCaptain", "https://gcaptain.com/feed/"),
("Lloyd's List", "https://lloydslist.maritimeintelligence.informa.com/rss/all"),
],
"space": [
("SpaceRef", "https://spaceref.com/feed/"),
("NASASpaceFlight", "https://www.nasaspaceflight.com/feed/"),
("Space.com", "https://www.space.com/feeds/all"),
],
"nuclear": [
("World Nuclear News", "https://world-nuclear-news.org/rss"),
("Arms Control Assn", "https://www.armscontrol.org/rss/all"),
("Nuclear Threat Initiative", "https://www.nti.org/feed/"),
],
"climate": [
("Climate Home News", "https://www.climatechangenews.com/feed/"),
("InsideClimate News", "https://insideclimatenews.org/feed/"),
("E&E News", "https://www.eenews.net/feed/"),
], ],
} }
@@ -243,6 +274,26 @@ SOURCE_TIERS: dict[str, str] = {
"HRW": "intl_org", "HRW": "intl_org",
"The Register": "specialty", "The Register": "specialty",
"White House": "government", "White House": "government",
# Phase 16 additions
"The Lancet": "major",
"Eurasianet": "specialty",
"The Astana Times": "specialty",
"Radio Free Europe": "government",
"The Barents Observer": "specialty",
"Arctic Today": "specialty",
"High North News": "specialty",
"Maritime Executive": "specialty",
"gCaptain": "specialty",
"Lloyd's List": "specialty",
"SpaceRef": "specialty",
"NASASpaceFlight": "specialty",
"Space.com": "major",
"World Nuclear News": "specialty",
"Arms Control Assn": "think_tank",
"Nuclear Threat Initiative": "think_tank",
"Climate Home News": "specialty",
"InsideClimate News": "specialty",
"E&E News": "specialty",
} }
_STOPWORDS: set[str] = { _STOPWORDS: set[str] = {
+194
View File
@@ -1183,3 +1183,197 @@ async def test_fetch_financial_centers_filter_country() -> None:
assert result["source"] == "static-geospatial" assert result["source"] == "static-geospatial"
assert result["count"] > 0 assert result["count"] > 0
assert all(fc["iso3"] == "USA" for fc in result["centers"]) assert all(fc["iso3"] == "USA" for fc in result["centers"])
# ---------------------------------------------------------------------------
# Country Dossier (Phase 16)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@respx.mock
async def test_fetch_country_dossier(fetcher: Fetcher) -> None:
"""Dossier aggregates country brief, stocks, elections, sanctions, news."""
from world_intel_mcp.analysis.dossier import fetch_country_dossier
# Mock World Bank GDP
respx.get("https://api.worldbank.org/v2/country/US/indicator/NY.GDP.MKTP.CD").mock(
return_value=httpx.Response(200, json=[
{"page": 1},
[{"date": "2024", "value": 28000000000000}],
])
)
# Mock World Bank inflation
respx.get("https://api.worldbank.org/v2/country/US/indicator/FP.CPI.TOTL.ZG").mock(
return_value=httpx.Response(200, json=[
{"page": 1},
[{"date": "2024", "value": 3.2}],
])
)
# Mock ACLED (no key = skip)
# Mock Yahoo Finance for country stocks
respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC").mock(
return_value=httpx.Response(200, json={
"chart": {"result": [{"meta": {
"regularMarketPrice": 5800,
"chartPreviousClose": 5750,
"currency": "USD",
"exchangeName": "SNP",
}}]},
})
)
# Mock OFAC sanctions
respx.get("https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.XML").mock(
return_value=httpx.Response(200, text="<sdnList></sdnList>")
)
# Mock news feeds — just one category needed
respx.route().mock(return_value=httpx.Response(200, text="""<?xml version="1.0"?>
<rss version="2.0"><channel><title>Test</title>
<item><title>US Economy Grows</title><link>https://example.com/1</link></item>
<item><title>China Trade</title><link>https://example.com/2</link></item>
</channel></rss>"""))
result = await fetch_country_dossier(fetcher, country="US")
assert result["source"] == "country-dossier"
assert result["overview"]["iso2"] == "US"
assert result["overview"]["iso3"] == "USA"
assert "economy" in result
assert "markets" in result
assert "elections" in result
assert "sanctions" in result
assert "news" in result
assert "security" in result
assert len(result["sections"]) == 7
@pytest.mark.asyncio
async def test_fetch_country_dossier_invalid_code(fetcher: Fetcher) -> None:
"""Dossier returns error for unknown country code."""
from world_intel_mcp.analysis.dossier import fetch_country_dossier
result = await fetch_country_dossier(fetcher, country="XX")
assert "error" in result
assert "Unknown" in result["error"]
# ---------------------------------------------------------------------------
# Traffic (Phase 16)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@respx.mock
async def test_fetch_traffic_flow_no_key(fetcher: Fetcher) -> None:
"""Traffic flow returns error when TOMTOM_API_KEY is not set."""
from world_intel_mcp.sources.traffic import fetch_traffic_flow
with patch.dict("os.environ", {}, clear=True):
result = await fetch_traffic_flow(fetcher)
assert "error" in result
assert "TOMTOM_API_KEY" in result["error"]
@pytest.mark.asyncio
@respx.mock
async def test_fetch_traffic_flow_with_key(fetcher: Fetcher) -> None:
"""Traffic flow fetches congestion data from TomTom."""
from world_intel_mcp.sources.traffic import fetch_traffic_flow
respx.route().mock(return_value=httpx.Response(200, json={
"flowSegmentData": {
"currentSpeed": 30.0,
"freeFlowSpeed": 60.0,
},
}))
with patch.dict("os.environ", {"TOMTOM_API_KEY": "test-key"}):
result = await fetch_traffic_flow(fetcher)
assert result["source"] == "tomtom"
assert result["count"] > 0
assert result["global_avg_congestion"] == 50.0 # (1 - 30/60) * 100
assert result["cities"][0]["congestion_pct"] == 50
@pytest.mark.asyncio
@respx.mock
async def test_fetch_traffic_incidents_no_key(fetcher: Fetcher) -> None:
"""Traffic incidents returns error when no API key."""
from world_intel_mcp.sources.traffic import fetch_traffic_incidents
with patch.dict("os.environ", {}, clear=True):
result = await fetch_traffic_incidents(fetcher)
assert "error" in result
# ---------------------------------------------------------------------------
# Aviation Domestic (Phase 16)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@respx.mock
async def test_fetch_domestic_flights(fetcher: Fetcher) -> None:
"""Domestic flights buckets OpenSky states by region."""
from world_intel_mcp.sources.aviation import fetch_domestic_flights
# Simulate 3 airborne aircraft at known positions
respx.route().mock(return_value=httpx.Response(200, json={
"states": [
# [icao24, callsign, origin, ..., on_ground=False, lon, lat, ...]
["abc123", "UAL123 ", "United States", None, None, -73.9, 40.7, 10000, False, None, None, None, None, None, None, None],
["def456", "BAW789 ", "United Kingdom", None, None, -0.1, 51.5, 11000, False, None, None, None, None, None, None, None],
["ghi789", "CCA100 ", "China", None, None, 116.4, 39.9, 12000, False, None, None, None, None, None, None, None],
],
}))
result = await fetch_domestic_flights(fetcher)
assert result["source"] == "opensky-domestic"
assert result["total_aircraft"] == 3
assert len(result["by_region"]) > 0
assert len(result["busiest_origins"]) > 0
# ---------------------------------------------------------------------------
# Webcams (Phase 16)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fetch_webcams_no_key(fetcher: Fetcher) -> None:
"""Webcams returns error when WINDY_API_KEY is not set."""
from world_intel_mcp.sources.webcams import fetch_webcams
with patch.dict("os.environ", {}, clear=True):
result = await fetch_webcams(fetcher)
assert "error" in result
assert "WINDY_API_KEY" in result["error"]
@pytest.mark.asyncio
@respx.mock
async def test_fetch_webcams_with_key(fetcher: Fetcher) -> None:
"""Webcams fetches camera data from Windy API."""
from world_intel_mcp.sources.webcams import fetch_webcams
respx.route().mock(return_value=httpx.Response(200, json={
"webcams": [
{
"webcamId": "cam-1",
"title": "Times Square",
"location": {"latitude": 40.758, "longitude": -73.985, "city": "New York", "country": "US"},
"images": {"current": {"preview": "https://example.com/prev.jpg", "thumbnail": "https://example.com/thumb.jpg"}},
"player": {"day": {"embed": "https://example.com/player"}},
"status": "active",
},
],
}))
with patch.dict("os.environ", {"WINDY_API_KEY": "test-key"}):
result = await fetch_webcams(fetcher, category="traffic", limit=10)
assert result["source"] == "windy-webcams"
assert result["count"] == 1
assert result["cameras"][0]["title"] == "Times Square"
assert result["cameras"][0]["lat"] == 40.758