"""Intelligence Collector Daemon — continuous vector store population. Fetches all intelligence sources in parallel and stores results in the Qdrant vector store. Runs independently of the MCP server and dashboard, ensuring data accumulates 24/7 for semantic search and historical analysis. Usage: intel-collector # Single collection cycle intel-collector --daemon # Run every 5 minutes intel-collector --interval 120 # Custom interval (seconds) intel-collector --sources markets,conflict # Specific sources only """ import asyncio import logging import os import signal import sys import time from pathlib import Path from dotenv import load_dotenv load_dotenv(Path(__file__).resolve().parents[2] / ".env", override=False) logging.basicConfig( level=os.environ.get("WORLD_INTEL_LOG_LEVEL", "INFO"), format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger("world-intel-collector") from .cache import Cache from .circuit_breaker import CircuitBreaker from .fetcher import Fetcher from .vector_store import VectorStore # All fetchable sources grouped by domain. # Each entry: (name, module_path, function_name, kwargs) SOURCES = [ # Markets (7) ("market_quotes", "sources.markets", "fetch_market_quotes", {}), ("crypto_quotes", "sources.markets", "fetch_crypto_quotes", {}), ("macro_signals", "sources.markets", "fetch_macro_signals", {}), ("sector_heatmap", "sources.markets", "fetch_sector_heatmap", {}), ("stablecoin_status", "sources.markets", "fetch_stablecoin_status", {}), ("etf_flows", "sources.markets", "fetch_etf_flows", {}), ("commodity_quotes", "sources.markets", "fetch_commodity_quotes", {}), ("btc_technicals", "sources.markets", "fetch_btc_technicals", {}), # Economic (6) ("energy_prices", "sources.economic", "fetch_energy_prices", {}), ("gas_prices", "sources.economic", "fetch_gas_prices", {}), ("residential_natgas", "sources.economic", "fetch_residential_natgas_prices", {}), ("electricity_rates", "sources.economic", "fetch_electricity_rates", {}), ("central_bank_rates", "sources.central_banks", "fetch_central_bank_rates", {}), # Natural Disasters (2) ("earthquakes", "sources.seismology", "fetch_earthquakes", {}), ("wildfires", "sources.wildfire", "fetch_wildfires", {}), # Conflict & Security (4) ("acled_events", "sources.conflict", "fetch_acled_events", {}), ("ucdp_events", "sources.conflict", "fetch_ucdp_events", {}), ("displacement", "sources.displacement", "fetch_displacement_summary", {}), # Military (2) ("military_flights", "sources.military", "fetch_military_flights", {}), # Infrastructure (4) ("internet_outages", "sources.infrastructure", "fetch_internet_outages", {}), ("cable_health", "sources.infrastructure", "fetch_cable_health", {}), ("service_status", "sources.service_status", "fetch_service_status", {}), # Maritime (1) ("nav_warnings", "sources.maritime", "fetch_nav_warnings", {}), # Climate (1) ("climate_anomalies", "sources.climate", "fetch_climate_anomalies", {}), # News (2) ("news_feed", "sources.news", "fetch_news_feed", {}), ("trending_keywords", "sources.news", "fetch_trending_keywords", {}), # Prediction (1) ("prediction_markets", "sources.prediction", "fetch_prediction_markets", {}), # Aviation (2) ("airport_delays", "sources.aviation", "fetch_airport_delays", {}), ("domestic_flights", "sources.aviation", "fetch_domestic_flights", {}), # Cyber (1) ("cyber_threats", "sources.cyber", "fetch_cyber_threats", {}), # Space Weather (1) ("space_weather", "sources.space_weather", "fetch_space_weather", {}), # AI/Tech (1) ("ai_watch", "sources.ai_watch", "fetch_ai_watch", {}), # Health (1) ("disease_outbreaks", "sources.health", "fetch_disease_outbreaks", {}), # Elections (1) ("election_calendar", "sources.elections", "fetch_election_calendar", {}), # Shipping (1) ("shipping_index", "sources.shipping", "fetch_shipping_index", {}), # Social (1) ("social_signals", "sources.social", "fetch_social_signals", {}), # Nuclear (1) ("nuclear_monitor", "sources.nuclear", "fetch_nuclear_monitor", {}), # Traffic (2) ("traffic_flow", "sources.traffic", "fetch_traffic_flow", {}), ("traffic_incidents", "sources.traffic", "fetch_traffic_incidents", {}), # Analysis (cross-domain, runs after raw sources) ("risk_scores", "sources.intelligence", "fetch_risk_scores", {}), ("signal_convergence", "sources.intelligence", "fetch_signal_convergence", {}), ("alert_digest", "analysis.alerts", "fetch_alert_digest", {}), ("weekly_trends", "analysis.alerts", "fetch_weekly_trends", {}), ("strategic_posture", "analysis.posture", "fetch_strategic_posture", {}), ("fleet_report", "sources.fleet", "fetch_fleet_report", {}), ("usni_fleet", "sources.usni_fleet", "fetch_usni_fleet", {}), ] # Domain name → list of source names for --sources filtering DOMAIN_GROUPS = { "markets": [ "market_quotes", "crypto_quotes", "macro_signals", "sector_heatmap", "stablecoin_status", "etf_flows", "commodity_quotes", "btc_technicals", ], "economic": [ "energy_prices", "gas_prices", "residential_natgas", "electricity_rates", "central_bank_rates", ], "natural": ["earthquakes", "wildfires"], "conflict": ["acled_events", "ucdp_events", "displacement"], "military": ["military_flights"], "infrastructure": ["internet_outages", "cable_health", "service_status"], "maritime": ["nav_warnings"], "climate": ["climate_anomalies"], "news": ["news_feed", "trending_keywords"], "prediction": ["prediction_markets"], "aviation": ["airport_delays", "domestic_flights"], "cyber": ["cyber_threats"], "space": ["space_weather"], "ai": ["ai_watch"], "health": ["disease_outbreaks"], "elections": ["election_calendar"], "shipping": ["shipping_index"], "social": ["social_signals"], "nuclear": ["nuclear_monitor"], "traffic": ["traffic_flow", "traffic_incidents"], "analysis": [ "risk_scores", "signal_convergence", "alert_digest", "weekly_trends", "strategic_posture", "fleet_report", "usni_fleet", ], } def _resolve_source_filter(source_filter: str | None) -> set[str] | None: """Resolve --sources argument to a set of source names.""" if not source_filter: return None names: set[str] = set() for part in source_filter.split(","): part = part.strip() if part in DOMAIN_GROUPS: names.update(DOMAIN_GROUPS[part]) else: names.add(part) return names def _import_fetch_fn(module_path: str, fn_name: str): """Dynamically import a fetch function from world_intel_mcp.""" import importlib full_module = f"world_intel_mcp.{module_path}" mod = importlib.import_module(full_module) return getattr(mod, fn_name) async def collect_once( fetcher: Fetcher, vector_store: VectorStore, source_filter: set[str] | None = None, timeout: float = 45.0, ) -> dict: """Run one collection cycle across all sources. Returns dict with counts of successes, failures, and skipped. """ start = time.time() sources_to_run = [ s for s in SOURCES if source_filter is None or s[0] in source_filter ] async def _fetch_one(name: str, module_path: str, fn_name: str, kwargs: dict): try: fn = _import_fetch_fn(module_path, fn_name) result = await asyncio.wait_for(fn(fetcher, **kwargs), timeout=timeout) return name, result, None except asyncio.TimeoutError: return name, None, f"timeout ({timeout}s)" except Exception as exc: return name, None, str(exc)[:120] tasks = [_fetch_one(name, mod, fn, kw) for name, mod, fn, kw in sources_to_run] results = await asyncio.gather(*tasks, return_exceptions=True) successes = 0 failures = 0 errors = [] for item in results: if isinstance(item, Exception): failures += 1 errors.append(str(item)[:100]) continue name, data, error = item if error: failures += 1 errors.append(f"{name}: {error}") logger.warning("Collect %s failed: %s", name, error) elif data is not None: successes += 1 # The fetcher already stores in vector_store via its hook, # but for sources that return composite data (analysis modules), # we also store the high-level result directly. if not isinstance(data, dict) or not data.get("error"): await vector_store.store(name, data) else: failures += 1 elapsed = time.time() - start stats = await vector_store.collection_stats() summary = { "cycle_time_s": round(elapsed, 1), "sources_attempted": len(sources_to_run), "successes": successes, "failures": failures, "errors": errors[:10], "vector_store_points": stats.get("points_count", 0), } logger.info( "Collection cycle: %d/%d sources in %.1fs, %d vector points", successes, len(sources_to_run), elapsed, stats.get("points_count", 0), ) return summary async def run_daemon( interval: int = 300, source_filter: str | None = None, ) -> None: """Run the collector in daemon mode with a configurable interval.""" cache = Cache() breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=300) vector_store = VectorStore(enabled=True) await vector_store.start() fetcher = Fetcher(cache=cache, breaker=breaker, vector_store=vector_store) resolved_filter = _resolve_source_filter(source_filter) filter_desc = source_filter or "all" logger.info( "Collector daemon starting: interval=%ds, sources=%s", interval, filter_desc, ) stop_event = asyncio.Event() def _handle_signal(sig, frame): logger.info("Received signal %s, stopping...", sig) stop_event.set() signal.signal(signal.SIGINT, _handle_signal) signal.signal(signal.SIGTERM, _handle_signal) cycle = 0 while not stop_event.is_set(): cycle += 1 logger.info("Starting collection cycle %d", cycle) try: summary = await collect_once(fetcher, vector_store, resolved_filter) logger.info("Cycle %d complete: %s", cycle, summary) except Exception as exc: logger.error("Cycle %d failed: %s", cycle, exc) # Evict expired cache entries periodically if cycle % 12 == 0: # Every ~hour at 5min interval evicted = cache.evict_expired() if evicted: logger.info("Evicted %d expired cache entries", evicted) # Wait for next cycle or stop signal try: await asyncio.wait_for(stop_event.wait(), timeout=interval) except asyncio.TimeoutError: pass await vector_store.stop() await fetcher.close() logger.info("Collector daemon stopped") async def run_once(source_filter: str | None = None) -> dict: """Run a single collection cycle and exit.""" cache = Cache() breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=300) vector_store = VectorStore(enabled=True) await vector_store.start() fetcher = Fetcher(cache=cache, breaker=breaker, vector_store=vector_store) resolved_filter = _resolve_source_filter(source_filter) summary = await collect_once(fetcher, vector_store, resolved_filter) # Wait for vector store queue to drain if vector_store._store_queue: await vector_store._store_queue.join() await vector_store.stop() await fetcher.close() return summary def main(): """CLI entry point for the collector.""" import argparse parser = argparse.ArgumentParser( description="World Intelligence Collector — populate vector store from all sources" ) parser.add_argument( "--daemon", action="store_true", help="Run continuously at --interval seconds", ) parser.add_argument( "--interval", type=int, default=300, help="Collection interval in seconds (default: 300 = 5 minutes)", ) parser.add_argument( "--sources", type=str, default=None, help="Comma-separated source names or domain groups (e.g., 'markets,conflict,cyber')", ) args = parser.parse_args() if args.daemon: asyncio.run(run_daemon(interval=args.interval, source_filter=args.sources)) else: summary = asyncio.run(run_once(source_filter=args.sources)) print("Collection complete:") print( f" Sources: {summary['successes']}/{summary['sources_attempted']} succeeded" ) print(f" Time: {summary['cycle_time_s']}s") print(f" Vector store: {summary['vector_store_points']} points") if summary["errors"]: print(f" Errors ({len(summary['errors'])}):") for e in summary["errors"]: print(f" - {e}") sys.exit(0 if summary["failures"] == 0 else 1) if __name__ == "__main__": main()