| """ |
| Interactive Chart Explorer & "Surprise Me" Dashboard |
| =================================================== |
| |
| Runs a local FastAPI server that lets you dynamically generate random visual |
| queries using an LLM, resolve indicators, compile Vega-Lite specs, run |
| DeepEval G-Eval quality scoring, and render the resulting charts in real-time. |
| It now compares the Data360 engine against a direct LLM generation side-by-side, |
| supporting unique persistence, batch runs of 20 sets, and recommendations to improve. |
| |
| Run: |
| uv run python evals/interactive_explorer.py |
| |
| Then open: http://localhost:8090 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import sys |
| import json |
| import hashlib |
| import asyncio |
| import time |
| from pathlib import Path |
| from typing import Any |
| from unittest.mock import patch |
|
|
| import uvicorn |
| import httpx |
| import pandas as pd |
| from pydantic import BaseModel |
| from fastapi import FastAPI, BackgroundTasks |
| from fastapi.responses import HTMLResponse, JSONResponse |
| from dotenv import load_dotenv |
|
|
| |
| load_dotenv(Path(__file__).parent.parent / ".env") |
|
|
| async def call_mcp_tool_on_8021(name: str, arguments: dict) -> dict: |
| """Call an MCP tool on the local running server (port 8021) and parse SSE response.""" |
| port = int(os.environ.get("MCP_PORT", 8021)) |
| url = f"http://localhost:{port}/mcp" |
| payload = { |
| "jsonrpc": "2.0", |
| "method": "tools/call", |
| "params": { |
| "name": name, |
| "arguments": arguments |
| }, |
| "id": 1 |
| } |
| headers = { |
| "Content-Type": "application/json", |
| "Accept": "application/json, text/event-stream" |
| } |
|
|
| async with httpx.AsyncClient(timeout=45.0) as client: |
| async with client.stream("POST", url, json=payload, headers=headers) as response: |
| if response.status_code != 200: |
| raise RuntimeError(f"MCP server returned status code {response.status_code}") |
| async for line in response.aiter_lines(): |
| if line.startswith("data:"): |
| data_str = line[5:].strip() |
| if not data_str: |
| continue |
| try: |
| res_json = json.loads(data_str) |
| if "result" in res_json: |
| result_data = res_json["result"] |
| if result_data.get("isError"): |
| error_msg = "" |
| if result_data.get("content"): |
| error_msg = result_data["content"][0].get("text", "") |
| raise RuntimeError(error_msg or "Tool call failed") |
|
|
| if result_data.get("content"): |
| text_content = result_data["content"][0].get("text", "") |
| try: |
| parsed_viz = json.loads(text_content) |
| return parsed_viz |
| except json.JSONDecodeError: |
| return {"url": None, "error": text_content} |
| return {"url": None, "error": "No content in result"} |
| elif "error" in res_json: |
| raise RuntimeError(res_json["error"].get("message", "Unknown JSON-RPC error")) |
| except Exception as e: |
| if isinstance(e, RuntimeError): |
| raise e |
| continue |
| raise RuntimeError("No response data received from MCP server") |
|
|
| |
| _HERE = Path(__file__).parent |
| _REPO = _HERE.parent |
| sys.path.insert(0, str(_REPO)) |
|
|
| from data360.visualization import get_viz_spec, get_multi_indicator_viz_spec |
| from data360.api import search as api_search, _resolve_country_code |
| from evals.test_chart_rules_deepeval import resolve_indicator, _zero_shot_grammar_of_graphics_metric |
| from evals.prompts.chartjs_prompt import get_chartjs_system_prompt, get_chartjs_user_prompt |
|
|
| |
| REPORTS_DIR = _HERE / "reports" |
| REPORTS_DIR.mkdir(exist_ok=True) |
|
|
| HF_TOKEN = os.environ.get("HF_TOKEN") |
| HF_DATASET_ID = os.environ.get("HF_DATASET_ID", "rafmacalaba/data360-explorer-reports") |
|
|
| def sync_reports_from_hf(): |
| if not (HF_TOKEN and HF_DATASET_ID): |
| print("[HF Dataset Sync] HF_TOKEN or HF_DATASET_ID not set. Running locally.") |
| return |
| print(f"[HF Dataset Sync] Pulling files from dataset: {HF_DATASET_ID}...") |
| try: |
| import urllib.request |
| import json |
|
|
| |
| api_url = f"https://huggingface.co/api/datasets/{HF_DATASET_ID}/tree/main" |
| req = urllib.request.Request(api_url) |
| if HF_TOKEN: |
| req.add_header("Authorization", f"Bearer {HF_TOKEN}") |
|
|
| with urllib.request.urlopen(req) as response: |
| tree_data = json.loads(response.read().decode()) |
|
|
| files = [item["path"] for item in tree_data if item.get("type") == "file"] |
|
|
| |
| for f in files: |
| if f.startswith(".") or f == "README.md": |
| continue |
|
|
| raw_url = f"https://huggingface.co/datasets/{HF_DATASET_ID}/raw/main/{f}" |
| file_req = urllib.request.Request(raw_url) |
| if HF_TOKEN: |
| file_req.add_header("Authorization", f"Bearer {HF_TOKEN}") |
|
|
| local_path = REPORTS_DIR / f |
| print(f"[HF Dataset Sync] Downloading {f}...") |
| with urllib.request.urlopen(file_req) as file_resp: |
| with open(local_path, "wb") as out_file: |
| out_file.write(file_resp.read()) |
|
|
| print("[HF Dataset Sync] Pull completed successfully.") |
| except Exception as e: |
| print(f"[HF Dataset Sync] Error pulling from Hugging Face: {e}") |
|
|
| def upload_file_to_hf(file_path: Path): |
| if not (HF_TOKEN and HF_DATASET_ID): |
| return |
| try: |
| from huggingface_hub import HfApi |
| api = HfApi(token=HF_TOKEN) |
| |
| api.create_repo(repo_id=HF_DATASET_ID, repo_type="dataset", private=True, exist_ok=True) |
|
|
| |
| relative_path = file_path.name |
| api.upload_file( |
| path_or_fileobj=str(file_path), |
| path_in_repo=relative_path, |
| repo_id=HF_DATASET_ID, |
| repo_type="dataset" |
| ) |
| print(f"[HF Dataset Sync] Uploaded {relative_path} to HF dataset successfully.") |
| except Exception as e: |
| print(f"[HF Dataset Sync] Failed to upload {file_path.name} to Hugging Face: {e}") |
|
|
| |
| app = FastAPI(title="Data360 MCP - Interactive Chart Explorer") |
|
|
| @app.on_event("startup") |
| async def startup_event(): |
| sync_reports_from_hf() |
|
|
| |
| batch_status = { |
| "running": False, |
| "current": 0, |
| "total": 50, |
| "errors": [] |
| } |
| batch_cancel_requested = False |
|
|
| |
| |
| |
|
|
| WB_THEME: dict[str, Any] = { |
| "background": "#ffffff", |
| "view": {"stroke": None}, |
| "arc": {"fill": "#34A7F2"}, |
| "area": {"fill": "#34A7F2"}, |
| "line": {"stroke": "#34A7F2", "strokeCap": "round", "strokeJoin": "round"}, |
| "rect": {"fill": "#34A7F2"}, |
| "point": {"filled": True, "stroke": "white", "strokeWidth": 1}, |
| "title": { |
| "font": "Open Sans, Arial, sans-serif", |
| "subtitleFont": "Open Sans, Arial, sans-serif", |
| "anchor": "start", |
| "fontSize": 18, |
| "fontWeight": 600, |
| "offset": 20, |
| "subtitleFontSize": 15, |
| "subtitleColor": "#666666", |
| "subtitlePadding": 6, |
| }, |
| "axis": { |
| "titleFont": "Open Sans, Arial, sans-serif", |
| "titleFontSize": 13, |
| "titleFontWeight": 600, |
| "labelFont": "Open Sans, Arial, sans-serif", |
| "labelColor": "#666666", |
| "labelFontSize": 13, |
| "gridWidth": 1, |
| "tickColor": "#CED4DE", |
| "tickWidth": 0.2, |
| "titleColor": "#111111", |
| "gridDash": [4, 2], |
| "gridColor": "#CED4DE", |
| "labelPadding": 6, |
| "labelOverlap": True, |
| "labelFlush": False, |
| }, |
| "axisBand": {"grid": False}, |
| "axisX": {"grid": True, "tickSize": 0, "domain": False}, |
| "axisY": {"domain": False, "grid": True, "tickSize": 0}, |
| "legend": { |
| "labelFont": "Open Sans, Arial, sans-serif", |
| "titleFont": "Open Sans, Arial, sans-serif", |
| "titleFontSize": 15, |
| "labelFontSize": 13, |
| "labelColor": "#111111", |
| "padding": 1, |
| "symbolSize": 140, |
| "orient": "bottom", |
| "direction": "horizontal", |
| }, |
| "range": { |
| "category": ["#34A7F2", "#FF9800", "#664AB6", "#4EC2C0", "#F3578E", "#081079", "#0C7C68"], |
| }, |
| } |
|
|
| WB_PALETTE = WB_THEME["range"]["category"] |
|
|
|
|
| def _wb_get_mark(spec: dict) -> str: |
| """Extract the mark type string from a flat or compound Vega-Lite spec.""" |
| mark = spec.get("mark") |
| if not mark: |
| return "line" |
| if isinstance(mark, str): |
| return mark |
| return mark.get("type", "line") |
|
|
|
|
| def prepare_spec(spec: dict, chart_height: int = 340) -> dict: |
| """ |
| Python port of prepareSpec from packages/mcp-viz-core/src/prepare-spec.ts. |
| Applies the 8-guard pipeline to make any Data360 Vega-Lite spec compatible |
| with the WB visual theme and the dashboard renderer. |
| |
| Guards: |
| 1. Inline named dataset -> data.values |
| 2. Responsive sizing (width: container, configurable height) |
| 3. Suppress built-in Vega legend (unless quantitative color) |
| 3b. Strip top-level title (shown in card header above the chart) |
| 4. Strip zoom/pan params (conflicts with card controls) |
| 5. Normalize $schema to vega-lite/v5 |
| 6. Merge WB_THEME into spec.config (spec values win on conflict) |
| 7. scale.zero = False for line/area/point/tick; True for bar |
| 8. x-axis format: %Y for temporal; null title for nominal/ordinal |
| """ |
| import copy |
| out = copy.deepcopy(spec) |
| mark_type = _wb_get_mark(out) |
|
|
| |
| name = out.get("data", {}).get("name") |
| if name and out.get("datasets", {}).get(name): |
| out["data"] = {"values": out["datasets"][name]} |
| out.pop("datasets", None) |
|
|
| |
| out["width"] = "container" |
| out["height"] = chart_height |
|
|
| |
| encoding = out.get("encoding", {}) |
| color_enc = encoding.get("color", {}) |
| if color_enc and color_enc.get("type") != "quantitative": |
| color_enc["legend"] = None |
|
|
| |
| |
| |
|
|
| |
| out.pop("params", None) |
|
|
| |
| out["$schema"] = "https://vega.github.io/schema/vega-lite/v5.json" |
|
|
| |
| import copy as _copy |
| base_theme = _copy.deepcopy(WB_THEME) |
| existing_config = out.get("config", {}) |
| out["config"] = {**base_theme, **existing_config} |
|
|
| |
| y_enc = encoding.get("y", {}) |
| if y_enc: |
| if "scale" not in y_enc: |
| y_enc["scale"] = {} |
| y_enc["scale"]["zero"] = (mark_type == "bar") |
|
|
| |
| x_enc = encoding.get("x", {}) |
| if x_enc: |
| if "axis" not in x_enc: |
| x_enc["axis"] = {} |
| x_type = x_enc.get("type", "") |
| if x_type == "temporal": |
| x_enc["axis"]["format"] = "%Y" |
| x_enc["axis"]["title"] = None |
| else: |
| x_enc["axis"].pop("format", None) |
| x_enc["axis"]["title"] = None |
|
|
| return out |
|
|
|
|
| def _chartjs_quality_metric(): |
| """ |
| Dedicated G-Eval metric to evaluate Chart.js v4 configurations. |
| Focuses on visualization layout, chart suitability, theme styling, and correctness. |
| """ |
| from evals.test_chart_rules_deepeval import GEval, SingleTurnParams |
| return GEval( |
| name="Chart.js v4 Charting Suitability & Design Quality Metric", |
| criteria=""" |
| Evaluate the visual charting quality and layout correctness of the generated Chart.js v4 JSON config. Focus entirely on the visualization's design, hierarchy, and representation suitability. |
| |
| Scoring Criteria: |
| 1. ENCODING & CHART TYPE SUITABILITY (0-4): Does the selected Chart.js type ('line', 'bar', 'scatter', etc.) represent the query intent effectively? (e.g. line charts for trends, bar charts for single-year comparisons, scatter plots for correlation). |
| 2. STYLING, THEMES & READABILITY (0-4): Does the chart follow professional design guidelines? (e.g. uses colors from the World Bank palette, displays clear scale titles for axes, legend placed at the bottom, and a descriptive title is defined in options). |
| 3. DESIGN ROBUSTNESS (0-2): Is the configuration format standard (type, data, options keys), responsive, and optimized for interactive rendering inside a canvas? |
| |
| Give a final score from 0.0 to 1.0 (where >= 0.75 passes). |
| Provide: |
| - A concise critique and rationale focusing on charting quality. |
| - A dedicated section "RECOMMENDATIONS" listing concrete visual styling and charting suggestions. |
| """, |
| evaluation_params=[SingleTurnParams.INPUT, SingleTurnParams.ACTUAL_OUTPUT], |
| evaluation_steps=[ |
| "Inspect the INPUT (the requested query context and parameters).", |
| "Inspect the ACTUAL_OUTPUT (the generated Chart.js JSON config).", |
| "Verify the chart type fits the intent of the comparison (line vs bar vs scatter).", |
| "Evaluate dataset structures (labels, datasets, data points) for clean charting representation.", |
| "Assess thematic styling correctness (World Bank colors, axis titles, and options configuration).", |
| "Check responsiveness and options settings.", |
| "Generate the final score, written critique, and recommendations." |
| ], |
| threshold=0.75, |
| ) |
|
|
|
|
| def compile_deepeval_input(resolved_indicators: list, scenario: dict, rows: list) -> str: |
| """Compile a clean summary of indicator min/max/count from actual values for the DeepEval judge.""" |
| data_summary = "" |
| if rows: |
| try: |
| df_temp = pd.DataFrame(rows) |
| |
| actual_countries = sorted(list(set(df_temp["country"].dropna().tolist()))) if "country" in df_temp.columns else [] |
| actual_country_codes = sorted(list(set(df_temp["country_code"].dropna().tolist()))) if "country_code" in df_temp.columns else [] |
| if not actual_country_codes and "ref_area" in df_temp.columns: |
| actual_country_codes = sorted(list(set(df_temp["ref_area"].dropna().tolist()))) |
|
|
| actual_years = sorted(list(set(df_temp["year"].dropna().tolist()))) if "year" in df_temp.columns else [] |
| if not actual_years and "time_period" in df_temp.columns: |
| actual_years = sorted(list(set(df_temp["time_period"].dropna().tolist()))) |
|
|
| summary_parts = [] |
| if actual_countries: |
| summary_parts.append(f"Actual Countries in Retrieved Data: {', '.join(actual_countries)} ({', '.join(actual_country_codes)})") |
| if actual_years: |
| summary_parts.append(f"Actual Years in Retrieved Data: {min(actual_years)} to {max(actual_years)}") |
|
|
| for col in df_temp.columns: |
| if col not in ("year", "time_period", "country", "country_code", "ref_area", "indicator_id", "indicator_name"): |
| try: |
| non_null = df_temp[col].dropna() |
| if not non_null.empty: |
| min_val = non_null.min() |
| max_val = non_null.max() |
| summary_parts.append(f"Indicator '{col}': min={min_val}, max={max_val}, count={len(non_null)}") |
| except Exception: |
| pass |
| data_summary = "\n ".join(summary_parts) |
| except Exception: |
| pass |
|
|
| eval_input = f""" |
| Requested Indicators: |
| {json.dumps(resolved_indicators, indent=2)} |
| |
| Parameters: |
| - Countries: {scenario.get('country_code')} |
| - Start Year: {scenario.get('start_year')} |
| - End Year: {scenario.get('end_year')} |
| - Chart Type Hint: {scenario.get('chart_type')} |
| - Disaggregation Filters: {scenario.get('disaggregation_filters')} |
| |
| User Context Question: {scenario.get('user_question', 'Plot custom indicators.')} |
| |
| Dataset Summary from Retrieved Data: |
| {data_summary or "No data summary available."} |
| |
| CRITICAL EVALUATION RULE FOR MISSING DATA: |
| If a country, year, or series requested by the user is completely missing from the 'Dataset Summary from Retrieved Data' (e.g. there are no rows/values retrieved for Argentina or a specific year), this means the data is NOT available in the database. |
| Do NOT penalize the visualization engine or give a lower score for not plotting missing data. The engine can only visualize data that was actually retrieved from the database. Only evaluate the correctness of the layout, styling, and representation of the data that WAS actually retrieved. |
| """ |
| return eval_input.strip() |
|
|
|
|
| |
| |
| |
|
|
| async def run_surprise_generation(log_callback=None) -> dict: |
| """ |
| Core surprise generation routine. Generates a visual query scenario, |
| obtains data, runs Data360 viz compiler, calls direct LLM Vega-Lite generator, |
| evaluates both via DeepEval G-Eval, and saves report with a unique scenario ID. |
| """ |
| api_key = os.environ.get("OPENAI_API_KEY") |
| if not api_key: |
| return {"error": "OPENAI_API_KEY not set in environment.", "status_code": 500} |
|
|
| logs = [] |
| def log(msg: str): |
| logs.append(msg) |
| print(f"[Dashboard Log] {msg}") |
| if log_callback: |
| log_callback(msg) |
|
|
| log("Initiating Surprise-Me request...") |
|
|
| |
| past_questions = [] |
| for p in REPORTS_DIR.glob("surprise_*.json"): |
| try: |
| data = json.loads(p.read_text()) |
| if data.get("question"): |
| past_questions.append(data.get("question")) |
| except Exception: |
| pass |
| |
| past_questions = list(set(past_questions))[-15:] |
| past_questions_str = "\n".join(f"- {q}" for q in past_questions) |
|
|
| |
| CURATED_POOL = [ |
| |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_NY_GDP_PCAP_KD", "name": "GDP per capita (constant 2015 USD)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_EG_ELC_ACCS_ZS", "name": "Access to electricity (% of population)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_IT_NET_USER_ZS", "name": "Individuals using the Internet (% of population)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_SE_ADT_LITR_ZS", "name": "Literacy rate, adult total (% of people ages 15 and above)"}, |
| {"database_id": "WB_ESG", "indicator_id": "WB_ESG_EN_ATM_CO2E_PC", "name": "CO2 emissions (metric tons per capita)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_SP_POP_TOTL", "name": "Population, total"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_SL_UEM_TOTL_ZS", "name": "Unemployment, total (% of total labor force)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_SP_DYN_LE00_IN", "name": "Life expectancy at birth, total (years)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_FP_CPI_TOTL_ZG", "name": "Inflation, consumer prices (annual %)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_AG_LND_FRST_ZS", "name": "Forest area (% of land area)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_IT_CEL_SETS_P2", "name": "Mobile cellular subscriptions (per 100 people)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_NE_EXP_GNFS_ZS", "name": "Exports of goods and services (% of GDP)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_SI_POV_GINI", "name": "Gini index"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_SH_TBS_INCD", "name": "Incidence of tuberculosis (per 100,000 people)"}, |
| {"database_id": "WB_WDI", "indicator_id": "WB_WDI_SE_PRM_CMPT_ZS", "name": "Primary completion rate, total (% of relevant age group)"}, |
|
|
| |
| {"database_id": "WB_ESG", "indicator_id": "WB_ESG_EG_FEC_RNEW_ZS", "name": "Renewable energy consumption (% of total final energy consumption)"}, |
| {"database_id": "WB_ESG", "indicator_id": "WB_ESG_SH_STA_BIRT", "name": "Births attended by skilled health staff (% of total)"}, |
| {"database_id": "WB_ESG", "indicator_id": "WB_ESG_GB_XPD_RSDV_GD_ZS", "name": "Research and development expenditure (% of GDP)"}, |
|
|
| |
| {"database_id": "WB_WGI", "indicator_id": "GOV_WGI_VA", "name": "Voice and Accountability: Estimate"}, |
| {"database_id": "WB_WGI", "indicator_id": "GOV_WGI_GE", "name": "Government Effectiveness: Estimate"}, |
| {"database_id": "WB_WGI", "indicator_id": "GOV_WGI_CC", "name": "Control of Corruption: Estimate"}, |
| {"database_id": "WB_WGI", "indicator_id": "GOV_WGI_RL", "name": "Rule of Law: Estimate"}, |
| {"database_id": "WB_WGI", "indicator_id": "GOV_WGI_RQ", "name": "Regulatory Quality: Estimate"}, |
| {"database_id": "WB_WGI", "indicator_id": "GOV_WGI_PV", "name": "Political Stability and Absence of Violence/Terrorism: Estimate"}, |
|
|
| |
| {"database_id": "WB_ES", "indicator_id": "WB_ES_T_JOBS1", "name": "Jobs share"}, |
| {"database_id": "WB_ES", "indicator_id": "WB_ES_T_PERF2", "name": "Annual employment growth (%)"}, |
| {"database_id": "WB_ES", "indicator_id": "WB_ES_T_EXPT1", "name": "Percent of firms that export"}, |
| ] |
|
|
| import random |
| from data360.api import get_disaggregation, get_metadata |
|
|
| last_error_msg = "Unknown error" |
| last_status_code = 500 |
|
|
| for attempt in range(4): |
| log(f"Surprise-Me generation attempt {attempt + 1} of 4...") |
|
|
| |
| indicator_choice = random.random() |
| if indicator_choice < 0.60: |
| n_indicators = 1 |
| elif indicator_choice < 0.85: |
| n_indicators = 2 |
| else: |
| n_indicators = 3 |
|
|
| selected = [] |
| if n_indicators > 1: |
| |
| wdi_candidates = [ind for ind in CURATED_POOL if ind["database_id"] == "WB_WDI"] |
| selected = random.sample(wdi_candidates, min(len(wdi_candidates), n_indicators)) |
| else: |
| |
| selected = [random.choice(CURATED_POOL)] |
|
|
| |
| indicators_info = [] |
| resolved_indicators = [] |
|
|
| for s in selected: |
| db_id = s["database_id"] |
| ind_id = s["indicator_id"] |
|
|
| |
| try: |
| disagg = await get_disaggregation(db_id, ind_id) |
| dims = disagg.get("dimensions", []) |
| except Exception: |
| dims = [] |
|
|
| |
| cleaned_dims = [] |
| for d in dims: |
| f_name = d["field_name"] |
| if f_name not in ["REF_AREA", "TIME_PERIOD", "REGION", "UNIT_MEASURE"]: |
| val_list = d.get("field_value") or d.get("sample") or [] |
| clean_vals = [v for v in val_list if v not in ["_T", "_Z"]] |
| if clean_vals: |
| cleaned_dims.append({ |
| "field_name": f_name, |
| "label_name": d.get("label_name", f_name), |
| "values": clean_vals |
| }) |
|
|
| |
| ind_name = s["name"] |
| try: |
| meta = await get_metadata(db_id, ind_id, select_fields=["name"]) |
| if meta and meta.get("name"): |
| ind_name = meta["name"] |
| except Exception: |
| pass |
|
|
| indicators_info.append({ |
| "database_id": db_id, |
| "indicator_id": ind_id, |
| "name": ind_name, |
| "breakdown_dimensions": cleaned_dims |
| }) |
|
|
| resolved_indicators.append({ |
| "database_id": db_id, |
| "indicator_id": ind_id, |
| "name": ind_name |
| }) |
|
|
| is_multi = len(resolved_indicators) >= 2 |
|
|
| |
| |
| is_single_year = random.random() < 0.4 |
| if is_single_year: |
| year = random.randint(2015, 2022) |
| start_year = year |
| end_year = year |
| else: |
| start_year = random.randint(2010, 2017) |
| end_year = random.randint(2018, 2022) |
|
|
| |
| country_pool = ["USA", "CHN", "JPN", "DEU", "FRA", "GBR", "IND", "BRA", "ITA", "CAN", "KEN", "UGA", "RWA", "ZAF", "ESP", "MEX", "COL", "BGD"] |
| country_choice = random.random() |
| if is_multi: |
| |
| if country_choice < 0.5: |
| country_code = random.choice(country_pool) |
| else: |
| selected_countries = random.sample(country_pool, random.randint(2, 4)) |
| country_code = ";".join(selected_countries) |
| else: |
| |
| if country_choice < 0.2: |
| country_code = random.choice(country_pool) |
| elif country_choice < 0.7: |
| selected_countries = random.sample(country_pool, random.randint(2, 5)) |
| country_code = ";".join(selected_countries) |
| else: |
| |
| selected_countries = random.sample(country_pool, random.randint(8, 15)) |
| country_code = ";".join(selected_countries) |
|
|
| |
| disaggregation_filters = {} |
| available_dims = indicators_info[0]["breakdown_dimensions"] |
| if available_dims: |
| breakdown_choice = random.random() |
| if breakdown_choice < 0.4: |
| |
| dim = random.choice(available_dims) |
| disaggregation_filters[dim["field_name"]] = None |
| elif breakdown_choice < 0.8: |
| |
| dim = random.choice(available_dims) |
| val = random.choice(dim["values"]) |
| disaggregation_filters[dim["field_name"]] = val |
| else: |
| pass |
|
|
| |
| try: |
| from openai import OpenAI |
| client = OpenAI(api_key=api_key) |
|
|
| prompt = f""" |
| You are an expert World Bank Data360 query builder. |
| Generate ONE realistic natural language question (user query) that matches the following pre-selected indicators and parameter configuration. |
| |
| Selected Indicator(s): |
| {json.dumps(indicators_info, indent=2)} |
| |
| Configured Parameters: |
| - Countries: {country_code} |
| - Year Range: {start_year} to {end_year} |
| - Disaggregation/Breakdown Filters: {json.dumps(disaggregation_filters, indent=2)} |
| |
| Instructions for framing the query: |
| 1. The query MUST exactly match the parameters. For example, if the country list is "KEN;UGA", make sure the query mentions Kenya and Uganda. If it is a single year, make sure it specifies that year (e.g. "in 2020"). |
| 2. If a disaggregation filter maps to null (e.g., "COMP_BREAKDOWN_1": null), it means we want to compare all values of that dimension. Frame the question as a comparison of those values (e.g. "compare estimate and standard error of Control of Corruption..."). |
| 3. If a disaggregation filter maps to a specific value (e.g. "COMP_BREAKDOWN_1": "WGI_EST"), make sure the question specifies that value (e.g. "Voice and Accountability estimate..."). |
| 4. If no disaggregation filters are specified, do not ask for breakdowns. |
| 5. Ensure the final query is phrased naturally, like a human user would ask. Do NOT mention visual types (like line chart or bar chart) in the question. |
| |
| Avoid outputting the same queries repeatedly. Be creative and cover diverse global topics. |
| Output ONLY a JSON object containing: |
| "user_question": "Your framed question" |
| No markdown wrapping. |
| """ |
|
|
| |
| prompt += f"\n\nSeed: {time.time()}" |
| if past_questions: |
| prompt += f"\n\nYou MUST NOT generate questions that duplicate the semantic intent of any of these recent questions:\n{past_questions_str}" |
|
|
| log("Contacting LLM to frame natural language query matching parameters...") |
| response = client.chat.completions.create( |
| model="gpt-4o-mini", |
| response_format={"type": "json_object"}, |
| messages=[ |
| {"role": "system", "content": "You are a visual query scenario builder."}, |
| {"role": "user", "content": prompt} |
| ], |
| temperature=0.8, |
| ) |
| llm_res = json.loads(response.choices[0].message.content) |
| user_question = llm_res.get("user_question", "Visual query") |
|
|
| |
| scenario = { |
| "user_question": user_question, |
| "country_code": country_code, |
| "start_year": start_year, |
| "end_year": end_year, |
| "chart_type": None, |
| "disaggregation_filters": disaggregation_filters |
| } |
|
|
| log(f"LLM framed query: '{user_question}'") |
| except Exception as exc: |
| last_error_msg = f"LLM generation failed: {exc}" |
| last_status_code = 500 |
| log(f"[Attempt {attempt + 1} Error] {last_error_msg}. Retrying...") |
| continue |
|
|
| |
| try: |
| log("Executing Data360 visualization engine via MCP server on 8021...") |
| if is_multi: |
| viz_result = await call_mcp_tool_on_8021( |
| "data360_get_multi_indicator_viz_spec", |
| { |
| "indicator_ids": [ |
| {"database_id": ri["database_id"], "indicator_id": ri["indicator_id"]} |
| for ri in resolved_indicators |
| ], |
| "country_code": scenario.get("country_code"), |
| "start_year": scenario.get("start_year"), |
| "end_year": scenario.get("end_year"), |
| "chart_type": scenario.get("chart_type"), |
| "disaggregation_filters": scenario.get("disaggregation_filters"), |
| } |
| ) |
| else: |
| ri = resolved_indicators[0] |
| viz_result = await call_mcp_tool_on_8021( |
| "data360_get_viz_spec", |
| { |
| "database_id": ri["database_id"], |
| "indicator_id": ri["indicator_id"], |
| "country_code": scenario.get("country_code"), |
| "start_year": scenario.get("start_year"), |
| "end_year": scenario.get("end_year"), |
| "chart_type": scenario.get("chart_type"), |
| "disaggregation_filters": scenario.get("disaggregation_filters"), |
| } |
| ) |
|
|
| if viz_result.get("error"): |
| last_error_msg = viz_result["error"] |
| last_status_code = 400 |
| log(f"[Attempt {attempt + 1} Error] Visual engine returned: {last_error_msg}. Retrying...") |
| continue |
|
|
| spec = viz_result.get("spec", {}) |
| log(f"Chart spec generated successfully via MCP (strategy: {viz_result.get('strategy')})") |
| |
| break |
| except Exception as exc: |
| last_error_msg = f"Visualization spec building failed: {exc}" |
| last_status_code = 500 |
| log(f"[Attempt {attempt + 1} Error] {last_error_msg}. Retrying...") |
| continue |
| else: |
| |
| log(f"All 4 generation attempts failed. Last error: {last_error_msg}") |
| return {"error": last_error_msg, "status_code": last_status_code} |
|
|
| |
| name = spec.get("data", {}).get("name") |
| if name and spec.get("datasets", {}).get(name): |
| rows = spec["datasets"][name] |
| else: |
| rows = spec.get("data", {}).get("values", []) |
|
|
| |
| system_spec = prepare_spec(spec) |
| log("prepareSpec + WB Theme applied to system spec.") |
|
|
| |
| llm_chartjs = {} |
| llm_score_10 = 0.0 |
| llm_critique = "No direct LLM chart generated." |
|
|
| if rows: |
| try: |
| log("Contacting LLM to generate Chart.js v4 config for comparison...") |
| sample_data = rows |
| column_names = list(sample_data[0].keys()) if sample_data else [] |
|
|
| llm_chartjs_prompt = get_chartjs_user_prompt( |
| question=scenario['user_question'], |
| column_names=column_names, |
| sample_data=sample_data |
| ) |
|
|
| llm_response = client.chat.completions.create( |
| model="gpt-4o", |
| response_format={"type": "json_object"}, |
| messages=[ |
| {"role": "system", "content": get_chartjs_system_prompt()}, |
| {"role": "user", "content": llm_chartjs_prompt} |
| ], |
| temperature=0.2, |
| ) |
| llm_chartjs = json.loads(llm_response.choices[0].message.content) |
| log("Direct LLM Chart.js config generated.") |
| except Exception as exc: |
| log(f"Direct LLM Chart.js generation failed: {exc}") |
|
|
| |
| try: |
| from evals.test_chart_rules_deepeval import LLMTestCase |
| log("Running DeepEval G-Eval visual quality score on System Spec...") |
| metric = _zero_shot_grammar_of_graphics_metric() |
|
|
| eval_input = compile_deepeval_input(resolved_indicators, scenario, rows) |
|
|
| test_case = LLMTestCase( |
| input=eval_input.strip(), |
| actual_output=json.dumps(spec, indent=2) |
| ) |
| metric.measure(test_case) |
| score_10 = round((metric.score or 0.0) * 10, 1) |
| critique = metric.reason or "No critique provided." |
| log(f"DeepEval score: {score_10}/10") |
|
|
| if llm_chartjs: |
| log("Running DeepEval G-Eval visual quality score on LLM Chart.js config...") |
| llm_metric = _chartjs_quality_metric() |
| llm_test_case = LLMTestCase( |
| input=eval_input.strip(), |
| actual_output=json.dumps(llm_chartjs, indent=2) |
| ) |
| llm_metric.measure(llm_test_case) |
| llm_score_10 = round((llm_metric.score or 0.0) * 10, 1) |
| llm_critique = llm_metric.reason or "No critique provided." |
| log(f"Direct LLM Chart.js score: {llm_score_10}/10") |
|
|
| except Exception as exc: |
| log(f"DeepEval score failed: {exc}. Defaulting to score 0.0.") |
| score_10 = 0.0 |
| critique = f"DeepEval scoring failed: {exc}" |
|
|
| |
| timestamp_sec = int(time.time()) |
| scenario_id = f"surprise_{hashlib.md5(scenario['user_question'].encode()).hexdigest()[:8]}_{timestamp_sec}" |
| report_file = REPORTS_DIR / f"{scenario_id}.json" |
|
|
| report_data = { |
| "scenario_id": scenario_id, |
| "question": scenario["user_question"], |
| "scenario": scenario, |
| "resolved_indicators": resolved_indicators, |
| "viz_result": viz_result, |
| "system_spec": system_spec, |
| "system_score": score_10, |
| "system_critique": critique, |
| "llm_chartjs": llm_chartjs, |
| "llm_score": llm_score_10, |
| "llm_critique": llm_critique, |
| |
| "spec": system_spec, |
| "score": score_10, |
| "critique": critique, |
| "timestamp": pd.Timestamp.now().isoformat(), |
| "logs": logs |
| } |
|
|
| with open(report_file, "w") as f: |
| json.dump(report_data, f, indent=2) |
| upload_file_to_hf(report_file) |
|
|
| log("Report saved successfully. Returning to client.") |
| return report_data |
|
|
| |
| |
| |
|
|
| async def run_batch_surprise(): |
| global batch_status, batch_cancel_requested |
| batch_cancel_requested = False |
| batch_status["running"] = True |
| batch_status["current"] = 0 |
| batch_status["errors"] = [] |
|
|
| for i in range(50): |
| if batch_cancel_requested: |
| print("[Batch Run] Cancellation requested. Stopping batch loop.") |
| batch_status["errors"].append("Cancelled by user request.") |
| break |
| try: |
| print(f"[Batch Run] Starting set {i+1} of 50...") |
| await run_surprise_generation() |
| batch_status["current"] += 1 |
| |
| await asyncio.sleep(1) |
| except Exception as e: |
| batch_status["errors"].append(str(e)) |
| print(f"[Batch Error] Set {i+1} failed: {e}") |
|
|
| batch_status["running"] = False |
|
|
| |
| |
| |
|
|
| @app.get("/api/history") |
| def get_history(): |
| """List past generated surprise and custom charts.""" |
| reports = [] |
| for prefix in ("surprise_", "custom_"): |
| for p in REPORTS_DIR.glob(f"{prefix}*.json"): |
| try: |
| data = json.loads(p.read_text()) |
| scenario_id = data.get("scenario_id") |
|
|
| |
| sys_vc_score = None |
| llm_vc_score = None |
| if scenario_id: |
| vc_file = REPORTS_DIR / f"visual_critique_{scenario_id}.json" |
| if vc_file.exists(): |
| try: |
| vc_data = json.loads(vc_file.read_text()) |
| sys_vc = vc_data.get("system_visual_critique") |
| if isinstance(sys_vc, dict): |
| sys_vc_score = sys_vc.get("score") |
| llm_vc = vc_data.get("llm_visual_critique") |
| if isinstance(llm_vc, dict): |
| llm_vc_score = llm_vc.get("score") |
| except Exception: |
| pass |
|
|
| reports.append({ |
| "filename": p.name, |
| "scenario_id": scenario_id, |
| "question": data.get("question"), |
| "score": data.get("system_score", data.get("score", 0.0)), |
| "llm_score": data.get("llm_score", 0.0), |
| "sys_vc_score": sys_vc_score, |
| "llm_vc_score": llm_vc_score, |
| "timestamp": data.get("timestamp"), |
| }) |
| except Exception: |
| pass |
| |
| reports.sort(key=lambda x: x.get("timestamp", ""), reverse=True) |
| return reports |
|
|
|
|
| @app.get("/api/reports/{filename}") |
| def get_report(filename: str): |
| path = REPORTS_DIR / filename |
| if not path.exists(): |
| return JSONResponse(status_code=404, content={"error": "Report not found"}) |
| return json.loads(path.read_text()) |
|
|
|
|
| @app.post("/api/surprise-me") |
| async def surprise_me(): |
| """Trigger a single Surprise-Me run.""" |
| res = await run_surprise_generation() |
| if "error" in res and res.get("status_code"): |
| return JSONResponse(status_code=res["status_code"], content={"error": res["error"]}) |
| return res |
|
|
|
|
| async def parse_query_with_llm(question: str, indicators_info: list, api_key: str) -> dict: |
| """Use GPT-4o-mini to parse a natural language question into scenario parameters.""" |
| if not api_key: |
| return {} |
|
|
| from openai import OpenAI |
| client = OpenAI(api_key=api_key) |
|
|
| prompt = f""" |
| You are an expert World Bank Data360 query parser. |
| Your task is to parse the natural language query: "{question}" |
| into structured parameters matching the provided indicators: |
| {json.dumps(indicators_info, indent=2)} |
| |
| You MUST output a JSON object with: |
| 1. `country_code`: A semi-colon separated string of ISO 3-letter codes for the countries mentioned in the query (e.g. "ARG;CHL" for Argentina and Chile). |
| 2. `start_year`: Start year (integer) mentioned in the query (e.g. 2015). |
| 3. `end_year`: End year (integer) mentioned in the query (e.g. 2021). |
| 4. `chart_type`: Visual hint. If the query explicitly asks for a "bar", "line", "map", etc., specify it. Otherwise, set to null. |
| 5. `disaggregation_filters`: A JSON object mapping breakdown dimension keys to null or specific values if requested in the query. |
| |
| Output ONLY the JSON object. No markdown wrapping. |
| """ |
|
|
| try: |
| response = client.chat.completions.create( |
| model="gpt-4o-mini", |
| response_format={"type": "json_object"}, |
| messages=[ |
| {"role": "system", "content": "You are a query parser."}, |
| {"role": "user", "content": prompt} |
| ], |
| temperature=0.0, |
| ) |
| return json.loads(response.choices[0].message.content) |
| except Exception as e: |
| print(f"[Query Parser Error] {e}") |
| return {} |
| @app.post("/api/reports/{scenario_id}/delete") |
| async def delete_report(scenario_id: str): |
| """ |
| Delete a past report and all related artifacts (JSON, G-Eval critique, screenshots) |
| both locally and from the Hugging Face dataset if configured. |
| """ |
| |
| safe_id = "".join([c for c in scenario_id if c.isalnum() or c in ("-", "_")]) |
| if not safe_id: |
| return JSONResponse(status_code=400, content={"error": "Invalid scenario ID"}) |
|
|
| |
| artifacts = [ |
| f"{safe_id}.json", |
| f"visual_critique_{safe_id}.json", |
| f"render_{safe_id}_system.png", |
| f"render_{safe_id}_llm.png", |
| ] |
|
|
| deleted_local = [] |
| errors = [] |
|
|
| |
| for art in artifacts: |
| local_path = REPORTS_DIR / art |
| if local_path.exists(): |
| try: |
| local_path.unlink() |
| deleted_local.append(art) |
| print(f"[Delete] Deleted local file: {local_path}") |
| except Exception as e: |
| errors.append(f"Failed to delete local file {art}: {e}") |
|
|
| |
| if deleted_local and HF_TOKEN and HF_DATASET_ID: |
| try: |
| from huggingface_hub import HfApi |
| api = HfApi(token=HF_TOKEN) |
| for art in deleted_local: |
| try: |
| |
| api.delete_file( |
| path_in_repo=art, |
| repo_id=HF_DATASET_ID, |
| repo_type="dataset" |
| ) |
| print(f"[Delete] Deleted file from HF dataset: {art}") |
| except Exception as hf_e: |
| |
| print(f"[Delete] HF delete warning for {art}: {hf_e}") |
| except Exception as api_e: |
| errors.append(f"Hugging Face API deletion failed: {api_e}") |
|
|
| if errors: |
| return JSONResponse( |
| status_code=207, |
| content={ |
| "status": "partial", |
| "deleted": deleted_local, |
| "errors": errors, |
| } |
| ) |
|
|
| return {"status": "success", "deleted": deleted_local} |
|
|
|
|
| @app.post("/api/reports/{filename}/rerun") |
| async def rerun_report(filename: str): |
| """ |
| Rerun a past report: compile with the latest Data360 visual routing |
| rules and re-score both the engine and direct LLM outputs. |
| """ |
| path = REPORTS_DIR / filename |
| if not path.exists(): |
| return JSONResponse(status_code=404, content={"error": "Report not found"}) |
|
|
| try: |
| data = json.loads(path.read_text()) |
| except Exception as exc: |
| return JSONResponse(status_code=500, content={"error": f"Failed to parse report: {exc}"}) |
|
|
| api_key = os.environ.get("OPENAI_API_KEY") |
| if not api_key: |
| return JSONResponse(status_code=500, content={"error": "OPENAI_API_KEY not set in environment."}) |
|
|
| logs = [] |
| def log(msg: str): |
| logs.append(msg) |
| print(f"[Rerun Log] {msg}") |
|
|
| log(f"Rerunning past report scenario: '{data.get('question')}'...") |
|
|
| question = data["question"] |
| resolved_indicators = data["resolved_indicators"] |
| scenario = data.get("scenario", {}) |
|
|
| |
| country_code = scenario.get("country_code") |
| start_year = scenario.get("start_year") |
| end_year = scenario.get("end_year") |
| chart_type = scenario.get("chart_type") |
| disaggregation_filters = scenario.get("disaggregation_filters") |
|
|
| |
| |
| num_countries = len(country_code.replace(",", ";").split(";")) if country_code else 0 |
| if not country_code or num_countries > 10: |
| log(f"Detected missing or suspicious country count ({num_countries}). Attempting to parse query with LLM...") |
| parsed_params = await parse_query_with_llm(question, resolved_indicators, api_key) |
| if parsed_params: |
| country_code = parsed_params.get("country_code") |
| start_year = parsed_params.get("start_year") or start_year |
| end_year = parsed_params.get("end_year") or end_year |
| chart_type = parsed_params.get("chart_type") or chart_type |
| disaggregation_filters = parsed_params.get("disaggregation_filters") or disaggregation_filters |
| log(f"LLM parsed parameters: country_code={country_code}, start_year={start_year}, end_year={end_year}, chart_type={chart_type}") |
|
|
| |
| if chart_type: |
| q = question.lower() |
| hint_lower = chart_type.lower() |
| has_keyword = False |
| if "bar" in hint_lower or "column" in hint_lower: |
| has_keyword = "bar" in q or "column" in q or "grouped" in q or "stacked" in q |
| elif "line" in hint_lower or "trend" in hint_lower: |
| has_keyword = "line" in q or "trend" in q or "over time" in q |
| elif "map" in hint_lower or "choropleth" in hint_lower: |
| has_keyword = "map" in q or "choropleth" in q |
| elif "scatter" in hint_lower: |
| has_keyword = "scatter" in q or "correlation" in q or "versus" in q or "vs" in q |
| elif "heatmap" in hint_lower: |
| has_keyword = "heatmap" in q or "grid" in q |
|
|
| if not has_keyword: |
| log(f"Stripping unrequested chart hint '{chart_type}' from rerun scenario") |
| chart_type = None |
|
|
| |
| scenario_id = data.get("scenario_id") |
| if scenario_id: |
| sys_img_path = REPORTS_DIR / f"render_{scenario_id}_system.png" |
| llm_img_path = REPORTS_DIR / f"render_{scenario_id}_llm.png" |
| critique_file = REPORTS_DIR / f"visual_critique_{scenario_id}.json" |
| for p in (sys_img_path, llm_img_path, critique_file): |
| if p.exists(): |
| try: |
| p.unlink() |
| log(f"Cleaned up stale file: {p.name}") |
| except Exception as e: |
| log(f"Failed to clean up stale file {p.name}: {e}") |
|
|
| |
| if not country_code and "spec" in data and "data" in data["spec"]: |
| rows = data["spec"]["data"].get("values", []) |
| if rows: |
| try: |
| start_year = min(int(r["year"]) for r in rows if "year" in r) |
| end_year = max(int(r["year"]) for r in rows if "year" in r) |
| |
| country_names = list(set(r["country"] for r in rows if "country" in r)) |
| log(f"Fallback extracted years: {start_year}-{end_year}, countries: {country_names}") |
|
|
| |
| resolved_codes = [] |
| for name in country_names: |
| code = await _resolve_country_code(name) |
| if code: |
| resolved_codes.append(code) |
| if resolved_codes: |
| country_code = ";".join(resolved_codes) |
| log(f"Resolved fallback country codes: {country_code}") |
| except Exception as e: |
| log(f"Failed to resolve fallback country codes: {e}") |
|
|
| is_multi = len(resolved_indicators) >= 2 |
|
|
| |
| try: |
| log("Executing Data360 visualization engine via MCP server on 8021...") |
| if is_multi: |
| viz_result = await call_mcp_tool_on_8021( |
| "data360_get_multi_indicator_viz_spec", |
| { |
| "indicator_ids": [ |
| {"database_id": ri["database_id"], "indicator_id": ri["indicator_id"]} |
| for ri in resolved_indicators |
| ], |
| "country_code": country_code, |
| "start_year": start_year, |
| "end_year": end_year, |
| "chart_type": chart_type, |
| "disaggregation_filters": disaggregation_filters, |
| } |
| ) |
| else: |
| ri = resolved_indicators[0] |
| viz_result = await call_mcp_tool_on_8021( |
| "data360_get_viz_spec", |
| { |
| "database_id": ri["database_id"], |
| "indicator_id": ri["indicator_id"], |
| "country_code": country_code, |
| "start_year": start_year, |
| "end_year": end_year, |
| "chart_type": chart_type, |
| "disaggregation_filters": disaggregation_filters, |
| } |
| ) |
|
|
| if viz_result.get("error"): |
| log(f"Visualization engine returned error: {viz_result['error']}") |
| return JSONResponse(status_code=400, content={"error": viz_result["error"]}) |
|
|
| spec = viz_result.get("spec", {}) |
| log(f"Chart spec generated successfully via MCP (strategy: {viz_result.get('strategy')})") |
| except Exception as exc: |
| return JSONResponse(status_code=500, content={"error": f"Visualization spec building failed: {exc}"}) |
|
|
| |
| name = spec.get("data", {}).get("name") |
| if name and spec.get("datasets", {}).get(name): |
| rows = spec["datasets"][name] |
| else: |
| rows = spec.get("data", {}).get("values", []) |
|
|
| |
| system_spec = prepare_spec(spec) |
| log("prepareSpec + WB Theme applied to system spec.") |
|
|
| |
| llm_chartjs = {} |
| llm_score_10 = 0.0 |
| llm_critique = "No direct LLM chart generated." |
|
|
| if rows: |
| try: |
| from openai import OpenAI |
| client = OpenAI(api_key=api_key) |
| log("Contacting LLM to generate Chart.js v4 config for comparison...") |
| sample_data = rows |
| column_names = list(sample_data[0].keys()) if sample_data else [] |
|
|
| llm_chartjs_prompt = get_chartjs_user_prompt( |
| question=question, |
| column_names=column_names, |
| sample_data=sample_data |
| ) |
|
|
| llm_response = client.chat.completions.create( |
| model="gpt-4o", |
| response_format={"type": "json_object"}, |
| messages=[ |
| {"role": "system", "content": get_chartjs_system_prompt()}, |
| {"role": "user", "content": llm_chartjs_prompt} |
| ], |
| temperature=0.2, |
| ) |
| llm_chartjs = json.loads(llm_response.choices[0].message.content) |
| log("Direct LLM Chart.js config generated.") |
| except Exception as exc: |
| log(f"Direct LLM Chart.js generation failed: {exc}") |
|
|
| |
| try: |
| from evals.test_chart_rules_deepeval import LLMTestCase |
| log("Running DeepEval G-Eval visual quality score on System Spec...") |
| metric = _zero_shot_grammar_of_graphics_metric() |
|
|
| scenario = { |
| "country_code": country_code, |
| "start_year": start_year, |
| "end_year": end_year, |
| "chart_type": chart_type, |
| "disaggregation_filters": disaggregation_filters, |
| "user_question": question |
| } |
| eval_input = compile_deepeval_input(resolved_indicators, scenario, rows) |
|
|
| test_case = LLMTestCase( |
| input=eval_input.strip(), |
| actual_output=json.dumps(spec, indent=2) |
| ) |
| metric.measure(test_case) |
| score_10 = round((metric.score or 0.0) * 10, 1) |
| critique = metric.reason or "No critique provided." |
| log(f"DeepEval score: {score_10}/10") |
|
|
| if llm_chartjs: |
| log("Running DeepEval G-Eval visual quality score on LLM Chart.js config...") |
| llm_metric = _chartjs_quality_metric() |
| llm_test_case = LLMTestCase( |
| input=eval_input.strip(), |
| actual_output=json.dumps(llm_chartjs, indent=2) |
| ) |
| llm_metric.measure(llm_test_case) |
| llm_score_10 = round((llm_metric.score or 0.0) * 10, 1) |
| llm_critique = llm_metric.reason or "No critique provided." |
| log(f"Direct LLM Chart.js score: {llm_score_10}/10") |
|
|
| except Exception as exc: |
| log(f"DeepEval score failed: {exc}. Defaulting to score 0.0.") |
| score_10 = 0.0 |
| critique = f"DeepEval scoring failed: {exc}" |
|
|
| |
| report_data = { |
| "scenario_id": data["scenario_id"], |
| "question": question, |
| "scenario": { |
| "user_question": question, |
| "country_code": country_code, |
| "start_year": start_year, |
| "end_year": end_year, |
| "chart_type": chart_type, |
| "disaggregation_filters": disaggregation_filters |
| }, |
| "resolved_indicators": resolved_indicators, |
| "viz_result": viz_result, |
| "system_spec": system_spec, |
| "system_score": score_10, |
| "system_critique": critique, |
| "llm_chartjs": llm_chartjs, |
| "llm_score": llm_score_10, |
| "llm_critique": llm_critique, |
| "spec": system_spec, |
| "score": score_10, |
| "critique": critique, |
| "timestamp": data["timestamp"], |
| "logs": logs |
| } |
|
|
| with open(path, "w") as f: |
| json.dump(report_data, f, indent=2) |
| upload_file_to_hf(path) |
|
|
| log("Report rerun completed and saved successfully.") |
| return report_data |
|
|
|
|
| @app.post("/api/batch-surprise") |
| async def trigger_batch_surprise(background_tasks: BackgroundTasks): |
| """Trigger background execution of 50 surprise sets.""" |
| if batch_status["running"]: |
| return JSONResponse(status_code=400, content={"error": "A batch run is already in progress."}) |
| background_tasks.add_task(run_batch_surprise) |
| return {"status": "Batch surprise-me run started in the background."} |
|
|
|
|
| class ImagePayload(BaseModel): |
| scenario_id: str |
| system_png_base64: str |
| llm_png_base64: str |
|
|
|
|
| @app.post("/api/save-rendered-images") |
| async def save_rendered_images(payload: ImagePayload): |
| """Decode and save client-side rendered system and LLM chart screenshots to disk.""" |
| try: |
| import base64 |
| |
| if payload.system_png_base64.startswith("data:image/png;base64,"): |
| sys_data = base64.b64decode(payload.system_png_base64.split(",")[1]) |
| sys_path = REPORTS_DIR / f"render_{payload.scenario_id}_system.png" |
| sys_path.write_bytes(sys_data) |
| print(f"[Dashboard Log] System chart screenshot saved: {sys_path.name}") |
| upload_file_to_hf(sys_path) |
|
|
| |
| if payload.llm_png_base64.startswith("data:image/png;base64,"): |
| llm_data = base64.b64decode(payload.llm_png_base64.split(",")[1]) |
| llm_path = REPORTS_DIR / f"render_{payload.scenario_id}_llm.png" |
| llm_path.write_bytes(llm_data) |
| print(f"[Dashboard Log] LLM chart screenshot saved: {llm_path.name}") |
| upload_file_to_hf(llm_path) |
|
|
| |
| report_files = list(REPORTS_DIR.glob(f"*{payload.scenario_id}*.json")) |
| if report_files: |
| report_file = report_files[0] |
| try: |
| data = json.loads(report_file.read_text()) |
| data["system_image_path"] = f"render_{payload.scenario_id}_system.png" |
| data["llm_image_path"] = f"render_{payload.scenario_id}_llm.png" |
| report_file.write_text(json.dumps(data, indent=2)) |
| upload_file_to_hf(report_file) |
| except Exception as e: |
| print(f"[Error] Failed to update report with image paths: {e}") |
|
|
| return {"status": "success", "message": "Rendered charts stored successfully."} |
| except Exception as exc: |
| return JSONResponse(status_code=500, content={"error": f"Failed to save screenshots: {exc}"}) |
|
|
|
|
| @app.get("/api/reports/{scenario_id}/visual-critique") |
| def get_visual_critique(scenario_id: str): |
| """Return the cached visual critique JSON for a scenario if it exists.""" |
| critique_file = REPORTS_DIR / f"visual_critique_{scenario_id}.json" |
| if not critique_file.exists(): |
| return JSONResponse(status_code=404, content={"error": "No visual critique found for this scenario."}) |
| return json.loads(critique_file.read_text()) |
|
|
|
|
| @app.post("/api/reports/{scenario_id}/visual-critique") |
| async def run_visual_critique(scenario_id: str): |
| """Run GPT-4o Vision visual quality audit on the saved chart screenshots for a scenario.""" |
| |
| critique_file = REPORTS_DIR / f"visual_critique_{scenario_id}.json" |
| if critique_file.exists(): |
| try: |
| return json.loads(critique_file.read_text()) |
| except Exception: |
| pass |
|
|
| import base64 |
|
|
| api_key = os.environ.get("OPENAI_API_KEY") |
| if not api_key: |
| return JSONResponse(status_code=500, content={"error": "OPENAI_API_KEY not set in environment."}) |
|
|
| sys_img_path = REPORTS_DIR / f"render_{scenario_id}_system.png" |
| llm_img_path = REPORTS_DIR / f"render_{scenario_id}_llm.png" |
|
|
| if not sys_img_path.exists() and not llm_img_path.exists(): |
| return JSONResponse( |
| status_code=404, |
| content={"error": f"No rendered screenshots found for scenario '{scenario_id}'. Generate the chart first so screenshots are captured."} |
| ) |
|
|
| |
| report_files = list(REPORTS_DIR.glob(f"*{scenario_id}*.json")) |
| query = "Visualize data" |
| actual_countries_msg = "" |
| for rf in report_files: |
| if "visual_critique" not in rf.name and "viz_score" not in rf.name: |
| try: |
| rd = json.loads(rf.read_text()) |
| query = rd.get("question", rd.get("query", "Visualize data")) |
|
|
| |
| spec = rd.get("spec", {}) |
| rows = [] |
| name = spec.get("data", {}).get("name") |
| if name and spec.get("datasets", {}).get(name): |
| rows = spec["datasets"][name] |
| else: |
| rows = spec.get("data", {}).get("values", []) |
|
|
| if rows: |
| df_temp = pd.DataFrame(rows) |
| actual_countries = sorted(list(set(df_temp["country"].dropna().tolist()))) if "country" in df_temp.columns else [] |
| actual_country_codes = sorted(list(set(df_temp["country_code"].dropna().tolist()))) if "country_code" in df_temp.columns else [] |
| if not actual_country_codes and "ref_area" in df_temp.columns: |
| actual_country_codes = sorted(list(set(df_temp["ref_area"].dropna().tolist()))) |
|
|
| if actual_countries: |
| actual_countries_msg = f"Actually retrieved countries in database: {', '.join(actual_countries)} ({', '.join(actual_country_codes)})" |
| break |
| except Exception: |
| pass |
|
|
| audit_prompt = f"""You are an expert data visualization design auditor. Evaluate the actual rendered chart screenshot based on the user query and data availability. |
| |
| User Query Context: "{query}" |
| {actual_countries_msg} |
| |
| Audit Guidelines: |
| 1. VISUAL REPRESENTATION (0-4 points): Does the chart type fit the data layout? (e.g. line for trends, bar for comparison). Do the data lines/bars flatline at the zero axis or compress visual variation? |
| 2. TYPOGRAPHY & DESIGN (0-4 points): Are titles, subtitles, axis labels, and legends readable? Are there any overlapping text labels or clipping issues? |
| 3. THEME CONFORMANCE (0-2 points): Does it conform to clean, professional styling (approved color palette, subtle gridlines, clean layout bounds)? |
| |
| CRITICAL EVALUATION RULE FOR MISSING DATA: |
| If some requested countries or series are missing from the chart because they are not listed in 'Actually retrieved countries in database', do NOT penalize the visualization engine or give a lower score for not plotting them. The engine can only plot the data that was actually retrieved from the database. Only evaluate the layout, design, and representation quality of the data that was actually retrieved. |
| |
| Return a single JSON object containing: |
| - "score": A float from 0.0 to 10.0 (sum of the audit points). |
| - "critique": A detailed critique paragraph justifying the score based on visual evidence in the screenshot. |
| - "recommendations": A list of specific design improvements.""" |
|
|
| def _judge(img_path: Path) -> dict: |
| from PIL import Image |
| from deepeval.metrics import GEval |
| from deepeval.test_case import LLMTestCase, MLLMImage, SingleTurnParams |
|
|
| temp_jpg_path = img_path.with_suffix(".temp.jpg") |
|
|
| |
| with Image.open(img_path) as img: |
| |
| background = Image.new("RGB", img.size, (255, 255, 255)) |
|
|
| |
| if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info): |
| background.paste(img, mask=img.convert("RGBA").split()[3]) |
| else: |
| background.paste(img) |
|
|
| |
| background.save(temp_jpg_path, format="JPEG", quality=90) |
|
|
| try: |
| |
| visual_metric = GEval( |
| name="Visual Chart Quality Audit", |
| criteria=( |
| "Evaluate the actual rendered chart screenshot image based on the query. " |
| "You must evaluate layout, typography, design hierarchy, and color scheme. " |
| "You MUST structure the evaluation reason as a valid JSON object string. " |
| "The JSON object must contain exactly these keys:\n" |
| "- 'critique': A detailed paragraph justifying the score based on visual evidence in the screenshot.\n" |
| "- 'recommendations': A list of specific design improvements (as strings).\n" |
| "Ensure the output reason is only the raw JSON string and does not contain markdown code fences (e.g. ```json ... ```)." |
| ), |
| evaluation_params=[SingleTurnParams.INPUT], |
| model="gpt-4o" |
| ) |
|
|
| |
| image_obj = MLLMImage(url=str(temp_jpg_path), local=True) |
| test_case = LLMTestCase( |
| input=f"User Query Context: '{query}'\n{actual_countries_msg}\nChart screenshot: {image_obj}" |
| ) |
|
|
| visual_metric.measure(test_case) |
|
|
| |
| score_val = (visual_metric.score or 0.0) * 10.0 |
| reason_text = (visual_metric.reason or "").strip() |
|
|
| |
| try: |
| clean_reason = reason_text |
| if clean_reason.startswith("```"): |
| lines = clean_reason.splitlines() |
| if len(lines) > 2: |
| clean_reason = "\n".join(lines[1:-1]) |
| parsed = json.loads(clean_reason) |
| parsed["score"] = score_val |
| return parsed |
| except Exception as e: |
| print(f"[Visual Critique] Failed to parse JSON from G-Eval reason: {e}. Raw reason: {reason_text}") |
| return { |
| "score": score_val, |
| "critique": reason_text, |
| "recommendations": [] |
| } |
| finally: |
| if temp_jpg_path.exists(): |
| try: |
| temp_jpg_path.unlink() |
| except Exception: |
| pass |
|
|
| critique_results = { |
| "scenario_id": scenario_id, |
| "query": query, |
| "system_visual_critique": None, |
| "llm_visual_critique": None |
| } |
|
|
| try: |
| if sys_img_path.exists(): |
| print(f"[Visual Critique] Auditing system render for {scenario_id}...") |
| critique_results["system_visual_critique"] = _judge(sys_img_path) |
| print(f"[Visual Critique] System score: {critique_results['system_visual_critique'].get('score')}/10") |
| except Exception as exc: |
| critique_results["system_visual_critique"] = {"score": 0.0, "critique": f"Audit failed: {exc}", "recommendations": []} |
|
|
| try: |
| if llm_img_path.exists(): |
| print(f"[Visual Critique] Auditing LLM render for {scenario_id}...") |
| critique_results["llm_visual_critique"] = _judge(llm_img_path) |
| print(f"[Visual Critique] LLM score: {critique_results['llm_visual_critique'].get('score')}/10") |
| except Exception as exc: |
| critique_results["llm_visual_critique"] = {"score": 0.0, "critique": f"Audit failed: {exc}", "recommendations": []} |
|
|
| |
| critique_file = REPORTS_DIR / f"visual_critique_{scenario_id}.json" |
| critique_file.write_text(json.dumps(critique_results, indent=2)) |
| print(f"[Visual Critique] Saved critique to {critique_file.name}") |
| upload_file_to_hf(critique_file) |
|
|
| return critique_results |
|
|
|
|
| @app.get("/api/batch-status") |
| def get_batch_status(): |
| """Get current progress of the background batch run.""" |
| return batch_status |
|
|
|
|
| @app.post("/api/batch-cancel") |
| def cancel_batch_surprise(): |
| """Cancel the active background batch run.""" |
| global batch_cancel_requested |
| if not batch_status["running"]: |
| return {"status": "No batch run is currently active."} |
| batch_cancel_requested = True |
| return {"status": "Batch surprise-me run cancellation requested."} |
|
|
|
|
| @app.get("/api/search-indicators") |
| async def search_indicators(query: str): |
| """Search for database indicators matching the text query.""" |
| try: |
| from data360 import api as data360_api |
| res = await data360_api.search(query=query, limit=10) |
|
|
| if hasattr(res, "error") and res.error: |
| return {"error": res.error} |
| if isinstance(res, dict) and res.get("error"): |
| return {"error": res.get("error")} |
|
|
| indicators = [] |
| if hasattr(res, "indicators") and res.indicators: |
| for ind in res.indicators: |
| indicators.append({ |
| "database_id": ind.database_id, |
| "indicator_id": ind.idno, |
| "name": ind.name |
| }) |
| elif isinstance(res, dict) and res.get("indicators"): |
| for ind in res["indicators"]: |
| indicators.append({ |
| "database_id": ind.get("database_id"), |
| "indicator_id": ind.get("idno"), |
| "name": ind.get("name") |
| }) |
| return indicators |
| except Exception as exc: |
| return JSONResponse(status_code=500, content={"error": str(exc)}) |
| @app.get("/api/search-countries") |
| async def search_countries(query: str): |
| """Search for country/economy codes matching the query string.""" |
| try: |
| from data360.providers import get_codelist_manager |
| cm = get_codelist_manager() |
| matches = await cm.find_value("REF_AREA", query, limit=10) |
| return matches |
| except Exception as exc: |
| return JSONResponse(status_code=500, content={"error": str(exc)}) |
|
|
| @app.get("/api/groups") |
| async def get_all_groups(): |
| """Retrieve all FMR groups filtering to region, income, and lending types.""" |
| try: |
| from data360.providers import get_group_hierarchy_manager |
| ghm = get_group_hierarchy_manager() |
| groups = [] |
| for code, info in ghm._groups.items(): |
| if info["type"] in {"REGION", "INCOME", "LENDING", "OTHER"}: |
| groups.append({ |
| "code": code, |
| "name": info["name"], |
| "type": info["type"], |
| "count": len(info["countries"]) |
| }) |
| groups.sort(key=lambda x: x["name"]) |
| return groups |
| except Exception as exc: |
| return JSONResponse(status_code=500, content={"error": str(exc)}) |
|
|
|
|
| @app.get("/api/groups/{group_code}/expand") |
| async def expand_group_endpoint(group_code: str): |
| """Retrieve member country codes for a given FMR group.""" |
| try: |
| from data360.providers import get_group_hierarchy_manager |
| ghm = get_group_hierarchy_manager() |
| return ghm.expand_group(group_code) |
| except Exception as exc: |
| return JSONResponse(status_code=500, content={"error": str(exc)}) |
|
|
|
|
| @app.get("/api/indicator-disaggregation") |
| async def get_disaggregation_options(database_id: str, indicator_id: str): |
| """Retrieve available timeframe, geography, and dimension breakdowns.""" |
| try: |
| from data360 import api as data360_api |
| res = await data360_api.get_disaggregation( |
| database_id=database_id, |
| indicator_id=indicator_id |
| ) |
| return res |
| except Exception as exc: |
| return JSONResponse(status_code=500, content={"error": str(exc)}) |
|
|
|
|
| class CustomChartRequest(BaseModel): |
| database_id: str |
| indicator_id: str |
| indicator_name: str |
| country_code: str |
| start_year: int |
| end_year: int |
| chart_type: str | None = None |
| disaggregation_filters: dict[str, Any] = {} |
|
|
|
|
| @app.post("/api/custom-chart") |
| async def generate_custom_chart(req: CustomChartRequest): |
| """Generate system Vega-Lite and direct LLM Chart.js specs for a custom user-defined query.""" |
| api_key = os.environ.get("OPENAI_API_KEY") |
| if not api_key: |
| return JSONResponse(status_code=500, content={"error": "OPENAI_API_KEY not set in environment."}) |
|
|
| logs = [] |
| def log(msg: str): |
| print(f"[Custom Chart] {msg}") |
| logs.append(msg) |
|
|
| database_id = req.database_id |
| indicator_id = req.indicator_id |
| indicator_name = req.indicator_name |
| country_code = req.country_code.strip() if req.country_code else None |
| if not country_code: |
| country_code = None |
| start_year = req.start_year |
| end_year = req.end_year |
| chart_type = req.chart_type if req.chart_type else None |
| disaggregation_filters = req.disaggregation_filters |
|
|
| resolved_indicators = [{ |
| "database_id": database_id, |
| "indicator_id": indicator_id, |
| "name": indicator_name |
| }] |
|
|
| log(f"Compiling spec for custom indicator request: {indicator_id} ({indicator_name})") |
|
|
| try: |
| log("Executing Data360 visualization engine via MCP server on 8021...") |
| viz_result = await call_mcp_tool_on_8021( |
| "data360_get_viz_spec", |
| { |
| "database_id": database_id, |
| "indicator_id": indicator_id, |
| "country_code": country_code, |
| "start_year": start_year, |
| "end_year": end_year, |
| "chart_type": chart_type, |
| "disaggregation_filters": disaggregation_filters, |
| } |
| ) |
|
|
| if viz_result.get("error"): |
| log(f"Visualization engine returned error: {viz_result['error']}") |
| return JSONResponse(status_code=400, content={"error": viz_result["error"]}) |
|
|
| spec = viz_result.get("spec", {}) |
| log(f"Chart spec generated successfully via MCP (strategy: {viz_result.get('strategy')})") |
| except Exception as exc: |
| return JSONResponse(status_code=500, content={"error": f"Visualization spec building failed: {exc}"}) |
|
|
| |
| name = spec.get("data", {}).get("name") |
| if name and spec.get("datasets", {}).get(name): |
| rows = spec["datasets"][name] |
| else: |
| rows = spec.get("data", {}).get("values", []) |
|
|
| |
| system_spec = prepare_spec(spec) |
| log("prepareSpec + WB Theme applied to system spec.") |
|
|
| |
| llm_chartjs = {} |
| llm_score_10 = 0.0 |
| llm_critique = "No direct LLM chart generated." |
|
|
| if rows: |
| try: |
| from openai import OpenAI |
| client = OpenAI(api_key=api_key) |
| log("Contacting LLM to generate Chart.js v4 config for comparison...") |
| sample_data = rows |
| column_names = list(sample_data[0].keys()) if sample_data else [] |
|
|
| llm_chartjs_prompt = get_chartjs_user_prompt( |
| question=f"Plot the indicator '{indicator_name}' for countries '{country_code}' from {start_year} to {end_year}.", |
| column_names=column_names, |
| sample_data=sample_data |
| ) |
|
|
| llm_response = client.chat.completions.create( |
| model="gpt-4o", |
| response_format={"type": "json_object"}, |
| messages=[ |
| {"role": "system", "content": get_chartjs_system_prompt()}, |
| {"role": "user", "content": llm_chartjs_prompt} |
| ], |
| temperature=0.2, |
| ) |
| llm_chartjs = json.loads(llm_response.choices[0].message.content) |
| log("Direct LLM Chart.js config generated.") |
| except Exception as exc: |
| log(f"Direct LLM Chart.js generation failed: {exc}") |
|
|
| |
| try: |
| from evals.test_chart_rules_deepeval import LLMTestCase |
| log("Running DeepEval G-Eval visual quality score on System Spec...") |
| metric = _zero_shot_grammar_of_graphics_metric() |
|
|
| scenario = { |
| "country_code": country_code, |
| "start_year": start_year, |
| "end_year": end_year, |
| "chart_type": chart_type, |
| "disaggregation_filters": disaggregation_filters, |
| "user_question": f"Plot custom indicator chart: {indicator_name}" |
| } |
| eval_input = compile_deepeval_input(resolved_indicators, scenario, rows) |
|
|
| test_case = LLMTestCase( |
| input=eval_input.strip(), |
| actual_output=json.dumps(spec, indent=2) |
| ) |
| metric.measure(test_case) |
| score_10 = round((metric.score or 0.0) * 10, 1) |
| critique = metric.reason or "No critique provided." |
| log(f"DeepEval score: {score_10}/10") |
|
|
| if llm_chartjs: |
| log("Running DeepEval G-Eval visual quality score on LLM Chart.js config...") |
| llm_metric = _chartjs_quality_metric() |
| llm_test_case = LLMTestCase( |
| input=eval_input.strip(), |
| actual_output=json.dumps(llm_chartjs, indent=2) |
| ) |
| llm_metric.measure(llm_test_case) |
| llm_score_10 = round((llm_metric.score or 0.0) * 10, 1) |
| llm_critique = llm_metric.reason or "No critique provided." |
| log(f"Direct LLM Chart.js score: {llm_score_10}/10") |
|
|
| except Exception as exc: |
| log(f"DeepEval score failed: {exc}. Defaulting to score 0.0.") |
| score_10 = 0.0 |
| critique = f"DeepEval scoring failed: {exc}" |
|
|
| |
| timestamp_sec = int(time.time()) |
| scenario_id = f"custom_{hashlib.md5(indicator_id.encode()).hexdigest()[:8]}_{timestamp_sec}" |
| report_file = REPORTS_DIR / f"{scenario_id}.json" |
|
|
| report_data = { |
| "scenario_id": scenario_id, |
| "question": f"Custom Chart: {indicator_name} ({country_code}, {start_year}-{end_year})", |
| "scenario": { |
| "user_question": f"Custom Chart: {indicator_name} ({country_code}, {start_year}-{end_year})", |
| "country_code": country_code, |
| "start_year": start_year, |
| "end_year": end_year, |
| "chart_type": chart_type, |
| "disaggregation_filters": disaggregation_filters |
| }, |
| "resolved_indicators": resolved_indicators, |
| "viz_result": viz_result, |
| "system_spec": system_spec, |
| "system_score": score_10, |
| "system_critique": critique, |
| "llm_chartjs": llm_chartjs, |
| "llm_score": llm_score_10, |
| "llm_critique": llm_critique, |
| "spec": system_spec, |
| "score": score_10, |
| "critique": critique, |
| "timestamp": pd.Timestamp.now().isoformat(), |
| "logs": logs |
| } |
|
|
| with open(report_file, "w") as f: |
| json.dump(report_data, f, indent=2) |
| upload_file_to_hf(report_file) |
|
|
| log("Custom chart generated and report saved successfully.") |
| return report_data |
|
|
|
|
| |
| |
| |
|
|
| HTML_CONTENT = """ |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Data360-MCP Visualization Engine Explorer</title> |
| |
| <!-- Fonts --> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> |
| |
| <!-- Vega Embed (system panel) --> |
| <script src="https://cdn.jsdelivr.net/npm/vega@5"></script> |
| <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script> |
| <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script> |
| <!-- Chart.js v4 (LLM panel) --> |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> |
| |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Fira+Sans:wght@300;400;500;600;700&display=swap'); |
| |
| :root { |
| /* WBG Light Theme (Default) */ |
| --bg: #f8fafc; |
| --surface1: #ffffff; |
| --surface2: #f1f5f9; |
| --border: #cbd5e1; |
| --text: #0f172a; |
| --text-muted: #475569; |
| --accent: #0071BC; |
| --accent-hover: #005a96; |
| --accent-glow: rgba(0, 113, 188, 0.15); |
| --wbg-gold: #CA8A04; |
| --wbg-gold-hover: #a16207; |
| --success: #059669; |
| --warning: #d97706; |
| --danger: #dc2626; |
| --card-bg: rgba(255, 255, 255, 0.9); |
| --sidebar-bg: #f1f5f9; |
| --header-bg: rgba(255, 255, 255, 0.9); |
| --header-border: #cbd5e1; |
| --glass-glow: 0 8px 32px 0 rgba(0, 113, 188, 0.05); |
| --transition-speed: 0.22s; |
| --font-body: 'Fira Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| --font-mono: 'Fira Code', 'JetBrains Mono', monospace; |
| } |
| |
| [data-theme="dark"] { |
| /* WBG Dark Theme */ |
| --bg: #0b1329; |
| --surface1: #111a36; |
| --surface2: #1c274c; |
| --border: #29386c; |
| --text: #f8fafc; |
| --text-muted: #94a3b8; |
| --accent: #0071BC; |
| --accent-hover: #008be5; |
| --accent-glow: rgba(0, 113, 188, 0.25); |
| --wbg-gold: #CA8A04; |
| --wbg-gold-hover: #eab308; |
| --success: #10b981; |
| --warning: #f59e0b; |
| --danger: #ef4444; |
| --card-bg: rgba(17, 26, 54, 0.45); |
| --sidebar-bg: #0a1024; |
| --header-bg: rgba(10, 16, 36, 0.85); |
| --header-border: #1c274c; |
| --glass-glow: 0 8px 32px 0 rgba(0, 0, 0, 0.35); |
| } |
| |
| * { |
| box-sizing: border-box; |
| margin: 0; |
| padding: 0; |
| } |
| |
| body { |
| background-color: var(--bg); |
| color: var(--text); |
| font-family: var(--font-body); |
| height: 100vh; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease; |
| } |
| |
| header { |
| background: var(--header-bg); |
| backdrop-filter: blur(12px); |
| -webkit-backdrop-filter: blur(12px); |
| border-bottom: 1px solid var(--header-border); |
| padding: 14px 24px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| position: sticky; |
| top: 0; |
| z-index: 100; |
| transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease; |
| } |
| |
| .header-logo { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .logo-badge { |
| background: linear-gradient(135deg, var(--accent), var(--accent-hover)); |
| color: #fff; |
| font-weight: 700; |
| font-size: 14px; |
| padding: 5px 12px; |
| border-radius: 6px; |
| box-shadow: 0 4px 12px var(--accent-glow); |
| } |
| |
| .header-title { |
| font-size: 18px; |
| font-weight: 600; |
| letter-spacing: -0.5px; |
| } |
| |
| .header-controls { |
| display: flex; |
| align-items: center; |
| gap: 16px; |
| } |
| |
| /* Premium Theme Toggle Button */ |
| .theme-toggle-btn { |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| color: var(--text); |
| cursor: pointer; |
| width: 36px; |
| height: 36px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| transition: all var(--transition-speed) cubic-bezier(0.4, 0, 0.2, 1); |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); |
| } |
| |
| .theme-toggle-btn:hover { |
| background: var(--border); |
| transform: scale(1.05); |
| } |
| |
| .theme-toggle-btn svg { |
| width: 18px; |
| height: 18px; |
| fill: none; |
| stroke: currentColor; |
| stroke-width: 2; |
| stroke-linecap: round; |
| stroke-linejoin: round; |
| } |
| |
| [data-theme="light"] .theme-toggle-btn .moon-icon { display: none; } |
| [data-theme="light"] .theme-toggle-btn .sun-icon { display: block; } |
| [data-theme="dark"] .theme-toggle-btn .moon-icon { display: block; } |
| [data-theme="dark"] .theme-toggle-btn .sun-icon { display: none; } |
| |
| .container { |
| display: grid; |
| grid-template-columns: 340px 1fr; |
| flex: 1; |
| height: 0; |
| overflow: hidden; |
| } |
| |
| /* Sidebar History */ |
| .sidebar { |
| background: var(--sidebar-bg); |
| border-right: 1px solid var(--border); |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease; |
| } |
| |
| .sidebar-header { |
| padding: 16px 20px; |
| font-size: 13px; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| color: var(--text-muted); |
| border-bottom: 1px solid var(--border); |
| } |
| |
| .history-list { |
| flex: 1; |
| overflow-y: auto; |
| padding: 12px; |
| } |
| |
| /* Custom Scrollbar for visual continuity */ |
| ::-webkit-scrollbar { |
| width: 6px; |
| height: 6px; |
| } |
| ::-webkit-scrollbar-track { |
| background: transparent; |
| } |
| ::-webkit-scrollbar-thumb { |
| background: var(--border); |
| border-radius: 4px; |
| } |
| ::-webkit-scrollbar-thumb:hover { |
| background: var(--text-muted); |
| } |
| |
| .history-card { |
| background: var(--surface1); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 12px 14px; |
| margin-bottom: 8px; |
| cursor: pointer; |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); |
| } |
| |
| .history-card:hover { |
| background: var(--surface2); |
| border-color: var(--text-muted); |
| transform: translateY(-1px); |
| } |
| |
| .history-card.active { |
| background: var(--accent-glow); |
| border-color: var(--accent); |
| box-shadow: 0 4px 12px rgba(59, 130, 246, 0.08); |
| } |
| |
| .history-card-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: flex-start; |
| gap: 10px; |
| margin-bottom: 6px; |
| } |
| |
| .history-question { |
| font-size: 13px; |
| font-weight: 600; |
| line-height: 1.4; |
| display: -webkit-box; |
| -webkit-line-clamp: 2; |
| -webkit-box-orient: vertical; |
| overflow: hidden; |
| } |
| |
| .scores-badge { |
| display: flex; |
| gap: 4px; |
| } |
| |
| .history-score { |
| font-size: 10px; |
| font-weight: 700; |
| padding: 2px 5px; |
| border-radius: 4px; |
| white-space: nowrap; |
| } |
| |
| .history-score.high { background: rgba(16, 185, 129, 0.12); color: var(--success); } |
| .history-score.mid { background: rgba(245, 158, 11, 0.12); color: var(--warning); } |
| .history-score.low { background: rgba(239, 68, 68, 0.12); color: var(--danger); } |
| .history-score.na { background: rgba(156, 163, 175, 0.12); color: var(--text-muted); } |
| |
| .history-time { |
| font-size: 11px; |
| color: var(--text-muted); |
| } |
| |
| /* Main Content */ |
| .main-content { |
| padding: 24px; |
| overflow-y: auto; |
| display: flex; |
| flex-direction: column; |
| gap: 24px; |
| transition: background-color var(--transition-speed) ease; |
| } |
| |
| /* Batch progress banner */ |
| .batch-progress-bar { |
| display: none; |
| background: rgba(16, 185, 129, 0.08); |
| border: 1px solid var(--success); |
| color: var(--success); |
| padding: 12px 20px; |
| border-radius: 8px; |
| font-size: 13px; |
| font-weight: 500; |
| align-items: center; |
| justify-content: space-between; |
| animation: pulse 2s infinite; |
| } |
| |
| @keyframes pulse { |
| 0% { opacity: 0.9; } |
| 50% { opacity: 1; } |
| 100% { opacity: 0.9; } |
| } |
| |
| /* Controls block */ |
| .controls-row { |
| background: var(--surface1); |
| border: 1px solid var(--border); |
| border-radius: 12px; |
| padding: 20px 24px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| gap: 20px; |
| transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease; |
| } |
| |
| .query-display { |
| flex: 1; |
| min-width: 0; |
| } |
| |
| .query-label { |
| font-size: 10px; |
| font-weight: 700; |
| text-transform: uppercase; |
| color: var(--text-muted); |
| margin-bottom: 4px; |
| letter-spacing: 0.5px; |
| } |
| |
| .query-text { |
| font-size: 15px; |
| font-weight: 500; |
| line-height: 1.4; |
| word-break: break-all; |
| overflow-wrap: break-word; |
| } |
| |
| .btn-surprise, .btn-batch { |
| border: none; |
| color: #fff; |
| font-weight: 600; |
| font-size: 13px; |
| padding: 10px 20px; |
| border-radius: 8px; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); |
| white-space: nowrap; |
| } |
| |
| .btn-surprise { |
| background: linear-gradient(135deg, var(--accent), #004d80); |
| box-shadow: 0 4px 12px var(--accent-glow); |
| } |
| |
| .btn-surprise:hover:not(:disabled) { |
| transform: translateY(-1px); |
| box-shadow: 0 6px 16px rgba(0, 113, 188, 0.4); |
| } |
| |
| .btn-batch { |
| background: linear-gradient(135deg, #475569, #334155); |
| box-shadow: 0 4px 12px rgba(71, 85, 105, 0.2); |
| } |
| |
| .btn-batch:hover:not(:disabled) { |
| transform: translateY(-1px); |
| box-shadow: 0 6px 16px rgba(71, 85, 105, 0.35); |
| } |
| |
| .btn-surprise:disabled, .btn-batch:disabled { |
| opacity: 0.6; |
| cursor: not-allowed; |
| transform: none !important; |
| box-shadow: none !important; |
| } |
| |
| /* Spinner */ |
| .spinner { |
| width: 16px; |
| height: 16px; |
| border: 2px solid rgba(255, 255, 255, 0.3); |
| border-top-color: #fff; |
| border-radius: 50%; |
| animation: spin 1s linear infinite; |
| display: none; |
| } |
| |
| .spinner-large { |
| width: 40px; |
| height: 40px; |
| border: 3px solid rgba(255, 255, 255, 0.1); |
| border-top-color: var(--accent); |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| } |
| |
| @keyframes spin { |
| to { transform: rotate(360deg); } |
| } |
| |
| /* Side-by-side spec comparison layout */ |
| .viz-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 24px; |
| } |
| |
| .card { |
| background: var(--card-bg); |
| border: 1px solid var(--border); |
| border-radius: 14px; |
| backdrop-filter: blur(12px); |
| -webkit-backdrop-filter: blur(12px); |
| overflow: hidden; |
| display: flex; |
| flex-direction: column; |
| box-shadow: var(--glass-glow); |
| transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease, box-shadow var(--transition-speed) ease; |
| } |
| |
| .card-header { |
| padding: 14px 20px; |
| border-bottom: 1px solid var(--border); |
| font-weight: 600; |
| font-size: 14px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| background: rgba(255, 255, 255, 0.01); |
| transition: border-color var(--transition-speed) ease; |
| } |
| |
| .card-body { |
| padding: 20px; |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| gap: 18px; |
| } |
| |
| .empty-state { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| min-height: 250px; |
| text-align: center; |
| padding: 30px; |
| border: 2px dashed var(--border); |
| border-radius: 10px; |
| background: rgba(255, 255, 255, 0.01); |
| transition: border-color var(--transition-speed) ease; |
| } |
| |
| .empty-icon { |
| font-size: 40px; |
| margin-bottom: 12px; |
| color: var(--border); |
| transition: color var(--transition-speed) ease; |
| } |
| |
| .empty-title { |
| font-size: 15px; |
| font-weight: 600; |
| margin-bottom: 6px; |
| } |
| |
| .empty-desc { |
| font-size: 13px; |
| color: var(--text-muted); |
| line-height: 1.4; |
| transition: color var(--transition-speed) ease; |
| } |
| |
| .chart-container-div { |
| width: 100%; |
| min-height: 320px; |
| display: none; |
| overflow-x: auto; |
| } |
| |
| .vega-embed canvas, .vega-embed svg { |
| max-width: 100% !important; |
| height: auto !important; |
| } |
| |
| /* Score Header Section */ |
| .score-banner { |
| display: flex; |
| align-items: center; |
| gap: 16px; |
| background: rgba(255, 255, 255, 0.01); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 12px 16px; |
| transition: border-color var(--transition-speed) ease; |
| } |
| |
| .score-circle-wrapper { |
| flex-shrink: 0; |
| } |
| |
| .score-circle { |
| position: relative; |
| width: 64px; |
| height: 64px; |
| } |
| |
| .score-circle svg { |
| width: 64px; |
| height: 64px; |
| transform: rotate(-90deg); |
| } |
| |
| .score-circle circle { |
| fill: none; |
| stroke-width: 5; |
| } |
| |
| .score-circle .circle-bg { |
| stroke: var(--surface2); |
| transition: stroke var(--transition-speed) ease; |
| } |
| |
| .score-circle .circle-progress { |
| stroke: var(--accent); |
| stroke-linecap: round; |
| transition: stroke-dashoffset 0.6s ease, stroke var(--transition-speed) ease; |
| } |
| |
| .score-value { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .score-num { |
| font-size: 18px; |
| font-weight: 700; |
| } |
| |
| .score-max { |
| font-size: 10px; |
| color: var(--text-muted); |
| transition: color var(--transition-speed) ease; |
| } |
| |
| .score-critique-box { |
| flex: 1; |
| } |
| |
| .critique-title { |
| font-size: 9px; |
| font-weight: 700; |
| color: var(--text-muted); |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| margin-bottom: 4px; |
| transition: color var(--transition-speed) ease; |
| } |
| |
| .critique-text { |
| font-size: 12px; |
| line-height: 1.45; |
| color: var(--text); |
| max-height: 80px; |
| overflow-y: auto; |
| transition: color var(--transition-speed) ease; |
| } |
| |
| /* Visual DeepEval Critique Button */ |
| .btn-visual-critique { |
| display: inline-flex; |
| align-items: center; |
| gap: 7px; |
| padding: 6px 14px; |
| font-size: 12px; |
| font-weight: 600; |
| font-family: var(--font-body); |
| border-radius: 6px; |
| border: 1px solid #7c3aed; |
| background: rgba(124, 58, 237, 0.12); |
| color: #a78bfa; |
| cursor: pointer; |
| transition: background 0.2s, color 0.2s, border-color 0.2s; |
| white-space: nowrap; |
| } |
| .btn-visual-critique:hover:not(:disabled) { |
| background: rgba(124, 58, 237, 0.25); |
| color: #c4b5fd; |
| border-color: #a78bfa; |
| } |
| .btn-visual-critique:disabled { |
| opacity: 0.5; |
| cursor: not-allowed; |
| } |
| .btn-visual-critique .btn-spinner { |
| width: 12px; |
| height: 12px; |
| border: 2px solid rgba(167,139,250,0.3); |
| border-top-color: #a78bfa; |
| border-radius: 50%; |
| animation: spin 0.7s linear infinite; |
| display: none; |
| } |
| /* Visual Critique Result Panel */ |
| .visual-critique-panel { |
| display: none; |
| flex-direction: column; |
| gap: 8px; |
| margin-top: 10px; |
| padding: 12px 14px; |
| border: 1px solid #7c3aed; |
| border-radius: 8px; |
| background: rgba(124, 58, 237, 0.06); |
| } |
| .visual-critique-panel .vc-score-row { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| .visual-critique-panel .vc-score-badge { |
| font-size: 22px; |
| font-weight: 700; |
| color: #a78bfa; |
| line-height: 1; |
| } |
| .visual-critique-panel .vc-label { |
| font-size: 9px; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: 0.6px; |
| color: #7c3aed; |
| margin-bottom: 2px; |
| } |
| .visual-critique-panel .vc-critique { |
| font-size: 12px; |
| line-height: 1.5; |
| color: var(--text); |
| } |
| .visual-critique-panel .vc-recs { |
| font-size: 11px; |
| color: var(--text-muted); |
| padding-left: 14px; |
| margin: 0; |
| } |
| .visual-critique-panel .vc-recs li { |
| margin-bottom: 2px; |
| } |
| |
| /* Tooltip styling */ |
| .info-tooltip-wrapper { |
| position: relative; |
| display: inline-flex; |
| align-items: center; |
| margin-left: 6px; |
| cursor: help; |
| color: var(--text-muted); |
| transition: color var(--transition-speed); |
| } |
| .info-tooltip-wrapper:hover { |
| color: var(--accent); |
| } |
| .info-tooltip-text { |
| visibility: hidden; |
| width: 220px; |
| background-color: var(--surface2); |
| color: var(--text); |
| text-align: left; |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 10px 12px; |
| position: absolute; |
| z-index: 100; |
| bottom: 125%; /* Position above the icon */ |
| left: 50%; |
| transform: translateX(-50%); |
| opacity: 0; |
| transition: opacity 0.2s, visibility 0.2s; |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); |
| font-size: 11px; |
| line-height: 1.4; |
| font-weight: normal; |
| pointer-events: none; |
| font-family: var(--font-body); |
| } |
| .info-tooltip-wrapper:hover .info-tooltip-text { |
| visibility: visible; |
| opacity: 1; |
| } |
| /* Scrollable card body */ |
| .card-body { |
| overflow-y: auto; |
| } |
| |
| /* Details tab bar */ |
| .details-card { |
| display: none !important; |
| background: var(--surface1); |
| border: 1px solid var(--border); |
| border-radius: 12px; |
| overflow: hidden; |
| transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease; |
| } |
| |
| .tabs-header { |
| background: var(--surface2); |
| border-bottom: 1px solid var(--border); |
| display: flex; |
| transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease; |
| } |
| |
| .tab-btn { |
| padding: 12px 18px; |
| border: none; |
| background: none; |
| color: var(--text-muted); |
| font-weight: 500; |
| font-size: 13px; |
| cursor: pointer; |
| border-right: 1px solid var(--border); |
| transition: all var(--transition-speed) ease; |
| font-family: var(--font-body); |
| } |
| |
| .tab-btn:hover { |
| color: var(--text); |
| background: rgba(255, 255, 255, 0.02); |
| } |
| |
| .tab-btn.active { |
| color: var(--text); |
| background: var(--surface1); |
| font-weight: 600; |
| } |
| |
| .tab-body { |
| padding: 20px; |
| display: none; |
| } |
| |
| .tab-body.active { |
| display: block; |
| } |
| |
| /* Pagination */ |
| .pagination-btn { |
| padding: 6px 12px; |
| background: var(--surface1); |
| border: 1px solid var(--border); |
| color: var(--text); |
| font-size: 12px; |
| font-weight: 500; |
| border-radius: 6px; |
| cursor: pointer; |
| font-family: var(--font-body); |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); |
| } |
| .pagination-btn:hover:not(:disabled) { |
| background: var(--surface2); |
| border-color: var(--accent); |
| color: var(--accent); |
| box-shadow: 0 2px 8px var(--accent-glow); |
| } |
| .pagination-btn:disabled { |
| opacity: 0.4; |
| cursor: not-allowed; |
| } |
| |
| /* Console lines */ |
| .log-container { |
| background: #070b13; |
| border: 1px solid var(--border); |
| border-radius: 6px; |
| padding: 16px; |
| font-family: var(--font-mono); |
| font-size: 12px; |
| line-height: 1.6; |
| max-height: 250px; |
| overflow-y: auto; |
| transition: border-color var(--transition-speed) ease; |
| } |
| |
| .terminal-line { |
| margin-bottom: 4px; |
| color: #94a3b8; |
| } |
| |
| .terminal-line.info { color: #38bdf8; } |
| .terminal-line.err { color: #f87171; } |
| |
| pre { |
| background: #070b13; |
| padding: 16px; |
| border-radius: 6px; |
| overflow: auto; |
| max-height: 400px; |
| border: 1px solid var(--border); |
| transition: border-color var(--transition-speed) ease; |
| } |
| |
| code { |
| font-family: var(--font-mono); |
| font-size: 12px; |
| color: #38bdf8; |
| } |
| |
| /* Responsive breakpoints for small monitors, laptops, and tablets */ |
| @media (max-width: 1550px) { |
| .viz-grid { |
| grid-template-columns: 1fr; /* Stack charts vertically on smaller monitors */ |
| gap: 16px; |
| } |
| } |
| |
| @media (max-width: 900px) { |
| .container { |
| grid-template-columns: 1fr; /* Stack sidebar and main content vertically */ |
| height: auto; |
| overflow: auto; |
| } |
| body { |
| height: auto; |
| overflow: auto; |
| } |
| .sidebar { |
| height: auto; |
| border-right: none; |
| border-bottom: 1px solid var(--border); |
| } |
| .controls-row { |
| flex-direction: column; |
| align-items: stretch; |
| } |
| .controls-row div[style*="display: flex"] { |
| flex-direction: column; |
| width: 100%; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| |
| <header> |
| <div class="header-logo" onclick="showHomepage()" style="cursor: pointer;" title="Go to Homepage"> |
| <div class="logo-badge">Data360-MCP</div> |
| <div class="header-title">Visualization Engine Explorer</div> |
| </div> |
| <div class="header-controls"> |
| <!-- Premium Switch Theme Button (No emoji, clean SVGs) --> |
| <button class="theme-toggle-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme"> |
| <!-- Sun icon shown in Dark theme --> |
| <svg class="sun-icon" viewBox="0 0 24 24"> |
| <circle cx="12" cy="12" r="4"></circle> |
| <path d="M12 2v2"></path> |
| <path d="M12 20v2"></path> |
| <path d="M4.93 4.93l1.41 1.41"></path> |
| <path d="M17.66 17.66l1.41 1.41"></path> |
| <path d="M2 12h2"></path> |
| <path d="M20 12h2"></path> |
| <path d="M6.34 17.66l-1.41 1.41"></path> |
| <path d="M19.07 4.93l-1.41 1.41"></path> |
| </svg> |
| <!-- Moon icon shown in Light theme --> |
| <svg class="moon-icon" viewBox="0 0 24 24"> |
| <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path> |
| </svg> |
| </button> |
| <div style="font-size: 13px; color: var(--text-muted);" id="connection-status"> |
| Connected |
| </div> |
| </div> |
| </header> |
| |
| <div class="container"> |
| |
| <!-- Sidebar History & Builder --> |
| <div class="sidebar" id="sidebar" style="display: flex; flex-direction: column; transition: opacity var(--transition-speed) ease, filter var(--transition-speed) ease;"> |
| |
| <!-- Sidebar Tabs --> |
| <div style="display: flex; border-bottom: 1px solid var(--border); transition: border-color var(--transition-speed) ease;"> |
| <button id="sidebar-tab-history" onclick="switchSidebarTab('history')" style="flex: 1; padding: 12px; border: none; background: var(--surface2); color: var(--text); font-weight: 600; cursor: pointer; border-bottom: 2px solid var(--accent); transition: all 0.2s; font-family: var(--font-body); font-size: 13px;">Past Runs</button> |
| <button id="sidebar-tab-builder" onclick="switchSidebarTab('builder')" style="flex: 1; padding: 12px; border: none; background: var(--surface1); color: var(--text-muted); font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; font-family: var(--font-body); font-size: 13px;">Chart Builder</button> |
| </div> |
| |
| <!-- Tab Content: History List --> |
| <div id="history-tab-content" style="display: flex; flex-direction: column; flex: 1; overflow: hidden;"> |
| <div class="sidebar-header" style="display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; transition: background-color var(--transition-speed) ease;"> |
| <span>Past Runs</span> |
| <button onclick="triggerSurpriseMe()" style="background: rgba(37, 99, 235, 0.15); border: 1px solid var(--accent); color: var(--accent); font-size: 14px; font-weight: bold; cursor: pointer; padding: 2px 8px; border-radius: 4px; transition: all 0.2s ease;" title="New Surprise Run" id="btn-plus-new">+</button> |
| </div> |
| <div class="history-list" id="history-list" style="flex: 1; overflow-y: auto;"> |
| <!-- Dynamic history cards --> |
| </div> |
| <!-- Pagination controls --> |
| <div class="pagination-controls" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 16px; border-top: 1px solid var(--border); background: var(--surface2); transition: border-color var(--transition-speed) ease, background-color var(--transition-speed) ease;"> |
| <button id="btn-page-prev" onclick="prevHistoryPage()" class="pagination-btn">Prev</button> |
| <span id="page-indicator" style="font-size: 12px; color: var(--text-muted); font-family: var(--font-body);">Page 1 of 1</span> |
| <button id="btn-page-next" onclick="nextHistoryPage()" class="pagination-btn">Next</button> |
| </div> |
| </div> |
| |
| <!-- Tab Content: Chart Builder Form --> |
| <div id="builder-tab-content" style="display: none; padding: 16px; overflow-y: auto; flex: 1;"> |
| <div style="display: flex; flex-direction: column; gap: 14px;"> |
| |
| <!-- Search indicators box --> |
| <div style="display: flex; flex-direction: column; gap: 6px;"> |
| <label style="font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">1. Search Indicator</label> |
| <div style="display: flex; gap: 8px;"> |
| <input type="text" id="builder-search-input" placeholder="e.g. government effectiveness" style="flex: 1; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); color: var(--text); font-family: var(--font-body); font-size: 13px; outline: none; transition: border-color 0.2s;" onkeydown="if(event.key==='Enter') searchBuilderIndicators()"> |
| <button onclick="searchBuilderIndicators()" style="padding: 8px 12px; border: none; background: var(--accent); color: white; font-weight: 600; border-radius: 6px; cursor: pointer; font-size: 13px; transition: opacity 0.2s;">Search</button> |
| </div> |
| <div id="builder-search-results" style="max-height: 150px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); display: none; margin-top: 6px; transition: border-color var(--transition-speed) ease;"> |
| <!-- Dynamic search results list --> |
| </div> |
| </div> |
| |
| <!-- Configuration Fields (hidden until an indicator is selected) --> |
| <div id="builder-configurator" style="display: none; flex-direction: column; gap: 14px; border-top: 1px dashed var(--border); padding-top: 14px;"> |
| |
| <!-- Selected Indicator details --> |
| <div style="background: rgba(37, 99, 235, 0.08); border: 1px solid rgba(37, 99, 235, 0.2); padding: 10px; border-radius: 6px;"> |
| <div style="font-size: 11px; font-weight: 700; color: var(--accent);" id="selected-indicator-db"></div> |
| <div style="font-size: 13px; font-weight: 600; margin-top: 2px; color: var(--text);" id="selected-indicator-name"></div> |
| <div style="font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px;" id="selected-indicator-id"></div> |
| </div> <!-- Countries selector --> |
| <div style="display: flex; flex-direction: column; gap: 4px; position: relative;"> |
| <label style="font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">2. Selected Economies / Country Codes</label> |
| <input type="text" id="builder-countries" value="USA;BRA;KEN;COL" style="padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); color: var(--text); font-family: var(--font-mono); font-size: 13px; outline: none;" placeholder="e.g. USA;BRA;KEN;COL" oninput="updateCountryBadges()"> |
| <div style="font-size: 11px; color: var(--text-muted); margin-top: 1px; margin-bottom: 2px;">Leave blank to query all economies.</div> |
| |
| <!-- Assisted Country Search --> |
| <div style="display: flex; gap: 6px; margin-top: 4px;"> |
| <input type="text" id="builder-country-search" placeholder="Type country to add (e.g. India)..." style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); color: var(--text); font-family: var(--font-body); font-size: 12px; outline: none;" onkeydown="if(event.key==='Enter') { event.preventDefault(); searchBuilderCountries(); }"> |
| <button type="button" onclick="searchBuilderCountries()" style="padding: 6px 10px; border: none; background: var(--accent); color: white; font-weight: 600; border-radius: 6px; cursor: pointer; font-size: 12px;">Add</button> |
| </div> |
| <div id="builder-country-results" style="max-height: 120px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); display: none; margin-top: 4px; position: absolute; z-index: 10; width: 100%; top: 86px;"> |
| <!-- Country search results list --> |
| </div> |
| |
| <!-- Economy Group (FMR) Selector --> |
| <div style="display: flex; flex-direction: column; gap: 4px; margin-top: 8px;"> |
| <label style="font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">Add Economy Group (FMR)</label> |
| <select id="builder-group-select" onchange="onGroupSelected()" style="padding: 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); color: var(--text); font-family: var(--font-body); font-size: 13px; outline: none; width: 100%;"> |
| <option value="">Select a group...</option> |
| <optgroup label="Regions"> |
| <option value="EAS">East Asia & Pacific</option> |
| <option value="ECS">Europe & Central Asia</option> |
| <option value="LCN">Latin America & Caribbean</option> |
| <option value="MEA">Middle East & North Africa</option> |
| <option value="NAC">North America</option> |
| <option value="SAS">South Asia</option> |
| <option value="SSF">Sub-Saharan Africa</option> |
| </optgroup> |
| <optgroup label="Income Groups"> |
| <option value="LIC">Low income</option> |
| <option value="LMC">Lower middle income</option> |
| <option value="UMC">Upper middle income</option> |
| <option value="HIC">High income</option> |
| </optgroup> |
| </select> |
| </div> |
| |
| <!-- Economy Group Mode Configuration --> |
| <div id="builder-group-config" style="display: none; flex-direction: column; gap: 6px; background: rgba(255, 255, 255, 0.02); border: 1px solid var(--border); padding: 10px; border-radius: 6px; margin-top: 6px;"> |
| <div style="font-size: 12px; font-weight: 600; color: var(--text);" id="builder-selected-group-name"></div> |
| <div style="display: flex; flex-direction: column; gap: 4px;"> |
| <label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); cursor: pointer;"> |
| <input type="radio" name="builder-group-mode" value="direct" checked style="cursor: pointer;"> |
| <span>Use group code directly (e.g. <span id="builder-selected-group-code-direct" style="font-family: monospace; font-weight: 600;"></span>)</span> |
| </label> |
| <label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); cursor: pointer;"> |
| <input type="radio" name="builder-group-mode" value="expand" style="cursor: pointer;"> |
| <span>Expand to its member economies (<span id="builder-selected-group-count" style="font-weight: 600;"></span> countries)</span> |
| </label> |
| </div> |
| <button type="button" onclick="addGroupToBuilder()" style="margin-top: 4px; padding: 6px 12px; border: none; background: var(--accent); color: white; font-weight: 600; border-radius: 6px; cursor: pointer; font-size: 12px; align-self: flex-start;">Add Group</button> |
| </div> |
| |
| <div id="builder-selected-countries-badges" style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;"> |
| <!-- Dynamically populated badges for active countries --> |
| </div> |
| </div> |
| |
| <!-- Year bounds --> |
| <div style="display: flex; gap: 12px;"> |
| <div style="flex: 1; display: flex; flex-direction: column; gap: 4px;"> |
| <label style="font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">Start Year</label> |
| <select id="builder-start-year" style="padding: 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); color: var(--text); font-family: var(--font-body); font-size: 13px; outline: none; width: 100%;"> |
| <!-- Dynamic start years --> |
| </select> |
| </div> |
| <div style="flex: 1; display: flex; flex-direction: column; gap: 4px;"> |
| <label style="font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">End Year</label> |
| <select id="builder-end-year" style="padding: 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); color: var(--text); font-family: var(--font-body); font-size: 13px; outline: none; width: 100%;"> |
| <!-- Dynamic end years --> |
| </select> |
| </div> |
| </div> |
| |
| <!-- Dynamic Disaggregation filters --> |
| <div id="builder-disaggregations-container" style="display: flex; flex-direction: column; gap: 6px;"> |
| <label style="font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">3. Disaggregation Options</label> |
| <div id="builder-disaggregations-list" style="display: flex; flex-direction: column; gap: 8px; background: var(--surface2); padding: 10px; border-radius: 6px; border: 1px solid var(--border); max-height: 180px; overflow-y: auto;"> |
| <!-- Dynamic dimensions checkboxes/radios --> |
| </div> |
| </div> |
| |
| <!-- Chart Hint --> |
| <div style="display: flex; flex-direction: column; gap: 4px;"> |
| <label style="font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">4. Chart Type Hint</label> |
| <select id="builder-chart-type" style="padding: 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); color: var(--text); font-family: var(--font-body); font-size: 13px; outline: none; width: 100%;"> |
| <option value="">None (Auto-Route)</option> |
| <option value="line">Line Chart</option> |
| <option value="bar">Bar Chart</option> |
| <option value="scatter">Scatter Plot</option> |
| <option value="small_multiples">Small Multiples</option> |
| </select> |
| </div> |
| |
| <!-- Submit Button --> |
| <button id="btn-builder-generate" onclick="generateCustomBuilderChart()" style="width: 100%; padding: 12px; border: none; background: linear-gradient(135deg, var(--accent), #1d4ed8); color: white; font-weight: bold; border-radius: 8px; cursor: pointer; font-size: 14px; margin-top: 8px; box-shadow: 0 4px 12px var(--accent-glow); transition: all 0.2s ease;"> |
| Generate Custom Visuals |
| </button> |
| </div> |
| |
| </div> |
| </div> |
| </div> |
| |
| <!-- Main Content --> |
| <div class="main-content"> |
| |
| <!-- Batch Progress Banner --> |
| <div class="batch-progress-bar" id="batch-progress-bar" style="display: none; justify-content: space-between; align-items: center; width: 100%;"> |
| <span style="display: flex; align-items: center; gap: 6px;"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg> |
| <strong>Batch Run in progress:</strong> <span id="batch-progress-text">0/50 sets completed</span> |
| </span> |
| <button onclick="cancelBatchSurprise()" style="background: #ef4444; border: 1px solid #dc2626; color: white; font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 6px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='#dc2626'" onmouseout="this.style.background='#ef4444'">Cancel Run</button> |
| </div> |
| |
| <!-- Controls Row --> |
| <div class="controls-row"> |
| <div class="query-display" style="display: flex; flex-direction: column; width: 100%; flex: 1; min-width: 0;"> |
| <div style="display: flex; justify-content: space-between; align-items: center; width: 100%;"> |
| <div class="query-label">Active Scenario Query</div> |
| <button id="btn-clear-selection" onclick="showHomepage()" style="display: none; background: transparent; border: none; color: var(--text-muted); cursor: pointer; padding: 4px; border-radius: 4px; transition: color 0.2s;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--text-muted)'" title="Return to Homepage"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> |
| </button> |
| </div> |
| <div class="query-text" id="query-text">No active query. Click "Surprise Me!" or trigger a past run.</div> |
| </div> |
| <div style="display: flex; gap: 12px; align-items: center;"> |
| <button class="btn-surprise" id="btn-visual-critique-global" onclick="triggerVisualCritiqueGlobal()" style="display: none; background: linear-gradient(135deg, var(--accent), var(--accent-hover)); box-shadow: 0 4px 15px var(--accent-glow);"> |
| <div class="spinner" id="vc-spinner-global"></div> |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg> |
| <span id="btn-vc-label-global">Run Visual Critique</span> |
| </button> |
| <button class="btn-surprise" id="btn-rerun" onclick="triggerRerun()" style="display: none; background: linear-gradient(135deg, #4B5563, #374151); box-shadow: 0 4px 15px rgba(75, 85, 99, 0.4);"> |
| <div class="spinner" id="btn-rerun-spinner"></div> |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg> |
| <span id="btn-rerun-label">Rerun this run</span> |
| </button> |
| <button class="btn-surprise" id="btn-delete" onclick="triggerDelete()" style="display: none; background: linear-gradient(135deg, #EF4444, #DC2626); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);"> |
| <div class="spinner" id="btn-delete-spinner"></div> |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> |
| <span id="btn-delete-label">Delete this run</span> |
| </button> |
| <button class="btn-batch" id="btn-batch" onclick="triggerBatchSurprise()" style="display: none !important;"> |
| <div class="spinner" id="btn-batch-spinner"></div> |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg> |
| <span id="btn-batch-label">Batch Run (50 sets)</span> |
| </button> |
| <button class="btn-surprise" id="btn-surprise" onclick="triggerSurpriseMe()"> |
| <div class="spinner" id="btn-spinner"></div> |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275Z"/><path d="m5 3 1 2.5L8.5 6 6 7 5 9.5 4 7 1.5 6 4 5Z"/><path d="m19 17 1 2.5 2.5.5-2.5 1-1 2.5-1-2.5-2.5-1 2.5-1Z"/></svg> |
| <span id="btn-label">Surprise Me!</span> |
| </button> |
| </div> |
| </div> |
| |
| <!-- Welcome / User Guide Homepage --> |
| <div class="welcome-container" id="welcome-container" style="background: var(--card-bg); border: 1px solid var(--border); border-radius: 14px; padding: 32px; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: var(--glass-glow); display: block; color: var(--text); margin-bottom: 24px;"> |
| <h1 style="font-size: 26px; font-weight: 700; margin-top: 0; margin-bottom: 8px; background: linear-gradient(135deg, var(--text), var(--text-muted)); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Welcome to Data360-MCP Visualization Engine Explorer</h1> |
| <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 32px; line-height: 1.5;">An interactive interface for evaluating the Data360 MCP visualization engine vs. Direct LLM renders.</p> |
| |
| <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px;"> |
| <div style="background: rgba(255, 255, 255, 0.02); border: 1px solid var(--border); padding: 20px; border-radius: 10px;"> |
| <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275Z"/><path d="m5 3 1 2.5L8.5 6 6 7 5 9.5 4 7 1.5 6 4 5Z"/><path d="m19 17 1 2.5 2.5.5-2.5 1-1 2.5-1-2.5-2.5-1 2.5-1Z"/></svg> |
| <h3 style="font-size: 15px; font-weight: 600; margin: 0; color: var(--text);">Surprise Me! Action</h3> |
| </div> |
| <p style="font-size: 13px; color: var(--text-muted); margin: 0; line-height: 1.5;"> |
| Randomly generates a data profile (indicator counts, start/end years, country cardinalities, sex/age/urbanisation filters, and custom dimensions) directly from the Data360 MCP, then prompts GPT-4o-mini to frame a natural question matching those parameters. This ensures testing is free of prompt bias. |
| </p> |
| </div> |
| |
| <div style="background: rgba(255, 255, 255, 0.02); border: 1px solid var(--border); padding: 20px; border-radius: 10px;"> |
| <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg> |
| <h3 style="font-size: 15px; font-weight: 600; margin: 0; color: var(--text);">Run Visual Critique</h3> |
| </div> |
| <p style="font-size: 13px; color: var(--text-muted); margin: 0; line-height: 1.5;"> |
| Captures high-fidelity PNG screenshots of both chart renders and triggers a GPT-4o Vision audit to check for layout issues (axis overlapping, contrast, flatlining). Critique score and improvement lists are displayed under each chart and cached instantly. |
| </p> |
| </div> |
| </div> |
| |
| <div style="background: rgba(0, 113, 188, 0.06); border: 1px solid var(--border); padding: 24px; border-radius: 10px;"> |
| <h3 style="font-size: 15px; font-weight: 600; margin-top: 0; margin-bottom: 8px; color: var(--text); display: flex; align-items: center; gap: 6px;"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> |
| How it works |
| </h3> |
| <p style="font-size: 13px; color: var(--text-muted); margin: 0 0 16px 0; line-height: 1.5;"> |
| The central view allows side-by-side assessment. On the left is the <strong>Data360 Engine Spec</strong>, applying the repository's strict visualization routing and scaling rules. On the right is the <strong>Direct LLM Spec</strong> (GPT-4o), which generates free-form Chart.js graphs. |
| </p> |
| <div style="font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); background: rgba(0,0,0,0.15); padding: 8px 12px; border-radius: 6px; display: inline-block;"> |
| No active query. Click "Surprise Me!" or trigger a past run from the sidebar. |
| </div> |
| </div> |
| </div> |
| |
| <!-- Comparison Grid --> |
| <div class="viz-grid" id="viz-grid" style="display: none;"> |
| |
| <!-- Left: Data360 System Engine --> |
| <div class="card"> |
| <div class="card-header"> |
| <span>Data360 Engine Spec</span> |
| <span id="system-strategy-badge" style="font-size:11px; background:var(--surface2); padding:4px 8px; border-radius:4px; font-family:monospace; display:none;"></span> |
| </div> |
| <div class="card-body"> |
| |
| <!-- Visualization rendering --> |
| <div class="empty-state" id="system-chart-empty-state"> |
| <svg class="empty-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="width: 48px; height: 48px; color: var(--text-muted); opacity: 0.6; margin-bottom: 12px;"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg> |
| <div class="empty-title">Data360 Spec View</div> |
| <div class="empty-desc">System engine visualization will render here.</div> |
| </div> |
| <div class="empty-state" id="system-chart-loading-state" style="display: none; flex-direction: column; justify-content: center; align-items: center; min-height: 250px;"> |
| <div class="spinner-large" style="margin-bottom: 16px;"></div> |
| <div class="empty-title" style="font-size:14px; font-weight:600; color:var(--text);">Compiling Spec...</div> |
| <div class="empty-desc" style="font-size:12px; color:var(--text-muted); margin-top:4px;">Retrieving MCP data and rendering charts</div> |
| </div> |
| <div class="chart-container-div" id="system-chart-container"></div> |
| |
| <!-- Score & Critique Banner --> |
| <div class="score-banner" id="system-score-banner" style="display: none; margin-top: 20px;"> |
| <div class="score-circle-wrapper"> |
| <div class="score-circle"> |
| <svg> |
| <circle class="circle-bg" cx="34" cy="34" r="30"></circle> |
| <circle class="circle-progress" id="system-circle-progress" cx="34" cy="34" r="30" stroke-dasharray="188.5" stroke-dashoffset="188.5"></circle> |
| </svg> |
| <div class="score-value"> |
| <span class="score-num" id="system-score-num">0.0</span> |
| <span class="score-max">/ 10</span> |
| </div> |
| </div> |
| </div> |
| <div class="score-critique-box"> |
| <div class="critique-title" style="display: flex; align-items: center;"> |
| <span>G-Eval Scorer Critique & Rationale</span> |
| <span class="info-tooltip-wrapper"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> |
| <span class="info-tooltip-text">Assesses chart specification correctness, parameter alignment, and proper layout routing based on MCP metadata standards.</span> |
| </span> |
| </div> |
| <div class="critique-text" id="system-critique-text">No critique.</div> |
| </div> |
| </div> |
| |
| <!-- Visual Critique Result (System) --> |
| <div class="visual-critique-panel" id="vc-panel-system" style="margin-top: 20px;"> |
| <div class="vc-label" style="display: flex; align-items: center;"> |
| <span>GPT-4o Vision Audit</span> |
| <span class="info-tooltip-wrapper"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> |
| <span class="info-tooltip-text">Reviews the rendered chart image for visual bugs: overlapping text, clipped labels, color contrast, and empty flatlining.</span> |
| </span> |
| </div> |
| <div class="vc-score-row"> |
| <div class="vc-score-badge" id="vc-score-system">–</div> |
| <div style="font-size:11px; color:var(--text-muted);">/ 10</div> |
| </div> |
| <div class="vc-critique" id="vc-critique-system"></div> |
| <ul class="vc-recs" id="vc-recs-system"></ul> |
| </div> |
| |
| </div> |
| </div> |
| |
| <!-- Right: Direct LLM Vega-Lite --> |
| <div class="card"> |
| <div class="card-header"> |
| <span>Direct LLM Spec</span> |
| <span style="font-size:11px; background:var(--surface2); padding:4px 8px; border-radius:4px; font-family:monospace;">Model: gpt-4o</span> |
| </div> |
| <div class="card-body"> |
| |
| <!-- Visualization rendering --> |
| <div class="empty-state" id="llm-chart-empty-state"> |
| <svg class="empty-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="width: 48px; height: 48px; color: var(--text-muted); opacity: 0.6; margin-bottom: 12px;"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg> |
| <div class="empty-title">Direct LLM Spec</div> |
| <div class="empty-desc">Direct LLM generated visualization will render here.</div> |
| </div> |
| <div class="empty-state" id="llm-chart-loading-state" style="display: none; flex-direction: column; justify-content: center; align-items: center; min-height: 250px;"> |
| <div class="spinner-large" style="margin-bottom: 16px;"></div> |
| <div class="empty-title" style="font-size:14px; font-weight:600; color:var(--text);">Compiling Spec...</div> |
| <div class="empty-desc" style="font-size:12px; color:var(--text-muted); margin-top:4px;">Retrieving Direct LLM visualization output</div> |
| </div> |
| <div class="chart-container-div" id="llm-chart-container"></div> |
| |
| <!-- Score & Critique Banner --> |
| <div class="score-banner" id="llm-score-banner" style="display: none; margin-top: 20px;"> |
| <div class="score-circle-wrapper"> |
| <div class="score-circle"> |
| <svg> |
| <circle class="circle-bg" cx="34" cy="34" r="30"></circle> |
| <circle class="circle-progress" id="llm-circle-progress" cx="34" cy="34" r="30" stroke-dasharray="188.5" stroke-dashoffset="188.5"></circle> |
| </svg> |
| <div class="score-value"> |
| <span class="score-num" id="llm-score-num">0.0</span> |
| <span class="score-max">/ 10</span> |
| </div> |
| </div> |
| </div> |
| <div class="score-critique-box"> |
| <div class="critique-title" style="display: flex; align-items: center;"> |
| <span>G-Eval Scorer Critique & Rationale</span> |
| <span class="info-tooltip-wrapper"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> |
| <span class="info-tooltip-text">Assesses chart specification correctness, parameter alignment, and proper layout routing based on MCP standards.</span> |
| </span> |
| </div> |
| <div class="critique-text" id="llm-critique-text">No critique.</div> |
| </div> |
| </div> |
| |
| <!-- Visual Critique Result (LLM) --> |
| <div class="visual-critique-panel" id="vc-panel-llm" style="margin-top: 20px;"> |
| <div class="vc-label" style="display: flex; align-items: center;"> |
| <span>GPT-4o Vision Audit</span> |
| <span class="info-tooltip-wrapper"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> |
| <span class="info-tooltip-text">Reviews the rendered chart image for visual bugs: overlapping text, clipped labels, color contrast, and empty flatlining.</span> |
| </span> |
| </div> |
| <div class="vc-score-row"> |
| <div class="vc-score-badge" id="vc-score-llm">–</div> |
| <div style="font-size:11px; color:var(--text-muted);">/ 10</div> |
| </div> |
| <div class="vc-critique" id="vc-critique-llm"></div> |
| <ul class="vc-recs" id="vc-recs-llm"></ul> |
| </div> |
| |
| </div> |
| </div> |
| |
| </div> |
| |
| <!-- Bottom Details Tabs --> |
| <div class="details-card" style="display: none;"> |
| <div class="tabs-header"> |
| <button class="tab-btn active" onclick="switchTab(event, 'tab-specs')">Vega-Lite Specs Comparison</button> |
| <button class="tab-btn" onclick="switchTab(event, 'tab-resolved')">Resolved Parameters</button> |
| </div> |
| |
| <div class="tab-body" id="tab-logs" style="display: none !important;"> |
| <div class="log-container" id="log-console"> |
| <div class="terminal-line">Console initialized. Ready to generate scenarios.</div> |
| </div> |
| </div> |
| |
| <div class="tab-body active" id="tab-specs"> |
| <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;"> |
| <div> |
| <div style="font-size:12px; font-weight:600; color:var(--text-muted); margin-bottom:8px;">Data360 Engine Spec</div> |
| <pre><code id="system-spec-code">No spec loaded</code></pre> |
| </div> |
| <div> |
| <div style="font-size:12px; font-weight:600; color:var(--text-muted); margin-bottom:8px;">Direct LLM Spec</div> |
| <pre><code id="llm-spec-code">No spec loaded</code></pre> |
| </div> |
| </div> |
| </div> |
| |
| <div class="tab-body" id="tab-resolved"> |
| <pre><code id="resolved-code-block">{ "resolved": "No parameters resolved" }</code></pre> |
| </div> |
| </div> |
| |
| </div> |
| </div> |
| |
| <script> |
| let activeReport = null; |
| let activeReportFilename = null; |
| let batchCheckInterval = null; |
| window.lastReportData = null; |
| |
| // Theme switching logic |
| function initTheme() { |
| const savedTheme = localStorage.getItem("theme") || "light"; |
| document.documentElement.setAttribute("data-theme", savedTheme); |
| } |
| |
| function toggleTheme() { |
| const currentTheme = document.documentElement.getAttribute("data-theme") || "light"; |
| const newTheme = currentTheme === "dark" ? "light" : "dark"; |
| document.documentElement.setAttribute("data-theme", newTheme); |
| localStorage.setItem("theme", newTheme); |
| if (window.lastReportData) { |
| renderReport(window.lastReportData); |
| } |
| } |
| |
| initTheme(); |
| |
| let historyItems = []; |
| let currentHistoryPage = 1; |
| const historyPageSize = 8; |
| |
| // Load History on startup |
| async function loadHistory() { |
| try { |
| const res = await fetch("/api/history"); |
| historyItems = await res.json(); |
| |
| // Clamp current page if items decreased |
| const totalPages = Math.ceil(historyItems.length / historyPageSize) || 1; |
| if (currentHistoryPage > totalPages) { |
| currentHistoryPage = totalPages; |
| } |
| |
| renderHistoryPage(); |
| } catch (err) { |
| console.error("Failed to load history:", err); |
| } |
| } |
| |
| function renderHistoryPage() { |
| const container = document.getElementById("history-list"); |
| container.innerHTML = ""; |
| |
| if (historyItems.length === 0) { |
| container.innerHTML = `<div style="text-align:center; padding:20px; font-size:12px; color:var(--text-muted);">No history found. Click "Surprise Me!"</div>`; |
| document.getElementById("page-indicator").textContent = "Page 1 of 1"; |
| document.getElementById("btn-page-prev").disabled = true; |
| document.getElementById("btn-page-next").disabled = true; |
| return; |
| } |
| |
| const totalPages = Math.ceil(historyItems.length / historyPageSize) || 1; |
| document.getElementById("page-indicator").textContent = `Page ${currentHistoryPage} of ${totalPages}`; |
| document.getElementById("btn-page-prev").disabled = (currentHistoryPage === 1); |
| document.getElementById("btn-page-next").disabled = (currentHistoryPage === totalPages); |
| |
| const startIndex = (currentHistoryPage - 1) * historyPageSize; |
| const endIndex = startIndex + historyPageSize; |
| const pageItems = historyItems.slice(startIndex, endIndex); |
| |
| pageItems.forEach((item) => { |
| const card = document.createElement("div"); |
| card.className = "history-card"; |
| if (activeReport && activeReport.scenario_id === item.scenario_id) { |
| card.classList.add("active"); |
| } |
| |
| let sysText = "N/A"; |
| let scoreClass = "na"; |
| let sysTooltip = `Engine Visual: Not audited yet (Spec: ${item.score.toFixed(1)}/10)`; |
| if (item.sys_vc_score !== null && item.sys_vc_score !== undefined) { |
| sysText = item.sys_vc_score.toFixed(1); |
| if (item.sys_vc_score >= 7.5) scoreClass = "high"; |
| else if (item.sys_vc_score >= 5.0) scoreClass = "mid"; |
| else scoreClass = "low"; |
| sysTooltip = `Engine Visual: ${item.sys_vc_score.toFixed(1)}/10 (Spec: ${item.score.toFixed(1)}/10)`; |
| } |
| |
| let llmText = "N/A"; |
| let llmScoreClass = "na"; |
| let llmTooltip = `LLM Visual: Not audited yet (Spec: ${item.llm_score.toFixed(1)}/10)`; |
| if (item.llm_vc_score !== null && item.llm_vc_score !== undefined) { |
| llmText = item.llm_vc_score.toFixed(1); |
| if (item.llm_vc_score >= 7.5) llmScoreClass = "high"; |
| else if (item.llm_vc_score >= 5.0) llmScoreClass = "mid"; |
| else llmScoreClass = "low"; |
| llmTooltip = `LLM Visual: ${item.llm_vc_score.toFixed(1)}/10 (Spec: ${item.llm_score.toFixed(1)}/10)`; |
| } |
| |
| const formattedTime = new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); |
| |
| card.innerHTML = ` |
| <div class="history-card-header"> |
| <span class="history-question">${item.question}</span> |
| <div class="scores-badge"> |
| <span class="history-score ${scoreClass}" title="${sysTooltip}">Engine: ${sysText}</span> |
| <span class="history-score ${llmScoreClass}" title="${llmTooltip}">LLM: ${llmText}</span> |
| </div> |
| </div> |
| <div class="history-time">${formattedTime}</div> |
| `; |
| card.onclick = () => { |
| activeReportFilename = item.filename; |
| loadReport(item.filename); |
| }; |
| container.appendChild(card); |
| }); |
| } |
| |
| function prevHistoryPage() { |
| if (currentHistoryPage > 1) { |
| currentHistoryPage--; |
| renderHistoryPage(); |
| } |
| } |
| |
| function nextHistoryPage() { |
| const totalPages = Math.ceil(historyItems.length / historyPageSize) || 1; |
| if (currentHistoryPage < totalPages) { |
| currentHistoryPage++; |
| renderHistoryPage(); |
| } |
| } |
| |
| function selectScenario(scenarioId, isNewRun = false) { |
| const item = historyItems.find(x => x.scenario_id === scenarioId); |
| if (item) { |
| const index = historyItems.indexOf(item); |
| currentHistoryPage = Math.floor(index / historyPageSize) + 1; |
| activeReportFilename = item.filename; |
| loadReport(item.filename, isNewRun); |
| } |
| } |
| |
| // Background task interface blocker |
| function setSidebarDisabled(disabled) { |
| const sidebar = document.getElementById('sidebar'); |
| if (!sidebar) return; |
| if (disabled) { |
| sidebar.style.pointerEvents = 'none'; |
| sidebar.style.opacity = '0.5'; |
| sidebar.style.filter = 'grayscale(35%)'; |
| sidebar.style.cursor = 'not-allowed'; |
| } else { |
| sidebar.style.pointerEvents = 'auto'; |
| sidebar.style.opacity = '1'; |
| sidebar.style.filter = 'none'; |
| sidebar.style.cursor = 'default'; |
| } |
| } |
| |
| // Chart loaders state toggler |
| function setChartLoading(loading) { |
| const sysEmpty = document.getElementById("system-chart-empty-state"); |
| const sysLoading = document.getElementById("system-chart-loading-state"); |
| const sysContainer = document.getElementById("system-chart-container"); |
| |
| const llmEmpty = document.getElementById("llm-chart-empty-state"); |
| const llmLoading = document.getElementById("llm-chart-loading-state"); |
| const llmContainer = document.getElementById("llm-chart-container"); |
| |
| if (loading) { |
| if (sysEmpty) sysEmpty.style.display = "none"; |
| if (sysLoading) sysLoading.style.display = "flex"; |
| if (sysContainer) sysContainer.style.display = "none"; |
| |
| if (llmEmpty) llmEmpty.style.display = "none"; |
| if (llmLoading) llmLoading.style.display = "flex"; |
| if (llmContainer) llmContainer.style.display = "none"; |
| } else { |
| if (sysLoading) sysLoading.style.display = "none"; |
| if (llmLoading) llmLoading.style.display = "none"; |
| } |
| } |
| |
| async function loadReport(filename, isNewRun = false) { |
| try { |
| activeReportFilename = filename; |
| const res = await fetch(`/api/reports/${filename}`); |
| const data = await res.json(); |
| if (activeReportFilename !== filename) return; |
| data.filename = filename; |
| renderReport(data, isNewRun); |
| } catch (err) { |
| console.error("Failed to load report:", err); |
| } |
| } |
| |
| function renderReport(data, isNewRun = false) { |
| activeReport = data; |
| window.lastReportData = data; |
| setChartLoading(false); |
| |
| document.getElementById("welcome-container").style.display = "none"; |
| document.getElementById("viz-grid").style.display = "grid"; |
| document.querySelector(".details-card").style.display = "block"; |
| document.getElementById("btn-clear-selection").style.display = "block"; |
| |
| const currentTheme = document.documentElement.getAttribute("data-theme") || "light"; |
| const isDark = currentTheme === "dark"; |
| |
| document.getElementById("btn-rerun").style.display = "flex"; |
| document.getElementById("btn-delete").style.display = "flex"; |
| |
| // Update UI active card states |
| document.querySelectorAll(".history-card").forEach(card => { |
| const question = card.querySelector(".history-question").textContent; |
| if (question === data.question) { |
| card.classList.add("active"); |
| } else { |
| card.classList.remove("active"); |
| } |
| }); |
| |
| document.getElementById("query-text").textContent = data.question; |
| |
| // Render System Spec |
| document.getElementById("system-chart-empty-state").style.display = "none"; |
| const systemContainer = document.getElementById("system-chart-container"); |
| systemContainer.style.display = "block"; |
| systemContainer.innerHTML = ""; |
| |
| const strategyBadge = document.getElementById("system-strategy-badge"); |
| strategyBadge.style.display = "inline"; |
| strategyBadge.textContent = `Strategy: ${data.viz_result.strategy}`; |
| |
| // Clone system spec to dynamically customize colors based on active theme |
| const spec = JSON.parse(JSON.stringify(data.system_spec || data.spec || {})); |
| if (spec && Object.keys(spec).length > 0) { |
| if (!spec.config) spec.config = {}; |
| spec.config.background = "transparent"; |
| |
| const textColor = isDark ? "#f9fafb" : "#0f172a"; |
| const gridColor = isDark ? "#243146" : "#e2e8f0"; |
| const mutedColor = isDark ? "#9ca3af" : "#475569"; |
| |
| if (!spec.config.axis) spec.config.axis = {}; |
| spec.config.axis.titleColor = textColor; |
| spec.config.axis.labelColor = mutedColor; |
| spec.config.axis.gridColor = gridColor; |
| spec.config.axis.tickColor = gridColor; |
| |
| if (!spec.config.legend) spec.config.legend = {}; |
| spec.config.legend.labelColor = textColor; |
| spec.config.legend.titleColor = textColor; |
| |
| if (!spec.config.title) spec.config.title = {}; |
| spec.config.title.color = textColor; |
| spec.config.title.subtitleColor = mutedColor; |
| |
| // Apply dynamic subtitle and title limits to prevent layout squeezing |
| const containerWidth = systemContainer.clientWidth || 500; |
| const textLimit = Math.max(300, containerWidth - 40); |
| spec.config.title.limit = textLimit; |
| spec.config.title.subtitleLimit = textLimit; |
| if (spec.title && typeof spec.title === "object") { |
| if (spec.title.limit === undefined) spec.title.limit = textLimit; |
| if (spec.title.subtitleLimit === undefined) spec.title.subtitleLimit = textLimit; |
| } |
| |
| // Render at default specs, scaled proportionally in CSS to avoid squishing |
| |
| vegaEmbed("#system-chart-container", spec, { |
| actions: { export: true, source: false, editor: false }, |
| renderer: "canvas", |
| }).catch(err => { |
| systemContainer.innerHTML = `<div style="color:var(--danger); padding:16px;">Compile error: ${err}</div>`; |
| }); |
| } |
| |
| // Render LLM Chart.js panel |
| const llmContainer = document.getElementById("llm-chart-container"); |
| if (data.llm_chartjs && data.llm_chartjs.type && data.llm_chartjs.data) { |
| document.getElementById("llm-chart-empty-state").style.display = "none"; |
| llmContainer.style.display = "block"; |
| llmContainer.innerHTML = `<div style="position:relative;width:100%;height:340px;"><canvas id="llm-chartjs-canvas"></canvas></div>`; |
| const canvas = document.getElementById("llm-chartjs-canvas"); |
| if (window._llmChart) { |
| try { window._llmChart.destroy(); } catch(e) {} |
| } |
| try { |
| const llmChartjs = JSON.parse(JSON.stringify(data.llm_chartjs)); |
| |
| if (!llmChartjs.options) llmChartjs.options = {}; |
| llmChartjs.options.responsive = true; |
| llmChartjs.options.maintainAspectRatio = false; |
| |
| // Theme options dynamically |
| const textColor = isDark ? "#f9fafb" : "#0f172a"; |
| const gridColor = isDark ? "#243146" : "#e2e8f0"; |
| const mutedColor = isDark ? "#9ca3af" : "#475569"; |
| |
| if (!llmChartjs.options.plugins) llmChartjs.options.plugins = {}; |
| |
| // Style Title |
| if (!llmChartjs.options.plugins.title) llmChartjs.options.plugins.title = {}; |
| llmChartjs.options.plugins.title.color = textColor; |
| llmChartjs.options.plugins.title.font = { |
| family: "var(--font-body)", |
| size: 15, |
| weight: '600' |
| }; |
| |
| // Style Legend |
| if (!llmChartjs.options.plugins.legend) llmChartjs.options.plugins.legend = {}; |
| if (!llmChartjs.options.plugins.legend.labels) llmChartjs.options.plugins.legend.labels = {}; |
| llmChartjs.options.plugins.legend.labels.color = textColor; |
| llmChartjs.options.plugins.legend.labels.font = { family: "var(--font-body)" }; |
| |
| // Style Scales Grid & Ticks |
| if (!llmChartjs.options.scales) llmChartjs.options.scales = {}; |
| for (let key in llmChartjs.options.scales) { |
| const scale = llmChartjs.options.scales[key]; |
| if (!scale.grid) scale.grid = {}; |
| scale.grid.color = gridColor; |
| |
| if (!scale.ticks) scale.ticks = {}; |
| scale.ticks.color = mutedColor; |
| scale.ticks.font = { family: "var(--font-body)" }; |
| |
| if (scale.title) { |
| scale.title.color = textColor; |
| scale.title.font = { family: "var(--font-body)", weight: '600' }; |
| } |
| } |
| |
| window._llmChart = new Chart(canvas, llmChartjs); |
| } catch (err) { |
| llmContainer.innerHTML = `<div style="color:var(--danger); padding:16px;">Chart.js render error: ${err}</div>`; |
| } |
| } else { |
| const emptyState = document.getElementById("llm-chart-empty-state"); |
| emptyState.style.display = "flex"; |
| llmContainer.style.display = "none"; |
| |
| const emptyDesc = emptyState.querySelector(".empty-desc"); |
| if (data.llm_chartjs && data.llm_chartjs.error) { |
| emptyDesc.innerHTML = `<span style="color:var(--danger); font-weight:500;">Refusal: ${data.llm_chartjs.error}</span>`; |
| } else { |
| emptyDesc.textContent = "Direct LLM generated visualization will render here."; |
| } |
| } |
| |
| // Animate score circles |
| renderScoreGauge("system", data.system_score || data.score || 0.0, data.system_critique || data.critique || "No critique"); |
| renderScoreGauge("llm", data.llm_score || 0.0, data.llm_critique || "No critique"); |
| |
| // Always reset visual critique panels when loading a new report |
| ["system", "llm"].forEach(panel => { |
| document.getElementById(`vc-panel-${panel}`).style.display = "none"; |
| document.getElementById(`vc-score-${panel}`).textContent = "\u2013"; |
| document.getElementById(`vc-critique-${panel}`).textContent = ""; |
| document.getElementById(`vc-recs-${panel}`).innerHTML = ""; |
| }); |
| document.getElementById("btn-visual-critique-global").style.display = "flex"; |
| document.getElementById("btn-visual-critique-global").disabled = false; |
| const globalLabel = document.getElementById("btn-vc-label-global"); |
| if (globalLabel) globalLabel.textContent = "Run Visual Critique"; |
| |
| // Auto-capture rendered charts, then load cached critique |
| if (data.scenario_id) { |
| const scenarioId = data.scenario_id; |
| |
| // Load cached visual critique immediately (instant visual) |
| fetch(`/api/reports/${scenarioId}/visual-critique`) |
| .then(res => res.ok ? res.json() : null) |
| .then(critique => { |
| if (activeReport && activeReport.scenario_id !== scenarioId) return; |
| if (!critique) { |
| if (globalLabel) globalLabel.textContent = "Run Visual Critique"; |
| return; |
| } |
| if (globalLabel) globalLabel.textContent = "Visual Critique (Cached)"; |
| |
| ["system", "llm"].forEach(panel => { |
| const key = panel === "system" ? "system_visual_critique" : "llm_visual_critique"; |
| const result = critique[key]; |
| if (!result) return; |
| applyVisualCritiqueResult(panel, result); |
| }); |
| }) |
| .catch(() => {}); |
| |
| // Delay DOM-to-PNG screen capture slightly to guarantee rendering has finished |
| setTimeout(() => { |
| captureAndSaveRenderedCharts(scenarioId); |
| }, 1500); |
| } |
| |
| // Update Specs Code Tab |
| document.getElementById("system-spec-code").textContent = JSON.stringify(data.system_spec || data.spec, null, 2); |
| document.getElementById("llm-spec-code").textContent = JSON.stringify(data.llm_chartjs || {}, null, 2); |
| |
| // Update Parameters Tab |
| document.getElementById("resolved-code-block").textContent = JSON.stringify({ |
| resolved_indicators: data.resolved_indicators, |
| viz_result: data.viz_result |
| }, null, 2); |
| |
| // Log Console Rendering |
| const logConsole = document.getElementById("log-console"); |
| logConsole.innerHTML = ""; |
| if (data.logs && data.logs.length > 0) { |
| data.logs.forEach(l => { |
| const line = document.createElement("div"); |
| line.className = "terminal-line"; |
| if (l.includes("score:") || l.includes("successfully") || l.includes("completed") || l.includes("generated")) { |
| line.classList.add("info"); |
| } else if (l.includes("failed") || l.includes("error")) { |
| line.classList.add("err"); |
| } |
| line.textContent = l; |
| logConsole.appendChild(line); |
| }); |
| } else { |
| logConsole.innerHTML = `<div class="terminal-line">No console log history available for this run.</div>`; |
| } |
| } |
| |
| function renderScoreGauge(prefix, score, critique) { |
| document.getElementById(`${prefix}-score-banner`).style.display = "flex"; |
| document.getElementById(`${prefix}-score-num`).textContent = score.toFixed(1); |
| |
| const percent = score / 10; |
| const circumference = 188.5; // 2 * pi * 30 |
| const offset = circumference - (percent * circumference); |
| |
| const circle = document.getElementById(`${prefix}-circle-progress`); |
| circle.style.strokeDashoffset = offset; |
| |
| if (score >= 7.5) { |
| circle.style.stroke = "var(--success)"; |
| } else if (score >= 5.0) { |
| circle.style.stroke = "var(--warning)"; |
| } else { |
| circle.style.stroke = "var(--danger)"; |
| } |
| |
| document.getElementById(`${prefix}-critique-text`).textContent = critique; |
| } |
| |
| // Trigger Single Scenario |
| function showHomepage() { |
| activeReport = null; |
| activeReportFilename = null; |
| |
| // Hide active scenario UI elements |
| document.getElementById("btn-visual-critique-global").style.display = "none"; |
| document.getElementById("btn-rerun").style.display = "none"; |
| document.getElementById("btn-delete").style.display = "none"; |
| document.getElementById("btn-clear-selection").style.display = "none"; |
| |
| // Reset query display text |
| document.getElementById("query-text").textContent = 'No active query. Click "Surprise Me!" or trigger a past run.'; |
| |
| // Hide charts and details, show welcome guide |
| document.getElementById("viz-grid").style.display = "none"; |
| document.querySelector(".details-card").style.display = "none"; |
| document.getElementById("welcome-container").style.display = "block"; |
| |
| // Deselect active cards in the sidebar |
| document.querySelectorAll(".history-card").forEach(card => card.classList.remove("active")); |
| } |
| |
| async function triggerSurpriseMe() { |
| const btn = document.getElementById("btn-surprise"); |
| const spinner = document.getElementById("btn-spinner"); |
| const label = document.getElementById("btn-label"); |
| |
| btn.disabled = true; |
| spinner.style.display = "block"; |
| label.textContent = "Generating..."; |
| |
| document.getElementById("welcome-container").style.display = "none"; |
| document.getElementById("viz-grid").style.display = "grid"; |
| document.querySelector(".details-card").style.display = "block"; |
| document.getElementById("btn-clear-selection").style.display = "block"; |
| |
| // Empty states during generate |
| document.getElementById("query-text").textContent = "Generating visual query scenario using LLM..."; |
| document.getElementById("system-chart-container").innerHTML = ""; |
| document.getElementById("system-chart-empty-state").style.display = "flex"; |
| document.getElementById("system-score-banner").style.display = "none"; |
| document.getElementById("system-strategy-badge").style.display = "none"; |
| document.getElementById("btn-rerun").style.display = "none"; |
| document.getElementById("btn-delete").style.display = "none"; |
| |
| // Reset visual critique panels |
| document.getElementById("vc-panel-system").style.display = "none"; |
| document.getElementById("vc-panel-llm").style.display = "none"; |
| document.getElementById("btn-visual-critique-global").disabled = true; |
| document.getElementById("btn-visual-critique-global").style.display = "none"; |
| |
| document.getElementById("llm-chart-container").innerHTML = ""; |
| document.getElementById("llm-chart-empty-state").style.display = "flex"; |
| document.getElementById("llm-score-banner").style.display = "none"; |
| |
| const logConsole = document.getElementById("log-console"); |
| logConsole.innerHTML = `<div class="terminal-line info">Triggering Surprise-Me request...</div>`; |
| |
| try { |
| const res = await fetch("/api/surprise-me", { method: "POST" }); |
| if (!res.ok) { |
| const err = await res.json(); |
| throw new Error(err.error || "Unknown server error"); |
| } |
| const data = await res.json(); |
| |
| // Inject filename for active tracking |
| const filename = `${data.scenario_id}.json`; |
| activeReportFilename = filename; |
| data.filename = filename; |
| |
| renderReport(data, true); |
| await loadHistory(); |
| } catch (err) { |
| logConsole.innerHTML += `<div class="terminal-line err">Error during execution: ${err.message}</div>`; |
| document.getElementById("query-text").textContent = "Generation failed. Review logs in terminal below."; |
| } finally { |
| btn.disabled = false; |
| spinner.style.display = "none"; |
| label.textContent = "✨ Surprise Me!"; |
| } |
| } |
| |
| // Shared helper: populate a visual critique panel with a result object |
| function applyVisualCritiqueResult(panel, result) { |
| if (!result) return; |
| const score = result.score ?? 0; |
| const critique = result.critique ?? "No critique available."; |
| const recs = result.recommendations ?? []; |
| |
| const scoreEl = document.getElementById(`vc-score-${panel}`); |
| const critiqueEl = document.getElementById(`vc-critique-${panel}`); |
| const recsEl = document.getElementById(`vc-recs-${panel}`); |
| const panelEl = document.getElementById(`vc-panel-${panel}`); |
| |
| scoreEl.textContent = score.toFixed(1); |
| scoreEl.style.color = score >= 7.5 ? "var(--success)" : score >= 5 ? "var(--warning)" : "var(--danger)"; |
| critiqueEl.textContent = critique; |
| recsEl.innerHTML = recs.map(r => `<li>${r}</li>`).join(""); |
| panelEl.style.display = "flex"; |
| panelEl.style.flexDirection = "column"; |
| |
| const btn = document.getElementById(`btn-visual-critique-${panel}`); |
| if (btn) { |
| const textSpan = btn.querySelector("span:not(.btn-spinner)"); |
| if (textSpan) textSpan.textContent = "Visual Critique (Cached)"; |
| } |
| } |
| |
| // Visual DeepEval critique for both rendered charts |
| async function triggerVisualCritiqueGlobal() { |
| if (!activeReportFilename) return; |
| const scenarioId = activeReportFilename.replace(".json", ""); |
| |
| const btn = document.getElementById("btn-visual-critique-global"); |
| const spinner = document.getElementById("vc-spinner-global"); |
| const label = document.getElementById("btn-vc-label-global"); |
| |
| btn.disabled = true; |
| spinner.style.display = "block"; |
| label.textContent = "Running Vision Audit..."; |
| setSidebarDisabled(true); |
| |
| const logConsole = document.getElementById("log-console"); |
| logConsole.innerHTML += `<div class="terminal-line info">Running Visual Critique on both spec renders (GPT-4o Vision)...</div>`; |
| |
| try { |
| const res = await fetch(`/api/reports/${scenarioId}/visual-critique`, { method: "POST" }); |
| if (!res.ok) { |
| const err = await res.json(); |
| throw new Error(err.error || "Visual critique request failed."); |
| } |
| const data = await res.json(); |
| |
| if (activeReport && activeReport.scenario_id !== scenarioId) return; |
| |
| ["system", "llm"].forEach(panel => { |
| const key = panel === "system" ? "system_visual_critique" : "llm_visual_critique"; |
| const result = data[key]; |
| if (result) { |
| applyVisualCritiqueResult(panel, result); |
| const score = result.score ?? 0; |
| logConsole.innerHTML += `<div class="terminal-line info">Visual Critique (${panel}): ${score.toFixed(1)}/10</div>`; |
| } |
| }); |
| |
| label.textContent = "Visual Critique (Cached)"; |
| } catch (err) { |
| logConsole.innerHTML += `<div class="terminal-line err">Visual Critique error: ${err.message}</div>`; |
| label.textContent = "Run Visual Critique"; |
| } finally { |
| btn.disabled = false; |
| spinner.style.display = "none"; |
| setSidebarDisabled(false); |
| } |
| } |
| |
| // Rerun currently active report |
| async function triggerRerun() { |
| if (!activeReportFilename) return; |
| |
| const btn = document.getElementById("btn-rerun"); |
| const spinner = document.getElementById("btn-rerun-spinner"); |
| const label = document.getElementById("btn-rerun-label"); |
| |
| btn.disabled = true; |
| spinner.style.display = "block"; |
| label.textContent = "Rerunning..."; |
| setSidebarDisabled(true); |
| setChartLoading(true); |
| |
| const logConsole = document.getElementById("log-console"); |
| logConsole.innerHTML = `<div class="terminal-line info">Triggering rerun for report: ${activeReportFilename}...</div>`; |
| |
| try { |
| const res = await fetch(`/api/reports/${activeReportFilename}/rerun`, { method: "POST" }); |
| if (!res.ok) { |
| const err = await res.json(); |
| throw new Error(err.error || "Unknown server error"); |
| } |
| const data = await res.json(); |
| |
| // Reload history & render new report data |
| await loadHistory(); |
| await loadReport(activeReportFilename); |
| } catch (err) { |
| logConsole.innerHTML += `<div class="terminal-line err">Error during rerun: ${err.message}</div>`; |
| setChartLoading(false); |
| } finally { |
| btn.disabled = false; |
| spinner.style.display = "none"; |
| label.textContent = "🔄 Rerun this run"; |
| setSidebarDisabled(false); |
| } |
| } |
| |
| // Delete currently active report and its artifacts |
| async function triggerDelete() { |
| if (!activeReportFilename) return; |
| |
| const scenarioId = activeReport ? activeReport.scenario_id : null; |
| if (!scenarioId) return; |
| |
| if (!confirm("Are you sure you want to delete this run and all its related artifacts? This action cannot be undone.")) { |
| return; |
| } |
| |
| const btn = document.getElementById("btn-delete"); |
| const spinner = document.getElementById("btn-delete-spinner"); |
| const label = document.getElementById("btn-delete-label"); |
| |
| btn.disabled = true; |
| spinner.style.display = "block"; |
| label.textContent = "Deleting..."; |
| |
| const logConsole = document.getElementById("log-console"); |
| logConsole.innerHTML = `<div class="terminal-line info">Deleting report: ${activeReportFilename}...</div>`; |
| |
| try { |
| const res = await fetch(`/api/reports/${scenarioId}/delete`, { method: "POST" }); |
| if (!res.ok) { |
| const err = await res.json(); |
| throw new Error(err.error || "Unknown server error"); |
| } |
| const data = await res.json(); |
| |
| logConsole.innerHTML += `<div class="terminal-line success">Successfully deleted ${data.deleted.length} files.</div>`; |
| |
| // Return to homepage and reload history |
| showHomepage(); |
| await loadHistory(); |
| } catch (err) { |
| logConsole.innerHTML += `<div class="terminal-line err">Error during deletion: ${err.message}</div>`; |
| } finally { |
| btn.disabled = false; |
| spinner.style.display = "none"; |
| label.textContent = "🗑️ Delete this run"; |
| } |
| } |
| |
| // Trigger Batch Run (20 sets) |
| async function triggerBatchSurprise() { |
| const btn = document.getElementById("btn-batch"); |
| const spinner = document.getElementById("btn-batch-spinner"); |
| const label = document.getElementById("btn-batch-label"); |
| |
| btn.disabled = true; |
| spinner.style.display = "block"; |
| label.textContent = "Launching Batch..."; |
| |
| try { |
| const res = await fetch("/api/batch-surprise", { method: "POST" }); |
| if (!res.ok) { |
| const err = await res.json(); |
| throw new Error(err.error || "Unknown server error"); |
| } |
| |
| // Start polling for batch status |
| document.getElementById("batch-progress-bar").style.display = "flex"; |
| pollBatchStatus(); |
| if (!batchCheckInterval) { |
| batchCheckInterval = setInterval(pollBatchStatus, 3000); |
| } |
| } catch (err) { |
| alert("Failed to start batch: " + err.message); |
| btn.disabled = false; |
| spinner.style.display = "none"; |
| label.textContent = "📦 Batch Run (50 sets)"; |
| } |
| } |
| |
| async function pollBatchStatus() { |
| try { |
| const res = await fetch("/api/batch-status"); |
| const status = await res.json(); |
| |
| if (status.running) { |
| document.getElementById("batch-progress-bar").style.display = "flex"; |
| document.getElementById("batch-progress-text").textContent = `${status.current}/${status.total} sets completed`; |
| |
| document.getElementById("btn-batch").disabled = true; |
| document.getElementById("btn-batch-spinner").style.display = "block"; |
| document.getElementById("btn-batch-label").textContent = "Batch Running..."; |
| } else { |
| // Stopped running |
| document.getElementById("batch-progress-bar").style.display = "none"; |
| document.getElementById("btn-batch").disabled = false; |
| document.getElementById("btn-batch-spinner").style.display = "none"; |
| document.getElementById("btn-batch-label").textContent = "📦 Batch Run (50 sets)"; |
| |
| if (batchCheckInterval) { |
| clearInterval(batchCheckInterval); |
| batchCheckInterval = null; |
| } |
| await loadHistory(); |
| } |
| } catch (err) { |
| console.error("Error polling batch status:", err); |
| } |
| } |
| |
| async function cancelBatchSurprise() { |
| if (!confirm("Are you sure you want to cancel the active batch run?")) return; |
| try { |
| const res = await fetch("/api/batch-cancel", { method: "POST" }); |
| const data = await res.json(); |
| alert(data.status); |
| await pollBatchStatus(); |
| } catch (err) { |
| alert("Failed to cancel batch: " + err.message); |
| } |
| } |
| |
| // Switch Tabs helper |
| function switchTab(evt, tabId) { |
| document.querySelectorAll(".tab-btn").forEach(btn => btn.classList.remove("active")); |
| document.querySelectorAll(".tab-body").forEach(body => body.classList.remove("active")); |
| |
| evt.currentTarget.classList.add("active"); |
| document.getElementById(tabId).classList.add("active"); |
| } |
| |
| // Sidebar Tabs switching helper |
| function switchSidebarTab(tab) { |
| const tabHistory = document.getElementById('sidebar-tab-history'); |
| const tabBuilder = document.getElementById('sidebar-tab-builder'); |
| const contentHistory = document.getElementById('history-tab-content'); |
| const contentBuilder = document.getElementById('builder-tab-content'); |
| |
| if (tab === 'history') { |
| tabHistory.style.background = 'var(--surface2)'; |
| tabHistory.style.borderBottomColor = 'var(--accent)'; |
| tabHistory.style.fontWeight = '600'; |
| tabHistory.style.color = 'var(--text)'; |
| |
| tabBuilder.style.background = 'var(--surface1)'; |
| tabBuilder.style.borderBottomColor = 'transparent'; |
| tabBuilder.style.fontWeight = '500'; |
| tabBuilder.style.color = 'var(--text-muted)'; |
| |
| contentHistory.style.display = 'block'; |
| contentBuilder.style.display = 'none'; |
| } else { |
| tabBuilder.style.background = 'var(--surface2)'; |
| tabBuilder.style.borderBottomColor = 'var(--accent)'; |
| tabBuilder.style.fontWeight = '600'; |
| tabBuilder.style.color = 'var(--text)'; |
| |
| tabHistory.style.background = 'var(--surface1)'; |
| tabHistory.style.borderBottomColor = 'transparent'; |
| tabHistory.style.fontWeight = '500'; |
| tabHistory.style.color = 'var(--text-muted)'; |
| |
| contentHistory.style.display = 'none'; |
| contentBuilder.style.display = 'block'; |
| updateCountryBadges(); |
| } |
| } |
| |
| let selectedIndicator = null; |
| let currentDisaggregation = null; |
| const allGroups = [ |
| { code: 'EAS', name: 'East Asia & Pacific' }, |
| { code: 'ECS', name: 'Europe & Central Asia' }, |
| { code: 'LCN', name: 'Latin America & Caribbean' }, |
| { code: 'MEA', name: 'Middle East & North Africa' }, |
| { code: 'NAC', name: 'North America' }, |
| { code: 'SAS', name: 'South Asia' }, |
| { code: 'SSF', name: 'Sub-Saharan Africa' }, |
| { code: 'LIC', name: 'Low income' }, |
| { code: 'LMC', name: 'Lower middle income' }, |
| { code: 'UMC', name: 'Upper middle income' }, |
| { code: 'HIC', name: 'High income' } |
| ]; |
| let selectedGroupCountries = []; |
| |
| window.onGroupSelected = async function() { |
| const select = document.getElementById('builder-group-select'); |
| const configDiv = document.getElementById('builder-group-config'); |
| if (!select || !configDiv) return; |
| const code = select.value; |
| if (!code) { |
| configDiv.style.display = 'none'; |
| return; |
| } |
| |
| const group = allGroups.find(g => g.code === code); |
| if (!group) return; |
| |
| document.getElementById('builder-selected-group-name').innerText = `${group.name} (${group.code})`; |
| document.getElementById('builder-selected-group-code-direct').innerText = group.code; |
| document.getElementById('builder-selected-group-count').innerText = "..."; |
| configDiv.style.display = 'flex'; |
| |
| try { |
| const res = await fetch(`/api/groups/${code}/expand`); |
| selectedGroupCountries = await res.json(); |
| document.getElementById('builder-selected-group-count').innerText = selectedGroupCountries.length; |
| } catch (err) { |
| console.error("Failed to load group details:", err); |
| document.getElementById('builder-selected-group-count').innerText = "error"; |
| } |
| }; |
| |
| window.addGroupToBuilder = function() { |
| const select = document.getElementById('builder-group-select'); |
| const configDiv = document.getElementById('builder-group-config'); |
| if (!select || !configDiv) return; |
| const code = select.value; |
| if (!code) return; |
| |
| const mode = document.querySelector('input[name="builder-group-mode"]:checked').value; |
| if (mode === 'direct') { |
| addCountryCode(code); |
| } else { |
| if (Array.isArray(selectedGroupCountries)) { |
| selectedGroupCountries.forEach(c => addCountryCode(c)); |
| } |
| } |
| |
| select.value = ''; |
| configDiv.style.display = 'none'; |
| }; |
| |
| // Assisted country selection helpers |
| function updateCountryBadges() { |
| const input = document.getElementById('builder-countries'); |
| const container = document.getElementById('builder-selected-countries-badges'); |
| if (!input || !container) return; |
| container.innerHTML = ''; |
| |
| const codes = input.value.split(';').map(c => c.trim()).filter(Boolean); |
| codes.forEach(code => { |
| const badge = document.createElement('div'); |
| badge.style.display = 'inline-flex'; |
| badge.style.alignItems = 'center'; |
| badge.style.gap = '6px'; |
| badge.style.background = 'rgba(255, 255, 255, 0.05)'; |
| badge.style.border = '1px solid var(--border)'; |
| badge.style.padding = '4px 8px'; |
| badge.style.borderRadius = '4px'; |
| badge.style.fontSize = '11px'; |
| badge.style.color = 'var(--text)'; |
| badge.innerHTML = ` |
| <span style="font-weight:600; font-family:var(--font-mono);">${code}</span> |
| <span onclick="removeCountryCode('${code}')" style="cursor:pointer; color:var(--text-muted); font-weight:700; transition:color 0.2s;" onmouseover="this.style.color='var(--danger)'" onmouseout="this.style.color='var(--text-muted)'">×</span> |
| `; |
| container.appendChild(badge); |
| }); |
| } |
| |
| window.removeCountryCode = function(code) { |
| const input = document.getElementById('builder-countries'); |
| if (!input) return; |
| const codes = input.value.split(';').map(c => c.trim()).filter(Boolean); |
| const index = codes.indexOf(code); |
| if (index !== -1) { |
| codes.splice(index, 1); |
| input.value = codes.join(';'); |
| updateCountryBadges(); |
| } |
| }; |
| |
| function addCountryCode(code) { |
| const input = document.getElementById('builder-countries'); |
| if (!input) return; |
| const codes = input.value.split(';').map(c => c.trim()).filter(Boolean); |
| if (!codes.includes(code)) { |
| codes.push(code); |
| input.value = codes.join(';'); |
| updateCountryBadges(); |
| } |
| document.getElementById('builder-country-search').value = ''; |
| document.getElementById('builder-country-results').style.display = 'none'; |
| } |
| |
| async function searchBuilderCountries() { |
| const query = document.getElementById('builder-country-search').value.trim(); |
| if (!query) return; |
| |
| const resultsDiv = document.getElementById('builder-country-results'); |
| resultsDiv.innerHTML = '<div style="padding: 8px; color: var(--text-muted); font-size:11px;">Searching countries...</div>'; |
| resultsDiv.style.display = 'block'; |
| |
| try { |
| const res = await fetch(`/api/search-countries?query=${encodeURIComponent(query)}`); |
| const data = await res.json(); |
| if (!data.length) { |
| resultsDiv.innerHTML = '<div style="padding: 8px; color: var(--text-muted); font-size:11px;">No countries found.</div>'; |
| return; |
| } |
| |
| resultsDiv.innerHTML = ''; |
| data.forEach(item => { |
| const card = document.createElement('div'); |
| card.style.padding = '6px 10px'; |
| card.style.borderBottom = '1px solid var(--border)'; |
| card.style.cursor = 'pointer'; |
| card.style.fontSize = '11px'; |
| card.style.color = 'var(--text)'; |
| card.innerHTML = ` |
| <span style="font-weight:600;">${item.name}</span> |
| <span style="float:right; font-family:monospace; color:var(--accent); font-weight:600;">${item.id}</span> |
| `; |
| card.onmouseover = () => card.style.background = 'rgba(255,255,255,0.05)'; |
| card.onmouseout = () => card.style.background = 'transparent'; |
| card.onclick = () => addCountryCode(item.id); |
| resultsDiv.appendChild(card); |
| }); |
| } catch (err) { |
| resultsDiv.innerHTML = `<div style="padding: 8px; color: var(--danger); font-size:11px;">Error: ${err.message}</div>`; |
| } |
| } |
| |
| // Call updateCountryBadges on load |
| document.addEventListener("DOMContentLoaded", () => { |
| setTimeout(updateCountryBadges, 100); |
| }); |
| |
| async function searchBuilderIndicators() { |
| const query = document.getElementById('builder-search-input').value.trim(); |
| if (!query) return; |
| |
| const resultsDiv = document.getElementById('builder-search-results'); |
| resultsDiv.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size:12px;">Searching...</div>'; |
| resultsDiv.style.display = 'block'; |
| |
| try { |
| const res = await fetch(`/api/search-indicators?query=${encodeURIComponent(query)}`); |
| const data = await res.json(); |
| if (data.error) { |
| resultsDiv.innerHTML = `<div style="padding: 10px; color: var(--danger); font-size:12px;">Error: ${data.error}</div>`; |
| return; |
| } |
| if (!data.length) { |
| resultsDiv.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size:12px;">No indicators found.</div>'; |
| return; |
| } |
| |
| resultsDiv.innerHTML = ''; |
| data.forEach(ind => { |
| const card = document.createElement('div'); |
| card.style.padding = '8px 12px'; |
| card.style.borderBottom = '1px solid var(--border)'; |
| card.style.cursor = 'pointer'; |
| card.style.fontSize = '12px'; |
| card.style.transition = 'all 0.2s'; |
| card.innerHTML = ` |
| <div style="font-weight: 600; color: var(--text);">${ind.name}</div> |
| <div style="font-size: 10px; color: var(--text-muted); font-family: monospace; margin-top:2px;">${ind.database_id} | ${ind.indicator_id}</div> |
| `; |
| card.onmouseover = () => card.style.background = 'rgba(255,255,255,0.05)'; |
| card.onmouseout = () => card.style.background = 'transparent'; |
| card.onclick = () => selectIndicatorForBuilder(ind); |
| resultsDiv.appendChild(card); |
| }); |
| } catch (err) { |
| resultsDiv.innerHTML = `<div style="padding: 10px; color: var(--danger); font-size:12px;">Search failed: ${err}</div>`; |
| } |
| } |
| |
| async function selectIndicatorForBuilder(ind) { |
| selectedIndicator = ind; |
| document.getElementById('builder-search-results').style.display = 'none'; |
| document.getElementById('builder-search-input').value = ind.name; |
| |
| // Show selected indicator card details |
| document.getElementById('selected-indicator-db').innerText = ind.database_id; |
| document.getElementById('selected-indicator-name').innerText = ind.name; |
| document.getElementById('selected-indicator-id').innerText = ind.indicator_id; |
| document.getElementById('builder-configurator').style.display = 'flex'; |
| |
| // Fetch disaggregation details |
| const disaggList = document.getElementById('builder-disaggregations-list'); |
| disaggList.innerHTML = '<div style="color: var(--text-muted); font-size:12px;">Loading options...</div>'; |
| |
| try { |
| const res = await fetch(`/api/indicator-disaggregation?database_id=${ind.database_id}&indicator_id=${ind.indicator_id}`); |
| const data = await res.json(); |
| currentDisaggregation = data; |
| |
| // 1. Populate Years |
| const startSelect = document.getElementById('builder-start-year'); |
| const endSelect = document.getElementById('builder-end-year'); |
| startSelect.innerHTML = ''; |
| endSelect.innerHTML = ''; |
| |
| const timeDim = data.dimensions ? data.dimensions.find(d => d.field_name === 'TIME_PERIOD') : null; |
| let years = []; |
| if (timeDim && Array.isArray(timeDim.field_value)) { |
| years = timeDim.field_value.map(y => parseInt(y)).sort((a,b) => a - b); |
| } else { |
| // Fallback years |
| const currentYear = new Date().getFullYear(); |
| for (let y = currentYear - 15; y <= currentYear; y++) { |
| years.push(y); |
| } |
| } |
| |
| years.forEach(year => { |
| const optStart = document.createElement('option'); |
| optStart.value = year; |
| optStart.innerText = year; |
| startSelect.appendChild(optStart); |
| |
| const optEnd = document.createElement('option'); |
| optEnd.value = year; |
| optEnd.innerText = year; |
| endSelect.appendChild(optEnd); |
| }); |
| |
| // Default to last 10 years or max range |
| if (years.length > 0) { |
| startSelect.value = years[0]; |
| endSelect.value = years[years.length - 1]; |
| } |
| |
| // 2. Populate Disaggregation Dimensions (excluding TIME_PERIOD and REF_AREA) |
| disaggList.innerHTML = ''; |
| const otherDims = data.dimensions ? data.dimensions.filter(d => d.field_name !== 'TIME_PERIOD' && d.field_name !== 'REF_AREA' && d.field_name.toUpperCase() !== 'REGION') : []; |
| |
| if (!otherDims.length) { |
| disaggList.innerHTML = '<div style="color: var(--text-muted); font-size:12px;">No breakdowns available (National values only).</div>'; |
| } else { |
| otherDims.forEach(dim => { |
| const dimGroup = document.createElement('div'); |
| dimGroup.style.display = 'flex'; |
| dimGroup.style.flexDirection = 'column'; |
| dimGroup.style.gap = '4px'; |
| dimGroup.style.borderBottom = '1px solid var(--border)'; |
| dimGroup.style.paddingBottom = '8px'; |
| dimGroup.style.marginBottom = '4px'; |
| |
| dimGroup.innerHTML = ` |
| <div style="font-size:11px; font-weight:600; color:var(--text);">${dim.field_name}</div> |
| <div style="display:flex; flex-wrap:wrap; gap:8px;" id="dim-values-${dim.field_name}"></div> |
| `; |
| disaggList.appendChild(dimGroup); |
| |
| const valuesContainer = document.getElementById(`dim-values-${dim.field_name}`); |
| |
| // Add "All / Compare" radio option first |
| const allLabel = document.createElement('label'); |
| allLabel.style.display = 'flex'; |
| allLabel.style.alignItems = 'center'; |
| allLabel.style.gap = '4px'; |
| allLabel.style.fontSize = '12px'; |
| allLabel.style.cursor = 'pointer'; |
| allLabel.style.color = 'var(--text)'; |
| allLabel.innerHTML = ` |
| <input type="radio" name="dim-filter-${dim.field_name}" value="__ALL__" checked style="cursor:pointer;"> |
| <span style="font-weight: 500;">All / Compare</span> |
| `; |
| valuesContainer.appendChild(allLabel); |
| |
| dim.field_value.forEach(val => { |
| const label = document.createElement('label'); |
| label.style.display = 'flex'; |
| label.style.alignItems = 'center'; |
| label.style.gap = '4px'; |
| label.style.fontSize = '12px'; |
| label.style.cursor = 'pointer'; |
| label.style.color = 'var(--text-muted)'; |
| |
| label.innerHTML = ` |
| <input type="radio" name="dim-filter-${dim.field_name}" value="${val}" style="cursor:pointer;"> |
| <span>${val}</span> |
| `; |
| valuesContainer.appendChild(label); |
| }); |
| }); |
| } |
| |
| } catch (err) { |
| disaggList.innerHTML = `<div style="color: var(--danger); font-size:12px;">Failed to load dimensions: ${err}</div>`; |
| } |
| } |
| |
| async function generateCustomBuilderChart() { |
| if (!selectedIndicator) return; |
| |
| const generateBtn = document.getElementById('btn-builder-generate'); |
| const oldText = generateBtn.innerText; |
| generateBtn.disabled = true; |
| generateBtn.innerText = 'Generating Visuals...'; |
| setSidebarDisabled(true); |
| setChartLoading(true); |
| |
| // Build filters |
| const disaggregation_filters = {}; |
| const otherDims = currentDisaggregation && currentDisaggregation.dimensions |
| ? currentDisaggregation.dimensions.filter(d => d.field_name !== 'TIME_PERIOD' && d.field_name !== 'REF_AREA' && d.field_name.toUpperCase() !== 'REGION') |
| : []; |
| |
| otherDims.forEach(dim => { |
| const radioName = `dim-filter-${dim.field_name}`; |
| const selectedRadio = document.querySelector(`input[name="${radioName}"]:checked`); |
| if (selectedRadio && selectedRadio.value !== '__ALL__') { |
| disaggregation_filters[dim.field_name] = selectedRadio.value; |
| } |
| }); |
| |
| const body = { |
| database_id: selectedIndicator.database_id, |
| indicator_id: selectedIndicator.indicator_id, |
| indicator_name: selectedIndicator.name, |
| country_code: document.getElementById('builder-countries').value.trim(), |
| start_year: parseInt(document.getElementById('builder-start-year').value), |
| end_year: parseInt(document.getElementById('builder-end-year').value), |
| chart_type: document.getElementById('builder-chart-type').value || null, |
| disaggregation_filters: disaggregation_filters |
| }; |
| |
| try { |
| const res = await fetch('/api/custom-chart', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body) |
| }); |
| const data = await res.json(); |
| if (data.error) { |
| alert(`Generation failed: ${data.error}`); |
| setChartLoading(false); |
| return; |
| } |
| |
| // Refresh history list and switch back to history tab |
| await loadHistory(); |
| switchSidebarTab('history'); |
| |
| // Select the newly created custom run |
| selectScenario(data.scenario_id, true); |
| |
| } catch (err) { |
| alert(`Request failed: ${err}`); |
| setChartLoading(false); |
| } finally { |
| generateBtn.disabled = false; |
| generateBtn.innerText = oldText; |
| setSidebarDisabled(false); |
| } |
| } |
| |
| async function captureAndSaveRenderedCharts(scenarioId) { |
| const vegaCanvas = document.querySelector("#system-chart-container canvas"); |
| const chartjsCanvas = document.getElementById("llm-chartjs-canvas"); |
| |
| const sysBase64 = vegaCanvas ? vegaCanvas.toDataURL("image/png") : ""; |
| const llmBase64 = chartjsCanvas ? chartjsCanvas.toDataURL("image/png") : ""; |
| |
| if (!sysBase64 && !llmBase64) return; |
| |
| try { |
| await fetch("/api/save-rendered-images", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| scenario_id: scenarioId, |
| system_png_base64: sysBase64, |
| llm_png_base64: llmBase64 |
| }) |
| }); |
| console.log("Rendered charts saved to backend successfully."); |
| } catch (err) { |
| console.error("Failed to save rendered charts:", err); |
| } |
| } |
| |
| // Init |
| loadHistory(); |
| pollBatchStatus(); |
| setInterval(pollBatchStatus, 5000); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| @app.get("/", response_class=HTMLResponse) |
| def index(): |
| return HTMLResponse(content=HTML_CONTENT) |
|
|
|
|
| if __name__ == "__main__": |
| import argparse |
| parser = argparse.ArgumentParser(description="Run interactive chart explorer server.") |
| parser.add_argument("--port", type=int, default=8090, help="Port to run the dashboard on.") |
| args = parser.parse_args() |
|
|
| print(f"Launching Data360-MCP Visualization Engine Explorer at: http://localhost:{args.port}") |
| uvicorn.run("evals.interactive_explorer:app", host="0.0.0.0", port=args.port, reload=False, loop="asyncio") |
|
|