feat: phase 13 — USNI fleet tracker, RSS expansion, remove static reports

- Add intel_usni_fleet tool: parses USNI News Fleet Tracker RSS for
  Navy fleet disposition (ships, hull numbers, strike groups, regions)
- Expand RSS to 90+ feeds across 16 categories: +3 Latin America
  (InSight Crime, Brazil Reports, Mexico News Daily), +7 multilingual
  (BBC Mundo, DW ES/DE, France24 FR, RFI, UN News ES/FR)
- Add data freshness monitoring: per-source staleness in dashboard
  drawer via cache.freshness() and SSE stream
- Add USNI Fleet Tracker drawer section to dashboard
- Remove static HTML report system (reports/ dir, 3 MCP tools,
  4 CLI commands, PDF endpoint) — live dashboard replaces all
- Update ROADMAP.md: phases 1-11 complete, 80 tools, 15 datasets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Shade
2026-02-26 16:41:38 -05:00
co-authored by Claude Opus 4.6
parent 6075910505
commit 39e687b054
16 changed files with 436 additions and 1720 deletions
-55
View File
@@ -19,7 +19,6 @@ 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
from .reports import generator as report_gen
console = Console()
@@ -859,60 +858,6 @@ def instability(ctx: click.Context, country_code: str | None) -> None:
console.print(table)
# ---------------------------------------------------------------------------
# Reports
# ---------------------------------------------------------------------------
@main.group()
def report() -> None:
"""Generate intelligence reports (HTML)."""
main.add_command(report)
@report.command(name="daily")
@click.option("--output-dir", "-o", default=None, help="Output directory")
def report_daily(output_dir: str | None) -> None:
"""Generate daily intelligence brief (HTML)."""
console.print("[bold]Generating daily brief...[/bold]")
result = _run(report_gen.generate_daily_brief(output_dir=output_dir))
console.print(f"[green]Report saved:[/green] {result.get('file_path', '?')}")
summary = result.get("summary", {})
console.print(f" Quotes: {summary.get('market_quotes', 0)} | "
f"Conflicts: {summary.get('conflict_events', 0)} | "
f"Threats: {summary.get('cyber_threats', 0)} | "
f"Quakes: {summary.get('earthquakes', 0)}")
@report.command(name="threat")
@click.option("--output-dir", "-o", default=None, help="Output directory")
def report_threat(output_dir: str | None) -> None:
"""Generate threat landscape report (HTML)."""
console.print("[bold]Generating threat landscape...[/bold]")
result = _run(report_gen.generate_threat_landscape(output_dir=output_dir))
console.print(f"[green]Report saved:[/green] {result.get('file_path', '?')}")
@report.command(name="market")
@click.option("--output-dir", "-o", default=None, help="Output directory")
def report_market(output_dir: str | None) -> None:
"""Generate market overview report (HTML)."""
console.print("[bold]Generating market overview...[/bold]")
result = _run(report_gen.generate_market_overview(output_dir=output_dir))
console.print(f"[green]Report saved:[/green] {result.get('file_path', '?')}")
@report.command(name="dossier")
@click.argument("country_code")
@click.option("--output-dir", "-o", default=None, help="Output directory")
def report_dossier(country_code: str, output_dir: str | None) -> None:
"""Generate country dossier report (HTML)."""
console.print(f"[bold]Generating dossier for {country_code}...[/bold]")
result = _run(report_gen.generate_country_dossier(
country_code=country_code, output_dir=output_dir,
))
console.print(f"[green]Report saved:[/green] {result.get('file_path', '?')}")
# ---------------------------------------------------------------------------
+7 -45
View File
@@ -52,6 +52,7 @@ from world_intel_mcp.analysis.posture import fetch_strategic_posture
from world_intel_mcp.analysis.exposure import fetch_population_exposure
from world_intel_mcp.analysis.situation import fetch_situation_brief
from world_intel_mcp.sources.fleet import fetch_fleet_report
from world_intel_mcp.sources.usni_fleet import fetch_usni_fleet
from world_intel_mcp.config.countries import INTEL_HOTSPOTS, STRATEGIC_WATERWAYS
from world_intel_mcp.config.geospatial import MILITARY_BASES, STRATEGIC_PORTS, PIPELINES, NUCLEAR_FACILITIES
from world_intel_mcp.sources.infrastructure import CABLE_CORRIDORS
@@ -120,6 +121,7 @@ async def _fetch_overview() -> dict:
"service_status": service_status.fetch_service_status(fetcher),
"strategic_posture": fetch_strategic_posture(fetcher),
"fleet_report": fetch_fleet_report(fetcher),
"usni_fleet": fetch_usni_fleet(fetcher),
"population_exposure": fetch_population_exposure(fetcher),
"domestic_flights": aviation.fetch_domestic_flights(fetcher),
"traffic_flow": traffic.fetch_traffic_flow(fetcher),
@@ -205,6 +207,7 @@ async def _fetch_overview() -> dict:
# Attach source health + timestamp
result["source_health"] = _breaker.status() if _breaker else {}
result["cache_stats"] = _cache.stats() if _cache else {}
result["cache_freshness"] = _cache.freshness() if _cache else {}
result["timestamp"] = datetime.now(timezone.utc).isoformat()
return result
@@ -281,51 +284,10 @@ async def api_health(request):
async def api_report_pdf(request):
"""Generate a PDF daily brief report.
Renders the daily_brief.html template with live data, then converts
to PDF via weasyprint. Requires ``pip install world-intel-mcp[pdf]``.
"""
try:
from weasyprint import HTML as WeasyHTML
except ImportError:
return JSONResponse(
{"error": "weasyprint not installed — run: pip install world-intel-mcp[pdf]"},
status_code=501,
)
from world_intel_mcp.reports.html_report import render_template
data = await _fetch_overview()
context = {
"title": "Daily Intelligence Brief",
"generated_at": data.get("timestamp", ""),
"market_quotes": data.get("market_quotes", {}),
"crypto_quotes": data.get("crypto_quotes", {}),
"macro_signals": data.get("macro_signals", {}),
"earthquakes": data.get("earthquakes", {}),
"cyber_threats": data.get("cyber_threats", {}),
"news_feed": data.get("news_feed", {}),
"military_flights": data.get("military_flights", {}),
"internet_outages": data.get("internet_outages", {}),
"climate_anomalies": data.get("climate_anomalies", {}),
"displacement": data.get("displacement", {}),
"risk_scores": data.get("risk_scores", {}),
"alert_digest": data.get("alert_digest", {}),
}
html_str = render_template("daily_brief.html", context)
pdf_bytes = WeasyHTML(string=html_str).write_pdf()
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="intel-brief-{now_str}.pdf"',
"Access-Control-Allow-Origin": "*",
},
"""PDF report generation (removed — use the live dashboard instead)."""
return JSONResponse(
{"error": "PDF reports removed — use the live dashboard at /"},
status_code=410,
)
+43
View File
@@ -2461,6 +2461,49 @@ function updateDrawer(data) {
});
}
// ── USNI FLEET TRACKER ──
if (data.usni_fleet && !data.usni_fleet.error && data.usni_fleet.ship_count > 0) {
var uf = data.usni_fleet;
h += '<div class="sh">USNI FLEET TRACKER</div>';
h += '<div class="dim" style="font-size:0.6rem;padding:2px 0">' + esc(uf.report_title || '') + '</div>';
if (uf.force_totals && uf.force_totals.battle_force) {
var bf = uf.force_totals.battle_force;
var dep = uf.force_totals.deployed || {};
var uw = uf.force_totals.underway || {};
h += '<div class="mini-boxes" style="margin:6px 0">';
h += '<div class="mini-box"><div class="v">' + bf.total + '</div><div class="l">Battle Force</div></div>';
h += '<div class="mini-box"><div class="v">' + (dep.total || '?') + '</div><div class="l">Deployed</div></div>';
h += '<div class="mini-box"><div class="v">' + (uw.total || '?') + '</div><div class="l">Underway</div></div>';
h += '</div>';
}
if (uf.region_breakdown) {
var regions = Object.entries(uf.region_breakdown).sort(function(a,b){return b[1]-a[1];});
regions.forEach(function(r) {
h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">' + esc(r[0]) + '</span> <span class="dim">' + r[1] + ' ships</span></div>';
});
}
(uf.ships || []).slice(0, 15).forEach(function(s) {
var cls = s.type === 'Aircraft Carrier' ? 'crit' : 'bright';
h += '<div style="font-size:0.65rem;padding:1px 0"><span class="' + cls + '">' + esc(s.name) + '</span> <span class="dim">(' + esc(s.hull_number) + ') ' + esc(s.region) + '</span></div>';
});
}
// ── DATA FRESHNESS ──
if (data.cache_freshness && Object.keys(data.cache_freshness).length > 0) {
var cf = data.cache_freshness;
var sources = Object.entries(cf).sort(function(a,b) { return a[1].last_updated_s_ago - b[1].last_updated_s_ago; });
var staleCount = sources.filter(function(s) { return s[1].is_stale; }).length;
h += '<div class="sh">DATA FRESHNESS</div>';
h += '<div class="dim" style="font-size:0.6rem;padding:2px 0">' + sources.length + ' sources tracked, ' + staleCount + ' stale</div>';
sources.forEach(function(s) {
var name = s[0], info = s[1];
var ageMin = Math.round(info.last_updated_s_ago / 60);
var cls = info.is_stale ? 'crit' : ageMin > 10 ? 'warn' : 'dim';
var label = ageMin < 1 ? '<1m' : ageMin + 'm';
h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">' + esc(name) + '</span> <span class="' + cls + '">' + label + (info.is_stale ? ' STALE' : '') + '</span></div>';
});
}
// ── AI SITUATION BRIEF ──
if (data.situation_brief && !data.situation_brief.error && data.situation_brief.brief) {
var sb = data.situation_brief;
-1
View File
@@ -1 +0,0 @@
"""Report generation for world-intel-mcp."""
-299
View File
@@ -1,299 +0,0 @@
"""Report orchestrator for world-intel-mcp.
Gathers data from multiple intelligence sources in parallel and renders
HTML or Markdown reports via Jinja2 templates.
"""
import asyncio
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
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,
)
logger = logging.getLogger("world-intel-mcp.reports.generator")
# Default output directory
_DEFAULT_OUTPUT_DIR = os.environ.get(
"INTEL_REPORT_DIR",
os.path.join(os.environ.get("STORAGE_BASE", "/tmp"), "reports", "intel"),
)
def _ensure_output_dir(path: str | None = None) -> Path:
"""Create output directory if needed and return Path."""
output_dir = Path(path or _DEFAULT_OUTPUT_DIR)
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
async def generate_daily_brief(output_dir: str | None = None) -> dict:
"""Generate a daily intelligence brief HTML report.
Gathers: market quotes, macro signals, conflict events, cyber threats,
earthquakes, wildfires, prediction markets, trending keywords.
Renders to HTML and returns the file path + summary.
"""
cache = Cache()
breaker = CircuitBreaker()
fetcher = Fetcher(cache=cache, breaker=breaker)
try:
# Gather all data in parallel
(
market_data,
macro_data,
conflict_data,
cyber_data,
quake_data,
fire_data,
predict_data,
keyword_data,
) = await asyncio.gather(
markets.fetch_market_quotes(fetcher),
markets.fetch_macro_signals(fetcher),
conflict.fetch_acled_events(fetcher, days=1, limit=50),
cyber.fetch_cyber_threats(fetcher, limit=30),
seismology.fetch_earthquakes(fetcher, min_magnitude=4.5, hours=24),
wildfire.fetch_wildfires(fetcher),
prediction.fetch_prediction_markets(fetcher, limit=10),
news.fetch_trending_keywords(fetcher, min_count=3),
)
now = datetime.now(timezone.utc)
context = {
"title": "Daily Intelligence Brief",
"generated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"market_summary": {
"quotes": market_data.get("quotes", []),
"macro_signals": macro_data.get("signals", {}),
},
"conflict_summary": {
"events": conflict_data.get("events", []),
"count": conflict_data.get("count", 0),
},
"cyber_summary": {
"threats": cyber_data.get("threats", []),
"by_severity": cyber_data.get("by_severity", {}),
},
"natural_summary": {
"earthquakes": quake_data.get("earthquakes", []),
"fire_count": fire_data.get("total_fires", 0),
},
"prediction_highlights": predict_data.get("markets", []),
"trending_keywords": keyword_data.get("keywords", []),
}
# Render HTML
from .html_report import render_template
html = render_template("daily_brief.html", context)
# Write to file
out_dir = _ensure_output_dir(output_dir)
filename = f"daily_brief_{now.strftime('%Y%m%d_%H%M%S')}.html"
filepath = out_dir / filename
filepath.write_text(html, encoding="utf-8")
logger.info("Daily brief generated: %s", filepath)
return {
"report_type": "daily_brief",
"file_path": str(filepath),
"generated_at": context["generated_at"],
"summary": {
"market_quotes": len(context["market_summary"]["quotes"]),
"conflict_events": context["conflict_summary"]["count"],
"cyber_threats": len(context["cyber_summary"]["threats"]),
"earthquakes": len(context["natural_summary"]["earthquakes"]),
"predictions": len(context["prediction_highlights"]),
"keywords": len(context["trending_keywords"]),
},
}
finally:
await fetcher.close()
async def generate_country_dossier(
country_code: str,
output_dir: str | None = None,
) -> dict:
"""Generate a country dossier HTML report."""
cache = Cache()
breaker = CircuitBreaker()
fetcher = Fetcher(cache=cache, breaker=breaker)
try:
(
brief_data,
instability_data,
conflict_data,
displacement_data,
) = await asyncio.gather(
intelligence.fetch_country_brief(fetcher, country_code=country_code),
intelligence.fetch_instability_index(fetcher, country_code=country_code),
conflict.fetch_acled_events(fetcher, country=country_code, days=30, limit=50),
displacement.fetch_displacement_summary(fetcher),
)
now = datetime.now(timezone.utc)
context = {
"title": f"Country Dossier: {country_code}",
"generated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"country_code": country_code,
"brief": brief_data.get("brief", ""),
"instability": {
"instability_index": instability_data.get("instability_index", 0),
"components": instability_data.get("components", {}),
"risk_level": instability_data.get("risk_level", "unknown"),
},
"conflict_events": conflict_data.get("events", []),
"displacement": {
"by_origin": displacement_data.get("by_origin", []),
"global_totals": displacement_data.get("global_totals", {}),
},
"economic": {
"gdp": brief_data.get("data", {}).get("gdp", []),
"inflation": brief_data.get("data", {}).get("inflation", []),
},
}
from .html_report import render_template
html = render_template("country_dossier.html", context)
out_dir = _ensure_output_dir(output_dir)
filename = f"dossier_{country_code}_{now.strftime('%Y%m%d_%H%M%S')}.html"
filepath = out_dir / filename
filepath.write_text(html, encoding="utf-8")
logger.info("Country dossier generated: %s", filepath)
return {
"report_type": "country_dossier",
"country_code": country_code,
"file_path": str(filepath),
"generated_at": context["generated_at"],
}
finally:
await fetcher.close()
async def generate_threat_landscape(output_dir: str | None = None) -> dict:
"""Generate a threat landscape HTML report."""
cache = Cache()
breaker = CircuitBreaker()
fetcher = Fetcher(cache=cache, breaker=breaker)
try:
(
cyber_data,
conflict_data,
military_data,
cable_data,
outage_data,
) = await asyncio.gather(
cyber.fetch_cyber_threats(fetcher, limit=50),
conflict.fetch_acled_events(fetcher, days=7, limit=100),
military.fetch_theater_posture(fetcher),
infrastructure.fetch_cable_health(fetcher),
infrastructure.fetch_internet_outages(fetcher),
)
now = datetime.now(timezone.utc)
context = {
"title": "Threat Landscape Report",
"generated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"cyber_threats": {
"threats": cyber_data.get("threats", []),
"by_severity": cyber_data.get("by_severity", {}),
"by_type": cyber_data.get("by_type", {}),
},
"conflict_events": conflict_data.get("events", []),
"military_activity": {
"theaters": military_data.get("theaters", {}),
"total_military_aircraft": military_data.get("total_military_aircraft", 0),
},
"cable_health": {
"corridors": cable_data.get("corridors", {}),
},
"outages": {
"outages": outage_data.get("outages", []),
"ongoing_count": outage_data.get("ongoing_count", 0),
},
}
from .html_report import render_template
html = render_template("threat_landscape.html", context)
out_dir = _ensure_output_dir(output_dir)
filename = f"threat_landscape_{now.strftime('%Y%m%d_%H%M%S')}.html"
filepath = out_dir / filename
filepath.write_text(html, encoding="utf-8")
logger.info("Threat landscape generated: %s", filepath)
return {
"report_type": "threat_landscape",
"file_path": str(filepath),
"generated_at": context["generated_at"],
}
finally:
await fetcher.close()
async def generate_market_overview(output_dir: str | None = None) -> dict:
"""Generate a market overview HTML report."""
cache = Cache()
breaker = CircuitBreaker()
fetcher = Fetcher(cache=cache, breaker=breaker)
try:
(
quote_data,
crypto_data,
macro_data,
sector_data,
etf_data,
) = await asyncio.gather(
markets.fetch_market_quotes(fetcher),
markets.fetch_crypto_quotes(fetcher, limit=20),
markets.fetch_macro_signals(fetcher),
markets.fetch_sector_heatmap(fetcher),
markets.fetch_etf_flows(fetcher),
)
now = datetime.now(timezone.utc)
context = {
"title": "Market Overview",
"generated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"quotes": quote_data.get("quotes", []),
"crypto": crypto_data.get("coins", []),
"macro_signals": macro_data.get("signals", {}),
"sector_heatmap": sector_data.get("sectors", []),
"etf_flows": etf_data,
}
from .html_report import render_template
html = render_template("market_overview.html", context)
out_dir = _ensure_output_dir(output_dir)
filename = f"market_overview_{now.strftime('%Y%m%d_%H%M%S')}.html"
filepath = out_dir / filename
filepath.write_text(html, encoding="utf-8")
logger.info("Market overview generated: %s", filepath)
return {
"report_type": "market_overview",
"file_path": str(filepath),
"generated_at": context["generated_at"],
}
finally:
await fetcher.close()
@@ -1,35 +0,0 @@
"""HTML report renderer using Jinja2 templates.
Loads templates from the ``templates/`` subdirectory and renders them
with the provided context data.
"""
import logging
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
logger = logging.getLogger("world-intel-mcp.reports.html_report")
_TEMPLATE_DIR = Path(__file__).parent / "templates"
_env = Environment(
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
)
def render_template(template_name: str, context: dict) -> str:
"""Render a Jinja2 HTML template with the given context.
Args:
template_name: Name of the template file in ``templates/``.
context: Dict of variables to pass to the template.
Returns:
Rendered HTML string.
"""
template = _env.get_template(template_name)
return template.render(**context)
@@ -1,161 +0,0 @@
"""Markdown report generator for world-intel-mcp.
Generates Markdown reports with optional Mermaid diagrams.
"""
import logging
from datetime import datetime, timezone
logger = logging.getLogger("world-intel-mcp.reports.markdown_report")
def generate_daily_brief_md(
market_summary: dict,
conflict_summary: dict,
cyber_summary: dict,
natural_summary: dict,
prediction_highlights: list,
trending_keywords: list,
) -> str:
"""Generate a daily intelligence brief in Markdown format."""
now = datetime.now(timezone.utc)
lines = [
f"# Daily Intelligence Brief",
f"*Generated: {now.strftime('%Y-%m-%d %H:%M UTC')}*",
"",
"## Markets",
]
quotes = market_summary.get("quotes", [])
if quotes:
lines.append("| Symbol | Price | Change |")
lines.append("|--------|------:|-------:|")
for q in quotes[:8]:
chg = q.get("change_pct") or 0
lines.append(f"| {q.get('symbol', '?')} | {q.get('price', 0):,.2f} | {chg:+.2f}% |")
lines.append("")
# Conflict
events = conflict_summary.get("events", [])
lines.append(f"## Conflict ({conflict_summary.get('count', 0)} events)")
if events:
lines.append("| Date | Type | Country | Fatalities |")
lines.append("|------|------|---------|----------:|")
for e in events[:10]:
lines.append(
f"| {(e.get('event_date') or '')[:10]} "
f"| {e.get('event_type', '')} "
f"| {e.get('country', '')} "
f"| {e.get('fatalities', 0)} |"
)
lines.append("")
# Cyber
by_sev = cyber_summary.get("by_severity", {})
lines.append(f"## Cyber Threats")
lines.append(f"Critical: {by_sev.get('critical', 0)} | "
f"High: {by_sev.get('high', 0)} | "
f"Medium: {by_sev.get('medium', 0)}")
lines.append("")
# Natural
quakes = natural_summary.get("earthquakes", [])
lines.append(f"## Natural Events")
lines.append(f"Earthquakes: {len(quakes)} | Fires: {natural_summary.get('fire_count', 0)}")
if quakes:
lines.append("")
lines.append("| Mag | Location | Depth |")
lines.append("|----:|----------|------:|")
for q in quakes[:5]:
lines.append(f"| {q.get('magnitude', 0):.1f} | {(q.get('place') or '')[:40]} | {q.get('depth_km', 0):.0f}km |")
lines.append("")
# Predictions
if prediction_highlights:
lines.append("## Prediction Markets")
for p in prediction_highlights[:5]:
yes = (p.get("yes_probability", 0) or 0) * 100
lines.append(f"- **{(p.get('question') or '')[:60]}** — YES: {yes:.0f}% ({p.get('sentiment', '')})")
lines.append("")
# Trending
if trending_keywords:
lines.append("## Trending Keywords")
kw_str = ", ".join(f"**{k['word']}** ({k['count']})" for k in trending_keywords[:15])
lines.append(kw_str)
lines.append("")
lines.append(f"---\n*Phoenix AGI System — World Intelligence*")
return "\n".join(lines)
def generate_threat_landscape_md(
cyber_threats: dict,
conflict_events: list,
military_activity: dict,
cable_health: dict,
outages: dict,
) -> str:
"""Generate a threat landscape report in Markdown with Mermaid diagram."""
now = datetime.now(timezone.utc)
lines = [
"# Threat Landscape Report",
f"*Generated: {now.strftime('%Y-%m-%d %H:%M UTC')}*",
"",
]
# Mermaid threat overview
by_sev = cyber_threats.get("by_severity", {})
lines.append("## Threat Overview")
lines.append("```mermaid")
lines.append("pie title Threat Severity Distribution")
for level in ["critical", "high", "medium", "low"]:
count = by_sev.get(level, 0)
if count > 0:
lines.append(f' "{level.title()}" : {count}')
lines.append("```")
lines.append("")
# Cyber section
lines.append(f"## Cyber Threats ({len(cyber_threats.get('threats', []))})")
for t in cyber_threats.get("threats", [])[:10]:
lines.append(f"- [{t.get('severity', '').upper()}] **{(t.get('indicator') or '')[:40]}** — {t.get('threat', '')} (via {t.get('source_feed', '')})")
lines.append("")
# Military
theaters = military_activity.get("theaters", {})
total = military_activity.get("total_military_aircraft", 0)
lines.append(f"## Military Activity ({total} aircraft)")
for name, info in theaters.items():
count = info.get("count", 0)
lines.append(f"- **{name.replace('_', ' ').title()}**: {count} aircraft")
lines.append("")
# Conflict events count
conflict_count = len(conflict_events)
if conflict_count:
lines.append(f"## Active Conflicts ({conflict_count} events, 7 days)")
for e in conflict_events[:10]:
lines.append(
f"- [{(e.get('event_date') or '')[:10]}] **{e.get('country', '')}** — "
f"{e.get('event_type', '')} ({e.get('fatalities', 0)} fatalities)"
)
lines.append("")
# Infrastructure
corridors = cable_health.get("corridors", {})
if corridors:
lines.append("## Cable Health")
status_map = {0: "Clear", 1: "Advisory", 2: "At Risk", 3: "Disrupted"}
for name, info in corridors.items():
score = info.get("status_score", 0)
lines.append(f"- **{name.replace('_', ' ').title()}**: {status_map.get(score, '?')}")
lines.append("")
# Outages
ongoing = outages.get("ongoing_count", 0)
lines.append(f"## Internet Outages ({ongoing} ongoing)")
lines.append("")
lines.append(f"---\n*Phoenix AGI System — Threat Intelligence*")
return "\n".join(lines)
@@ -1,270 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — {{ generated_at[:10] }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; padding: 2rem; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { font-size: 2rem; color: #f8fafc; margin-bottom: 0.5rem; }
h2 { font-size: 1.4rem; color: #94a3b8; margin: 2rem 0 1rem; border-bottom: 1px solid #334155; padding-bottom: 0.5rem; }
.meta { color: #64748b; margin-bottom: 2rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }
.card { background: #1e293b; border-radius: 8px; padding: 1.5rem; border: 1px solid #334155; }
.card h3 { color: #f1f5f9; margin-bottom: 1rem; font-size: 1.1rem; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #334155; }
th { color: #94a3b8; font-weight: 600; font-size: 0.85rem; text-transform: uppercase; }
td { color: #cbd5e1; }
.severity-critical { color: #ef4444; font-weight: bold; }
.severity-high { color: #f59e0b; }
.severity-medium { color: #6b7280; }
.up { color: #22c55e; }
.down { color: #ef4444; }
.chart-container { position: relative; height: 250px; margin: 1rem 0; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; margin: 2px; background: #334155; }
.empty { color: #64748b; font-style: italic; padding: 1rem 0; }
.brief-text { background: #1e293b; border-radius: 8px; padding: 1.5rem; border: 1px solid #334155; white-space: pre-wrap; margin-bottom: 1.5rem; }
.risk-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; font-weight: bold; font-size: 0.9rem; text-transform: uppercase; }
.risk-critical { background: #7f1d1d; color: #fca5a5; }
.risk-high { background: #78350f; color: #fbbf24; }
.risk-elevated { background: #3f3f46; color: #a1a1aa; }
.risk-moderate { background: #14532d; color: #86efac; }
.risk-low { background: #1e3a5f; color: #93c5fd; }
.stat-number { font-size: 2rem; font-weight: bold; color: #f8fafc; }
.stat-label { font-size: 0.85rem; color: #94a3b8; text-transform: uppercase; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #334155; color: #64748b; font-size: 0.85rem; text-align: center; }
</style>
</head>
<body>
<div class="container">
<h1>{{ title }}</h1>
<p class="meta">Country Code: {{ country_code }} | Generated: {{ generated_at }} | Phoenix AGI System</p>
<!-- Instability Overview -->
<h2>Instability Assessment</h2>
{% if instability %}
<div class="grid">
<div class="card" style="text-align:center;">
<h3>Instability Index</h3>
<div class="stat-number">{{ "%.1f"|format(instability.instability_index or 0) }}</div>
<div class="stat-label" style="margin:0.5rem 0;">
<span class="risk-badge risk-{{ instability.risk_level|default('moderate')|lower }}">{{ instability.risk_level|default('Unknown') }}</span>
</div>
</div>
<div class="card">
<h3>Instability Components</h3>
<div class="chart-container">
<canvas id="radarChart"></canvas>
</div>
</div>
</div>
{% else %}
<p class="empty">No instability data available for this country.</p>
{% endif %}
<!-- LLM Brief -->
<h2>Intelligence Brief</h2>
{% if brief %}
<div class="brief-text">{{ brief }}</div>
{% else %}
<p class="empty">No brief generated.</p>
{% endif %}
<!-- Conflict Events -->
<h2>Conflict Events</h2>
{% if conflict_events %}
<div class="card">
<table>
<tr><th>Date</th><th>Type</th><th>Sub-Type</th><th>Location</th><th>Fatalities</th></tr>
{% for e in conflict_events[:15] %}
<tr>
<td>{{ (e.event_date or '')[:10] }}</td>
<td>{{ e.event_type|default('') }}</td>
<td>{{ e.sub_event_type|default('') }}</td>
<td>{{ e.location|default('') }}</td>
<td class="{{ 'severity-critical' if (e.fatalities or 0) >= 10 else 'severity-high' if (e.fatalities or 0) > 0 else '' }}">{{ e.fatalities|default(0) }}</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p class="empty">No conflict events reported for this country.</p>
{% endif %}
<!-- Displacement -->
<h2>Displacement &amp; Refugees</h2>
{% if displacement %}
<div class="grid">
<div class="card">
<h3>Displacement by Origin</h3>
{% if displacement.by_origin %}
<table>
<tr><th>Year</th><th>Refugees</th><th>IDPs</th><th>Asylum Seekers</th></tr>
{% for d in displacement.by_origin[:10] %}
<tr>
<td>{{ d.year|default('') }}</td>
<td>{{ "{:,}".format(d.refugees|default(0)|int) }}</td>
<td>{{ "{:,}".format(d.idps|default(0)|int) }}</td>
<td>{{ "{:,}".format(d.asylum_seekers|default(0)|int) }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No displacement data by origin.</p>
{% endif %}
</div>
{% if displacement.global_totals %}
<div class="card">
<h3>Global Context</h3>
<table>
<tr><th>Metric</th><th>Total</th></tr>
{% for key, val in displacement.global_totals.items() %}
<tr>
<td>{{ key }}</td>
<td>{{ "{:,}".format(val|int) if val is not none else 'N/A' }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% else %}
<p class="empty">No displacement data available.</p>
{% endif %}
<!-- Economic Indicators -->
<h2>Economic Indicators</h2>
{% if economic %}
<div class="grid">
<div class="card">
<h3>GDP Trend</h3>
{% if economic.gdp %}
<div class="chart-container">
<canvas id="gdpChart"></canvas>
</div>
{% else %}
<p class="empty">No GDP data available.</p>
{% endif %}
</div>
<div class="card">
<h3>Inflation Trend</h3>
{% if economic.inflation %}
<table>
<tr><th>Year</th><th>Inflation (%)</th></tr>
{% for entry in economic.inflation[:10] %}
<tr>
<td>{{ entry.year|default(entry.date|default('')) }}</td>
<td class="{{ 'severity-critical' if (entry.value or 0) > 10 else 'severity-high' if (entry.value or 0) > 5 else '' }}">{{ "%.1f"|format(entry.value or 0) }}%</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No inflation data available.</p>
{% endif %}
</div>
</div>
{% else %}
<p class="empty">No economic data available.</p>
{% endif %}
<footer>Phoenix AGI System &mdash; Country Dossier: {{ country_code }} &mdash; {{ generated_at[:10] }}</footer>
</div>
<script>
// Radar Chart: Instability Components
{% if instability and instability.components %}
(function() {
var components = {{ instability.components|tojson }};
var labels = Object.keys(components);
var values = Object.values(components);
new Chart(document.getElementById('radarChart'), {
type: 'radar',
data: {
labels: labels.map(function(l) {
return l.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
}),
datasets: [{
label: 'Instability',
data: values,
backgroundColor: 'rgba(239, 68, 68, 0.2)',
borderColor: '#ef4444',
borderWidth: 2,
pointBackgroundColor: '#ef4444',
pointBorderColor: '#ef4444',
pointRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
beginAtZero: true,
max: 10,
ticks: { color: '#94a3b8', backdropColor: 'transparent', stepSize: 2 },
grid: { color: '#334155' },
angleLines: { color: '#334155' },
pointLabels: { color: '#cbd5e1', font: { size: 11 } }
}
},
plugins: {
legend: { display: false }
}
}
});
})();
{% endif %}
// Bar Chart: GDP Trend
{% if economic and economic.gdp %}
(function() {
var gdpData = {{ economic.gdp|tojson }};
var labels = gdpData.map(function(d) { return d.year || d.date || ''; });
var values = gdpData.map(function(d) { return d.value || 0; });
new Chart(document.getElementById('gdpChart'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'GDP (current USD)',
data: values,
backgroundColor: '#3b82f6',
borderColor: '#2563eb',
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
y: {
ticks: {
color: '#94a3b8',
callback: function(v) {
if (v >= 1e12) return (v / 1e12).toFixed(1) + 'T';
if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B';
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
return v;
}
},
grid: { color: '#334155' }
}
},
plugins: {
legend: { labels: { color: '#cbd5e1' } }
}
}
});
})();
{% endif %}
</script>
</body>
</html>
@@ -1,186 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — {{ generated_at[:10] }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; padding: 2rem; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { font-size: 2rem; color: #f8fafc; margin-bottom: 0.5rem; }
h2 { font-size: 1.4rem; color: #94a3b8; margin: 2rem 0 1rem; border-bottom: 1px solid #334155; padding-bottom: 0.5rem; }
.meta { color: #64748b; margin-bottom: 2rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }
.card { background: #1e293b; border-radius: 8px; padding: 1.5rem; border: 1px solid #334155; }
.card h3 { color: #f1f5f9; margin-bottom: 1rem; font-size: 1.1rem; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #334155; }
th { color: #94a3b8; font-weight: 600; font-size: 0.85rem; text-transform: uppercase; }
td { color: #cbd5e1; }
.severity-critical { color: #ef4444; font-weight: bold; }
.severity-high { color: #f59e0b; }
.severity-medium { color: #6b7280; }
.up { color: #22c55e; }
.down { color: #ef4444; }
.chart-container { position: relative; height: 250px; margin: 1rem 0; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; margin: 2px; background: #334155; }
.empty { color: #64748b; font-style: italic; padding: 1rem 0; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #334155; color: #64748b; font-size: 0.85rem; text-align: center; }
</style>
</head>
<body>
<div class="container">
<h1>{{ title }}</h1>
<p class="meta">Generated: {{ generated_at }} | Phoenix AGI System</p>
<!-- Market Summary -->
<h2>Markets</h2>
{% if market_summary %}
<div class="grid">
<div class="card">
<h3>Index Quotes</h3>
{% if market_summary.quotes %}
<table>
<tr><th>Symbol</th><th>Price</th><th>Change</th></tr>
{% for q in market_summary.quotes[:8] %}
<tr>
<td>{{ q.symbol|default('—') }}</td>
<td>{{ "%.2f"|format(q.price or 0) }}</td>
<td class="{{ 'up' if (q.change_pct or 0) >= 0 else 'down' }}">{{ "%+.2f"|format(q.change_pct or 0) }}%</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No quote data available.</p>
{% endif %}
</div>
<div class="card">
<h3>Macro Signals</h3>
{% if market_summary.macro_signals %}
<table>
<tr><th>Signal</th><th>Value</th></tr>
{% for name, info in market_summary.macro_signals.items() %}
<tr>
<td>{{ name }}</td>
<td>{% if info is mapping %}{{ info.value|default(info.price|default('?')) }}{% elif info is not none %}{{ info }}{% else %}<span style="color:#64748b">N/A</span>{% endif %}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No macro signals available.</p>
{% endif %}
</div>
</div>
{% else %}
<p class="empty">Market data unavailable.</p>
{% endif %}
<!-- Conflict & Security -->
<h2>Conflict &amp; Security</h2>
<div class="grid">
<div class="card">
<h3>Recent Events ({{ conflict_summary.count|default(0) }})</h3>
{% if conflict_summary and conflict_summary.events %}
<table>
<tr><th>Date</th><th>Type</th><th>Country</th><th>Fatalities</th></tr>
{% for e in conflict_summary.events[:10] %}
<tr>
<td>{{ (e.event_date or '')[:10] }}</td>
<td>{{ e.event_type|default('') }}</td>
<td>{{ e.country|default('') }}</td>
<td class="{{ 'severity-critical' if (e.fatalities or 0) >= 10 else 'severity-high' if (e.fatalities or 0) > 0 else '' }}">{{ e.fatalities|default(0) }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No conflict events reported.</p>
{% endif %}
</div>
<div class="card">
<h3>Cyber Threats</h3>
{% if cyber_summary and cyber_summary.threats %}
<p style="margin-bottom:1rem;">
<span class="severity-critical">Critical: {{ cyber_summary.by_severity.critical|default(0) }}</span> |
<span class="severity-high">High: {{ cyber_summary.by_severity.high|default(0) }}</span> |
Medium: {{ cyber_summary.by_severity.medium|default(0) }}
</p>
<table>
<tr><th>Severity</th><th>Indicator</th><th>Threat</th></tr>
{% for t in cyber_summary.threats[:8] %}
<tr>
<td class="severity-{{ t.severity|default('medium') }}">{{ t.severity|default('unknown') }}</td>
<td>{{ (t.indicator or '')[:30] }}</td>
<td>{{ (t.threat or '')[:30] }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No cyber threat data available.</p>
{% endif %}
</div>
</div>
<!-- Natural Events -->
<h2>Natural Events</h2>
<div class="grid">
<div class="card">
<h3>Earthquakes</h3>
{% if natural_summary and natural_summary.earthquakes %}
<table>
<tr><th>Mag</th><th>Location</th><th>Depth</th></tr>
{% for q in natural_summary.earthquakes[:8] %}
<tr>
<td class="{{ 'severity-critical' if (q.magnitude or 0) >= 6 else 'severity-high' if (q.magnitude or 0) >= 5 else '' }}">{{ "%.1f"|format(q.magnitude or 0) }}</td>
<td>{{ (q.place or '')[:40] }}</td>
<td>{{ "%.0f"|format(q.depth_km or 0) }}km</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No earthquake data available.</p>
{% endif %}
</div>
<div class="card">
<h3>Wildfires</h3>
<p>{{ natural_summary.fire_count|default(0) }} high-confidence fires detected</p>
</div>
</div>
<!-- Predictions & Trending -->
<h2>Signals</h2>
<div class="grid">
<div class="card">
<h3>Prediction Markets</h3>
{% if prediction_highlights %}
<table>
<tr><th>Question</th><th>YES</th><th>Sentiment</th></tr>
{% for p in prediction_highlights[:8] %}
<tr>
<td>{{ (p.question or '')[:40] }}</td>
<td>{{ "%.0f"|format((p.yes_probability or 0) * 100) }}%</td>
<td>{{ p.sentiment|default('') }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No prediction data available.</p>
{% endif %}
</div>
<div class="card">
<h3>Trending Keywords</h3>
{% if trending_keywords %}
{% for kw in trending_keywords[:20] %}
<span class="tag">{{ kw.word }} ({{ kw.count }})</span>
{% endfor %}
{% else %}
<p class="empty">No trending keyword data.</p>
{% endif %}
</div>
</div>
<footer>Phoenix AGI System &mdash; World Intelligence Report &mdash; {{ generated_at[:10] }}</footer>
</div>
</body>
</html>
@@ -1,277 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — {{ generated_at[:10] }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; padding: 2rem; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { font-size: 2rem; color: #f8fafc; margin-bottom: 0.5rem; }
h2 { font-size: 1.4rem; color: #94a3b8; margin: 2rem 0 1rem; border-bottom: 1px solid #334155; padding-bottom: 0.5rem; }
.meta { color: #64748b; margin-bottom: 2rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }
.card { background: #1e293b; border-radius: 8px; padding: 1.5rem; border: 1px solid #334155; }
.card h3 { color: #f1f5f9; margin-bottom: 1rem; font-size: 1.1rem; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #334155; }
th { color: #94a3b8; font-weight: 600; font-size: 0.85rem; text-transform: uppercase; }
td { color: #cbd5e1; }
.severity-critical { color: #ef4444; font-weight: bold; }
.severity-high { color: #f59e0b; }
.severity-medium { color: #6b7280; }
.up { color: #22c55e; }
.down { color: #ef4444; }
.chart-container { position: relative; height: 250px; margin: 1rem 0; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; margin: 2px; background: #334155; }
.empty { color: #64748b; font-style: italic; padding: 1rem 0; }
.stat-row { display: flex; gap: 1.5rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.stat-box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1.2rem; text-align: center; flex: 1; min-width: 140px; }
.stat-number { font-size: 2rem; font-weight: bold; color: #f8fafc; }
.stat-label { font-size: 0.85rem; color: #94a3b8; text-transform: uppercase; }
.heatmap-cell { display: inline-block; padding: 6px 10px; margin: 3px; border-radius: 4px; font-size: 0.85rem; font-weight: 600; min-width: 80px; text-align: center; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #334155; color: #64748b; font-size: 0.85rem; text-align: center; }
</style>
</head>
<body>
<div class="container">
<h1>{{ title }}</h1>
<p class="meta">Generated: {{ generated_at }} | Phoenix AGI System</p>
<!-- Equity Quotes -->
<h2>Equity &amp; Index Quotes</h2>
{% if quotes %}
<div class="card">
<table>
<tr><th>Symbol</th><th>Name</th><th>Price</th><th>Change</th><th>% Change</th><th>Volume</th></tr>
{% for q in quotes %}
<tr>
<td style="font-weight:600;">{{ q.symbol|default('—') }}</td>
<td>{{ q.name|default(q.shortName|default('')) }}</td>
<td>{{ "%.2f"|format(q.price or q.regularMarketPrice or 0) }}</td>
<td class="{{ 'up' if (q.change or q.regularMarketChange or 0) >= 0 else 'down' }}">
{{ "%+.2f"|format(q.change or q.regularMarketChange or 0) }}
</td>
<td class="{{ 'up' if (q.change_pct or q.regularMarketChangePercent or 0) >= 0 else 'down' }}">
{{ "%+.2f"|format(q.change_pct or q.regularMarketChangePercent or 0) }}%
</td>
<td>{{ "{:,}".format((q.volume or q.regularMarketVolume or 0)|int) }}</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p class="empty">No equity quote data available.</p>
{% endif %}
<!-- Sector Heatmap -->
<h2>Sector Performance</h2>
{% if sector_heatmap %}
<div class="grid">
<div class="card" style="grid-column: 1 / -1;">
<h3>Sector Heatmap</h3>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:1.5rem;">
{% for s in sector_heatmap %}
{% set pct = s.change_pct if s.change_pct is not none else 0 %}
{% if pct >= 2 %}
{% set bg = '#166534' %}
{% elif pct >= 0.5 %}
{% set bg = '#14532d' %}
{% elif pct >= 0 %}
{% set bg = '#1a2e1a' %}
{% elif pct >= -0.5 %}
{% set bg = '#2e1a1a' %}
{% elif pct >= -2 %}
{% set bg = '#7f1d1d' %}
{% else %}
{% set bg = '#991b1b' %}
{% endif %}
<div class="heatmap-cell" style="background:{{ bg }};color:{{ '#86efac' if pct >= 0 else '#fca5a5' }};">
{{ s.symbol|default(s.name|default('?')) }}<br>{{ "%+.1f"|format(pct) }}%
</div>
{% endfor %}
</div>
<h3>Sector Bar Chart</h3>
<div class="chart-container" style="height:300px;">
<canvas id="sectorChart"></canvas>
</div>
</div>
</div>
{% else %}
<p class="empty">No sector data available.</p>
{% endif %}
<!-- Cryptocurrency -->
<h2>Cryptocurrency</h2>
{% if crypto %}
<div class="card">
<table>
<tr><th>Coin</th><th>Price (USD)</th><th>24h Change</th><th>Market Cap</th><th>24h Volume</th></tr>
{% for c in crypto[:15] %}
<tr>
<td style="font-weight:600;">{{ c.symbol|default(c.id|default(''))|upper }}{% if c.name %} <span style="color:#64748b;font-weight:normal;">{{ c.name }}</span>{% endif %}</td>
<td>{{ "${:,.2f}".format(c.current_price or c.price or 0) }}</td>
<td class="{{ 'up' if (c.price_change_percentage_24h or c.change_24h or 0) >= 0 else 'down' }}">
{{ "%+.2f"|format(c.price_change_percentage_24h or c.change_24h or 0) }}%
</td>
<td>
{% set mcap = c.market_cap or 0 %}
{% if mcap >= 1e12 %}${{ "%.1f"|format(mcap / 1e12) }}T
{% elif mcap >= 1e9 %}${{ "%.1f"|format(mcap / 1e9) }}B
{% elif mcap >= 1e6 %}${{ "%.1f"|format(mcap / 1e6) }}M
{% elif mcap > 0 %}${{ "{:,.0f}".format(mcap) }}
{% else %}—{% endif %}
</td>
<td>
{% set vol = c.total_volume or c.volume_24h or 0 %}
{% if vol >= 1e9 %}${{ "%.1f"|format(vol / 1e9) }}B
{% elif vol >= 1e6 %}${{ "%.1f"|format(vol / 1e6) }}M
{% elif vol > 0 %}${{ "{:,.0f}".format(vol) }}
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p class="empty">No cryptocurrency data available.</p>
{% endif %}
<!-- Macro Signals -->
<h2>Macro Signals</h2>
{% if macro_signals %}
<div class="grid">
{% for name, info in macro_signals.items() %}
<div class="card">
<h3>{{ name }}</h3>
{% if info is mapping %}
<table>
{% for key, val in info.items() %}
<tr>
<td>{{ key.replace('_', ' ')|title }}</td>
<td>
{% if val is number %}
{% if val >= 1e9 %}{{ "%.2f"|format(val / 1e9) }}B
{% elif val >= 1e6 %}{{ "%.2f"|format(val / 1e6) }}M
{% else %}{{ "%.4f"|format(val) if val < 1 and val > -1 else "%.2f"|format(val) }}{% endif %}
{% elif val is not none %}{{ val }}
{% else %}<span style="color:#64748b">N/A</span>{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% elif info is not none %}
<p style="font-size:1.5rem;font-weight:bold;color:#f8fafc;">{{ info }}</p>
{% else %}
<p class="empty">N/A</p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">No macro signal data available.</p>
{% endif %}
<!-- ETF Flows -->
<h2>ETF Flows</h2>
{% if etf_flows %}
<div class="card">
{% if etf_flows is mapping %}
<table>
<tr><th>ETF / Category</th><th>Flow</th><th>Details</th></tr>
{% for name, data in etf_flows.items() %}
<tr>
<td style="font-weight:600;">{{ name }}</td>
{% if data is mapping %}
<td class="{{ 'up' if (data.flow or data.net_flow or 0) >= 0 else 'down' }}">
{% set flow = data.flow or data.net_flow or 0 %}
{% if flow >= 1e9 %}${{ "%+.1f"|format(flow / 1e9) }}B
{% elif flow >= 1e6 %}${{ "%+.1f"|format(flow / 1e6) }}M
{% else %}{{ "%+.2f"|format(flow) }}{% endif %}
</td>
<td>{{ data.description|default(data.period|default('')) }}</td>
{% else %}
<td>{{ data }}</td>
<td></td>
{% endif %}
</tr>
{% endfor %}
</table>
{% elif etf_flows is iterable and etf_flows is not string %}
<table>
<tr><th>ETF</th><th>Flow</th></tr>
{% for item in etf_flows %}
<tr>
<td>{{ item.name|default(item.symbol|default('')) }}</td>
<td>{{ item.flow|default(item.value|default('')) }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{{ etf_flows }}</p>
{% endif %}
</div>
{% else %}
<p class="empty">No ETF flow data available.</p>
{% endif %}
<footer>Phoenix AGI System &mdash; Market Overview &mdash; {{ generated_at[:10] }}</footer>
</div>
<script>
// Bar Chart: Sector Performance
{% if sector_heatmap %}
(function() {
var sectors = {{ sector_heatmap|tojson }};
var labels = sectors.map(function(s) { return s.name || s.symbol || '?'; });
var values = sectors.map(function(s) { return s.change_pct || 0; });
var colors = values.map(function(v) { return v >= 0 ? '#22c55e' : '#ef4444'; });
new Chart(document.getElementById('sectorChart'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Change %',
data: values,
backgroundColor: colors,
borderColor: colors.map(function(c) { return c === '#22c55e' ? '#16a34a' : '#dc2626'; }),
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
scales: {
x: {
ticks: {
color: '#94a3b8',
callback: function(v) { return v + '%'; }
},
grid: { color: '#334155' }
},
y: {
ticks: { color: '#cbd5e1', font: { size: 11 } },
grid: { display: false }
}
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(ctx) { return ctx.parsed.x.toFixed(2) + '%'; }
}
}
}
}
});
})();
{% endif %}
</script>
</body>
</html>
@@ -1,265 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — {{ generated_at[:10] }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; padding: 2rem; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { font-size: 2rem; color: #f8fafc; margin-bottom: 0.5rem; }
h2 { font-size: 1.4rem; color: #94a3b8; margin: 2rem 0 1rem; border-bottom: 1px solid #334155; padding-bottom: 0.5rem; }
.meta { color: #64748b; margin-bottom: 2rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }
.card { background: #1e293b; border-radius: 8px; padding: 1.5rem; border: 1px solid #334155; }
.card h3 { color: #f1f5f9; margin-bottom: 1rem; font-size: 1.1rem; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #334155; }
th { color: #94a3b8; font-weight: 600; font-size: 0.85rem; text-transform: uppercase; }
td { color: #cbd5e1; }
.severity-critical { color: #ef4444; font-weight: bold; }
.severity-high { color: #f59e0b; }
.severity-medium { color: #6b7280; }
.up { color: #22c55e; }
.down { color: #ef4444; }
.chart-container { position: relative; height: 250px; margin: 1rem 0; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; margin: 2px; background: #334155; }
.empty { color: #64748b; font-style: italic; padding: 1rem 0; }
.stat-row { display: flex; gap: 1.5rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.stat-box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1.2rem; text-align: center; flex: 1; min-width: 140px; }
.stat-number { font-size: 2rem; font-weight: bold; color: #f8fafc; }
.stat-label { font-size: 0.85rem; color: #94a3b8; text-transform: uppercase; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #334155; color: #64748b; font-size: 0.85rem; text-align: center; }
</style>
</head>
<body>
<div class="container">
<h1>{{ title }}</h1>
<p class="meta">Generated: {{ generated_at }} | Phoenix AGI System</p>
<!-- Threat Summary Stats -->
<h2>Threat Overview</h2>
<div class="stat-row">
<div class="stat-box">
<div class="stat-number severity-critical">{{ cyber_threats.by_severity.critical|default(0) if cyber_threats else 0 }}</div>
<div class="stat-label">Critical</div>
</div>
<div class="stat-box">
<div class="stat-number severity-high">{{ cyber_threats.by_severity.high|default(0) if cyber_threats else 0 }}</div>
<div class="stat-label">High</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ cyber_threats.by_severity.medium|default(0) if cyber_threats else 0 }}</div>
<div class="stat-label">Medium</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ (cyber_threats.threats|length) if cyber_threats and cyber_threats.threats else 0 }}</div>
<div class="stat-label">Total Indicators</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ outages.ongoing_count|default(0) if outages else 0 }}</div>
<div class="stat-label">Active Outages</div>
</div>
</div>
<!-- Cyber Threats -->
<h2>Cyber Threats</h2>
{% if cyber_threats and cyber_threats.threats %}
<div class="grid">
<div class="card">
<h3>Severity Distribution</h3>
<div class="chart-container">
<canvas id="severityChart"></canvas>
</div>
</div>
<div class="card">
<h3>Threat Types</h3>
{% if cyber_threats.by_type %}
<table>
<tr><th>Type</th><th>Count</th></tr>
{% for type_name, count in cyber_threats.by_type.items() %}
<tr>
<td>{{ type_name }}</td>
<td>{{ count }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No threat type breakdown available.</p>
{% endif %}
</div>
</div>
<div class="card" style="margin-top:1.5rem;">
<h3>Threat Indicators</h3>
<table>
<tr><th>Severity</th><th>Indicator</th><th>Type</th><th>Threat</th><th>First Seen</th></tr>
{% for t in cyber_threats.threats[:20] %}
<tr>
<td class="severity-{{ t.severity|default('medium') }}">{{ t.severity|default('unknown') }}</td>
<td style="font-family:monospace;font-size:0.85rem;">{{ (t.indicator or '')[:40] }}</td>
<td>{{ t.type|default(t.indicator_type|default('')) }}</td>
<td>{{ (t.threat or '')[:30] }}</td>
<td>{{ (t.first_seen or '')[:10] }}</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p class="empty">No cyber threat data available.</p>
{% endif %}
<!-- Conflict Events -->
<h2>Conflict Events</h2>
{% if conflict_events %}
<div class="card">
<table>
<tr><th>Date</th><th>Type</th><th>Country</th><th>Location</th><th>Fatalities</th></tr>
{% for e in conflict_events[:15] %}
<tr>
<td>{{ (e.event_date or '')[:10] }}</td>
<td>{{ e.event_type|default('') }}</td>
<td>{{ e.country|default('') }}</td>
<td>{{ e.location|default('') }}</td>
<td class="{{ 'severity-critical' if (e.fatalities or 0) >= 10 else 'severity-high' if (e.fatalities or 0) > 0 else '' }}">{{ e.fatalities|default(0) }}</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p class="empty">No conflict events reported.</p>
{% endif %}
<!-- Military Activity -->
<h2>Military Activity</h2>
{% if military_activity %}
<div class="grid">
{% if military_activity.theaters %}
{% for theater_name, theater_data in military_activity.theaters.items() %}
<div class="card">
<h3>{{ theater_name }}</h3>
{% if theater_data is mapping %}
<table>
<tr><th>Metric</th><th>Value</th></tr>
{% for key, val in theater_data.items() %}
<tr>
<td>{{ key.replace('_', ' ')|title }}</td>
<td>{{ val }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{{ theater_data }}</p>
{% endif %}
</div>
{% endfor %}
{% endif %}
<div class="card">
<h3>Summary</h3>
<div class="stat-number" style="margin-bottom:0.5rem;">{{ military_activity.total_military_aircraft|default(0) }}</div>
<div class="stat-label">Military Aircraft Tracked</div>
</div>
</div>
{% else %}
<p class="empty">No military activity data available.</p>
{% endif %}
<!-- Cable Health -->
<h2>Submarine Cable Health</h2>
{% if cable_health and cable_health.corridors %}
<div class="grid">
{% for corridor_name, corridor_data in cable_health.corridors.items() %}
<div class="card">
<h3>{{ corridor_name }}</h3>
{% if corridor_data is mapping %}
<table>
{% for key, val in corridor_data.items() %}
<tr>
<td>{{ key.replace('_', ' ')|title }}</td>
<td>{{ val }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{{ corridor_data }}</p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">No cable health data available.</p>
{% endif %}
<!-- Infrastructure Outages -->
<h2>Infrastructure Outages</h2>
{% if outages and outages.outages %}
<div class="card">
<table>
<tr><th>Service</th><th>Status</th><th>Region</th><th>Since</th><th>Impact</th></tr>
{% for o in outages.outages[:15] %}
<tr>
<td>{{ o.service|default(o.name|default('')) }}</td>
<td class="{{ 'severity-critical' if (o.status or '')|lower in ['major', 'critical'] else 'severity-high' if (o.status or '')|lower in ['partial', 'degraded'] else '' }}">{{ o.status|default('unknown') }}</td>
<td>{{ o.region|default('') }}</td>
<td>{{ (o.since or o.started or '')[:16] }}</td>
<td>{{ o.impact|default('') }}</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p class="empty">No active outages reported.</p>
{% endif %}
<footer>Phoenix AGI System &mdash; Threat Landscape Report &mdash; {{ generated_at[:10] }}</footer>
</div>
<script>
// Doughnut Chart: Threat Severity Distribution
{% if cyber_threats and cyber_threats.by_severity %}
(function() {
var severity = {{ cyber_threats.by_severity|tojson }};
var labels = Object.keys(severity).map(function(l) {
return l.charAt(0).toUpperCase() + l.slice(1);
});
var values = Object.values(severity);
var colorMap = {
'Critical': '#ef4444',
'High': '#f59e0b',
'Medium': '#6b7280',
'Low': '#3b82f6',
'Info': '#64748b'
};
var colors = labels.map(function(l) { return colorMap[l] || '#94a3b8'; });
new Chart(document.getElementById('severityChart'), {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: colors,
borderColor: '#0f172a',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '55%',
plugins: {
legend: {
position: 'bottom',
labels: { color: '#cbd5e1', padding: 12, font: { size: 12 } }
}
}
}
});
})();
{% endif %}
</script>
</body>
</html>
+12 -46
View File
@@ -10,7 +10,7 @@ conflict, military flights, infrastructure, and more.
Phase 1: Markets, Economic, Seismology, Wildfire (14 tools).
Phase 2: Conflict, Military, Infrastructure, Maritime, Climate (+10 = 24 tools).
Phase 3: News, Intelligence, Prediction, Displacement, Aviation, Cyber (+9 = 33 tools).
Phase 4: Reports daily brief, country dossier, threat landscape (+3 = 36 tools).
Phase 4: (reports removed use live dashboard instead).
Phase 5: Analysis focal points, signal summary, temporal anomalies, CII v2 (+3 = 39 tools).
Phase 6: Military & infrastructure intelligence (+6 = 45 tools).
Phase 7: Health, sanctions, elections, shipping, social, nuclear, alerts, trends (+10 = 55 tools).
@@ -36,8 +36,7 @@ from mcp.types import Tool, TextContent
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, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear, service_status, geospatial, hacker_news, github_trending, arxiv_papers, usa_spending, environmental
from .reports import generator as report_gen
from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear, service_status, geospatial, hacker_news, github_trending, arxiv_papers, usa_spending, environmental, usni_fleet
logging.basicConfig(
level=os.environ.get("WORLD_INTEL_LOG_LEVEL", "INFO"),
@@ -455,39 +454,6 @@ TOOLS: list[Tool] = [
},
},
),
# --- Reports (3 tools) ---
Tool(
name="intel_daily_brief",
description="Generate a daily intelligence brief HTML report (markets, conflict, cyber, natural, predictions, trending). Returns file path.",
inputSchema={
"type": "object",
"properties": {
"output_dir": {"type": "string", "description": "Custom output directory (default: $STORAGE_BASE/reports/intel/)"},
},
},
),
Tool(
name="intel_country_dossier",
description="Generate a full country dossier HTML report (brief, instability index, conflict, displacement, economic). Returns file path.",
inputSchema={
"type": "object",
"properties": {
"country_code": {"type": "string", "description": "ISO country code (e.g., UKR, SYR, MMR)"},
"output_dir": {"type": "string", "description": "Custom output directory"},
},
"required": ["country_code"],
},
),
Tool(
name="intel_threat_landscape",
description="Generate a threat landscape HTML report (cyber threats, conflict, military, cable health, outages). Returns file path.",
inputSchema={
"type": "object",
"properties": {
"output_dir": {"type": "string", "description": "Custom output directory"},
},
},
),
# --- Space Weather (1 tool) ---
Tool(
name="intel_space_weather",
@@ -854,6 +820,12 @@ TOOLS: list[Tool] = [
},
},
),
# --- USNI Fleet (1 tool) ---
Tool(
name="intel_usni_fleet",
description="US Navy fleet disposition from USNI News Fleet Tracker. Extracts ships, hull numbers, carrier strike groups, regional deployment, and force totals from the latest weekly report.",
inputSchema={"type": "object", "properties": {}},
),
# --- Environmental (2 tools) ---
Tool(
name="intel_environmental_events",
@@ -1104,16 +1076,6 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
from .analysis.alerts import fetch_weekly_trends
return await fetch_weekly_trends(fetcher)
# Reports
case "intel_daily_brief":
return await report_gen.generate_daily_brief(output_dir=arguments.get("output_dir"))
case "intel_country_dossier":
return await report_gen.generate_country_dossier(
country_code=arguments["country_code"],
output_dir=arguments.get("output_dir"),
)
case "intel_threat_landscape":
return await report_gen.generate_threat_landscape(output_dir=arguments.get("output_dir"))
# Service Status
case "intel_service_status":
@@ -1239,6 +1201,10 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
limit=arguments.get("limit", 25),
)
# USNI Fleet
case "intel_usni_fleet":
return await usni_fleet.fetch_usni_fleet(fetcher)
# Environmental
case "intel_environmental_events":
return await environmental.fetch_environmental_events(
+22
View File
@@ -99,6 +99,18 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
("Americas Quarterly", "https://www.americasquarterly.org/feed/"),
("Buenos Aires Times", "https://www.batimes.com.ar/feed"),
("Tico Times", "https://ticotimes.net/feed"),
("InSight Crime", "https://insightcrime.org/feed/"),
("Brazil Reports", "https://brazilian.report/feed/"),
("Mexico News Daily", "https://mexiconewsdaily.com/feed/"),
],
"multilingual": [
("BBC Mundo", "https://feeds.bbci.co.uk/mundo/rss.xml"),
("DW Español", "https://rss.dw.com/rss/es/top_news/rss-es-top"),
("DW Deutsch", "https://rss.dw.com/rss/de/top_news/rss-de-top"),
("France24 Français", "https://www.france24.com/fr/rss"),
("RFI Français", "https://www.rfi.fr/fr/rss"),
("UN News Español", "https://news.un.org/feed/subscribe/es/news/all/rss.xml"),
("UN News Français", "https://news.un.org/feed/subscribe/fr/news/all/rss.xml"),
],
"energy": [
("Oil Price", "https://oilprice.com/rss/main"),
@@ -151,6 +163,16 @@ SOURCE_TIERS: dict[str, str] = {
"ReliefWeb": "intl_org",
"Lowy Interpreter": "think_tank",
"Dialogo Americas": "specialty",
"InSight Crime": "specialty",
"Brazil Reports": "specialty",
"Mexico News Daily": "specialty",
"BBC Mundo": "major",
"DW Español": "major",
"DW Deutsch": "major",
"France24 Français": "major",
"RFI Français": "major",
"UN News Español": "government",
"UN News Français": "government",
"Nikkei Asia": "major",
"The National UAE": "major",
"Zero Hedge": "aggregator",
+275
View File
@@ -0,0 +1,275 @@
"""USNI News Fleet and Marine Tracker source for world-intel-mcp.
Parses the USNI Fleet Tracker RSS feed for US Navy fleet disposition,
extracting carrier strike groups, ship deployments, and force posture.
No API key required uses the public RSS category feed.
"""
import logging
import re
from datetime import datetime, timezone
from ..fetcher import Fetcher
try:
import feedparser
except ImportError:
feedparser = None # type: ignore[assignment]
logger = logging.getLogger("world-intel-mcp.sources.usni_fleet")
_FEED_URL = "https://news.usni.org/category/fleet-tracker/feed"
# Regex patterns for extracting fleet data from article content
_SHIP_PATTERN = re.compile(
r"USS\s+([\w\s.]+?)\s*\(((?:CVN|DDG|CG|LHD|LHA|LPD|LSD|SSN|SSBN|SSGN|FFG|MCM|PC|AS|ESB|ESD|EPF|AFSB|T-AO|T-AKE|T-ESB|WAGB|LCS)-?\d+)\)",
re.IGNORECASE,
)
_USCG_PATTERN = re.compile(
r"USCGC\s+([\w\s.]+?)\s*\((WAGB|WMSL|WPC|WPB|WLB)-?\d+\)",
re.IGNORECASE,
)
_CSG_PATTERN = re.compile(
r"Carrier Strike Group\s+(\d+|[A-Z]+)",
re.IGNORECASE,
)
_ESG_PATTERN = re.compile(
r"(?:Expeditionary Strike Group|Amphibious Ready Group)\s+(\d+|[A-Z]+)",
re.IGNORECASE,
)
_BATTLE_FORCE_PATTERN = re.compile(
r"(\d+)\s+ships?\s*\((\d+)\s+USS,\s*(\d+)\s+USNS\)",
re.IGNORECASE,
)
_DEPLOYED_PATTERN = re.compile(
r"(\d+)\s+deployed\s*\((\d+)\s+USS,\s*(\d+)\s+USNS\)",
re.IGNORECASE,
)
_UNDERWAY_PATTERN = re.compile(
r"(\d+)\s+underway\s*\((\d+)\s+deployed,\s*(\d+)\s+local\)",
re.IGNORECASE,
)
# Region keywords for location classification
_REGION_KEYWORDS = {
"Arabian Sea": "CENTCOM",
"Persian Gulf": "CENTCOM",
"Red Sea": "CENTCOM",
"Gulf of Oman": "CENTCOM",
"Gulf of Aden": "CENTCOM",
"Mediterranean": "EUCOM",
"Atlantic": "EUCOM",
"North Sea": "EUCOM",
"Baltic": "EUCOM",
"Caribbean": "SOUTHCOM",
"Pacific": "INDOPACOM",
"Philippine Sea": "INDOPACOM",
"South China Sea": "INDOPACOM",
"East China Sea": "INDOPACOM",
"Western Pacific": "INDOPACOM",
"Japan": "INDOPACOM",
"Yokosuka": "INDOPACOM",
"Guam": "INDOPACOM",
"Indian Ocean": "INDOPACOM",
"Antarctica": "OTHER",
"Arctic": "NORTHCOM",
"San Diego": "HOMEPORT",
"Norfolk": "HOMEPORT",
"Mayport": "HOMEPORT",
"Bremerton": "HOMEPORT",
}
def _classify_region(text: str) -> str:
"""Classify a text snippet into a combatant command region."""
for keyword, region in _REGION_KEYWORDS.items():
if keyword.lower() in text.lower():
return region
return "UNKNOWN"
def _extract_fleet_data(content: str) -> dict:
"""Extract structured fleet disposition from article HTML/text content."""
ships = []
strike_groups = []
# Extract USS ships
for match in _SHIP_PATTERN.finditer(content):
name = match.group(1).strip()
hull = match.group(2).strip()
# Find surrounding context for region classification
start = max(0, match.start() - 200)
end = min(len(content), match.end() + 200)
context = content[start:end]
region = _classify_region(context)
ship_type = hull.split("-")[0] if "-" in hull else hull[:3]
type_labels = {
"CVN": "Aircraft Carrier",
"DDG": "Destroyer",
"CG": "Cruiser",
"LHD": "Amphibious Assault Ship",
"LHA": "Amphibious Assault Ship",
"LPD": "Amphibious Transport Dock",
"LSD": "Dock Landing Ship",
"SSN": "Attack Submarine",
"SSBN": "Ballistic Missile Submarine",
"SSGN": "Guided Missile Submarine",
"FFG": "Frigate",
"LCS": "Littoral Combat Ship",
"MCM": "Mine Countermeasure",
"ESB": "Expeditionary Sea Base",
"ESD": "Expeditionary Transfer Dock",
"EPF": "Expeditionary Fast Transport",
}
ships.append({
"name": f"USS {name}",
"hull_number": hull,
"type": type_labels.get(ship_type, ship_type),
"region": region,
})
# Extract USCG cutters
for match in _USCG_PATTERN.finditer(content):
name = match.group(1).strip()
hull = match.group(2).strip()
start = max(0, match.start() - 200)
end = min(len(content), match.end() + 200)
context = content[start:end]
region = _classify_region(context)
ships.append({
"name": f"USCGC {name}",
"hull_number": hull,
"type": "Coast Guard Cutter",
"region": region,
})
# Extract carrier strike groups
for match in _CSG_PATTERN.finditer(content):
strike_groups.append({"name": f"CSG-{match.group(1)}", "type": "Carrier Strike Group"})
for match in _ESG_PATTERN.finditer(content):
strike_groups.append({"name": f"ESG-{match.group(1)}", "type": "Expeditionary Strike Group"})
# Extract force totals
force_totals = {}
bf = _BATTLE_FORCE_PATTERN.search(content)
if bf:
force_totals["battle_force"] = {
"total": int(bf.group(1)),
"uss": int(bf.group(2)),
"usns": int(bf.group(3)),
}
dep = _DEPLOYED_PATTERN.search(content)
if dep:
force_totals["deployed"] = {
"total": int(dep.group(1)),
"uss": int(dep.group(2)),
"usns": int(dep.group(3)),
}
uw = _UNDERWAY_PATTERN.search(content)
if uw:
force_totals["underway"] = {
"total": int(uw.group(1)),
"deployed": int(uw.group(2)),
"local": int(uw.group(3)),
}
# Deduplicate ships by hull number
seen_hulls: set[str] = set()
unique_ships = []
for ship in ships:
if ship["hull_number"] not in seen_hulls:
seen_hulls.add(ship["hull_number"])
unique_ships.append(ship)
# Region breakdown
region_counts: dict[str, int] = {}
for ship in unique_ships:
r = ship["region"]
region_counts[r] = region_counts.get(r, 0) + 1
return {
"ships": unique_ships,
"ship_count": len(unique_ships),
"strike_groups": strike_groups,
"force_totals": force_totals,
"region_breakdown": region_counts,
}
async def fetch_usni_fleet(fetcher: Fetcher) -> dict:
"""Fetch latest USNI Fleet Tracker disposition.
Parses the USNI News Fleet Tracker RSS feed for the most recent
weekly fleet disposition report. Extracts ship names, hull numbers,
strike groups, regions, and force totals.
Returns:
Dict with ships[], strike_groups[], force_totals, region_breakdown,
report_date, report_url, source, timestamp.
"""
if feedparser is None:
return {
"error": "feedparser not installed",
"ships": [],
"ship_count": 0,
"source": "usni-fleet-tracker",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
xml = await fetcher.get_text(
_FEED_URL,
source="usni-fleet-tracker",
cache_key="usni:fleet_tracker_rss",
cache_ttl=3600, # Weekly updates, cache for 1 hour
)
if not xml:
return {
"error": "failed to fetch USNI fleet tracker feed",
"ships": [],
"ship_count": 0,
"source": "usni-fleet-tracker",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
feed = feedparser.parse(xml)
if not feed.entries:
return {
"error": "no fleet tracker entries found",
"ships": [],
"ship_count": 0,
"source": "usni-fleet-tracker",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
# Get the most recent entry (fleet tracker article)
entry = feed.entries[0]
title = entry.get("title", "")
link = entry.get("link", "")
published = entry.get("published", "")
# Try content:encoded first (full article), fall back to summary
content = ""
if hasattr(entry, "content") and entry.content:
content = entry.content[0].get("value", "")
if not content:
content = entry.get("summary", entry.get("description", ""))
# Strip HTML tags for cleaner regex matching
clean_content = re.sub(r"<[^>]+>", " ", content)
clean_content = re.sub(r"\s+", " ", clean_content)
fleet_data = _extract_fleet_data(clean_content)
return {
**fleet_data,
"report_title": title,
"report_url": link,
"report_date": published,
"source": "usni-fleet-tracker",
"timestamp": datetime.now(timezone.utc).isoformat(),
}