Hugging Face Deployer commited on
Commit ·
befe0f0
1
Parent(s): 33fcdf4
Deploy explorer to Hugging Face Spaces
Browse files
evals/prompts/chartjs_prompt.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
def get_chartjs_system_prompt() -> str:
|
| 4 |
+
"""
|
| 5 |
+
Returns the system instructions for generating a valid Chart.js JSON configuration.
|
| 6 |
+
"""
|
| 7 |
+
return (
|
| 8 |
+
"You are a Chart.js v4 JSON config generator. Return only a valid JSON object. "
|
| 9 |
+
"Normally, the object must contain keys: 'type', 'data', and 'options'. "
|
| 10 |
+
"However, if the dataset is not suitable to chart (e.g. empty, all null, or "
|
| 11 |
+
"insufficient data points), you MUST refuse by returning a JSON object containing "
|
| 12 |
+
"exactly one key: 'error' with a clear explanation. No markdown, no leading/trailing text."
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
def get_chartjs_user_prompt(question: str, column_names: list[str], sample_data: list[dict]) -> str:
|
| 16 |
+
"""
|
| 17 |
+
Generates the user instructions containing the dataset structure, rows,
|
| 18 |
+
and visual grammar instructions for Chart.js.
|
| 19 |
+
"""
|
| 20 |
+
return f"""
|
| 21 |
+
You are an expert data visualization engineer. You are given a user request and a dataset.
|
| 22 |
+
Generate a complete, production-ready Chart.js v4 configuration object (JSON) that best represents the requested chart.
|
| 23 |
+
|
| 24 |
+
User Request: "{question}"
|
| 25 |
+
|
| 26 |
+
Dataset columns available: {json.dumps(column_names)}
|
| 27 |
+
Dataset:
|
| 28 |
+
{json.dumps(sample_data, indent=2)}
|
| 29 |
+
|
| 30 |
+
Requirements:
|
| 31 |
+
1. Data Suitability and Refusal:
|
| 32 |
+
- First, evaluate if the dataset is suitable to chart.
|
| 33 |
+
- Refusal Criteria: If the dataset is empty, contains all null values, or contains insufficient non-null data points (e.g. less than 2 data points for a temporal line trend, or 0 data points for categorical comparison), or the requested series/columns are missing:
|
| 34 |
+
- You MUST refuse to chart.
|
| 35 |
+
- Return a single JSON object containing exactly one key: "error" with a clear, specific, human-readable reason why (e.g. {{"error": "No non-null data points available to plot."}}).
|
| 36 |
+
2. JSON Structure: If the dataset is suitable, output a single JSON object with exactly three top-level keys: "type", "data", and "options". Do NOT include an "error" key.
|
| 37 |
+
3. Chart Type Selection: Decide the best Chart.js chart type on your own based solely on the user request and dataset structure. Supported types (exactly these 8 standard Chart.js types):
|
| 38 |
+
- "line": Use for multi-year temporal trends (requires at least 2 distinct data points over time).
|
| 39 |
+
- "bar": Use for single-year comparisons, ranking, or categorical comparisons.
|
| 40 |
+
- "radar": Use for multi-dimensional profile comparisons across 3+ categories.
|
| 41 |
+
- "doughnut": Use for part-to-whole breakdown comparisons (preferred over pie).
|
| 42 |
+
- "pie": Use for simple part-to-whole comparisons.
|
| 43 |
+
- "polarArea": Use for comparing categories with similar/equal angles but differing magnitudes.
|
| 44 |
+
- "bubble": Use for 3-dimensional numeric data comparisons.
|
| 45 |
+
- "scatter": Use for correlation between two numerical indicators.
|
| 46 |
+
4. "data": Structure it properly based on your chosen chart type:
|
| 47 |
+
- For line/bar/radar/polarArea charts:
|
| 48 |
+
* "labels" should be an array of categories (e.g. years, or country names).
|
| 49 |
+
* "datasets" should be an array of objects, one per series. Each dataset object must have:
|
| 50 |
+
* "label": human-readable series name.
|
| 51 |
+
* "data": array of numeric values corresponding to the "labels" array.
|
| 52 |
+
* "borderColor" and/or "backgroundColor": colors chosen from the World Bank palette.
|
| 53 |
+
- For scatter/bubble plots:
|
| 54 |
+
* "datasets" should contain the data points formatted as objects: {{"x": val1, "y": val2}} (or {{"x": x, "y": y, "r": r}} for bubble).
|
| 55 |
+
5. Colors: Use the World Bank palette (cycle through as needed):
|
| 56 |
+
["#34A7F2", "#FF9800", "#664AB6", "#4EC2C0", "#F3578E", "#081079", "#0C7C68"]
|
| 57 |
+
6. "options": Configure title, legend, and scales:
|
| 58 |
+
- "responsive": true, "maintainAspectRatio": false.
|
| 59 |
+
- "plugins": {{"title": {{"display": true, "text": "<descriptive title>"}}, "legend": {{"display": true, "position": "bottom"}}}}
|
| 60 |
+
- "scales": (Only for Cartesian types: line, bar, scatter, bubble):
|
| 61 |
+
{{"x": {{"title": {{"display": true, "text": "<x axis label>"}}}}, "y": {{"title": {{"display": true, "text": "<y axis label>"}}, "beginAtZero": false}}}}
|
| 62 |
+
7. Populate "data" only with values from the dataset above. Do NOT use placeholder or dummy data.
|
| 63 |
+
8. Return ONLY a valid JSON object. No markdown code blocks, no leading/trailing text.
|
| 64 |
+
"""
|
evals/test_chart_rules_deepeval.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Layer 2 - Dynamic Chart Rules Evaluation Suite
|
| 3 |
+
==============================================
|
| 4 |
+
|
| 5 |
+
Tests the Data360 MCP visualization engine + routing rules comprehensively.
|
| 6 |
+
Does not reuse any existing scenarios.
|
| 7 |
+
|
| 8 |
+
Features:
|
| 9 |
+
- Dynamic scenario generation using OpenAI (with static defaults fallback).
|
| 10 |
+
- Transparent caching of all external HTTP API requests (metadata, data, search).
|
| 11 |
+
- Zero-shot G-Eval DeepEval metrics (judging the visual spec grammar from the query).
|
| 12 |
+
- Local run:
|
| 13 |
+
uv run deepeval test run evals/test_chart_rules_deepeval.py
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
import json
|
| 21 |
+
import hashlib
|
| 22 |
+
import asyncio
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any
|
| 25 |
+
from unittest.mock import patch
|
| 26 |
+
|
| 27 |
+
import pytest
|
| 28 |
+
import pandas as pd
|
| 29 |
+
import httpx
|
| 30 |
+
from deepeval import assert_test
|
| 31 |
+
from deepeval.metrics import GEval
|
| 32 |
+
from deepeval.test_case import LLMTestCase, SingleTurnParams
|
| 33 |
+
from openai import OpenAI
|
| 34 |
+
|
| 35 |
+
# Add project root to python path so data360 package is importable
|
| 36 |
+
_HERE = Path(__file__).parent
|
| 37 |
+
_REPO = _HERE.parent
|
| 38 |
+
sys.path.insert(0, str(_REPO))
|
| 39 |
+
|
| 40 |
+
from data360.visualization import get_viz_spec, get_multi_indicator_viz_spec, save_specs_to_static
|
| 41 |
+
from data360.api import search as api_search
|
| 42 |
+
from data360.http_client import aclose_shared_httpx_client
|
| 43 |
+
|
| 44 |
+
@pytest.fixture(autouse=True)
|
| 45 |
+
async def reset_shared_client():
|
| 46 |
+
await aclose_shared_httpx_client()
|
| 47 |
+
yield
|
| 48 |
+
await aclose_shared_httpx_client()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# Load env variables
|
| 52 |
+
try:
|
| 53 |
+
from dotenv import load_dotenv
|
| 54 |
+
load_dotenv(_REPO / ".env.evals", override=False)
|
| 55 |
+
load_dotenv(_REPO / ".env", override=False)
|
| 56 |
+
except ImportError:
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
# Setup directories
|
| 60 |
+
CACHE_DIR = _HERE / "cache"
|
| 61 |
+
CACHE_DIR.mkdir(exist_ok=True)
|
| 62 |
+
REPORTS_DIR = _HERE / "reports"
|
| 63 |
+
REPORTS_DIR.mkdir(exist_ok=True)
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# HTTP Traffic Caching Layer (intercepts all external API requests)
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
original_async_send = httpx.AsyncClient.send
|
| 70 |
+
original_sync_send = httpx.Client.send
|
| 71 |
+
|
| 72 |
+
def get_cache_key(request: httpx.Request) -> str:
|
| 73 |
+
content = request.read()
|
| 74 |
+
key_src = f"{request.method}:{request.url}:{content.decode('utf-8', errors='ignore')}"
|
| 75 |
+
return hashlib.md5(key_src.encode("utf-8")).hexdigest()
|
| 76 |
+
|
| 77 |
+
async def cached_async_send(self, request: httpx.Request, *args, **kwargs):
|
| 78 |
+
# Only cache external APIs (do not cache localhost where local servers run)
|
| 79 |
+
url_str = str(request.url)
|
| 80 |
+
if "localhost" in url_str or "127.0.0.1" in url_str:
|
| 81 |
+
return await original_async_send(self, request, *args, **kwargs)
|
| 82 |
+
|
| 83 |
+
cache_key = get_cache_key(request)
|
| 84 |
+
cache_file = CACHE_DIR / f"http_{cache_key}.json"
|
| 85 |
+
|
| 86 |
+
if cache_file.exists():
|
| 87 |
+
try:
|
| 88 |
+
with open(cache_file, "r") as f:
|
| 89 |
+
data = json.load(f)
|
| 90 |
+
return httpx.Response(
|
| 91 |
+
status_code=data["status_code"],
|
| 92 |
+
headers=data["headers"],
|
| 93 |
+
content=data["content"].encode("utf-8") if isinstance(data["content"], str) else bytes(data["content"]),
|
| 94 |
+
request=request,
|
| 95 |
+
)
|
| 96 |
+
except Exception:
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
response = await original_async_send(self, request, *args, **kwargs)
|
| 100 |
+
|
| 101 |
+
if response.status_code < 400:
|
| 102 |
+
try:
|
| 103 |
+
content_str = response.content.decode("utf-8")
|
| 104 |
+
except UnicodeDecodeError:
|
| 105 |
+
content_str = response.content.hex()
|
| 106 |
+
|
| 107 |
+
headers = dict(response.headers)
|
| 108 |
+
for h in ["content-encoding", "transfer-encoding", "content-length"]:
|
| 109 |
+
headers.pop(h, None)
|
| 110 |
+
|
| 111 |
+
cache_data = {
|
| 112 |
+
"status_code": response.status_code,
|
| 113 |
+
"headers": headers,
|
| 114 |
+
"content": content_str,
|
| 115 |
+
}
|
| 116 |
+
with open(cache_file, "w") as f:
|
| 117 |
+
json.dump(cache_data, f, indent=2)
|
| 118 |
+
|
| 119 |
+
return response
|
| 120 |
+
|
| 121 |
+
def cached_sync_send(self, request: httpx.Request, *args, **kwargs):
|
| 122 |
+
url_str = str(request.url)
|
| 123 |
+
if "localhost" in url_str or "127.0.0.1" in url_str:
|
| 124 |
+
return original_sync_send(self, request, *args, **kwargs)
|
| 125 |
+
|
| 126 |
+
cache_key = get_cache_key(request)
|
| 127 |
+
cache_file = CACHE_DIR / f"http_{cache_key}.json"
|
| 128 |
+
|
| 129 |
+
if cache_file.exists():
|
| 130 |
+
try:
|
| 131 |
+
with open(cache_file, "r") as f:
|
| 132 |
+
data = json.load(f)
|
| 133 |
+
return httpx.Response(
|
| 134 |
+
status_code=data["status_code"],
|
| 135 |
+
headers=data["headers"],
|
| 136 |
+
content=data["content"].encode("utf-8") if isinstance(data["content"], str) else bytes(data["content"]),
|
| 137 |
+
request=request,
|
| 138 |
+
)
|
| 139 |
+
except Exception:
|
| 140 |
+
pass
|
| 141 |
+
|
| 142 |
+
response = original_sync_send(self, request, *args, **kwargs)
|
| 143 |
+
|
| 144 |
+
if response.status_code < 400:
|
| 145 |
+
try:
|
| 146 |
+
content_str = response.content.decode("utf-8")
|
| 147 |
+
except UnicodeDecodeError:
|
| 148 |
+
content_str = response.content.hex()
|
| 149 |
+
|
| 150 |
+
headers = dict(response.headers)
|
| 151 |
+
for h in ["content-encoding", "transfer-encoding", "content-length"]:
|
| 152 |
+
headers.pop(h, None)
|
| 153 |
+
|
| 154 |
+
cache_data = {
|
| 155 |
+
"status_code": response.status_code,
|
| 156 |
+
"headers": headers,
|
| 157 |
+
"content": content_str,
|
| 158 |
+
}
|
| 159 |
+
with open(cache_file, "w") as f:
|
| 160 |
+
json.dump(cache_data, f, indent=2)
|
| 161 |
+
|
| 162 |
+
return response
|
| 163 |
+
|
| 164 |
+
# Apply monkey patches to httpx send methods
|
| 165 |
+
httpx.AsyncClient.send = cached_async_send
|
| 166 |
+
httpx.Client.send = cached_sync_send
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ---------------------------------------------------------------------------
|
| 170 |
+
# Dynamic Scenario Generation Logic
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
|
| 173 |
+
DEFAULT_SCENARIOS = [
|
| 174 |
+
{
|
| 175 |
+
"id": "dyn_001_gdp_trend",
|
| 176 |
+
"user_question": "Show me a line chart of GDP growth for Brazil, Argentina, and Colombia from 2012 to 2022.",
|
| 177 |
+
"search_queries": ["GDP growth (annual %)"],
|
| 178 |
+
"country_code": "BRA;ARG;COL",
|
| 179 |
+
"start_year": 2012,
|
| 180 |
+
"end_year": 2022,
|
| 181 |
+
"chart_type": "line"
|
| 182 |
+
},
|
| 183 |
+
{
|
| 184 |
+
"id": "dyn_002_school_ranking",
|
| 185 |
+
"user_question": "Compare primary school enrollment gross percentage in Southeast Asia countries in 2021.",
|
| 186 |
+
"search_queries": ["Primary school enrollment, gross (%)"],
|
| 187 |
+
"country_code": "IDN;MYS;THA;PHL;VNM;SGP",
|
| 188 |
+
"start_year": 2021,
|
| 189 |
+
"end_year": 2021,
|
| 190 |
+
"chart_type": "bar"
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"id": "dyn_003_gdp_vs_life_expectancy",
|
| 194 |
+
"user_question": "What is the relationship between GDP per capita and life expectancy across G7 countries in 2020?",
|
| 195 |
+
"search_queries": ["GDP per capita (const 2015 USD)", "Life expectancy at birth (years)"],
|
| 196 |
+
"country_code": "USA;GBR;DEU;FRA;JPN;CAN;ITA",
|
| 197 |
+
"start_year": 2020,
|
| 198 |
+
"end_year": 2020,
|
| 199 |
+
"chart_type": "scatter"
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
"id": "dyn_004_gdp_sectors_shares_compat",
|
| 203 |
+
"user_question": "Compare the agriculture value added share of GDP and industry value added share of GDP in India from 2015 to 2020.",
|
| 204 |
+
"search_queries": ["Agriculture, forestry, and fishing, value added (% of GDP)", "Industry (including construction), value added (% of GDP)"],
|
| 205 |
+
"country_code": "IND",
|
| 206 |
+
"start_year": 2015,
|
| 207 |
+
"end_year": 2020,
|
| 208 |
+
"chart_type": "line"
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
"id": "dyn_005_public_sector_emp_disagg",
|
| 212 |
+
"user_question": "Show the public sector employment share in Kenya by sex from 2015 to 2020.",
|
| 213 |
+
"search_queries": ["Public employment (% of total employment)"],
|
| 214 |
+
"country_code": "KEN",
|
| 215 |
+
"start_year": 2015,
|
| 216 |
+
"end_year": 2020,
|
| 217 |
+
"chart_type": "line"
|
| 218 |
+
}
|
| 219 |
+
]
|
| 220 |
+
|
| 221 |
+
SYSTEM_PROMPT = """
|
| 222 |
+
You are a test scenario generator for the World Bank Data360 MCP visualization engine.
|
| 223 |
+
Your task is to generate 5 diverse, realistic visual data queries that a user might ask a chatbot.
|
| 224 |
+
Each scenario must be represented as a JSON object with:
|
| 225 |
+
1. `id`: A short, unique slug starting with 'dyn_' (e.g. `dyn_gdp_growth`).
|
| 226 |
+
2. `user_question`: A natural language question (e.g. "Compare GDP growth in Germany and France from 2010 to 2022").
|
| 227 |
+
3. `search_queries`: A list of 1 or 2 search queries to locate the indicators (e.g. `["GDP growth (annual %)", "Inflation, consumer prices (annual %)"]`).
|
| 228 |
+
4. `country_code`: A semi-colon separated string of 3-letter country ISO codes (e.g. `DEU;FRA`).
|
| 229 |
+
5. `start_year`: The start year (integer).
|
| 230 |
+
6. `end_year`: The end year (integer).
|
| 231 |
+
7. `chart_type`: An optional visual hint (e.g. "line", "bar", "scatter").
|
| 232 |
+
|
| 233 |
+
Ensure the 5 scenarios cover these diverse profiles:
|
| 234 |
+
- Scenario 1: A single-indicator multi-country line chart over a multi-year range (temporal trend).
|
| 235 |
+
- Scenario 2: A single-indicator multi-country ranking/comparison for a single year (cross-sectional horizontal bar).
|
| 236 |
+
- Scenario 3: A multi-indicator scatter plot comparing two indicators for multiple countries in a single year (correlation).
|
| 237 |
+
- Scenario 4: A multi-indicator line chart/layered/faceted subplots for a single country over multiple years (multi-indicator comparison).
|
| 238 |
+
- Scenario 5: A single-indicator breakdown query (e.g., employment by sector/gender, or water access by urban/rural) for a country over time or a single year.
|
| 239 |
+
|
| 240 |
+
Output ONLY a JSON object containing the list under a top-level key "scenarios". No markdown wrapping.
|
| 241 |
+
"""
|
| 242 |
+
|
| 243 |
+
def generate_scenarios() -> list[dict]:
|
| 244 |
+
cache_file = CACHE_DIR / "generated_scenarios.json"
|
| 245 |
+
if cache_file.exists():
|
| 246 |
+
try:
|
| 247 |
+
with open(cache_file, "r") as f:
|
| 248 |
+
return json.load(f)
|
| 249 |
+
except Exception:
|
| 250 |
+
pass
|
| 251 |
+
|
| 252 |
+
api_key = os.environ.get("OPENAI_API_KEY")
|
| 253 |
+
if not api_key:
|
| 254 |
+
return DEFAULT_SCENARIOS
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
client = OpenAI(api_key=api_key)
|
| 258 |
+
response = client.chat.completions.create(
|
| 259 |
+
model="gpt-4o",
|
| 260 |
+
response_format={"type": "json_object"},
|
| 261 |
+
messages=[
|
| 262 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 263 |
+
{"role": "user", "content": "Generate 5 new, comprehensive visualization scenarios."}
|
| 264 |
+
],
|
| 265 |
+
temperature=0.7,
|
| 266 |
+
)
|
| 267 |
+
content = response.choices[0].message.content
|
| 268 |
+
data = json.loads(content)
|
| 269 |
+
scenarios = data.get("scenarios", [])
|
| 270 |
+
if len(scenarios) == 5:
|
| 271 |
+
# Cache it
|
| 272 |
+
with open(cache_file, "w") as f:
|
| 273 |
+
json.dump(scenarios, f, indent=2)
|
| 274 |
+
return scenarios
|
| 275 |
+
except Exception as e:
|
| 276 |
+
print(f"Error generating dynamic scenarios: {e}. Falling back to default scenarios.")
|
| 277 |
+
|
| 278 |
+
return DEFAULT_SCENARIOS
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ---------------------------------------------------------------------------
|
| 282 |
+
# DeepEval Metric & Resolver
|
| 283 |
+
# ---------------------------------------------------------------------------
|
| 284 |
+
|
| 285 |
+
def _zero_shot_grammar_of_graphics_metric() -> GEval:
|
| 286 |
+
return GEval(
|
| 287 |
+
name="Visual Charting Suitability & Graphics Grammar Metric",
|
| 288 |
+
criteria="""
|
| 289 |
+
Evaluate the visual charting quality and layout correctness of the Vega-Lite JSON specification. Focus entirely on the visualization's design, hierarchy, and representation suitability.
|
| 290 |
+
|
| 291 |
+
Scoring Criteria:
|
| 292 |
+
1. ENCODING & CHART TYPE SUITABILITY (0-4): Does the visual encoding (marks, axes mapping, facets) represent the query intent effectively? (e.g. line/area for multi-year trends, nominal/ordinal x-axis for categorical horizontal/vertical bar charts, scatter plots for correlation, small multiples/facets for breakdown subplots).
|
| 293 |
+
2. STYLING, THEMES & READABILITY (0-4): Does the chart follow professional dashboard styling? (e.g. clean World Bank visual themes applied, gridlines visible but subtle, axis titles clear and formatted, legend placement, titles/subtitles descriptive).
|
| 294 |
+
3. DESIGN ROBUSTNESS (0-2): Are there no duplicate legends/axes, no overlapping titles, and are tooltip configurations complete and functional for clean interaction?
|
| 295 |
+
|
| 296 |
+
Give a final score from 0.0 to 1.0 (where >= 0.75 passes).
|
| 297 |
+
Provide:
|
| 298 |
+
- A concise critique and rationale focusing on charting quality.
|
| 299 |
+
- A dedicated section "RECOMMENDATIONS TO IMPROVE THE ENGINE" listing concrete visual styling and charting suggestions.
|
| 300 |
+
""",
|
| 301 |
+
evaluation_params=[SingleTurnParams.INPUT, SingleTurnParams.ACTUAL_OUTPUT],
|
| 302 |
+
evaluation_steps=[
|
| 303 |
+
"Inspect the INPUT (the requested query context and parameters).",
|
| 304 |
+
"Inspect the ACTUAL_OUTPUT (the generated Vega-Lite JSON spec).",
|
| 305 |
+
"Verify the mark type fits the cardinality and intent of the comparison (line vs bar vs scatter).",
|
| 306 |
+
"Evaluate visual channels (x, y, color, facet) for encoding readability.",
|
| 307 |
+
"Assess thematic styling correctness (World Bank themes, axis cleanups, and title preservation).",
|
| 308 |
+
"Identify any visual overlaps, label truncation, or duplicate legends.",
|
| 309 |
+
"Generate the final score, written critique, and recommendations."
|
| 310 |
+
],
|
| 311 |
+
threshold=0.75,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
async def resolve_indicator(query: str, country: str | None = None) -> tuple[str, str, str]:
|
| 315 |
+
"""Resolve a search query to database_id, indicator_id, and name."""
|
| 316 |
+
search_country = None
|
| 317 |
+
if country:
|
| 318 |
+
search_country = country.split(";")[0].split(",")[0].strip()
|
| 319 |
+
|
| 320 |
+
# Sanitize query by removing punctuation like commas, parentheses, etc.
|
| 321 |
+
clean_query = query
|
| 322 |
+
for char in [",", "(", ")", "$", "%", "#", "@", "!"]:
|
| 323 |
+
clean_query = clean_query.replace(char, " ")
|
| 324 |
+
clean_query = " ".join(clean_query.split())
|
| 325 |
+
|
| 326 |
+
res = await api_search(query=clean_query, required_country=search_country, limit=1)
|
| 327 |
+
|
| 328 |
+
# Handle EnrichedSearchResponse
|
| 329 |
+
if hasattr(res, "indicators") and res.indicators:
|
| 330 |
+
ind = res.indicators[0]
|
| 331 |
+
return ind.database_id, ind.idno, ind.name
|
| 332 |
+
# Handle dict fallback
|
| 333 |
+
elif isinstance(res, dict) and res.get("indicators"):
|
| 334 |
+
ind = res["indicators"][0]
|
| 335 |
+
return ind.get("database_id"), ind.get("idno"), ind.get("name")
|
| 336 |
+
|
| 337 |
+
raise ValueError(f"Could not resolve indicator for query: {query} (sanitized: {clean_query})")
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ---------------------------------------------------------------------------
|
| 341 |
+
# Test Runner
|
| 342 |
+
# ---------------------------------------------------------------------------
|
| 343 |
+
|
| 344 |
+
SCENARIOS_LIST = generate_scenarios()
|
| 345 |
+
|
| 346 |
+
@pytest.mark.asyncio
|
| 347 |
+
@pytest.mark.parametrize("scenario", SCENARIOS_LIST, ids=lambda s: s["id"])
|
| 348 |
+
async def test_dynamic_scenarios(scenario: dict):
|
| 349 |
+
# 1. Resolve indicators
|
| 350 |
+
search_queries = scenario["search_queries"]
|
| 351 |
+
is_multi = len(search_queries) >= 2
|
| 352 |
+
|
| 353 |
+
resolved_indicators = []
|
| 354 |
+
for sq in search_queries:
|
| 355 |
+
db_id, ind_id, ind_name = await resolve_indicator(sq, scenario.get("country_code"))
|
| 356 |
+
resolved_indicators.append({
|
| 357 |
+
"database_id": db_id,
|
| 358 |
+
"indicator_id": ind_id,
|
| 359 |
+
"name": ind_name
|
| 360 |
+
})
|
| 361 |
+
|
| 362 |
+
# 2. Run visualization spec pipeline
|
| 363 |
+
captured_spec = {}
|
| 364 |
+
def fake_save(spec: dict) -> str:
|
| 365 |
+
captured_spec["spec"] = spec
|
| 366 |
+
return "http://localhost:8021/static/viz_specs/dyn_test.json"
|
| 367 |
+
|
| 368 |
+
# Call visualization engine directly
|
| 369 |
+
with patch("data360.visualization.save_specs_to_static", side_effect=fake_save):
|
| 370 |
+
if is_multi:
|
| 371 |
+
# Multi-indicator path
|
| 372 |
+
viz_result = await get_multi_indicator_viz_spec(
|
| 373 |
+
indicator_ids=[
|
| 374 |
+
{"database_id": ri["database_id"], "indicator_id": ri["indicator_id"]}
|
| 375 |
+
for ri in resolved_indicators
|
| 376 |
+
],
|
| 377 |
+
country_code=scenario.get("country_code"),
|
| 378 |
+
start_year=scenario.get("start_year"),
|
| 379 |
+
end_year=scenario.get("end_year"),
|
| 380 |
+
chart_type=scenario.get("chart_type"),
|
| 381 |
+
)
|
| 382 |
+
else:
|
| 383 |
+
# Single-indicator path
|
| 384 |
+
ri = resolved_indicators[0]
|
| 385 |
+
viz_result = await get_viz_spec(
|
| 386 |
+
database_id=ri["database_id"],
|
| 387 |
+
indicator_id=ri["indicator_id"],
|
| 388 |
+
country_code=scenario.get("country_code"),
|
| 389 |
+
start_year=scenario.get("start_year"),
|
| 390 |
+
end_year=scenario.get("end_year"),
|
| 391 |
+
chart_type=scenario.get("chart_type"),
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
assert viz_result["error"] is None, f"Visualization failed: {viz_result['error']}"
|
| 395 |
+
assert "spec" in captured_spec, "No spec saved to static files."
|
| 396 |
+
|
| 397 |
+
spec = captured_spec["spec"]
|
| 398 |
+
spec_json = json.dumps(spec, indent=2)
|
| 399 |
+
|
| 400 |
+
# 3. Save report JSON to file
|
| 401 |
+
report_file = REPORTS_DIR / f"{scenario['id']}_report.json"
|
| 402 |
+
report_data = {
|
| 403 |
+
"scenario_id": scenario["id"],
|
| 404 |
+
"question": scenario["user_question"],
|
| 405 |
+
"resolved_indicators": resolved_indicators,
|
| 406 |
+
"viz_result": viz_result,
|
| 407 |
+
"spec": spec,
|
| 408 |
+
"timestamp": pd.Timestamp.now().isoformat()
|
| 409 |
+
}
|
| 410 |
+
with open(report_file, "w") as f:
|
| 411 |
+
json.dump(report_data, f, indent=2)
|
| 412 |
+
|
| 413 |
+
# 4. DeepEval LLM evaluation (if key is present)
|
| 414 |
+
if os.environ.get("OPENAI_API_KEY"):
|
| 415 |
+
eval_input = f"""
|
| 416 |
+
Requested Indicators:
|
| 417 |
+
{json.dumps(resolved_indicators, indent=2)}
|
| 418 |
+
|
| 419 |
+
Parameters:
|
| 420 |
+
- Countries: {scenario.get('country_code')}
|
| 421 |
+
- Start Year: {scenario.get('start_year')}
|
| 422 |
+
- End Year: {scenario.get('end_year')}
|
| 423 |
+
- Chart Type Hint: {scenario.get('chart_type')}
|
| 424 |
+
- Disaggregation Filters: {scenario.get('disaggregation_filters')}
|
| 425 |
+
|
| 426 |
+
User Context Question: {scenario['user_question']}
|
| 427 |
+
"""
|
| 428 |
+
test_case = LLMTestCase(
|
| 429 |
+
input=eval_input.strip(),
|
| 430 |
+
actual_output=spec_json
|
| 431 |
+
)
|
| 432 |
+
assert_test(test_case, [_zero_shot_grammar_of_graphics_metric()])
|
| 433 |
+
else:
|
| 434 |
+
# Fallback offline assertions
|
| 435 |
+
assert "$schema" in spec
|
| 436 |
+
assert "data" in spec
|
| 437 |
+
assert "encoding" in spec
|