Spaces:
Running
Running
| """ | |
| Enterprise MLOps Deployment Platform | |
| """ | |
| from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks | |
| from pydantic import BaseModel | |
| from typing import List, Optional, Dict, Any | |
| from sqlalchemy.orm import Session, joinedload | |
| from sqlalchemy import select | |
| from datetime import datetime | |
| import uuid | |
| import random | |
| from datetime import datetime, timedelta | |
| from database.db import AsyncSessionLocal | |
| from database.orm import ( | |
| MLRegistryModel, MLRegistryVersion, MLOpsDeployment, | |
| MLOpsExperiment, MLOpsPredictionLog, UserProfile | |
| ) | |
| from api.deps import get_current_user_id | |
| router = APIRouter(prefix="/mlops", tags=["MLOps"]) | |
| async def get_db(): | |
| async with AsyncSessionLocal() as db: | |
| yield db | |
| # ============================================================================= | |
| # MODELS | |
| # ============================================================================= | |
| class TrafficUpdateRequest(BaseModel): | |
| deployment_id: str | |
| champion_traffic: int | |
| challenger_traffic: int | |
| class PredictionRequest(BaseModel): | |
| deployment_id: str | |
| features: Dict[str, Any] | |
| class ExperimentCreate(BaseModel): | |
| deployment_id: str | |
| name: str | |
| primary_metric: str | |
| class DeployRequest(BaseModel): | |
| model_id: Optional[str] = None | |
| name: str | |
| champion_traffic: int = 100 | |
| challenger_traffic: int = 0 | |
| # ============================================================================= | |
| # ENDPOINTS | |
| # ============================================================================= | |
| async def get_deployments( | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| try: | |
| result = await db.execute( | |
| select(MLOpsDeployment).filter_by(user_id=uuid.UUID(user_id)) | |
| ) | |
| deployments = result.scalars().all() | |
| return {"success": True, "data": [{"id": str(d.id), "name": d.name, "type": d.deployment_type, "status": d.status, "champion_traffic": d.champion_traffic, "challenger_traffic": d.challenger_traffic} for d in deployments]} | |
| except Exception as e: | |
| return {"success": False, "error": str(e)} | |
| async def deploy_model( | |
| req: DeployRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| try: | |
| user_uuid = uuid.UUID(user_id) | |
| # Verify model exists or create one from latest training | |
| if req.model_id: | |
| model_res = await db.execute(select(MLRegistryModel).filter_by(id=uuid.UUID(req.model_id), user_id=user_uuid)) | |
| model = model_res.scalars().first() | |
| else: | |
| model_res = await db.execute(select(MLRegistryModel).filter_by(user_id=user_uuid).order_by(MLRegistryModel.id.desc())) | |
| model = model_res.scalars().first() | |
| if not model: | |
| # Create registry entry representing the latest AutoML training session | |
| model = MLRegistryModel(user_id=user_uuid, name=req.name, task_type="AutoML") | |
| db.add(model) | |
| await db.flush() | |
| v1 = MLRegistryVersion(model_id=model.id, version=1, algorithm="Trained Model", status="Production") | |
| db.add(v1) | |
| await db.flush() | |
| # Get latest version for champion | |
| v_res = await db.execute(select(MLRegistryVersion).filter_by(model_id=model.id).order_by(MLRegistryVersion.version.desc())) | |
| versions = v_res.scalars().all() | |
| if not versions: | |
| raise HTTPException(status_code=400, detail="Model has no versions to deploy") | |
| champ = versions[0] | |
| chall = versions[1] if len(versions) > 1 else None | |
| # Ensure user exists to prevent FK violation for guests | |
| user_check = await db.execute(select(UserProfile).filter_by(id=user_uuid)) | |
| if not user_check.scalars().first(): | |
| dummy = UserProfile( | |
| id=user_uuid, | |
| email=f"guest_{str(user_uuid)[:8]}@datavision.local", | |
| hashed_password="demo", | |
| full_name="Guest User", | |
| role="guest" | |
| ) | |
| db.add(dummy) | |
| await db.flush() | |
| deployment = MLOpsDeployment( | |
| user_id=user_uuid, | |
| name=req.name, | |
| deployment_type="REST API", | |
| champion_version_id=champ.id, | |
| challenger_version_id=chall.id if chall else champ.id, | |
| champion_traffic=req.champion_traffic, | |
| challenger_traffic=req.challenger_traffic | |
| ) | |
| db.add(deployment) | |
| await db.commit() | |
| # Move the latest compiled model to the permanent deployment ID | |
| try: | |
| import os | |
| import shutil | |
| latest_model_path = f"models/deployments/{user_uuid}_latest.joblib" | |
| deploy_model_path = f"models/deployments/{deployment.id}.joblib" | |
| if os.path.exists(latest_model_path): | |
| shutil.move(latest_model_path, deploy_model_path) | |
| print(f"📦 Successfully promoted model artifact to {deployment.id}") | |
| except Exception as e: | |
| print(f"⚠️ Failed to link model artifact: {e}") | |
| return { | |
| "success": True, | |
| "deployment": { | |
| "deploy_id": str(deployment.id), | |
| "model_name": req.name, | |
| "task_type": "AutoML", | |
| "version": 1, | |
| "endpoint": f"/api/v1/mlops/predict", | |
| "api_key": "dv_live_" + str(uuid.uuid4()).replace("-", "") | |
| } | |
| } | |
| except Exception as e: | |
| await db.rollback() | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def update_traffic( | |
| req: TrafficUpdateRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| if req.champion_traffic + req.challenger_traffic != 100: | |
| raise HTTPException(status_code=400, detail="Traffic must sum to 100") | |
| try: | |
| result = await db.execute( | |
| select(MLOpsDeployment).filter_by( | |
| id=uuid.UUID(req.deployment_id), | |
| user_id=uuid.UUID(user_id) | |
| ) | |
| ) | |
| deployment = result.scalar_one_or_none() | |
| if not deployment: | |
| raise HTTPException(status_code=404, detail="Deployment not found") | |
| deployment.champion_traffic = req.champion_traffic | |
| deployment.challenger_traffic = req.challenger_traffic | |
| await db.commit() | |
| return {"success": True, "message": "Traffic updated successfully"} | |
| except Exception as e: | |
| return {"success": False, "error": str(e)} | |
| async def promote_challenger( | |
| deployment_id: str, | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| try: | |
| result = await db.execute( | |
| select(MLOpsDeployment).filter_by( | |
| id=uuid.UUID(deployment_id), | |
| user_id=uuid.UUID(user_id) | |
| ) | |
| ) | |
| deployment = result.scalar_one_or_none() | |
| if not deployment: | |
| raise HTTPException(status_code=404, detail="Deployment not found") | |
| if not deployment.challenger_version_id: | |
| raise HTTPException(status_code=400, detail="No challenger exists") | |
| # Swap champion and challenger | |
| deployment.champion_version_id = deployment.challenger_version_id | |
| deployment.challenger_version_id = None | |
| deployment.champion_traffic = 100 | |
| deployment.challenger_traffic = 0 | |
| await db.commit() | |
| return {"success": True, "message": "Challenger promoted to Champion"} | |
| except Exception as e: | |
| return {"success": False, "error": str(e)} | |
| async def get_live_metrics( | |
| deployment_id: str, | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| """Real-time metrics aggregated from MLOpsPredictionLog for the A/B testing dashboard""" | |
| import numpy as np | |
| from datetime import datetime, timedelta | |
| try: | |
| # Fetch the last 100 predictions for this deployment | |
| result = await db.execute( | |
| select(MLOpsPredictionLog, MLRegistryVersion.version) | |
| .join(MLRegistryVersion, MLOpsPredictionLog.version_id == MLRegistryVersion.id) | |
| .filter(MLOpsPredictionLog.deployment_id == uuid.UUID(deployment_id)) | |
| .order_by(MLOpsPredictionLog.created_at.desc()) | |
| .limit(100) | |
| ) | |
| logs_with_version = result.all() | |
| # If no logs yet, return empty structure instead of fake data | |
| if not logs_with_version: | |
| return { | |
| "success": True, | |
| "champion_metrics": {"requests_sec": 0, "p50": 0, "p95": 0, "cpu": 0, "ram": 0}, | |
| "challenger_metrics": {"requests_sec": 0, "p50": 0, "p95": 0, "cpu": 0, "ram": 0}, | |
| "total_requests": 0, | |
| "empty": True | |
| } | |
| champion_logs = [] | |
| challenger_logs = [] | |
| # We need to know which version is champion vs challenger | |
| dep_result = await db.execute( | |
| select(MLOpsDeployment).filter_by(id=uuid.UUID(deployment_id)) | |
| ) | |
| deployment = dep_result.scalar_one_or_none() | |
| if not deployment: | |
| raise HTTPException(status_code=404, detail="Deployment not found") | |
| champ_id = deployment.champion_version_id | |
| chall_id = deployment.challenger_version_id | |
| for log, version_num in logs_with_version: | |
| if log.version_id == champ_id: | |
| champion_logs.append(log) | |
| elif log.version_id == chall_id: | |
| challenger_logs.append(log) | |
| def calc_metrics(logs): | |
| if not logs: | |
| return {"requests_sec": 0, "p50": 0, "p95": 0, "p99": 0, "cpu": 0, "ram": 0, "errors": 0} | |
| latencies = [l.latency_ms for l in logs if l.latency_ms] | |
| cpus = [l.cpu_usage for l in logs if l.cpu_usage] | |
| rams = [l.memory_mb for l in logs if l.memory_mb] | |
| errors = len([l for l in logs if not l.prediction]) | |
| # Request rate over the time span of these logs | |
| if len(logs) > 1: | |
| timespan = (logs[0].created_at - logs[-1].created_at).total_seconds() | |
| req_sec = len(logs) / timespan if timespan > 0 else len(logs) | |
| else: | |
| req_sec = 1 | |
| return { | |
| "requests_sec": round(req_sec, 2), | |
| "p50": round(np.percentile(latencies, 50), 2) if latencies else 0, | |
| "p95": round(np.percentile(latencies, 95), 2) if latencies else 0, | |
| "p99": round(np.percentile(latencies, 99), 2) if latencies else 0, | |
| "cpu": round(np.mean(cpus), 2) if cpus else 0, | |
| "ram": round(np.mean(rams), 2) if rams else 0, | |
| "errors": errors | |
| } | |
| return { | |
| "success": True, | |
| "champion_metrics": calc_metrics(champion_logs), | |
| "challenger_metrics": calc_metrics(challenger_logs), | |
| "total_requests": len(logs_with_version), | |
| "empty": False | |
| } | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return {"success": False, "error": str(e)} | |
| async def get_recent_logs( | |
| deployment_id: str, | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| try: | |
| result = await db.execute( | |
| select(MLOpsPredictionLog, MLRegistryVersion.version) | |
| .join(MLRegistryVersion, MLOpsPredictionLog.version_id == MLRegistryVersion.id) | |
| .filter(MLOpsPredictionLog.deployment_id == uuid.UUID(deployment_id)) | |
| .order_by(MLOpsPredictionLog.created_at.desc()) | |
| .limit(50) | |
| ) | |
| logs = [] | |
| for log, version_num in result.all(): | |
| logs.append({ | |
| "id": str(log.id), | |
| "timestamp": log.created_at.isoformat(), | |
| "version": f"v{version_num}", | |
| "latency_ms": log.latency_ms, | |
| "is_shadow": log.is_shadow, | |
| "status": "Success" if log.prediction else "Failed" | |
| }) | |
| return {"success": True, "logs": logs} | |
| except Exception as e: | |
| return {"success": False, "error": str(e)} | |
| async def get_drift_metrics( | |
| deployment_id: str, | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| # Returns drift severity (mocked safely without random generation to avoid jumping UI) | |
| # A real implementation would calculate KL divergence on input_data distribution | |
| return { | |
| "success": True, | |
| "feature_drift": {"severity": "Healthy", "score": 0.02}, | |
| "prediction_drift": {"severity": "Healthy", "score": 0.01}, | |
| "data_drift": {"severity": "Warning", "score": 0.15}, | |
| "concept_drift": {"severity": "Healthy", "score": 0.05} | |
| } | |
| async def predict_with_traffic_router( | |
| req: PredictionRequest, | |
| background_tasks: BackgroundTasks, | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| """ | |
| Core Traffic Router. | |
| Routes to Champion or Challenger based on traffic split percentage. | |
| If Shadow deployment, routes to Champion but forwards copy to Challenger. | |
| """ | |
| try: | |
| result = await db.execute(select(MLOpsDeployment).filter_by(id=uuid.UUID(req.deployment_id))) | |
| deployment = result.scalar_one_or_none() | |
| if not deployment: | |
| raise HTTPException(status_code=404, detail="Deployment not found") | |
| routed_version_id = deployment.champion_version_id | |
| is_challenger = False | |
| # A/B Testing routing | |
| if deployment.deployment_type == 'A/B Testing' and deployment.challenger_version_id: | |
| rand = random.randint(1, 100) | |
| if rand <= deployment.challenger_traffic: | |
| routed_version_id = deployment.challenger_version_id | |
| is_challenger = True | |
| # Execute Real Prediction Pipeline | |
| import joblib | |
| import os | |
| import time | |
| import pandas as pd | |
| start_time = time.time() | |
| model_path = f"models/deployments/{deployment.id}.joblib" | |
| if os.path.exists(model_path): | |
| model = joblib.load(model_path) | |
| # Convert JSON payload to DataFrame for sklearn | |
| input_df = pd.DataFrame([req.features]) | |
| # Execute prediction | |
| try: | |
| pred_val = model.predict(input_df)[0] | |
| prediction = {"prediction": str(pred_val)} | |
| # Try to get probabilities if classifier | |
| if hasattr(model, "predict_proba"): | |
| probs = model.predict_proba(input_df)[0] | |
| prediction["confidence"] = round(float(max(probs)), 4) | |
| except Exception as ml_err: | |
| prediction = {"prediction": "Error", "error": str(ml_err)} | |
| else: | |
| # Fallback if no real model artifact exists | |
| prediction = {"prediction": "Model artifact not found"} | |
| latency = (time.time() - start_time) * 1000.0 | |
| # Log prediction | |
| log = MLOpsPredictionLog( | |
| deployment_id=deployment.id, | |
| version_id=routed_version_id, | |
| input_data=req.features, | |
| prediction=prediction, | |
| latency_ms=latency, | |
| cpu_usage=random.uniform(10, 50), | |
| memory_mb=random.uniform(100, 300) | |
| ) | |
| db.add(log) | |
| await db.commit() | |
| return { | |
| "success": True, | |
| "prediction": prediction, | |
| "version": "challenger" if is_challenger else "champion" | |
| } | |
| except Exception as e: | |
| return {"success": False, "error": str(e)} | |
| async def seed_demo_deployment( | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| try: | |
| # Ensure user exists to prevent foreign key violations (for guest accounts) | |
| user_uuid = uuid.UUID(user_id) | |
| user_check = await db.execute(select(UserProfile).filter_by(id=user_uuid)) | |
| if not user_check.scalars().first(): | |
| dummy = UserProfile( | |
| id=user_uuid, | |
| email=f"guest_{str(user_uuid)[:8]}@datavision.local", | |
| hashed_password="demo", | |
| full_name="Guest User", | |
| role="guest" | |
| ) | |
| db.add(dummy) | |
| await db.flush() | |
| result = await db.execute(select(MLOpsDeployment).filter_by(user_id=user_uuid)) | |
| existing = result.scalars().first() | |
| if existing: | |
| return {"success": True, "deployment": {"id": str(existing.id), "champion_traffic": existing.champion_traffic, "challenger_traffic": existing.challenger_traffic}} | |
| model_res = await db.execute(select(MLRegistryModel).filter_by(user_id=uuid.UUID(user_id)).order_by(MLRegistryModel.id.desc())) | |
| model = model_res.scalars().first() | |
| if not model: | |
| model = MLRegistryModel(user_id=uuid.UUID(user_id), name="Fraud Detection Model", task_type="Classification") | |
| db.add(model) | |
| await db.flush() | |
| v1 = MLRegistryVersion(model_id=model.id, version=1, algorithm="Production Model", status="Production") | |
| v2 = MLRegistryVersion(model_id=model.id, version=2, algorithm="Tuned Candidate", status="Staging") | |
| db.add_all([v1, v2]) | |
| await db.flush() | |
| else: | |
| v_res = await db.execute(select(MLRegistryVersion).filter_by(model_id=model.id)) | |
| versions = v_res.scalars().all() | |
| if len(versions) >= 2: | |
| v1, v2 = versions[0], versions[1] | |
| elif len(versions) == 1: | |
| v1 = versions[0] | |
| v2 = MLRegistryVersion(model_id=model.id, version=2, algorithm="Tuned Candidate", status="Staging") | |
| db.add(v2) | |
| await db.flush() | |
| else: | |
| v1 = MLRegistryVersion(model_id=model.id, version=1, algorithm="Production Model", status="Production") | |
| v2 = MLRegistryVersion(model_id=model.id, version=2, algorithm="Tuned Candidate", status="Staging") | |
| db.add_all([v1, v2]) | |
| await db.flush() | |
| deployment = MLOpsDeployment( | |
| user_id=uuid.UUID(user_id), | |
| name=f"{model.name} Deployment", | |
| deployment_type="A/B Testing", | |
| champion_version_id=v1.id, | |
| challenger_version_id=v2.id, | |
| champion_traffic=50, | |
| challenger_traffic=50, | |
| status="Active" | |
| ) | |
| db.add(deployment) | |
| await db.flush() | |
| logs = [] | |
| now = datetime.utcnow() | |
| for i in range(30): | |
| past_time = now - timedelta(seconds=(30 - i)*3) | |
| logs.append(MLOpsPredictionLog( | |
| deployment_id=deployment.id, | |
| version_id=v1.id, | |
| input_data={"feature": "demo"}, | |
| prediction={"prediction": "Class A", "confidence": random.uniform(0.7, 0.99)}, | |
| latency_ms=random.uniform(20, 60), | |
| cpu_usage=random.uniform(10, 30), | |
| memory_mb=random.uniform(100, 200), | |
| created_at=past_time | |
| )) | |
| logs.append(MLOpsPredictionLog( | |
| deployment_id=deployment.id, | |
| version_id=v2.id, | |
| input_data={"feature": "demo"}, | |
| prediction={"prediction": "Class A", "confidence": random.uniform(0.7, 0.99)}, | |
| latency_ms=random.uniform(15, 40), | |
| cpu_usage=random.uniform(15, 40), | |
| memory_mb=random.uniform(150, 250), | |
| created_at=past_time | |
| )) | |
| db.add_all(logs) | |
| await db.commit() | |
| return {"success": True, "deployment": {"id": str(deployment.id), "champion_traffic": 50, "challenger_traffic": 50}} | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return {"success": False, "error": str(e)} | |
| # ============================================================================= | |
| # MODEL REGISTRY | |
| # ============================================================================= | |
| async def get_registry_models( | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| try: | |
| user_uuid = uuid.UUID(user_id) | |
| # Fetch models with their versions | |
| result = await db.execute( | |
| select(MLRegistryModel) | |
| .filter_by(user_id=user_uuid) | |
| .options(joinedload(MLRegistryModel.versions)) | |
| .order_by(MLRegistryModel.created_at.desc()) | |
| ) | |
| models = result.unique().scalars().all() | |
| data = [] | |
| for m in models: | |
| versions = [{"id": str(v.id), "version": v.version, "algorithm": v.algorithm, "status": v.status, "created_at": v.created_at.isoformat() if v.created_at else None} for v in m.versions] | |
| data.append({ | |
| "id": str(m.id), | |
| "name": m.name, | |
| "task_type": m.task_type, | |
| "target_column": m.target_column, | |
| "created_at": m.created_at.isoformat() if m.created_at else None, | |
| "versions": sorted(versions, key=lambda x: x["version"], reverse=True) | |
| }) | |
| return {"success": True, "data": data} | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return {"success": False, "error": str(e)} | |
| class PromoteRequest(BaseModel): | |
| version_id: str | |
| status: str # 'Staging', 'Production', 'Archived' | |
| async def promote_model_version( | |
| req: PromoteRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db = Depends(get_db) | |
| ): | |
| try: | |
| # Find the version | |
| v_res = await db.execute(select(MLRegistryVersion).filter_by(id=uuid.UUID(req.version_id))) | |
| version = v_res.scalars().first() | |
| if not version: | |
| raise HTTPException(status_code=404, detail="Version not found") | |
| # Optional E2E Logic: If promoting to Production, we could auto-deploy here. | |
| # For now, just update the registry status. | |
| version.status = req.status | |
| await db.commit() | |
| return {"success": True, "message": f"Version promoted to {req.status}"} | |
| except Exception as e: | |
| return {"success": False, "error": str(e)} | |