diff --git "a/api_server_extended.py" "b/api_server_extended.py" --- "a/api_server_extended.py" +++ "b/api_server_extended.py" @@ -1,3920 +1,4059 @@ -#!/usr/bin/env python3 -""" -API Server Extended - HuggingFace Spaces Deployment Ready -Complete Admin API with Real Data Only - NO MOCKS -""" - -import os -import threading -import asyncio -import sqlite3 -import httpx -import json -import subprocess -import logging -from pathlib import Path -from typing import Optional, Dict, Any, List -from datetime import datetime -from contextlib import asynccontextmanager -from collections import defaultdict - -logger = logging.getLogger(__name__) - -from fastapi import FastAPI, HTTPException, Response, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, FileResponse, HTMLResponse -from fastapi.staticfiles import StaticFiles -from starlette.middleware.base import BaseHTTPMiddleware -from pydantic import BaseModel - -# Environment variables -USE_MOCK_DATA = os.getenv("USE_MOCK_DATA", "false").lower() == "true" -PORT = int(os.getenv("PORT", "7860")) - -# Paths - In Docker container, use /app as base -WORKSPACE_ROOT = Path("/app" if Path("/app").exists() else (Path("/workspace") if Path("/workspace").exists() else Path("."))) -DB_PATH = WORKSPACE_ROOT / "data" / "database" / "crypto_monitor.db" -LOG_DIR = WORKSPACE_ROOT / "logs" -PROVIDERS_CONFIG_PATH = WORKSPACE_ROOT / "providers_config_extended.json" -AUTO_DISCOVERY_REPORT_PATH = WORKSPACE_ROOT / "PROVIDER_AUTO_DISCOVERY_REPORT.json" -API_REGISTRY_PATH = WORKSPACE_ROOT / "all_apis_merged_2025.json" - -# Ensure directories exist -DB_PATH.parent.mkdir(parents=True, exist_ok=True) -LOG_DIR.mkdir(parents=True, exist_ok=True) - -# Global state for providers -_provider_state = { - "providers": {}, - "pools": {}, - "logs": [], - "last_check": None, - "stats": {"total": 0, "online": 0, "offline": 0, "degraded": 0} -} - - -# ===== Database Setup ===== -def init_database(): - """Initialize SQLite database with required tables""" - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS prices ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - symbol TEXT NOT NULL, - name TEXT, - price_usd REAL NOT NULL, - volume_24h REAL, - market_cap REAL, - percent_change_24h REAL, - rank INTEGER, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS sentiment_analysis ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - text TEXT NOT NULL, - sentiment_label TEXT NOT NULL, - confidence REAL NOT NULL, - model_used TEXT, - analysis_type TEXT, - symbol TEXT, - scores TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS news_articles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - content TEXT, - url TEXT, - source TEXT, - sentiment_label TEXT, - sentiment_confidence REAL, - related_symbols TEXT, - published_date DATETIME, - analyzed_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - """) - - cursor.execute("CREATE INDEX IF NOT EXISTS idx_prices_symbol ON prices(symbol)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_prices_timestamp ON prices(timestamp)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_sentiment_timestamp ON sentiment_analysis(timestamp)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_sentiment_symbol ON sentiment_analysis(symbol)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_news_published ON news_articles(published_date)") - - conn.commit() - conn.close() - print(f"[OK] Database initialized at {DB_PATH}") - - -def save_price_to_db(price_data: Dict[str, Any]): - """Save price data to SQLite""" - try: - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO prices (symbol, name, price_usd, volume_24h, market_cap, percent_change_24h, rank) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - price_data.get("symbol"), - price_data.get("name"), - price_data.get("price_usd", 0.0), - price_data.get("volume_24h"), - price_data.get("market_cap"), - price_data.get("percent_change_24h"), - price_data.get("rank") - )) - conn.commit() - conn.close() - except Exception as e: - print(f"Error saving price to database: {e}") - - -def get_price_history_from_db(symbol: str, limit: int = 10) -> List[Dict[str, Any]]: - """Get price history from SQLite""" - try: - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - cursor.execute(""" - SELECT * FROM prices - WHERE symbol = ? - ORDER BY timestamp DESC - LIMIT ? - """, (symbol, limit)) - rows = cursor.fetchall() - conn.close() - return [dict(row) for row in rows] - except Exception as e: - print(f"Error fetching price history: {e}") - return [] - - -def get_latest_prices_from_db() -> Dict[str, Dict[str, Any]]: - """Get latest prices for BTC, ETH, BNB from database as fallback""" - try: - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - # Get latest price for each symbol - symbols = ["BTC", "ETH", "BNB"] - latest_prices = {} - - for symbol in symbols: - cursor.execute(""" - SELECT * FROM prices - WHERE symbol = ? - ORDER BY timestamp DESC - LIMIT 1 - """, (symbol,)) - row = cursor.fetchone() - if row: - latest_prices[symbol] = dict(row) - - conn.close() - return latest_prices - except Exception as e: - logger.warning(f"Error fetching latest prices from database: {e}") - return {} - - -# ===== Provider Management ===== -def load_providers_config() -> Dict[str, Any]: - """Load providers from providers_config_extended.json""" - try: - if PROVIDERS_CONFIG_PATH.exists(): - with open(PROVIDERS_CONFIG_PATH, 'r', encoding='utf-8') as f: - config = json.load(f) - # Validate structure - if not isinstance(config, dict): - logger.warning("Providers config is not a dict, returning empty") - return {"providers": {}} - if "providers" not in config: - logger.warning("Providers config missing 'providers' key, adding it") - config["providers"] = {} - return config - logger.warning(f"Providers config file not found at {PROVIDERS_CONFIG_PATH}") - return {"providers": {}} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error loading providers config: {e}") - return {"providers": {}} - except Exception as e: - logger.error(f"Error loading providers config: {e}") - return {"providers": {}} - - -def load_apl_report() -> Dict[str, Any]: - """Load APL validation report (alias for auto-discovery report)""" - return load_auto_discovery_report() - -def load_auto_discovery_report() -> Dict[str, Any]: - """Load PROVIDER_AUTO_DISCOVERY_REPORT.json""" - try: - if AUTO_DISCOVERY_REPORT_PATH.exists(): - with open(AUTO_DISCOVERY_REPORT_PATH, 'r', encoding='utf-8') as f: - return json.load(f) - return {} - except Exception as e: - logger.error(f"Error loading auto-discovery report: {e}") - return {} - -def load_api_registry() -> Dict[str, Any]: - """Load all_apis_merged_2025.json""" - try: - if API_REGISTRY_PATH.exists(): - with open(API_REGISTRY_PATH, 'r', encoding='utf-8') as f: - return json.load(f) - return {} - except Exception as e: - logger.error(f"Error loading API registry: {e}") - return {} - - -# ===== Deduplication Helpers ===== -def deduplicate_providers(providers_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """ - Deduplicate providers by id, or by name+base_url if no id. - Merge tags/categories when duplicates are found. - """ - seen = {} - result = [] - - for provider in providers_list: - # Determine unique key - provider_id = provider.get("id") or provider.get("provider_id") - if provider_id: - key = f"id:{provider_id}" - else: - name = provider.get("name", "unknown") - base_url = provider.get("base_url", "") - key = f"name_url:{name}:{base_url}" - - if key in seen: - # Merge tags/categories - existing = seen[key] - existing_tags = set(existing.get("tags", []) if isinstance(existing.get("tags"), list) else []) - new_tags = set(provider.get("tags", []) if isinstance(provider.get("tags"), list) else []) - existing["tags"] = list(existing_tags | new_tags) - - # Merge categories if different - existing_cat = existing.get("category", "") - new_cat = provider.get("category", "") - if new_cat and new_cat != existing_cat: - if existing_cat: - existing["categories"] = list(set([existing_cat, new_cat])) - else: - existing["category"] = new_cat - else: - # Ensure tags is a list - if "tags" not in provider: - provider["tags"] = [] - elif not isinstance(provider["tags"], list): - provider["tags"] = [provider["tags"]] - - seen[key] = provider - result.append(provider) - - return result - - -def deduplicate_resources(resources_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """ - Deduplicate resources by id, or by name+url if no id. - """ - seen = {} - result = [] - - for resource in resources_list: - # Determine unique key - resource_id = resource.get("id") - if resource_id: - key = f"id:{resource_id}" - else: - name = resource.get("name", "unknown") - url = resource.get("url") or resource.get("base_url", "") - path = resource.get("path", "") - key = f"name_url:{name}:{url}{path}" - - if key not in seen: - seen[key] = resource - result.append(resource) - - return result - - -def filter_resources_by_query(resources: List[Dict[str, Any]], query: str) -> List[Dict[str, Any]]: - """ - Filter resources by search query (case-insensitive). - Searches in name, description, category, and tags. - """ - if not query: - return resources - - query_lower = query.lower() - filtered = [] - - for resource in resources: - # Search in name - if query_lower in resource.get("name", "").lower(): - filtered.append(resource) - continue - - # Search in description - if query_lower in resource.get("description", "").lower(): - filtered.append(resource) - continue - - # Search in category - if query_lower in resource.get("category", "").lower(): - filtered.append(resource) - continue - - # Search in tags - tags = resource.get("tags", []) - if isinstance(tags, list): - if any(query_lower in str(tag).lower() for tag in tags): - filtered.append(resource) - continue - - return filtered - - -# ===== Real Data Providers ===== -HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "application/json" -} - - -async def fetch_coingecko_simple_price() -> Dict[str, Any]: - """Fetch real price data from CoinGecko API with proper error handling""" - url = "https://api.coingecko.com/api/v3/simple/price" - params = { - "ids": "bitcoin,ethereum,binancecoin", - "vs_currencies": "usd", - "include_market_cap": "true", - "include_24hr_vol": "true", - "include_24hr_change": "true" - } - - try: - async with httpx.AsyncClient(timeout=15.0, headers=HEADERS) as client: - response = await client.get(url, params=params) - if response.status_code != 200: - logger.warning(f"CoinGecko API returned HTTP {response.status_code}") - raise Exception(f"CoinGecko API error: HTTP {response.status_code}") - return response.json() - except httpx.TimeoutException: - logger.warning("CoinGecko API request timed out") - raise Exception("CoinGecko API request timed out") - except httpx.RequestError as e: - logger.warning(f"CoinGecko API request error: {str(e)}") - raise Exception(f"CoinGecko API request failed: {str(e)}") - except Exception as e: - logger.warning(f"CoinGecko API error: {str(e)}") - raise - - -async def fetch_fear_greed_index() -> Dict[str, Any]: - """Fetch real Fear & Greed Index from Alternative.me""" - url = "https://api.alternative.me/fng/" - params = {"limit": "1", "format": "json"} - - async with httpx.AsyncClient(timeout=15.0, headers=HEADERS) as client: - response = await client.get(url, params=params) - if response.status_code != 200: - raise HTTPException(status_code=503, detail=f"Alternative.me API error: HTTP {response.status_code}") - return response.json() - - -async def fetch_coingecko_trending() -> Dict[str, Any]: - """Fetch real trending coins from CoinGecko""" - url = "https://api.coingecko.com/api/v3/search/trending" - - async with httpx.AsyncClient(timeout=15.0, headers=HEADERS) as client: - response = await client.get(url) - if response.status_code != 200: - raise HTTPException(status_code=503, detail=f"CoinGecko trending API error: HTTP {response.status_code}") - return response.json() - - -# ===== Self-Healing Health Registry ===== -from dataclasses import dataclass, field -from typing import Callable -import time as time_module - -@dataclass -class ProviderHealthEntry: - """Health tracking entry for a provider/resource""" - id: str - name: str - status: str = "unknown" # "healthy", "degraded", "unavailable", "unknown" - last_success: Optional[float] = None - last_error: Optional[float] = None - error_count: int = 0 - success_count: int = 0 - cooldown_until: Optional[float] = None - last_error_message: Optional[str] = None - -class HealthRegistry: - """ - Self-healing health registry for providers and external API endpoints. - Tracks failures, implements cooldowns, and provides graceful degradation. - """ - def __init__(self): - self._providers: Dict[str, ProviderHealthEntry] = {} - self._lock = threading.Lock() - # Load config - try: - from config import get_settings - self.settings = get_settings() - except: - # Fallback defaults if config not available - class FallbackSettings: - health_error_threshold = 3 - health_cooldown_seconds = 300 - health_success_recovery_count = 2 - self.settings = FallbackSettings() - - def _get_or_create_entry(self, provider_id: str, provider_name: str = None) -> ProviderHealthEntry: - """Get or create health entry for a provider""" - if provider_id not in self._providers: - self._providers[provider_id] = ProviderHealthEntry( - id=provider_id, - name=provider_name or provider_id, - status="unknown" - ) - return self._providers[provider_id] - - def update_on_success(self, provider_id: str, provider_name: str = None): - """Update health registry after successful provider call""" - with self._lock: - entry = self._get_or_create_entry(provider_id, provider_name) - entry.last_success = time_module.time() - entry.success_count += 1 - - # Reset error count gradually - if entry.error_count > 0: - entry.error_count = max(0, entry.error_count - 1) - - # Recovery logic - if entry.success_count >= self.settings.health_success_recovery_count: - entry.status = "healthy" - entry.cooldown_until = None - - def update_on_failure(self, provider_id: str, error_msg: str, provider_name: str = None): - """Update health registry after failed provider call""" - with self._lock: - entry = self._get_or_create_entry(provider_id, provider_name) - entry.last_error = time_module.time() - entry.error_count += 1 - entry.last_error_message = error_msg[:500] # Limit error message length - entry.success_count = 0 - - # Determine status based on error count - if entry.error_count >= self.settings.health_error_threshold: - entry.status = "unavailable" - entry.cooldown_until = time_module.time() + self.settings.health_cooldown_seconds - elif entry.error_count >= (self.settings.health_error_threshold // 2): - entry.status = "degraded" - else: - entry.status = "healthy" - - def is_in_cooldown(self, provider_id: str) -> bool: - """Check if provider is in cooldown period""" - if provider_id not in self._providers: - return False - entry = self._providers[provider_id] - if entry.cooldown_until is None: - return False - return time_module.time() < entry.cooldown_until - - def get_status(self, provider_id: str) -> Optional[str]: - """Get current status of a provider""" - if provider_id not in self._providers: - return "unknown" - return self._providers[provider_id].status - - def get_all_entries(self) -> List[Dict[str, Any]]: - """Get all health entries as list of dicts""" - with self._lock: - return [ - { - "id": entry.id, - "name": entry.name, - "status": entry.status, - "last_success": entry.last_success, - "last_error": entry.last_error, - "error_count": entry.error_count, - "success_count": entry.success_count, - "cooldown_until": entry.cooldown_until, - "in_cooldown": self.is_in_cooldown(entry.id), - "last_error_message": entry.last_error_message - } - for entry in self._providers.values() - ] - - def get_summary(self) -> Dict[str, Any]: - """Get summary statistics of health registry""" - with self._lock: - total = len(self._providers) - healthy = sum(1 for e in self._providers.values() if e.status == "healthy") - degraded = sum(1 for e in self._providers.values() if e.status == "degraded") - unavailable = sum(1 for e in self._providers.values() if e.status == "unavailable") - unknown = sum(1 for e in self._providers.values() if e.status == "unknown") - in_cooldown = sum(1 for e in self._providers.values() if self.is_in_cooldown(e.id)) - - return { - "total": total, - "healthy": healthy, - "degraded": degraded, - "unavailable": unavailable, - "unknown": unknown, - "in_cooldown": in_cooldown - } - -# Global health registry instance -_health_registry = HealthRegistry() - - -async def call_provider_safe( - provider_id: str, - provider_name: str, - call_func: Callable, - *args, - **kwargs -) -> Dict[str, Any]: - """ - Safely call a provider with health tracking. - - Args: - provider_id: Unique identifier for the provider - provider_name: Human-readable name - call_func: Async function to call - *args, **kwargs: Arguments to pass to call_func - - Returns: - Dict with status and data or error - """ - # Check if provider is in cooldown - if _health_registry.is_in_cooldown(provider_id): - entry = _health_registry._providers[provider_id] - cooldown_remaining = int(entry.cooldown_until - time_module.time()) - return { - "status": "cooldown", - "error": f"Provider in cooldown for {cooldown_remaining}s", - "provider_id": provider_id, - "cooldown_remaining": cooldown_remaining - } - - try: - # Call the provider function - result = await call_func(*args, **kwargs) - # Update health on success - _health_registry.update_on_success(provider_id, provider_name) - return { - "status": "success", - "data": result, - "provider_id": provider_id - } - except httpx.TimeoutException as e: - error_msg = f"Timeout: {str(e)[:200]}" - _health_registry.update_on_failure(provider_id, error_msg, provider_name) - return { - "status": "timeout", - "error": error_msg, - "provider_id": provider_id - } - except httpx.HTTPStatusError as e: - error_msg = f"HTTP {e.response.status_code}: {str(e)[:200]}" - _health_registry.update_on_failure(provider_id, error_msg, provider_name) - return { - "status": "http_error", - "error": error_msg, - "provider_id": provider_id, - "status_code": e.response.status_code - } - except Exception as e: - error_msg = f"{type(e).__name__}: {str(e)[:200]}" - _health_registry.update_on_failure(provider_id, error_msg, provider_name) - return { - "status": "error", - "error": error_msg, - "provider_id": provider_id - } - - -# ===== Lifespan Management ===== -@asynccontextmanager -async def lifespan(app: FastAPI): - """Application lifespan manager""" - print("=" * 80) - print("Starting Crypto Monitor Admin API") - print("=" * 80) - init_database() - - # Load providers - config = load_providers_config() - _provider_state["providers"] = config.get("providers", {}) - print(f"[OK] Loaded {len(_provider_state['providers'])} providers from config") - - # Load auto-discovery report - apl_report = load_auto_discovery_report() - if apl_report: - print(f"[OK] Loaded auto-discovery report with validation data") - - # Load API registry - api_registry = load_api_registry() - if api_registry: - metadata = api_registry.get("metadata", {}) - print(f"[OK] Loaded API registry: {metadata.get('name', 'unknown')} v{metadata.get('version', 'unknown')}") - - # Initialize AI models - try: - from ai_models import initialize_models, registry_status, HF_MAX_STARTUP_MODELS - model_init_result = initialize_models(max_models=HF_MAX_STARTUP_MODELS) - registry_info = registry_status() - print(f"[OK] AI Models initialized: {model_init_result}") - print(f"[OK] HF Registry status: {registry_info}") - except Exception as e: - print(f"[WARN] AI Models initialization failed: {e}") - - # Validate unified resources - try: - from backend.services.resource_validator import validate_unified_resources - validation_report = validate_unified_resources(str(WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json")) - print(f"[OK] Resource validation: {validation_report['local_backend_routes']['routes_count']} local routes") - if validation_report['local_backend_routes']['duplicate_signatures'] > 0: - print(f"[WARN] Found {validation_report['local_backend_routes']['duplicate_signatures']} duplicate route signatures") - except Exception as e: - print(f"[WARN] Resource validation failed: {e}") - - print(f"[OK] Server ready on port {PORT}") - print("=" * 80) - yield - print("Shutting down...") - - -# ===== FastAPI Application ===== -app = FastAPI( - title="Crypto Monitor Admin API", - description="Real-time cryptocurrency data API with Admin Dashboard", - version="5.0.0", - lifespan=lifespan -) - -# CORS Middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Middleware to ensure HTML responses have correct Content-Type -class HTMLContentTypeMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - response = await call_next(request) - if isinstance(response, HTMLResponse): - response.headers["Content-Type"] = "text/html; charset=utf-8" - response.headers["X-Content-Type-Options"] = "nosniff" - return response - -app.add_middleware(HTMLContentTypeMiddleware) - -# Mount static files -try: - static_path = WORKSPACE_ROOT / "static" - if static_path.exists(): - app.mount("/static", StaticFiles(directory=str(static_path)), name="static") - logger.info(f"Mounted static files from {static_path}") - else: - # Create static directories if they don't exist - static_path.mkdir(parents=True, exist_ok=True) - (static_path / "css").mkdir(exist_ok=True) - (static_path / "js").mkdir(exist_ok=True) - logger.info(f"Created static directories at {static_path}") -except Exception as e: - logger.warning(f"Could not mount static files: {e}") - -# Serve trading pairs file -@app.get("/trading_pairs.txt") -async def get_trading_pairs(): - """Serve trading pairs text file""" - from fastapi.responses import PlainTextResponse - trading_pairs_file = WORKSPACE_ROOT / "trading_pairs.txt" - if trading_pairs_file.exists(): - return FileResponse(trading_pairs_file, media_type="text/plain") - return PlainTextResponse("BTCUSDT\nETHUSDT\nBNBUSDT\nSOLUSDT", status_code=200) - - -# ===== HTML UI Endpoints ===== -@app.get("/", response_class=HTMLResponse) -async def root(): - """Serve main dashboard""" - index_path = WORKSPACE_ROOT / "index.html" - if index_path.exists(): - content = index_path.read_text(encoding="utf-8", errors="ignore") - return HTMLResponse( - content=content, - media_type="text/html", - headers={ - "Content-Type": "text/html; charset=utf-8", - "X-Content-Type-Options": "nosniff" - } - ) - return HTMLResponse( - "

Cryptocurrency Data & Analysis API

See /docs for API documentation

", - headers={"Content-Type": "text/html; charset=utf-8"} - ) - -@app.get("/index.html", response_class=HTMLResponse) -async def index(): - """Serve index.html""" - index_path = WORKSPACE_ROOT / "index.html" - if index_path.exists(): - content = index_path.read_text(encoding="utf-8", errors="ignore") - return HTMLResponse( - content=content, - media_type="text/html", - headers={ - "Content-Type": "text/html; charset=utf-8", - "X-Content-Type-Options": "nosniff" - } - ) - return HTMLResponse( - "

index.html not found

", - headers={"Content-Type": "text/html; charset=utf-8"} - ) - -@app.get("/test.html", response_class=HTMLResponse) -async def test_page(): - """Serve test.html for debugging""" - test_path = WORKSPACE_ROOT / "test.html" - if test_path.exists(): - content = test_path.read_text(encoding="utf-8", errors="ignore") - return HTMLResponse( - content=content, - media_type="text/html", - headers={ - "Content-Type": "text/html; charset=utf-8", - "X-Content-Type-Options": "nosniff" - } - ) - return HTMLResponse( - "

✅ Server is Running

WORKSPACE_ROOT: " + str(WORKSPACE_ROOT) + "

", - headers={"Content-Type": "text/html; charset=utf-8"} - ) - -@app.get("/ai-tools", response_class=HTMLResponse) -async def ai_tools_page(request: Request): - """ - Serve the standalone AI Tools page. - - This page provides: - - Sentiment Playground: POST /api/sentiment/analyze - - Text Summarizer: POST /api/ai/summarize - - Model Status & Diagnostics: GET /api/models/status, /api/models/list - """ - ai_tools_path = WORKSPACE_ROOT / "templates" / "ai_tools.html" - if ai_tools_path.exists(): - content = ai_tools_path.read_text(encoding="utf-8", errors="ignore") - return HTMLResponse( - content=content, - media_type="text/html", - headers={ - "Content-Type": "text/html; charset=utf-8", - "X-Content-Type-Options": "nosniff" - } - ) - return HTMLResponse( - "

AI Tools page not found

", - headers={"Content-Type": "text/html; charset=utf-8"} - ) - -@app.get("/debug-info", response_class=HTMLResponse) -async def debug_info(): - """Debug endpoint to show server configuration""" - import os - info = f""" - - - - - Debug Info - - - -

🔍 Server Debug Information

-

Paths:

-
-WORKSPACE_ROOT: {WORKSPACE_ROOT}
-Current Dir: {Path.cwd()}
-index.html exists: {"✅ YES" if (WORKSPACE_ROOT / "index.html").exists() else "❌ NO"}
-static dir exists: {"✅ YES" if (WORKSPACE_ROOT / "static").exists() else "❌ NO"}
-        
-

Files in WORKSPACE_ROOT:

-
-{chr(10).join([f"- {f.name}" for f in sorted(WORKSPACE_ROOT.glob("*.html"))[:20]])}
-        
-

Environment:

-
-Python: {os.sys.version}
-Port: 7860
-Host: 127.0.0.1
-        
-

Quick Links:

- - - - """ - return HTMLResponse(content=info, headers={"Content-Type": "text/html; charset=utf-8"}) - - -# ===== Health & Status Endpoints ===== -@app.get("/health") -async def health(): - """Health check endpoint (legacy)""" - return { - "status": "healthy", - "timestamp": datetime.now().isoformat(), - "database": str(DB_PATH), - "use_mock_data": USE_MOCK_DATA, - "providers_loaded": len(_provider_state["providers"]) - } - - -@app.get("/api/health") -async def api_health(): - """API health check endpoint - never crashes""" - try: - version = "1.0.0" - try: - # Try to get version from metadata - api_registry = load_api_registry() - metadata = api_registry.get("metadata", {}) - if metadata.get("version"): - version = metadata.get("version") - except Exception: - pass - - return { - "status": "ok", - "timestamp": datetime.now().isoformat(), - "version": version - } - except Exception as e: - # Even if something goes wrong, return a clean response - logger.error(f"Health check error: {e}") - return JSONResponse( - status_code=200, - content={ - "status": "ok", - "timestamp": datetime.now().isoformat(), - "version": "unknown" - } - ) - - -@app.get("/api/status") -async def get_status(): - """System status with real aggregated data""" - try: - # Load providers - config = load_providers_config() - providers = config.get("providers", {}) - - # Count free vs paid providers - free_count = sum(1 for p in providers.values() - if not p.get("requires_auth", False) and p.get("rate_limit")) - paid_count = sum(1 for p in providers.values() - if p.get("requires_auth", False)) - - # Load resources from unified file - resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" - resources_data = {"total": 0, "categories": {}} - - if resources_json.exists(): - try: - with open(resources_json, 'r', encoding='utf-8') as f: - unified_data = json.load(f) - registry = unified_data.get('registry', {}) - - for category, items in registry.items(): - if category == 'metadata': - continue - if isinstance(items, list): - count = len(items) - resources_data['total'] += count - - # Group similar categories - cat_key = category.replace('_', '-') - if cat_key not in resources_data['categories']: - resources_data['categories'][cat_key] = 0 - resources_data['categories'][cat_key] += count - except Exception as e: - logger.error(f"Error loading resources: {e}") - - # Get model count - model_count = 0 - try: - from ai_models import MODEL_SPECS - model_count = len(MODEL_SPECS) if MODEL_SPECS else 0 - except Exception: - pass - - # Get system health metrics - online_count = 0 - degraded_count = 0 - offline_count = 0 - response_times = [] - - # Try to get health status from providers if available - # This is a simplified version - in production you'd check actual provider health - system_health = "ok" if len(providers) > 0 else "unknown" - - return { - "status": "ok", - "system_health": system_health, - "timestamp": datetime.now().isoformat(), - "last_update": datetime.now().isoformat(), - "providers": { - "total": len(providers), - "free": free_count, - "paid": paid_count - }, - "online": online_count, - "degraded": degraded_count, - "offline": offline_count, - "avg_response_time_ms": round(sum(response_times) / len(response_times), 2) if response_times else 0, - "resources": resources_data, - "models": { - "total": model_count - } - } - except Exception as e: - logger.error(f"Status endpoint error: {e}") - return { - "status": "error", - "timestamp": datetime.now().isoformat(), - "error": str(e), - "providers": {"total": 0, "free": 0, "paid": 0}, - "resources": {"total": 0, "categories": {}} - } - - -@app.get("/api/stats") -async def get_stats(): - """System statistics""" - config = load_providers_config() - providers = config.get("providers", {}) - - # Group by category - categories = defaultdict(int) - for p in providers.values(): - cat = p.get("category", "unknown") - categories[cat] += 1 - - return { - "total_providers": len(providers), - "categories": dict(categories), - "total_categories": len(categories), - "timestamp": datetime.now().isoformat() - } - - -# ===== Market Data Endpoint ===== -@app.get("/api/market") -async def get_market_data(): - """Market data from CoinGecko with database fallback""" - cryptocurrencies = [] - coin_mapping = { - "bitcoin": {"name": "Bitcoin", "symbol": "BTC", "rank": 1, "image": "https://assets.coingecko.com/coins/images/1/small/bitcoin.png"}, - "ethereum": {"name": "Ethereum", "symbol": "ETH", "rank": 2, "image": "https://assets.coingecko.com/coins/images/279/small/ethereum.png"}, - "binancecoin": {"name": "BNB", "symbol": "BNB", "rank": 3, "image": "https://assets.coingecko.com/coins/images/825/small/bnb-icon2_2x.png"} - } - - data_source = "CoinGecko API (Real Data)" - use_fallback = False - - # Try to fetch from CoinGecko API first - try: - data = await fetch_coingecko_simple_price() - - for coin_id, coin_info in coin_mapping.items(): - if coin_id in data: - coin_data = data[coin_id] - crypto_entry = { - "rank": coin_info["rank"], - "name": coin_info["name"], - "symbol": coin_info["symbol"], - "price": coin_data.get("usd", 0), - "change_24h": coin_data.get("usd_24h_change", 0), - "market_cap": coin_data.get("usd_market_cap", 0), - "volume_24h": coin_data.get("usd_24h_vol", 0), - "image": coin_info["image"] - } - cryptocurrencies.append(crypto_entry) - - # Save to database - try: - save_price_to_db({ - "symbol": coin_info["symbol"], - "name": coin_info["name"], - "price_usd": crypto_entry["price"], - "volume_24h": crypto_entry["volume_24h"], - "market_cap": crypto_entry["market_cap"], - "percent_change_24h": crypto_entry["change_24h"], - "rank": coin_info["rank"] - }) - except Exception as db_error: - logger.warning(f"Failed to save price to database: {db_error}") - - except Exception as e: - logger.warning(f"Failed to fetch from CoinGecko API: {str(e)}, trying database fallback...") - use_fallback = True - data_source = "Database (Cached Data)" - - # Fallback to database - latest_prices = get_latest_prices_from_db() - - for coin_id, coin_info in coin_mapping.items(): - symbol = coin_info["symbol"] - if symbol in latest_prices: - db_data = latest_prices[symbol] - crypto_entry = { - "rank": coin_info["rank"], - "name": coin_info["name"], - "symbol": coin_info["symbol"], - "price": db_data.get("price_usd", 0), - "change_24h": db_data.get("percent_change_24h", 0), - "market_cap": db_data.get("market_cap", 0), - "volume_24h": db_data.get("volume_24h", 0), - "image": coin_info["image"] - } - cryptocurrencies.append(crypto_entry) - else: - # If no database data, add placeholder with zero values - logger.warning(f"No cached data found for {symbol}") - cryptocurrencies.append({ - "rank": coin_info["rank"], - "name": coin_info["name"], - "symbol": coin_info["symbol"], - "price": 0, - "change_24h": 0, - "market_cap": 0, - "volume_24h": 0, - "image": coin_info["image"] - }) - - # If still no data, return empty structure with message - if not cryptocurrencies: - logger.error("No market data available from API or database") - return { - "cryptocurrencies": [], - "total_market_cap": 0, - "btc_dominance": 0, - "timestamp": datetime.now().isoformat(), - "source": "No data available", - "error": "Unable to fetch market data. Please try again later.", - "message": "Market data temporarily unavailable" - } - - # Calculate dominance - total_market_cap = sum(c["market_cap"] for c in cryptocurrencies) - btc_dominance = 0 - if total_market_cap > 0: - btc_entry = next((c for c in cryptocurrencies if c["symbol"] == "BTC"), None) - if btc_entry: - btc_dominance = (btc_entry["market_cap"] / total_market_cap) * 100 - - response = { - "cryptocurrencies": cryptocurrencies, - "total_market_cap": total_market_cap, - "btc_dominance": btc_dominance, - "timestamp": datetime.now().isoformat(), - "source": data_source - } - - if use_fallback: - response["warning"] = "Using cached data from database. API unavailable." - - return response - - -@app.get("/api/market/history") -async def get_market_history(symbol: str = "BTC", limit: int = 10): - """Get price history from database - REAL DATA ONLY""" - history = get_price_history_from_db(symbol.upper(), limit) - - if not history: - return { - "symbol": symbol, - "history": [], - "count": 0, - "message": "No history available" - } - - return { - "symbol": symbol, - "history": history, - "count": len(history), - "source": "SQLite Database (Real Data)" - } - - -@app.get("/api/sentiment") -async def get_sentiment(): - """Sentiment data from Alternative.me - REAL DATA ONLY""" - try: - data = await fetch_fear_greed_index() - - if "data" in data and len(data["data"]) > 0: - fng_data = data["data"][0] - return { - "fear_greed_index": int(fng_data["value"]), - "fear_greed_label": fng_data["value_classification"], - "timestamp": datetime.now().isoformat(), - "source": "Alternative.me API (Real Data)" - } - - raise HTTPException(status_code=503, detail="Invalid response from Alternative.me") - - except Exception as e: - raise HTTPException(status_code=503, detail=f"Failed to fetch sentiment: {str(e)}") - - -@app.post("/api/sentiment") -async def analyze_sentiment_simple(request: Dict[str, Any]): - """Analyze sentiment with mode routing - simplified endpoint""" - try: - from ai_models import ( - analyze_crypto_sentiment, - analyze_financial_sentiment, - analyze_social_sentiment, - _registry, - MODEL_SPECS, - ModelNotAvailable - ) - - text = request.get("text", "").strip() - if not text: - raise HTTPException(status_code=400, detail="Text is required") - - mode = request.get("mode", "auto").lower() - model_key = request.get("model_key") - - # If model_key is provided, use that specific model - if model_key: - if model_key not in MODEL_SPECS: - raise HTTPException(status_code=404, detail=f"Model key '{model_key}' not found") - - try: - pipeline = _registry.get_pipeline(model_key) - spec = MODEL_SPECS[model_key] - - # Handle trading signal models specially - if spec.category == "trading_signal": - raw_result = pipeline(text, max_length=200, num_return_sequences=1) - if isinstance(raw_result, list) and raw_result: - raw_result = raw_result[0] - generated_text = raw_result.get("generated_text", str(raw_result)) - - decision = "HOLD" - if "buy" in generated_text.lower(): - decision = "BUY" - elif "sell" in generated_text.lower(): - decision = "SELL" - - return { - "sentiment": decision.lower(), - "confidence": 0.7, - "raw_label": decision, - "mode": "trading", - "model": model_key, - "extra": { - "decision": decision, - "rationale": generated_text, - "raw": raw_result - } - } - - # Regular sentiment analysis - raw_result = pipeline(text[:512]) - if isinstance(raw_result, list) and raw_result: - raw_result = raw_result[0] - - label = raw_result.get("label", "neutral").upper() - score = raw_result.get("score", 0.5) - - # Map to standard format - mapped = "Bullish" if "POSITIVE" in label or "BULLISH" in label or "LABEL_2" in label else ( - "Bearish" if "NEGATIVE" in label or "BEARISH" in label or "LABEL_0" in label else "Neutral" - ) - - return { - "sentiment": mapped, - "confidence": score, - "raw_label": label, - "mode": mode, - "model": model_key, - "extra": {"raw": raw_result} - } - - except ModelNotAvailable as e: - logger.warning(f"Model {model_key} not available: {e}") - raise HTTPException(status_code=503, detail=f"Model not available: {str(e)}") - - # Mode-based routing (no explicit model key) - result = None - actual_model = None - - if mode == "crypto" or mode == "auto": - result = analyze_crypto_sentiment(text) - actual_model = "crypto_sent_kk08" # Default crypto model - elif mode == "social": - result = analyze_social_sentiment(text) - actual_model = "crypto_sent_social" # ElKulako/cryptobert - elif mode == "financial": - result = analyze_financial_sentiment(text) - actual_model = "crypto_sent_fin" # FinTwitBERT - elif mode == "news": - result = analyze_financial_sentiment(text) # Use financial for news - actual_model = "crypto_sent_fin" - elif mode == "trading": - # Try to use trading model - try: - pipeline = _registry.get_pipeline("crypto_trading_lm") - raw_result = pipeline(text, max_length=200, num_return_sequences=1) - if isinstance(raw_result, list) and raw_result: - raw_result = raw_result[0] - generated_text = raw_result.get("generated_text", str(raw_result)) - - decision = "HOLD" - if "buy" in generated_text.lower(): - decision = "BUY" - elif "sell" in generated_text.lower(): - decision = "SELL" - - return { - "sentiment": decision, - "confidence": 0.7, - "raw_label": decision, - "mode": "trading", - "model": "crypto_trading_lm", - "extra": { - "decision": decision, - "rationale": generated_text - } - } - except ModelNotAvailable: - # Fallback to crypto sentiment - result = analyze_crypto_sentiment(text) - actual_model = "crypto_sent_kk08" - else: - result = analyze_crypto_sentiment(text) # Default fallback - actual_model = "crypto_sent_kk08" - - if not result: - raise HTTPException(status_code=500, detail="Sentiment analysis failed") - - # Standardize result format - sentiment = result.get("label", "Neutral") - confidence = result.get("confidence", 0.5) - - # Capitalize first letter - sentiment_formatted = sentiment.capitalize() if isinstance(sentiment, str) else "Neutral" - - return { - "sentiment": sentiment_formatted, - "confidence": confidence, - "raw_label": sentiment, - "mode": mode, - "model": actual_model, - "extra": result - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Sentiment analysis error: {e}") - raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") - - -@app.get("/api/resources") -async def get_resources(q: Optional[str] = None): - """Get all resources with optional search query and deduplication""" - try: - resources_list = [] - - # Load from unified resources file - resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" - if resources_json.exists(): - try: - with open(resources_json, 'r', encoding='utf-8') as f: - unified_data = json.load(f) - registry = unified_data.get('registry', {}) - - for category, items in registry.items(): - if category == 'metadata': - continue - if isinstance(items, list): - for item in items: - # Normalize resource structure - resource = { - "id": item.get("id"), - "name": item.get("name", item.get("title", "Unknown")), - "category": category, - "url": item.get("url") or item.get("base_url", ""), - "free": item.get("free", True), - "auth_required": item.get("auth_required", False) or (item.get("auth", {}).get("type") != "none" if "auth" in item else False), - "tags": item.get("tags", []) if isinstance(item.get("tags"), list) else [], - "description": item.get("description", "") or item.get("note", "") - } - - # Additional fields if present - if "method" in item: - resource["method"] = item["method"] - if "path" in item: - resource["path"] = item["path"] - if "endpoint" in item: - resource["endpoint"] = item["endpoint"] - - resources_list.append(resource) - except Exception as e: - logger.error(f"Error loading unified resources: {e}") - - # Load from API registry (all_apis_merged_2025.json) - api_registry = load_api_registry() - if api_registry and "raw_files" in api_registry: - # Parse raw files for additional resources (basic extraction) - for raw_file in api_registry.get("raw_files", [])[:10]: # Limit to first 10 - content = raw_file.get("content", "") - filename = raw_file.get("filename", "") - - # Simple extraction: look for URLs in content - import re - urls = re.findall(r'https?://[^\s<>"]+', content) - for url in urls[:5]: # Limit URLs per file - resources_list.append({ - "id": None, - "name": f"Resource from {filename}", - "category": "discovered", - "url": url, - "free": True, - "auth_required": False, - "tags": ["auto-discovered"], - "description": f"Auto-discovered from {filename}" - }) - - # Apply deduplication - deduplicated_resources = deduplicate_resources(resources_list) - - # Apply search filter if query provided - if q: - deduplicated_resources = filter_resources_by_query(deduplicated_resources, q) - - return deduplicated_resources - - except Exception as e: - logger.error(f"Error in get_resources: {e}") - raise HTTPException(status_code=500, detail=f"Failed to fetch resources: {str(e)}") - - -@app.get("/api/resources/summary") -async def get_resources_summary(): - """Get resources summary for HTML dashboard (includes API registry metadata and local routes)""" - try: - # Import MODEL_SPECS first as source of truth for models count - try: - from ai_models import MODEL_SPECS - models_count = len(MODEL_SPECS) if MODEL_SPECS else 0 - except Exception as e: - logger.warning(f"Failed to import MODEL_SPECS: {e}") - models_count = 0 - - # Load API registry for metadata - api_registry = load_api_registry() - metadata = api_registry.get("metadata", {}) if api_registry else {} - - # Try to load resources from JSON files - resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" - - summary = { - "total_resources": 0, - "free_resources": 0, - "models_available": models_count, # Use MODEL_SPECS as source of truth - "local_routes_count": 0, - "categories": {} - } - - # Load from unified resources - if resources_json.exists(): - try: - with open(resources_json, 'r', encoding='utf-8') as f: - data = json.load(f) - registry = data.get('registry', {}) - - # Process all categories - for category, items in registry.items(): - if category == 'metadata': - continue - if isinstance(items, list): - count = len(items) - summary['total_resources'] += count - summary['categories'][category] = { - "count": count, - "type": "local" if category == "local_backend_routes" else "external" - } - - # Track local routes separately - if category == 'local_backend_routes': - summary['local_routes_count'] = count - - free_count = sum(1 for item in items if item.get('free', False) or item.get('auth', {}).get('type') == 'none') - summary['free_resources'] += free_count - except Exception as e: - logger.warning(f"Failed to load resources JSON: {e}") - - # Ensure models_available is always non-zero if MODEL_SPECS is available - if summary['models_available'] == 0 and models_count > 0: - summary['models_available'] = models_count - - # If no resources found, provide fallback data but keep models count from MODEL_SPECS - if summary['total_resources'] == 0: - logger.warning("No resources found in JSON files, using fallback data") - summary['total_resources'] = 15 - summary['free_resources'] = 12 - # Ensure models count is at least from MODEL_SPECS or fallback minimum - summary['models_available'] = max(summary['models_available'], models_count, 7) - summary['categories'] = { - 'market_data': 5, - 'news': 3, - 'sentiment': 2, - 'blockchain': 3, - 'defi': 2 - } - - return { - "success": True, - "summary": summary, - "api_registry_metadata": metadata, - "timestamp": datetime.now().isoformat() - } - except Exception as e: - logger.error(f"Error in get_resources_summary: {e}") - # Return fallback data on error, but try to get models count from MODEL_SPECS - try: - from ai_models import MODEL_SPECS - fallback_models = len(MODEL_SPECS) if MODEL_SPECS else 7 - except: - fallback_models = 7 - - return { - "success": True, - "summary": { - "total_resources": 15, - "free_resources": 12, - "models_available": fallback_models, - "local_routes_count": 0, - "categories": { - 'market_data': 5, - 'news': 3, - 'sentiment': 2, - 'blockchain': 3, - 'defi': 2 - } - }, - "error": str(e), - "timestamp": datetime.now().isoformat() - } - -@app.get("/api/resources/apis") -async def get_resources_apis(): - """Get API registry with local and external routes""" - registry = load_api_registry() - - # Load unified resources for local routes - resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" - local_routes = [] - unified_metadata = {} - - if resources_json.exists(): - try: - with open(resources_json, 'r', encoding='utf-8') as f: - unified_data = json.load(f) - unified_registry = unified_data.get('registry', {}) - unified_metadata = unified_registry.get('metadata', {}) - local_routes = unified_registry.get('local_backend_routes', []) - except Exception as e: - logger.error(f"Error loading unified resources: {e}") - - # Process legacy registry - categories = set() - metadata = {} - raw_files = [] - trimmed_files = [] - - if registry: - metadata = registry.get("metadata", {}) - raw_files = registry.get("raw_files", []) - - # Extract categories from raw file content (basic parsing) - for raw_file in raw_files[:5]: # Limit to first 5 files for performance - content = raw_file.get("content", "") - # Simple category detection from content - if "market data" in content.lower() or "price" in content.lower(): - categories.add("market_data") - if "explorer" in content.lower() or "blockchain" in content.lower(): - categories.add("block_explorer") - if "rpc" in content.lower() or "node" in content.lower(): - categories.add("rpc_nodes") - if "cors" in content.lower() or "proxy" in content.lower(): - categories.add("cors_proxy") - if "news" in content.lower(): - categories.add("news") - if "sentiment" in content.lower() or "fear" in content.lower(): - categories.add("sentiment") - if "whale" in content.lower(): - categories.add("whale_tracking") - - # Provide trimmed raw files (first 500 chars each) - for raw_file in raw_files[:10]: # Limit to 10 files - content = raw_file.get("content", "") - trimmed_files.append({ - "filename": raw_file.get("filename", ""), - "preview": content[:500] + "..." if len(content) > 500 else content, - "size": len(content) - }) - - # Add local category - if local_routes: - categories.add("local") - - return { - "ok": True, - "metadata": { - "name": metadata.get("name", "") or unified_metadata.get("description", ""), - "version": metadata.get("version", "") or unified_metadata.get("version", ""), - "description": metadata.get("description", ""), - "created_at": metadata.get("created_at", ""), - "source_files": metadata.get("source_files", []), - "updated": unified_metadata.get("updated", "") - }, - "categories": list(categories), - "local_routes": { - "count": len(local_routes), - "routes": local_routes[:20] # Return first 20 for preview - }, - "raw_files_preview": trimmed_files, - "total_raw_files": len(raw_files), - "sources": ["all_apis_merged_2025.json", "crypto_resources_unified_2025-11-11.json"] - } - -@app.get("/api/resources/apis/raw") -async def get_resources_apis_raw(): - """Get raw files from API registry (trimmed to avoid huge payloads)""" - registry = load_api_registry() - - if not registry: - return { - "ok": False, - "error": "API registry file not found" - } - - raw_files = registry.get("raw_files", []) - - # Return trimmed versions (first 1000 chars each, max 20 files) - trimmed = [] - for raw_file in raw_files[:20]: - content = raw_file.get("content", "") - trimmed.append({ - "filename": raw_file.get("filename", ""), - "preview": content[:1000] + "..." if len(content) > 1000 else content, - "full_size": len(content) - }) - - return { - "ok": True, - "files": trimmed, - "total_files": len(raw_files), - "showing": min(20, len(raw_files)), - "source": "all_apis_merged_2025.json" - } - - -@app.get("/api/trending") -async def get_trending(): - """Trending coins from CoinGecko - REAL DATA ONLY""" - try: - data = await fetch_coingecko_trending() - - trending_coins = [] - if "coins" in data: - for item in data["coins"][:10]: - coin = item.get("item", {}) - trending_coins.append({ - "id": coin.get("id"), - "name": coin.get("name"), - "symbol": coin.get("symbol"), - "market_cap_rank": coin.get("market_cap_rank"), - "thumb": coin.get("thumb"), - "score": coin.get("score", 0) - }) - - return { - "trending": trending_coins, - "count": len(trending_coins), - "timestamp": datetime.now().isoformat(), - "source": "CoinGecko API (Real Data)" - } - - except Exception as e: - raise HTTPException(status_code=503, detail=f"Failed to fetch trending: {str(e)}") - - -# ===== Providers Management Endpoints ===== -@app.get("/api/providers") -async def get_providers(): - """Get all providers with deduplication applied""" - try: - # Load primary config - config = load_providers_config() - providers_dict = config.get("providers", {}) - - # Load auto-discovery report for validation status - discovery_report = load_auto_discovery_report() - discovery_results = {} - if discovery_report and "http_providers" in discovery_report: - for result in discovery_report["http_providers"].get("results", []): - discovery_results[result.get("provider_id")] = result - - # Build provider list from primary config - providers_list = [] - for provider_id, provider_data in providers_dict.items(): - # Merge with auto-discovery data if available - discovery_data = discovery_results.get(provider_id, {}) - - # Determine auth requirement - auth_required = provider_data.get("requires_auth", False) - free = not auth_required - - # Extract tags from provider data - tags = [] - if "tags" in provider_data: - tags = provider_data["tags"] if isinstance(provider_data["tags"], list) else [provider_data["tags"]] - - # Build description - description = provider_data.get("description", "") or provider_data.get("note", "") - if not description and provider_data.get("name"): - description = f"{provider_data.get('name')} - {provider_data.get('category', 'unknown')} provider" - - provider_entry = { - "id": provider_id, - "name": provider_data.get("name", provider_id), - "category": provider_data.get("category", "unknown"), - "base_url": provider_data.get("base_url", ""), - "auth_required": auth_required, - "free": free, - "tags": tags, - "description": description, - "type": provider_data.get("type", "http"), - "priority": provider_data.get("priority", 0), - "weight": provider_data.get("weight", 0), - "rate_limit": provider_data.get("rate_limit", {}), - "endpoints": provider_data.get("endpoints", {}), - "status": discovery_data.get("status", "UNKNOWN") if discovery_data else "unvalidated", - "validated_at": provider_data.get("validated_at"), - "response_time_ms": discovery_data.get("response_time_ms") or provider_data.get("response_time_ms"), - "added_by": provider_data.get("added_by", "manual") - } - providers_list.append(provider_entry) - - # Add HF Models as providers (with proper structure) - try: - from ai_models import MODEL_SPECS, _registry - for model_key, spec in MODEL_SPECS.items(): - is_loaded = model_key in _registry._pipelines - providers_list.append({ - "id": f"hf_model_{model_key}", - "name": f"HF Model: {spec.model_id}", - "category": spec.category, - "base_url": f"/api/models/{model_key}/predict", - "auth_required": spec.requires_auth, - "free": not spec.requires_auth, - "tags": ["huggingface", "ai-model", spec.task, spec.category], - "description": f"Hugging Face {spec.task} model for {spec.category}", - "type": "hf_model", - "status": "available" if is_loaded else "not_loaded", - "model_key": model_key, - "model_id": spec.model_id, - "task": spec.task, - "added_by": "hf_models" - }) - except Exception as e: - logger.warning(f"Could not add HF models as providers: {e}") - - # Apply deduplication - deduplicated_providers = deduplicate_providers(providers_list) - - return { - "providers": deduplicated_providers, - "total": len(deduplicated_providers), - "source": "providers_config_extended.json + PROVIDER_AUTO_DISCOVERY_REPORT.json + HF Models (deduplicated)" - } - except Exception as e: - logger.error(f"Error in get_providers: {e}") - return { - "providers": [], - "total": 0, - "error": str(e), - "source": "error" - } - - -@app.get("/api/providers/{provider_id}") -async def get_provider_detail(provider_id: str): - """Get specific provider details""" - # Check if it's an HF model provider - if provider_id.startswith("hf_model_"): - model_key = provider_id.replace("hf_model_", "") - try: - from ai_models import MODEL_SPECS, _registry - if model_key not in MODEL_SPECS: - raise HTTPException(status_code=404, detail=f"Model {model_key} not found") - - spec = MODEL_SPECS[model_key] - is_loaded = model_key in _registry._pipelines - - return { - "provider_id": provider_id, - "name": f"HF Model: {spec.model_id}", - "category": spec.category, - "type": "hf_model", - "status": "available" if is_loaded else "not_loaded", - "model_key": model_key, - "model_id": spec.model_id, - "task": spec.task, - "requires_auth": spec.requires_auth, - "endpoint": f"/api/models/{model_key}/predict", - "usage": { - "method": "POST", - "url": f"/api/models/{model_key}/predict", - "body": {"text": "string", "options": {}} - }, - "added_by": "hf_models" - } - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - # Regular provider - config = load_providers_config() - providers = config.get("providers", {}) - - if provider_id not in providers: - raise HTTPException(status_code=404, detail=f"Provider {provider_id} not found") - - return { - "provider_id": provider_id, - **providers[provider_id] - } - - -@app.get("/api/providers/category/{category}") -async def get_providers_by_category(category: str): - """Get providers by category""" - config = load_providers_config() - providers = config.get("providers", {}) - - filtered = { - pid: data for pid, data in providers.items() - if data.get("category") == category - } - - return { - "category": category, - "providers": filtered, - "count": len(filtered) - } - - -# ===== Pools Endpoints (Placeholder - to be implemented) ===== -@app.get("/api/pools") -async def get_pools(): - """Get provider pools""" - return { - "pools": [], - "message": "Pools feature not yet implemented in this version" - } - - -# ===== Logs Endpoints ===== -@app.get("/api/logs/recent") -async def get_recent_logs(): - """Get recent logs""" - return { - "logs": _provider_state.get("logs", [])[-50:], - "count": min(50, len(_provider_state.get("logs", []))) - } - - -@app.get("/api/logs/errors") -async def get_error_logs(): - """Get error logs""" - all_logs = _provider_state.get("logs", []) - errors = [log for log in all_logs if log.get("level") == "ERROR"] - return { - "errors": errors[-50:], - "count": len(errors) - } - - -# ===== Diagnostics Endpoints ===== -@app.post("/api/diagnostics/run") -async def run_diagnostics(auto_fix: bool = False): - """Run system diagnostics""" - issues = [] - fixes_applied = [] - - # Check database - if not DB_PATH.exists(): - issues.append({"type": "database", "message": "Database file not found"}) - if auto_fix: - init_database() - fixes_applied.append("Initialized database") - - # Check providers config - if not PROVIDERS_CONFIG_PATH.exists(): - issues.append({"type": "config", "message": "Providers config not found"}) - - # Check auto-discovery report - if not AUTO_DISCOVERY_REPORT_PATH.exists(): - issues.append({"type": "auto_discovery", "message": "Auto-discovery report not found"}) - - return { - "status": "completed", - "issues_found": len(issues), - "issues": issues, - "fixes_applied": fixes_applied if auto_fix else [], - "timestamp": datetime.now().isoformat() - } - - -@app.get("/api/diagnostics/last") -async def get_last_diagnostics(): - """Get last diagnostics results""" - # Would load from file in real implementation - return { - "status": "no_previous_run", - "message": "No previous diagnostics run found" - } - - -@app.get("/api/diagnostics/health") -async def get_diagnostics_health(): - """ - Get comprehensive health status of all providers and models. - Returns health registry data for diagnostics and observability. - """ - try: - # Get provider health - provider_health = _health_registry.get_all_entries() - provider_summary = _health_registry.get_summary() - - # Get model health - model_health = [] - model_summary = { - "total": 0, - "healthy": 0, - "degraded": 0, - "unavailable": 0, - "unknown": 0, - "in_cooldown": 0 - } - - try: - from ai_models import get_model_health_registry - model_health = get_model_health_registry() - # Calculate model summary - model_summary["total"] = len(model_health) - for model in model_health: - status = model.get("status", "unknown") - model_summary[status] = model_summary.get(status, 0) + 1 - if model.get("in_cooldown", False): - model_summary["in_cooldown"] += 1 - except Exception as e: - logger.warning(f"Could not load model health: {e}") - - return { - "status": "success", - "timestamp": datetime.now().isoformat(), - "providers": { - "summary": provider_summary, - "entries": provider_health - }, - "models": { - "summary": model_summary, - "entries": model_health - }, - "overall_health": { - "providers_ok": provider_summary["healthy"] >= (provider_summary["total"] // 2) if provider_summary["total"] > 0 else True, - "models_ok": model_summary["healthy"] >= (model_summary["total"] // 4) if model_summary["total"] > 0 else True - } - } - except Exception as e: - logger.error(f"Error getting health diagnostics: {e}") - return { - "status": "error", - "error": str(e), - "timestamp": datetime.now().isoformat() - } - - -@app.post("/api/diagnostics/run-test") -async def run_diagnostic_test(): - """ - Run test_models_diagnostic.py and return results. - Execute the Python script and capture stdout/stderr. - """ - import subprocess - import time - - start_time = time.time() - - try: - # Find the diagnostic script - check multiple possible locations - diagnostic_script = None - possible_paths = [ - WORKSPACE_ROOT / "test_models_diagnostic.py", - Path("test_models_diagnostic.py"), - Path(__file__).parent / "test_models_diagnostic.py", - ] - - for path in possible_paths: - if path.exists(): - diagnostic_script = path - break - - if not diagnostic_script: - return { - "status": "error", - "output": "test_models_diagnostic.py not found. Searched in:\n" + "\n".join([str(p) for p in possible_paths]), - "timestamp": datetime.now().isoformat(), - "duration_seconds": 0, - "summary": { - "transformers_available": False, - "hf_hub_connected": False, - "models_loaded": 0, - "critical_issues": ["Diagnostic script not found"] - } - } - - # Execute the diagnostic script - result = subprocess.run( - ["python3", str(diagnostic_script)], - capture_output=True, - text=True, - timeout=60, # 60 second timeout - cwd=str(diagnostic_script.parent) - ) - - duration = time.time() - start_time - - # Combine stdout and stderr - full_output = result.stdout - if result.stderr: - full_output += "\n--- STDERR ---\n" + result.stderr - - # Parse output for summary information - summary = { - "transformers_available": "✅ transformers:" in full_output and "OK" in full_output, - "hf_hub_connected": "✅ Hub connection:" in full_output and "OK" in full_output, - "models_loaded": 0, # Would need more parsing to count actual loaded models - "critical_issues": [] - } - - # Check for critical issues - if "❌ transformers:" in full_output: - summary["critical_issues"].append("Transformers library not available") - if "❌ Authenticated access:" in full_output and "FAILED" in full_output: - summary["critical_issues"].append("HuggingFace authentication failed") - if "❌ Model not available" in full_output: - summary["critical_issues"].append("AI models failed to load") - - return { - "status": "success", - "output": full_output, - "timestamp": datetime.now().isoformat(), - "duration_seconds": round(duration, 2), - "summary": summary - } - - except subprocess.TimeoutExpired: - duration = time.time() - start_time - return { - "status": "timeout", - "output": f"Test timed out after {duration:.1f} seconds", - "timestamp": datetime.now().isoformat(), - "duration_seconds": round(duration, 2), - "summary": { - "transformers_available": False, - "hf_hub_connected": False, - "models_loaded": 0, - "critical_issues": ["Test execution timed out"] - } - } - - except Exception as e: - duration = time.time() - start_time - return { - "status": "error", - "output": f"Error running diagnostic test: {str(e)}", - "timestamp": datetime.now().isoformat(), - "duration_seconds": round(duration, 2), - "summary": { - "transformers_available": False, - "hf_hub_connected": False, - "models_loaded": 0, - "critical_issues": [f"Execution error: {str(e)}"] - } - } - - -@app.post("/api/diagnostics/self-heal") -async def trigger_self_heal(model_key: Optional[str] = None): - """ - Trigger self-healing actions for models. - Safe, idempotent, and non-blocking. - - Query params: - model_key: Specific model to reinitialize (optional) - """ - try: - from ai_models import attempt_model_reinit, get_model_health_registry - - results = [] - - if model_key: - # Reinit specific model - result = attempt_model_reinit(model_key) - results.append({ - "model_key": model_key, - **result - }) - else: - # Reinit all failed models that are out of cooldown - model_health = get_model_health_registry() - failed_models = [ - m for m in model_health - if m.get("status") in ["unavailable", "degraded"] - and not m.get("in_cooldown", False) - ] - - for model in failed_models[:5]: # Limit to 5 at a time to avoid blocking - result = attempt_model_reinit(model["key"]) - results.append({ - "model_key": model["key"], - **result - }) - - success_count = sum(1 for r in results if r.get("status") == "success") - - return { - "status": "completed", - "timestamp": datetime.now().isoformat(), - "results": results, - "summary": { - "total_attempts": len(results), - "successful": success_count, - "failed": len(results) - success_count - } - } - except Exception as e: - logger.error(f"Error in self-heal: {e}") - return { - "status": "error", - "error": str(e), - "timestamp": datetime.now().isoformat() - } - - -# ===== APL (Auto Provider Loader) Endpoints ===== -@app.post("/api/apl/run") -async def run_apl_scan(): - """Run APL provider scan""" - try: - # Run APL script - result = subprocess.run( - ["python3", str(WORKSPACE_ROOT / "auto_provider_loader.py")], - capture_output=True, - text=True, - timeout=300, - cwd=str(WORKSPACE_ROOT) - ) - - # Reload providers after APL run - config = load_providers_config() - _provider_state["providers"] = config.get("providers", {}) - - return { - "status": "completed", - "stdout": result.stdout[-1000:], # Last 1000 chars - "returncode": result.returncode, - "providers_count": len(_provider_state["providers"]), - "timestamp": datetime.now().isoformat() - } - - except subprocess.TimeoutExpired: - return { - "status": "timeout", - "message": "APL scan timed out after 5 minutes" - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"APL scan failed: {str(e)}") - - -@app.get("/api/apl/report") -async def get_apl_report(): - """Get APL validation report (alias for auto-discovery report)""" - return await get_providers_auto_discovery_report() - -@app.get("/api/providers/auto-discovery-report") -async def get_providers_auto_discovery_report(): - """Get PROVIDER_AUTO_DISCOVERY_REPORT.json""" - report = load_auto_discovery_report() - - if not report: - return { - "ok": False, - "error": "Auto-discovery report file not found", - "message": f"Report file not found at {AUTO_DISCOVERY_REPORT_PATH}" - } - - return { - "ok": True, - "report": report, - "source": "PROVIDER_AUTO_DISCOVERY_REPORT.json" - } - -@app.get("/api/providers/health-summary") -async def get_providers_health_summary(): - """Get simplified health summary from auto-discovery report + local routes - always returns 200""" - try: - report = load_auto_discovery_report() - - # Load local routes for health checking - resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" - local_routes = [] - local_health = {"total": 0, "checked": 0, "up": 0, "down": 0} - - if resources_json.exists(): - try: - with open(resources_json, 'r', encoding='utf-8') as f: - unified_data = json.load(f) - unified_registry = unified_data.get('registry', {}) - local_routes = unified_registry.get('local_backend_routes', []) - local_health["total"] = len(local_routes) - - # Quick health check for up to 10 local routes - async with httpx.AsyncClient(timeout=2.0) as client: - routes_to_check = [r for r in local_routes if 'ws://' not in r.get('base_url', '')][:10] - for route in routes_to_check: - base_url = route.get('base_url', '').replace('{API_BASE}', f'http://localhost:{PORT}') - if 'http' in base_url: - try: - response = await client.get(base_url, timeout=2.0) - local_health["checked"] += 1 - if response.status_code < 500: - local_health["up"] += 1 - else: - local_health["down"] += 1 - except: - local_health["checked"] += 1 - local_health["down"] += 1 - except Exception as e: - logger.error(f"Error checking local routes health: {e}") - - if not report or "stats" not in report: - return JSONResponse( - status_code=200, - content={ - "ok": False, - "error": "Auto-discovery report not found or invalid", - "message": f"Report file not found at {AUTO_DISCOVERY_REPORT_PATH}", - "summary": { - "total_active_providers": 0, - "http_valid": 0, - "http_invalid": 0, - "http_conditional": 0, - "hf_valid": 0, - "hf_invalid": 0, - "hf_conditional": 0, - "status_breakdown": {"VALID": 0, "INVALID": 0, "CONDITIONALLY_AVAILABLE": 0}, - "execution_time_sec": 0, - "timestamp": "", - "local_routes": local_health - } - } - ) - - stats = report.get("stats", {}) - http_providers = report.get("http_providers", {}) - hf_providers = report.get("hf_providers", {}) - - # Count by status - status_counts = {"VALID": 0, "INVALID": 0, "CONDITIONALLY_AVAILABLE": 0} - for result in http_providers.get("results", []): - status = result.get("status", "UNKNOWN") - if status in status_counts: - status_counts[status] += 1 - - return JSONResponse( - status_code=200, - content={ - "ok": True, - "summary": { - "total_active_providers": stats.get("total_active_providers", 0), - "http_valid": stats.get("http_valid", 0), - "http_invalid": stats.get("http_invalid", 0), - "http_conditional": stats.get("http_conditional", 0), - "hf_valid": stats.get("hf_valid", 0), - "hf_invalid": stats.get("hf_invalid", 0), - "hf_conditional": stats.get("hf_conditional", 0), - "status_breakdown": status_counts, - "execution_time_sec": stats.get("execution_time_sec", 0), - "timestamp": stats.get("timestamp", ""), - "local_routes": local_health - }, - "source": "PROVIDER_AUTO_DISCOVERY_REPORT.json + local routes" - } - ) - except Exception as e: - logger.error(f"Error loading health summary: {e}") - return JSONResponse( - status_code=200, - content={ - "ok": False, - "error": str(e), - "summary": { - "total_active_providers": 0, - "http_valid": 0, - "http_invalid": 0, - "http_conditional": 0, - "hf_valid": 0, - "hf_invalid": 0, - "hf_conditional": 0, - "status_breakdown": {"VALID": 0, "INVALID": 0, "CONDITIONALLY_AVAILABLE": 0}, - "execution_time_sec": 0, - "timestamp": "", - "local_routes": {"total": 0, "checked": 0, "up": 0, "down": 0} - } - } - ) - -@app.get("/api/apl/summary") -async def get_apl_summary(): - """Get APL summary statistics (alias for health-summary)""" - return await get_providers_health_summary() - - -# ===== HF Models Endpoints ===== -@app.get("/api/hf/models") -async def get_hf_models(): - """Get HuggingFace models from APL report""" - report = load_apl_report() - - if not report: - return {"models": [], "count": 0} - - hf_models = report.get("hf_models", {}).get("results", []) - - return { - "models": hf_models, - "count": len(hf_models), - "source": "APL Validation Report (Real Data)" - } - - -@app.get("/api/hf/health") -async def get_hf_health(): - """Get HF services health""" - try: - from backend.services.hf_registry import REGISTRY - health = REGISTRY.health() - return health - except Exception as e: - return { - "ok": False, - "error": f"HF registry not available: {str(e)}" - } - - -# ===== DeFi Endpoint ===== -@app.get("/api/defi") -async def get_defi(): - """DeFi endpoint""" - return { - "success": True, - "message": "DeFi data endpoint", - "data": [], - "timestamp": datetime.now().isoformat() - } - - -# ===== News Endpoint (compatible with UI) ===== -@app.get("/api/news") -async def get_news_api(limit: int = 20): - """Get news (compatible with UI) - with external API fallback""" - try: - # Try to get news from database first - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - cursor.execute(""" - SELECT * FROM news_articles - ORDER BY analyzed_at DESC - LIMIT ? - """, (limit,)) - rows = cursor.fetchall() - columns = [desc[0] for desc in cursor.description] - conn.close() - - results = [] - for row in rows: - record = dict(zip(columns, row)) - if record.get("related_symbols"): - try: - record["related_symbols"] = json.loads(record["related_symbols"]) - except: - pass - results.append(record) - - # If database is empty, fetch from external API - if len(results) == 0: - logger.info("No news in database, fetching from external API...") - try: - # Get API key from environment - cryptocompare_api_key = os.getenv("CRYPTOCOMPARE_API_KEY", "968a5e25552b4cb5ba3280361d8444ab") - - async with httpx.AsyncClient(timeout=10.0) as client: - # Try CryptoCompare News API with API key - response = await client.get( - "https://min-api.cryptocompare.com/data/v2/news/?lang=EN", - headers={ - "User-Agent": "Mozilla/5.0", - "authorization": f"Apikey {cryptocompare_api_key}" - } - ) - if response.status_code == 200: - data = response.json() - if data.get("Data"): - for article in data["Data"][:limit]: - results.append({ - "id": article.get("id"), - "title": article.get("title", ""), - "content": article.get("body", "")[:500], - "url": article.get("url", ""), - "source": article.get("source", "CryptoCompare"), - "sentiment_label": None, - "sentiment_confidence": None, - "related_symbols": article.get("categories", "").split("|") if article.get("categories") else [], - "published_date": datetime.fromtimestamp(article.get("published_on", 0)).isoformat() if article.get("published_on") else None, - "analyzed_at": datetime.now().isoformat() - }) - logger.info(f"Fetched {len(results)} news articles from CryptoCompare") - except Exception as api_error: - logger.warning(f"External news API failed: {api_error}") - - return { - "success": True, - "news": results, - "count": len(results), - "source": "database" if len(results) > 0 and rows else "external_api" - } - except Exception as e: - logger.error(f"Error in get_news_api: {e}") - return { - "success": False, - "news": [], - "count": 0, - "error": str(e) - } - - -# ===== Logs Endpoints ===== -@app.get("/api/logs/summary") -async def get_logs_summary(): - """Get logs summary""" - try: - return { - "success": True, - "total": len(_provider_state.get("logs", [])), - "recent": _provider_state.get("logs", [])[-10:], - "timestamp": datetime.now().isoformat() - } - except Exception as e: - return { - "success": False, - "error": str(e) - } - - -# ===== Diagnostics Endpoints ===== -@app.get("/api/diagnostics/errors") -async def get_diagnostics_errors(): - """Get diagnostic errors""" - try: - return { - "success": True, - "errors": [], - "timestamp": datetime.now().isoformat() - } - except Exception as e: - return { - "success": False, - "errors": [], - "error": str(e) - } - - -# ===== Resources Endpoints ===== -@app.get("/api/resources/search") -async def search_resources(q: str = "", source: str = "all"): - """Search resources""" - try: - return { - "success": True, - "query": q, - "source": source, - "results": [], - "count": 0 - } - except Exception as e: - return { - "success": False, - "error": str(e) - } - - -# ===== V2 API Endpoints (compatibility) ===== -@app.post("/api/v2/export/{export_type}") -async def export_v2(export_type: str, data: Dict[str, Any] = None): - """V2 export endpoint""" - return { - "success": True, - "type": export_type, - "message": "Export functionality", - "data": data or {} - } - - -@app.post("/api/v2/backup") -async def backup_v2(): - """V2 backup endpoint""" - return { - "success": True, - "message": "Backup functionality", - "timestamp": datetime.now().isoformat() - } - - -@app.post("/api/v2/import/providers") -async def import_providers_v2(data: Dict[str, Any]): - """V2 import providers endpoint""" - return { - "success": True, - "message": "Import providers functionality", - "data": data - } - - -# ===== HuggingFace ML Sentiment Endpoints ===== -@app.post("/api/sentiment/analyze") -async def analyze_sentiment(request: Dict[str, Any]): - """Analyze sentiment using Hugging Face models""" - try: - from ai_models import ( - analyze_crypto_sentiment, - analyze_financial_sentiment, - analyze_social_sentiment, - analyze_market_text, - _registry, - MODEL_SPECS, - ModelNotAvailable - ) - - text = request.get("text", "").strip() - if not text: - raise HTTPException(status_code=400, detail="Text is required") - - mode = request.get("mode", "auto").lower() - source = request.get("source", "user") - model_key = request.get("model_key") - symbol = request.get("symbol") - - try: - # If model_key is provided, use that specific model - if model_key and model_key in MODEL_SPECS: - try: - pipeline = _registry.get_pipeline(model_key) - spec = MODEL_SPECS[model_key] - - # Handle different task types - if spec.task == "text-generation": - # For trading signal models or generation models - raw_result = pipeline(text, max_length=200, num_return_sequences=1) - if isinstance(raw_result, list) and raw_result: - raw_result = raw_result[0] - - generated_text = raw_result.get("generated_text", str(raw_result)) - - # Parse trading signals if applicable - if spec.category == "trading_signal": - # Extract signal from generated text - decision = "HOLD" - if "buy" in generated_text.lower(): - decision = "BUY" - elif "sell" in generated_text.lower(): - decision = "SELL" - - return { - "ok": True, - "available": True, - "sentiment": decision.lower(), - "label": decision.lower(), - "score": 0.7, - "confidence": 0.7, - "model": model_key, - "engine": "huggingface", - "mode": "trading", - "extra": { - "decision": decision, - "rationale": generated_text, - "raw": raw_result - } - } - else: - # Generation model - return generated text - return { - "ok": True, - "available": True, - "sentiment": "neutral", - "label": "neutral", - "score": 0.5, - "confidence": 0.5, - "model": model_key, - "engine": "huggingface", - "mode": "generation", - "extra": { - "generated_text": generated_text, - "raw": raw_result - } - } - else: - # Text classification / sentiment - raw_result = pipeline(text[:512]) - if isinstance(raw_result, list) and raw_result: - raw_result = raw_result[0] - - label = raw_result.get("label", "neutral").upper() - score = raw_result.get("score", 0.5) - - # Map labels to standard format - mapped = "bullish" if "POSITIVE" in label or "BULLISH" in label or "LABEL_2" in label else ( - "bearish" if "NEGATIVE" in label or "BEARISH" in label or "LABEL_0" in label else "neutral" - ) - - return { - "ok": True, - "available": True, - "sentiment": mapped, - "label": mapped, - "score": score, - "confidence": score, - "raw_label": label, - "model": model_key, - "engine": "huggingface", - "mode": mode, - "extra": { - "vote": score if mapped == "bullish" else (-score if mapped == "bearish" else 0.0), - "raw": raw_result - } - } - except ModelNotAvailable as e: - logger.warning(f"Model {model_key} not available: {e}") - return { - "ok": False, - "available": False, - "error": f"Model {model_key} not available: {str(e)}", - "label": "neutral", - "sentiment": "neutral", - "score": 0.0, - "confidence": 0.0 - } - - # Default mode-based analysis - if mode == "crypto": - result = analyze_crypto_sentiment(text) - elif mode == "financial": - result = analyze_financial_sentiment(text) - elif mode == "social": - result = analyze_social_sentiment(text) - elif mode == "trading": - # Try to use trading signal model - result = analyze_crypto_sentiment(text) - else: - result = analyze_market_text(text) - - sentiment_label = result.get("label", "neutral") - confidence = result.get("confidence", result.get("score", 0.5)) - model_used = result.get("model_count", result.get("model", result.get("engine", "unknown"))) - - # Prepare response compatible with frontend format - response_data = { - "ok": True, - "available": True, - "sentiment": sentiment_label.lower(), - "label": sentiment_label.lower(), - "confidence": float(confidence), - "score": float(confidence), - "model": f"{model_used} models" if isinstance(model_used, int) else str(model_used), - "engine": result.get("engine", "huggingface"), - "mode": mode - } - - # Add details if available for score bars - if result.get("scores"): - scores_dict = result.get("scores", {}) - if isinstance(scores_dict, dict): - labels_list = [] - scores_list = [] - for lbl, scr in scores_dict.items(): - labels_list.append(lbl) - scores_list.append(float(scr) if isinstance(scr, (int, float)) else float(scr.get("score", 0.5)) if isinstance(scr, dict) else 0.5) - if labels_list: - response_data["details"] = { - "labels": labels_list, - "scores": scores_list - } - - # Save to database - try: - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO sentiment_analysis - (text, sentiment_label, confidence, model_used, analysis_type, symbol, scores) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - text[:500], - sentiment_label, - confidence, - f"{model_used} models" if isinstance(model_used, int) else str(model_used), - mode, - symbol, - json.dumps(result.get("scores", {})) - )) - conn.commit() - conn.close() - except Exception as db_error: - logger.warning(f"Failed to save to database: {db_error}") - - return response_data - - except Exception as e: - # Unexpected error - log and return error response - logger.error(f"Sentiment analysis unexpected error: {str(e)}") - return { - "ok": False, - "available": False, - "error": f"Analysis failed: {str(e)}", - "sentiment": "neutral", - "label": "neutral", - "confidence": 0.0, - "score": 0.0 - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Sentiment analysis failed: {str(e)}") - - -@app.post("/api/ai/summarize") -async def summarize_text(request: Dict[str, Any]): - """ - Summarize text using Hugging Face models or simple text processing. - - Expects: { "text": "string", "max_sentences": 3 } - Returns: { "ok": true, "summary": "...", "sentences": ["...", "..."] } - """ - try: - text = request.get("text", "").strip() - max_sentences = request.get("max_sentences", 3) - - if not text: - return { - "ok": False, - "error": "Text is required" - } - - # Try to use Hugging Face summarization model if available - try: - from ai_models import MODEL_SPECS, _registry, ModelNotAvailable - - # Check if summarization model is available - summarization_key = None - for key, spec in MODEL_SPECS.items(): - if spec.task == "summarization": - summarization_key = key - break - - if summarization_key: - try: - pipeline = _registry.get_pipeline(summarization_key) - # Use HF model for summarization - # Try with parameters first, then fallback to simple call - try: - summary_result = pipeline(text, max_length=max_sentences * 50, min_length=max_sentences * 20, do_sample=False) - except TypeError: - # Some pipelines don't accept these parameters - summary_result = pipeline(text) - - if isinstance(summary_result, list) and summary_result: - summary_text = summary_result[0].get("summary_text", summary_result[0].get("generated_text", str(summary_result[0]))) - elif isinstance(summary_result, dict): - summary_text = summary_result.get("summary_text", summary_result.get("generated_text", str(summary_result))) - else: - summary_text = str(summary_result) - - # Split into sentences - sentences = [s.strip() + ("." if not s.strip().endswith((".", "!", "?")) else "") for s in summary_text.split(". ") if s.strip()] - sentences = sentences[:max_sentences] - - return { - "ok": True, - "summary": summary_text, - "sentences": sentences - } - except ModelNotAvailable: - # Fall through to simple summarizer - pass - except Exception as e: - logger.warning(f"HF summarization failed: {e}, using fallback") - # Fall through to simple summarizer - pass - except Exception as e: - logger.warning(f"HF summarization model not available: {e}") - # Fall through to simple summarizer - - # Simple placeholder summarizer: split by sentences and take first N - sentences = [] - current_sentence = "" - - for char in text: - current_sentence += char - if char in ".!?": - sentence = current_sentence.strip() - if sentence: - sentences.append(sentence) - current_sentence = "" - if len(sentences) >= max_sentences: - break - - # If we didn't get enough sentences, add the rest - if len(sentences) < max_sentences and current_sentence.strip(): - sentences.append(current_sentence.strip()) - - # If still no sentences, just truncate - if not sentences: - words = text.split() - chunk_size = len(words) // max_sentences - sentences = [] - for i in range(max_sentences): - start_idx = i * chunk_size - end_idx = start_idx + chunk_size if i < max_sentences - 1 else len(words) - if start_idx < len(words): - sentence = " ".join(words[start_idx:end_idx]) - if sentence: - sentences.append(sentence) - - summary = " ".join(sentences) - - return { - "ok": True, - "summary": summary, - "sentences": sentences[:max_sentences] - } - - except Exception as e: - logger.error(f"Summarization failed: {e}") - return { - "ok": False, - "error": f"Summarization failed: {str(e)}" - } - - -@app.post("/api/news/analyze") -async def analyze_news(request: Dict[str, Any]): - """Analyze news article sentiment using HF models""" - try: - from ai_models import analyze_news_item - - title = request.get("title", "").strip() - content = request.get("content", request.get("description", "")).strip() - url = request.get("url", "") - source = request.get("source", "unknown") - published_date = request.get("published_date") - - if not title and not content: - raise HTTPException(status_code=400, detail="Title or content is required") - - try: - news_item = { - "title": title, - "description": content - } - result = analyze_news_item(news_item) - - sentiment_label = result.get("sentiment", "neutral") - sentiment_confidence = result.get("sentiment_confidence", 0.5) - sentiment_details = result.get("sentiment_details", {}) - related_symbols = request.get("related_symbols", []) - - # Check if HF models were used (for diagnostics) - hf_available = sentiment_details.get("engine", "unknown") == "huggingface" if isinstance(sentiment_details, dict) else True - - # Save to database (always) - saved_to_db = False - try: - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO news_articles - (title, content, url, source, sentiment_label, sentiment_confidence, related_symbols, published_date) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, ( - title[:500], - content[:2000] if content else None, - url, - source, - sentiment_label, - sentiment_confidence, - json.dumps(related_symbols) if related_symbols else None, - published_date - )) - conn.commit() - conn.close() - saved_to_db = True - except Exception as db_error: - logger.warning(f"Failed to save to database: {db_error}") - - return { - "success": True, - "available": True, - "hf_models_available": hf_available, - "news": { - "title": title, - "sentiment": sentiment_label, - "confidence": sentiment_confidence, - "details": sentiment_details - }, - "saved_to_db": saved_to_db - } - - except Exception as e: - logger.error(f"News analysis error: {str(e)}") - return { - "success": False, - "available": False, - "error": f"Analysis failed: {str(e)}", - "news": { - "title": title, - "sentiment": "neutral", - "confidence": 0.0 - } - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"News analysis failed: {str(e)}") - - -@app.get("/api/sentiment/history") -async def get_sentiment_history( - symbol: Optional[str] = None, - limit: int = 50 -): - """Get sentiment analysis history from database""" - try: - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - - if symbol: - cursor.execute(""" - SELECT * FROM sentiment_analysis - WHERE symbol = ? - ORDER BY timestamp DESC - LIMIT ? - """, (symbol.upper(), limit)) - else: - cursor.execute(""" - SELECT * FROM sentiment_analysis - ORDER BY timestamp DESC - LIMIT ? - """, (limit,)) - - rows = cursor.fetchall() - columns = [desc[0] for desc in cursor.description] - conn.close() - - results = [] - for row in rows: - record = dict(zip(columns, row)) - if record.get("scores"): - try: - record["scores"] = json.loads(record["scores"]) - except: - pass - results.append(record) - - return { - "success": True, - "count": len(results), - "results": results - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to fetch sentiment history: {str(e)}") - - -@app.post("/api/news/fetch") -async def fetch_and_save_news(limit: int = 50): - """Fetch news from CryptoCompare API and save to database""" - try: - cryptocompare_api_key = os.getenv("CRYPTOCOMPARE_API_KEY", "968a5e25552b4cb5ba3280361d8444ab") - - async with httpx.AsyncClient(timeout=15.0) as client: - response = await client.get( - "https://min-api.cryptocompare.com/data/v2/news/?lang=EN", - headers={ - "User-Agent": "Mozilla/5.0", - "authorization": f"Apikey {cryptocompare_api_key}" - } - ) - - if response.status_code != 200: - return { - "success": False, - "error": f"CryptoCompare API returned {response.status_code}", - "saved": 0 - } - - data = response.json() - - if not data.get("Data"): - return { - "success": False, - "error": "No news data returned from API", - "saved": 0 - } - - # Save to database - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - saved_count = 0 - - for article in data["Data"][:limit]: - try: - # Check if article already exists - cursor.execute("SELECT id FROM news_articles WHERE url = ?", (article.get("url", ""),)) - if cursor.fetchone(): - continue # Skip duplicates - - # Extract related symbols from categories - categories = article.get("categories", "").split("|") if article.get("categories") else [] - related_symbols_json = json.dumps(categories) - - # Insert news article - cursor.execute(""" - INSERT INTO news_articles ( - title, content, url, source, - related_symbols, published_date, analyzed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - article.get("title", ""), - article.get("body", "")[:1000], # Limit content length - article.get("url", ""), - article.get("source", "CryptoCompare"), - related_symbols_json, - datetime.fromtimestamp(article.get("published_on", 0)).isoformat() if article.get("published_on") else None, - datetime.now().isoformat() - )) - saved_count += 1 - except Exception as e: - logger.warning(f"Error saving article: {e}") - continue - - conn.commit() - conn.close() - - logger.info(f"[OK] Saved {saved_count} news articles to database") - - return { - "success": True, - "saved": saved_count, - "total_fetched": len(data["Data"][:limit]), - "message": f"Successfully saved {saved_count} news articles" - } - - except Exception as e: - logger.error(f"Error fetching news: {e}") - return { - "success": False, - "error": str(e), - "saved": 0 - } - - -@app.get("/api/news/latest") -async def get_latest_news( - limit: int = 20, - sentiment: Optional[str] = None -): - """Get latest analyzed news from database""" - try: - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - - if sentiment: - cursor.execute(""" - SELECT * FROM news_articles - WHERE sentiment_label = ? - ORDER BY analyzed_at DESC - LIMIT ? - """, (sentiment.lower(), limit)) - else: - cursor.execute(""" - SELECT * FROM news_articles - ORDER BY analyzed_at DESC - LIMIT ? - """, (limit,)) - - rows = cursor.fetchall() - columns = [desc[0] for desc in cursor.description] - conn.close() - - results = [] - for row in rows: - record = dict(zip(columns, row)) - if record.get("related_symbols"): - try: - record["related_symbols"] = json.loads(record["related_symbols"]) - except: - pass - results.append(record) - - return { - "success": True, - "count": len(results), - "news": results - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to fetch news: {str(e)}") - - -@app.post("/api/news/summarize") -async def summarize_news(request: Dict[str, Any]): - """ - Summarize crypto/financial news using Hugging Face Crypto-Financial-News-Summarizer model - - Expects: { "title": "News Title", "content": "Full article text" } - Returns: { "summary": "Summarized news paragraph", "model": "Crypto-Financial-News-Summarizer" } - """ - try: - from ai_models import MODEL_SPECS, _registry, ModelNotAvailable - - title = request.get("title", "").strip() - content = request.get("content", "").strip() - - if not title and not content: - raise HTTPException(status_code=400, detail="Title or content is required") - - # Combine title and content for summarization - text_to_summarize = f"{title}. {content}" if title and content else (title or content) - - try: - # Try to use the Crypto-Financial-News-Summarizer model - summarization_key = "summarization_0" - - if summarization_key in MODEL_SPECS: - try: - pipeline = _registry.get_pipeline(summarization_key) - spec = MODEL_SPECS[summarization_key] - - # Use HF model for summarization - # Limit input text to avoid token length issues - max_input_length = 1024 - text_input = text_to_summarize[:max_input_length] - - try: - # Try with parameters first - summary_result = pipeline( - text_input, - max_length=150, - min_length=50, - do_sample=False, - truncation=True - ) - except TypeError: - # Some pipelines don't accept these parameters - summary_result = pipeline(text_input, truncation=True) - - # Extract summary text from result - if isinstance(summary_result, list) and summary_result: - summary_text = summary_result[0].get("summary_text", summary_result[0].get("generated_text", str(summary_result[0]))) - elif isinstance(summary_result, dict): - summary_text = summary_result.get("summary_text", summary_result.get("generated_text", str(summary_result))) - else: - summary_text = str(summary_result) - - return { - "success": True, - "summary": summary_text, - "model": spec.model_id, - "available": True, - "input_length": len(text_input), - "title": title, - "timestamp": datetime.now().isoformat() - } - - except ModelNotAvailable as e: - logger.warning(f"Crypto-Financial-News-Summarizer not available: {e}") - # Fall through to fallback - except Exception as e: - logger.warning(f"HF summarization failed: {e}, using fallback") - # Fall through to fallback - - # Fallback: Simple extractive summarization - # Split into sentences and take the most important ones - sentences = [] - current_sentence = "" - - for char in text_to_summarize: - current_sentence += char - if char in ".!?": - sentence = current_sentence.strip() - if sentence and len(sentence) > 10: # Filter out very short sentences - sentences.append(sentence) - current_sentence = "" - if len(sentences) >= 5: # Take first 5 sentences max - break - - # If we didn't get enough sentences, add the rest - if len(sentences) < 3 and current_sentence.strip(): - sentences.append(current_sentence.strip()) - - # Take first 3 sentences as summary - summary = " ".join(sentences[:3]) if sentences else text_to_summarize[:500] - - return { - "success": True, - "summary": summary, - "model": "fallback_extractive", - "available": False, - "note": "Using fallback extractive summarization (HF model not available)", - "title": title, - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - logger.error(f"Summarization error: {str(e)}") - return { - "success": False, - "error": f"Summarization failed: {str(e)}", - "summary": "", - "model": "error", - "available": False - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"News summarization failed: {str(e)}") - - -@app.get("/api/models/status") -async def get_models_status(): - """Get AI models status and registry info - honest status reporting""" - try: - from ai_models import ( - get_model_info, registry_status, HF_MODE, TRANSFORMERS_AVAILABLE, - INFERENCE_API_MODE, _registry, - ) - - model_info = get_model_info() - registry_info = registry_status() - loaded_count = len(_registry._pipelines) + len(_registry._inference_ready) - - # Determine honest status - if HF_MODE == "off": - status = "disabled" - status_message = "HF models are disabled (HF_MODE=off). To enable them, set HF_MODE=public or HF_MODE=auth in the environment." - elif INFERENCE_API_MODE and loaded_count > 0: - status = "ok" if len(_registry._failed_models) == 0 else "partial" - status_message = f"{loaded_count} model(s) ready via HF Inference API" - elif not TRANSFORMERS_AVAILABLE and not INFERENCE_API_MODE: - status = "transformers_unavailable" - status_message = "Transformers library is not installed. Models cannot be loaded." - elif not _registry._initialized: - status = "not_initialized" - status_message = "Models have not been initialized yet." - elif loaded_count == 0: - status = "no_models_loaded" - status_message = f"No models could be loaded. {len(_registry._failed_models)} models failed. Check model IDs or HF access." - elif loaded_count > 0: - status = "ok" if len(_registry._failed_models) == 0 else "partial" - backend = "inference API" if INFERENCE_API_MODE else "local" - status_message = f"{loaded_count} model(s) loaded successfully ({backend})" - if len(_registry._failed_models) > 0: - status_message += f", {len(_registry._failed_models)} failed" - else: - status = "unknown" - status_message = "Unknown status" - - # Format failed models as list of [key, error] tuples for ai_tools.html - failed_list = [] - for key, error in list(_registry._failed_models.items())[:10]: - failed_list.append([key, str(error)]) - - return { - "success": True, - "status": status, - "status_message": status_message, - "hf_mode": HF_MODE, - "inference_api_mode": INFERENCE_API_MODE, - "models_loaded": loaded_count, - "models_failed": len(_registry._failed_models), - "transformers_available": TRANSFORMERS_AVAILABLE, - "initialized": _registry._initialized, - "models": model_info, - "registry": registry_info, - "failed": failed_list, # Format: [[key, error], ...] for ai_tools.html - "failed_models": list(_registry._failed_models.keys())[:10], # Keep for backward compatibility - "loaded_models": list(_registry._pipelines.keys()) + list(_registry._inference_ready), - "database": { - "path": str(DB_PATH), - "exists": DB_PATH.exists() - } - } - except Exception as e: - logger.error(f"Error getting models status: {e}") - return { - "success": False, - "status": "error", - "status_message": f"Error retrieving model status: {str(e)}", - "error": str(e), - "hf_mode": "unknown", - "models_loaded": 0, - "models_failed": 0 - } - - -@app.post("/api/models/initialize") -async def initialize_ai_models(): - """Initialize AI models (force reload)""" - try: - from ai_models import initialize_models, _registry, HF_MAX_STARTUP_MODELS - - result = initialize_models(max_models=HF_MAX_STARTUP_MODELS) - registry_status = _registry.get_registry_status() - - return registry_status - except Exception as e: - logger.error(f"Failed to initialize models: {e}") - return { - "models_total": 0, - "models_loaded": 0, - "models_failed": 0, - "items": [], - "error": str(e) - } - - -# ===== Model-based Data Endpoints (Using HF Models as Data Sources) ===== -@app.get("/api/models/list") -async def list_available_models(): - """List all available Hugging Face models as data sources""" - try: - from ai_models import get_model_info, MODEL_SPECS, _registry, CRYPTO_SENTIMENT_MODELS, SOCIAL_SENTIMENT_MODELS, FINANCIAL_SENTIMENT_MODELS, NEWS_SENTIMENT_MODELS, GENERATION_MODELS, TRADING_SIGNAL_MODELS - - model_info = get_model_info() - - # Model descriptions - model_descriptions = { - "kk08/CryptoBERT": "Crypto sentiment binary classification model trained on cryptocurrency-related text", - "ElKulako/cryptobert": "Crypto social sentiment classifier (Bullish/Neutral/Bearish) for social media and news", - "StephanAkkerman/FinTwitBERT-sentiment": "Financial tweet sentiment analysis model for market-related social media content", - "OpenC/crypto-gpt-o3-mini": "Crypto and DeFi text generation model for analysis and content creation", - "ElKulako/cryptobert": "Crypto sentiment model used for trading signal generation (buy/sell/hold based on sentiment)", - "cardiffnlp/twitter-roberta-base-sentiment-latest": "General Twitter sentiment analysis (fallback model)", - "ProsusAI/finbert": "Financial sentiment analysis model for news and financial documents", - "FurkanGozukara/Crypto-Financial-News-Summarizer": "Specialized model for summarizing cryptocurrency and financial news articles" - } - - models_list = [] - for key, spec in MODEL_SPECS.items(): - is_loaded = key in _registry._pipelines or key in getattr(_registry, "_inference_ready", set()) - error_msg = None - if key in _registry._failed_models: - error_msg = str(_registry._failed_models[key]) - - models_list.append({ - "key": key, - "id": key, - "name": spec.model_id, - "model_id": spec.model_id, - "task": spec.task, - "category": spec.category, - "requires_auth": spec.requires_auth, - "loaded": is_loaded, - "error": error_msg, - "description": model_descriptions.get(spec.model_id, f"{spec.category} model for {spec.task}"), - "endpoint": f"/api/models/{key}/predict" - }) - - return { - "success": True, - "total_models": len(models_list), - "models": models_list, - "categories": { - "crypto_sentiment": CRYPTO_SENTIMENT_MODELS, - "social_sentiment": SOCIAL_SENTIMENT_MODELS, - "financial_sentiment": FINANCIAL_SENTIMENT_MODELS, - "news_sentiment": NEWS_SENTIMENT_MODELS, - "generation": GENERATION_MODELS, - "trading_signals": TRADING_SIGNAL_MODELS, - "summarization": ["FurkanGozukara/Crypto-Financial-News-Summarizer"] - }, - "model_info": model_info - } - except Exception as e: - return { - "success": False, - "error": str(e), - "models": [] - } - - -@app.get("/api/models/{model_key}/info") -async def get_model_info_endpoint(model_key: str): - """Get information about a specific model""" - try: - from ai_models import MODEL_SPECS, ModelNotAvailable, _registry - - if model_key not in MODEL_SPECS: - raise HTTPException(status_code=404, detail=f"Model {model_key} not found") - - spec = MODEL_SPECS[model_key] - is_loaded = model_key in _registry._pipelines - - return { - "success": True, - "model_key": model_key, - "model_id": spec.model_id, - "task": spec.task, - "category": spec.category, - "requires_auth": spec.requires_auth, - "is_loaded": is_loaded, - "endpoint": f"/api/models/{model_key}/predict", - "usage": { - "method": "POST", - "url": f"/api/models/{model_key}/predict", - "body": {"text": "string", "options": {}} - } - } - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/api/models/{model_key}/predict") -async def predict_with_model(model_key: str, request: Dict[str, Any]): - """Use a specific model to generate predictions/data""" - try: - from ai_models import MODEL_SPECS, _registry, ModelNotAvailable - - if model_key not in MODEL_SPECS: - raise HTTPException(status_code=404, detail=f"Model {model_key} not found") - - spec = MODEL_SPECS[model_key] - text = request.get("text", "").strip() - - if not text: - raise HTTPException(status_code=400, detail="Text is required") - - try: - pipeline = _registry.get_pipeline(model_key) - result = pipeline(text[:512]) - - if isinstance(result, list) and result: - result = result[0] - - return { - "success": True, - "available": True, - "model_key": model_key, - "model_id": spec.model_id, - "task": spec.task, - "input": text[:100], - "output": result, - "timestamp": datetime.now().isoformat() - } - except ModelNotAvailable as e: - return { - "success": False, - "available": False, - "model_key": model_key, - "model_id": spec.model_id, - "error": str(e), - "reason": "model_unavailable" - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Prediction failed: {str(e)}") - - -@app.post("/api/models/batch/predict") -async def batch_predict(request: Dict[str, Any]): - """Batch prediction using multiple models""" - try: - from ai_models import MODEL_SPECS, _registry, ModelNotAvailable - - texts = request.get("texts", []) - model_keys = request.get("models", []) - - if not texts: - raise HTTPException(status_code=400, detail="Texts array is required") - - if not model_keys: - model_keys = list(MODEL_SPECS.keys())[:5] - - results = [] - for text in texts: - if not text.strip(): - continue - - text_results = {} - for model_key in model_keys: - if model_key not in MODEL_SPECS: - continue - - try: - spec = MODEL_SPECS[model_key] - pipeline = _registry.get_pipeline(model_key) - result = pipeline(text[:512]) - - if isinstance(result, list) and result: - result = result[0] - - text_results[model_key] = { - "model_id": spec.model_id, - "result": result, - "success": True - } - except ModelNotAvailable: - text_results[model_key] = { - "success": False, - "error": "Model not available" - } - except Exception as e: - text_results[model_key] = { - "success": False, - "error": str(e) - } - - results.append({ - "text": text[:100], - "predictions": text_results - }) - - return { - "success": True, - "total_texts": len(results), - "models_used": model_keys, - "results": results, - "timestamp": datetime.now().isoformat() - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Batch prediction failed: {str(e)}") - - -@app.post("/api/analyze/text") -async def analyze_text(request: Dict[str, Any]): - """ - Analyze or generate text using crypto-gpt-o3-mini generation model. - - Expects: { "prompt": "...", "mode": "analysis" | "generation" } - Returns: { "text": "...", "model": "OpenC/crypto-gpt-o3-mini" } - """ - try: - from ai_models import MODEL_SPECS, _registry, ModelNotAvailable - - prompt = request.get("prompt", "").strip() - mode = request.get("mode", "analysis").lower() - max_length = request.get("max_length", 200) - - if not prompt: - raise HTTPException(status_code=400, detail="Prompt is required") - - # Find generation model (crypto-gpt-o3-mini) - use specific key first - generation_key = "crypto_ai_analyst" if "crypto_ai_analyst" in MODEL_SPECS else None - - # Fallback: search by category or model name - if not generation_key: - for key, spec in MODEL_SPECS.items(): - if spec.category == "analysis_generation" or "crypto-gpt" in spec.model_id.lower(): - generation_key = key - break - - if not generation_key: - return { - "success": False, - "available": False, - "error": "Crypto text generation model not configured", - "text": "" - } - - try: - spec = MODEL_SPECS[generation_key] - pipeline = _registry.get_pipeline(generation_key) - - # Generate text - result = pipeline(prompt, max_length=max_length, num_return_sequences=1, truncation=True) - - if isinstance(result, list) and result: - result = result[0] - - generated_text = result.get("generated_text", str(result)) - - return { - "success": True, - "available": True, - "text": generated_text, - "model": spec.model_id, - "mode": mode, - "prompt": prompt[:100], - "timestamp": datetime.now().isoformat() - } - - except ModelNotAvailable as e: - logger.warning(f"Generation model not available: {e}") - return { - "success": False, - "available": False, - "error": f"Model not available: {str(e)}", - "text": "", - "note": "HF model unavailable - check model configuration" - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Text analysis failed: {e}") - raise HTTPException(status_code=500, detail=f"Text analysis failed: {str(e)}") - - -@app.post("/api/trading/decision") -async def trading_decision(request: Dict[str, Any]): - """ - Get trading decision based on sentiment analysis. - Uses sentiment analysis to determine BUY/SELL/HOLD signals. - - Expects: { "symbol": "BTC", "context": "market context..." } - Returns: { - "decision": "BUY" | "SELL" | "HOLD", - "confidence": float, - "rationale": "explanation", - "raw": {...} - } - """ - try: - from ai_models import analyze_crypto_sentiment - - symbol = request.get("symbol", "").strip().upper() - context = request.get("context", "").strip() - - if not symbol: - raise HTTPException(status_code=400, detail="Symbol is required") - - # Build text for sentiment analysis - if context: - analysis_text = f"{symbol} {context}" - else: - analysis_text = f"{symbol} market analysis" - - # Default response in case of any failure - default_response = { - "success": True, - "available": True, - "decision": "HOLD", - "confidence": 0.5, - "rationale": "Sentiment analysis unavailable - defaulting to HOLD", - "symbol": symbol, - "model": "fallback", - "context_provided": bool(context), - "timestamp": datetime.now().isoformat() - } - - try: - # Analyze sentiment using crypto sentiment model - sentiment_result = analyze_crypto_sentiment(analysis_text) - - # Extract sentiment label and confidence - sentiment_label = sentiment_result.get("label", "neutral").lower() - confidence = sentiment_result.get("confidence", 0.5) - - # Map sentiment to trading decision - decision = "HOLD" # Default - if sentiment_label == "bullish": - decision = "BUY" - elif sentiment_label == "bearish": - decision = "SELL" - else: # neutral or unknown - decision = "HOLD" - - # Build rationale - rationale = f"Sentiment analysis indicates {sentiment_label} sentiment (confidence: {confidence:.2f})" - if context: - rationale += f" based on: {context[:200]}" - - return { - "success": True, - "available": True, - "decision": decision, - "confidence": float(confidence), - "rationale": rationale, - "symbol": symbol, - "model": sentiment_result.get("engine", "sentiment_analysis"), - "sentiment": sentiment_label, - "context_provided": bool(context), - "raw": sentiment_result, - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - logger.warning(f"Sentiment analysis failed for trading decision: {e}") - # Return default HOLD response instead of crashing - default_response["error"] = f"Sentiment analysis failed: {str(e)[:100]}" - default_response["note"] = "Using default HOLD signal due to analysis failure" - return default_response - - except HTTPException: - raise - except Exception as e: - logger.error(f"Trading decision failed: {e}") - # Return safe default instead of raising exception - return { - "success": True, - "available": False, - "error": f"Trading decision processing failed: {str(e)[:100]}", - "decision": "HOLD", - "confidence": 0.5, - "rationale": "Error occurred during analysis - defaulting to HOLD for safety", - "symbol": request.get("symbol", "UNKNOWN"), - "timestamp": datetime.now().isoformat() - } - - -@app.get("/api/models/data/generated") -async def get_generated_data( - limit: int = 50, - model_key: Optional[str] = None, - symbol: Optional[str] = None -): - """Get data generated by models from database""" - try: - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - - if model_key and symbol: - cursor.execute(""" - SELECT * FROM sentiment_analysis - WHERE analysis_type = ? AND symbol = ? - ORDER BY timestamp DESC - LIMIT ? - """, (model_key, symbol.upper(), limit)) - elif model_key: - cursor.execute(""" - SELECT * FROM sentiment_analysis - WHERE analysis_type = ? - ORDER BY timestamp DESC - LIMIT ? - """, (model_key, limit)) - elif symbol: - cursor.execute(""" - SELECT * FROM sentiment_analysis - WHERE symbol = ? - ORDER BY timestamp DESC - LIMIT ? - """, (symbol.upper(), limit)) - else: - cursor.execute(""" - SELECT * FROM sentiment_analysis - ORDER BY timestamp DESC - LIMIT ? - """, (limit,)) - - rows = cursor.fetchall() - columns = [desc[0] for desc in cursor.description] - conn.close() - - results = [] - for row in rows: - record = dict(zip(columns, row)) - if record.get("scores"): - try: - record["scores"] = json.loads(record["scores"]) - except: - pass - results.append(record) - - return { - "success": True, - "count": len(results), - "data": results, - "source": "models", - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to fetch generated data: {str(e)}") - - -@app.get("/api/models/data/stats") -async def get_models_data_stats(): - """Get statistics about data generated by models""" - try: - conn = sqlite3.connect(str(DB_PATH)) - cursor = conn.cursor() - - cursor.execute("SELECT COUNT(*) FROM sentiment_analysis") - total_analyses = cursor.fetchone()[0] - - cursor.execute("SELECT COUNT(DISTINCT symbol) FROM sentiment_analysis WHERE symbol IS NOT NULL") - unique_symbols = cursor.fetchone()[0] - - cursor.execute("SELECT COUNT(DISTINCT analysis_type) FROM sentiment_analysis") - unique_types = cursor.fetchone()[0] - - cursor.execute(""" - SELECT sentiment_label, COUNT(*) as count - FROM sentiment_analysis - GROUP BY sentiment_label - """) - sentiment_dist = {row[0]: row[1] for row in cursor.fetchall()} - - cursor.execute(""" - SELECT analysis_type, COUNT(*) as count - FROM sentiment_analysis - GROUP BY analysis_type - """) - type_dist = {row[0]: row[1] for row in cursor.fetchall()} - - conn.close() - - return { - "success": True, - "statistics": { - "total_analyses": total_analyses, - "unique_symbols": unique_symbols, - "unique_model_types": unique_types, - "sentiment_distribution": sentiment_dist, - "model_type_distribution": type_dist - }, - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to fetch statistics: {str(e)}") - - -@app.post("/api/hf/run-sentiment") -async def run_hf_sentiment(data: Dict[str, Any]): - """Run sentiment analysis using HF models (compatible with UI)""" - try: - from ai_models import analyze_market_text, ModelNotAvailable - - texts = data.get("texts", []) - if isinstance(texts, str): - texts = [texts] - - if not texts or not any(t.strip() for t in texts): - raise HTTPException(status_code=400, detail="At least one text is required") - - try: - all_results = [] - total_vote = 0.0 - count = 0 - models_available = False - - for text in texts: - if not text.strip(): - continue - - result = analyze_market_text(text.strip()) - - # Check if models are available - if result.get("available", True): - models_available = True - - label = result.get("label", "neutral") - confidence = result.get("confidence", 0.5) - - vote_score = 0.0 - if label == "bullish": - vote_score = confidence - elif label == "bearish": - vote_score = -confidence - - total_vote += vote_score - count += 1 - - all_results.append({ - "text": text[:100], - "label": label, - "confidence": confidence, - "vote": vote_score, - "available": result.get("available", True) - }) - - avg_vote = total_vote / count if count > 0 else 0.0 - - return { - "available": models_available, - "vote": avg_vote, - "results": all_results, - "count": count, - "average_confidence": sum(r["confidence"] for r in all_results) / len(all_results) if all_results else 0.0 - } - - except ModelNotAvailable as e: - return { - "available": False, - "vote": 0.0, - "results": [], - "count": 0, - "average_confidence": 0.0, - "error": str(e), - "reason": "model_unavailable" - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Sentiment analysis failed: {str(e)}") - - - - -# ===== Short Hunter / v2 compatibility routes (free Binance + CoinGecko) ===== -try: - from api_compat_routes import register_compat_routes - - register_compat_routes(app) -except Exception as compat_error: - logger.warning(f"Compat routes not loaded: {compat_error}") - -# ===== Main Entry Point ===== -if __name__ == "__main__": - import uvicorn - print(f"Starting Crypto Monitor Admin Server on port {PORT}") - uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info") +#!/usr/bin/env python3 +""" +API Server Extended - HuggingFace Spaces Deployment Ready +Complete Admin API with Real Data Only - NO MOCKS +""" + +import os +import threading +import asyncio +import sqlite3 +import httpx +import json +import subprocess +import logging +from pathlib import Path +from typing import Optional, Dict, Any, List +from datetime import datetime +from contextlib import asynccontextmanager +from collections import defaultdict + +try: + from api_hub_registry import get_secret, provider_runtime_summary +except Exception: + def get_secret(name): + return os.getenv(name) + def provider_runtime_summary(): + return {"totalProviders": 0, "categories": {}} + +logger = logging.getLogger(__name__) + +from fastapi import FastAPI, HTTPException, Response, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, FileResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware +from pydantic import BaseModel + +# Environment variables +USE_MOCK_DATA = os.getenv("USE_MOCK_DATA", "false").lower() == "true" +PORT = int(os.getenv("PORT", "7860")) + +# Paths - In Docker container, use /app as base +WORKSPACE_ROOT = Path("/app" if Path("/app").exists() else (Path("/workspace") if Path("/workspace").exists() else Path("."))) +DB_PATH = WORKSPACE_ROOT / "data" / "database" / "crypto_monitor.db" +LOG_DIR = WORKSPACE_ROOT / "logs" +PROVIDERS_CONFIG_PATH = WORKSPACE_ROOT / "providers_config_extended.json" +AUTO_DISCOVERY_REPORT_PATH = WORKSPACE_ROOT / "PROVIDER_AUTO_DISCOVERY_REPORT.json" +API_REGISTRY_PATH = WORKSPACE_ROOT / "all_apis_merged_2025.json" + +# Ensure directories exist +DB_PATH.parent.mkdir(parents=True, exist_ok=True) +LOG_DIR.mkdir(parents=True, exist_ok=True) + +# Global state for providers +_provider_state = { + "providers": {}, + "pools": {}, + "logs": [], + "last_check": None, + "stats": {"total": 0, "online": 0, "offline": 0, "degraded": 0} +} + + +# ===== Database Setup ===== +def init_database(): + """Initialize SQLite database with required tables""" + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS prices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + name TEXT, + price_usd REAL NOT NULL, + volume_24h REAL, + market_cap REAL, + percent_change_24h REAL, + rank INTEGER, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sentiment_analysis ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + sentiment_label TEXT NOT NULL, + confidence REAL NOT NULL, + model_used TEXT, + analysis_type TEXT, + symbol TEXT, + scores TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS news_articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT, + url TEXT, + source TEXT, + sentiment_label TEXT, + sentiment_confidence REAL, + related_symbols TEXT, + published_date DATETIME, + analyzed_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_prices_symbol ON prices(symbol)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_prices_timestamp ON prices(timestamp)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_sentiment_timestamp ON sentiment_analysis(timestamp)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_sentiment_symbol ON sentiment_analysis(symbol)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_news_published ON news_articles(published_date)") + + conn.commit() + conn.close() + print(f"[OK] Database initialized at {DB_PATH}") + + +def save_price_to_db(price_data: Dict[str, Any]): + """Save price data to SQLite""" + try: + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO prices (symbol, name, price_usd, volume_24h, market_cap, percent_change_24h, rank) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + price_data.get("symbol"), + price_data.get("name"), + price_data.get("price_usd", 0.0), + price_data.get("volume_24h"), + price_data.get("market_cap"), + price_data.get("percent_change_24h"), + price_data.get("rank") + )) + conn.commit() + conn.close() + except Exception as e: + print(f"Error saving price to database: {e}") + + +def get_price_history_from_db(symbol: str, limit: int = 10) -> List[Dict[str, Any]]: + """Get price history from SQLite""" + try: + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM prices + WHERE symbol = ? + ORDER BY timestamp DESC + LIMIT ? + """, (symbol, limit)) + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + except Exception as e: + print(f"Error fetching price history: {e}") + return [] + + +def get_latest_prices_from_db() -> Dict[str, Dict[str, Any]]: + """Get latest prices for BTC, ETH, BNB from database as fallback""" + try: + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # Get latest price for each symbol + symbols = ["BTC", "ETH", "BNB"] + latest_prices = {} + + for symbol in symbols: + cursor.execute(""" + SELECT * FROM prices + WHERE symbol = ? + ORDER BY timestamp DESC + LIMIT 1 + """, (symbol,)) + row = cursor.fetchone() + if row: + latest_prices[symbol] = dict(row) + + conn.close() + return latest_prices + except Exception as e: + logger.warning(f"Error fetching latest prices from database: {e}") + return {} + + +# ===== Provider Management ===== +def load_providers_config() -> Dict[str, Any]: + """Load providers from providers_config_extended.json""" + try: + if PROVIDERS_CONFIG_PATH.exists(): + with open(PROVIDERS_CONFIG_PATH, 'r', encoding='utf-8') as f: + config = json.load(f) + # Validate structure + if not isinstance(config, dict): + logger.warning("Providers config is not a dict, returning empty") + return {"providers": {}} + if "providers" not in config: + logger.warning("Providers config missing 'providers' key, adding it") + config["providers"] = {} + return config + logger.warning(f"Providers config file not found at {PROVIDERS_CONFIG_PATH}") + return {"providers": {}} + except json.JSONDecodeError as e: + logger.error(f"JSON decode error loading providers config: {e}") + return {"providers": {}} + except Exception as e: + logger.error(f"Error loading providers config: {e}") + return {"providers": {}} + + +def load_apl_report() -> Dict[str, Any]: + """Load APL validation report (alias for auto-discovery report)""" + return load_auto_discovery_report() + +def load_auto_discovery_report() -> Dict[str, Any]: + """Load PROVIDER_AUTO_DISCOVERY_REPORT.json""" + try: + if AUTO_DISCOVERY_REPORT_PATH.exists(): + with open(AUTO_DISCOVERY_REPORT_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + return {} + except Exception as e: + logger.error(f"Error loading auto-discovery report: {e}") + return {} + +def load_api_registry() -> Dict[str, Any]: + """Load all_apis_merged_2025.json""" + try: + if API_REGISTRY_PATH.exists(): + with open(API_REGISTRY_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + return {} + except Exception as e: + logger.error(f"Error loading API registry: {e}") + return {} + + +# ===== Deduplication Helpers ===== +def deduplicate_providers(providers_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Deduplicate providers by id, or by name+base_url if no id. + Merge tags/categories when duplicates are found. + """ + seen = {} + result = [] + + for provider in providers_list: + # Determine unique key + provider_id = provider.get("id") or provider.get("provider_id") + if provider_id: + key = f"id:{provider_id}" + else: + name = provider.get("name", "unknown") + base_url = provider.get("base_url", "") + key = f"name_url:{name}:{base_url}" + + if key in seen: + # Merge tags/categories + existing = seen[key] + existing_tags = set(existing.get("tags", []) if isinstance(existing.get("tags"), list) else []) + new_tags = set(provider.get("tags", []) if isinstance(provider.get("tags"), list) else []) + existing["tags"] = list(existing_tags | new_tags) + + # Merge categories if different + existing_cat = existing.get("category", "") + new_cat = provider.get("category", "") + if new_cat and new_cat != existing_cat: + if existing_cat: + existing["categories"] = list(set([existing_cat, new_cat])) + else: + existing["category"] = new_cat + else: + # Ensure tags is a list + if "tags" not in provider: + provider["tags"] = [] + elif not isinstance(provider["tags"], list): + provider["tags"] = [provider["tags"]] + + seen[key] = provider + result.append(provider) + + return result + + +def deduplicate_resources(resources_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Deduplicate resources by id, or by name+url if no id. + """ + seen = {} + result = [] + + for resource in resources_list: + # Determine unique key + resource_id = resource.get("id") + if resource_id: + key = f"id:{resource_id}" + else: + name = resource.get("name", "unknown") + url = resource.get("url") or resource.get("base_url", "") + path = resource.get("path", "") + key = f"name_url:{name}:{url}{path}" + + if key not in seen: + seen[key] = resource + result.append(resource) + + return result + + +def filter_resources_by_query(resources: List[Dict[str, Any]], query: str) -> List[Dict[str, Any]]: + """ + Filter resources by search query (case-insensitive). + Searches in name, description, category, and tags. + """ + if not query: + return resources + + query_lower = query.lower() + filtered = [] + + for resource in resources: + # Search in name + if query_lower in resource.get("name", "").lower(): + filtered.append(resource) + continue + + # Search in description + if query_lower in resource.get("description", "").lower(): + filtered.append(resource) + continue + + # Search in category + if query_lower in resource.get("category", "").lower(): + filtered.append(resource) + continue + + # Search in tags + tags = resource.get("tags", []) + if isinstance(tags, list): + if any(query_lower in str(tag).lower() for tag in tags): + filtered.append(resource) + continue + + return filtered + + +# ===== Real Data Providers ===== +HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json" +} + + +async def fetch_coingecko_simple_price() -> Dict[str, Any]: + """Fetch real price data from CoinGecko API with proper error handling""" + url = "https://api.coingecko.com/api/v3/simple/price" + params = { + "ids": "bitcoin,ethereum,binancecoin", + "vs_currencies": "usd", + "include_market_cap": "true", + "include_24hr_vol": "true", + "include_24hr_change": "true" + } + + try: + async with httpx.AsyncClient(timeout=15.0, headers=HEADERS) as client: + response = await client.get(url, params=params) + if response.status_code != 200: + logger.warning(f"CoinGecko API returned HTTP {response.status_code}") + raise Exception(f"CoinGecko API error: HTTP {response.status_code}") + return response.json() + except httpx.TimeoutException: + logger.warning("CoinGecko API request timed out") + raise Exception("CoinGecko API request timed out") + except httpx.RequestError as e: + logger.warning(f"CoinGecko API request error: {str(e)}") + raise Exception(f"CoinGecko API request failed: {str(e)}") + except Exception as e: + logger.warning(f"CoinGecko API error: {str(e)}") + raise + + +async def fetch_fear_greed_index() -> Dict[str, Any]: + """Fetch real Fear & Greed Index from Alternative.me""" + url = "https://api.alternative.me/fng/" + params = {"limit": "1", "format": "json"} + + async with httpx.AsyncClient(timeout=15.0, headers=HEADERS) as client: + response = await client.get(url, params=params) + if response.status_code != 200: + raise HTTPException(status_code=503, detail=f"Alternative.me API error: HTTP {response.status_code}") + return response.json() + + +async def fetch_coingecko_trending() -> Dict[str, Any]: + """Fetch real trending coins from CoinGecko""" + url = "https://api.coingecko.com/api/v3/search/trending" + + async with httpx.AsyncClient(timeout=15.0, headers=HEADERS) as client: + response = await client.get(url) + if response.status_code != 200: + raise HTTPException(status_code=503, detail=f"CoinGecko trending API error: HTTP {response.status_code}") + return response.json() + + +# ===== Self-Healing Health Registry ===== +from dataclasses import dataclass, field +from typing import Callable +import time as time_module + +@dataclass +class ProviderHealthEntry: + """Health tracking entry for a provider/resource""" + id: str + name: str + status: str = "unknown" # "healthy", "degraded", "unavailable", "unknown" + last_success: Optional[float] = None + last_error: Optional[float] = None + error_count: int = 0 + success_count: int = 0 + cooldown_until: Optional[float] = None + last_error_message: Optional[str] = None + +class HealthRegistry: + """ + Self-healing health registry for providers and external API endpoints. + Tracks failures, implements cooldowns, and provides graceful degradation. + """ + def __init__(self): + self._providers: Dict[str, ProviderHealthEntry] = {} + self._lock = threading.Lock() + # Load config + try: + from config import get_settings + self.settings = get_settings() + except: + # Fallback defaults if config not available + class FallbackSettings: + health_error_threshold = 3 + health_cooldown_seconds = 300 + health_success_recovery_count = 2 + self.settings = FallbackSettings() + + def _get_or_create_entry(self, provider_id: str, provider_name: str = None) -> ProviderHealthEntry: + """Get or create health entry for a provider""" + if provider_id not in self._providers: + self._providers[provider_id] = ProviderHealthEntry( + id=provider_id, + name=provider_name or provider_id, + status="unknown" + ) + return self._providers[provider_id] + + def update_on_success(self, provider_id: str, provider_name: str = None): + """Update health registry after successful provider call""" + with self._lock: + entry = self._get_or_create_entry(provider_id, provider_name) + entry.last_success = time_module.time() + entry.success_count += 1 + + # Reset error count gradually + if entry.error_count > 0: + entry.error_count = max(0, entry.error_count - 1) + + # Recovery logic + if entry.success_count >= self.settings.health_success_recovery_count: + entry.status = "healthy" + entry.cooldown_until = None + + def update_on_failure(self, provider_id: str, error_msg: str, provider_name: str = None): + """Update health registry after failed provider call""" + with self._lock: + entry = self._get_or_create_entry(provider_id, provider_name) + entry.last_error = time_module.time() + entry.error_count += 1 + entry.last_error_message = error_msg[:500] # Limit error message length + entry.success_count = 0 + + # Determine status based on error count + if entry.error_count >= self.settings.health_error_threshold: + entry.status = "unavailable" + entry.cooldown_until = time_module.time() + self.settings.health_cooldown_seconds + elif entry.error_count >= (self.settings.health_error_threshold // 2): + entry.status = "degraded" + else: + entry.status = "healthy" + + def is_in_cooldown(self, provider_id: str) -> bool: + """Check if provider is in cooldown period""" + if provider_id not in self._providers: + return False + entry = self._providers[provider_id] + if entry.cooldown_until is None: + return False + return time_module.time() < entry.cooldown_until + + def get_status(self, provider_id: str) -> Optional[str]: + """Get current status of a provider""" + if provider_id not in self._providers: + return "unknown" + return self._providers[provider_id].status + + def get_all_entries(self) -> List[Dict[str, Any]]: + """Get all health entries as list of dicts""" + with self._lock: + return [ + { + "id": entry.id, + "name": entry.name, + "status": entry.status, + "last_success": entry.last_success, + "last_error": entry.last_error, + "error_count": entry.error_count, + "success_count": entry.success_count, + "cooldown_until": entry.cooldown_until, + "in_cooldown": self.is_in_cooldown(entry.id), + "last_error_message": entry.last_error_message + } + for entry in self._providers.values() + ] + + def get_summary(self) -> Dict[str, Any]: + """Get summary statistics of health registry""" + with self._lock: + total = len(self._providers) + healthy = sum(1 for e in self._providers.values() if e.status == "healthy") + degraded = sum(1 for e in self._providers.values() if e.status == "degraded") + unavailable = sum(1 for e in self._providers.values() if e.status == "unavailable") + unknown = sum(1 for e in self._providers.values() if e.status == "unknown") + in_cooldown = sum(1 for e in self._providers.values() if self.is_in_cooldown(e.id)) + + return { + "total": total, + "healthy": healthy, + "degraded": degraded, + "unavailable": unavailable, + "unknown": unknown, + "in_cooldown": in_cooldown + } + +# Global health registry instance +_health_registry = HealthRegistry() + + +async def call_provider_safe( + provider_id: str, + provider_name: str, + call_func: Callable, + *args, + **kwargs +) -> Dict[str, Any]: + """ + Safely call a provider with health tracking. + + Args: + provider_id: Unique identifier for the provider + provider_name: Human-readable name + call_func: Async function to call + *args, **kwargs: Arguments to pass to call_func + + Returns: + Dict with status and data or error + """ + # Check if provider is in cooldown + if _health_registry.is_in_cooldown(provider_id): + entry = _health_registry._providers[provider_id] + cooldown_remaining = int(entry.cooldown_until - time_module.time()) + return { + "status": "cooldown", + "error": f"Provider in cooldown for {cooldown_remaining}s", + "provider_id": provider_id, + "cooldown_remaining": cooldown_remaining + } + + try: + # Call the provider function + result = await call_func(*args, **kwargs) + # Update health on success + _health_registry.update_on_success(provider_id, provider_name) + return { + "status": "success", + "data": result, + "provider_id": provider_id + } + except httpx.TimeoutException as e: + error_msg = f"Timeout: {str(e)[:200]}" + _health_registry.update_on_failure(provider_id, error_msg, provider_name) + return { + "status": "timeout", + "error": error_msg, + "provider_id": provider_id + } + except httpx.HTTPStatusError as e: + error_msg = f"HTTP {e.response.status_code}: {str(e)[:200]}" + _health_registry.update_on_failure(provider_id, error_msg, provider_name) + return { + "status": "http_error", + "error": error_msg, + "provider_id": provider_id, + "status_code": e.response.status_code + } + except Exception as e: + error_msg = f"{type(e).__name__}: {str(e)[:200]}" + _health_registry.update_on_failure(provider_id, error_msg, provider_name) + return { + "status": "error", + "error": error_msg, + "provider_id": provider_id + } + + +# ===== Lifespan Management ===== +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan manager""" + print("=" * 80) + print("Starting Crypto Monitor Admin API") + print("=" * 80) + init_database() + + # Load providers + config = load_providers_config() + _provider_state["providers"] = config.get("providers", {}) + print(f"[OK] Loaded {len(_provider_state['providers'])} providers from config") + + # Load auto-discovery report + apl_report = load_auto_discovery_report() + if apl_report: + print(f"[OK] Loaded auto-discovery report with validation data") + + # Load API registry + api_registry = load_api_registry() + if api_registry: + metadata = api_registry.get("metadata", {}) + print(f"[OK] Loaded API registry: {metadata.get('name', 'unknown')} v{metadata.get('version', 'unknown')}") + + # Initialize AI models + try: + from ai_models import initialize_models, registry_status, HF_MAX_STARTUP_MODELS + model_init_result = initialize_models(max_models=HF_MAX_STARTUP_MODELS) + registry_info = registry_status() + print(f"[OK] AI Models initialized: {model_init_result}") + print(f"[OK] HF Registry status: {registry_info}") + except Exception as e: + print(f"[WARN] AI Models initialization failed: {e}") + + # Validate unified resources + try: + from backend.services.resource_validator import validate_unified_resources + validation_report = validate_unified_resources(str(WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json")) + print(f"[OK] Resource validation: {validation_report['local_backend_routes']['routes_count']} local routes") + if validation_report['local_backend_routes']['duplicate_signatures'] > 0: + print(f"[WARN] Found {validation_report['local_backend_routes']['duplicate_signatures']} duplicate route signatures") + except Exception as e: + print(f"[WARN] Resource validation failed: {e}") + + print(f"[OK] Server ready on port {PORT}") + print("=" * 80) + yield + print("Shutting down...") + + +# ===== FastAPI Application ===== +app = FastAPI( + title="Crypto Monitor Admin API", + description="Real-time cryptocurrency data API with Admin Dashboard", + version="5.0.0", + lifespan=lifespan +) + +# CORS Middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Middleware to ensure HTML responses have correct Content-Type +class HTMLContentTypeMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + if isinstance(response, HTMLResponse): + response.headers["Content-Type"] = "text/html; charset=utf-8" + response.headers["X-Content-Type-Options"] = "nosniff" + return response + +app.add_middleware(HTMLContentTypeMiddleware) + +# Mount static files +try: + static_path = WORKSPACE_ROOT / "static" + if static_path.exists(): + app.mount("/static", StaticFiles(directory=str(static_path)), name="static") + logger.info(f"Mounted static files from {static_path}") + else: + # Create static directories if they don't exist + static_path.mkdir(parents=True, exist_ok=True) + (static_path / "css").mkdir(exist_ok=True) + (static_path / "js").mkdir(exist_ok=True) + logger.info(f"Created static directories at {static_path}") +except Exception as e: + logger.warning(f"Could not mount static files: {e}") + +# Serve trading pairs file +@app.get("/trading_pairs.txt") +async def get_trading_pairs(): + """Serve trading pairs text file""" + from fastapi.responses import PlainTextResponse + trading_pairs_file = WORKSPACE_ROOT / "trading_pairs.txt" + if trading_pairs_file.exists(): + return FileResponse(trading_pairs_file, media_type="text/plain") + return PlainTextResponse("BTCUSDT\nETHUSDT\nBNBUSDT\nSOLUSDT", status_code=200) + + +# ===== HTML UI Endpoints ===== +@app.get("/", response_class=HTMLResponse) +async def root(): + """Serve main dashboard""" + index_path = WORKSPACE_ROOT / "index.html" + if index_path.exists(): + content = index_path.read_text(encoding="utf-8", errors="ignore") + return HTMLResponse( + content=content, + media_type="text/html", + headers={ + "Content-Type": "text/html; charset=utf-8", + "X-Content-Type-Options": "nosniff" + } + ) + return HTMLResponse( + "

Cryptocurrency Data & Analysis API

See /docs for API documentation

", + headers={"Content-Type": "text/html; charset=utf-8"} + ) + +@app.get("/index.html", response_class=HTMLResponse) +async def index(): + """Serve index.html""" + index_path = WORKSPACE_ROOT / "index.html" + if index_path.exists(): + content = index_path.read_text(encoding="utf-8", errors="ignore") + return HTMLResponse( + content=content, + media_type="text/html", + headers={ + "Content-Type": "text/html; charset=utf-8", + "X-Content-Type-Options": "nosniff" + } + ) + return HTMLResponse( + "

index.html not found

", + headers={"Content-Type": "text/html; charset=utf-8"} + ) + +@app.get("/test.html", response_class=HTMLResponse) +async def test_page(): + """Serve test.html for debugging""" + test_path = WORKSPACE_ROOT / "test.html" + if test_path.exists(): + content = test_path.read_text(encoding="utf-8", errors="ignore") + return HTMLResponse( + content=content, + media_type="text/html", + headers={ + "Content-Type": "text/html; charset=utf-8", + "X-Content-Type-Options": "nosniff" + } + ) + return HTMLResponse( + "

✅ Server is Running

WORKSPACE_ROOT: " + str(WORKSPACE_ROOT) + "

", + headers={"Content-Type": "text/html; charset=utf-8"} + ) + +@app.get("/ai-tools", response_class=HTMLResponse) +async def ai_tools_page(request: Request): + """ + Serve the standalone AI Tools page. + + This page provides: + - Sentiment Playground: POST /api/sentiment/analyze + - Text Summarizer: POST /api/ai/summarize + - Model Status & Diagnostics: GET /api/models/status, /api/models/list + """ + ai_tools_path = WORKSPACE_ROOT / "templates" / "ai_tools.html" + if ai_tools_path.exists(): + content = ai_tools_path.read_text(encoding="utf-8", errors="ignore") + return HTMLResponse( + content=content, + media_type="text/html", + headers={ + "Content-Type": "text/html; charset=utf-8", + "X-Content-Type-Options": "nosniff" + } + ) + return HTMLResponse( + "

AI Tools page not found

", + headers={"Content-Type": "text/html; charset=utf-8"} + ) + +@app.get("/debug-info", response_class=HTMLResponse) +async def debug_info(): + """Debug endpoint to show server configuration""" + import os + info = f""" + + + + + Debug Info + + + +

🔍 Server Debug Information

+

Paths:

+
+WORKSPACE_ROOT: {WORKSPACE_ROOT}
+Current Dir: {Path.cwd()}
+index.html exists: {"✅ YES" if (WORKSPACE_ROOT / "index.html").exists() else "❌ NO"}
+static dir exists: {"✅ YES" if (WORKSPACE_ROOT / "static").exists() else "❌ NO"}
+        
+

Files in WORKSPACE_ROOT:

+
+{chr(10).join([f"- {f.name}" for f in sorted(WORKSPACE_ROOT.glob("*.html"))[:20]])}
+        
+

Environment:

+
+Python: {os.sys.version}
+Port: 7860
+Host: 127.0.0.1
+        
+

Quick Links:

+ + + + """ + return HTMLResponse(content=info, headers={"Content-Type": "text/html; charset=utf-8"}) + + +# ===== Health & Status Endpoints ===== +@app.get("/health") +async def health(): + """Health check endpoint (legacy)""" + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "database": str(DB_PATH), + "use_mock_data": USE_MOCK_DATA, + "providers_loaded": len(_provider_state["providers"]) + } + + +@app.get("/api/health") +async def api_health(): + """Stable API health check for HuggingFace Space consumers.""" + try: + version = "4.1.0-short-hunter-compat" + try: + api_registry = load_api_registry() + metadata = api_registry.get("metadata", {}) if isinstance(api_registry, dict) else {} + version = metadata.get("version") or version + except Exception: + pass + + return { + "ok": True, + "success": True, + "status": "ok", + "service": "Datasourceforcryptocurrency-4", + "version": version, + "timestamp": datetime.now().isoformat(), + "uptime": None, + "errors": [], + } + except Exception as e: + logger.error(f"Health check error: {e}") + return JSONResponse( + status_code=200, + content={ + "ok": True, + "success": True, + "status": "ok", + "service": "Datasourceforcryptocurrency-4", + "version": "unknown", + "timestamp": datetime.now().isoformat(), + "errors": [], + }, + ) + +@app.get("/api/status") +async def get_status(): + """Capability-level status. Missing optional capabilities never mark the whole Space down.""" + try: + config = load_providers_config() + providers_config = config.get("providers", {}) if isinstance(config, dict) else {} + resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" + + resources_data = {"total": 0, "categories": {}} + errors = [] + if resources_json.exists(): + try: + with open(resources_json, "r", encoding="utf-8") as f: + unified_data = json.load(f) + registry = unified_data.get("registry", {}) if isinstance(unified_data, dict) else {} + for category, items in registry.items(): + if category == "metadata": + continue + if isinstance(items, list): + count = len(items) + resources_data["total"] += count + resources_data["categories"][category.replace("_", "-")] = resources_data["categories"].get(category.replace("_", "-"), 0) + count + except Exception as resource_error: + errors.append(f"resources_load_failed: {resource_error}") + + model_count = 0 + try: + from ai_models import MODEL_SPECS + model_count = len(MODEL_SPECS) if MODEL_SPECS else 0 + except Exception as model_error: + errors.append(f"model_registry_unavailable: {model_error}") + + # Capability model: this endpoint reports route/provider capability, not a slow live external probe. + capabilities = { + "market": {"status": "available", "providers": ["CoinGecko public", "database cache"]}, + "coinsTop": {"status": "available", "providers": ["CoinGecko public"]}, + "trending": {"status": "available", "providers": ["CoinGecko public"]}, + "ohlcv": {"status": "available", "providers": ["Binance public", "KuCoin public"]}, + "klines": {"status": "available", "providers": ["Binance public", "KuCoin public"]}, + "indicators": {"status": "available", "providers": ["local OHLCV computation"]}, + "sentiment": {"status": "available" if model_count > 0 else "partial", "providers": ["Alternative.me", "HuggingFace models"]}, + "news": {"status": "available_or_empty", "providers": ["database", "CryptoCompare public"]}, + "orderbook": {"status": "available", "providers": ["KuCoin public", "Binance public"]}, + } + + missing_capabilities = [name for name, cap in capabilities.items() if cap.get("status") in {"unavailable", "missing"}] + degraded_capabilities = [name for name, cap in capabilities.items() if cap.get("status") in {"partial", "degraded", "available_or_empty"}] + + if missing_capabilities and len(missing_capabilities) >= len(capabilities): + data_state = "UNAVAILABLE" + elif missing_capabilities: + data_state = "DEGRADED" + elif degraded_capabilities: + data_state = "PARTIAL" + else: + data_state = "COMPLETE" + + provider_health = health_registry.get_summary() if "health_registry" in globals() else {} + + return { + "ok": True, + "success": True, + "status": "ok", + "service": "Datasourceforcryptocurrency-4", + "dataState": data_state, + "timestamp": datetime.now().isoformat(), + "last_update": datetime.now().isoformat(), + "providers": { + "configuredTotal": len(providers_config), + "health": provider_health, + "market": capabilities["market"], + "ohlcv": capabilities["ohlcv"], + "indicators": capabilities["indicators"], + "sentiment": capabilities["sentiment"], + "news": capabilities["news"], + "orderbook": capabilities["orderbook"], + }, + "capabilities": capabilities, + "missingCapabilities": missing_capabilities, + "degradedCapabilities": degraded_capabilities, + "errors": errors, + "resources": resources_data, + "models": {"total": model_count}, + } + except Exception as e: + logger.error(f"Status endpoint error: {e}") + return { + "ok": False, + "success": False, + "status": "error", + "dataState": "DEGRADED", + "timestamp": datetime.now().isoformat(), + "providers": {}, + "capabilities": {}, + "missingCapabilities": [], + "errors": [str(e)], + "resources": {"total": 0, "categories": {}}, + } + +@app.get("/api/stats") +async def get_stats(): + """System statistics""" + config = load_providers_config() + providers = config.get("providers", {}) + + # Group by category + categories = defaultdict(int) + for p in providers.values(): + cat = p.get("category", "unknown") + categories[cat] += 1 + + return { + "total_providers": len(providers), + "categories": dict(categories), + "total_categories": len(categories), + "timestamp": datetime.now().isoformat() + } + + +# ===== Market Data Endpoint ===== +@app.get("/api/market") +async def get_market_data(limit: int = 100): + """Normalized market data with backward-compatible fields.""" + cryptocurrencies = [] + errors = [] + + # Optional primary: CoinMarketCap if configured in HuggingFace Space secrets. + cmc_key = get_secret("COINMARKETCAP_KEY") + if cmc_key: + try: + cmc_symbols = "BTC,ETH,BNB,SOL,XRP,DOGE,ADA,TRX,AVAX,LINK,DOT,MATIC,TON,LTC,BCH,UNI,ATOM,ETC,APT,ARB,OP,NEAR,FIL,INJ,SUI,SEI" + async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0), headers={**HEADERS, "X-CMC_PRO_API_KEY": cmc_key}) as client: + response = await client.get( + "https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest", + params={"symbol": cmc_symbols, "convert": "USD"}, + ) + if response.status_code == 200: + payload = response.json() + data = payload.get("data") if isinstance(payload, dict) else {} + if isinstance(data, dict): + for sym, item in data.items(): + quote = ((item.get("quote") or {}).get("USD") or {}) if isinstance(item, dict) else {} + cryptocurrencies.append({ + "rank": item.get("cmc_rank"), + "name": item.get("name"), + "symbol": f"{sym.upper()}USDT", + "baseSymbol": sym.upper(), + "price": quote.get("price"), + "change24h": quote.get("percent_change_24h"), + "change_24h": quote.get("percent_change_24h"), + "marketCap": quote.get("market_cap"), + "market_cap": quote.get("market_cap"), + "volume24h": quote.get("volume_24h"), + "volume_24h": quote.get("volume_24h"), + "source": "coinmarketcap_quotes", + }) + else: + errors.append(f"coinmarketcap_http_{response.status_code}") + except Exception as cmc_error: + errors.append(f"coinmarketcap_failed: {cmc_error}") + + # Primary public fallback: CoinGecko coins/markets gives richer rows for scanners. + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0), headers=HEADERS) as client: + response = await client.get( + "https://api.coingecko.com/api/v3/coins/markets", + params={ + "vs_currency": "usd", + "order": "market_cap_desc", + "per_page": min(max(int(limit or 100), 1), 250), + "page": 1, + "sparkline": "false", + "price_change_percentage": "24h", + }, + ) + if response.status_code == 200: + payload = response.json() + if isinstance(payload, list): + for item in payload: + base_symbol = str(item.get("symbol", "")).upper() + normalized_symbol = f"{base_symbol}USDT" if base_symbol and not base_symbol.endswith("USDT") else base_symbol + cryptocurrencies.append({ + "rank": item.get("market_cap_rank"), + "name": item.get("name"), + "symbol": normalized_symbol, + "baseSymbol": base_symbol, + "price": item.get("current_price"), + "change24h": item.get("price_change_percentage_24h"), + "change_24h": item.get("price_change_percentage_24h"), + "marketCap": item.get("market_cap"), + "market_cap": item.get("market_cap"), + "volume24h": item.get("total_volume"), + "volume_24h": item.get("total_volume"), + "image": item.get("image"), + "source": "coingecko_markets", + }) + else: + errors.append(f"coingecko_markets_http_{response.status_code}") + except Exception as market_error: + errors.append(f"coingecko_markets_failed: {market_error}") + + # Fallback: existing simple-price helper for BTC/ETH/BNB. + if not cryptocurrencies: + coin_mapping = { + "bitcoin": {"name": "Bitcoin", "symbol": "BTCUSDT", "rank": 1, "image": "https://assets.coingecko.com/coins/images/1/small/bitcoin.png"}, + "ethereum": {"name": "Ethereum", "symbol": "ETHUSDT", "rank": 2, "image": "https://assets.coingecko.com/coins/images/279/small/ethereum.png"}, + "binancecoin": {"name": "BNB", "symbol": "BNBUSDT", "rank": 3, "image": "https://assets.coingecko.com/coins/images/825/small/bnb-icon2_2x.png"}, + } + try: + data = await fetch_coingecko_simple_price() + for coin_id, coin_info in coin_mapping.items(): + coin_data = data.get(coin_id, {}) if isinstance(data, dict) else {} + if coin_data: + cryptocurrencies.append({ + "rank": coin_info["rank"], + "name": coin_info["name"], + "symbol": coin_info["symbol"], + "baseSymbol": coin_info["symbol"].replace("USDT", ""), + "price": coin_data.get("usd", 0), + "change24h": coin_data.get("usd_24h_change", 0), + "change_24h": coin_data.get("usd_24h_change", 0), + "marketCap": coin_data.get("usd_market_cap", 0), + "market_cap": coin_data.get("usd_market_cap", 0), + "volume24h": coin_data.get("usd_24h_vol", 0), + "volume_24h": coin_data.get("usd_24h_vol", 0), + "image": coin_info["image"], + "source": "coingecko_simple_price", + }) + except Exception as simple_error: + errors.append(f"coingecko_simple_price_failed: {simple_error}") + + # Secondary fallback: CryptoCompare prices, with optional key. + if not cryptocurrencies: + try: + cc_key = get_secret("CRYPTOCOMPARE_KEY") + params = {"fsyms": "BTC,ETH,BNB,SOL,XRP,DOGE,ADA,TRX,AVAX,LINK", "tsyms": "USD"} + if cc_key: + params["api_key"] = cc_key + async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0), headers=HEADERS) as client: + response = await client.get("https://min-api.cryptocompare.com/data/pricemultifull", params=params) + if response.status_code == 200: + payload = response.json() + raw = (payload.get("RAW") or {}) if isinstance(payload, dict) else {} + for sym, row in raw.items(): + usd = (row or {}).get("USD") or {} + cryptocurrencies.append({ + "rank": None, + "name": sym.upper(), + "symbol": f"{sym.upper()}USDT", + "baseSymbol": sym.upper(), + "price": usd.get("PRICE"), + "change24h": usd.get("CHANGEPCT24HOUR"), + "change_24h": usd.get("CHANGEPCT24HOUR"), + "marketCap": usd.get("MKTCAP"), + "market_cap": usd.get("MKTCAP"), + "volume24h": usd.get("VOLUME24HOURTO"), + "volume_24h": usd.get("VOLUME24HOURTO"), + "source": "cryptocompare_pricemultifull", + }) + else: + errors.append(f"cryptocompare_prices_http_{response.status_code}") + except Exception as cc_error: + errors.append(f"cryptocompare_prices_failed: {cc_error}") + + # Last fallback: cached DB rows. Zero-placeholder rows are no longer advertised as valid data. + if not cryptocurrencies: + latest_prices = get_latest_prices_from_db() + for symbol, db_data in latest_prices.items(): + cryptocurrencies.append({ + "rank": db_data.get("rank"), + "name": db_data.get("name"), + "symbol": f"{symbol}USDT" if not str(symbol).upper().endswith("USDT") else str(symbol).upper(), + "baseSymbol": symbol, + "price": db_data.get("price_usd", 0), + "change24h": db_data.get("percent_change_24h", 0), + "change_24h": db_data.get("percent_change_24h", 0), + "marketCap": db_data.get("market_cap", 0), + "market_cap": db_data.get("market_cap", 0), + "volume24h": db_data.get("volume_24h", 0), + "volume_24h": db_data.get("volume_24h", 0), + "source": "sqlite_cache", + }) + + total_market_cap = sum((c.get("marketCap") or c.get("market_cap") or 0) for c in cryptocurrencies) + btc_entry = next((c for c in cryptocurrencies if str(c.get("symbol", "")).startswith("BTC")), None) + btc_dominance = ((btc_entry.get("marketCap") or btc_entry.get("market_cap") or 0) / total_market_cap * 100) if btc_entry and total_market_cap else 0 + + if not cryptocurrencies: + return { + "success": False, + "data": [], + "cryptocurrencies": [], + "count": 0, + "dataState": "UNAVAILABLE", + "missingCapabilities": ["market"], + "errors": errors or ["No market provider returned data"], + "source": "none", + "timestamp": datetime.now().isoformat(), + } + + return { + "success": True, + "data": cryptocurrencies, + "cryptocurrencies": cryptocurrencies, + "count": len(cryptocurrencies), + "total_market_cap": total_market_cap, + "btc_dominance": btc_dominance, + "dataState": "COMPLETE" if not errors else "PARTIAL", + "missingCapabilities": [], + "errors": errors, + "source": cryptocurrencies[0].get("source", "mixed_public_sources"), + "timestamp": datetime.now().isoformat(), + } + +@app.get("/api/market/history") +async def get_market_history(symbol: str = "BTC", limit: int = 10): + """Get price history from database - REAL DATA ONLY""" + history = get_price_history_from_db(symbol.upper(), limit) + + if not history: + return { + "symbol": symbol, + "history": [], + "count": 0, + "message": "No history available" + } + + return { + "symbol": symbol, + "history": history, + "count": len(history), + "source": "SQLite Database (Real Data)" + } + + +@app.get("/api/sentiment") +async def get_sentiment(): + """Sentiment data from Alternative.me - REAL DATA ONLY""" + try: + data = await fetch_fear_greed_index() + + if "data" in data and len(data["data"]) > 0: + fng_data = data["data"][0] + return { + "fear_greed_index": int(fng_data["value"]), + "fear_greed_label": fng_data["value_classification"], + "timestamp": datetime.now().isoformat(), + "source": "Alternative.me API (Real Data)" + } + + raise HTTPException(status_code=503, detail="Invalid response from Alternative.me") + + except Exception as e: + raise HTTPException(status_code=503, detail=f"Failed to fetch sentiment: {str(e)}") + + +@app.post("/api/sentiment") +async def analyze_sentiment_simple(request: Dict[str, Any]): + """Analyze sentiment with mode routing - simplified endpoint""" + try: + from ai_models import ( + analyze_crypto_sentiment, + analyze_financial_sentiment, + analyze_social_sentiment, + _registry, + MODEL_SPECS, + ModelNotAvailable + ) + + text = request.get("text", "").strip() + if not text: + raise HTTPException(status_code=400, detail="Text is required") + + mode = request.get("mode", "auto").lower() + model_key = request.get("model_key") + + # If model_key is provided, use that specific model + if model_key: + if model_key not in MODEL_SPECS: + raise HTTPException(status_code=404, detail=f"Model key '{model_key}' not found") + + try: + pipeline = _registry.get_pipeline(model_key) + spec = MODEL_SPECS[model_key] + + # Handle trading signal models specially + if spec.category == "trading_signal": + raw_result = pipeline(text, max_length=200, num_return_sequences=1) + if isinstance(raw_result, list) and raw_result: + raw_result = raw_result[0] + generated_text = raw_result.get("generated_text", str(raw_result)) + + decision = "HOLD" + if "buy" in generated_text.lower(): + decision = "BUY" + elif "sell" in generated_text.lower(): + decision = "SELL" + + return { + "sentiment": decision.lower(), + "confidence": 0.7, + "raw_label": decision, + "mode": "trading", + "model": model_key, + "extra": { + "decision": decision, + "rationale": generated_text, + "raw": raw_result + } + } + + # Regular sentiment analysis + raw_result = pipeline(text[:512]) + if isinstance(raw_result, list) and raw_result: + raw_result = raw_result[0] + + label = raw_result.get("label", "neutral").upper() + score = raw_result.get("score", 0.5) + + # Map to standard format + mapped = "Bullish" if "POSITIVE" in label or "BULLISH" in label or "LABEL_2" in label else ( + "Bearish" if "NEGATIVE" in label or "BEARISH" in label or "LABEL_0" in label else "Neutral" + ) + + return { + "sentiment": mapped, + "confidence": score, + "raw_label": label, + "mode": mode, + "model": model_key, + "extra": {"raw": raw_result} + } + + except ModelNotAvailable as e: + logger.warning(f"Model {model_key} not available: {e}") + raise HTTPException(status_code=503, detail=f"Model not available: {str(e)}") + + # Mode-based routing (no explicit model key) + result = None + actual_model = None + + if mode == "crypto" or mode == "auto": + result = analyze_crypto_sentiment(text) + actual_model = "crypto_sent_kk08" # Default crypto model + elif mode == "social": + result = analyze_social_sentiment(text) + actual_model = "crypto_sent_social" # ElKulako/cryptobert + elif mode == "financial": + result = analyze_financial_sentiment(text) + actual_model = "crypto_sent_fin" # FinTwitBERT + elif mode == "news": + result = analyze_financial_sentiment(text) # Use financial for news + actual_model = "crypto_sent_fin" + elif mode == "trading": + # Try to use trading model + try: + pipeline = _registry.get_pipeline("crypto_trading_lm") + raw_result = pipeline(text, max_length=200, num_return_sequences=1) + if isinstance(raw_result, list) and raw_result: + raw_result = raw_result[0] + generated_text = raw_result.get("generated_text", str(raw_result)) + + decision = "HOLD" + if "buy" in generated_text.lower(): + decision = "BUY" + elif "sell" in generated_text.lower(): + decision = "SELL" + + return { + "sentiment": decision, + "confidence": 0.7, + "raw_label": decision, + "mode": "trading", + "model": "crypto_trading_lm", + "extra": { + "decision": decision, + "rationale": generated_text + } + } + except ModelNotAvailable: + # Fallback to crypto sentiment + result = analyze_crypto_sentiment(text) + actual_model = "crypto_sent_kk08" + else: + result = analyze_crypto_sentiment(text) # Default fallback + actual_model = "crypto_sent_kk08" + + if not result: + raise HTTPException(status_code=500, detail="Sentiment analysis failed") + + # Standardize result format + sentiment = result.get("label", "Neutral") + confidence = result.get("confidence", 0.5) + + # Capitalize first letter + sentiment_formatted = sentiment.capitalize() if isinstance(sentiment, str) else "Neutral" + + return { + "sentiment": sentiment_formatted, + "confidence": confidence, + "raw_label": sentiment, + "mode": mode, + "model": actual_model, + "extra": result + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Sentiment analysis error: {e}") + raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") + + +@app.get("/api/resources") +async def get_resources(q: Optional[str] = None): + """Get all resources with optional search query and deduplication""" + try: + resources_list = [] + + # Load from unified resources file + resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" + if resources_json.exists(): + try: + with open(resources_json, 'r', encoding='utf-8') as f: + unified_data = json.load(f) + registry = unified_data.get('registry', {}) + + for category, items in registry.items(): + if category == 'metadata': + continue + if isinstance(items, list): + for item in items: + # Normalize resource structure + resource = { + "id": item.get("id"), + "name": item.get("name", item.get("title", "Unknown")), + "category": category, + "url": item.get("url") or item.get("base_url", ""), + "free": item.get("free", True), + "auth_required": item.get("auth_required", False) or (item.get("auth", {}).get("type") != "none" if "auth" in item else False), + "tags": item.get("tags", []) if isinstance(item.get("tags"), list) else [], + "description": item.get("description", "") or item.get("note", "") + } + + # Additional fields if present + if "method" in item: + resource["method"] = item["method"] + if "path" in item: + resource["path"] = item["path"] + if "endpoint" in item: + resource["endpoint"] = item["endpoint"] + + resources_list.append(resource) + except Exception as e: + logger.error(f"Error loading unified resources: {e}") + + # Load from API registry (all_apis_merged_2025.json) + api_registry = load_api_registry() + if api_registry and "raw_files" in api_registry: + # Parse raw files for additional resources (basic extraction) + for raw_file in api_registry.get("raw_files", [])[:10]: # Limit to first 10 + content = raw_file.get("content", "") + filename = raw_file.get("filename", "") + + # Simple extraction: look for URLs in content + import re + urls = re.findall(r'https?://[^\s<>"]+', content) + for url in urls[:5]: # Limit URLs per file + resources_list.append({ + "id": None, + "name": f"Resource from {filename}", + "category": "discovered", + "url": url, + "free": True, + "auth_required": False, + "tags": ["auto-discovered"], + "description": f"Auto-discovered from {filename}" + }) + + # Apply deduplication + deduplicated_resources = deduplicate_resources(resources_list) + + # Apply search filter if query provided + if q: + deduplicated_resources = filter_resources_by_query(deduplicated_resources, q) + + return deduplicated_resources + + except Exception as e: + logger.error(f"Error in get_resources: {e}") + raise HTTPException(status_code=500, detail=f"Failed to fetch resources: {str(e)}") + + +@app.get("/api/resources/summary") +async def get_resources_summary(): + """Get resources summary for HTML dashboard (includes API registry metadata and local routes)""" + try: + # Import MODEL_SPECS first as source of truth for models count + try: + from ai_models import MODEL_SPECS + models_count = len(MODEL_SPECS) if MODEL_SPECS else 0 + except Exception as e: + logger.warning(f"Failed to import MODEL_SPECS: {e}") + models_count = 0 + + # Load API registry for metadata + api_registry = load_api_registry() + metadata = api_registry.get("metadata", {}) if api_registry else {} + + # Try to load resources from JSON files + resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" + + summary = { + "total_resources": 0, + "free_resources": 0, + "models_available": models_count, # Use MODEL_SPECS as source of truth + "local_routes_count": 0, + "categories": {} + } + + # Load from unified resources + if resources_json.exists(): + try: + with open(resources_json, 'r', encoding='utf-8') as f: + data = json.load(f) + registry = data.get('registry', {}) + + # Process all categories + for category, items in registry.items(): + if category == 'metadata': + continue + if isinstance(items, list): + count = len(items) + summary['total_resources'] += count + summary['categories'][category] = { + "count": count, + "type": "local" if category == "local_backend_routes" else "external" + } + + # Track local routes separately + if category == 'local_backend_routes': + summary['local_routes_count'] = count + + free_count = sum(1 for item in items if item.get('free', False) or item.get('auth', {}).get('type') == 'none') + summary['free_resources'] += free_count + except Exception as e: + logger.warning(f"Failed to load resources JSON: {e}") + + # Ensure models_available is always non-zero if MODEL_SPECS is available + if summary['models_available'] == 0 and models_count > 0: + summary['models_available'] = models_count + + # If no resources found, provide fallback data but keep models count from MODEL_SPECS + if summary['total_resources'] == 0: + logger.warning("No resources found in JSON files, using fallback data") + summary['total_resources'] = 15 + summary['free_resources'] = 12 + # Ensure models count is at least from MODEL_SPECS or fallback minimum + summary['models_available'] = max(summary['models_available'], models_count, 7) + summary['categories'] = { + 'market_data': 5, + 'news': 3, + 'sentiment': 2, + 'blockchain': 3, + 'defi': 2 + } + + return { + "success": True, + "summary": summary, + "api_registry_metadata": metadata, + "timestamp": datetime.now().isoformat() + } + except Exception as e: + logger.error(f"Error in get_resources_summary: {e}") + # Return fallback data on error, but try to get models count from MODEL_SPECS + try: + from ai_models import MODEL_SPECS + fallback_models = len(MODEL_SPECS) if MODEL_SPECS else 7 + except: + fallback_models = 7 + + return { + "success": True, + "summary": { + "total_resources": 15, + "free_resources": 12, + "models_available": fallback_models, + "local_routes_count": 0, + "categories": { + 'market_data': 5, + 'news': 3, + 'sentiment': 2, + 'blockchain': 3, + 'defi': 2 + } + }, + "error": str(e), + "timestamp": datetime.now().isoformat() + } + +@app.get("/api/resources/apis") +async def get_resources_apis(): + """Get API registry with local and external routes""" + registry = load_api_registry() + + # Load unified resources for local routes + resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" + local_routes = [] + unified_metadata = {} + + if resources_json.exists(): + try: + with open(resources_json, 'r', encoding='utf-8') as f: + unified_data = json.load(f) + unified_registry = unified_data.get('registry', {}) + unified_metadata = unified_registry.get('metadata', {}) + local_routes = unified_registry.get('local_backend_routes', []) + except Exception as e: + logger.error(f"Error loading unified resources: {e}") + + # Process legacy registry + categories = set() + metadata = {} + raw_files = [] + trimmed_files = [] + + if registry: + metadata = registry.get("metadata", {}) + raw_files = registry.get("raw_files", []) + + # Extract categories from raw file content (basic parsing) + for raw_file in raw_files[:5]: # Limit to first 5 files for performance + content = raw_file.get("content", "") + # Simple category detection from content + if "market data" in content.lower() or "price" in content.lower(): + categories.add("market_data") + if "explorer" in content.lower() or "blockchain" in content.lower(): + categories.add("block_explorer") + if "rpc" in content.lower() or "node" in content.lower(): + categories.add("rpc_nodes") + if "cors" in content.lower() or "proxy" in content.lower(): + categories.add("cors_proxy") + if "news" in content.lower(): + categories.add("news") + if "sentiment" in content.lower() or "fear" in content.lower(): + categories.add("sentiment") + if "whale" in content.lower(): + categories.add("whale_tracking") + + # Provide trimmed raw files (first 500 chars each) + for raw_file in raw_files[:10]: # Limit to 10 files + content = raw_file.get("content", "") + trimmed_files.append({ + "filename": raw_file.get("filename", ""), + "preview": content[:500] + "..." if len(content) > 500 else content, + "size": len(content) + }) + + # Add local category + if local_routes: + categories.add("local") + + return { + "ok": True, + "metadata": { + "name": metadata.get("name", "") or unified_metadata.get("description", ""), + "version": metadata.get("version", "") or unified_metadata.get("version", ""), + "description": metadata.get("description", ""), + "created_at": metadata.get("created_at", ""), + "source_files": metadata.get("source_files", []), + "updated": unified_metadata.get("updated", "") + }, + "categories": list(categories), + "local_routes": { + "count": len(local_routes), + "routes": local_routes[:20] # Return first 20 for preview + }, + "raw_files_preview": trimmed_files, + "total_raw_files": len(raw_files), + "sources": ["all_apis_merged_2025.json", "crypto_resources_unified_2025-11-11.json"] + } + +@app.get("/api/resources/apis/raw") +async def get_resources_apis_raw(): + """Get raw files from API registry (trimmed to avoid huge payloads)""" + registry = load_api_registry() + + if not registry: + return { + "ok": False, + "error": "API registry file not found" + } + + raw_files = registry.get("raw_files", []) + + # Return trimmed versions (first 1000 chars each, max 20 files) + trimmed = [] + for raw_file in raw_files[:20]: + content = raw_file.get("content", "") + trimmed.append({ + "filename": raw_file.get("filename", ""), + "preview": content[:1000] + "..." if len(content) > 1000 else content, + "full_size": len(content) + }) + + return { + "ok": True, + "files": trimmed, + "total_files": len(raw_files), + "showing": min(20, len(raw_files)), + "source": "all_apis_merged_2025.json" + } + + +@app.get("/api/trending") +async def get_trending(limit: int = 10): + """Trending coins from CoinGecko, normalized for API clients.""" + try: + data = await fetch_coingecko_trending() + trending_coins = [] + if isinstance(data, dict) and "coins" in data: + for item in data["coins"][: min(max(int(limit or 10), 1), 50)]: + coin = item.get("item", {}) + sym = str(coin.get("symbol", "")).upper() + trending_coins.append({ + "id": coin.get("id"), + "name": coin.get("name"), + "symbol": f"{sym}USDT" if sym and not sym.endswith("USDT") else sym, + "baseSymbol": sym, + "marketCapRank": coin.get("market_cap_rank"), + "market_cap_rank": coin.get("market_cap_rank"), + "thumb": coin.get("thumb"), + "score": coin.get("score", 0), + "source": "coingecko_trending", + }) + + return { + "success": True, + "data": trending_coins, + "trending": trending_coins, + "count": len(trending_coins), + "timestamp": datetime.now().isoformat(), + "source": "CoinGecko API (Real Data)", + "errors": [], + } + except Exception as e: + logger.warning(f"Trending fetch failed: {e}") + return { + "success": False, + "data": [], + "trending": [], + "count": 0, + "timestamp": datetime.now().isoformat(), + "source": "coingecko_trending", + "errors": [str(e)], + "missingCapabilities": ["trending"], + } + +# ===== Providers Management Endpoints ===== +@app.get("/api/providers") +async def get_providers(): + """Get all providers with deduplication applied""" + try: + # Load primary config + config = load_providers_config() + providers_dict = config.get("providers", {}) + + # Load auto-discovery report for validation status + discovery_report = load_auto_discovery_report() + discovery_results = {} + if discovery_report and "http_providers" in discovery_report: + for result in discovery_report["http_providers"].get("results", []): + discovery_results[result.get("provider_id")] = result + + # Build provider list from primary config + providers_list = [] + for provider_id, provider_data in providers_dict.items(): + # Merge with auto-discovery data if available + discovery_data = discovery_results.get(provider_id, {}) + + # Determine auth requirement + auth_required = provider_data.get("requires_auth", False) + free = not auth_required + + # Extract tags from provider data + tags = [] + if "tags" in provider_data: + tags = provider_data["tags"] if isinstance(provider_data["tags"], list) else [provider_data["tags"]] + + # Build description + description = provider_data.get("description", "") or provider_data.get("note", "") + if not description and provider_data.get("name"): + description = f"{provider_data.get('name')} - {provider_data.get('category', 'unknown')} provider" + + provider_entry = { + "id": provider_id, + "name": provider_data.get("name", provider_id), + "category": provider_data.get("category", "unknown"), + "base_url": provider_data.get("base_url", ""), + "auth_required": auth_required, + "free": free, + "tags": tags, + "description": description, + "type": provider_data.get("type", "http"), + "priority": provider_data.get("priority", 0), + "weight": provider_data.get("weight", 0), + "rate_limit": provider_data.get("rate_limit", {}), + "endpoints": provider_data.get("endpoints", {}), + "status": discovery_data.get("status", "UNKNOWN") if discovery_data else "unvalidated", + "validated_at": provider_data.get("validated_at"), + "response_time_ms": discovery_data.get("response_time_ms") or provider_data.get("response_time_ms"), + "added_by": provider_data.get("added_by", "manual") + } + providers_list.append(provider_entry) + + # Add HF Models as providers (with proper structure) + try: + from ai_models import MODEL_SPECS, _registry + for model_key, spec in MODEL_SPECS.items(): + is_loaded = model_key in _registry._pipelines + providers_list.append({ + "id": f"hf_model_{model_key}", + "name": f"HF Model: {spec.model_id}", + "category": spec.category, + "base_url": f"/api/models/{model_key}/predict", + "auth_required": spec.requires_auth, + "free": not spec.requires_auth, + "tags": ["huggingface", "ai-model", spec.task, spec.category], + "description": f"Hugging Face {spec.task} model for {spec.category}", + "type": "hf_model", + "status": "available" if is_loaded else "not_loaded", + "model_key": model_key, + "model_id": spec.model_id, + "task": spec.task, + "added_by": "hf_models" + }) + except Exception as e: + logger.warning(f"Could not add HF models as providers: {e}") + + # Apply deduplication + deduplicated_providers = deduplicate_providers(providers_list) + + return { + "providers": deduplicated_providers, + "total": len(deduplicated_providers), + "source": "providers_config_extended.json + PROVIDER_AUTO_DISCOVERY_REPORT.json + HF Models (deduplicated)" + } + except Exception as e: + logger.error(f"Error in get_providers: {e}") + return { + "providers": [], + "total": 0, + "error": str(e), + "source": "error" + } + + +@app.get("/api/providers/{provider_id}") +async def get_provider_detail(provider_id: str): + """Get specific provider details""" + # Check if it's an HF model provider + if provider_id.startswith("hf_model_"): + model_key = provider_id.replace("hf_model_", "") + try: + from ai_models import MODEL_SPECS, _registry + if model_key not in MODEL_SPECS: + raise HTTPException(status_code=404, detail=f"Model {model_key} not found") + + spec = MODEL_SPECS[model_key] + is_loaded = model_key in _registry._pipelines + + return { + "provider_id": provider_id, + "name": f"HF Model: {spec.model_id}", + "category": spec.category, + "type": "hf_model", + "status": "available" if is_loaded else "not_loaded", + "model_key": model_key, + "model_id": spec.model_id, + "task": spec.task, + "requires_auth": spec.requires_auth, + "endpoint": f"/api/models/{model_key}/predict", + "usage": { + "method": "POST", + "url": f"/api/models/{model_key}/predict", + "body": {"text": "string", "options": {}} + }, + "added_by": "hf_models" + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + # Regular provider + config = load_providers_config() + providers = config.get("providers", {}) + + if provider_id not in providers: + raise HTTPException(status_code=404, detail=f"Provider {provider_id} not found") + + return { + "provider_id": provider_id, + **providers[provider_id] + } + + +@app.get("/api/providers/category/{category}") +async def get_providers_by_category(category: str): + """Get providers by category""" + config = load_providers_config() + providers = config.get("providers", {}) + + filtered = { + pid: data for pid, data in providers.items() + if data.get("category") == category + } + + return { + "category": category, + "providers": filtered, + "count": len(filtered) + } + + +# ===== Pools Endpoints (Placeholder - to be implemented) ===== +@app.get("/api/pools") +async def get_pools(): + """Get provider pools""" + return { + "pools": [], + "message": "Pools feature not yet implemented in this version" + } + + +# ===== Logs Endpoints ===== +@app.get("/api/logs/recent") +async def get_recent_logs(): + """Get recent logs""" + return { + "logs": _provider_state.get("logs", [])[-50:], + "count": min(50, len(_provider_state.get("logs", []))) + } + + +@app.get("/api/logs/errors") +async def get_error_logs(): + """Get error logs""" + all_logs = _provider_state.get("logs", []) + errors = [log for log in all_logs if log.get("level") == "ERROR"] + return { + "errors": errors[-50:], + "count": len(errors) + } + + +# ===== Diagnostics Endpoints ===== +@app.post("/api/diagnostics/run") +async def run_diagnostics(auto_fix: bool = False): + """Run system diagnostics""" + issues = [] + fixes_applied = [] + + # Check database + if not DB_PATH.exists(): + issues.append({"type": "database", "message": "Database file not found"}) + if auto_fix: + init_database() + fixes_applied.append("Initialized database") + + # Check providers config + if not PROVIDERS_CONFIG_PATH.exists(): + issues.append({"type": "config", "message": "Providers config not found"}) + + # Check auto-discovery report + if not AUTO_DISCOVERY_REPORT_PATH.exists(): + issues.append({"type": "auto_discovery", "message": "Auto-discovery report not found"}) + + return { + "status": "completed", + "issues_found": len(issues), + "issues": issues, + "fixes_applied": fixes_applied if auto_fix else [], + "timestamp": datetime.now().isoformat() + } + + +@app.get("/api/diagnostics/last") +async def get_last_diagnostics(): + """Get last diagnostics results""" + # Would load from file in real implementation + return { + "status": "no_previous_run", + "message": "No previous diagnostics run found" + } + + +@app.get("/api/diagnostics/health") +async def get_diagnostics_health(): + """ + Get comprehensive health status of all providers and models. + Returns health registry data for diagnostics and observability. + """ + try: + # Get provider health + provider_health = _health_registry.get_all_entries() + provider_summary = _health_registry.get_summary() + + # Get model health + model_health = [] + model_summary = { + "total": 0, + "healthy": 0, + "degraded": 0, + "unavailable": 0, + "unknown": 0, + "in_cooldown": 0 + } + + try: + from ai_models import get_model_health_registry + model_health = get_model_health_registry() + # Calculate model summary + model_summary["total"] = len(model_health) + for model in model_health: + status = model.get("status", "unknown") + model_summary[status] = model_summary.get(status, 0) + 1 + if model.get("in_cooldown", False): + model_summary["in_cooldown"] += 1 + except Exception as e: + logger.warning(f"Could not load model health: {e}") + + return { + "status": "success", + "timestamp": datetime.now().isoformat(), + "providers": { + "summary": provider_summary, + "entries": provider_health + }, + "models": { + "summary": model_summary, + "entries": model_health + }, + "overall_health": { + "providers_ok": provider_summary["healthy"] >= (provider_summary["total"] // 2) if provider_summary["total"] > 0 else True, + "models_ok": model_summary["healthy"] >= (model_summary["total"] // 4) if model_summary["total"] > 0 else True + } + } + except Exception as e: + logger.error(f"Error getting health diagnostics: {e}") + return { + "status": "error", + "error": str(e), + "timestamp": datetime.now().isoformat() + } + + +@app.post("/api/diagnostics/run-test") +async def run_diagnostic_test(): + """ + Run test_models_diagnostic.py and return results. + Execute the Python script and capture stdout/stderr. + """ + import subprocess + import time + + start_time = time.time() + + try: + # Find the diagnostic script - check multiple possible locations + diagnostic_script = None + possible_paths = [ + WORKSPACE_ROOT / "test_models_diagnostic.py", + Path("test_models_diagnostic.py"), + Path(__file__).parent / "test_models_diagnostic.py", + ] + + for path in possible_paths: + if path.exists(): + diagnostic_script = path + break + + if not diagnostic_script: + return { + "status": "error", + "output": "test_models_diagnostic.py not found. Searched in:\n" + "\n".join([str(p) for p in possible_paths]), + "timestamp": datetime.now().isoformat(), + "duration_seconds": 0, + "summary": { + "transformers_available": False, + "hf_hub": False, + "models_loaded": 0, + "critical_issues": ["Diagnostic script not found"] + } + } + + # Execute the diagnostic script + result = subprocess.run( + ["python3", str(diagnostic_script)], + capture_output=True, + text=True, + timeout=60, # 60 second timeout + cwd=str(diagnostic_script.parent) + ) + + duration = time.time() - start_time + + # Combine stdout and stderr + full_output = result.stdout + if result.stderr: + full_output += "\n--- STDERR ---\n" + result.stderr + + # Parse output for summary information + summary = { + "transformers_available": "✅ transformers:" in full_output and "OK" in full_output, + "hf_hub": "✅ Hub connection:" in full_output and "OK" in full_output, + "models_loaded": 0, # Would need more parsing to count actual loaded models + "critical_issues": [] + } + + # Check for critical issues + if "❌ transformers:" in full_output: + summary["critical_issues"].append("Transformers library not available") + if "❌ Authenticated access:" in full_output and "FAILED" in full_output: + summary["critical_issues"].append("HuggingFace authentication failed") + if "❌ Model not available" in full_output: + summary["critical_issues"].append("AI models failed to load") + + return { + "status": "success", + "output": full_output, + "timestamp": datetime.now().isoformat(), + "duration_seconds": round(duration, 2), + "summary": summary + } + + except subprocess.TimeoutExpired: + duration = time.time() - start_time + return { + "status": "timeout", + "output": f"Test timed out after {duration:.1f} seconds", + "timestamp": datetime.now().isoformat(), + "duration_seconds": round(duration, 2), + "summary": { + "transformers_available": False, + "hf_hub": False, + "models_loaded": 0, + "critical_issues": ["Test execution timed out"] + } + } + + except Exception as e: + duration = time.time() - start_time + return { + "status": "error", + "output": f"Error running diagnostic test: {str(e)}", + "timestamp": datetime.now().isoformat(), + "duration_seconds": round(duration, 2), + "summary": { + "transformers_available": False, + "hf_hub": False, + "models_loaded": 0, + "critical_issues": [f"Execution error: {str(e)}"] + } + } + + +@app.post("/api/diagnostics/self-heal") +async def trigger_self_heal(model_key: Optional[str] = None): + """ + Trigger self-healing actions for models. + Safe, idempotent, and non-blocking. + + Query params: + model_key: Specific model to reinitialize (optional) + """ + try: + from ai_models import attempt_model_reinit, get_model_health_registry + + results = [] + + if model_key: + # Reinit specific model + result = attempt_model_reinit(model_key) + results.append({ + "model_key": model_key, + **result + }) + else: + # Reinit all failed models that are out of cooldown + model_health = get_model_health_registry() + failed_models = [ + m for m in model_health + if m.get("status") in ["unavailable", "degraded"] + and not m.get("in_cooldown", False) + ] + + for model in failed_models[:5]: # Limit to 5 at a time to avoid blocking + result = attempt_model_reinit(model["key"]) + results.append({ + "model_key": model["key"], + **result + }) + + success_count = sum(1 for r in results if r.get("status") == "success") + + return { + "status": "completed", + "timestamp": datetime.now().isoformat(), + "results": results, + "summary": { + "total_attempts": len(results), + "successful": success_count, + "failed": len(results) - success_count + } + } + except Exception as e: + logger.error(f"Error in self-heal: {e}") + return { + "status": "error", + "error": str(e), + "timestamp": datetime.now().isoformat() + } + + +# ===== APL (Auto Provider Loader) Endpoints ===== +@app.post("/api/apl/run") +async def run_apl_scan(): + """Run APL provider scan""" + try: + # Run APL script + result = subprocess.run( + ["python3", str(WORKSPACE_ROOT / "auto_provider_loader.py")], + capture_output=True, + text=True, + timeout=300, + cwd=str(WORKSPACE_ROOT) + ) + + # Reload providers after APL run + config = load_providers_config() + _provider_state["providers"] = config.get("providers", {}) + + return { + "status": "completed", + "stdout": result.stdout[-1000:], # Last 1000 chars + "returncode": result.returncode, + "providers_count": len(_provider_state["providers"]), + "timestamp": datetime.now().isoformat() + } + + except subprocess.TimeoutExpired: + return { + "status": "timeout", + "message": "APL scan timed out after 5 minutes" + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"APL scan failed: {str(e)}") + + +@app.get("/api/apl/report") +async def get_apl_report(): + """Get APL validation report (alias for auto-discovery report)""" + return await get_providers_auto_discovery_report() + +@app.get("/api/providers/auto-discovery-report") +async def get_providers_auto_discovery_report(): + """Get PROVIDER_AUTO_DISCOVERY_REPORT.json""" + report = load_auto_discovery_report() + + if not report: + return { + "ok": False, + "error": "Auto-discovery report file not found", + "message": f"Report file not found at {AUTO_DISCOVERY_REPORT_PATH}" + } + + return { + "ok": True, + "report": report, + "source": "PROVIDER_AUTO_DISCOVERY_REPORT.json" + } + +@app.get("/api/providers/health-summary") +async def get_providers_health_summary(): + """Get simplified health summary from auto-discovery report + local routes - always returns 200""" + try: + report = load_auto_discovery_report() + + # Load local routes for health checking + resources_json = WORKSPACE_ROOT / "api-resources" / "crypto_resources_unified_2025-11-11.json" + local_routes = [] + local_health = {"total": 0, "checked": 0, "up": 0, "down": 0} + + if resources_json.exists(): + try: + with open(resources_json, 'r', encoding='utf-8') as f: + unified_data = json.load(f) + unified_registry = unified_data.get('registry', {}) + local_routes = unified_registry.get('local_backend_routes', []) + local_health["total"] = len(local_routes) + + # Quick health check for up to 10 local routes + async with httpx.AsyncClient(timeout=2.0) as client: + routes_to_check = [r for r in local_routes if 'ws://' not in r.get('base_url', '')][:10] + for route in routes_to_check: + base_url = route.get('base_url', '').replace('{API_BASE}', f'http://localhost:{PORT}') + if 'http' in base_url: + try: + response = await client.get(base_url, timeout=2.0) + local_health["checked"] += 1 + if response.status_code < 500: + local_health["up"] += 1 + else: + local_health["down"] += 1 + except: + local_health["checked"] += 1 + local_health["down"] += 1 + except Exception as e: + logger.error(f"Error checking local routes health: {e}") + + if not report or "stats" not in report: + return JSONResponse( + status_code=200, + content={ + "ok": False, + "error": "Auto-discovery report not found or invalid", + "message": f"Report file not found at {AUTO_DISCOVERY_REPORT_PATH}", + "summary": { + "total_active_providers": 0, + "http_valid": 0, + "http_invalid": 0, + "http_conditional": 0, + "hf_valid": 0, + "hf_invalid": 0, + "hf_conditional": 0, + "status_breakdown": {"VALID": 0, "INVALID": 0, "CONDITIONALLY_AVAILABLE": 0}, + "execution_time_sec": 0, + "timestamp": "", + "local_routes": local_health + } + } + ) + + stats = report.get("stats", {}) + http_providers = report.get("http_providers", {}) + hf_providers = report.get("hf_providers", {}) + + # Count by status + status_counts = {"VALID": 0, "INVALID": 0, "CONDITIONALLY_AVAILABLE": 0} + for result in http_providers.get("results", []): + status = result.get("status", "UNKNOWN") + if status in status_counts: + status_counts[status] += 1 + + return JSONResponse( + status_code=200, + content={ + "ok": True, + "summary": { + "total_active_providers": stats.get("total_active_providers", 0), + "http_valid": stats.get("http_valid", 0), + "http_invalid": stats.get("http_invalid", 0), + "http_conditional": stats.get("http_conditional", 0), + "hf_valid": stats.get("hf_valid", 0), + "hf_invalid": stats.get("hf_invalid", 0), + "hf_conditional": stats.get("hf_conditional", 0), + "status_breakdown": status_counts, + "execution_time_sec": stats.get("execution_time_sec", 0), + "timestamp": stats.get("timestamp", ""), + "local_routes": local_health + }, + "source": "PROVIDER_AUTO_DISCOVERY_REPORT.json + local routes" + } + ) + except Exception as e: + logger.error(f"Error loading health summary: {e}") + return JSONResponse( + status_code=200, + content={ + "ok": False, + "error": str(e), + "summary": { + "total_active_providers": 0, + "http_valid": 0, + "http_invalid": 0, + "http_conditional": 0, + "hf_valid": 0, + "hf_invalid": 0, + "hf_conditional": 0, + "status_breakdown": {"VALID": 0, "INVALID": 0, "CONDITIONALLY_AVAILABLE": 0}, + "execution_time_sec": 0, + "timestamp": "", + "local_routes": {"total": 0, "checked": 0, "up": 0, "down": 0} + } + } + ) + +@app.get("/api/apl/summary") +async def get_apl_summary(): + """Get APL summary statistics (alias for health-summary)""" + return await get_providers_health_summary() + + +# ===== HF Models Endpoints ===== +@app.get("/api/hf/models") +async def get_hf_models(): + """Get HuggingFace models from APL report""" + report = load_apl_report() + + if not report: + return {"models": [], "count": 0} + + hf_models = report.get("hf_models", {}).get("results", []) + + return { + "models": hf_models, + "count": len(hf_models), + "source": "APL Validation Report (Real Data)" + } + + +@app.get("/api/hf/health") +async def get_hf_health(): + """Get HF services health""" + try: + from backend.services.hf_registry import REGISTRY + health = REGISTRY.health() + return health + except Exception as e: + return { + "ok": False, + "error": f"HF registry not available: {str(e)}" + } + + +# ===== DeFi Endpoint ===== +@app.get("/api/defi") +async def get_defi(): + """DeFi endpoint""" + return { + "success": True, + "message": "DeFi data endpoint", + "data": [], + "timestamp": datetime.now().isoformat() + } + + +# ===== News Endpoint (compatible with UI) ===== +@app.get("/api/news") +async def get_news_api(limit: int = 20): + """Get news as structured JSON. Empty news is not fatal.""" + results = [] + errors = [] + db_rows_available = False + try: + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM news_articles + ORDER BY analyzed_at DESC + LIMIT ? + """, (limit,)) + rows = cursor.fetchall() + columns = [desc[0] for desc in cursor.description] + conn.close() + db_rows_available = bool(rows) + for row in rows: + record = dict(zip(columns, row)) + if record.get("related_symbols"): + try: + record["related_symbols"] = json.loads(record["related_symbols"]) + except Exception: + pass + results.append(record) + except Exception as db_error: + errors.append(f"database_news_failed: {db_error}") + + if not results: + try: + newsapi_key = get_secret("NEWSAPI_KEY") + if newsapi_key: + async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0), headers=HEADERS) as client: + response = await client.get( + "https://newsapi.org/v2/everything", + params={"q": "crypto OR bitcoin OR ethereum", "language": "en", "pageSize": min(max(int(limit or 20), 1), 100), "apiKey": newsapi_key, "sortBy": "publishedAt"}, + ) + if response.status_code == 200: + payload = response.json() + for article in (payload.get("articles") or [])[:limit]: + results.append({ + "title": article.get("title", ""), + "content": article.get("description") or article.get("content") or "", + "url": article.get("url", ""), + "source": (article.get("source") or {}).get("name", "NewsAPI"), + "sentiment_label": None, + "sentiment_confidence": None, + "related_symbols": [], + "published_date": article.get("publishedAt"), + "analyzed_at": datetime.now().isoformat(), + }) + else: + errors.append(f"newsapi_http_{response.status_code}") + except Exception as newsapi_error: + errors.append(f"newsapi_failed: {newsapi_error}") + + if not results: + try: + cryptocompare_api_key = get_secret("CRYPTOCOMPARE_KEY") + headers = {"User-Agent": "Mozilla/5.0"} + if cryptocompare_api_key: + headers["authorization"] = f"Apikey {cryptocompare_api_key}" + async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client: + response = await client.get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN", headers=headers) + if response.status_code == 200: + data = response.json() + for article in (data.get("Data") or [])[:limit]: + results.append({ + "id": article.get("id"), + "title": article.get("title", ""), + "content": article.get("body", "")[:500], + "url": article.get("url", ""), + "source": article.get("source", "CryptoCompare"), + "sentiment_label": None, + "sentiment_confidence": None, + "related_symbols": article.get("categories", "").split("|") if article.get("categories") else [], + "published_date": datetime.fromtimestamp(article.get("published_on", 0)).isoformat() if article.get("published_on") else None, + "analyzed_at": datetime.now().isoformat(), + }) + else: + errors.append(f"cryptocompare_news_http_{response.status_code}") + except Exception as api_error: + errors.append(f"external_news_failed: {api_error}") + + return { + "success": True, + "data": results, + "news": results, + "count": len(results), + "status": "available" if results else "empty", + "source": "database" if db_rows_available else "external_or_empty", + "errors": errors, + "timestamp": datetime.now().isoformat(), + } + +# ===== Logs Endpoints ===== +@app.get("/api/logs/summary") +async def get_logs_summary(): + """Get logs summary""" + try: + return { + "success": True, + "total": len(_provider_state.get("logs", [])), + "recent": _provider_state.get("logs", [])[-10:], + "timestamp": datetime.now().isoformat() + } + except Exception as e: + return { + "success": False, + "error": str(e) + } + + +# ===== Diagnostics Endpoints ===== +@app.get("/api/diagnostics/errors") +async def get_diagnostics_errors(): + """Get diagnostic errors""" + try: + return { + "success": True, + "errors": [], + "timestamp": datetime.now().isoformat() + } + except Exception as e: + return { + "success": False, + "errors": [], + "error": str(e) + } + + +# ===== Resources Endpoints ===== +@app.get("/api/resources/search") +async def search_resources(q: str = "", source: str = "all"): + """Search resources""" + try: + return { + "success": True, + "query": q, + "source": source, + "results": [], + "count": 0 + } + except Exception as e: + return { + "success": False, + "error": str(e) + } + + +# ===== V2 API Endpoints (compatibility) ===== +@app.post("/api/v2/export/{export_type}") +async def export_v2(export_type: str, data: Dict[str, Any] = None): + """V2 export endpoint""" + return { + "success": True, + "type": export_type, + "message": "Export functionality", + "data": data or {} + } + + +@app.post("/api/v2/backup") +async def backup_v2(): + """V2 backup endpoint""" + return { + "success": True, + "message": "Backup functionality", + "timestamp": datetime.now().isoformat() + } + + +@app.post("/api/v2/import/providers") +async def import_providers_v2(data: Dict[str, Any]): + """V2 import providers endpoint""" + return { + "success": True, + "message": "Import providers functionality", + "data": data + } + + +# ===== HuggingFace ML Sentiment Endpoints ===== +@app.post("/api/sentiment/analyze") +async def analyze_sentiment(request: Dict[str, Any]): + """Analyze sentiment using Hugging Face models""" + try: + from ai_models import ( + analyze_crypto_sentiment, + analyze_financial_sentiment, + analyze_social_sentiment, + analyze_market_text, + _registry, + MODEL_SPECS, + ModelNotAvailable + ) + + text = request.get("text", "").strip() + if not text: + raise HTTPException(status_code=400, detail="Text is required") + + mode = request.get("mode", "auto").lower() + source = request.get("source", "user") + model_key = request.get("model_key") + symbol = request.get("symbol") + + try: + # If model_key is provided, use that specific model + if model_key and model_key in MODEL_SPECS: + try: + pipeline = _registry.get_pipeline(model_key) + spec = MODEL_SPECS[model_key] + + # Handle different task types + if spec.task == "text-generation": + # For trading signal models or generation models + raw_result = pipeline(text, max_length=200, num_return_sequences=1) + if isinstance(raw_result, list) and raw_result: + raw_result = raw_result[0] + + generated_text = raw_result.get("generated_text", str(raw_result)) + + # Parse trading signals if applicable + if spec.category == "trading_signal": + # Extract signal from generated text + decision = "HOLD" + if "buy" in generated_text.lower(): + decision = "BUY" + elif "sell" in generated_text.lower(): + decision = "SELL" + + return { + "ok": True, + "available": True, + "sentiment": decision.lower(), + "label": decision.lower(), + "score": 0.7, + "confidence": 0.7, + "model": model_key, + "engine": "huggingface", + "mode": "trading", + "extra": { + "decision": decision, + "rationale": generated_text, + "raw": raw_result + } + } + else: + # Generation model - return generated text + return { + "ok": True, + "available": True, + "sentiment": "neutral", + "label": "neutral", + "score": 0.5, + "confidence": 0.5, + "model": model_key, + "engine": "huggingface", + "mode": "generation", + "extra": { + "generated_text": generated_text, + "raw": raw_result + } + } + else: + # Text classification / sentiment + raw_result = pipeline(text[:512]) + if isinstance(raw_result, list) and raw_result: + raw_result = raw_result[0] + + label = raw_result.get("label", "neutral").upper() + score = raw_result.get("score", 0.5) + + # Map labels to standard format + mapped = "bullish" if "POSITIVE" in label or "BULLISH" in label or "LABEL_2" in label else ( + "bearish" if "NEGATIVE" in label or "BEARISH" in label or "LABEL_0" in label else "neutral" + ) + + return { + "ok": True, + "available": True, + "sentiment": mapped, + "label": mapped, + "score": score, + "confidence": score, + "raw_label": label, + "model": model_key, + "engine": "huggingface", + "mode": mode, + "extra": { + "vote": score if mapped == "bullish" else (-score if mapped == "bearish" else 0.0), + "raw": raw_result + } + } + except ModelNotAvailable as e: + logger.warning(f"Model {model_key} not available: {e}") + return { + "ok": False, + "available": False, + "error": f"Model {model_key} not available: {str(e)}", + "label": "neutral", + "sentiment": "neutral", + "score": 0.0, + "confidence": 0.0 + } + + # Default mode-based analysis + if mode == "crypto": + result = analyze_crypto_sentiment(text) + elif mode == "financial": + result = analyze_financial_sentiment(text) + elif mode == "social": + result = analyze_social_sentiment(text) + elif mode == "trading": + # Try to use trading signal model + result = analyze_crypto_sentiment(text) + else: + result = analyze_market_text(text) + + sentiment_label = result.get("label", "neutral") + confidence = result.get("confidence", result.get("score", 0.5)) + model_used = result.get("model_count", result.get("model", result.get("engine", "unknown"))) + + # Prepare response compatible with frontend format + response_data = { + "ok": True, + "available": True, + "sentiment": sentiment_label.lower(), + "label": sentiment_label.lower(), + "confidence": float(confidence), + "score": float(confidence), + "model": f"{model_used} models" if isinstance(model_used, int) else str(model_used), + "engine": result.get("engine", "huggingface"), + "mode": mode + } + + # Add details if available for score bars + if result.get("scores"): + scores_dict = result.get("scores", {}) + if isinstance(scores_dict, dict): + labels_list = [] + scores_list = [] + for lbl, scr in scores_dict.items(): + labels_list.append(lbl) + scores_list.append(float(scr) if isinstance(scr, (int, float)) else float(scr.get("score", 0.5)) if isinstance(scr, dict) else 0.5) + if labels_list: + response_data["details"] = { + "labels": labels_list, + "scores": scores_list + } + + # Save to database + try: + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO sentiment_analysis + (text, sentiment_label, confidence, model_used, analysis_type, symbol, scores) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + text[:500], + sentiment_label, + confidence, + f"{model_used} models" if isinstance(model_used, int) else str(model_used), + mode, + symbol, + json.dumps(result.get("scores", {})) + )) + conn.commit() + conn.close() + except Exception as db_error: + logger.warning(f"Failed to save to database: {db_error}") + + return response_data + + except Exception as e: + # Unexpected error - log and return error response + logger.error(f"Sentiment analysis unexpected error: {str(e)}") + return { + "ok": False, + "available": False, + "error": f"Analysis failed: {str(e)}", + "sentiment": "neutral", + "label": "neutral", + "confidence": 0.0, + "score": 0.0 + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Sentiment analysis failed: {str(e)}") + + +@app.post("/api/ai/summarize") +async def summarize_text(request: Dict[str, Any]): + """ + Summarize text using Hugging Face models or simple text processing. + + Expects: { "text": "string", "max_sentences": 3 } + Returns: { "ok": true, "summary": "...", "sentences": ["...", "..."] } + """ + try: + text = request.get("text", "").strip() + max_sentences = request.get("max_sentences", 3) + + if not text: + return { + "ok": False, + "error": "Text is required" + } + + # Try to use Hugging Face summarization model if available + try: + from ai_models import MODEL_SPECS, _registry, ModelNotAvailable + + # Check if summarization model is available + summarization_key = None + for key, spec in MODEL_SPECS.items(): + if spec.task == "summarization": + summarization_key = key + break + + if summarization_key: + try: + pipeline = _registry.get_pipeline(summarization_key) + # Use HF model for summarization + # Try with parameters first, then fallback to simple call + try: + summary_result = pipeline(text, max_length=max_sentences * 50, min_length=max_sentences * 20, do_sample=False) + except TypeError: + # Some pipelines don't accept these parameters + summary_result = pipeline(text) + + if isinstance(summary_result, list) and summary_result: + summary_text = summary_result[0].get("summary_text", summary_result[0].get("generated_text", str(summary_result[0]))) + elif isinstance(summary_result, dict): + summary_text = summary_result.get("summary_text", summary_result.get("generated_text", str(summary_result))) + else: + summary_text = str(summary_result) + + # Split into sentences + sentences = [s.strip() + ("." if not s.strip().endswith((".", "!", "?")) else "") for s in summary_text.split(". ") if s.strip()] + sentences = sentences[:max_sentences] + + return { + "ok": True, + "summary": summary_text, + "sentences": sentences + } + except ModelNotAvailable: + # Fall through to simple summarizer + pass + except Exception as e: + logger.warning(f"HF summarization failed: {e}, using fallback") + # Fall through to simple summarizer + pass + except Exception as e: + logger.warning(f"HF summarization model not available: {e}") + # Fall through to simple summarizer + + # Simple placeholder summarizer: split by sentences and take first N + sentences = [] + current_sentence = "" + + for char in text: + current_sentence += char + if char in ".!?": + sentence = current_sentence.strip() + if sentence: + sentences.append(sentence) + current_sentence = "" + if len(sentences) >= max_sentences: + break + + # If we didn't get enough sentences, add the rest + if len(sentences) < max_sentences and current_sentence.strip(): + sentences.append(current_sentence.strip()) + + # If still no sentences, just truncate + if not sentences: + words = text.split() + chunk_size = len(words) // max_sentences + sentences = [] + for i in range(max_sentences): + start_idx = i * chunk_size + end_idx = start_idx + chunk_size if i < max_sentences - 1 else len(words) + if start_idx < len(words): + sentence = " ".join(words[start_idx:end_idx]) + if sentence: + sentences.append(sentence) + + summary = " ".join(sentences) + + return { + "ok": True, + "summary": summary, + "sentences": sentences[:max_sentences] + } + + except Exception as e: + logger.error(f"Summarization failed: {e}") + return { + "ok": False, + "error": f"Summarization failed: {str(e)}" + } + + +@app.post("/api/news/analyze") +async def analyze_news(request: Dict[str, Any]): + """Analyze news article sentiment using HF models""" + try: + from ai_models import analyze_news_item + + title = request.get("title", "").strip() + content = request.get("content", request.get("description", "")).strip() + url = request.get("url", "") + source = request.get("source", "unknown") + published_date = request.get("published_date") + + if not title and not content: + raise HTTPException(status_code=400, detail="Title or content is required") + + try: + news_item = { + "title": title, + "description": content + } + result = analyze_news_item(news_item) + + sentiment_label = result.get("sentiment", "neutral") + sentiment_confidence = result.get("sentiment_confidence", 0.5) + sentiment_details = result.get("sentiment_details", {}) + related_symbols = request.get("related_symbols", []) + + # Check if HF models were used (for diagnostics) + hf_available = sentiment_details.get("engine", "unknown") == "huggingface" if isinstance(sentiment_details, dict) else True + + # Save to database (always) + saved_to_db = False + try: + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO news_articles + (title, content, url, source, sentiment_label, sentiment_confidence, related_symbols, published_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + title[:500], + content[:2000] if content else None, + url, + source, + sentiment_label, + sentiment_confidence, + json.dumps(related_symbols) if related_symbols else None, + published_date + )) + conn.commit() + conn.close() + saved_to_db = True + except Exception as db_error: + logger.warning(f"Failed to save to database: {db_error}") + + return { + "success": True, + "available": True, + "hf_available": hf_available, + "news": { + "title": title, + "sentiment": sentiment_label, + "confidence": sentiment_confidence, + "details": sentiment_details + }, + "saved_to_db": saved_to_db + } + + except Exception as e: + logger.error(f"News analysis error: {str(e)}") + return { + "success": False, + "available": False, + "error": f"Analysis failed: {str(e)}", + "news": { + "title": title, + "sentiment": "neutral", + "confidence": 0.0 + } + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"News analysis failed: {str(e)}") + + +@app.get("/api/sentiment/history") +async def get_sentiment_history( + symbol: Optional[str] = None, + limit: int = 50 +): + """Get sentiment analysis history from database""" + try: + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + if symbol: + cursor.execute(""" + SELECT * FROM sentiment_analysis + WHERE symbol = ? + ORDER BY timestamp DESC + LIMIT ? + """, (symbol.upper(), limit)) + else: + cursor.execute(""" + SELECT * FROM sentiment_analysis + ORDER BY timestamp DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + columns = [desc[0] for desc in cursor.description] + conn.close() + + results = [] + for row in rows: + record = dict(zip(columns, row)) + if record.get("scores"): + try: + record["scores"] = json.loads(record["scores"]) + except: + pass + results.append(record) + + return { + "success": True, + "count": len(results), + "results": results + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch sentiment history: {str(e)}") + + +@app.post("/api/news/fetch") +async def fetch_and_save_news(limit: int = 50): + """Fetch news from CryptoCompare API and save to database""" + try: + cryptocompare_api_key = os.getenv("CRYPTOCOMPARE_API_KEY", "") + + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.get( + "https://min-api.cryptocompare.com/data/v2/news/?lang=EN", + headers={ + "User-Agent": "Mozilla/5.0", + "authorization": f"Apikey {cryptocompare_api_key}" + } + ) + + if response.status_code != 200: + return { + "success": False, + "error": f"CryptoCompare API returned {response.status_code}", + "saved": 0 + } + + data = response.json() + + if not data.get("Data"): + return { + "success": False, + "error": "No news data returned from API", + "saved": 0 + } + + # Save to database + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + saved_count = 0 + + for article in data["Data"][:limit]: + try: + # Check if article already exists + cursor.execute("SELECT id FROM news_articles WHERE url = ?", (article.get("url", ""),)) + if cursor.fetchone(): + continue # Skip duplicates + + # Extract related symbols from categories + categories = article.get("categories", "").split("|") if article.get("categories") else [] + related_symbols_json = json.dumps(categories) + + # Insert news article + cursor.execute(""" + INSERT INTO news_articles ( + title, content, url, source, + related_symbols, published_date, analyzed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + article.get("title", ""), + article.get("body", "")[:1000], # Limit content length + article.get("url", ""), + article.get("source", "CryptoCompare"), + related_symbols_json, + datetime.fromtimestamp(article.get("published_on", 0)).isoformat() if article.get("published_on") else None, + datetime.now().isoformat() + )) + saved_count += 1 + except Exception as e: + logger.warning(f"Error saving article: {e}") + continue + + conn.commit() + conn.close() + + logger.info(f"[OK] Saved {saved_count} news articles to database") + + return { + "success": True, + "saved": saved_count, + "total_fetched": len(data["Data"][:limit]), + "message": f"Successfully saved {saved_count} news articles" + } + + except Exception as e: + logger.error(f"Error fetching news: {e}") + return { + "success": False, + "error": str(e), + "saved": 0 + } + + +@app.get("/api/news/latest") +async def get_latest_news(limit: int = 20, sentiment: Optional[str] = None): + """Get latest analyzed news; empty is a valid non-fatal state.""" + try: + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + if sentiment: + cursor.execute(""" + SELECT * FROM news_articles + WHERE sentiment_label = ? + ORDER BY analyzed_at DESC + LIMIT ? + """, (sentiment.lower(), limit)) + else: + cursor.execute(""" + SELECT * FROM news_articles + ORDER BY analyzed_at DESC + LIMIT ? + """, (limit,)) + rows = cursor.fetchall() + columns = [desc[0] for desc in cursor.description] + conn.close() + + results = [] + for row in rows: + record = dict(zip(columns, row)) + if record.get("related_symbols"): + try: + record["related_symbols"] = json.loads(record["related_symbols"]) + except Exception: + pass + results.append(record) + + return { + "success": True, + "data": results, + "news": results, + "count": len(results), + "status": "available" if results else "empty", + "errors": [], + "timestamp": datetime.now().isoformat(), + } + except Exception as e: + logger.warning(f"Latest news fetch failed: {e}") + return { + "success": True, + "data": [], + "news": [], + "count": 0, + "status": "empty", + "errors": [str(e)], + "timestamp": datetime.now().isoformat(), + } + +@app.post("/api/news/summarize") +async def summarize_news(request: Dict[str, Any]): + """ + Summarize crypto/financial news using Hugging Face Crypto-Financial-News-Summarizer model + + Expects: { "title": "News Title", "content": "Full article text" } + Returns: { "summary": "Summarized news paragraph", "model": "Crypto-Financial-News-Summarizer" } + """ + try: + from ai_models import MODEL_SPECS, _registry, ModelNotAvailable + + title = request.get("title", "").strip() + content = request.get("content", "").strip() + + if not title and not content: + raise HTTPException(status_code=400, detail="Title or content is required") + + # Combine title and content for summarization + text_to_summarize = f"{title}. {content}" if title and content else (title or content) + + try: + # Try to use the Crypto-Financial-News-Summarizer model + summarization_key = "summarization_0" + + if summarization_key in MODEL_SPECS: + try: + pipeline = _registry.get_pipeline(summarization_key) + spec = MODEL_SPECS[summarization_key] + + # Use HF model for summarization + # Limit input text to avoid token length issues + max_input_length = 1024 + text_input = text_to_summarize[:max_input_length] + + try: + # Try with parameters first + summary_result = pipeline( + text_input, + max_length=150, + min_length=50, + do_sample=False, + truncation=True + ) + except TypeError: + # Some pipelines don't accept these parameters + summary_result = pipeline(text_input, truncation=True) + + # Extract summary text from result + if isinstance(summary_result, list) and summary_result: + summary_text = summary_result[0].get("summary_text", summary_result[0].get("generated_text", str(summary_result[0]))) + elif isinstance(summary_result, dict): + summary_text = summary_result.get("summary_text", summary_result.get("generated_text", str(summary_result))) + else: + summary_text = str(summary_result) + + return { + "success": True, + "summary": summary_text, + "model": spec.model_id, + "available": True, + "input_length": len(text_input), + "title": title, + "timestamp": datetime.now().isoformat() + } + + except ModelNotAvailable as e: + logger.warning(f"Crypto-Financial-News-Summarizer not available: {e}") + # Fall through to fallback + except Exception as e: + logger.warning(f"HF summarization failed: {e}, using fallback") + # Fall through to fallback + + # Fallback: Simple extractive summarization + # Split into sentences and take the most important ones + sentences = [] + current_sentence = "" + + for char in text_to_summarize: + current_sentence += char + if char in ".!?": + sentence = current_sentence.strip() + if sentence and len(sentence) > 10: # Filter out very short sentences + sentences.append(sentence) + current_sentence = "" + if len(sentences) >= 5: # Take first 5 sentences max + break + + # If we didn't get enough sentences, add the rest + if len(sentences) < 3 and current_sentence.strip(): + sentences.append(current_sentence.strip()) + + # Take first 3 sentences as summary + summary = " ".join(sentences[:3]) if sentences else text_to_summarize[:500] + + return { + "success": True, + "summary": summary, + "model": "fallback_extractive", + "available": False, + "note": "Using fallback extractive summarization (HF model not available)", + "title": title, + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"Summarization error: {str(e)}") + return { + "success": False, + "error": f"Summarization failed: {str(e)}", + "summary": "", + "model": "error", + "available": False + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"News summarization failed: {str(e)}") + + +@app.get("/api/models/status") +async def get_models_status(): + """Get AI models status and registry info - honest status reporting""" + try: + from ai_models import ( + get_model_info, registry_status, HF_MODE, TRANSFORMERS_AVAILABLE, + INFERENCE_API_MODE, _registry, + ) + + model_info = get_model_info() + registry_info = registry_status() + loaded_count = len(_registry._pipelines) + len(_registry._inference_ready) + + # Determine honest status + if HF_MODE == "off": + status = "disabled" + status_message = "HF models are disabled (HF_MODE=off). To enable them, set HF_MODE=public or HF_MODE=auth in the environment." + elif INFERENCE_API_MODE and loaded_count > 0: + status = "ok" if len(_registry._failed_models) == 0 else "partial" + status_message = f"{loaded_count} model(s) ready via HF Inference API" + elif not TRANSFORMERS_AVAILABLE and not INFERENCE_API_MODE: + status = "transformers_unavailable" + status_message = "Transformers library is not installed. Models cannot be loaded." + elif not _registry._initialized: + status = "not_initialized" + status_message = "Models have not been initialized yet." + elif loaded_count == 0: + status = "no_models_loaded" + status_message = f"No models could be loaded. {len(_registry._failed_models)} models failed. Check model IDs or HF access." + elif loaded_count > 0: + status = "ok" if len(_registry._failed_models) == 0 else "partial" + backend = "inference API" if INFERENCE_API_MODE else "local" + status_message = f"{loaded_count} model(s) loaded successfully ({backend})" + if len(_registry._failed_models) > 0: + status_message += f", {len(_registry._failed_models)} failed" + else: + status = "unknown" + status_message = "Unknown status" + + # Format failed models as list of [key, error] tuples for ai_tools.html + failed_list = [] + for key, error in list(_registry._failed_models.items())[:10]: + failed_list.append([key, str(error)]) + + return { + "success": True, + "status": status, + "status_message": status_message, + "hf_mode": HF_MODE, + "inference_api_mode": INFERENCE_API_MODE, + "models_loaded": loaded_count, + "models_failed": len(_registry._failed_models), + "transformers_available": TRANSFORMERS_AVAILABLE, + "initialized": _registry._initialized, + "models": model_info, + "registry": registry_info, + "failed": failed_list, # Format: [[key, error], ...] for ai_tools.html + "failed_models": list(_registry._failed_models.keys())[:10], # Keep for backward compatibility + "loaded_models": list(_registry._pipelines.keys()) + list(_registry._inference_ready), + "database": { + "path": str(DB_PATH), + "exists": DB_PATH.exists() + } + } + except Exception as e: + logger.error(f"Error getting models status: {e}") + return { + "success": False, + "status": "error", + "status_message": f"Error retrieving model status: {str(e)}", + "error": str(e), + "hf_mode": "unknown", + "models_loaded": 0, + "models_failed": 0 + } + + +@app.post("/api/models/initialize") +async def initialize_ai_models(): + """Initialize AI models (force reload)""" + try: + from ai_models import initialize_models, _registry, HF_MAX_STARTUP_MODELS + + result = initialize_models(max_models=HF_MAX_STARTUP_MODELS) + registry_status = _registry.get_registry_status() + + return registry_status + except Exception as e: + logger.error(f"Failed to initialize models: {e}") + return { + "models_total": 0, + "models_loaded": 0, + "models_failed": 0, + "items": [], + "error": str(e) + } + + +# ===== Model-based Data Endpoints (Using HF Models as Data Sources) ===== +@app.get("/api/models/list") +async def list_available_models(): + """List all available Hugging Face models as data sources""" + try: + from ai_models import get_model_info, MODEL_SPECS, _registry, CRYPTO_SENTIMENT_MODELS, SOCIAL_SENTIMENT_MODELS, FINANCIAL_SENTIMENT_MODELS, NEWS_SENTIMENT_MODELS, GENERATION_MODELS, TRADING_SIGNAL_MODELS + + model_info = get_model_info() + + # Model descriptions + model_descriptions = { + "kk08/CryptoBERT": "Crypto sentiment binary classification model trained on cryptocurrency-related text", + "ElKulako/cryptobert": "Crypto social sentiment classifier (Bullish/Neutral/Bearish) for social media and news", + "StephanAkkerman/FinTwitBERT-sentiment": "Financial tweet sentiment analysis model for market-related social media content", + "OpenC/crypto-gpt-o3-mini": "Crypto and DeFi text generation model for analysis and content creation", + "ElKulako/cryptobert": "Crypto sentiment model used for trading signal generation (buy/sell/hold based on sentiment)", + "cardiffnlp/twitter-roberta-base-sentiment-latest": "General Twitter sentiment analysis (fallback model)", + "ProsusAI/finbert": "Financial sentiment analysis model for news and financial documents", + "FurkanGozukara/Crypto-Financial-News-Summarizer": "Specialized model for summarizing cryptocurrency and financial news articles" + } + + models_list = [] + for key, spec in MODEL_SPECS.items(): + is_loaded = key in _registry._pipelines or key in getattr(_registry, "_inference_ready", set()) + error_msg = None + if key in _registry._failed_models: + error_msg = str(_registry._failed_models[key]) + + models_list.append({ + "key": key, + "id": key, + "name": spec.model_id, + "model_id": spec.model_id, + "task": spec.task, + "category": spec.category, + "requires_auth": spec.requires_auth, + "loaded": is_loaded, + "error": error_msg, + "description": model_descriptions.get(spec.model_id, f"{spec.category} model for {spec.task}"), + "endpoint": f"/api/models/{key}/predict" + }) + + return { + "success": True, + "total_models": len(models_list), + "models": models_list, + "categories": { + "crypto_sentiment": CRYPTO_SENTIMENT_MODELS, + "social_sentiment": SOCIAL_SENTIMENT_MODELS, + "financial_sentiment": FINANCIAL_SENTIMENT_MODELS, + "news_sentiment": NEWS_SENTIMENT_MODELS, + "generation": GENERATION_MODELS, + "trading_signals": TRADING_SIGNAL_MODELS, + "summarization": ["FurkanGozukara/Crypto-Financial-News-Summarizer"] + }, + "model_info": model_info + } + except Exception as e: + return { + "success": False, + "error": str(e), + "models": [] + } + + +@app.get("/api/models/{model_key}/info") +async def get_model_info_endpoint(model_key: str): + """Get information about a specific model""" + try: + from ai_models import MODEL_SPECS, ModelNotAvailable, _registry + + if model_key not in MODEL_SPECS: + raise HTTPException(status_code=404, detail=f"Model {model_key} not found") + + spec = MODEL_SPECS[model_key] + is_loaded = model_key in _registry._pipelines + + return { + "success": True, + "model_key": model_key, + "model_id": spec.model_id, + "task": spec.task, + "category": spec.category, + "requires_auth": spec.requires_auth, + "is_loaded": is_loaded, + "endpoint": f"/api/models/{model_key}/predict", + "usage": { + "method": "POST", + "url": f"/api/models/{model_key}/predict", + "body": {"text": "string", "options": {}} + } + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/models/{model_key}/predict") +async def predict_with_model(model_key: str, request: Dict[str, Any]): + """Use a specific model to generate predictions/data""" + try: + from ai_models import MODEL_SPECS, _registry, ModelNotAvailable + + if model_key not in MODEL_SPECS: + raise HTTPException(status_code=404, detail=f"Model {model_key} not found") + + spec = MODEL_SPECS[model_key] + text = request.get("text", "").strip() + + if not text: + raise HTTPException(status_code=400, detail="Text is required") + + try: + pipeline = _registry.get_pipeline(model_key) + result = pipeline(text[:512]) + + if isinstance(result, list) and result: + result = result[0] + + return { + "success": True, + "available": True, + "model_key": model_key, + "model_id": spec.model_id, + "task": spec.task, + "input": text[:100], + "output": result, + "timestamp": datetime.now().isoformat() + } + except ModelNotAvailable as e: + return { + "success": False, + "available": False, + "model_key": model_key, + "model_id": spec.model_id, + "error": str(e), + "reason": "model_unavailable" + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Prediction failed: {str(e)}") + + +@app.post("/api/models/batch/predict") +async def batch_predict(request: Dict[str, Any]): + """Batch prediction using multiple models""" + try: + from ai_models import MODEL_SPECS, _registry, ModelNotAvailable + + texts = request.get("texts", []) + model_keys = request.get("models", []) + + if not texts: + raise HTTPException(status_code=400, detail="Texts array is required") + + if not model_keys: + model_keys = list(MODEL_SPECS.keys())[:5] + + results = [] + for text in texts: + if not text.strip(): + continue + + text_results = {} + for model_key in model_keys: + if model_key not in MODEL_SPECS: + continue + + try: + spec = MODEL_SPECS[model_key] + pipeline = _registry.get_pipeline(model_key) + result = pipeline(text[:512]) + + if isinstance(result, list) and result: + result = result[0] + + text_results[model_key] = { + "model_id": spec.model_id, + "result": result, + "success": True + } + except ModelNotAvailable: + text_results[model_key] = { + "success": False, + "error": "Model not available" + } + except Exception as e: + text_results[model_key] = { + "success": False, + "error": str(e) + } + + results.append({ + "text": text[:100], + "predictions": text_results + }) + + return { + "success": True, + "total_texts": len(results), + "models_used": model_keys, + "results": results, + "timestamp": datetime.now().isoformat() + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Batch prediction failed: {str(e)}") + + +@app.post("/api/analyze/text") +async def analyze_text(request: Dict[str, Any]): + """ + Analyze or generate text using crypto-gpt-o3-mini generation model. + + Expects: { "prompt": "...", "mode": "analysis" | "generation" } + Returns: { "text": "...", "model": "OpenC/crypto-gpt-o3-mini" } + """ + try: + from ai_models import MODEL_SPECS, _registry, ModelNotAvailable + + prompt = request.get("prompt", "").strip() + mode = request.get("mode", "analysis").lower() + max_length = request.get("max_length", 200) + + if not prompt: + raise HTTPException(status_code=400, detail="Prompt is required") + + # Find generation model (crypto-gpt-o3-mini) - use specific key first + generation_key = "crypto_ai_analyst" if "crypto_ai_analyst" in MODEL_SPECS else None + + # Fallback: search by category or model name + if not generation_key: + for key, spec in MODEL_SPECS.items(): + if spec.category == "analysis_generation" or "crypto-gpt" in spec.model_id.lower(): + generation_key = key + break + + if not generation_key: + return { + "success": False, + "available": False, + "error": "Crypto text generation model not configured", + "text": "" + } + + try: + spec = MODEL_SPECS[generation_key] + pipeline = _registry.get_pipeline(generation_key) + + # Generate text + result = pipeline(prompt, max_length=max_length, num_return_sequences=1, truncation=True) + + if isinstance(result, list) and result: + result = result[0] + + generated_text = result.get("generated_text", str(result)) + + return { + "success": True, + "available": True, + "text": generated_text, + "model": spec.model_id, + "mode": mode, + "prompt": prompt[:100], + "timestamp": datetime.now().isoformat() + } + + except ModelNotAvailable as e: + logger.warning(f"Generation model not available: {e}") + return { + "success": False, + "available": False, + "error": f"Model not available: {str(e)}", + "text": "", + "note": "HF model unavailable - check model configuration" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Text analysis failed: {e}") + raise HTTPException(status_code=500, detail=f"Text analysis failed: {str(e)}") + + +@app.post("/api/trading/decision") +async def trading_decision(request: Dict[str, Any]): + """ + Get trading decision based on sentiment analysis. + Uses sentiment analysis to determine BUY/SELL/HOLD signals. + + Expects: { "symbol": "BTC", "context": "market context..." } + Returns: { + "decision": "BUY" | "SELL" | "HOLD", + "confidence": float, + "rationale": "explanation", + "raw": {...} + } + """ + try: + from ai_models import analyze_crypto_sentiment + + symbol = request.get("symbol", "").strip().upper() + context = request.get("context", "").strip() + + if not symbol: + raise HTTPException(status_code=400, detail="Symbol is required") + + # Build text for sentiment analysis + if context: + analysis_text = f"{symbol} {context}" + else: + analysis_text = f"{symbol} market analysis" + + # Default response in case of any failure + default_response = { + "success": True, + "available": True, + "decision": "HOLD", + "confidence": 0.5, + "rationale": "Sentiment analysis unavailable - defaulting to HOLD", + "symbol": symbol, + "model": "fallback", + "context_provided": bool(context), + "timestamp": datetime.now().isoformat() + } + + try: + # Analyze sentiment using crypto sentiment model + sentiment_result = analyze_crypto_sentiment(analysis_text) + + # Extract sentiment label and confidence + sentiment_label = sentiment_result.get("label", "neutral").lower() + confidence = sentiment_result.get("confidence", 0.5) + + # Map sentiment to trading decision + decision = "HOLD" # Default + if sentiment_label == "bullish": + decision = "BUY" + elif sentiment_label == "bearish": + decision = "SELL" + else: # neutral or unknown + decision = "HOLD" + + # Build rationale + rationale = f"Sentiment analysis indicates {sentiment_label} sentiment (confidence: {confidence:.2f})" + if context: + rationale += f" based on: {context[:200]}" + + return { + "success": True, + "available": True, + "decision": decision, + "confidence": float(confidence), + "rationale": rationale, + "symbol": symbol, + "model": sentiment_result.get("engine", "sentiment_analysis"), + "sentiment": sentiment_label, + "context_provided": bool(context), + "raw": sentiment_result, + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + logger.warning(f"Sentiment analysis failed for trading decision: {e}") + # Return default HOLD response instead of crashing + default_response["error"] = f"Sentiment analysis failed: {str(e)[:100]}" + default_response["note"] = "Using default HOLD signal due to analysis failure" + return default_response + + except HTTPException: + raise + except Exception as e: + logger.error(f"Trading decision failed: {e}") + # Return safe default instead of raising exception + return { + "success": True, + "available": False, + "error": f"Trading decision processing failed: {str(e)[:100]}", + "decision": "HOLD", + "confidence": 0.5, + "rationale": "Error occurred during analysis - defaulting to HOLD for safety", + "symbol": request.get("symbol", "UNKNOWN"), + "timestamp": datetime.now().isoformat() + } + + +@app.get("/api/models/data/generated") +async def get_generated_data( + limit: int = 50, + model_key: Optional[str] = None, + symbol: Optional[str] = None +): + """Get data generated by models from database""" + try: + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + if model_key and symbol: + cursor.execute(""" + SELECT * FROM sentiment_analysis + WHERE analysis_type = ? AND symbol = ? + ORDER BY timestamp DESC + LIMIT ? + """, (model_key, symbol.upper(), limit)) + elif model_key: + cursor.execute(""" + SELECT * FROM sentiment_analysis + WHERE analysis_type = ? + ORDER BY timestamp DESC + LIMIT ? + """, (model_key, limit)) + elif symbol: + cursor.execute(""" + SELECT * FROM sentiment_analysis + WHERE symbol = ? + ORDER BY timestamp DESC + LIMIT ? + """, (symbol.upper(), limit)) + else: + cursor.execute(""" + SELECT * FROM sentiment_analysis + ORDER BY timestamp DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + columns = [desc[0] for desc in cursor.description] + conn.close() + + results = [] + for row in rows: + record = dict(zip(columns, row)) + if record.get("scores"): + try: + record["scores"] = json.loads(record["scores"]) + except: + pass + results.append(record) + + return { + "success": True, + "count": len(results), + "data": results, + "source": "models", + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch generated data: {str(e)}") + + +@app.get("/api/models/data/stats") +async def get_models_data_stats(): + """Get statistics about data generated by models""" + try: + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + cursor.execute("SELECT COUNT(*) FROM sentiment_analysis") + total_analyses = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(DISTINCT symbol) FROM sentiment_analysis WHERE symbol IS NOT NULL") + unique_symbols = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(DISTINCT analysis_type) FROM sentiment_analysis") + unique_types = cursor.fetchone()[0] + + cursor.execute(""" + SELECT sentiment_label, COUNT(*) as count + FROM sentiment_analysis + GROUP BY sentiment_label + """) + sentiment_dist = {row[0]: row[1] for row in cursor.fetchall()} + + cursor.execute(""" + SELECT analysis_type, COUNT(*) as count + FROM sentiment_analysis + GROUP BY analysis_type + """) + type_dist = {row[0]: row[1] for row in cursor.fetchall()} + + conn.close() + + return { + "success": True, + "statistics": { + "total_analyses": total_analyses, + "unique_symbols": unique_symbols, + "unique_model_types": unique_types, + "sentiment_distribution": sentiment_dist, + "model_type_distribution": type_dist + }, + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch statistics: {str(e)}") + + +@app.post("/api/hf/run-sentiment") +async def run_hf_sentiment(data: Dict[str, Any]): + """Run sentiment analysis using HF models (compatible with UI)""" + try: + from ai_models import analyze_market_text, ModelNotAvailable + + texts = data.get("texts", []) + if isinstance(texts, str): + texts = [texts] + + if not texts or not any(t.strip() for t in texts): + raise HTTPException(status_code=400, detail="At least one text is required") + + try: + all_results = [] + total_vote = 0.0 + count = 0 + models_available = False + + for text in texts: + if not text.strip(): + continue + + result = analyze_market_text(text.strip()) + + # Check if models are available + if result.get("available", True): + models_available = True + + label = result.get("label", "neutral") + confidence = result.get("confidence", 0.5) + + vote_score = 0.0 + if label == "bullish": + vote_score = confidence + elif label == "bearish": + vote_score = -confidence + + total_vote += vote_score + count += 1 + + all_results.append({ + "text": text[:100], + "label": label, + "confidence": confidence, + "vote": vote_score, + "available": result.get("available", True) + }) + + avg_vote = total_vote / count if count > 0 else 0.0 + + return { + "available": models_available, + "vote": avg_vote, + "results": all_results, + "count": count, + "average_confidence": sum(r["confidence"] for r in all_results) / len(all_results) if all_results else 0.0 + } + + except ModelNotAvailable as e: + return { + "available": False, + "vote": 0.0, + "results": [], + "count": 0, + "average_confidence": 0.0, + "error": str(e), + "reason": "model_unavailable" + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Sentiment analysis failed: {str(e)}") + + + + +# ===== Short Hunter / v2 compatibility routes (free Binance + CoinGecko) ===== +try: + from api_compat_routes import register_compat_routes + + register_compat_routes(app) +except Exception as compat_error: + logger.warning(f"Compat routes not loaded: {compat_error}") + +# ===== Main Entry Point ===== +if __name__ == "__main__": + import uvicorn + print(f"Starting Crypto Monitor Admin Server on port {PORT}") + uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")