merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
480
app/routers/human_catalog.py
Normal file
480
app/routers/human_catalog.py
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
"""
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue