diff --git a/CLAUDE.md b/CLAUDE.md index debde51..089508a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Is -World Intelligence MCP Server — 109 tools across 30+ domains providing real-time global intelligence from free public APIs. Serves four interfaces: MCP stdio (for Claude Code/Cursor), a live Starlette dashboard with SSE, a Click CLI with Rich output, and a collector daemon for 24/7 vector store population. Python 3.11+, built with hatchling. +World Intelligence MCP Server — 110 tools across 30+ domains providing real-time global intelligence from free public APIs. Serves four interfaces: MCP stdio (for Claude Code/Cursor), a live Starlette dashboard with SSE, a Click CLI with Rich output, and a collector daemon for 24/7 vector store population. Python 3.11+, built with hatchling. ## Commands @@ -57,6 +57,8 @@ collector.py (daemon) ──┘ **Collector** (`collector.py`): Standalone daemon for 24/7 vector store population. 43 sources organized by domain, with `--daemon`, `--interval`, `--sources` CLI args. Dynamic import via `_import_fetch_fn()`. Entry point: `intel-collector`. +**Reports** (`reports.py`): PDF/HTML intelligence report generator. Collects 18 domains in parallel, renders styled HTML, converts to PDF via WeasyPrint. Optional dependency: `pip install -e ".[pdf]"`. + **Dashboard** (`dashboard/`): Self-contained Starlette app with a single `index.html` template (no frontend build step). SSE endpoint streams all domains in parallel via `asyncio.gather()`, refreshes every 30 seconds. Loads `.env` from project root on startup. ## Adding a New Tool diff --git a/README.md b/README.md index cc6479e..f5e6d3f 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,9 @@ Built for AI agents that need world awareness: market conditions, geopolitical r | **Monitoring** | 2 | Webcams, server health/status | | **Vector Search** | 5 | Qdrant semantic search, similarity, timeline, stats | | **Cross-Domain Analytics** | 3 | Correlation, domain summary, trend detection | +| **Reports** | 1 | PDF/HTML multi-domain intelligence reports | -**Total: 109 tools** across 30+ intelligence domains. +**Total: 110 tools** across 30+ intelligence domains. --- @@ -102,6 +103,16 @@ intel-dashboard # http://localhost:8501 intel-dashboard --port 9000 # custom port ``` +### PDF/HTML Reports + +```bash +pip install -e ".[pdf]" # requires: brew install pango (macOS) +intel report # full PDF report → ~/.cache/world-intel-mcp/ +intel report --format html # HTML (no native deps needed) +intel report -o brief.pdf # custom output path +intel report -s markets,cyber,earthquakes # select sections +``` + Map-first ops center: Leaflet map with toggle-able layers (quakes, military, conflict, fires, convergence, nuclear, infrastructure), 35+ live SSE feeds, HUD bar, glassmorphic panels, per-source circuit breaker health. ### CLI @@ -405,6 +416,12 @@ collector.py (daemon) ─┘ | `intel_domain_summary` | Per-category summary of stored intelligence (counts, sources, recency) | | `intel_trend_detection` | Detect activity surges/drops by comparing recent vs baseline periods | +### Reports (1) + +| Tool | Description | +|------|-------------| +| `intel_generate_report` | Generate a PDF or HTML intelligence report covering 18 domains in parallel | + --- ## Vector Store diff --git a/src/world_intel_mcp/cli.py b/src/world_intel_mcp/cli.py index 559f032..4c9a16c 100644 --- a/src/world_intel_mcp/cli.py +++ b/src/world_intel_mcp/cli.py @@ -18,14 +18,37 @@ from rich import box from .cache import Cache from .circuit_breaker import CircuitBreaker from .fetcher import Fetcher -from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, shipping, social, nuclear, space_weather, ai_watch, elections, health, sanctions +from .sources import ( + markets, + economic, + seismology, + wildfire, + conflict, + military, + infrastructure, + maritime, + climate, + news, + intelligence, + prediction, + displacement, + aviation, + cyber, + shipping, + social, + nuclear, + space_weather, + ai_watch, + elections, + health, + sanctions, +) from .sources.central_banks import fetch_central_bank_rates from .sources.usni_fleet import fetch_usni_fleet from .sources.hacker_news import fetch_hacker_news from .sources.github_trending import fetch_trending_repos from .sources.arxiv_papers import fetch_arxiv_papers from .sources.usa_spending import fetch_usa_spending -from .sources.environmental import fetch_environmental_events, fetch_disaster_alerts from .sources import geospatial console = Console() @@ -57,6 +80,7 @@ def _print_json(data: dict) -> None: # Root group # --------------------------------------------------------------------------- + @click.group() @click.option("--json-output", is_flag=True, help="Output raw JSON") @click.pass_context @@ -70,6 +94,7 @@ def main(ctx: click.Context, json_output: bool) -> None: # Markets # --------------------------------------------------------------------------- + @main.command(name="markets") @click.option("--symbols", "-s", multiple=True, help="Ticker symbols") @click.pass_context @@ -178,6 +203,7 @@ def macro(ctx: click.Context) -> None: # Economic # --------------------------------------------------------------------------- + @main.command() @click.pass_context def energy(ctx: click.Context) -> None: @@ -196,7 +222,11 @@ def energy(ctx: click.Context) -> None: table.add_column("Price", justify="right") table.add_column("Date") - for name, info in [("Brent Crude", oil.get("brent")), ("WTI Crude", oil.get("wti")), ("Natural Gas", gas)]: + for name, info in [ + ("Brent Crude", oil.get("brent")), + ("WTI Crude", oil.get("wti")), + ("Natural Gas", gas), + ]: if info and isinstance(info, dict): table.add_row(name, f"${info.get('price', '?')}", str(info.get("date", ""))) console.print(table) @@ -230,6 +260,7 @@ def fred(ctx: click.Context, series_id: str, limit: int) -> None: # Natural # --------------------------------------------------------------------------- + @main.command() @click.option("--min-mag", "-m", default=4.5, help="Minimum magnitude") @click.option("--hours", "-h", default=24, help="Lookback hours") @@ -244,7 +275,9 @@ def earthquakes(ctx: click.Context, min_mag: float, hours: int) -> None: return quakes = data.get("earthquakes", []) - console.print(f"[bold]{data.get('count', 0)} earthquakes[/bold] (M{min_mag}+ in last {hours}h)\n") + console.print( + f"[bold]{data.get('count', 0)} earthquakes[/bold] (M{min_mag}+ in last {hours}h)\n" + ) table = Table(box=box.SIMPLE_HEAVY) table.add_column("Mag", justify="right", style="bold") @@ -279,7 +312,9 @@ def fires(ctx: click.Context, region: str | None) -> None: _print_json(data) return - console.print(f"[bold]{data.get('total_fires', 0)} high-confidence fires detected[/bold]\n") + console.print( + f"[bold]{data.get('total_fires', 0)} high-confidence fires detected[/bold]\n" + ) for reg_name, reg_data in data.get("fires_by_region", {}).items(): count = reg_data.get("count", 0) @@ -297,6 +332,7 @@ def fires(ctx: click.Context, region: str | None) -> None: # Conflict # --------------------------------------------------------------------------- + @main.command() @click.option("--country", "-c", default=None, help="Country name") @click.option("--days", "-d", default=7, help="Lookback days") @@ -311,7 +347,9 @@ def conflicts(ctx: click.Context, country: str | None, days: int) -> None: return events = data.get("events", []) - console.print(f"[bold]{data.get('count', 0)} conflict events[/bold] (last {days}d)\n") + console.print( + f"[bold]{data.get('count', 0)} conflict events[/bold] (last {days}d)\n" + ) table = Table(box=box.SIMPLE_HEAVY) table.add_column("Date", style="bold") @@ -338,8 +376,11 @@ def conflicts(ctx: click.Context, country: str | None, days: int) -> None: # Military # --------------------------------------------------------------------------- + @main.command() -@click.option("--bbox", "-b", default=None, help="Bounding box: lamin,lomin,lamax,lomax") +@click.option( + "--bbox", "-b", default=None, help="Bounding box: lamin,lomin,lamax,lomax" +) @click.pass_context def flights(ctx: click.Context, bbox: str | None) -> None: """Military aircraft tracking (OpenSky).""" @@ -382,7 +423,9 @@ def posture(ctx: click.Context) -> None: _print_json(data) return - console.print(f"[bold]{data.get('total_military_aircraft', 0)} total military aircraft[/bold]\n") + console.print( + f"[bold]{data.get('total_military_aircraft', 0)} total military aircraft[/bold]\n" + ) theaters = data.get("theaters", {}) table = Table(title="Theater Posture", box=box.SIMPLE_HEAVY) @@ -408,6 +451,7 @@ def posture(ctx: click.Context) -> None: # Infrastructure # --------------------------------------------------------------------------- + @main.command() @click.pass_context def outages(ctx: click.Context) -> None: @@ -419,13 +463,17 @@ def outages(ctx: click.Context) -> None: _print_json(data) return - console.print(f"[bold]{data.get('ongoing_count', 0)} ongoing outages[/bold], " - f"{data.get('total_7d', 0)} in last 7 days\n") + console.print( + f"[bold]{data.get('ongoing_count', 0)} ongoing outages[/bold], " + f"{data.get('total_7d', 0)} in last 7 days\n" + ) for o in data.get("outages", [])[:15]: ongoing = "[red]ONGOING[/red]" if o.get("is_ongoing") else "" countries = ", ".join(o.get("countries", [])[:5]) if o.get("countries") else "" - console.print(f" {o.get('start', '')[:16]} {countries} {o.get('description', '')[:80]} {ongoing}") + console.print( + f" {o.get('start', '')[:16]} {countries} {o.get('description', '')[:80]} {ongoing}" + ) @main.command() @@ -439,8 +487,12 @@ def cables(ctx: click.Context) -> None: _print_json(data) return - status_labels = {0: "[green]Clear[/green]", 1: "[yellow]Advisory[/yellow]", - 2: "[red]At Risk[/red]", 3: "[red bold]Disrupted[/red bold]"} + status_labels = { + 0: "[green]Clear[/green]", + 1: "[yellow]Advisory[/yellow]", + 2: "[red]At Risk[/red]", + 3: "[red bold]Disrupted[/red bold]", + } table = Table(title="Undersea Cable Health", box=box.SIMPLE_HEAVY) table.add_column("Corridor", style="bold") @@ -463,6 +515,7 @@ def cables(ctx: click.Context) -> None: # Maritime # --------------------------------------------------------------------------- + @main.command() @click.option("--navarea", "-n", default=None, help="NAVAREA number (e.g., IV)") @click.pass_context @@ -479,17 +532,22 @@ def warnings(ctx: click.Context, navarea: str | None) -> None: by_area = data.get("by_navarea", {}) if by_area: - console.print(" By NAVAREA: " + ", ".join(f"{k}:{v}" for k, v in sorted(by_area.items()))) + console.print( + " By NAVAREA: " + ", ".join(f"{k}:{v}" for k, v in sorted(by_area.items())) + ) console.print() for w in data.get("warnings", [])[:20]: - console.print(f" [{w.get('navarea', '?')}] {w.get('id', '')} {w.get('text', '')[:100]}") + console.print( + f" [{w.get('navarea', '?')}] {w.get('id', '')} {w.get('text', '')[:100]}" + ) # --------------------------------------------------------------------------- # Climate # --------------------------------------------------------------------------- + @main.command(name="climate") @click.pass_context def climate_cmd(ctx: click.Context) -> None: @@ -515,7 +573,9 @@ def climate_cmd(ctx: click.Context) -> None: prec_a = z.get("precip_anomaly_pct", 0) t_style = "red" if temp_a > 3 else "blue" if temp_a < -3 else "" flag = "[red bold]SIG[/red bold]" if key in sig else "" - t_str = f"[{t_style}]{temp_a:+.1f}C[/{t_style}]" if t_style else f"{temp_a:+.1f}C" + t_str = ( + f"[{t_style}]{temp_a:+.1f}C[/{t_style}]" if t_style else f"{temp_a:+.1f}C" + ) table.add_row(z.get("name", key), t_str, f"{prec_a:+.0f}%", flag) console.print(table) @@ -524,13 +584,42 @@ def climate_cmd(ctx: click.Context) -> None: # News # --------------------------------------------------------------------------- + @main.command(name="news") -@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", "central_asia", "arctic", "maritime", "space", "nuclear", "climate"]), - help="Category filter") +@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", + "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: @@ -547,7 +636,9 @@ def news_cmd(ctx: click.Context, category: str | None, limit: int) -> None: console.print("[yellow]No news items available[/yellow]") return - console.print(f"[bold]{data.get('count', 0)} items[/bold] from {', '.join(data.get('categories_fetched', []))}\n") + console.print( + f"[bold]{data.get('count', 0)} items[/bold] from {', '.join(data.get('categories_fetched', []))}\n" + ) for item in items: cat = item.get("category", "") @@ -571,7 +662,9 @@ def trending(ctx: click.Context, min_count: int) -> None: return keywords = data.get("keywords", []) - console.print(f"[bold]Trending keywords[/bold] (from {data.get('total_items_analyzed', 0)} items)\n") + console.print( + f"[bold]Trending keywords[/bold] (from {data.get('total_items_analyzed', 0)} items)\n" + ) table = Table(box=box.SIMPLE_HEAVY) table.add_column("#", justify="right") @@ -585,7 +678,9 @@ def trending(ctx: click.Context, min_count: int) -> None: @main.command() @click.argument("query", default="conflict") -@click.option("--mode", "-m", default="artlist", type=click.Choice(["artlist", "timelinevol"])) +@click.option( + "--mode", "-m", default="artlist", type=click.Choice(["artlist", "timelinevol"]) +) @click.option("--limit", "-n", default=20, help="Max records") @click.pass_context def gdelt(ctx: click.Context, query: str, mode: str, limit: int) -> None: @@ -613,6 +708,7 @@ def gdelt(ctx: click.Context, query: str, mode: str, limit: int) -> None: # Prediction # --------------------------------------------------------------------------- + @main.command() @click.option("--limit", "-n", default=20, help="Number of markets") @click.pass_context @@ -640,7 +736,9 @@ def predictions(ctx: click.Context, limit: int) -> None: yes_pct = (m.get("yes_probability", 0) or 0) * 100 sentiment = m.get("sentiment", "") vol = m.get("volume_24h", 0) or 0 - s_style = "green" if "yes" in sentiment else "red" if "no" in sentiment else "yellow" + s_style = ( + "green" if "yes" in sentiment else "red" if "no" in sentiment else "yellow" + ) table.add_row( (m.get("question", "")[:50]), f"{yes_pct:.0f}%", @@ -654,6 +752,7 @@ def predictions(ctx: click.Context, limit: int) -> None: # Displacement # --------------------------------------------------------------------------- + @main.command(name="displacement") @click.option("--year", "-y", default=None, type=int, help="Reporting year") @click.pass_context @@ -691,6 +790,7 @@ def displacement_cmd(ctx: click.Context, year: int | None) -> None: # Aviation # --------------------------------------------------------------------------- + @main.command() @click.pass_context def delays(ctx: click.Context) -> None: @@ -703,8 +803,10 @@ def delays(ctx: click.Context) -> None: return delayed = data.get("delayed", []) - console.print(f"[bold]{data.get('delayed_count', 0)} airports with delays[/bold] " - f"(checked {data.get('total_checked', 0)})\n") + console.print( + f"[bold]{data.get('delayed_count', 0)} airports with delays[/bold] " + f"(checked {data.get('total_checked', 0)})\n" + ) if not delayed: console.print("[green]No major airport delays![/green]") @@ -717,10 +819,14 @@ def delays(ctx: click.Context) -> None: for d in delayed: statuses = d.get("status", []) - info = "; ".join( - f"{s.get('type', '')} - {s.get('reason', '')} ({s.get('avg_delay', '')})" - for s in statuses - ) if statuses else "Details unavailable" + info = ( + "; ".join( + f"{s.get('type', '')} - {s.get('reason', '')} ({s.get('avg_delay', '')})" + for s in statuses + ) + if statuses + else "Details unavailable" + ) table.add_row(d.get("code", ""), d.get("name", ""), info[:80]) console.print(table) @@ -729,6 +835,7 @@ def delays(ctx: click.Context) -> None: # Cyber # --------------------------------------------------------------------------- + @main.command() @click.option("--limit", "-n", default=30, help="Max threats") @click.pass_context @@ -742,12 +849,16 @@ def threats(ctx: click.Context, limit: int) -> None: return by_sev = data.get("by_severity", {}) - console.print(f"[bold]{data.get('count', 0)} threats[/bold] " - f"({data.get('feeds_successful', 0)}/{data.get('feeds_attempted', 0)} feeds)") - console.print(f" [red]Critical: {by_sev.get('critical', 0)}[/red] " - f"[yellow]High: {by_sev.get('high', 0)}[/yellow] " - f"Medium: {by_sev.get('medium', 0)} " - f"[dim]Low: {by_sev.get('low', 0)}[/dim]\n") + console.print( + f"[bold]{data.get('count', 0)} threats[/bold] " + f"({data.get('feeds_successful', 0)}/{data.get('feeds_attempted', 0)} feeds)" + ) + console.print( + f" [red]Critical: {by_sev.get('critical', 0)}[/red] " + f"[yellow]High: {by_sev.get('high', 0)}[/yellow] " + f"Medium: {by_sev.get('medium', 0)} " + f"[dim]Low: {by_sev.get('low', 0)}[/dim]\n" + ) table = Table(box=box.SIMPLE_HEAVY) table.add_column("Severity") @@ -758,7 +869,12 @@ def threats(ctx: click.Context, limit: int) -> None: for t in data.get("threats", [])[:limit]: sev = t.get("severity", "") - sev_style = {"critical": "red bold", "high": "yellow", "medium": "", "low": "dim"}.get(sev, "") + sev_style = { + "critical": "red bold", + "high": "yellow", + "medium": "", + "low": "dim", + }.get(sev, "") sev_str = f"[{sev_style}]{sev}[/{sev_style}]" if sev_style else sev table.add_row( sev_str, @@ -774,6 +890,7 @@ def threats(ctx: click.Context, limit: int) -> None: # Intelligence # --------------------------------------------------------------------------- + @main.command() @click.argument("country_code", default="US") @click.pass_context @@ -786,14 +903,20 @@ def brief(ctx: click.Context, country_code: str) -> None: _print_json(data) return - llm_tag = "[green]LLM[/green]" if data.get("llm_available") else "[yellow]data-only[/yellow]" + llm_tag = ( + "[green]LLM[/green]" + if data.get("llm_available") + else "[yellow]data-only[/yellow]" + ) console.print(f"[bold]Intelligence Brief: {country_code}[/bold] ({llm_tag})\n") console.print(data.get("brief", "No brief available.")) d = data.get("data", {}) if d.get("gdp") or d.get("recent_events"): - console.print(f"\n[dim]GDP data points: {len(d.get('gdp', []))} | " - f"Recent conflict events: {d.get('recent_events', 0)}[/dim]") + console.print( + f"\n[dim]GDP data points: {len(d.get('gdp', []))} | " + f"Recent conflict events: {d.get('recent_events', 0)}[/dim]" + ) @main.command() @@ -802,6 +925,7 @@ def brief(ctx: click.Context, country_code: str) -> None: 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)) @@ -810,8 +934,10 @@ def dossier(ctx: click.Context, country: str) -> None: return overview = data.get("overview", {}) - console.print(f"[bold]Country Dossier: {overview.get('country', country)}[/bold] " - f"({overview.get('iso2')}/{overview.get('iso3')})\n") + console.print( + f"[bold]Country Dossier: {overview.get('country', country)}[/bold] " + f"({overview.get('iso2')}/{overview.get('iso3')})\n" + ) # Economy econ = data.get("economy", {}) @@ -819,7 +945,9 @@ def dossier(ctx: click.Context, country: str) -> None: 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") + 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']}") @@ -827,15 +955,19 @@ def dossier(ctx: click.Context, country: str) -> None: 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')}%)") + 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})") + 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", {}) @@ -881,7 +1013,12 @@ def risk(ctx: click.Context, limit: int) -> None: for i, c in enumerate(data.get("countries", []), 1): level = c.get("risk_level", "") - l_style = {"critical": "red bold", "elevated": "yellow", "moderate": "", "low": "dim"}.get(level, "") + l_style = { + "critical": "red bold", + "elevated": "yellow", + "moderate": "", + "low": "dim", + }.get(level, "") l_str = f"[{l_style}]{level}[/{l_style}]" if l_style else level table.add_row( str(i), @@ -908,7 +1045,9 @@ def instability(ctx: click.Context, country_code: str | None) -> None: if country_code: idx = data.get("instability_index", 0) level = data.get("risk_level", "") - console.print(f"[bold]{country_code} Instability Index: {idx}/100 ({level})[/bold]\n") + console.print( + f"[bold]{country_code} Instability Index: {idx}/100 ({level})[/bold]\n" + ) components = data.get("components", {}) for name, score in components.items(): bar = "█" * int(score) + "░" * (20 - int(score)) @@ -922,7 +1061,12 @@ def instability(ctx: click.Context, country_code: str | None) -> None: for c in data.get("countries", []): level = c.get("risk_level", "") - l_style = {"critical": "red bold", "high": "yellow", "medium": "", "low": "dim"}.get(level, "") + l_style = { + "critical": "red bold", + "high": "yellow", + "medium": "", + "low": "dim", + }.get(level, "") l_str = f"[{l_style}]{level}[/{l_style}]" if l_style else level table.add_row( f"{c.get('country_name', '')} ({c.get('country_code', '')})", @@ -937,6 +1081,7 @@ def instability(ctx: click.Context, country_code: str | None) -> None: # Finance (additional) # --------------------------------------------------------------------------- + @main.command() @click.pass_context def btc(ctx: click.Context) -> None: @@ -954,14 +1099,33 @@ def btc(ctx: click.Context) -> None: table.add_column("Value", justify="right") table.add_row("SMA-50", f"${data.get('sma_50', 0):,.2f}") - table.add_row("SMA-200", f"${data.get('sma_200', 0):,.2f}" if data.get('sma_200') else "N/A") - table.add_row("Mayer Multiple", f"{data.get('mayer_multiple', 0):.4f}" if data.get('mayer_multiple') else "N/A") - cross = data.get('cross_signal', 'neutral') - c_style = "green" if cross == "golden_cross" else "red" if cross == "death_cross" else "" - table.add_row("Cross Signal", f"[{c_style}]{cross}[/{c_style}]" if c_style else cross) + table.add_row( + "SMA-200", f"${data.get('sma_200', 0):,.2f}" if data.get("sma_200") else "N/A" + ) + table.add_row( + "Mayer Multiple", + f"{data.get('mayer_multiple', 0):.4f}" if data.get("mayer_multiple") else "N/A", + ) + cross = data.get("cross_signal", "neutral") + c_style = ( + "green" if cross == "golden_cross" else "red" if cross == "death_cross" else "" + ) + table.add_row( + "Cross Signal", f"[{c_style}]{cross}[/{c_style}]" if c_style else cross + ) table.add_row("ATH Distance", f"{data.get('ath_distance_pct', 0):.1f}%") - table.add_row("7d Change", f"{data.get('change_7d_pct', 0):+.2f}%" if data.get('change_7d_pct') is not None else "N/A") - table.add_row("30d Change", f"{data.get('change_30d_pct', 0):+.2f}%" if data.get('change_30d_pct') is not None else "N/A") + table.add_row( + "7d Change", + f"{data.get('change_7d_pct', 0):+.2f}%" + if data.get("change_7d_pct") is not None + else "N/A", + ) + table.add_row( + "30d Change", + f"{data.get('change_30d_pct', 0):+.2f}%" + if data.get("change_30d_pct") is not None + else "N/A", + ) console.print(table) @@ -976,7 +1140,11 @@ def central_banks_cmd(ctx: click.Context) -> None: _print_json(data) return - fred_tag = "[green]FRED[/green]" if data.get("fred_available") else "[yellow]curated[/yellow]" + fred_tag = ( + "[green]FRED[/green]" + if data.get("fred_available") + else "[yellow]curated[/yellow]" + ) console.print(f"[bold]{data.get('count', 0)} Central Banks[/bold] ({fred_tag})\n") table = Table(box=box.SIMPLE_HEAVY) @@ -987,9 +1155,19 @@ def central_banks_cmd(ctx: click.Context) -> None: for r in data.get("rates", []): rate = r.get("rate", 0) - style = "red bold" if rate >= 10 else "yellow" if rate >= 5 else "green" if rate < 1 else "" + style = ( + "red bold" + if rate >= 10 + else "yellow" + if rate >= 5 + else "green" + if rate < 1 + else "" + ) rate_str = f"[{style}]{rate:.2f}[/{style}]" if style else f"{rate:.2f}" - table.add_row(r.get("bank", ""), r.get("country", ""), rate_str, r.get("as_of", "")) + table.add_row( + r.get("bank", ""), r.get("country", ""), rate_str, r.get("as_of", "") + ) console.print(table) @@ -1004,8 +1182,10 @@ def shipping_cmd(ctx: click.Context) -> None: _print_json(data) return - console.print(f"[bold]Shipping Stress: {data.get('assessment', '?')}[/bold] " - f"(score: {data.get('stress_score', 0):.0f}/100)\n") + console.print( + f"[bold]Shipping Stress: {data.get('assessment', '?')}[/bold] " + f"(score: {data.get('stress_score', 0):.0f}/100)\n" + ) table = Table(box=box.SIMPLE_HEAVY) table.add_column("Symbol", style="bold") @@ -1015,7 +1195,11 @@ def shipping_cmd(ctx: click.Context) -> None: for q in data.get("quotes", []): chg = q.get("change_pct") or 0 style = "green" if chg >= 0 else "red" - table.add_row(q.get("symbol", ""), f"${q.get('price', 0):,.2f}", f"[{style}]{chg:+.2f}%[/{style}]") + table.add_row( + q.get("symbol", ""), + f"${q.get('price', 0):,.2f}", + f"[{style}]{chg:+.2f}%[/{style}]", + ) console.print(table) @@ -1023,6 +1207,7 @@ def shipping_cmd(ctx: click.Context) -> None: # Social & Health # --------------------------------------------------------------------------- + @main.command(name="social") @click.pass_context def social_cmd(ctx: click.Context) -> None: @@ -1035,14 +1220,20 @@ def social_cmd(ctx: click.Context) -> None: return metrics = data.get("velocity_metrics", {}) - console.print(f"[bold]Social Signals[/bold] — {metrics.get('total_posts', 0)} posts, " - f"{metrics.get('high_engagement_count', 0)} high engagement\n") + console.print( + f"[bold]Social Signals[/bold] — {metrics.get('total_posts', 0)} posts, " + f"{metrics.get('high_engagement_count', 0)} high engagement\n" + ) for post in data.get("top_posts", [])[:15]: score = post.get("score", 0) style = "bold" if score >= 1000 else "" title = post.get("title", "")[:80] - console.print(f" [{style}]{score:>5}[/{style}] {title}" if style else f" {score:>5} {title}") + console.print( + f" [{style}]{score:>5}[/{style}] {title}" + if style + else f" {score:>5} {title}" + ) @main.command(name="disease") @@ -1056,8 +1247,10 @@ def disease_cmd(ctx: click.Context) -> None: _print_json(data) return - console.print(f"[bold]{data.get('count', 0)} outbreak reports[/bold] " - f"([red]{data.get('high_concern_count', 0)} high concern[/red])\n") + console.print( + f"[bold]{data.get('count', 0)} outbreak reports[/bold] " + f"([red]{data.get('high_concern_count', 0)} high concern[/red])\n" + ) for item in data.get("items", [])[:20]: hc = "[red]HC[/red] " if item.get("is_high_concern") else " " @@ -1089,7 +1282,13 @@ def elections_cmd(ctx: click.Context, country: str | None) -> None: risk = e.get("risk_score", 0) r_style = "red bold" if risk >= 4 else "yellow" if risk >= 2 else "" r_str = f"[{r_style}]{risk:.1f}[/{r_style}]" if r_style else f"{risk:.1f}" - table.add_row(e.get("date", ""), e.get("country", ""), e.get("type", ""), str(e.get("days_until", "")), r_str) + table.add_row( + e.get("date", ""), + e.get("country", ""), + e.get("type", ""), + str(e.get("days_until", "")), + r_str, + ) console.print(table) @@ -1097,6 +1296,7 @@ def elections_cmd(ctx: click.Context, country: str | None) -> None: # Specialist # --------------------------------------------------------------------------- + @main.command(name="nuclear") @click.option("--hours", "-h", default=72, help="Lookback hours") @click.pass_context @@ -1109,8 +1309,10 @@ def nuclear_cmd(ctx: click.Context, hours: int) -> None: _print_json(data) return - console.print(f"[bold]{data.get('total_flagged_events', 0)} flagged events[/bold] " - f"([red]{data.get('critical_flags', 0)} critical[/red]) in last {hours}h\n") + console.print( + f"[bold]{data.get('total_flagged_events', 0)} flagged events[/bold] " + f"([red]{data.get('critical_flags', 0)} critical[/red]) in last {hours}h\n" + ) for site in data.get("sites", []): n = site.get("name", "") @@ -1135,7 +1337,13 @@ def space_cmd(ctx: click.Context) -> None: table.add_column("Metric", style="bold") table.add_column("Value", justify="right") - for key in ("k_index", "solar_wind_speed_km_s", "solar_wind_density", "bz_gsm_nt", "flux_10_7"): + for key in ( + "k_index", + "solar_wind_speed_km_s", + "solar_wind_density", + "bz_gsm_nt", + "flux_10_7", + ): val = data.get(key) if val is not None: table.add_row(key.replace("_", " ").title(), str(val)) @@ -1155,8 +1363,10 @@ def sanctions_cmd(ctx: click.Context, query: str, country: str | None) -> None: _print_json(data) return - console.print(f"[bold]{data.get('count', 0)} matches[/bold] for '{query}' " - f"(from {data.get('total_entities', 0)} total)\n") + console.print( + f"[bold]{data.get('count', 0)} matches[/bold] for '{query}' " + f"(from {data.get('total_entities', 0)} total)\n" + ) for m in data.get("matches", [])[:20]: etype = m.get("entity_type", "") @@ -1187,6 +1397,7 @@ def ai_watch_cmd(ctx: click.Context) -> None: # Navy # --------------------------------------------------------------------------- + @main.command(name="fleet") @click.pass_context def fleet_cmd(ctx: click.Context) -> None: @@ -1203,10 +1414,14 @@ def fleet_cmd(ctx: click.Context) -> None: totals = data.get("force_totals", {}) if totals.get("battle_force"): bf = totals["battle_force"] - console.print(f" Battle Force: {bf.get('total', 0)} ships ({bf.get('uss', 0)} USS, {bf.get('usns', 0)} USNS)") + console.print( + f" Battle Force: {bf.get('total', 0)} ships ({bf.get('uss', 0)} USS, {bf.get('usns', 0)} USNS)" + ) if totals.get("deployed"): dp = totals["deployed"] - console.print(f" Deployed: {dp.get('total', 0)} ({dp.get('uss', 0)} USS, {dp.get('usns', 0)} USNS)") + console.print( + f" Deployed: {dp.get('total', 0)} ({dp.get('uss', 0)} USS, {dp.get('usns', 0)} USNS)" + ) ships = data.get("ships", []) if ships: @@ -1217,7 +1432,12 @@ def fleet_cmd(ctx: click.Context) -> None: table.add_column("Type") table.add_column("Region") for s in ships[:20]: - table.add_row(s.get("name", ""), s.get("hull_number", ""), s.get("type", ""), s.get("region", "")) + table.add_row( + s.get("name", ""), + s.get("hull_number", ""), + s.get("type", ""), + s.get("region", ""), + ) console.print(table) @@ -1225,6 +1445,7 @@ def fleet_cmd(ctx: click.Context) -> None: # Tech & Science # --------------------------------------------------------------------------- + @main.command(name="hn") @click.option("--limit", "-n", default=20, help="Number of stories") @click.pass_context @@ -1303,7 +1524,9 @@ def spending_cmd(ctx: click.Context, limit: int) -> None: for a in data.get("agencies", []): budget = a.get("budget_authority", 0) or 0 obligated = a.get("obligated", 0) or 0 - table.add_row(a.get("name", ""), f"${budget/1e9:,.1f}B", f"${obligated/1e9:,.1f}B") + table.add_row( + a.get("name", ""), f"${budget / 1e9:,.1f}B", f"${obligated / 1e9:,.1f}B" + ) console.print(table) @@ -1311,6 +1534,7 @@ def spending_cmd(ctx: click.Context, limit: int) -> None: # Geospatial (static datasets) # --------------------------------------------------------------------------- + @main.command(name="bases") @click.option("--operator", "-o", default=None, help="Operator country (USA, RUS, CHN)") @click.option("--country", "-c", default=None, help="Host country") @@ -1323,7 +1547,9 @@ def bases_cmd(ctx: click.Context, operator: str | None, country: str | None) -> _print_json(data) return - console.print(f"[bold]{data.get('count', 0)} bases[/bold] (of {data.get('total_in_database', 0)})\n") + console.print( + f"[bold]{data.get('count', 0)} bases[/bold] (of {data.get('total_in_database', 0)})\n" + ) table = Table(box=box.SIMPLE_HEAVY) table.add_column("Name", style="bold") table.add_column("Operator") @@ -1332,12 +1558,20 @@ def bases_cmd(ctx: click.Context, operator: str | None, country: str | None) -> table.add_column("Branch") for b in data.get("bases", [])[:30]: - table.add_row(b.get("name", ""), b.get("operator", ""), b.get("country", ""), b.get("type", ""), b.get("branch", "")) + table.add_row( + b.get("name", ""), + b.get("operator", ""), + b.get("country", ""), + b.get("type", ""), + b.get("branch", ""), + ) console.print(table) @main.command(name="exchanges") -@click.option("--tier", "-t", default=None, type=click.Choice(["mega", "major", "mid", "small"])) +@click.option( + "--tier", "-t", default=None, type=click.Choice(["mega", "major", "mid", "small"]) +) @click.option("--country", "-c", default=None, help="Country filter") @click.pass_context def exchanges_cmd(ctx: click.Context, tier: str | None, country: str | None) -> None: @@ -1348,8 +1582,10 @@ def exchanges_cmd(ctx: click.Context, tier: str | None, country: str | None) -> _print_json(data) return - console.print(f"[bold]{data.get('count', 0)} exchanges[/bold] " - f"(${data.get('total_market_cap_usd_t', 0):.1f}T total market cap)\n") + console.print( + f"[bold]{data.get('count', 0)} exchanges[/bold] " + f"(${data.get('total_market_cap_usd_t', 0):.1f}T total market cap)\n" + ) table = Table(box=box.SIMPLE_HEAVY) table.add_column("Exchange", style="bold") @@ -1358,7 +1594,12 @@ def exchanges_cmd(ctx: click.Context, tier: str | None, country: str | None) -> table.add_column("Market Cap ($T)", justify="right") for e in data.get("exchanges", [])[:30]: - table.add_row(e.get("name", ""), e.get("country", ""), e.get("tier", ""), f"{e.get('market_cap_usd_t', 0):.2f}") + table.add_row( + e.get("name", ""), + e.get("country", ""), + e.get("tier", ""), + f"{e.get('market_cap_usd_t', 0):.2f}", + ) console.print(table) @@ -1366,11 +1607,13 @@ def exchanges_cmd(ctx: click.Context, tier: str | None, country: str | None) -> # 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)) @@ -1378,8 +1621,10 @@ def traffic(ctx: click.Context) -> None: _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") + 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") @@ -1390,9 +1635,12 @@ def traffic(ctx: click.Context) -> None: 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", ""))) + table.add_row( + c.get("name", ""), + c.get("country", ""), + f"[{style}]{cong}%[/{style}]", + str(c.get("current_speed_kmh", "")), + ) console.print(table) @@ -1401,6 +1649,7 @@ def traffic(ctx: click.Context) -> None: 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)) @@ -1408,8 +1657,10 @@ def incidents(ctx: click.Context) -> None: _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") + 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") @@ -1439,7 +1690,9 @@ def air_traffic_cmd(ctx: click.Context) -> None: _print_json(data) return - console.print(f"[bold]Air Traffic[/bold] — {data.get('total_aircraft', 0)} airborne\n") + 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") @@ -1447,8 +1700,12 @@ def air_traffic_cmd(ctx: click.Context) -> None: 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"])) + 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"): @@ -1464,6 +1721,7 @@ def air_traffic_cmd(ctx: click.Context) -> None: 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)) @@ -1471,7 +1729,9 @@ def webcams_cmd(ctx: click.Context, category: str, limit: int) -> None: _print_json(data) return - console.print(f"[bold]Webcams[/bold] — {data.get('count', 0)} cameras ({category})\n") + 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) @@ -1480,8 +1740,12 @@ def webcams_cmd(ctx: click.Context, category: str, limit: int) -> None: 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", "")) + table.add_row( + cam.get("title", "")[:30], + cam.get("city", ""), + cam.get("country", ""), + cam.get("status", ""), + ) console.print(table) @@ -1489,6 +1753,7 @@ def webcams_cmd(ctx: click.Context, category: str, limit: int) -> None: # System # --------------------------------------------------------------------------- + @main.command() @click.pass_context def status(ctx: click.Context) -> None: @@ -1504,8 +1769,10 @@ def status(ctx: click.Context) -> None: console.print(Panel("[bold]World Intelligence Status[/bold]")) # Cache - console.print(f"\n[bold]Cache:[/bold] {cache_stats.get('active_entries', 0)} active, " - f"{cache_stats.get('expired_entries', 0)} expired") + console.print( + f"\n[bold]Cache:[/bold] {cache_stats.get('active_entries', 0)} active, " + f"{cache_stats.get('expired_entries', 0)} expired" + ) # Circuit breakers if breaker_status: @@ -1517,12 +1784,16 @@ def status(ctx: click.Context) -> None: for source, info in sorted(breaker_status.items()): s = info.get("status", "closed") - style = "green" if s == "closed" else "yellow" if s == "half-open" else "red" + style = ( + "green" if s == "closed" else "yellow" if s == "half-open" else "red" + ) table.add_row( source, f"[{style}]{s}[/{style}]", str(info.get("failures", 0)), - f"{info.get('cooldown_remaining_s', 0):.0f}s" if info.get("cooldown_remaining_s") else "", + f"{info.get('cooldown_remaining_s', 0):.0f}s" + if info.get("cooldown_remaining_s") + else "", ) console.print(table) else: @@ -1537,8 +1808,12 @@ def sync_cmd(source: str | None) -> None: if source: # Delete all cache entries matching this source prefix # Simple approach: evict expired, then note we can't selectively clear yet - console.print(f"[yellow]Force sync not yet implemented for specific source '{source}'[/yellow]") - console.print("[dim]Workaround: cache entries expire naturally based on TTL[/dim]") + console.print( + f"[yellow]Force sync not yet implemented for specific source '{source}'[/yellow]" + ) + console.print( + "[dim]Workaround: cache entries expire naturally based on TTL[/dim]" + ) else: removed = f.cache.evict_expired() console.print(f"Evicted {removed} expired cache entries") @@ -1551,9 +1826,67 @@ def dashboard(port: int, host: str) -> None: """Launch the live intelligence dashboard.""" from .dashboard.app import run as run_dashboard - console.print(f"[bold]Starting Intelligence Dashboard[/bold] on http://{host}:{port}") + console.print( + f"[bold]Starting Intelligence Dashboard[/bold] on http://{host}:{port}" + ) run_dashboard(host=host, port=port) +@main.command() +@click.option( + "--output", "-o", type=click.Path(), default=None, help="Output file path" +) +@click.option("--title", "-t", default=None, help="Report title") +@click.option( + "--format", + "fmt", + type=click.Choice(["pdf", "html"]), + default="pdf", + help="Output format", +) +@click.option( + "--sections", "-s", default=None, help="Comma-separated section names to include" +) +def report( + output: str | None, title: str | None, fmt: str, sections: str | None +) -> None: + """Generate a PDF or HTML intelligence report.""" + from .reports import generate_report + + f = _get_fetcher() + section_list = [s.strip() for s in sections.split(",")] if sections else None + + with console.status("[bold]Generating report..."): + result = asyncio.run( + generate_report( + f, output_path=output, title=title, sections=section_list, fmt=fmt + ) + ) + + if "error" in result: + console.print(f"[red]Error:[/red] {result['error']}") + if "fallback" in result: + console.print(f"[yellow]{result['fallback']}[/yellow]") + return + + console.print( + Panel( + f"[bold green]Report generated[/bold green]\n\n" + f"Path: {result['path']}\n" + f"Format: {result['format']}\n" + f"Size: {result['size_bytes']:,} bytes\n" + f"Sections: {', '.join(result['sections_included'])}\n" + f"Time: {result['generation_seconds']}s" + + ( + f"\n[yellow]Failed: {', '.join(result['sections_failed'])}[/yellow]" + if result["sections_failed"] + else "" + ), + title="Intelligence Report", + border_style="green", + ) + ) + + if __name__ == "__main__": main() diff --git a/src/world_intel_mcp/reports.py b/src/world_intel_mcp/reports.py new file mode 100644 index 0000000..9ae3537 --- /dev/null +++ b/src/world_intel_mcp/reports.py @@ -0,0 +1,735 @@ +"""PDF intelligence report generator. + +Renders multi-domain intelligence summaries as styled PDF documents +using WeasyPrint. Data is pulled from the same source modules and +analysis engines used by the MCP server and dashboard. + +Optional dependency: ``pip install -e ".[pdf]"`` (weasyprint>=62.0). +Requires native pango/gobject libs (``brew install pango`` on macOS). +""" + +import asyncio +import logging +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .cache import Cache +from .circuit_breaker import CircuitBreaker +from .fetcher import Fetcher +from .sources import ( + markets, + seismology, + military, + infrastructure, + intelligence, # noqa: F401 + wildfire, + cyber, + climate, + conflict, + health, + shipping, + nuclear, + service_status, +) +from .analysis.alerts import fetch_alert_digest +from .analysis.clustering import fetch_news_clusters # noqa: F401 +from .analysis.posture import fetch_strategic_posture +from .analysis.world_brief import fetch_world_brief # noqa: F401 + +logger = logging.getLogger("world-intel-mcp.reports") + +# --------------------------------------------------------------------------- +# HTML template for the PDF report +# --------------------------------------------------------------------------- + +_CSS = """\ +@page { + size: A4; + margin: 1.5cm 1.8cm; + @bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; color: #888; } + @top-right { content: "WORLD INTELLIGENCE REPORT"; font-size: 7pt; color: #aaa; letter-spacing: 1px; } +} +* { box-sizing: border-box; } +body { + font-family: -apple-system, 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 9pt; + line-height: 1.45; + color: #1a1a2e; + margin: 0; +} +h1 { + font-size: 22pt; + margin: 0 0 4pt; + color: #0f0f23; + letter-spacing: -0.5px; +} +.subtitle { + font-size: 10pt; + color: #555; + margin-bottom: 14pt; + border-bottom: 2px solid #0f0f23; + padding-bottom: 8pt; +} +h2 { + font-size: 13pt; + color: #16213e; + margin: 16pt 0 6pt; + padding-bottom: 3pt; + border-bottom: 1px solid #ddd; + page-break-after: avoid; +} +h3 { + font-size: 10pt; + color: #1a1a2e; + margin: 10pt 0 4pt; + page-break-after: avoid; +} +table { + width: 100%; + border-collapse: collapse; + margin: 6pt 0 10pt; + font-size: 8.5pt; + page-break-inside: avoid; +} +th { + background: #16213e; + color: white; + padding: 4pt 6pt; + text-align: left; + font-weight: 600; + font-size: 8pt; +} +td { + padding: 3pt 6pt; + border-bottom: 1px solid #eee; + vertical-align: top; +} +tr:nth-child(even) td { background: #f8f9fa; } +.alert-box { + background: #fff3cd; + border-left: 4px solid #ffc107; + padding: 6pt 10pt; + margin: 6pt 0; + font-size: 8.5pt; + page-break-inside: avoid; +} +.alert-box.critical { + background: #f8d7da; + border-left-color: #dc3545; +} +.metric { + display: inline-block; + background: #e8eaf6; + border-radius: 3pt; + padding: 2pt 8pt; + margin: 2pt 4pt 2pt 0; + font-size: 8pt; + font-weight: 600; +} +.metric.green { background: #d4edda; color: #155724; } +.metric.red { background: #f8d7da; color: #721c24; } +.metric.amber { background: #fff3cd; color: #856404; } +.section-grid { + display: flex; + flex-wrap: wrap; + gap: 8pt; +} +.section-card { + flex: 1 1 45%; + border: 1px solid #dee2e6; + border-radius: 4pt; + padding: 6pt 8pt; + page-break-inside: avoid; +} +.footer { + margin-top: 20pt; + padding-top: 8pt; + border-top: 1px solid #ccc; + font-size: 7pt; + color: #999; + text-align: center; +} +.no-data { color: #999; font-style: italic; font-size: 8pt; } +""" + + +def _esc(text: Any) -> str: + """Escape HTML special chars.""" + if text is None: + return "" + s = str(text) + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def _fmt_num(val: Any, decimals: int = 2) -> str: + """Format a number with commas.""" + if val is None: + return "N/A" + try: + f = float(val) + if f == int(f) and decimals == 0: + return f"{int(f):,}" + return f"{f:,.{decimals}f}" + except (ValueError, TypeError): + return str(val) + + +def _change_class(val: Any) -> str: + """Return CSS class based on +/- value.""" + try: + v = float(val) + if v > 0: + return "green" + elif v < 0: + return "red" + except (ValueError, TypeError): + pass + return "" + + +# --------------------------------------------------------------------------- +# Section renderers +# --------------------------------------------------------------------------- + + +def _render_markets(data: dict) -> str: + """Render market quotes section.""" + quotes = data.get("quotes") or data.get("indices") or [] + if not quotes: + return '

Market data unavailable

' + + rows = [] + for q in quotes[:15]: + name = _esc(q.get("shortName") or q.get("symbol", "")) + price = _fmt_num(q.get("regularMarketPrice")) + chg = q.get("regularMarketChangePercent") + chg_str = f"{float(chg):+.2f}%" if chg is not None else "N/A" + cls = _change_class(chg) + rows.append( + f'{name}{price}{chg_str}' + ) + + return f""" + + + {"".join(rows)} +
Index / SymbolPriceChange
""" + + +def _render_earthquakes(data: dict) -> str: + """Render seismology section.""" + quakes = data.get("earthquakes", []) + if not quakes: + return '

No significant seismic activity

' + + rows = [] + for q in quakes[:10]: + props = q.get("properties", {}) + mag = _fmt_num(props.get("mag"), 1) + place = _esc(props.get("place", "Unknown")) + t = props.get("time") + time_str = ( + datetime.fromtimestamp(t / 1000, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ) + if t + else "" + ) + rows.append(f"{mag}{place}{time_str}") + + return f""" + + + {"".join(rows)} +
MagLocationTime
""" + + +def _render_conflicts(data: dict) -> str: + """Render conflict events section.""" + events = data.get("events", []) + if not events: + return '

No recent conflict events

' + + rows = [] + for e in events[:12]: + etype = _esc(e.get("event_type", "")) + country = _esc(e.get("country", "")) + fatalities = e.get("fatalities", 0) + date = _esc(e.get("event_date", "")) + notes = _esc(str(e.get("notes", ""))[:120]) + rows.append( + f"{etype}{country}{fatalities}{date}{notes}" + ) + + return f""" + + + {"".join(rows)} +
TypeCountryFatal.DateNotes
""" + + +def _render_news_clusters(data: dict) -> str: + """Render top news clusters.""" + clusters = data.get("clusters", []) + if not clusters: + return '

No news clusters available

' + + items = [] + for c in clusters[:8]: + title = _esc(c.get("label") or c.get("title", "")) + count = c.get("article_count", c.get("count", "")) + items.append(f"
  • {title} ({count} articles)
  • ") + return f"" + + +def _render_alerts(data: dict) -> str: + """Render alert digest.""" + alerts = data.get("alerts", []) + if not alerts: + return '

    No active alerts

    ' + + boxes = [] + for a in alerts[:10]: + severity = a.get("severity", "info") + css = "critical" if severity in ("critical", "high") else "" + title = _esc(a.get("title", a.get("type", ""))) + detail = _esc(str(a.get("detail", a.get("description", "")))[:200]) + boxes.append( + f'
    {title}
    {detail}
    ' + ) + return "".join(boxes) + + +def _render_posture(data: dict) -> str: + """Render strategic posture summary.""" + assessment = data.get("overall_assessment") or data.get("summary", "") + if not assessment: + return '

    Posture data unavailable

    ' + + level = _esc(data.get("threat_level", data.get("risk_level", ""))) + cls = ( + "red" + if "high" in level.lower() + else "amber" + if "medium" in level.lower() + else "green" + ) + + html = f'Threat Level: {level}' + html += f"

    {_esc(str(assessment)[:500])}

    " + + regions = data.get("regional_assessments") or data.get("regions", {}) + if regions and isinstance(regions, dict): + html += "

    Regional Breakdown

    " + return html + + +def _render_infrastructure(data: dict) -> str: + """Render infrastructure status.""" + outages = data.get("outages", data.get("entries", [])) + if not outages: + return '

    No infrastructure disruptions detected

    ' + + rows = [] + for o in outages[:8]: + name = _esc(o.get("entity") or o.get("name", "")) + score = o.get("score") or o.get("severity", "") + source = _esc(o.get("source", "")) + rows.append(f"{name}{score}{source}") + + return f""" + + + {"".join(rows)} +
    EntityScore / SeveritySource
    """ + + +def _render_cyber(data: dict) -> str: + """Render cyber threat intelligence.""" + threats = data.get("recent_threats") or data.get("threats", []) + if not threats: + return '

    No recent cyber threats

    ' + + rows = [] + for t in threats[:8]: + name = _esc(t.get("name") or t.get("tag", "")) + ttype = _esc(t.get("type", "")) + url = _esc(t.get("url", "")) + rows.append(f"{name}{ttype}{url[:60]}") + + return f""" + + + {"".join(rows)} +
    ThreatTypeReference
    """ + + +def _render_health(data: dict) -> str: + """Render health/disease outbreak data.""" + outbreaks = data.get("outbreaks") or data.get("events", []) + if not outbreaks: + return '

    No active disease outbreaks

    ' + + rows = [] + for o in outbreaks[:8]: + disease = _esc(o.get("disease") or o.get("title", "")) + country = _esc(o.get("country", "")) + date = _esc(o.get("date", "")) + rows.append(f"{disease}{country}{date}") + + return f""" + + + {"".join(rows)} +
    Disease/EventLocationDate
    """ + + +def _render_maritime(data: dict) -> str: + """Render maritime overview.""" + vessels = data.get("vessels") or data.get("snapshot", []) + if not vessels: + return '

    No maritime data

    ' + + rows = [] + items = vessels if isinstance(vessels, list) else [vessels] + for v in items[:8]: + name = _esc(v.get("name") or v.get("vessel_name", "")) + vtype = _esc(v.get("type") or v.get("ship_type", "")) + flag = _esc(v.get("flag", "")) + rows.append(f"{name}{vtype}{flag}") + + return f""" + + + {"".join(rows)} +
    VesselTypeFlag
    """ + + +def _render_situation_brief(data: dict) -> str: + """Render situation brief / world brief.""" + brief = data.get("brief") or data.get("summary", "") + if not brief: + return '

    Brief unavailable

    ' + return f"

    {_esc(str(brief)[:1000])}

    " + + +def _render_key_value(data: dict, keys: list[str] | None = None) -> str: + """Generic key-value renderer for simple dicts.""" + if not data: + return '

    Data unavailable

    ' + + items = [] + show_keys = keys or list(data.keys())[:20] + for k in show_keys: + v = data.get(k) + if v is not None and k not in ( + "source", + "cached", + "cache_age_seconds", + "fetched_at", + ): + items.append(f"
  • {_esc(k)}: {_esc(str(v)[:200])}
  • ") + return f"" if items else '

    No data

    ' + + +# --------------------------------------------------------------------------- +# Data collection +# --------------------------------------------------------------------------- + + +async def _collect_report_data( + fetcher: Fetcher, + sections: list[str] | None = None, +) -> dict[str, Any]: + """Fetch data for all report sections in parallel. + + Args: + fetcher: Configured Fetcher instance. + sections: Optional list of section names to include. + Default: all sections. + """ + all_sections = { + "world_brief": lambda: fetch_world_brief(fetcher), + "strategic_posture": lambda: fetch_strategic_posture(fetcher), + "alerts": lambda: fetch_alert_digest(fetcher), + "markets": lambda: markets.fetch_market_quotes(fetcher), + "economic": lambda: markets.fetch_macro_signals(fetcher), + "earthquakes": lambda: seismology.fetch_earthquakes( + fetcher, min_magnitude=4.5, hours=24 + ), + "wildfires": lambda: wildfire.fetch_wildfires(fetcher), + "conflicts": lambda: conflict.fetch_acled_events(fetcher, limit=15), + "military": lambda: military.fetch_military_flights(fetcher), + "infrastructure": lambda: infrastructure.fetch_internet_outages(fetcher), + "maritime": lambda: intelligence.fetch_vessel_snapshot(fetcher), + "cyber": lambda: cyber.fetch_cyber_threats(fetcher), + "health": lambda: health.fetch_disease_outbreaks(fetcher), + "news": lambda: fetch_news_clusters(fetcher), + "climate": lambda: climate.fetch_climate_anomalies(fetcher), + "nuclear": lambda: nuclear.fetch_nuclear_monitor(fetcher), + "shipping": lambda: shipping.fetch_shipping_index(fetcher), + "service_status": lambda: service_status.fetch_service_status(fetcher), + } + + if sections: + all_sections = {k: v for k, v in all_sections.items() if k in sections} + + results: dict[str, Any] = {} + tasks = {} + for name, fn in all_sections.items(): + tasks[name] = asyncio.create_task(_safe_fetch(name, fn)) + + for name, task in tasks.items(): + results[name] = await task + + return results + + +async def _safe_fetch(name: str, fn) -> dict: + """Wrap a fetch call with error handling.""" + try: + result = await fn() + return result if isinstance(result, dict) else {"data": result} + except Exception as exc: + logger.warning("Report section '%s' failed: %s", name, exc) + return {"error": str(exc)} + + +# --------------------------------------------------------------------------- +# HTML assembly +# --------------------------------------------------------------------------- + + +def _build_html(data: dict[str, Any], title: str | None = None) -> str: + """Assemble the full HTML document from collected data.""" + now = datetime.now(timezone.utc) + report_title = title or "World Intelligence Report" + timestamp = now.strftime("%Y-%m-%d %H:%M UTC") + + sections_html = [] + + # Executive summary (world brief) + if "world_brief" in data: + sections_html.append( + f"

    Executive Summary

    {_render_situation_brief(data['world_brief'])}" + ) + + # Strategic posture + if "strategic_posture" in data: + sections_html.append( + f"

    Strategic Posture

    {_render_posture(data['strategic_posture'])}" + ) + + # Alerts + if "alerts" in data: + sections_html.append(f"

    Active Alerts

    {_render_alerts(data['alerts'])}") + + # Markets + if "markets" in data: + sections_html.append( + f"

    Financial Markets

    {_render_markets(data['markets'])}" + ) + + # Economic + if "economic" in data: + sections_html.append( + f"

    Economic Indicators

    {_render_key_value(data['economic'])}" + ) + + # Conflicts + if "conflicts" in data: + sections_html.append( + f"

    Conflict & Security

    {_render_conflicts(data['conflicts'])}" + ) + + # Military + if "military" in data: + sections_html.append( + f"

    Military Activity

    {_render_key_value(data['military'])}" + ) + + # Earthquakes + if "earthquakes" in data: + sections_html.append( + f"

    Seismology

    {_render_earthquakes(data['earthquakes'])}" + ) + + # Infrastructure + if "infrastructure" in data: + sections_html.append( + f"

    Infrastructure

    {_render_infrastructure(data['infrastructure'])}" + ) + + # Cyber + if "cyber" in data: + sections_html.append(f"

    Cyber Threats

    {_render_cyber(data['cyber'])}") + + # Maritime + if "maritime" in data: + sections_html.append(f"

    Maritime

    {_render_maritime(data['maritime'])}") + + # Health + if "health" in data: + sections_html.append( + f"

    Health & Disease

    {_render_health(data['health'])}" + ) + + # Nuclear + if "nuclear" in data: + sections_html.append( + f"

    Nuclear Monitoring

    {_render_key_value(data['nuclear'])}" + ) + + # Climate + if "climate" in data: + sections_html.append( + f"

    Climate & Environment

    {_render_key_value(data['climate'])}" + ) + + # News + if "news" in data: + sections_html.append( + f"

    News Clusters

    {_render_news_clusters(data['news'])}" + ) + + # Shipping + if "shipping" in data: + sections_html.append(f"

    Shipping

    {_render_key_value(data['shipping'])}") + + # Service status + if "service_status" in data: + sections_html.append( + f"

    Cloud & Service Status

    {_render_key_value(data['service_status'])}" + ) + + body = "\n".join(sections_html) + + return f""" + + + + + + +

    {_esc(report_title)}

    +
    Generated {timestamp} — World Intel MCP — 109 intelligence sources
    + {body} + + +""" + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def pdf_dependencies_available() -> bool: + """Check if weasyprint is importable.""" + try: + from importlib.util import find_spec + + return find_spec("weasyprint") is not None + except Exception: + return False + + +async def generate_report( + fetcher: Fetcher, + output_path: str | Path | None = None, + title: str | None = None, + sections: list[str] | None = None, + fmt: str = "pdf", +) -> dict[str, Any]: + """Generate an intelligence report. + + Args: + fetcher: Configured Fetcher instance. + output_path: Where to write the file. Default: ``~/.cache/world-intel-mcp/report-.pdf`` + title: Report title. + sections: List of section names to include (default: all). + fmt: Output format — ``pdf`` or ``html``. + + Returns: + Dict with path, format, sections included, generation time. + """ + t0 = time.time() + + # Collect data + data = await _collect_report_data(fetcher, sections) + + # Build HTML + html = _build_html(data, title) + + # Determine output path + if output_path is None: + cache_dir = Path.home() / ".cache" / "world-intel-mcp" + cache_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + ext = "pdf" if fmt == "pdf" else "html" + output_path = cache_dir / f"report-{ts}.{ext}" + else: + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + if fmt == "pdf": + if not pdf_dependencies_available(): + return { + "error": 'WeasyPrint not installed. Install with `pip install -e ".[pdf]"`.', + "fallback": "Use fmt='html' for HTML output without WeasyPrint.", + } + # Import inside the function to avoid import-time failures + from weasyprint import HTML as WeasyHTML + + pdf_bytes: bytes = await asyncio.to_thread( + lambda: WeasyHTML(string=html).write_pdf() # type: ignore[return-value] + ) + output_path.write_bytes(pdf_bytes) + else: + output_path.write_text(html, encoding="utf-8") + + elapsed = time.time() - t0 + sections_included = [k for k, v in data.items() if "error" not in v] + sections_failed = [k for k, v in data.items() if "error" in v] + + return { + "path": str(output_path), + "format": fmt, + "size_bytes": output_path.stat().st_size, + "sections_included": sections_included, + "sections_failed": sections_failed, + "generation_seconds": round(elapsed, 2), + } + + +async def generate_report_standalone( + output_path: str | Path | None = None, + title: str | None = None, + sections: list[str] | None = None, + fmt: str = "pdf", +) -> dict[str, Any]: + """Generate a report using a fresh Fetcher (for CLI / standalone use).""" + cache = Cache() + breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=300) + fetcher = Fetcher(cache=cache, breaker=breaker) + return await generate_report(fetcher, output_path, title, sections, fmt) diff --git a/src/world_intel_mcp/server.py b/src/world_intel_mcp/server.py index 937d7ea..8218fb0 100644 --- a/src/world_intel_mcp/server.py +++ b/src/world_intel_mcp/server.py @@ -30,6 +30,8 @@ Phase 16: Vector intelligence — semantic search, similar events, timeline, vec Collector daemon for 24/7 data accumulation. Enterprise-grade semantic retrieval. Phase 17: Cross-domain analytics — cross-domain correlation, domain summary, trend detection (+3 = 109 tools). Historical analysis and early warning from accumulated vector data. +Phase 18: PDF/HTML intelligence reports (+1 = 110 tools). WeasyPrint-based multi-section + report generation covering 18 intelligence domains in parallel. """ import asyncio @@ -98,7 +100,9 @@ try: if vector_dependencies_available(): _vector_store = VectorStore(enabled=True) else: - logger.info("Vector store unavailable (qdrant_client / fastembed not installed)") + logger.info( + "Vector store unavailable (qdrant_client / fastembed not installed)" + ) except Exception as exc: logger.info("Vector store unavailable: %s", exc) @@ -1702,6 +1706,31 @@ TOOLS: list[Tool] = [ }, }, ), + # --- Reports (1 tool) --- + Tool( + name="intel_generate_report", + description="Generate a PDF or HTML intelligence report covering markets, conflicts, earthquakes, cyber threats, health, infrastructure, and more. Returns the file path. Optional: sections (list of section names), title (string), format ('pdf' or 'html').", + inputSchema={ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Report title (default: 'World Intelligence Report')", + }, + "sections": { + "type": "array", + "items": {"type": "string"}, + "description": "Sections to include: world_brief, strategic_posture, alerts, markets, economic, earthquakes, wildfires, conflicts, military, infrastructure, maritime, cyber, health, news, climate, nuclear, shipping, service_status. Default: all.", + }, + "format": { + "type": "string", + "enum": ["pdf", "html"], + "description": "Output format (default: pdf). Use html if weasyprint is not installed.", + "default": "pdf", + }, + }, + }, + ), # --- System (1 tool) --- Tool( name="intel_status", @@ -2362,6 +2391,18 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: baseline_hours=arguments.get("baseline_hours", 48.0), ) + # System + # Reports + case "intel_generate_report": + from .reports import generate_report + + return await generate_report( + fetcher, + title=arguments.get("title"), + sections=arguments.get("sections"), + fmt=arguments.get("format", "pdf"), + ) + # System case "intel_status": vs_stats = { diff --git a/src/world_intel_mcp/tests/test_reports.py b/src/world_intel_mcp/tests/test_reports.py new file mode 100644 index 0000000..d1d362a --- /dev/null +++ b/src/world_intel_mcp/tests/test_reports.py @@ -0,0 +1,482 @@ +"""Tests for the PDF/HTML intelligence report generator.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from world_intel_mcp.reports import ( + _build_html, + _esc, + _fmt_num, + _change_class, + _render_markets, + _render_earthquakes, + _render_conflicts, + _render_news_clusters, + _render_alerts, + _render_posture, + _render_infrastructure, + _render_cyber, + _render_health, + _render_maritime, + _render_situation_brief, + _render_key_value, + _collect_report_data, + _safe_fetch, + pdf_dependencies_available, + generate_report, +) + + +# --------------------------------------------------------------------------- +# Utility functions +# --------------------------------------------------------------------------- + + +class TestEsc: + def test_none(self): + assert _esc(None) == "" + + def test_html_chars(self): + assert ( + _esc('') + == "<script>"alert&"</script>" + ) + + def test_plain(self): + assert _esc("hello world") == "hello world" + + def test_number(self): + assert _esc(42) == "42" + + +class TestFmtNum: + def test_none(self): + assert _fmt_num(None) == "N/A" + + def test_integer(self): + assert _fmt_num(1234567, 0) == "1,234,567" + + def test_float(self): + assert _fmt_num(1234.567, 2) == "1,234.57" + + def test_string(self): + assert _fmt_num("not a number") == "not a number" + + +class TestChangeClass: + def test_positive(self): + assert _change_class(1.5) == "green" + + def test_negative(self): + assert _change_class(-0.5) == "red" + + def test_zero(self): + assert _change_class(0) == "" + + def test_none(self): + assert _change_class(None) == "" + + +# --------------------------------------------------------------------------- +# Section renderers +# --------------------------------------------------------------------------- + + +class TestRenderMarkets: + def test_no_data(self): + assert "unavailable" in _render_markets({}) + + def test_with_quotes(self): + data = { + "quotes": [ + { + "shortName": "S&P 500", + "regularMarketPrice": 4500.12, + "regularMarketChangePercent": 1.23, + }, + { + "symbol": "DJI", + "regularMarketPrice": 35000.0, + "regularMarketChangePercent": -0.45, + }, + ] + } + html = _render_markets(data) + assert "S&P 500" in html + assert "4,500.12" in html + assert "+1.23%" in html + assert "green" in html + assert "red" in html + + +class TestRenderEarthquakes: + def test_no_data(self): + assert "No significant" in _render_earthquakes({}) + + def test_with_quakes(self): + data = { + "earthquakes": [ + { + "properties": { + "mag": 5.2, + "place": "Near Tokyo", + "time": 1700000000000, + } + }, + ] + } + html = _render_earthquakes(data) + assert "5.2" in html + assert "Near Tokyo" in html + + +class TestRenderConflicts: + def test_no_data(self): + assert "No recent" in _render_conflicts({}) + + def test_with_events(self): + data = { + "events": [ + { + "event_type": "Battle", + "country": "Ukraine", + "fatalities": 5, + "event_date": "2025-01-01", + "notes": "Test event", + }, + ] + } + html = _render_conflicts(data) + assert "Battle" in html + assert "Ukraine" in html + + +class TestRenderNewsClusters: + def test_no_data(self): + assert "No news clusters" in _render_news_clusters({}) + + def test_with_clusters(self): + data = {"clusters": [{"label": "Climate Summit", "article_count": 42}]} + html = _render_news_clusters(data) + assert "Climate Summit" in html + assert "42" in html + + +class TestRenderAlerts: + def test_no_data(self): + assert "No active alerts" in _render_alerts({}) + + def test_with_alerts(self): + data = { + "alerts": [ + { + "severity": "critical", + "title": "Major Quake", + "detail": "M7.2 event detected", + }, + { + "severity": "info", + "title": "Minor Issue", + "description": "Low impact", + }, + ] + } + html = _render_alerts(data) + assert "critical" in html + assert "Major Quake" in html + + +class TestRenderPosture: + def test_no_data(self): + assert "unavailable" in _render_posture({}) + + def test_with_data(self): + data = { + "overall_assessment": "Tensions elevated in multiple regions.", + "threat_level": "HIGH", + "regions": {"Europe": "NATO exercises ongoing"}, + } + html = _render_posture(data) + assert "HIGH" in html + assert "red" in html + assert "Europe" in html + + +class TestRenderInfrastructure: + def test_no_data(self): + assert "No infrastructure" in _render_infrastructure({}) + + def test_with_outages(self): + data = {"outages": [{"entity": "AS12345", "score": 85, "source": "IODA"}]} + html = _render_infrastructure(data) + assert "AS12345" in html + + +class TestRenderCyber: + def test_no_data(self): + assert "No recent cyber" in _render_cyber({}) + + def test_with_threats(self): + data = { + "recent_threats": [ + {"name": "Emotet", "type": "malware", "url": "https://example.com"} + ] + } + html = _render_cyber(data) + assert "Emotet" in html + + +class TestRenderHealth: + def test_no_data(self): + assert "No active disease" in _render_health({}) + + def test_with_outbreaks(self): + data = { + "outbreaks": [{"disease": "Mpox", "country": "DRC", "date": "2025-01-15"}] + } + html = _render_health(data) + assert "Mpox" in html + + +class TestRenderMaritime: + def test_no_data(self): + assert "No maritime" in _render_maritime({}) + + def test_with_vessels(self): + data = {"vessels": [{"name": "USS Nimitz", "type": "CVN", "flag": "US"}]} + html = _render_maritime(data) + assert "USS Nimitz" in html + + +class TestRenderSituationBrief: + def test_no_data(self): + assert "unavailable" in _render_situation_brief({}) + + def test_with_brief(self): + data = { + "brief": "Global tensions remain elevated with multiple hotspots active." + } + html = _render_situation_brief(data) + assert "Global tensions" in html + + +class TestRenderKeyValue: + def test_no_data(self): + assert "unavailable" in _render_key_value({}) + + def test_with_data(self): + html = _render_key_value( + {"metric1": 42, "metric2": "active", "source": "should_skip"} + ) + assert "metric1" in html + assert "42" in html + assert "source" not in html # filtered out + + +# --------------------------------------------------------------------------- +# HTML assembly +# --------------------------------------------------------------------------- + + +class TestBuildHtml: + def test_basic_structure(self): + data = {"markets": {"quotes": []}, "world_brief": {"brief": "Test brief"}} + html = _build_html(data) + assert "" in html + assert "World Intelligence Report" in html + assert "Executive Summary" in html + assert "Financial Markets" in html + + def test_custom_title(self): + html = _build_html({}, title="Custom Report") + assert "Custom Report" in html + + def test_all_sections(self): + data = { + "world_brief": {"brief": "test"}, + "strategic_posture": {"overall_assessment": "test", "threat_level": "LOW"}, + "alerts": {"alerts": []}, + "markets": {"quotes": []}, + "economic": {"key": "val"}, + "conflicts": {"events": []}, + "military": {"data": "val"}, + "earthquakes": {"earthquakes": []}, + "infrastructure": {"outages": []}, + "cyber": {"recent_threats": []}, + "maritime": {"vessels": []}, + "health": {"outbreaks": []}, + "nuclear": {"sites": []}, + "climate": {"zones": {}}, + "news": {"clusters": []}, + "shipping": {"quotes": []}, + "service_status": {"services": []}, + } + html = _build_html(data) + assert "Executive Summary" in html + assert "Strategic Posture" in html + assert "Financial Markets" in html + assert "Cyber Threats" in html + + +# --------------------------------------------------------------------------- +# Data collection +# --------------------------------------------------------------------------- + + +class TestSafeFetch: + @pytest.mark.asyncio + async def test_success(self): + async def good_fn(): + return {"result": "ok"} + + result = await _safe_fetch("test", good_fn) + assert result == {"result": "ok"} + + @pytest.mark.asyncio + async def test_failure(self): + async def bad_fn(): + raise ValueError("boom") + + result = await _safe_fetch("test", bad_fn) + assert "error" in result + assert "boom" in result["error"] + + @pytest.mark.asyncio + async def test_non_dict_result(self): + async def list_fn(): + return [1, 2, 3] + + result = await _safe_fetch("test", list_fn) + assert result == {"data": [1, 2, 3]} + + +class TestCollectReportData: + @pytest.mark.asyncio + async def test_section_filter(self): + with patch("world_intel_mcp.reports.markets") as mock_markets: + mock_markets.fetch_market_quotes = AsyncMock(return_value={"quotes": []}) + + data = await _collect_report_data( + MagicMock(), + sections=["markets"], + ) + assert "markets" in data + assert "earthquakes" not in data + + @pytest.mark.asyncio + async def test_handles_failures(self): + with patch("world_intel_mcp.reports.markets") as mock_markets: + mock_markets.fetch_market_quotes = AsyncMock( + side_effect=RuntimeError("api down") + ) + + data = await _collect_report_data(MagicMock(), sections=["markets"]) + assert "error" in data["markets"] + + +# --------------------------------------------------------------------------- +# PDF dependency check +# --------------------------------------------------------------------------- + + +class TestPdfDependencies: + def test_check(self): + # Just verify it returns a bool without crashing + result = pdf_dependencies_available() + assert isinstance(result, bool) + + +# --------------------------------------------------------------------------- +# Report generation (HTML mode — no weasyprint needed) +# --------------------------------------------------------------------------- + + +class TestGenerateReport: + @pytest.mark.asyncio + async def test_html_output(self, tmp_path): + output = tmp_path / "test_report.html" + + with patch("world_intel_mcp.reports._collect_report_data") as mock_collect: + mock_collect.return_value = { + "markets": { + "quotes": [ + { + "shortName": "SPX", + "regularMarketPrice": 4500, + "regularMarketChangePercent": 0.5, + } + ] + }, + } + + fetcher = MagicMock() + result = await generate_report(fetcher, output_path=output, fmt="html") + + assert result["format"] == "html" + assert result["path"] == str(output) + assert result["size_bytes"] > 0 + assert "markets" in result["sections_included"] + assert output.exists() + + content = output.read_text() + assert "" in content + assert "SPX" in content + + @pytest.mark.asyncio + async def test_default_output_path(self, tmp_path): + with patch("world_intel_mcp.reports._collect_report_data") as mock_collect: + mock_collect.return_value = {"markets": {"quotes": []}} + + with patch("world_intel_mcp.reports.Path.home", return_value=tmp_path): + fetcher = MagicMock() + result = await generate_report(fetcher, fmt="html") + + assert result["format"] == "html" + assert "report-" in result["path"] + + @pytest.mark.asyncio + async def test_sections_tracking(self, tmp_path): + output = tmp_path / "test.html" + + with patch("world_intel_mcp.reports._collect_report_data") as mock_collect: + mock_collect.return_value = { + "markets": {"quotes": []}, + "earthquakes": {"error": "api down"}, + } + + result = await generate_report(MagicMock(), output_path=output, fmt="html") + + assert "markets" in result["sections_included"] + assert "earthquakes" in result["sections_failed"] + + @pytest.mark.asyncio + async def test_pdf_without_weasyprint(self, tmp_path): + output = tmp_path / "test.pdf" + + with patch( + "world_intel_mcp.reports.pdf_dependencies_available", return_value=False + ): + with patch("world_intel_mcp.reports._collect_report_data") as mock_collect: + mock_collect.return_value = {"markets": {"quotes": []}} + + result = await generate_report( + MagicMock(), output_path=output, fmt="pdf" + ) + + assert "error" in result + assert "WeasyPrint" in result["error"] + + @pytest.mark.asyncio + async def test_custom_title(self, tmp_path): + output = tmp_path / "custom.html" + + with patch("world_intel_mcp.reports._collect_report_data") as mock_collect: + mock_collect.return_value = {} + + result = await generate_report( + MagicMock(), output_path=output, title="Daily Brief", fmt="html" + ) + + content = output.read_text() + assert "Daily Brief" in content