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
+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,
type=click.Choice(["geopolitics", "security", "technology", "finance", "military", "science",
"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")
@click.option("--limit", "-n", default=30, help="Max items")
@click.pass_context
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()
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]")
@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()
@click.option("--limit", "-n", default=20, help="Top N countries")
@click.pass_context
@@ -1297,6 +1362,129 @@ def exchanges_cmd(ctx: click.Context, tier: str | None, country: str | None) ->
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
# ---------------------------------------------------------------------------
+70 -4
View File
@@ -309,14 +309,17 @@ TOOLS: list[Tool] = [
# --- News (3 tools) ---
Tool(
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={
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Category filter",
"enum": ["geopolitics", "security", "technology", "finance", "military", "science"],
"description": "Category filter (24 categories available)",
"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},
},
@@ -349,7 +352,7 @@ TOOLS: list[Tool] = [
},
},
),
# --- Intelligence (12 tools) ---
# --- Intelligence (13 tools) ---
Tool(
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.",
@@ -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(
name="intel_risk_scores",
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) ---
Tool(
name="intel_status",
@@ -1037,6 +1079,9 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
# Intelligence
case "intel_country_brief":
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":
return await intelligence.fetch_risk_scores(fetcher, limit=arguments.get("limit", 20))
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),
)
# 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
case "intel_status":
return {
+51
View File
@@ -161,6 +161,37 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
("STAT News", "https://www.statnews.com/feed/"),
("WHO News", "https://www.who.int/rss-feeds/news-english.xml"),
("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",
"The Register": "specialty",
"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] = {
+194
View File
@@ -1183,3 +1183,197 @@ async def test_fetch_financial_centers_filter_country() -> None:
assert result["source"] == "static-geospatial"
assert result["count"] > 0
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