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:
co-authored by
Claude Opus 4.6
parent
6075910505
commit
39e687b054
@@ -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', '?')}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
"""Report generation for world-intel-mcp."""
|
||||
@@ -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 & 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 — Country Dossier: {{ country_code }} — {{ 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 & 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 — World Intelligence Report — {{ 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 & 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 — Market Overview — {{ 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 — Threat Landscape Report — {{ 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>
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
Reference in New Issue
Block a user