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:
co-authored by
Claude Opus 4.6
parent
a5a1e207a7
commit
d075bbaf1a
@@ -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
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user