""" Chart Generation API - Creates Plotly JSON for real-time visualizations Enterprise-grade charts from user's uploaded data ChatGPT Pro-level visualizations with premium styling """ from fastapi import APIRouter, HTTPException, Header from pydantic import BaseModel from typing import Optional, List, Dict, Any, Tuple import json import pandas as pd import numpy as np from datetime import datetime, timedelta import logging from graph.query import revenue_dataframe, load_graph from utils.paths import get_user_paths from config.settings import Settings logger = logging.getLogger(__name__) router = APIRouter() # ============================================================================= # SECURITY HELPER - JWT Authentication # ============================================================================= def get_secure_user_id(body_user_id: str, x_user_id: Optional[str], authorization: Optional[str]) -> str: """ Get verified user_id from JWT token or headers. Priority: JWT token > X-User-ID header > Body data """ # 1. Try JWT token first (most secure) if authorization: try: token = authorization.replace("Bearer ", "") from core.auth import decode_jwt_token payload = decode_jwt_token(token) if payload and payload.get("sub"): return payload["sub"] except Exception as e: logger.debug(f"JWT decode failed: {e}") # 2. Try X-User-ID header (from authenticated frontend) if x_user_id and x_user_id != "default": return x_user_id # 3. Fallback to body data (least secure) if body_user_id and body_user_id != "default": logger.warning(f"Using body user_id: {body_user_id} - consider using JWT") return body_user_id # 4. Generate guest fingerprint import hashlib import time return f"guest_{hashlib.md5(str(time.time()).encode()).hexdigest()[:8]}" # ============================================================================ # PREMIUM COLOR PALETTES - ChatGPT Pro-Level Visualizations # ============================================================================ PREMIUM_COLORS = { "vibrant": [ "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9" ], "ocean": [ "#0077B6", "#00B4D8", "#90E0EF", "#CAF0F8", "#023E8A", "#0096C7", "#48CAE4", "#ADE8F4", "#03045E", "#0466C8" ], "sunset": [ "#FF6B35", "#F7C59F", "#EFEFD0", "#004E89", "#1A659E", "#FF9F1C", "#FFBF69", "#CBF3F0", "#2EC4B6", "#E71D36" ], "forest": [ "#2D6A4F", "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#1B4332", "#081C15", "#3A5A40" ], "royal": [ "#7B2CBF", "#9D4EDD", "#C77DFF", "#E0AAFF", "#5A189A", "#6A4C93", "#8B5CF6", "#A78BFA", "#C4B5FD", "#EDE9FE" ], "neon": [ "#00FF87", "#00D9FF", "#FF00E5", "#FFE000", "#FF6B00", "#00FF41", "#00F5FF", "#FF007A", "#FFD700", "#FF4500" ], "professional": [ "#10B981", "#06B6D4", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#14B8A6", "#F97316", "#6366F1", "#84CC16" ] } # Premium chart styling template - HIGH CONTRAST for better visibility CHART_STYLE = { "font": {"family": "Inter, system-ui, sans-serif", "color": "#1F2937"}, # Dark gray for better visibility "paper_bgcolor": "rgba(0,0,0,0)", "plot_bgcolor": "rgba(0,0,0,0)", "colorway": PREMIUM_COLORS["vibrant"], "title": {"font": {"size": 22, "color": "#111827", "weight": "bold"}}, "xaxis": { "gridcolor": "rgba(156, 163, 175, 0.2)", "color": "#374151", # High contrast labels "linecolor": "rgba(107, 114, 128, 0.4)", "tickfont": {"size": 12, "color": "#4B5563"} }, "yaxis": { "gridcolor": "rgba(156, 163, 175, 0.2)", "color": "#374151", # High contrast labels "linecolor": "rgba(107, 114, 128, 0.4)", "tickfont": {"size": 12, "color": "#4B5563"} }, "legend": { "bgcolor": "rgba(255,255,255,0.7)", # Semi-transparent background for legend "bordercolor": "rgba(209, 213, 219, 0.5)", "font": {"color": "#1F2937", "size": 12}, "orientation": "h", "y": -0.2 }, "hoverlabel": { "bgcolor": "#FFFFFF", "bordercolor": "#D1D5DB", "font": {"color": "#111827", "size": 14} }, "margin": {"l": 60, "r": 40, "t": 80, "b": 80} } def get_color_palette(style: str = "vibrant", count: int = 10) -> List[str]: """Get premium color palette with gradient support""" palette = PREMIUM_COLORS.get(style, PREMIUM_COLORS["vibrant"]) # Extend if needed while len(palette) < count: palette = palette + palette return palette[:count] def apply_premium_layout(layout: dict, title: str) -> dict: """Apply premium styling to chart layout""" return { **layout, "title": {"text": title, "font": CHART_STYLE["title"]["font"]}, "font": CHART_STYLE["font"], "paper_bgcolor": CHART_STYLE["paper_bgcolor"], "plot_bgcolor": CHART_STYLE["plot_bgcolor"], "hoverlabel": CHART_STYLE["hoverlabel"], "xaxis": {**layout.get("xaxis", {}), **CHART_STYLE["xaxis"]}, "yaxis": {**layout.get("yaxis", {}), **CHART_STYLE["yaxis"]}, "legend": {**layout.get("legend", {}), **CHART_STYLE["legend"]}, "margin": CHART_STYLE["margin"] } class ChartRequest(BaseModel): user_id: str chart_type: str # line, bar, pie, area, scatter, prediction data_source: str = "revenue" # revenue, customers, products time_period: Optional[str] = "all" # all, month, quarter, year class ChartResponse(BaseModel): chart_type: str plotly_json: Dict[str, Any] summary: str data_points: int # ============================================================================ # ⚡ DATAFRAME CACHE - Prevents reloading on every query # ============================================================================ import time as _time _df_cache: Dict[str, Tuple[pd.DataFrame, float]] = {} _DF_CACHE_TTL = 300 # 5 minutes cache TTL def _get_cached_df(user_id: str) -> Optional[pd.DataFrame]: """Get DataFrame from cache if not expired.""" if user_id in _df_cache: df, timestamp = _df_cache[user_id] if _time.time() - timestamp < _DF_CACHE_TTL: print(f"[CACHE] ⚡ Using cached DataFrame for user {user_id}") return df else: print(f"[CACHE] Cache expired for user {user_id}") del _df_cache[user_id] return None def _set_cached_df(user_id: str, df: pd.DataFrame): """Store DataFrame in cache.""" _df_cache[user_id] = (df, _time.time()) print(f"[CACHE] Cached DataFrame for user {user_id} ({len(df)} rows)") def clear_user_cache(user_id: str): """Clear cache for a specific user (call when they upload new files).""" if user_id in _df_cache: del _df_cache[user_id] print(f"[CACHE] Cleared cache for user {user_id}") def get_user_data(user_id: str) -> pd.DataFrame: """ Get user's uploaded data - PRESERVES ORIGINAL COLUMNS for ANY domain. Works with HR data, Sales data, Finance data, or any structured data. ⚡ CACHED: Uses in-memory cache to avoid reloading on every query. Returns the raw DataFrame with original column names like: - HR: Department, Salary, Employee - Sales: Customer, Product, Amount - Finance: Category, Value, Date """ # ⚡ Check cache first cached_df = _get_cached_df(user_id) if cached_df is not None: return cached_df try: from utils.paths import STORAGE_BASE all_dfs = [] # 1. Load directly from user's uploaded files user_files_dir = STORAGE_BASE / user_id / "files" if user_files_dir.exists(): for file_path in user_files_dir.glob("*.*"): if file_path.suffix.lower() not in ['.csv', '.xlsx', '.xls']: continue try: print(f"[DATA] Loading file: {file_path.name}") if file_path.suffix.lower() == '.csv': df = pd.read_csv(file_path) else: df = pd.read_excel(file_path) if not df.empty: # Keep original column names - no transformation! df['_source_file'] = file_path.name all_dfs.append(df) print(f"[DATA] Loaded {len(df)} rows with columns: {list(df.columns)}") except Exception as e: print(f"[DATA] Error loading {file_path}: {e}") continue # 2. ⚡ Fetch LIVE Pipelines (Postgres, Snowflake) try: import psycopg2 import os connections = [] if not str(user_id).startswith('guest_'): # Connect to datavision DB db_url = os.environ.get("DATABASE_URL", "postgresql://postgres:Naveen%402007@127.0.0.1:5432/datavision") sync_url = db_url.replace("+asyncpg", "") # Convert async url to sync for psycopg2 with psycopg2.connect(sync_url) as conn: with conn.cursor() as cur: cur.execute("SELECT id, source_type, host, database_name, credentials, target_table FROM data_connections WHERE user_id = %s", (user_id,)) connections = cur.fetchall() for conn_row in connections: c_id, source_type, host, database_name, credentials, target_table = conn_row try: if source_type.lower() in ('postgres', 'postgresql') and target_table: from urllib.parse import quote_plus safe_credentials = quote_plus(credentials) if credentials else "" conn_str = f"postgresql://postgres:{safe_credentials}@{host}/{database_name}" query = f"SELECT * FROM {target_table} LIMIT 500000" live_df = pd.read_sql(query, conn_str) if not live_df.empty: live_df['_source_file'] = f"Live: {target_table} (PostgreSQL)" all_dfs.append(live_df) print(f"[DATA] Loaded {len(live_df)} rows from Live Postgres {target_table}") elif source_type.lower() == 'snowflake' and target_table: try: import snowflake.connector ctx = snowflake.connector.connect( user='admin', password=credentials, account=host, database=database_name, schema='PUBLIC' ) query = f"SELECT * FROM {target_table} LIMIT 500000" live_df = pd.read_sql(query, ctx) ctx.close() if not live_df.empty: live_df['_source_file'] = f"Live: {target_table} (Snowflake)" all_dfs.append(live_df) except ImportError: print("[DATA] Snowflake connector not installed, skipping.") except Exception as e: print(f"[DATA] Snowflake connection failed: {e}") except Exception as e: print(f"[DATA] Error loading live connection {c_id}: {e}") except Exception as e: print(f"[DATA] Failed to fetch live connections from DB: {e}") if not all_dfs: print(f"[DATA] No files or live connections for user {user_id}") return pd.DataFrame() # If multiple files/streams, try to combine if len(all_dfs) == 1: result_df = all_dfs[0] else: # Combine all datastreams into a unified view regardless of matching columns result_df = pd.concat(all_dfs, ignore_index=True) # NEVER drop columns based on missing percentage, because live stream # dataframes have different columns than uploaded CSVs. If we drop them, # the dashboard completely loses the live streaming data! result_df = result_df.dropna(how='all', axis=1) # Fill numeric NaNs with 0 for better charting numeric_cols = result_df.select_dtypes(include=['int64', 'float64']).columns result_df[numeric_cols] = result_df[numeric_cols].fillna(0) # ⚡ Cache the result _set_cached_df(user_id, result_df) return result_df # Fallback to revenue_dataframe for backward compatibility print(f"[DATA] No files found, falling back to revenue_dataframe") paths = get_user_paths(user_id) Settings.GRAPH_DIR = paths["graph"] fallback_df = revenue_dataframe(user_id) if not fallback_df.empty: _set_cached_df(user_id, fallback_df) return fallback_df except Exception as e: print(f"[DATA] Error getting user data: {e}") import traceback traceback.print_exc() return pd.DataFrame() def auto_detect_columns(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """ Automatically detect the best categorical and numeric columns from ANY dataset. Works with HR data, sales data, or any structured data. Returns: {"category": col_name, "numeric": col_name, "title": suggested_title} """ query_lower = query.lower() # All columns all_cols = list(df.columns) # Identify column types categorical_cols = [] numeric_cols = [] for col in all_cols: col_lower = col.lower() # Skip ID and date columns for categorical if any(x in col_lower for x in ['id', 'date', 'time', 'index']): continue if df[col].dtype in ['int64', 'float64']: numeric_cols.append(col) elif df[col].dtype == 'object' or str(df[col].dtype) == 'category': # Check if it's a valid categorical (not too many unique values) if df[col].nunique() <= 50: categorical_cols.append(col) # Priority 1: Match query terms to actual columns matched_cat = None matched_num = None for col in categorical_cols: if col.lower() in query_lower or query_lower in col.lower(): matched_cat = col break for col in numeric_cols: if col.lower() in query_lower or query_lower in col.lower(): matched_num = col break # Priority 2: Use known column patterns category_patterns = ['department', 'dept', 'category', 'product', 'customer', 'region', 'type', 'status', 'name'] numeric_patterns = ['salary', 'amount', 'revenue', 'total', 'sales', 'price', 'cost', 'count', 'value', 'performance', 'rating'] if not matched_cat: for pattern in category_patterns: for col in categorical_cols: if pattern in col.lower(): matched_cat = col break if matched_cat: break if not matched_num: for pattern in numeric_patterns: for col in numeric_cols: if pattern in col.lower(): matched_num = col break if matched_num: break # Priority 3: Fall back to first available if not matched_cat and categorical_cols: matched_cat = categorical_cols[0] if not matched_num and numeric_cols: matched_num = numeric_cols[0] # Generate title if matched_cat and matched_num: title = f"Total {matched_num} by {matched_cat}" else: title = "Data Analysis" return { "category": matched_cat, "numeric": matched_num, "title": title, "all_categorical": categorical_cols, "all_numeric": numeric_cols } def generate_area_chart(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate area chart for cumulative data representation""" if df.empty: return {"error": "No data"} detected = auto_detect_columns(df, query) x_col = detected.get("category") y_col = detected.get("numeric") if not x_col or not y_col: return {"error": "Need data for area chart"} # Sort for area chart consistency df = df.sort_values(by=x_col) trace = { "x": df[x_col].tolist(), "y": df[y_col].tolist(), "type": "scatter", "mode": "lines", "fill": "tozeroy", "line": {"color": "#8b5cf6", "width": 2}, "fillcolor": "rgba(139, 92, 246, 0.2)" } return apply_premium_layout({ "data": [trace], "layout": {"xaxis": {"title": x_col}, "yaxis": {"title": y_col}} }, f"Cumulative {y_col} by {x_col}") def generate_comparison_chart(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate multi-series comparison chart (e.g., Cat1 vs Cat2 for Metric)""" if df.empty: return {"error": "No data"} params = extract_chart_params_from_query(query) detected = auto_detect_columns(df, query) cat_col = detected.get("category") num_col = detected.get("numeric") if not cat_col or not num_col: return {"error": "Insufficient data for comparison"} # Check for a second categorical column for multi-series all_cats = detected.get("all_categorical") series_col = all_cats[1] if len(all_cats) > 1 else None if not series_col: # Fallback to simple bar if no series found return generate_dynamic_bar_chart(df, group_col=cat_col, metric_col=num_col) # Pivot for multi-series pivot = df.pivot_table(index=cat_col, columns=series_col, values=num_col, aggfunc='sum').head(10) data = [] colors = get_color_palette("professional", len(pivot.columns)) for i, col in enumerate(pivot.columns): data.append({ "x": pivot.index.tolist(), "y": pivot[col].tolist(), "type": "bar", "name": str(col), "marker": {"color": colors[i]} }) return apply_premium_layout({ "data": data, "layout": { "barmode": "group", "xaxis": {"title": cat_col}, "yaxis": {"title": num_col} } }, f"{num_col} Comparison: {cat_col} by {series_col}") def generate_revenue_trend_chart(df: pd.DataFrame) -> Dict[str, Any]: """Generate revenue trend line chart""" if df.empty: return {"error": "No data available"} # Aggregate by date if date column exists date_col = None for col in df.columns: if 'date' in col.lower() or 'time' in col.lower(): date_col = col break amount_col = None for col in df.columns: if 'amount' in col.lower() or 'revenue' in col.lower() or 'total' in col.lower(): amount_col = col break if amount_col is None: # Find numeric column for col in df.columns: if df[col].dtype in ['int64', 'float64']: amount_col = col break if amount_col is None: return {"error": "No numeric data found"} # Create chart data if date_col: df[date_col] = pd.to_datetime(df[date_col], errors='coerce') df = df.sort_values(date_col) x_data = df[date_col].dt.strftime('%Y-%m-%d').tolist() cumulative = df[amount_col].cumsum().tolist() else: x_data = list(range(1, len(df) + 1)) cumulative = df[amount_col].cumsum().tolist() plotly_json = { "data": [{ "x": x_data, "y": cumulative, "type": "scatter", "mode": "lines+markers", "name": "Cumulative Revenue", "line": {"color": "#10b981", "width": 3}, "marker": {"size": 6}, "fill": "tozeroy", "fillcolor": "rgba(16, 185, 129, 0.1)" }], "layout": { "title": {"text": "Revenue Trend", "font": {"size": 18, "color": "#e5e7eb"}}, "xaxis": {"title": "Date", "gridcolor": "#374151", "color": "#9ca3af"}, "yaxis": {"title": "Revenue ($)", "gridcolor": "#374151", "color": "#9ca3af"}, "paper_bgcolor": "rgba(0,0,0,0)", "plot_bgcolor": "rgba(0,0,0,0)", "font": {"color": "#e5e7eb"}, "margin": {"l": 60, "r": 30, "t": 50, "b": 50}, "hovermode": "x unified" } } return plotly_json def generate_product_bar_chart(df: pd.DataFrame) -> Dict[str, Any]: """Generate bar chart - auto-detects best columns if product/amount not found""" if df.empty: return {"error": "No data available"} # Find category column (product, item, name, or any categorical) product_col = None for col in df.columns: col_lower = col.lower() if any(x in col_lower for x in ['product', 'item', 'category', 'department', 'name', 'type']): product_col = col break # Find amount column (any numeric) amount_col = None for col in df.columns: col_lower = col.lower() if any(x in col_lower for x in ['amount', 'revenue', 'total', 'salary', 'sales', 'price', 'value']): if df[col].dtype in ['int64', 'float64']: amount_col = col break # Fallback: use auto-detection if not product_col or not amount_col: detected = auto_detect_columns(df) if not product_col: product_col = detected.get("category") if not amount_col: amount_col = detected.get("numeric") # Final fallback to first columns if not product_col: for col in df.columns: if df[col].dtype == 'object' and df[col].nunique() <= 50: product_col = col break if not amount_col: for col in df.columns: if df[col].dtype in ['int64', 'float64']: amount_col = col break if not product_col or not amount_col: return {"error": f"Could not find suitable columns. Available: {list(df.columns)}"} print(f"[CHART] product_bar using: category={product_col}, numeric={amount_col}") # Aggregate by product try: product_revenue = df.groupby(product_col)[amount_col].sum().sort_values(ascending=True).tail(10) except Exception as e: return {"error": f"Aggregation failed: {str(e)}"} if product_revenue.empty or product_revenue.sum() == 0: return {"error": "No data after aggregation"} colors = [ '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16' ] title = f"Top 10 {product_col} by {amount_col}" plotly_json = { "data": [{ "x": product_revenue.values.tolist(), "y": product_revenue.index.tolist(), "type": "bar", "orientation": "h", "marker": {"color": colors[:len(product_revenue)]}, "text": [f"{v:,.0f}" for v in product_revenue.values], "textposition": "outside" }], "layout": { "title": {"text": title, "font": {"size": 18, "color": "#e5e7eb"}}, "xaxis": {"title": amount_col, "gridcolor": "#374151", "color": "#9ca3af"}, "yaxis": {"title": "", "gridcolor": "#374151", "color": "#9ca3af"}, "paper_bgcolor": "rgba(0,0,0,0)", "plot_bgcolor": "rgba(0,0,0,0)", "font": {"color": "#e5e7eb"}, "margin": {"l": 150, "r": 80, "t": 50, "b": 50} } } return plotly_json def generate_customer_bar_chart(df: pd.DataFrame, limit: int = 10, entity: str = "customer") -> Dict[str, Any]: """Generate dynamic bar chart for customers/products with configurable limit""" if df.empty: return {"error": "No data available"} # Find entity column based on entity type entity_col = None if entity == "customer": for col in df.columns: if 'customer' in col.lower() or 'client' in col.lower(): entity_col = col break elif entity == "product": for col in df.columns: if 'product' in col.lower() or 'item' in col.lower(): entity_col = col break if entity_col is None: entity_col = df.columns[0] # Find amount column amount_col = None for col in df.columns: if 'amount' in col.lower() or 'revenue' in col.lower() or 'total' in col.lower(): amount_col = col break if amount_col is None: for col in df.columns: if df[col].dtype in ['int64', 'float64']: amount_col = col break if amount_col is None: return {"error": "No numeric data found"} # Aggregate and limit dynamically entity_revenue = df.groupby(entity_col)[amount_col].sum().sort_values(ascending=True).tail(limit) # Expanded colors for 20+ items colors = [ '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#a855f7', '#22c55e', '#3b82f6', '#f43f5e', '#0ea5e9', '#d946ef', '#eab308', '#0284c7', '#78716c', '#dc2626' ] title = f"Top {limit} {entity.title()}s by Revenue" plotly_json = { "data": [{ "x": entity_revenue.values.tolist(), "y": entity_revenue.index.tolist(), "type": "bar", "orientation": "h", "marker": {"color": colors[:len(entity_revenue)]}, "text": [f"₹{v:,.0f}" for v in entity_revenue.values], "textposition": "outside" }], "layout": { "title": {"text": title, "font": {"size": 18, "color": "#e5e7eb"}}, "xaxis": {"title": "Revenue (₹)", "gridcolor": "#374151", "color": "#9ca3af"}, "yaxis": {"title": "", "gridcolor": "#374151", "color": "#9ca3af"}, "paper_bgcolor": "rgba(0,0,0,0)", "plot_bgcolor": "rgba(0,0,0,0)", "font": {"color": "#e5e7eb"}, "margin": {"l": 150, "r": 80, "t": 50, "b": 50} } } return plotly_json def extract_chart_params_from_query(query: str) -> Dict[str, Any]: """ Extract chart parameters from natural language query. ChatGPT Pro-level extraction for complex visualization requests. """ import re params = { "limit": 10, "entity": None, "metric": None, "chart_type": "bar", # Default "group_by": None, "secondary_metric": None, "is_comparison": False } q_lower = query.lower() # Detect Chart Type type_map = { 'trend': ['trend', 'over time', 'daily', 'monthly', 'yearly', 'timeline', 'forecast', 'predict'], 'pie': ['pie', 'breakdown', 'distribution', 'share', 'proportion', 'segment'], 'radar': ['radar', 'spider', 'skill', 'attributes', 'comparison', 'profile'], 'scatter': ['scatter', 'correlation', 'relationship', 'vs', 'versus'], 'area': ['area', 'stacked', 'cumulative'], 'box': ['box', 'distribution', 'variance', 'quartile', 'outlier'], 'line': ['line', 'trend'], 'bar': ['bar', 'ranking', 'top', 'compare'] } for c_type, keywords in type_map.items(): if any(kw in q_lower for kw in keywords): params["chart_type"] = c_type break # Detect limit num_match = re.search(r'top\s+(\d+)|(\d+)\s+top|last\s+(\d+)', q_lower) if num_match: params["limit"] = int(next(g for g in num_match.groups() if g)) # Detect "is comparison" if any(kw in q_lower for kw in ['compare', 'vs', 'versus', 'against']): params["is_comparison"] = True # Detect potential metric/entity matches (Fuzzy/Pattern) patterns = { "metric": ['salary', 'amount', 'revenue', 'total', 'sales', 'price', 'cost', 'count', 'value', 'performance', 'rating'], "entity": ['department', 'dept', 'product', 'customer', 'client', 'employee', 'staff', 'region', 'category', 'type'] } for p_type, words in patterns.items(): for word in words: if word in q_lower: if params[p_type] is None: params[p_type] = word elif p_type == "metric" and params["secondary_metric"] is None: params["secondary_metric"] = word # Group By Detection by_match = re.search(r'by\s+(\w+)', q_lower) if by_match: params["group_by"] = by_match.group(1) return params def generate_radar_chart(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate radar chart for comparison of multiple attributes""" if df.empty: return {"error": "No data available"} # Find categorical column (for entities/departments) cat_col = None for col in df.columns: if df[col].dtype == 'object' and df[col].nunique() <= 20: cat_col = col break if not cat_col: return {"error": "No categorical column found for radar chart"} # Find ALL numeric columns (for attributes) num_cols = [col for col in df.columns if df[col].dtype in ['int64', 'float64']] if len(num_cols) < 2: return {"error": f"Need at least 2 numeric columns for radar chart. Found: {len(num_cols)}"} # Limit to 5 attributes max for readability num_cols = num_cols[:5] try: # Group by category and calculate mean for each numeric column pivot = df.groupby(cat_col)[num_cols].mean().head(5) # Top 5 entities if pivot.empty: return {"error": "No data after grouping"} # Create radar traces data = [] colors = ['#FF6B35', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'] for idx, (index, row) in enumerate(pivot.iterrows()): data.append({ "type": "scatterpolar", "r": row.values.tolist(), "theta": num_cols, "fill": "toself", "name": str(index), "line": {"color": colors[idx % len(colors)]} }) return apply_premium_layout({ "data": data, "layout": { "polar": { "radialaxis": { "visible": True, "range": [0, float(pivot.values.max()) * 1.1], "tickfont": {"color": "#e5e7eb", "size": 12}, "linecolor": "#6b7280", "gridcolor": "#4b5563" }, "angularaxis": { "tickfont": {"color": "#f3f4f6", "size": 14}, "linecolor": "#6b7280", "gridcolor": "#4b5563" }, "bgcolor": "rgba(0,0,0,0)" }, "showlegend": True, "legend": {"font": {"color": "#e5e7eb", "size": 12}}, "font": {"color": "#e5e7eb"} } }, f"Radar: {', '.join(num_cols[:3])}{'...' if len(num_cols) > 3 else ''}") except Exception as e: return {"error": f"Radar chart generation failed: {str(e)}"} def generate_scatter_plot(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate scatter plot for correlation analysis""" if df.empty: return {"error": "No data"} detected = auto_detect_columns(df, query) num_cols = detected.get("all_numeric") cat_col = detected.get("category") if len(num_cols) < 2: return {"error": "Need 2 numeric columns for scatter"} x_col, y_col = num_cols[0], num_cols[1] trace = { "x": df[x_col].tolist(), "y": df[y_col].tolist(), "mode": "markers", "type": "scatter", "marker": {"size": 10, "opacity": 0.6, "color": "#10b981"} } if cat_col: trace["text"] = df[cat_col].tolist() return apply_premium_layout({ "data": [trace], "layout": { "xaxis": {"title": x_col}, "yaxis": {"title": y_col} } }, f"Relationship: {x_col} vs {y_col}") def generate_box_plot(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate box plot for distribution analysis""" if df.empty: return {"error": "No data available"} # Find categorical column cat_col = None for col in df.columns: if df[col].dtype == 'object' and df[col].nunique() <= 20: cat_col = col break # Find numeric column num_col = None for col in df.columns: if df[col].dtype in ['int64', 'float64']: num_col = col break if not cat_col: return {"error": "No categorical column found for box plot"} if not num_col: return {"error": "No numeric column found for box plot"} try: # Get unique categories (limit to 10 for readability) unique_cats = df[cat_col].unique()[:10] # Create box traces data = [] colors = ['#FF6B35', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9'] for idx, cat in enumerate(unique_cats): cat_data = df[df[cat_col] == cat][num_col].dropna().tolist() if cat_data: # Only add if there's data data.append({ "y": cat_data, "type": "box", "name": str(cat), "boxmean": True, "marker": {"color": colors[idx % len(colors)]} }) if not data: return {"error": "No valid data for box plot after filtering"} return apply_premium_layout({ "data": data, "layout": { "yaxis": {"title": num_col}, "xaxis": {"title": cat_col} } }, f"Distribution of {num_col} by {cat_col}") except Exception as e: return {"error": f"Box plot generation failed: {str(e)}"} # ============================================================================= # NEW DYNAMIC CHART TYPES - Work with ANY data schema # ============================================================================= def generate_histogram(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate histogram for distribution of a numeric column""" if df.empty: return {"error": "No data available"} # Find numeric column from query or first available num_col = None query_lower = query.lower() for col in df.columns: if df[col].dtype in ['int64', 'float64']: if col.lower() in query_lower: num_col = col break if not num_col: for col in df.columns: if df[col].dtype in ['int64', 'float64']: num_col = col break if not num_col: return {"error": "No numeric column found for histogram"} try: values = df[num_col].dropna().tolist() return apply_premium_layout({ "data": [{ "x": values, "type": "histogram", "marker": {"color": "#4ECDC4", "line": {"color": "#1f2937", "width": 1}}, "opacity": 0.8 }], "layout": {"xaxis": {"title": num_col}, "yaxis": {"title": "Frequency"}, "bargap": 0.05} }, f"Distribution of {num_col}") except Exception as e: return {"error": f"Histogram generation failed: {str(e)}"} def generate_heatmap(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate heatmap for correlation visualization""" if df.empty: return {"error": "No data available"} num_cols = [col for col in df.columns if df[col].dtype in ['int64', 'float64']] if len(num_cols) < 2: return {"error": f"Need at least 2 numeric columns for heatmap. Found: {len(num_cols)}"} try: num_cols = num_cols[:8] corr = df[num_cols].corr() return apply_premium_layout({ "data": [{ "z": corr.values.tolist(), "x": num_cols, "y": num_cols, "type": "heatmap", "colorscale": "RdBu", "zmin": -1, "zmax": 1, "showscale": True }], "layout": {"xaxis": {"tickangle": -45}} }, f"Correlation Heatmap ({len(num_cols)} variables)") except Exception as e: return {"error": f"Heatmap generation failed: {str(e)}"} def generate_area_chart(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate area chart for cumulative visualization""" if df.empty: return {"error": "No data available"} cat_col = None num_col = None for col in df.columns: if df[col].dtype == 'object' and df[col].nunique() <= 30: cat_col = col break for col in df.columns: if df[col].dtype in ['int64', 'float64']: num_col = col break if not cat_col or not num_col: return {"error": "Need categorical and numeric columns for area chart"} try: grouped = df.groupby(cat_col)[num_col].sum().sort_index() return apply_premium_layout({ "data": [{ "x": grouped.index.tolist(), "y": grouped.values.tolist(), "type": "scatter", "mode": "lines", "fill": "tozeroy", "fillcolor": "rgba(78, 205, 196, 0.4)", "line": {"color": "#4ECDC4", "width": 2} }], "layout": {"xaxis": {"title": cat_col}, "yaxis": {"title": num_col}} }, f"Cumulative {num_col} by {cat_col}") except Exception as e: return {"error": f"Area chart generation failed: {str(e)}"} def generate_funnel_chart(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate funnel chart for progression analysis""" if df.empty: return {"error": "No data available"} cat_col = None num_col = None for col in df.columns: if df[col].dtype == 'object' and df[col].nunique() <= 15: cat_col = col break for col in df.columns: if df[col].dtype in ['int64', 'float64']: num_col = col break if not cat_col or not num_col: return {"error": "Need categorical and numeric columns for funnel"} try: grouped = df.groupby(cat_col)[num_col].sum().sort_values(ascending=False).head(8) return apply_premium_layout({ "data": [{ "type": "funnel", "y": grouped.index.tolist(), "x": grouped.values.tolist(), "textinfo": "value+percent initial", "marker": {"color": ["#FF6B35", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F"]} }], "layout": {} }, f"{num_col} Funnel by {cat_col}") except Exception as e: return {"error": f"Funnel chart generation failed: {str(e)}"} def generate_treemap(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate treemap for hierarchical proportion visualization""" if df.empty: return {"error": "No data available"} cat_col = None num_col = None for col in df.columns: if df[col].dtype == 'object' and df[col].nunique() <= 20: cat_col = col break for col in df.columns: if df[col].dtype in ['int64', 'float64']: num_col = col break if not cat_col or not num_col: return {"error": "Need categorical and numeric columns for treemap"} try: grouped = df.groupby(cat_col)[num_col].sum().sort_values(ascending=False) return apply_premium_layout({ "data": [{ "type": "treemap", "labels": grouped.index.tolist(), "parents": [""] * len(grouped), "values": grouped.values.tolist(), "textinfo": "label+value+percent root", "marker": {"colors": ["#FF6B35", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9"][:len(grouped)]} }], "layout": {} }, f"{num_col} Treemap by {cat_col}") except Exception as e: return {"error": f"Treemap generation failed: {str(e)}"} def generate_gauge_chart(df: pd.DataFrame, query: str = "") -> Dict[str, Any]: """Generate gauge chart for KPI visualization""" if df.empty: return {"error": "No data available"} num_col = None for col in df.columns: if df[col].dtype in ['int64', 'float64']: num_col = col break if not num_col: return {"error": "No numeric column found for gauge"} try: value = df[num_col].mean() max_val = df[num_col].max() return apply_premium_layout({ "data": [{ "type": "indicator", "mode": "gauge+number+delta", "value": value, "title": {"text": f"Average {num_col}", "font": {"color": "#e5e7eb"}}, "delta": {"reference": max_val * 0.7}, "gauge": { "axis": {"range": [0, max_val], "tickfont": {"color": "#e5e7eb"}}, "bar": {"color": "#4ECDC4"}, "bgcolor": "#374151", "steps": [ {"range": [0, max_val * 0.33], "color": "#ef4444"}, {"range": [max_val * 0.33, max_val * 0.66], "color": "#f59e0b"}, {"range": [max_val * 0.66, max_val], "color": "#10b981"} ] } }], "layout": {"font": {"color": "#e5e7eb"}} }, f"{num_col} Gauge") except Exception as e: return {"error": f"Gauge chart generation failed: {str(e)}"} def generate_dynamic_bar_chart(df: pd.DataFrame, group_col: str = None, metric_col: str = None, title: str = None, limit: int = 10) -> Dict[str, Any]: """ Generate a bar chart grouped by any column with any metric. If columns not specified or not found, AUTO-DETECT from dataframe. """ if df.empty: return {"error": "No data available"} # Step 1: Try to find specified columns with fuzzy matching group_column = None metric_column = None if group_col: for col in df.columns: if group_col.lower() in col.lower() or col.lower() in group_col.lower(): group_column = col break if metric_col: for col in df.columns: if metric_col.lower() in col.lower() or col.lower() in metric_col.lower(): metric_column = col break # Step 2: If not found, AUTO-DETECT columns if not group_column or not metric_column: print(f"[CHART] Columns not found (group={group_col}, metric={metric_col}), auto-detecting...") detected = auto_detect_columns(df) if not group_column: group_column = detected.get("category") if not metric_column: metric_column = detected.get("numeric") if not title: title = detected.get("title", "Data Analysis") # Step 3: Final validation if not group_column or not metric_column: print(f"[CHART] ERROR: Could not detect columns. Available: {list(df.columns)}") return {"error": f"Could not auto-detect columns. Available: {list(df.columns)}"} if group_column not in df.columns: return {"error": f"Column '{group_column}' not found"} if metric_column not in df.columns: return {"error": f"Column '{metric_column}' not found"} print(f"[CHART] Using columns: group={group_column}, metric={metric_column}") # Ensure metric column is numeric if not pd.api.types.is_numeric_dtype(df[metric_column]): df[metric_column] = pd.to_numeric(df[metric_column], errors='coerce') # Aggregate by group grouped = df.groupby(group_column)[metric_column].sum().sort_values(ascending=True).tail(limit) if grouped.empty or grouped.sum() == 0: return {"error": "No data to visualize after aggregation"} colors = [ '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16' ] final_title = title or f"Total {metric_column} by {group_column}" plotly_json = { "data": [{ "x": grouped.values.tolist(), "y": grouped.index.tolist(), "type": "bar", "orientation": "h", "marker": {"color": colors[:len(grouped)]}, "text": [f"{v:,.0f}" for v in grouped.values], "textposition": "outside" }], "layout": { "title": {"text": final_title, "font": {"size": 18, "color": "#e5e7eb"}}, "xaxis": {"title": metric_column, "gridcolor": "#374151", "color": "#9ca3af"}, "yaxis": {"title": "", "gridcolor": "#374151", "color": "#9ca3af"}, "paper_bgcolor": "rgba(0,0,0,0)", "plot_bgcolor": "rgba(0,0,0,0)", "font": {"color": "#e5e7eb"}, "margin": {"l": 150, "r": 80, "t": 50, "b": 50} } } return plotly_json def generate_donut_chart(df: pd.DataFrame, query: str = "", colors: List[str] = None) -> Dict[str, Any]: """Generate donut chart (pie with hole in center)""" if df.empty: return {"error": "No data available"} detected = auto_detect_columns(df, query) cat_col = detected.get("category") num_col = detected.get("numeric") if not cat_col or not num_col: return {"error": "Could not detect columns for donut chart"} grouped = df.groupby(cat_col)[num_col].sum().sort_values(ascending=False).head(10) # Use provided colors or get from palette if not colors: colors = get_color_palette("vibrant", len(grouped)) return apply_premium_layout({ "data": [{ "type": "pie", "labels": [str(x) for x in grouped.index.tolist()], "values": [float(x) for x in grouped.values.tolist()], "hole": 0.4, # This makes it a donut! "marker": {"colors": colors[:len(grouped)]}, "textinfo": "label+percent", "hovertemplate": "%{label}
%{value:,.0f}
%{percent}" }], "layout": {"height": 400} }, f"{num_col} Distribution by {cat_col}") def generate_sunburst_chart(df: pd.DataFrame, query: str = "", colors: List[str] = None) -> Dict[str, Any]: """Generate hierarchical sunburst chart""" if df.empty: return {"error": "No data available"} detected = auto_detect_columns(df, query) cat_cols = detected.get("all_categorical", []) num_col = detected.get("numeric") if len(cat_cols) < 1 or not num_col: return {"error": "Need categorical and numeric columns for sunburst"} # Use first categorical for main breakdown cat_col = cat_cols[0] grouped = df.groupby(cat_col)[num_col].sum() labels = ["Total"] + [str(x) for x in grouped.index] parents = [""] + ["Total"] * len(grouped) values = [float(grouped.sum())] + [float(v) for v in grouped.values] # Use provided colors or get from palette if not colors: colors = get_color_palette("professional", len(labels)) return apply_premium_layout({ "data": [{ "type": "sunburst", "labels": labels, "parents": parents, "values": values, "branchvalues": "total", "marker": {"colors": colors}, "textinfo": "label+percent entry", "hovertemplate": "%{label}
%{value:,.0f}" }], "layout": {"height": 450} }, f"Hierarchical Breakdown: {cat_col}") def generate_bubble_chart(df: pd.DataFrame, query: str = "", colors: List[str] = None) -> Dict[str, Any]: """Generate bubble chart with size proportional to values""" if df.empty: return {"error": "No data available"} detected = auto_detect_columns(df, query) cat_col = detected.get("category") num_col = detected.get("numeric") if not cat_col or not num_col: return {"error": "Could not detect columns for bubble chart"} grouped = df.groupby(cat_col)[num_col].sum().sort_values(ascending=False).head(15) # Normalize sizes (min 20, max 80) max_val = grouped.max() if len(grouped) > 0 else 1 sizes = [max(20, min(80, (v / max_val) * 60 + 20)) for v in grouped.values] # Use provided colors or get from palette if not colors: colors = get_color_palette("vibrant", len(grouped)) return apply_premium_layout({ "data": [{ "type": "scatter", "mode": "markers+text", "x": list(range(len(grouped))), "y": grouped.values.tolist(), "text": grouped.index.tolist(), "textposition": "top center", "marker": { "size": sizes, "color": colors, "opacity": 0.7, "line": {"width": 2, "color": "white"} }, "hovertemplate": "%{text}
%{y:,.0f}" }], "layout": { "xaxis": {"visible": False}, "yaxis": {"title": num_col}, "height": 400 } }, f"Bubble Size by {num_col}") def generate_query_aware_chart(df: pd.DataFrame, query: str) -> Dict[str, Any]: """ Generate chart dynamically based on user query. ChatGPT Pro-level dynamic generation for ANY chart type. Orchestrates between specialized chart generators and LLM-driven smart_chart. CRITICAL: Check for EXPLICIT chart type keywords FIRST to respect user intent. """ from agents.smart_chart import smart_chart, get_color_palette_from_query query_lower = query.lower() print(f"[CHART] Orchestrating visualization for: {query[:50]}...") # ========================================================================== # EXTRACT COLOR PREFERENCE FROM QUERY # ========================================================================== colors = get_color_palette_from_query(query) print(f"[CHART] Color palette selected: {colors[:3]}...") # ========================================================================== # STEP 1: PRIORITIZE LLM-DRIVEN SMART CHART (INTELLIGENT AGENT) # This handles ALL chart types (violin, radar, pie, etc.) with advanced logic # ========================================================================== try: if 'smart_chart' in globals() or 'smart_chart' in locals(): from core.currency_converter import get_user_currency currency_symbol, _ = get_user_currency("default") print(f"[CHART] 🧠 Attempting Smart Chart for: '{query}'") chart_result, _ = smart_chart(query, df, currency_symbol=currency_symbol) # If successful, return immediately! if chart_result and 'error' not in chart_result: chart_type = chart_result.get('data', [{}])[0].get('type', 'unknown') print(f"✅ [CHART] Smart Chart successful! generated: {chart_type}") return chart_result else: print(f"⚠️ [CHART] Smart Chart returned valid format but marked as error or empty") except Exception as e: print(f"⚠️ [CHART] Smart Chart failed (falling back to manual): {e}") # ========================================================================== # STEP 2: MANUAL FALLBACKS (If Smart Chart fails) # Check for EXPLICIT chart type keywords to respect user intent # ========================================================================== # PIE CHART - User explicitly asked for pie chart (MUST be first check) # Broadened detection: 'pie' alone now triggers pie chart if 'pie' in query_lower and 'spider' not in query_lower: print(f"[CHART] 🥧 PIE CHART request detected in: '{query}'") result = generate_customer_pie_chart(df, colors=colors) if result and 'error' not in result: print(f"✅ [CHART] Pie chart generated successfully!") return result else: error_msg = result.get('error', 'Unknown') if result else 'None returned' print(f"⚠️ [CHART] Pie chart failed: {error_msg}") # DONUT CHART - User explicitly asked for donut if any(kw in query_lower for kw in ['donut', 'doughnut']): print(f"[CHART] 🍩 Explicit DONUT CHART request detected") result = generate_donut_chart(df, query, colors=colors) if result and 'error' not in result: print(f"✅ [CHART] Donut chart generated successfully!") return result # SUNBURST CHART - User explicitly asked for sunburst if any(kw in query_lower for kw in ['sunburst', 'sun burst', 'hierarchical pie']): print(f"[CHART] ☀️ Explicit SUNBURST CHART request detected") result = generate_sunburst_chart(df, query, colors=colors) if result and 'error' not in result: print(f"✅ [CHART] Sunburst chart generated successfully!") return result # BUBBLE CHART - User explicitly asked for bubble if any(kw in query_lower for kw in ['bubble chart', 'bubble graph', 'bubble']): print(f"[CHART] 🫧 Explicit BUBBLE CHART request detected") result = generate_bubble_chart(df, query, colors=colors) if result and 'error' not in result: print(f"✅ [CHART] Bubble chart generated successfully!") return result # BAR CHART - User explicitly asked for bar chart if any(kw in query_lower for kw in ['bar chart', 'bar graph', 'as a bar']): print(f"[CHART] 📊 Explicit BAR CHART request detected") cols = auto_detect_columns(df, query) if cols.get('category') and cols.get('numeric'): result = generate_dynamic_bar_chart(df, cols['category'], cols['numeric'], cols['title']) if result and 'error' not in result: print(f"✅ [CHART] Bar chart generated successfully!") return result # LINE CHART - User explicitly asked for line chart if any(kw in query_lower for kw in ['line chart', 'line graph']): print(f"[CHART] 📈 Explicit LINE CHART request detected") result = generate_revenue_trend_chart(df) if result and 'error' not in result: print(f"✅ [CHART] Line chart generated successfully!") return result # BOX PLOT - User explicitly asked for box plot if 'box' in query_lower and ('plot' in query_lower or 'chart' in query_lower): print(f"[CHART] 📦 Explicit BOX PLOT request detected") result = generate_box_plot(df, query) if result and 'error' not in result: print(f"✅ [CHART] Box plot generated successfully!") return result else: print(f"⚠️ [CHART] Box plot failed: {result.get('error', 'Unknown')}") # RADAR CHART - User explicitly asked for radar if 'radar' in query_lower: print(f"[CHART] 📡 Explicit RADAR CHART request detected") result = generate_radar_chart(df, query) if result and 'error' not in result: print(f"✅ [CHART] Radar chart generated successfully!") return result else: print(f"⚠️ [CHART] Radar chart failed: {result.get('error', 'Unknown')}") # SCATTER PLOT - User explicitly asked for scatter if 'scatter' in query_lower: print(f"[CHART] 📊 Explicit SCATTER PLOT request detected") result = generate_scatter_plot(df, query) if result and 'error' not in result: print(f"✅ [CHART] Scatter plot generated successfully!") return result # HISTOGRAM - User wants frequency distribution if 'histogram' in query_lower or 'frequency' in query_lower: print(f"[CHART] 📊 Explicit HISTOGRAM request detected") result = generate_histogram(df, query) if result and 'error' not in result: print(f"✅ [CHART] Histogram generated successfully!") return result # HEATMAP - User wants correlation/heatmap if 'heatmap' in query_lower or 'correlation' in query_lower: print(f"[CHART] 🗺️ Explicit HEATMAP request detected") result = generate_heatmap(df, query) if result and 'error' not in result: print(f"✅ [CHART] Heatmap generated successfully!") return result # AREA CHART - User wants cumulative/area if 'area' in query_lower and 'chart' in query_lower: print(f"[CHART] 📈 Explicit AREA CHART request detected") result = generate_area_chart(df, query) if result and 'error' not in result: print(f"✅ [CHART] Area chart generated successfully!") return result # FUNNEL CHART - User wants funnel/progression if 'funnel' in query_lower: print(f"[CHART] 📥 Explicit FUNNEL CHART request detected") result = generate_funnel_chart(df, query) if result and 'error' not in result: print(f"✅ [CHART] Funnel chart generated successfully!") return result # TREEMAP - User wants hierarchical treemap if 'treemap' in query_lower or 'tree map' in query_lower: print(f"[CHART] 🌳 Explicit TREEMAP request detected") result = generate_treemap(df, query) if result and 'error' not in result: print(f"✅ [CHART] Treemap generated successfully!") return result # GAUGE CHART - User wants KPI gauge if 'gauge' in query_lower or 'kpi' in query_lower: print(f"[CHART] 🎯 Explicit GAUGE CHART request detected") result = generate_gauge_chart(df, query) if result and 'error' not in result: print(f"✅ [CHART] Gauge chart generated successfully!") return result # COMPARISON - User wants to compare things params = extract_chart_params_from_query(query) if params["is_comparison"]: print(f"[CHART] 🔄 Comparison chart detected") result = generate_comparison_chart(df, query) if 'error' not in result: return result # PREDICTION - User wants prediction/forecast if params["chart_type"] == "prediction" or 'predict' in query_lower or 'forecast' in query_lower: print(f"[CHART] 🔮 Prediction chart detected") result = generate_prediction_chart(df) if 'error' not in result: return result # TREND - User wants trend analysis if params["chart_type"] == "trend" or 'trend' in query_lower or 'over time' in query_lower: print(f"[CHART] 📈 Trend chart detected") result = generate_revenue_trend_chart(df) if 'error' not in result: return result # ========================================================================== # STEP 3: DYNAMIC BAR/PIE FALLBACK # ========================================================================== cols = auto_detect_columns(df, query) if cols.get('category') and cols.get('numeric'): if 'pie' in query_lower or 'donut' in query_lower: # 1. Try smart/specific pie chart first result = generate_customer_pie_chart(df) if 'error' not in result: return result # 2. Desperate Fallback: Force Generic Pie Chart using detected columns # This prevents showing a bar chart when user explicitly asked for Pie print(f"[CHART] 🥧 Force Generic Pie Chart for: '{query}'") return generate_dynamic_pie_chart( df, category_col=cols.get('category'), value_col=cols.get('numeric'), title=f"Distribution of {cols.get('numeric')} by {cols.get('category')}" ) # Default to bar chart for aggregation queries result = generate_dynamic_bar_chart( df, group_col=cols.get('category'), metric_col=cols.get('numeric'), title=cols.get('title') ) if 'error' not in result: return result # ========================================================================== # STEP 4: LAST RESORT - Return informative error # ========================================================================== return { "error": f"Could not generate visualization for this query. Available columns: {', '.join(df.columns[:5])}" } def generate_customer_pie_chart(df: pd.DataFrame, colors: List[str] = None) -> Dict[str, Any]: """Generate pie chart - UNIVERSAL for ANY domain (HR, Sales, etc.) Args: df: DataFrame with the data colors: Optional list of colors from query-based palette """ if df.empty: return {"error": "No data available"} # Find grouping column - UNIVERSAL detection for ANY domain group_col = None group_keywords = [ # HR 'department', 'dept', 'team', 'division', 'role', 'position', # Sales 'customer', 'client', 'category', 'product', # General 'region', 'location', 'type', 'group', 'name', 'segment' ] for col in df.columns: col_lower = col.lower() for kw in group_keywords: if kw in col_lower: group_col = col break if group_col: break # Fallback: first text column if group_col is None: for col in df.columns: if df[col].dtype == 'object': group_col = col break if group_col is None: group_col = df.columns[0] # Find amount/value column - UNIVERSAL detection for ANY domain amount_col = None amount_keywords = [ # HR 'salary', 'wage', 'pay', 'compensation', 'income', # Sales 'amount', 'revenue', 'total', 'sales', 'price', 'value', # General 'cost', 'sum', 'count', 'quantity' ] for col in df.columns: col_lower = col.lower() for kw in amount_keywords: if kw in col_lower: amount_col = col break if amount_col: break # Fallback: first numeric column if amount_col is None: for col in df.columns: if df[col].dtype in ['int64', 'float64']: amount_col = col break if amount_col is None: return {"error": "No numeric data found for pie chart"} print(f"[PIE CHART] Using group_col={group_col}, amount_col={amount_col}") # Ensure numeric type if not pd.api.types.is_numeric_dtype(df[amount_col]): df[amount_col] = pd.to_numeric(df[amount_col], errors='coerce') # Get ALL items dynamically - no hardcoded limit grouped = df.groupby(group_col)[amount_col].sum().sort_values(ascending=False) if grouped.empty or grouped.sum() == 0: return {"error": "No data to visualize after grouping"} labels = [str(x) for x in grouped.index.tolist()] values = [float(x) for x in grouped.values.tolist()] # Use provided colors or default palette if not colors: colors = [ '#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#a855f7', '#22c55e', '#3b82f6', '#f43f5e', '#0ea5e9', '#d946ef', '#eab308', '#0284c7', '#78716c', '#dc2626' ] # Extend colors if needed while len(colors) < len(labels): colors = colors + colors # Dynamic title based on detected columns title = f"{amount_col.replace('_', ' ').title()} by {group_col.replace('_', ' ').title()}" print(f"[PIE CHART] ✅ Creating pie chart with {len(labels)} slices") plotly_json = { "data": [{ "labels": labels, "values": values, "type": "pie", "hole": 0.4, "marker": {"colors": colors[:len(labels)]}, "textinfo": "label+percent", "textposition": "outside", "hovertemplate": "%{label}
%{value:,.0f}
%{percent}" }], "layout": { "title": {"text": title, "font": {"size": 18, "color": "#e5e7eb"}}, "paper_bgcolor": "rgba(0,0,0,0)", "plot_bgcolor": "rgba(0,0,0,0)", "font": {"color": "#e5e7eb"}, "showlegend": True, "legend": {"x": 1, "y": 0.5, "font": {"color": "#9ca3af"}}, "height": 450 } } return plotly_json def generate_dynamic_pie_chart( df: pd.DataFrame, category_col: str, value_col: str, title: str = "Pie Chart" ) -> Dict[str, Any]: """ Force generate a generic pie chart from known columns. Used as valid backup when smart detection fails but we have columns. """ if df.empty or not category_col or not value_col: return {"error": "Invalid data for pie chart"} try: # Aggregate data properly grouped = df.groupby(category_col)[value_col].sum().sort_values(ascending=False) # Limit to top 20 slices to avoiding crashing functionality grouped = grouped.head(20) labels = [str(x) for x in grouped.index.tolist()] values = [float(x) for x in grouped.values.tolist()] colors = [ '#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16' ] return { "data": [{ "labels": labels, "values": values, "type": "pie", "hole": 0.4, # Donut style looks modern "marker": {"colors": colors[:len(labels)]}, "textinfo": "label+percent", "textposition": "outside", "hovertemplate": "%{label}
%{value:,.0f}
%{percent}" }], "layout": { "title": {"text": title, "font": {"size": 16, "color": "#e5e7eb"}}, "paper_bgcolor": "rgba(0,0,0,0)", "plot_bgcolor": "rgba(0,0,0,0)", "font": {"color": "#e5e7eb"}, "showlegend": True, "legend": {"x": 1, "y": 0.5}, "height": 450 } } except Exception as e: print(f"Generic pie chart failed: {e}") return {"error": str(e)} def generate_prediction_chart(df: pd.DataFrame) -> Dict[str, Any]: """Generate prediction chart with forecast band""" if df.empty: return {"error": "No data available"} # Find amount column amount_col = None for col in df.columns: if 'amount' in col.lower() or 'revenue' in col.lower() or 'total' in col.lower(): amount_col = col break if amount_col is None: for col in df.columns: if df[col].dtype in ['int64', 'float64']: amount_col = col break if amount_col is None: return {"error": "No numeric data found"} # Simple linear regression for prediction values = df[amount_col].values x = np.arange(len(values)) # Fit line if len(values) > 1: slope, intercept = np.polyfit(x, values, 1) else: slope, intercept = 0, values[0] if len(values) > 0 else 0 # Predict next 5 periods future_x = np.arange(len(values), len(values) + 5) predictions = slope * future_x + intercept # Confidence band (simple ±15%) upper_band = predictions * 1.15 lower_band = predictions * 0.85 # Historical data x_data = list(range(1, len(values) + 1)) # Future dates labels future_labels = [f"Period {i+1}" for i in future_x] plotly_json = { "data": [ # Historical data { "x": x_data, "y": values.tolist(), "type": "scatter", "mode": "lines+markers", "name": "Historical", "line": {"color": "#10b981", "width": 3}, "marker": {"size": 8} }, # Prediction line { "x": list(range(len(values) + 1, len(values) + 6)), "y": predictions.tolist(), "type": "scatter", "mode": "lines+markers", "name": "Prediction", "line": {"color": "#f59e0b", "width": 3, "dash": "dash"}, "marker": {"size": 8} }, # Upper confidence band { "x": list(range(len(values) + 1, len(values) + 6)), "y": upper_band.tolist(), "type": "scatter", "mode": "lines", "name": "Upper Bound", "line": {"color": "rgba(245, 158, 11, 0.3)", "width": 0}, "showlegend": False }, # Lower confidence band (fill to upper) { "x": list(range(len(values) + 1, len(values) + 6)), "y": lower_band.tolist(), "type": "scatter", "mode": "lines", "name": "Confidence Band", "line": {"color": "rgba(245, 158, 11, 0.3)", "width": 0}, "fill": "tonexty", "fillcolor": "rgba(245, 158, 11, 0.2)" } ], "layout": { "title": {"text": "Revenue Prediction (Next 5 Periods)", "font": {"size": 18, "color": "#e5e7eb"}}, "xaxis": {"title": "Period", "gridcolor": "#374151", "color": "#9ca3af"}, "yaxis": {"title": "Revenue ($)", "gridcolor": "#374151", "color": "#9ca3af"}, "paper_bgcolor": "rgba(0,0,0,0)", "plot_bgcolor": "rgba(0,0,0,0)", "font": {"color": "#e5e7eb"}, "margin": {"l": 60, "r": 30, "t": 50, "b": 50}, "legend": {"x": 0, "y": 1.1, "orientation": "h", "font": {"color": "#9ca3af"}}, "hovermode": "x unified" }, "prediction_summary": { "next_period": float(predictions[0]), "trend": "up" if slope > 0 else "down", "growth_rate": float(slope / intercept * 100) if intercept != 0 else 0 } } return plotly_json @router.post("/generate", response_model=ChartResponse) async def generate_chart( request: ChartRequest, x_user_id: Optional[str] = Header(None, alias="X-User-ID"), authorization: Optional[str] = Header(None, alias="Authorization") ): """Generate Plotly chart from user's data - SECURED""" try: # SECURITY: Get verified user_id from JWT secure_user_id = get_secure_user_id(request.user_id, x_user_id, authorization) df = get_user_data(secure_user_id) if df.empty: raise HTTPException(status_code=404, detail="No data found. Please upload files first.") chart_type = request.chart_type.lower() if chart_type in ["line", "trend", "revenue"]: plotly_json = generate_revenue_trend_chart(df) summary = f"Revenue trend chart with {len(df)} data points" elif chart_type in ["bar", "product", "products"]: plotly_json = generate_product_bar_chart(df) summary = f"Product comparison chart from {len(df)} records" elif chart_type in ["pie", "customer", "customers", "distribution"]: plotly_json = generate_customer_pie_chart(df) summary = f"Customer distribution from {len(df)} records" elif chart_type in ["prediction", "forecast", "predict"]: plotly_json = generate_prediction_chart(df) summary = f"Revenue prediction based on {len(df)} historical points" else: # Default to revenue trend plotly_json = generate_revenue_trend_chart(df) summary = f"Revenue trend chart with {len(df)} data points" return ChartResponse( chart_type=chart_type, plotly_json=plotly_json, summary=summary, data_points=len(df) ) except HTTPException: raise except Exception as e: print(f"Chart generation error: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/available-types") async def get_available_chart_types(): """Get list of available chart types""" return { "chart_types": [ {"id": "trend", "name": "Revenue Trend", "description": "Line chart showing revenue over time"}, {"id": "bar", "name": "Product Comparison", "description": "Bar chart comparing products"}, {"id": "pie", "name": "Customer Distribution", "description": "Pie chart of customer revenue"}, {"id": "prediction", "name": "Revenue Prediction", "description": "Forecast with confidence band"} ] }