- 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>
155 lines
5.6 KiB
Python
155 lines
5.6 KiB
Python
"""T27B HTTP routes - CatalogService endpoints.
|
|
|
|
Per v4.0 §T27. The thin HTTP layer over app.catalog.service.CatalogService.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.catalog.models import Chain
|
|
from app.catalog.service import get_catalog
|
|
|
|
router = APIRouter(prefix="/api/v1/catalog", tags=["catalog"])
|
|
|
|
|
|
# ── Request models ───────────────────────────────────────────────
|
|
class RagIngestRequest(BaseModel):
|
|
content: str = Field(..., min_length=1)
|
|
collection: str = "scam_intel"
|
|
doc_id: str | None = None
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class RagSearchRequest(BaseModel):
|
|
query: str = Field(..., min_length=1)
|
|
collection: str = "scam_intel"
|
|
top_k: int = Field(default=5, ge=1, le=50)
|
|
|
|
|
|
class ResolveEntityRequest(BaseModel):
|
|
wallet_id: str
|
|
max_chains: int = Field(default=5, ge=1, le=20)
|
|
|
|
|
|
class FindRiskyTokensRequest(BaseModel):
|
|
min_rug_count: int = Field(default=1, ge=1)
|
|
chain: str | None = None
|
|
limit: int = Field(default=50, ge=1, le=200)
|
|
|
|
|
|
class AttachRagRequest(BaseModel):
|
|
chain: str
|
|
address: str
|
|
qdrant_point_id: str
|
|
|
|
|
|
# ── Health / introspection ───────────────────────────────────────
|
|
@router.get("/stats")
|
|
async def stats() -> dict:
|
|
"""Catalog stats: which stores are reachable + entity counts."""
|
|
return await get_catalog().stats()
|
|
|
|
|
|
@router.get("/probe")
|
|
async def probe() -> dict:
|
|
"""Probe which stores are reachable from this container."""
|
|
return await get_catalog().probe_stores()
|
|
|
|
|
|
# ── Token endpoints ─────────────────────────────────────────────
|
|
@router.get("/tokens/{chain}/{address}")
|
|
async def get_token(chain: str, address: str) -> dict:
|
|
"""Get a token by chain+address. Returns full Token model + provenance."""
|
|
try:
|
|
c = Chain(chain)
|
|
except ValueError:
|
|
raise HTTPException(400, f"unknown chain: {chain}") from None
|
|
tok = await get_catalog().get_token(c, address)
|
|
if not tok:
|
|
raise HTTPException(404, "token not found")
|
|
return tok.model_dump(mode="json")
|
|
|
|
|
|
@router.get("/tokens/{chain}/{address}/risk")
|
|
async def get_token_risk(chain: str, address: str) -> dict:
|
|
"""Recipe 3 - Real-time risk score. Composes Redis + Postgres + Neo4j."""
|
|
try:
|
|
c = Chain(chain)
|
|
except ValueError:
|
|
raise HTTPException(400, f"unknown chain: {chain}") from None
|
|
return await get_catalog().get_token_risk(c, address)
|
|
|
|
|
|
@router.post("/tokens/risky-by-deployer")
|
|
async def risky_tokens(req: FindRiskyTokensRequest) -> dict:
|
|
"""Recipe 1 - Find tokens deployed by wallets with rug history."""
|
|
chain_enum = None
|
|
if req.chain:
|
|
try:
|
|
chain_enum = Chain(req.chain)
|
|
except ValueError:
|
|
raise HTTPException(400, f"unknown chain: {req.chain}") from None
|
|
tokens = await get_catalog().find_tokens_by_deployer_history(
|
|
min_rug_count=req.min_rug_count, chain=chain_enum, limit=req.limit
|
|
)
|
|
return {
|
|
"count": len(tokens),
|
|
"tokens": [t.model_dump(mode="json") for t in tokens],
|
|
}
|
|
|
|
|
|
# ── Wallet endpoints ─────────────────────────────────────────────
|
|
@router.get("/wallets/{chain}/{address}")
|
|
async def get_wallet(chain: str, address: str) -> dict:
|
|
try:
|
|
c = Chain(chain)
|
|
except ValueError:
|
|
raise HTTPException(400, f"unknown chain: {chain}") from None
|
|
w = await get_catalog().get_wallet(c, address)
|
|
if not w:
|
|
raise HTTPException(404, "wallet not found")
|
|
return w.model_dump(mode="json")
|
|
|
|
|
|
# ── Entity resolution (Recipe 5) ────────────────────────────────
|
|
@router.post("/entities/resolve")
|
|
async def resolve_entity(req: ResolveEntityRequest) -> dict:
|
|
"""Cross-chain entity resolution via Neo4j Cypher."""
|
|
return await get_catalog().resolve_entity(req.wallet_id, req.max_chains)
|
|
|
|
|
|
# ── RAG bridge endpoints ────────────────────────────────────────
|
|
@router.post("/rag/search")
|
|
async def rag_search(req: RagSearchRequest) -> dict:
|
|
"""Search the RAG system. Returns ranked hits with RRF scores."""
|
|
hits = await get_catalog().rag_search(
|
|
query=req.query, collection=req.collection, top_k=req.top_k
|
|
)
|
|
return {"count": len(hits), "hits": hits}
|
|
|
|
|
|
@router.post("/rag/ingest")
|
|
async def rag_ingest(req: RagIngestRequest) -> dict:
|
|
"""Ingest content into RAG. Returns qdrant_point_id for cross-store linking."""
|
|
return await get_catalog().rag_ingest(
|
|
content=req.content,
|
|
collection=req.collection,
|
|
doc_id=req.doc_id,
|
|
metadata=req.metadata,
|
|
)
|
|
|
|
|
|
@router.post("/tokens/{chain}/{address}/attach-rag")
|
|
async def attach_rag(chain: str, address: str, req: AttachRagRequest) -> dict:
|
|
"""Link an existing RAG embedding (Qdrant point) to a Token row."""
|
|
try:
|
|
c = Chain(chain)
|
|
except ValueError:
|
|
raise HTTPException(400, f"unknown chain: {chain}") from None
|
|
ok = await get_catalog().attach_rag_to_token(c, address, req.qdrant_point_id)
|
|
if not ok:
|
|
raise HTTPException(404, "token not found or update failed")
|
|
return {"ok": True, "chain": chain, "address": address, "rag_embedding_id": req.qdrant_point_id}
|