""" RAG Firehose - Continuous Intelligence Ingestion Engine ======================================================== Self-feeding RAG pipeline that continuously pulls, filters, and ingests crypto intelligence from multiple sources at different cadences. Architecture: ┌─────────────────────────────────────────────────────────┐ │ FIREHOSE ENGINE │ │ │ │ Hourly (news/social) Daily (scams/wallets) Weekly │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ │ │ News RSS │ │ Etherscan│ │ FAISS │ │ │ │ CT Rundn │ │ Chainab. │ │ rebuild │ │ │ │ Social │ │ Rekt DB │ │ RAGAS │ │ │ │ Sentiment│ │ Solana │ │ eval │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ └──────────────────────┼─────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────┐ │ │ │ SMART INGESTION PIPELINE │ │ │ │ Filter → Dedup → Extract │ │ │ │ → Classify → Embed → Store │ │ │ └────────────┬───────────────┘ │ │ ▼ │ │ ┌────────────────────────────┐ │ │ │ RAG COLLECTIONS │ │ │ │ known_scams, news_articles │ │ │ │ forensic_reports, etc. │ │ │ └────────────┬───────────────┘ │ │ ▼ │ │ ┌────────────────────────────┐ │ │ │ FEEDBACK LOOP │ │ │ │ Scanner hits → boost docs │ │ │ │ False positives → penalize │ │ │ └────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ Feed Cadences: - 15min: CT rundown, social sentiment, scam alerts (high-urgency) - 1hr: News RSS (200+ feeds), market brief, fear & greed - 6hr: X/Twitter profiles, KOL tracking, prediction markets - 24hr: Etherscan labels, Solana registry, chainabuse, rekt DB - 72hr: FAISS index rebuild, BM25 rebuild, RAGAS evaluation - 168hr: Full pattern extraction from confirmed scams, quality audit Smart Ingestion: - Content hash dedup (Redis) - never ingest the same doc twice - Quality scoring - skip low-signal content (<30 score) - Entity extraction - pull addresses, chains, tokens, protocols - Auto-classification - route to correct collection - Batch embedding with rate limiting - never overload embedder - Per-collection size caps - auto-evict oldest on overflow """ import asyncio import contextlib import hashlib import logging import os import time from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Any import httpx logger = logging.getLogger("rag.firehose") # ────────────────────────────────────────────────────────────── # Configuration # ────────────────────────────────────────────────────────────── RAG_API = "http://localhost:8000/api/v1/rag" # Per-collection size caps (auto-evict oldest on overflow) COLLECTION_CAPS = { "known_scams": 50000, # scam addresses - keep forever, large "scam_patterns": 5000, # curated patterns - small, high quality "forensic_reports": 10000, # hack reports - medium "contract_audits": 5000, # code audits - medium "wallet_profiles": 100000, # labeled wallets - large "news_articles": 20000, # news - rolling window "market_intel": 5000, # market data - medium "token_analysis": 50000, # token data - large "transaction_patterns": 10000, # on-chain patterns - medium "social_sentiment": 10000, # social data - rolling "general": 10000, # misc - catch-all } # Quality thresholds (skip docs below this score) MIN_QUALITY_SCORE = 30 # 0-100 # Rate limits (docs per minute per collection) RATE_LIMITS = { "known_scams": 60, "news_articles": 30, "social_sentiment": 20, "default": 30, } # Content hash TTL in Redis (7 days for news, 30 for scams) HASH_TTL = { "news_articles": 604800, # 7 days "social_sentiment": 604800, # 7 days "known_scams": 2592000, # 30 days "default": 1209600, # 14 days } # ────────────────────────────────────────────────────────────── # Data Structures # ────────────────────────────────────────────────────────────── @dataclass class FeedSource: """A data source the firehose pulls from.""" name: str collection: str cadence_seconds: int fetch_fn: Any = field(repr=False) enabled: bool = True description: str = "" last_run: float = 0 runs: int = 0 docs_ingested: int = 0 docs_skipped: int = 0 errors: int = 0 @dataclass class FirehoseStats: """Current firehose statistics.""" running: bool = False uptime_seconds: float = 0 total_sources: int = 0 total_ingested: int = 0 total_skipped: int = 0 total_errors: int = 0 sources: dict[str, dict] = field(default_factory=dict) last_activity: float = 0 # ────────────────────────────────────────────────────────────── # Smart Ingestion Pipeline # ────────────────────────────────────────────────────────────── class IngestionPipeline: """Filters, deduplicates, and enriches documents before RAG storage.""" def __init__(self, client: httpx.AsyncClient): self.client = client self._rate_trackers: dict[str, list[float]] = {} # collection → recent ingest timestamps def _check_rate(self, collection: str) -> bool: """Return True if we're under the rate limit for this collection.""" limit = RATE_LIMITS.get(collection, RATE_LIMITS["default"]) now = time.monotonic() times = self._rate_trackers.setdefault(collection, []) # Remove timestamps older than 60s times[:] = [t for t in times if now - t < 60] return len(times) < limit def _record_ingest(self, collection: str): """Record an ingestion for rate limiting.""" self._rate_trackers.setdefault(collection, []).append(time.monotonic()) def make_content_hash(self, content: str) -> str: """Deterministic hash for dedup.""" return hashlib.sha256(content.encode()).hexdigest()[:16] async def is_duplicate(self, content_hash: str, collection: str) -> bool: """Check if content hash already exists in Redis dedup set.""" try: resp = await self.client.get( f"{RAG_API}/dedup-check", params={"hash": content_hash, "collection": collection}, timeout=5, ) if resp.status_code == 200: return resp.json().get("exists", False) except Exception: pass return False def score_quality(self, content: str, metadata: dict) -> int: """Score document quality 0-100. Skip low-signal content.""" score = 50 # Start neutral # Length bonus (substantial content is better) if len(content) > 500: score += 15 elif len(content) > 200: score += 10 elif len(content) < 50: score -= 20 # Entity richness (more entities = more useful) entity_count = 0 if metadata.get("address"): entity_count += 1 if metadata.get("chain"): entity_count += 1 if metadata.get("token"): entity_count += 1 if metadata.get("protocol"): entity_count += 1 score += entity_count * 5 # Source authority high_authority = {"etherscan", "chainabuse", "rekt", "certik", "slowmist", "peckshield"} if metadata.get("source", "").lower() in high_authority: score += 20 # Severity boost (critical scams are more valuable) if metadata.get("severity") == "critical": score += 15 elif metadata.get("severity") == "high": score += 10 # Penalize duplicates of very similar content if metadata.get("is_variant"): score -= 10 return max(0, min(100, score)) def extract_entities(self, content: str) -> dict: """Extract addresses, chains, tokens from content.""" import re entities = {"addresses": [], "chains": [], "tokens": [], "protocols": []} # EVM addresses (0x...) evm_addrs = re.findall(r"0x[a-fA-F0-9]{40}", content) entities["addresses"].extend(evm_addrs[:10]) # Solana addresses (base58, 32-44 chars) sol_addrs = re.findall(r"[1-9A-HJ-NP-Za-km-z]{32,44}", content) entities["addresses"].extend([a for a in sol_addrs[:10] if a not in entities["addresses"]]) # Known chains known_chains = [ "ethereum", "bsc", "polygon", "arbitrum", "optimism", "avalanche", "solana", "base", "fantom", "gnosis", "celo", "zksync", "linea", "scroll", "mantle", "sui", "aptos", "near", "tron", "bitcoin", ] for chain in known_chains: if chain.lower() in content.lower(): entities["chains"].append(chain) # Token symbols ($TOKEN) tokens = re.findall(r"\$([A-Z]{2,10})", content) entities["tokens"].extend(tokens[:10]) # Known protocols known_protocols = [ "uniswap", "aave", "curve", "balancer", "sushi", "pancake", "raydium", "jupiter", "orca", "marinade", "lido", "eigenlayer", "compound", "maker", "yearn", "convex", ] for proto in known_protocols: if proto.lower() in content.lower(): entities["protocols"].append(proto) return entities async def ingest(self, collection: str, content: str, metadata: dict, doc_id: str | None = None) -> dict[str, Any]: """Run the full pipeline: dedup → quality → extract → classify → store.""" # 1. Hash and dedup content_hash = self.make_content_hash(content) if await self.is_duplicate(content_hash, collection): return {"status": "duplicate", "hash": content_hash} # 2. Quality filter quality = self.score_quality(content, metadata) if quality < MIN_QUALITY_SCORE: return {"status": "skipped", "reason": "low_quality", "score": quality} # 3. Entity extraction entities = self.extract_entities(content) metadata.update( { "entities": entities, "quality_score": quality, "content_hash": content_hash, "ingested_at": datetime.now(UTC).isoformat(), } ) # 4. Rate limit check if not self._check_rate(collection): return {"status": "rate_limited", "collection": collection} # 5. Ingest into RAG try: payload = { "collection": collection, "content": content, "metadata": metadata, } if doc_id: payload["doc_id"] = doc_id resp = await self.client.post(f"{RAG_API}/ingest", json=payload, timeout=30) self._record_ingest(collection) if resp.status_code == 200: result = resp.json() # Track in dedup set asyncio.create_task(self._mark_ingested(content_hash, collection)) return { "status": "ingested", "id": result.get("id"), "quality": quality, "entities": entities, } else: return {"status": "error", "code": resp.status_code, "detail": resp.text[:200]} except Exception as e: return {"status": "error", "detail": str(e)[:200]} async def _mark_ingested(self, content_hash: str, collection: str): """Mark content hash in Redis dedup set.""" try: ttl = HASH_TTL.get(collection, HASH_TTL["default"]) await self.client.post( f"{RAG_API}/dedup-mark", json={"hash": content_hash, "collection": collection, "ttl": ttl}, timeout=5, ) except Exception: pass async def ingest_batch(self, docs: list[dict]) -> dict[str, int]: """Ingest multiple documents with rate limiting.""" stats = {"ingested": 0, "duplicate": 0, "skipped": 0, "errors": 0} for doc in docs: collection = doc.get("collection", "general") content = doc.get("content", "") metadata = doc.get("metadata", {}) doc_id = doc.get("doc_id") result = await self.ingest(collection, content, metadata, doc_id) status = result.get("status", "error") if status == "ingested": stats["ingested"] += 1 elif status == "duplicate": stats["duplicate"] += 1 elif status == "skipped": stats["skipped"] += 1 else: stats["errors"] += 1 # Small delay between docs to avoid overwhelming embedder await asyncio.sleep(0.05) return stats # ────────────────────────────────────────────────────────────── # Feed Sources - Pull Functions # ────────────────────────────────────────────────────────────── class FeedSources: """All data sources the firehose can pull from.""" def __init__(self, client: httpx.AsyncClient): self.client = client # ── Hourly: News ── async def pull_news_rss(self) -> list[dict]: """Pull latest news from DataBus news provider (200+ RSS feeds).""" try: resp = await self.client.get( "http://localhost:8000/api/v1/databus/fetch/news", params={"limit": 30}, timeout=30 ) if resp.status_code != 200: return [] data = resp.json() articles = data.get("articles", data.get("data", [])) docs = [] for article in (articles if isinstance(articles, list) else [])[:20]: title = article.get("title", "") desc = article.get("description", article.get("summary", "")) if not title: continue content = f"News: {title}. {desc}" docs.append( { "collection": "news_articles", "content": content[:2000], "metadata": { "source": article.get("source", "news_rss"), "url": article.get("url", ""), "published": article.get("published_at", article.get("date", "")), "category": article.get("category", "crypto"), "title": title, }, } ) return docs except Exception as e: logger.warning(f"News RSS pull failed: {e}") return [] async def pull_ct_rundown(self) -> list[dict]: """Pull CT Rundown stories.""" try: resp = await self.client.get( "http://localhost:8000/api/v1/databus/fetch/ct_rundown", params={"limit": 10}, timeout=30, ) if resp.status_code != 200: return [] data = resp.json() stories = data.get("stories", data.get("data", [])) docs = [] for story in (stories if isinstance(stories, list) else [])[:5]: content = f"CT: {story.get('title', '')} - {story.get('summary', '')}" docs.append( { "collection": "news_articles", "content": content[:1500], "metadata": { "source": "ct_rundown", "category": "crypto_twitter", "handle": story.get("handle", ""), "engagement": story.get("engagement", 0), }, } ) return docs except Exception as e: logger.warning(f"CT rundown pull failed: {e}") return [] async def pull_social_sentiment(self) -> list[dict]: """Pull social sentiment and scam alerts.""" docs = [] try: # Scam monitor resp = await self.client.get("http://localhost:8000/api/v1/databus/fetch/scam_monitor", timeout=20) if resp.status_code == 200: data = resp.json() alerts = data.get("alerts", data.get("data", [])) for alert in (alerts if isinstance(alerts, list) else [])[:10]: docs.append( { "collection": "social_sentiment", "content": f"Scam alert: {alert.get('title', '')} - {alert.get('description', '')}", "metadata": { "source": "scam_monitor", "severity": alert.get("severity", "medium"), "token": alert.get("token_address", ""), "chain": alert.get("chain", ""), }, } ) # Social metrics resp2 = await self.client.get("http://localhost:8000/api/v1/databus/fetch/social_metrics", timeout=20) if resp2.status_code == 200: data2 = resp2.json() metrics = data2.get("metrics", data2.get("data", {})) if metrics: docs.append( { "collection": "social_sentiment", "content": f"Social metrics: Sentiment {metrics.get('sentiment', '?')}, " f"Trending: {metrics.get('trending_topics', [])}", "metadata": {"source": "social_metrics", "type": "daily_summary"}, } ) except Exception as e: logger.warning(f"Social pull failed: {e}") return docs # ── Daily: Scam Databases ── async def pull_etherscan_labels(self) -> list[dict]: """Pull etherscan labeled addresses (via existing CSV).""" docs = [] csv_path = os.path.join(os.path.dirname(__file__), "..", "data", "etherscan_phish_hack.csv") if not os.path.exists(csv_path): return docs import csv try: with open(csv_path, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) for row in rows: addr = row.get("address", "").strip() if not addr: continue content = ( f"Etherscan label: {addr} on {row.get('chain', 'Ethereum')}. " f"Tag: {row.get('name_tag', '')}. Type: {row.get('label_type', 'scam')}." ) docs.append( { "collection": "known_scams", "content": content, "metadata": { "address": addr.lower(), "chain": (row.get("chain", "Ethereum") or "Ethereum").lower(), "label_type": row.get("label_type", "scam"), "source": "etherscan", "severity": "critical" if "phish" in row.get("label_type", "").lower() else "high", }, } ) except Exception as e: logger.warning(f"Etherscan pull failed: {e}") return docs async def pull_solana_scams(self) -> list[dict]: """Pull Solana token registry flagged tokens.""" docs = [] try: resp = await self.client.get( "https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/solana.tokenlist.json", timeout=30, ) if resp.status_code == 200: data = resp.json() for token in data.get("tokens", []): tags = [t.lower() for t in token.get("tags", [])] if any(kw in str(tags) for kw in ["scam", "spam", "fake"]): docs.append( { "collection": "known_scams", "content": f"Solana scam token: {token.get('name', '')} ({token.get('symbol', '')}) " f"at {token.get('address', '')}. Tags: {tags}.", "metadata": { "address": token.get("address", ""), "name": token.get("name", ""), "symbol": token.get("symbol", ""), "chain": "solana", "source": "solana_token_registry", "tags": tags, "severity": "high", }, } ) except Exception as e: logger.warning(f"Solana pull failed: {e}") return docs async def pull_prediction_markets(self) -> list[dict]: """Pull Polymarket prediction data for market intel.""" docs = [] try: resp = await self.client.get("http://localhost:8000/api/v1/databus/fetch/prediction_markets", timeout=20) if resp.status_code == 200: data = resp.json() markets = data.get("markets", data.get("data", [])) for m in (markets if isinstance(markets, list) else [])[:10]: docs.append( { "collection": "market_intel", "content": f"Prediction market: {m.get('question', '')} - " f"YES: {m.get('yes_price', '?')} NO: {m.get('no_price', '?')}", "metadata": {"source": "polymarket", "type": "prediction"}, } ) except Exception as e: logger.warning(f"Prediction market pull failed: {e}") return docs # ── Weekly: Pattern Extraction ── async def extract_scam_patterns(self) -> list[dict]: """Extract common patterns from confirmed scam documents.""" docs = [] try: # Search for confirmed scams resp = await self.client.get( f"{RAG_API}/search", params={ "q": "rug pull honeypot scam confirmed", "collection": "known_scams", "limit": 50, }, timeout=30, ) if resp.status_code == 200: data = resp.json() results = data.get("results", []) if len(results) >= 10: # Create a meta-pattern document content_parts = [] for r in results[:20]: c = r.get("content", "")[:200] if c: content_parts.append(c) combined = "Common scam patterns observed: " + " | ".join(content_parts) docs.append( { "collection": "scam_patterns", "content": combined[:5000], "metadata": { "source": "pattern_extraction", "pattern_count": len(results), "extracted_at": datetime.now(UTC).isoformat(), }, } ) except Exception as e: logger.warning(f"Pattern extraction failed: {e}") return docs # ────────────────────────────────────────────────────────────── # Firehose Engine # ────────────────────────────────────────────────────────────── class FirehoseEngine: """The central continuous ingestion engine.""" def __init__(self): self._running = False self._task: asyncio.Task | None = None self._client: httpx.AsyncClient | None = None self._pipeline: IngestionPipeline | None = None self._feeds: FeedSources | None = None self._sources: list[FeedSource] = [] self._start_time: float = 0 self.stats = FirehoseStats() self._lock = asyncio.Lock() async def start(self): """Start the firehose engine.""" if self._running: logger.info("Firehose already running") return self._client = httpx.AsyncClient(timeout=30, limits=httpx.Limits(max_connections=20)) self._pipeline = IngestionPipeline(self._client) self._feeds = FeedSources(self._client) self._start_time = time.monotonic() # Define all feed sources with cadences self._sources = [ # ── 15-minute cadence: High urgency ── FeedSource( "ct_rundown", "news_articles", 900, self._feeds.pull_ct_rundown, True, "CT Rundown stories", ), FeedSource( "social_sentiment", "social_sentiment", 900, self._feeds.pull_social_sentiment, True, "Social sentiment and scam alerts", ), # ── 1-hour cadence: News ── FeedSource( "news_rss", "news_articles", 3600, self._feeds.pull_news_rss, True, "200+ RSS crypto news feeds", ), # ── 6-hour cadence: Market data ── FeedSource( "prediction_markets", "market_intel", 21600, self._feeds.pull_prediction_markets, True, "Polymarket predictions", ), # ── 24-hour cadence: Scam databases ── FeedSource( "etherscan_labels", "known_scams", 86400, self._feeds.pull_etherscan_labels, True, "Etherscan phish/hack labeled addresses", ), FeedSource( "solana_scams", "known_scams", 86400, self._feeds.pull_solana_scams, True, "Solana token registry flagged tokens", ), # ── 72-hour cadence: Pattern extraction ── FeedSource( "scam_patterns", "scam_patterns", 259200, self._feeds.extract_scam_patterns, True, "Extract common patterns from confirmed scams", ), ] self.stats.total_sources = len(self._sources) self._running = True self._task = asyncio.create_task(self._run_loop()) logger.info(f"Firehose started with {len(self._sources)} sources") async def stop(self): """Stop the firehose engine.""" self._running = False if self._task: self._task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._task if self._client: await self._client.aclose() logger.info("Firehose stopped") async def _run_loop(self): """Main firehose loop - checks sources and runs those due.""" logger.info("Firehose loop started") while self._running: now = time.monotonic() for source in self._sources: if not source.enabled: continue if now - source.last_run < source.cadence_seconds: continue # Run this source source.last_run = now source.runs += 1 self.stats.last_activity = now try: logger.debug(f"Firehose: pulling {source.name}") docs = await source.fetch_fn() if docs: stats = await self._pipeline.ingest_batch(docs) source.docs_ingested += stats["ingested"] source.docs_skipped += stats["duplicate"] + stats["skipped"] source.errors += stats["errors"] self.stats.total_ingested += stats["ingested"] self.stats.total_skipped += stats["duplicate"] + stats["skipped"] self.stats.total_errors += stats["errors"] logger.info( f"Firehose {source.name}: +{stats['ingested']} new, " f"{stats['duplicate']} dup, {stats['skipped']} skip, " f"{stats['errors']} err " f"(total: {source.docs_ingested})" ) except Exception as e: logger.error(f"Firehose {source.name} failed: {e}") source.errors += 1 self.stats.total_errors += 1 # Update source stats async with self._lock: self.stats.sources = { s.name: { "collection": s.collection, "cadence_min": s.cadence_seconds // 60, "runs": s.runs, "ingested": s.docs_ingested, "skipped": s.docs_skipped, "errors": s.errors, "last_run_ago": int(now - s.last_run) if s.last_run else -1, } for s in self._sources } # Check every 30 seconds await asyncio.sleep(30) async def feed_now(self, source_name: str) -> dict: """Manually trigger a specific feed source immediately.""" for source in self._sources: if source.name == source_name: try: docs = await source.fetch_fn() stats = await self._pipeline.ingest_batch(docs) source.runs += 1 source.docs_ingested += stats["ingested"] source.last_run = time.monotonic() self.stats.total_ingested += stats["ingested"] return {"source": source_name, "docs_fetched": len(docs), **stats} except Exception as e: return {"source": source_name, "error": str(e)} return {"error": f"Source '{source_name}' not found"} def get_status(self) -> dict: """Get current firehose status.""" return { "running": self._running, "uptime_seconds": int(time.monotonic() - self._start_time) if self._start_time else 0, "total_sources": self.stats.total_sources, "total_ingested": self.stats.total_ingested, "total_skipped": self.stats.total_skipped, "total_errors": self.stats.total_errors, "sources": self.stats.sources, } # ────────────────────────────────────────────────────────────── # Singleton # ────────────────────────────────────────────────────────────── _firehose: FirehoseEngine | None = None def get_firehose() -> FirehoseEngine: global _firehose if _firehose is None: _firehose = FirehoseEngine() return _firehose