rmi-backend/app/_archive/legacy_2026_07/rag_firehose.py
cryptorugmunch 628c1d2a10
Some checks failed
CI / build (push) Failing after 3s
refactor(rmi-backend,audit): mount Wave 3 + archive 136 dead-code files (P2.3)
PHASE 2.3 (AUDIT-2026-Q3.md):

Task 1 — Wire-in Wave 3 (1 router mounted, 2 deferred):
  - app.routers.unified_scanner_router mounted at /api/v2/scanner/* (2 routes:
    POST /api/v2/scanner/token/scan, POST /api/v2/scanner/wallet/scan).
    Refactored prefix from /api/v2 -> /api/v2/scanner to avoid future conflicts
    with the v1 /api/v1/scanner/ stub.
  - app.routers.unified_wallet_scanner DEFERRED (no router APIRouter attribute;
    library module consumed by unified_scanner_router via get_wallet_scanner()).
  - app.routers.admin_extensions DEFERRED (DORMANT per audit; 25 routes at
    /api/v1/admin/* would shadow /api/v1/admin/alerts_webhook).

Task 2 — Archive 136 dead-code files to app/_archive/legacy_2026_07/:
  - 73 routers in app/routers/ (reach graph showed zero reach into mount.py).
  - 63 flat app/*.py (domain modules never imported by live code).
  - 1 file RESTORED post-archive: app/routers/x402_bridge_health.py (caught by
    tests/unit/test_bridge_health.py which directly imports it; reach graph
    considered tests/ only as transitive reach — to be patched in next cycle).

Forced-LIVE (NOT archived per user directive):
  - app/ai_pipeline_v3.py  (3 importers in audit window, importers themselves DEAD)
  - app/splade_bm25.py       (LIVE via app.rag_service)
  - app/wallet_manager_v2.py (LIVE via x402_enforcement, x402_tools, sweep_all, sweep_now)
  - app/crypto_embeddings.py (NOT in audit ARCHIVE list; heavy import graph)

Verification (forward-import closure from mount.py + main.py + factory.py + lifespan.py):
  - imports = 348 app.* modules
  - reached = 194 files reachable from roots
  - archive set = audit_dead (186) - reached - forced_live (4) - test_live (1) = 136
  - Net delta: 136 files moved, 44,932 LOC reduction, 293->295 active routes (+2 from Wave 3)

pyproject.toml updates:
  - setuptools.packages.find: added exclude for app._archive*
  - ruff.extend-exclude: added "app/_archive/"
  - mypy.exclude: added "app/_archive/"

Smoke test: pytest tests/ — 817 passed, 3 pre-existing failures unchanged
(0 new failures; 0 routes lost; all 4 forced-LIVE files still importable).

Restoration: git mv app/_archive/legacy_2026_07/<name>.py <original-path>
and add the import to app/mount.py ROUTER_MODULES.

Refs: AUDIT-2026-Q3.md /home/dev/pry/rmi-final-deadcode-2026-07-06.md
2026-07-06 20:52:31 +02:00

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