"""
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 env variables from .env
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")
# Add project root to python path so data360 package is importable
_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
# Setup directories
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
# 1. Fetch file list from Hugging Face REST API using urllib
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"]
# 2. Download each file individually using urllib
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)
# Create private dataset if it doesn't exist yet
api.create_repo(repo_id=HF_DATASET_ID, repo_type="dataset", private=True, exist_ok=True)
# Upload the file
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}")
# FastAPI app
app = FastAPI(title="Data360 MCP - Interactive Chart Explorer")
@app.on_event("startup")
async def startup_event():
sync_reports_from_hf()
# Global batch status
batch_status = {
"running": False,
"current": 0,
"total": 50,
"errors": []
}
batch_cancel_requested = False
# ---------------------------------------------------------------------------
# WB Theme + prepareSpec pipeline (ported from packages/mcp-viz-core/src/)
# ---------------------------------------------------------------------------
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)
# 1. Inline named dataset
name = out.get("data", {}).get("name")
if name and out.get("datasets", {}).get(name):
out["data"] = {"values": out["datasets"][name]}
out.pop("datasets", None)
# 2. Responsive sizing
out["width"] = "container"
out["height"] = chart_height
# 3. Suppress built-in legend unless quantitative
encoding = out.get("encoding", {})
color_enc = encoding.get("color", {})
if color_enc and color_enc.get("type") != "quantitative":
color_enc["legend"] = None
# NOTE: guard 3b (strip title) is intentionally omitted here.
# In the React app, VegaChartCard shows the title in the card header and strips it from the spec.
# In this standalone dashboard there is no card header — the title must remain so vegaEmbed renders it.
# 4. Strip zoom/pan params
out.pop("params", None)
# 5. Normalize schema to v5
out["$schema"] = "https://vega.github.io/schema/vega-lite/v5.json"
# 6. Merge WB theme: WB base, spec config wins on conflict
import copy as _copy
base_theme = _copy.deepcopy(WB_THEME)
existing_config = out.get("config", {})
out["config"] = {**base_theme, **existing_config}
# 7. scale.zero
y_enc = encoding.get("y", {})
if y_enc:
if "scale" not in y_enc:
y_enc["scale"] = {}
y_enc["scale"]["zero"] = (mark_type == "bar")
# 8. x-axis format
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)
# Find actual countries in the retrieved dataset
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()
# ---------------------------------------------------------------------------
# Core Generation Helper
# ---------------------------------------------------------------------------
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...")
# Find recently generated questions to avoid repetition
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
# Keep them unique and cap at last 15
past_questions = list(set(past_questions))[-15:]
past_questions_str = "\n".join(f"- {q}" for q in past_questions)
# 1. Select interesting database indicators first to inspect their breakdown
CURATED_POOL = [
# WDI (Simple indicators without breakdown dimensions in this DB)
{"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)"},
# ESG (Environmental, Social and Governance)
{"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)"},
# WGI (Worldwide Governance Indicators)
{"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"},
# Enterprise Surveys
{"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...")
# 1. Decide number of indicators (single, 2, or 3)
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:
# Pick simple WDI indicators for multi-indicator comparison
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:
# Pick one from the whole pool
selected = [random.choice(CURATED_POOL)]
# Fetch metadata & disaggregation details for selected indicators
indicators_info = []
resolved_indicators = []
for s in selected:
db_id = s["database_id"]
ind_id = s["indicator_id"]
# Get disaggregation
try:
disagg = await get_disaggregation(db_id, ind_id)
dims = disagg.get("dimensions", [])
except Exception:
dims = []
# Clean dimensions to show only meaningful breakdown dimensions
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
})
# Fetch actual name from metadata if possible, fallback to default
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
# 2. Randomly select visual parameters on Python side to bypass LLM bias/hardcoding
# A. Years: Single-year vs. Multi-year
is_single_year = random.random() < 0.4 # 40% chance of single year
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)
# B. Countries: Single vs. Multi-few (2-4) vs. Multi-many (8-15)
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:
# Multi-indicator is typically compared for a single country or a few countries
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:
# Single indicator
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:
# Many countries to trigger distribution / heatmaps
selected_countries = random.sample(country_pool, random.randint(8, 15))
country_code = ";".join(selected_countries)
# C. Disaggregations / Breakdowns
disaggregation_filters = {}
available_dims = indicators_info[0]["breakdown_dimensions"]
if available_dims:
breakdown_choice = random.random()
if breakdown_choice < 0.4:
# Compare all values for a random dimension
dim = random.choice(available_dims)
disaggregation_filters[dim["field_name"]] = None
elif breakdown_choice < 0.8:
# Pin to one specific value
dim = random.choice(available_dims)
val = random.choice(dim["values"])
disaggregation_filters[dim["field_name"]] = val
else:
pass
# 3. Ask OpenAI to generate a realistic natural language question matching these exact parameters
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.
"""
# Add unique seed to force variety
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")
# Construct scenario
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
# 3. Call the visualization engine via local MCP server
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')})")
# Success! Break out of the retry loop
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:
# If we exhausted all attempts, return the final failure error
log(f"All 4 generation attempts failed. Last error: {last_error_msg}")
return {"error": last_error_msg, "status_code": last_status_code}
# Extract raw data rows from the compiled spec
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", [])
# 4a. Apply prepareSpec + WB Theme to the system spec
system_spec = prepare_spec(spec)
log("prepareSpec + WB Theme applied to system spec.")
# 4b. Generate direct LLM Chart.js v4 config from raw data rows and user question
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}")
# 5. Run DeepEval G-Eval quality scoring on BOTH
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}"
# 6. Save report to JSON file with unique scenario ID (timestamp)
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,
# For backwards compatibility:
"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
# ---------------------------------------------------------------------------
# Background Batch Runner
# ---------------------------------------------------------------------------
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
# Add a small delay to avoid hitting rate limits
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
# ---------------------------------------------------------------------------
# API Routes
# ---------------------------------------------------------------------------
@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")
# Check for visual critique file
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
# Sort by timestamp descending
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.
"""
# 1. Strip any directory traversal chars
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"})
# 2. Define filenames to delete
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 = []
# 3. Delete files locally
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}")
# 4. If HF dataset sync is enabled, delete from dataset repository
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:
# Hugging Face Hub delete_file operation
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:
# If file doesn't exist in HF yet (404), it is not a blocking error
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", {})
# Extract params with fallbacks for older formats
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 the loaded country_code is missing or has a suspicious list of all/many countries,
# use the LLM to parse the question into correct parameters.
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}")
# Post-process chart_type to match user query keyword constraints:
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
# Delete old screenshots and critique if they exist
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}")
# Try parsing country codes and years from data as absolute fallback if scenario is missing
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)
# Group by country name
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}")
# Resolve names to ISO country codes
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
# 1. Compile updated visualization engine spec via local MCP server
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}"})
# Extract raw data 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", [])
# 2a. Apply prepareSpec + WB Theme to the system spec
system_spec = prepare_spec(spec)
log("prepareSpec + WB Theme applied to system spec.")
# 2b. Generate direct LLM Chart.js v4 config
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}")
# 3. Run DeepEval G-Eval quality scoring
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}"
# Overwrite the existing report data
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
# Save System spec chart PNG
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)
# Save LLM Chart.js chart PNG
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)
# Update report file with image path metadata
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."""
# Check cache first
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."}
)
# Read query and actual retrieved countries from the saved report JSON
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"))
# Extract actual countries from dataset
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")
# Open image using Pillow to normalize color space and remove transparency
with Image.open(img_path) as img:
# Create a solid white background image matching the size
background = Image.new("RGB", img.size, (255, 255, 255))
# If the image has an alpha channel, use it as a mask when pasting
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)
# Save the flattened RGB image as a JPEG to a temp file
background.save(temp_jpg_path, format="JPEG", quality=90)
try:
# Create G-Eval Visual Audit Metric
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"
)
# Create test case with MLLMImage
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)
# Get G-Eval score (G-Eval scores 0-1, so scale by 10)
score_val = (visual_metric.score or 0.0) * 10.0
reason_text = (visual_metric.reason or "").strip()
# Attempt to parse JSON from reason
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": []}
# Persist the critique
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}"})
# Extract raw data 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", [])
# Apply prepareSpec + WB Theme to the system spec
system_spec = prepare_spec(spec)
log("prepareSpec + WB Theme applied to system spec.")
# Generate direct LLM Chart.js v4 config
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}")
# G-Eval quality scoring
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}"
# Save report to JSON file
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
# ---------------------------------------------------------------------------
# Dashboard Frontend (Single-Page App)
# ---------------------------------------------------------------------------
HTML_CONTENT = """
Data360-MCP Visualization Engine Explorer
Data360-MCP
Visualization Engine Explorer
Connected
Past Runs
Page 1 of 1
Leave blank to query all economies.
Batch Run in progress:0/50 sets completed
Active Scenario Query
No active query. Click "Surprise Me!" or trigger a past run.
Welcome to Data360-MCP Visualization Engine Explorer
An interactive interface for evaluating the Data360 MCP visualization engine vs. Direct LLM renders.
Surprise Me! Action
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.
Run Visual Critique
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.
How it works
The central view allows side-by-side assessment. On the left is the Data360 Engine Spec, applying the repository's strict visualization routing and scaling rules. On the right is the Direct LLM Spec (GPT-4o), which generates free-form Chart.js graphs.
No active query. Click "Surprise Me!" or trigger a past run from the sidebar.
Data360 Engine Spec
Data360 Spec View
System engine visualization will render here.
Compiling Spec...
Retrieving MCP data and rendering charts
0.0/ 10
G-Eval Scorer Critique & RationaleAssesses chart specification correctness, parameter alignment, and proper layout routing based on MCP metadata standards.
No critique.
GPT-4o Vision AuditReviews the rendered chart image for visual bugs: overlapping text, clipped labels, color contrast, and empty flatlining.
–
/ 10
Direct LLM SpecModel: gpt-4o
Direct LLM Spec
Direct LLM generated visualization will render here.
Compiling Spec...
Retrieving Direct LLM visualization output
0.0/ 10
G-Eval Scorer Critique & RationaleAssesses chart specification correctness, parameter alignment, and proper layout routing based on MCP standards.
No critique.
GPT-4o Vision AuditReviews the rendered chart image for visual bugs: overlapping text, clipped labels, color contrast, and empty flatlining.
–
/ 10
Console initialized. Ready to generate scenarios.
Data360 Engine Spec
No spec loaded
Direct LLM Spec
No spec loaded
{ "resolved": "No parameters resolved" }
"""
@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")