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