- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
877 lines
35 KiB
Python
877 lines
35 KiB
Python
"""
|
|
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
|