"""T27 CatalogService - unified read/write API for RMI. Per v4.0 §T27. The CatalogService is the ONLY sanctioned way to read or write catalog data. Domain facades call this; they never touch stores directly. Architecture: - Lazy store clients (init on first use, retry on failure) - Graceful degradation: unreachable stores return None/empty, not crash - Redis cache layer (always available since rmi-redis is up) - Cross-store ID conventions from app.catalog.models Recipe coverage: Recipe 1: find_tokens_by_deployer_history (Postgres + Neo4j) Recipe 2: find_similar_tokens (Qdrant + Postgres) Recipe 3: get_token_risk (Redis + Postgres + Neo4j) Recipe 4: news_price_correlation (Postgres, basic v1) Recipe 5: resolve_entity (Neo4j Cypher) """ from __future__ import annotations import asyncio import contextlib import json import logging import os import time from typing import Any import httpx from app.catalog.models import ( COLLECTIONS, Chain, Token, Wallet, utcnow, ) from app.rag.engine import ingest_document as rag_ingest_document from app.rag.engine import three_pillar_search as rag_three_pillar_search log = logging.getLogger(__name__) # ── Connection config (from env, with sensible defaults to netcup IPs) ── # These defaults point to the actual IPs on netcup rmi_network. # Override via env vars in docker-compose.yml for portability. DEFAULT_CONFIG: dict[str, Any] = { "redis": { "host": os.getenv("REDIS_HOST", "rmi-redis"), "port": int(os.getenv("REDIS_PORT", "6379")), "password": os.getenv("REDIS_PASSWORD", "RMI_PROD_REDIS_2026"), "db": int(os.getenv("REDIS_DB", "0")), }, "postgres": { "host": os.getenv("POSTGRES_HOST", "rmi-postgres"), "port": int(os.getenv("POSTGRES_PORT", "5432")), "user": os.getenv("POSTGRES_USER", "rmi"), "password": os.getenv("POSTGRES_PASSWORD", ""), "database": os.getenv("POSTGRES_DB", "rmi"), }, "neo4j": { "uri": os.getenv("NEO4J_URI", "bolt://rmi-neo4j:7687"), "user": os.getenv("NEO4J_USER", "neo4j"), "password": os.getenv("NEO4J_PASSWORD", ""), }, "qdrant": { "url": os.getenv("QDRANT_URL", "http://rmi-qdrant:6333"), "api_key": os.getenv("QDRANT_API_KEY", ""), }, "minio": { "endpoint": os.getenv("MINIO_ENDPOINT", "rmi-minio:9000"), "access_key": os.getenv("MINIO_ACCESS_KEY", ""), "secret_key": os.getenv("MINIO_SECRET_KEY", ""), }, } class StoreHealth: """Tracks which stores are reachable. Updated on each probe.""" def __init__(self) -> None: self.redis: bool = False self.postgres: bool = False self.neo4j: bool = False self.qdrant: bool = False self.minio: bool = False self.last_checked: float = 0.0 class CatalogService: """Unified read/write API for RMI data. Use `get_catalog()` to get the singleton instance. All methods are async and graceful-degrade - if a store is unreachable, the method returns None or an empty result with a logged warning. """ def __init__(self, config: dict[str, Any] | None = None) -> None: self.config = config or DEFAULT_CONFIG self._health = StoreHealth() self._redis = None self._pg_pool: Any = None self._neo_driver: Any = None self._qdrant: Any = None # httpx.AsyncClient self._init_lock = asyncio.Lock() # ── Store init (lazy) ──────────────────────────────────────────── async def _init_stores(self) -> None: async with self._init_lock: if self._redis is not None: return # already init # Redis (always try first - used for everything) try: import redis.asyncio as aioredis cfg = self.config["redis"] self._redis = aioredis.Redis( host=cfg["host"], port=cfg["port"], password=cfg["password"], db=cfg["db"], decode_responses=True, ) await self._redis.ping() self._health.redis = True log.info("catalog_redis_ok") except Exception as e: log.warning("catalog_redis_fail: %s", e) self._health.redis = False # Postgres try: import asyncpg cfg = self.config["postgres"] if cfg["password"]: self._pg_pool = await asyncpg.create_pool( host=cfg["host"], port=cfg["port"], user=cfg["user"], password=cfg["password"], database=cfg["database"], min_size=1, max_size=8, command_timeout=10, ) self._health.postgres = True log.info("catalog_postgres_ok") except Exception as e: log.warning("catalog_postgres_fail: %s", e) self._health.postgres = False # Neo4j (NEO4J_AUTH=none means no password) try: from neo4j import GraphDatabase cfg = self.config["neo4j"] auth = ( (cfg["user"], cfg["password"]) if cfg["password"] else None ) self._neo_driver = GraphDatabase.driver(cfg["uri"], auth=auth) with self._neo_driver.session() as s: s.run("RETURN 1").consume() self._health.neo4j = True log.info("catalog_neo4j_ok") except Exception as e: log.warning("catalog_neo4j_fail: %s", e) self._health.neo4j = False # MinIO (HTTP health probe) try: import socket host, port = self.config["minio"]["endpoint"].rsplit(":", 1) s = socket.socket() s.settimeout(3) s.connect((host, int(port))) s.close() self._health.minio = True log.info("catalog_minio_ok") except Exception as e: log.warning("catalog_minio_fail: %s", e) self._health.minio = False # Qdrant (HTTP) try: cfg = self.config["qdrant"] headers = {"api-key": cfg["api_key"]} if cfg["api_key"] else {} self._qdrant = httpx.AsyncClient( base_url=cfg["url"], headers=headers, timeout=5.0 ) r = await self._qdrant.get("/collections") if r.status_code == 200: self._health.qdrant = True log.info("catalog_qdrant_ok") else: log.warning("catalog_qdrant_http_%d", r.status_code) except Exception as e: log.warning("catalog_qdrant_fail: %s", e) self._health.qdrant = False self._health.last_checked = time.time() # ── Health probe ──────────────────────────────────────────────── async def probe_stores(self) -> dict[str, bool]: await self._init_stores() return { "redis": self._health.redis, "postgres": self._health.postgres, "neo4j": self._health.neo4j, "qdrant": self._health.qdrant, "minio": self._health.minio, } # ── Tokens (Postgres primary + Redis cache) ───────────────────── async def get_token(self, chain: Chain, address: str) -> Token | None: await self._init_stores() cache_key = f"catalog:token:{chain.value}:{address}" if self._health.redis: try: cached = await self._redis.get(cache_key) if cached: return Token.model_validate_json(cached) except Exception as e: log.debug("token_cache_get_fail: %s", e) if not self._health.postgres: return None try: async with self._pg_pool.acquire() as conn: row = await conn.fetchrow( "SELECT * FROM tokens WHERE chain=$1 AND address=$2", chain.value, address, ) if not row: return None token = Token(**dict(row)) if self._health.redis: try: await self._redis.setex(cache_key, 3600, token.model_dump_json()) except Exception as e: log.debug("token_cache_set_fail: %s", e) return token except Exception as e: log.warning("token_get_fail: %s", e) return None async def save_token(self, token: Token) -> bool: await self._init_stores() if not self._health.postgres: log.warning("token_save_no_postgres") return False try: async with self._pg_pool.acquire() as conn: await conn.execute( """ INSERT INTO tokens ( token_id, chain, address, symbol, name, decimals, deployer_wallet_id, deployed_at, initial_supply, current_supply, is_honeypot, is_mintable, is_proxy, tax_buy_bps, tax_sell_bps, risk_tier, risk_score, risk_factors, rag_embedding_id ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, $14,$15,$16,$17,$18,$19) ON CONFLICT (token_id) DO UPDATE SET symbol=EXCLUDED.symbol, name=EXCLUDED.name, current_supply=EXCLUDED.current_supply, is_honeypot=EXCLUDED.is_honeypot, is_mintable=EXCLUDED.is_mintable, risk_tier=EXCLUDED.risk_tier, risk_score=EXCLUDED.risk_score, risk_factors=EXCLUDED.risk_factors """, token.token_id, token.chain.value, token.address, token.symbol, token.name, token.decimals, token.deployer_wallet_id, token.deployed_at, token.initial_supply, token.current_supply, token.is_honeypot, token.is_mintable, token.is_proxy, token.tax_buy_bps, token.tax_sell_bps, token.risk_tier.value if token.risk_tier else None, token.risk_score, token.risk_factors, token.rag_embedding_id, ) # Invalidate cache if self._health.redis: with contextlib.suppress(Exception): await self._redis.delete( f"catalog:token:{token.chain.value}:{token.address}" ) return True except Exception as e: log.warning("token_save_fail: %s", e) return False # ── Wallets (Neo4j primary) ────────────────────────────────────── async def get_wallet(self, chain: Chain, address: str) -> Wallet | None: await self._init_stores() if not self._health.neo4j: return None wallet_id = f"{chain.value}:{address}" try: with self._neo_driver.session() as s: result = s.run( "MATCH (w:Wallet {wallet_id: $id}) RETURN w", id=wallet_id, ).single() if not result: return None node = dict(result["w"]) # Normalize datetime strings for k in ("first_seen", "last_seen"): if isinstance(node.get(k), str): from datetime import datetime with contextlib.suppress(Exception): node[k] = datetime.fromisoformat(node[k].replace("Z", "+00:00")) return Wallet(**node) except Exception as e: log.warning("wallet_get_fail: %s", e) return None async def save_wallet(self, wallet: Wallet) -> bool: await self._init_stores() if not self._health.neo4j: return False try: with self._neo_driver.session() as s: s.run( """ MERGE (w:Wallet {wallet_id: $wallet_id}) SET w.chain=$chain, w.address=$address, w.first_seen=$first_seen, w.last_seen=$last_seen, w.tx_count=$tx_count, w.total_volume_usd=$total_volume_usd, w.is_deployer=$is_deployer, w.is_known_exchange=$is_known_exchange, w.is_suspicious=$is_suspicious, w.reputation_score=$reputation_score """, wallet_id=wallet.wallet_id, chain=wallet.chain.value, address=wallet.address, first_seen=wallet.first_seen.isoformat(), last_seen=wallet.last_seen.isoformat(), tx_count=wallet.tx_count, total_volume_usd=wallet.total_volume_usd, is_deployer=wallet.is_deployer, is_known_exchange=wallet.is_known_exchange, is_suspicious=wallet.is_suspicious, reputation_score=wallet.reputation_score, ) return True except Exception as e: log.warning("wallet_save_fail: %s", e) return False # ── Recipe 1: find tokens by deployer history ─────────────────── async def find_tokens_by_deployer_history( self, min_rug_count: int = 1, chain: Chain | None = None, limit: int = 50 ) -> list[Token]: """Find tokens deployed by wallets that have rug-pulled before. Cross-store: Neo4j for the wallet filter, Postgres for the tokens. """ await self._init_stores() if not (self._health.neo4j and self._health.postgres): log.warning("recipe1_stores_unavailable") return [] try: # Step 1: Neo4j - find wallets with rug_count >= min_rug_count with self._neo_driver.session() as s: wallets = [ r["w.wallet_id"] for r in s.run( "MATCH (w:Deployer) WHERE w.rug_count >= $min " "RETURN w.wallet_id LIMIT 200", min=min_rug_count, ) ] if not wallets: return [] # Step 2: Postgres - find tokens deployed by those wallets async with self._pg_pool.acquire() as conn: query = ( "SELECT * FROM tokens WHERE deployer_wallet_id = ANY($1::text[]) " ) params: list[Any] = [wallets] if chain: query += "AND chain=$2 " params.append(chain.value) query += "ORDER BY deployed_at DESC LIMIT $3" params.append(limit) rows = await conn.fetch(query, *params) return [Token(**dict(r)) for r in rows] except Exception as e: log.warning("recipe1_fail: %s", e) return [] # ── Recipe 3: get_token_risk (3-store composition) ────────────── async def get_token_risk( self, chain: Chain, address: str ) -> dict[str, Any]: """Real-time risk score - composes Redis cache + Postgres + Neo4j. Returns dict (not Token) because it's a cross-store projection. Cache TTL 60s. """ await self._init_stores() cache_key = f"catalog:risk:{chain.value}:{address}" if self._health.redis: try: cached = await self._redis.get(cache_key) if cached: return json.loads(cached) except Exception: pass token = await self.get_token(chain, address) deployer_reputation: int | None = None if token and token.deployer_wallet_id and self._health.neo4j: try: with self._neo_driver.session() as s: r = s.run( "MATCH (d:Deployer {wallet_id: $id}) " "RETURN d.reputation_score AS rep, d.rug_count AS rugs", id=token.deployer_wallet_id, ).single() if r: deployer_reputation = r["rep"] except Exception as e: log.debug("risk_neo4j_fail: %s", e) token_score = token.risk_score if token and token.risk_score is not None else 50 if deployer_reputation is not None: score = int(0.6 * token_score + 0.4 * (100 - deployer_reputation)) else: score = token_score score = max(0, min(100, score)) if score < 25: tier = "low" elif score < 50: tier = "medium" elif score < 75: tier = "high" else: tier = "critical" result: dict[str, Any] = { "score": score, "tier": tier, "factors": token.risk_factors if token else [], "token": token.model_dump() if token else None, "deployer_reputation": deployer_reputation, "fetched_at": utcnow().isoformat(), } if self._health.redis: with contextlib.suppress(Exception): await self._redis.setex(cache_key, 60, json.dumps(result, default=str)) return result # ── Recipe 5: resolve_entity (Neo4j Cypher) ───────────────────── async def resolve_entity( self, wallet_id: str, max_chains: int = 5 ) -> dict[str, Any]: """Cross-chain entity resolution via Neo4j. Uses the SAME_AS / FUNDED_BY_SAME / CLONE_OF / BEHAVIORAL_MATCH edges defined in v4.0 §T32 with path-weighted confidence. """ await self._init_stores() if not self._health.neo4j: return {"entity_id": None, "wallets": [], "note": "neo4j unavailable"} try: with self._neo_driver.session() as s: result = s.run( """ MATCH (start:Wallet {wallet_id: $wallet_id}) OPTIONAL MATCH path = (start)-[:SAME_AS|FUNDED_BY_SAME|CLONE_OF|BEHAVIORAL_MATCH*1..3]-(other:Wallet) WHERE start <> other WITH start, other, path, reduce(conf = 1.0, r IN relationships(path) | conf * r.confidence) AS path_conf ORDER BY path_conf DESC LIMIT $limit RETURN start.entity_id AS entity_id, collect({ wallet_id: other.wallet_id, chain: other.chain, address: other.address, confidence: path_conf }) AS cross_chain_wallets """, wallet_id=wallet_id, limit=max_chains, ).single() if not result: return {"entity_id": None, "wallets": [], "note": "no edges"} wallets = [w for w in (result["cross_chain_wallets"] or []) if w.get("wallet_id")] return { "entity_id": result["entity_id"], "wallets": wallets, "note": f"{len(wallets)} cross-chain matches", } except Exception as e: log.warning("resolve_entity_fail: %s", e) return {"entity_id": None, "wallets": [], "error": str(e)} # ── RAG bridge: wire app/rag/engine.py into the catalog ───────── async def rag_search( self, query: str, collection: str = "scam_intel", top_k: int = 5 ) -> list[dict]: """Search the RAG system. Returns raw hits (catalog-agnostic). Use this when you want RAG results as raw search hits. Use get_token_risk / etc. when you want catalog-typed results. """ try: return await rag_three_pillar_search( query=query, collection=collection, top_k=top_k ) except Exception as e: log.warning("rag_search_fail: %s", e) return [] async def rag_ingest( self, content: str, collection: str = "scam_intel", doc_id: str | None = None, metadata: dict | None = None, ) -> dict: """Ingest a fact into RAG and (if it's about a token) link to Token.rag_embedding_id. Returns the RAG ingest result plus the Qdrant point_id for cross-store ref. """ from uuid import uuid4 doc_id = doc_id or f"finding:{uuid4().hex[:12]}" try: r = await rag_ingest_document( collection=collection, doc_id=doc_id, content=content, metadata=metadata or {}, ) r["qdrant_point_id"] = doc_id # Engine returns the same return r except Exception as e: log.warning("rag_ingest_fail: %s", e) return {"status": "failed", "error": str(e)} async def attach_rag_to_token( self, chain: Chain, address: str, qdrant_point_id: str ) -> bool: """Link an existing Qdrant point to a Token row as rag_embedding_id.""" token = await self.get_token(chain, address) if not token: return False token.rag_embedding_id = qdrant_point_id return await self.save_token(token) # ── Stats / introspection ──────────────────────────────────────── async def stats(self) -> dict[str, Any]: await self._init_stores() out: dict[str, Any] = { "stores": await self.probe_stores(), "collections": COLLECTIONS, } if self._health.postgres: try: async with self._pg_pool.acquire() as conn: out["tokens"] = await conn.fetchval("SELECT COUNT(*) FROM tokens") or 0 out["alerts"] = await conn.fetchval("SELECT COUNT(*) FROM alerts") or 0 except Exception as e: out["postgres_error"] = str(e) if self._health.neo4j: try: with self._neo_driver.session() as s: out["wallets"] = s.run("MATCH (w:Wallet) RETURN COUNT(w) AS c").single()["c"] or 0 out["deployers"] = s.run("MATCH (d:Deployer) RETURN COUNT(d) AS c").single()["c"] or 0 except Exception as e: out["neo4j_error"] = str(e) if self._health.qdrant: try: cols = await self._qdrant.get("/collections") if cols.status_code == 200: out["qdrant_collections"] = [ c["name"] for c in cols.json().get("result", {}).get("collections", []) ] except Exception as e: out["qdrant_error"] = str(e) return out # ── Singleton accessor ────────────────────────────────────────────── _catalog: CatalogService | None = None def get_catalog() -> CatalogService: """Get the global CatalogService. Lazy-init on first call.""" global _catalog if _catalog is None: _catalog = CatalogService() return _catalog