- 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>
480 lines
17 KiB
Python
480 lines
17 KiB
Python
"""
|
|
Human-Facing Tool Catalog API
|
|
================================
|
|
Rich, groupable, searchable catalog for the public web terminal and Telegram bot.
|
|
Every tool from TOOL_PRICES + expanded_mcp_catalog is auto-exposed here.
|
|
Adding a tool anywhere in the system? It's automatically picked up here.
|
|
|
|
Endpoints:
|
|
GET /api/v1/catalog - Full catalog grouped by category + service
|
|
GET /api/v1/catalog/search?q= - Free-text search
|
|
GET /api/v1/catalog/featured - Curated featured tools (5 per category)
|
|
GET /api/v1/catalog/by-trial - Grouped by trial count
|
|
GET /api/v1/catalog/by-price - Grouped by price tier (free/$0.01/$0.05/etc)
|
|
GET /api/v1/catalog/by-chain/{chain} - Tools for a specific chain
|
|
GET /api/v1/catalog/{tool_id} - Single tool with full metadata
|
|
GET /api/v1/catalog/stats - Catalog statistics
|
|
|
|
Trial policy tiers (per tool):
|
|
simple: 5 free trials (sentiment, info, lookup)
|
|
standard: 3 free trials (analysis, security scan)
|
|
premium: 1 free trial (audit, deep scan, codegen)
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from collections import defaultdict
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
|
|
logger = logging.getLogger("human_catalog")
|
|
router = APIRouter(prefix="/api/v1/catalog", tags=["human-catalog"])
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Trial tier mapping
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
# Tiers based on tool value/computational cost
|
|
TRIAL_TIERS = {
|
|
"free": {"trials": 5, "label": "Free", "color": "emerald", "badge": "🆓"},
|
|
"simple": {"trials": 5, "label": "5 free trials", "color": "emerald", "badge": "🎁"},
|
|
"standard": {"trials": 3, "label": "3 free trials", "color": "blue", "badge": "✨"},
|
|
"premium": {"trials": 1, "label": "1 free trial", "color": "purple", "badge": "💎"},
|
|
}
|
|
|
|
PRICE_TIERS = {
|
|
"micro": {"max": 0.01, "label": "Micro", "color": "emerald"},
|
|
"low": {"max": 0.05, "label": "Low", "color": "blue"},
|
|
"mid": {"max": 0.15, "label": "Mid", "color": "amber"},
|
|
"high": {"max": 0.50, "label": "High", "color": "rose"},
|
|
"premium": {"max": 999.0, "label": "Premium", "color": "violet"},
|
|
}
|
|
|
|
# Categories and their complexity (determines trial count)
|
|
CATEGORY_TRIAL_DEFAULTS = {
|
|
# Simple lookup/info - high trial count
|
|
"sentiment": "simple",
|
|
"social": "simple",
|
|
"api": "simple",
|
|
"research": "simple",
|
|
# Standard analysis - medium trial count
|
|
"analysis": "standard",
|
|
"intelligence": "standard",
|
|
"market": "standard",
|
|
"monitoring": "standard",
|
|
# Premium/heavy - low trial count
|
|
"security": "premium",
|
|
"forensics": "premium",
|
|
"premium": "premium",
|
|
"launchpad": "standard",
|
|
"defi": "standard",
|
|
"nft": "standard",
|
|
"bundle": "standard",
|
|
"mcp-external": "simple",
|
|
}
|
|
|
|
|
|
def _classify_trial_tier(tool: dict) -> str:
|
|
"""Determine trial tier from tool metadata."""
|
|
# Honor explicit trialFree in price_usd range
|
|
if tool.get("trialFree") is not None:
|
|
n = int(tool["trialFree"])
|
|
if n >= 5:
|
|
return "simple"
|
|
elif n >= 3:
|
|
return "standard"
|
|
else:
|
|
return "premium"
|
|
|
|
# Fall back to category default
|
|
cat = tool.get("category", "analysis").lower()
|
|
return CATEGORY_TRIAL_DEFAULTS.get(cat, "standard")
|
|
|
|
|
|
def _classify_price_tier(price_usd: float) -> str:
|
|
"""Determine price tier from USD amount."""
|
|
for tier_name, tier_info in PRICE_TIERS.items():
|
|
if price_usd <= tier_info["max"]:
|
|
return tier_name
|
|
return "premium"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Cache - rebuild catalog from sources on demand
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
_catalog_cache: dict[str, Any] = {}
|
|
_catalog_cache_time: float = 0
|
|
CATALOG_CACHE_TTL = 30 # 30 seconds - short enough to pick up new tools quickly
|
|
|
|
|
|
def _get_full_catalog() -> dict[str, Any]:
|
|
"""Pull the live catalog from all sources. Cached for CATALOG_CACHE_TTL."""
|
|
global _catalog_cache, _catalog_cache_time
|
|
|
|
now = time.time()
|
|
if _catalog_cache and (now - _catalog_cache_time) < CATALOG_CACHE_TTL:
|
|
return _catalog_cache
|
|
|
|
# Source 1: x402 enforcement (TOOL_PRICES) - authoritative for our 221 native tools
|
|
try:
|
|
from app.routers.x402_enforcement import TOOL_PRICES
|
|
|
|
dict(TOOL_PRICES)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Source 2: x402_catalog (which merges everything)
|
|
try:
|
|
from app.routers.x402_catalog import get_catalog
|
|
|
|
get_catalog_func = get_catalog
|
|
except ImportError:
|
|
get_catalog_func = None
|
|
|
|
# Source 3: expanded_mcp_catalog (the 25 new tools)
|
|
try:
|
|
from app.services.expanded_mcp_catalog import EXTERNAL_MCP_SERVICES, EXTERNAL_MCP_TOOLS
|
|
|
|
{t["id"] for t in EXTERNAL_MCP_TOOLS}
|
|
external_services = list(EXTERNAL_MCP_SERVICES)
|
|
except ImportError:
|
|
external_services = []
|
|
|
|
# Combine: take the x402_catalog full output and enrich
|
|
if get_catalog_func:
|
|
try:
|
|
merged = get_catalog_func()
|
|
tools_list = list(merged.get("tools", []))
|
|
except Exception:
|
|
tools_list = []
|
|
else:
|
|
tools_list = []
|
|
|
|
# Convert each to human-friendly format
|
|
enriched = []
|
|
for t in tools_list:
|
|
price_usd = float(t.get("priceUsd", 0.01) or 0.01)
|
|
trial_tier = _classify_trial_tier(t)
|
|
price_tier = _classify_price_tier(price_usd)
|
|
|
|
enriched.append(
|
|
{
|
|
"id": t.get("id", ""),
|
|
"name": t.get("name", t.get("id", "?")),
|
|
"description": t.get("description", ""),
|
|
"category": t.get("category", "analysis"),
|
|
"service": t.get("service", "rmi-native"),
|
|
"source": t.get("source", "rmi-native"),
|
|
"chains": t.get("chains", []),
|
|
"price_usd": price_usd,
|
|
"price_label": f"${price_usd:.2f}",
|
|
"trial_tier": trial_tier,
|
|
"trial_count": TRIAL_TIERS[trial_tier]["trials"],
|
|
"trial_label": TRIAL_TIERS[trial_tier]["label"],
|
|
"trial_badge": TRIAL_TIERS[trial_tier]["badge"],
|
|
"price_tier": price_tier,
|
|
"price_tier_label": PRICE_TIERS[price_tier]["label"],
|
|
"method": t.get("method", "POST"),
|
|
}
|
|
)
|
|
|
|
# Group by category
|
|
by_category = defaultdict(list)
|
|
for t in enriched:
|
|
by_category[t["category"]].append(t)
|
|
|
|
# Group by service
|
|
by_service = defaultdict(list)
|
|
for t in enriched:
|
|
by_service[t["service"]].append(t)
|
|
|
|
# Group by trial tier
|
|
by_trial = defaultdict(list)
|
|
for t in enriched:
|
|
by_trial[t["trial_tier"]].append(t)
|
|
|
|
# Group by price tier
|
|
by_price = defaultdict(list)
|
|
for t in enriched:
|
|
by_price[t["price_tier"]].append(t)
|
|
|
|
# Build featured set - top 5 from each major category
|
|
major_cats = ["security", "intelligence", "market", "analysis", "social", "defi"]
|
|
featured = []
|
|
for cat in major_cats:
|
|
cat_tools = by_category.get(cat, [])
|
|
featured.extend(cat_tools[:5])
|
|
|
|
# Sort: native first, then external
|
|
enriched.sort(key=lambda t: (0 if t["source"] == "enforcement" else 1, t["price_usd"]))
|
|
|
|
result = {
|
|
"version": "1.0.0",
|
|
"generated_at": int(now),
|
|
"total_tools": len(enriched),
|
|
"total_native": sum(1 for t in enriched if t["source"] in ("enforcement", "route", "rmi-native")),
|
|
"total_external": sum(1 for t in enriched if t["source"] in ("expanded-mcp", "mcp-router")),
|
|
"total_services": len(by_service),
|
|
"total_categories": len(by_category),
|
|
"total_chains": len({c for t in enriched for c in t["chains"]}),
|
|
"categories": sorted(by_category.keys()),
|
|
"services": sorted(by_service.keys()),
|
|
"tools": enriched,
|
|
"by_category": dict(by_category.items()),
|
|
"by_service": dict(by_service.items()),
|
|
"by_trial_tier": dict(by_trial.items()),
|
|
"by_price_tier": dict(by_price.items()),
|
|
"featured": featured,
|
|
"external_services": external_services,
|
|
"trial_tiers": TRIAL_TIERS,
|
|
"price_tiers": PRICE_TIERS,
|
|
}
|
|
|
|
_catalog_cache = result
|
|
_catalog_cache_time = now
|
|
return result
|
|
|
|
|
|
def invalidate_catalog_cache():
|
|
"""Force rebuild on next request - call after adding/updating tools."""
|
|
global _catalog_cache_time
|
|
_catalog_cache_time = 0
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Endpoints
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.get("")
|
|
async def get_catalog_root(
|
|
sort: str = Query("default", description="default, price, trial, name"),
|
|
category: str | None = Query(None, description="Filter by category"),
|
|
service: str | None = Query(None, description="Filter by service"),
|
|
chain: str | None = Query(None, description="Filter by chain"),
|
|
free_only: bool = Query(False, description="Only tools with free trials"),
|
|
limit: int = Query(500, description="Max tools to return (0 = all)"),
|
|
):
|
|
"""Full human-facing catalog. Grouped + filterable + sortable."""
|
|
cat = _get_full_catalog()
|
|
tools = list(cat["tools"])
|
|
|
|
# Filters
|
|
if category:
|
|
tools = [t for t in tools if t["category"].lower() == category.lower()]
|
|
if service:
|
|
tools = [t for t in tools if t["service"].lower() == service.lower()]
|
|
if chain:
|
|
chain_upper = chain.upper()
|
|
tools = [t for t in tools if chain_upper in [c.upper() for c in t["chains"]]]
|
|
if free_only:
|
|
tools = [t for t in tools if t["trial_count"] > 0]
|
|
|
|
# Sort
|
|
if sort == "price":
|
|
tools.sort(key=lambda t: t["price_usd"])
|
|
elif sort == "trial":
|
|
tools.sort(key=lambda t: -t["trial_count"])
|
|
elif sort == "name":
|
|
tools.sort(key=lambda t: t["name"].lower())
|
|
elif sort == "category":
|
|
tools.sort(key=lambda t: (t["category"], t["name"].lower()))
|
|
|
|
# Limit
|
|
if limit > 0:
|
|
tools = tools[:limit]
|
|
|
|
return {
|
|
"total": len(tools),
|
|
"total_available": cat["total_tools"],
|
|
"version": cat["version"],
|
|
"generated_at": cat["generated_at"],
|
|
"filters": {
|
|
"category": category,
|
|
"service": service,
|
|
"chain": chain,
|
|
"sort": sort,
|
|
"free_only": free_only,
|
|
},
|
|
"tools": tools,
|
|
"categories": cat["categories"],
|
|
"services": cat["services"],
|
|
"trial_tiers": cat["trial_tiers"],
|
|
"price_tiers": cat["price_tiers"],
|
|
}
|
|
|
|
|
|
@router.get("/search")
|
|
async def search_catalog(
|
|
q: str = Query(..., min_length=2, description="Search query"),
|
|
limit: int = Query(50, description="Max results"),
|
|
):
|
|
"""Free-text search across tool names, descriptions, IDs, and chains."""
|
|
cat = _get_full_catalog()
|
|
q_lower = q.lower()
|
|
results = []
|
|
|
|
for t in cat["tools"]:
|
|
score = 0
|
|
# ID match (highest)
|
|
if q_lower in t["id"].lower():
|
|
score += 100
|
|
# Name match
|
|
if q_lower in t["name"].lower():
|
|
score += 50
|
|
# Description match
|
|
if q_lower in t["description"].lower():
|
|
score += 20
|
|
# Category match
|
|
if q_lower in t["category"].lower():
|
|
score += 10
|
|
# Service match
|
|
if q_lower in t["service"].lower():
|
|
score += 5
|
|
# Chain match
|
|
if any(q_lower in c.lower() for c in t["chains"]):
|
|
score += 5
|
|
|
|
if score > 0:
|
|
results.append({**t, "_score": score})
|
|
|
|
# Sort by score
|
|
results.sort(key=lambda t: -t["_score"])
|
|
# Drop score
|
|
for r in results:
|
|
r.pop("_score")
|
|
|
|
return {
|
|
"query": q,
|
|
"total": len(results),
|
|
"tools": results[:limit],
|
|
}
|
|
|
|
|
|
@router.get("/featured")
|
|
async def get_featured():
|
|
"""Curated featured tools - 5 from each major category."""
|
|
cat = _get_full_catalog()
|
|
return {
|
|
"total": len(cat["featured"]),
|
|
"tools": cat["featured"],
|
|
}
|
|
|
|
|
|
@router.get("/by-trial")
|
|
async def get_by_trial():
|
|
"""Tools grouped by free trial tier (free/standard/premium)."""
|
|
cat = _get_full_catalog()
|
|
return {
|
|
"tiers": cat["trial_tiers"],
|
|
"groups": dict(cat["by_trial_tier"].items()),
|
|
"counts": {k: len(v) for k, v in cat["by_trial_tier"].items()},
|
|
}
|
|
|
|
|
|
@router.get("/by-price")
|
|
async def get_by_price():
|
|
"""Tools grouped by price tier (micro/low/mid/high/premium)."""
|
|
cat = _get_full_catalog()
|
|
return {
|
|
"tiers": cat["price_tiers"],
|
|
"groups": dict(cat["by_price_tier"].items()),
|
|
"counts": {k: len(v) for k, v in cat["by_price_tier"].items()},
|
|
}
|
|
|
|
|
|
@router.get("/by-chain/{chain}")
|
|
async def get_by_chain(chain: str):
|
|
"""All tools available for a specific chain."""
|
|
cat = _get_full_catalog()
|
|
chain_upper = chain.upper()
|
|
tools = [t for t in cat["tools"] if chain_upper in [c.upper() for c in t["chains"]]]
|
|
return {
|
|
"chain": chain_upper,
|
|
"total": len(tools),
|
|
"tools": tools,
|
|
}
|
|
|
|
|
|
@router.get("/by-category/{category}")
|
|
async def get_by_category(category: str):
|
|
"""All tools in a specific category."""
|
|
cat = _get_full_catalog()
|
|
tools = [t for t in cat["tools"] if t["category"].lower() == category.lower()]
|
|
return {
|
|
"category": category.lower(),
|
|
"total": len(tools),
|
|
"tools": tools,
|
|
}
|
|
|
|
|
|
@router.get("/by-service/{service}")
|
|
async def get_by_service(service: str):
|
|
"""All tools from a specific service."""
|
|
cat = _get_full_catalog()
|
|
tools = [t for t in cat["tools"] if t["service"].lower() == service.lower()]
|
|
return {
|
|
"service": service.lower(),
|
|
"total": len(tools),
|
|
"tools": tools,
|
|
}
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_stats():
|
|
"""Catalog statistics - counts, breakdown, pricing."""
|
|
cat = _get_full_catalog()
|
|
return {
|
|
"version": cat["version"],
|
|
"generated_at": cat["generated_at"],
|
|
"total_tools": cat["total_tools"],
|
|
"total_native": cat["total_native"],
|
|
"total_external": cat["total_external"],
|
|
"total_services": cat["total_services"],
|
|
"total_categories": cat["total_categories"],
|
|
"total_chains": cat["total_chains"],
|
|
"by_category": {k: len(v) for k, v in cat["by_category"].items()},
|
|
"by_service": {k: len(v) for k, v in cat["by_service"].items()},
|
|
"by_trial_tier": {k: len(v) for k, v in cat["by_trial_tier"].items()},
|
|
"by_price_tier": {k: len(v) for k, v in cat["by_price_tier"].items()},
|
|
"external_services": cat["external_services"],
|
|
}
|
|
|
|
|
|
@router.get("/external")
|
|
async def get_external():
|
|
"""All free open-source MCPs we've integrated, with their metadata."""
|
|
cat = _get_full_catalog()
|
|
return {
|
|
"total": len(cat["external_services"]),
|
|
"services": cat["external_services"],
|
|
"tools": [t for t in cat["tools"] if t["source"] in ("expanded-mcp", "mcp-router")],
|
|
}
|
|
|
|
|
|
@router.get("/{tool_id}")
|
|
async def get_tool(tool_id: str):
|
|
"""Single tool with full metadata."""
|
|
cat = _get_full_catalog()
|
|
for t in cat["tools"]:
|
|
if t["id"] == tool_id:
|
|
return t
|
|
raise HTTPException(status_code=404, detail=f"Tool '{tool_id}' not found")
|
|
|
|
|
|
@router.post("/invalidate-cache")
|
|
async def invalidate():
|
|
"""Force catalog rebuild on next request. Call after adding tools."""
|
|
invalidate_catalog_cache()
|
|
return {"status": "ok", "message": "Cache invalidated - next request will rebuild"}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Convenience: get_total_tool_count (used by other routers)
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
|
|
def get_total_tool_count() -> int:
|
|
return _get_full_catalog().get("total_tools", 0)
|