gooddata-eval 1.78.1.dev1__py3-none-any.whl → 1.78.1.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ from gooddata_eval.core.agentic.visualization import evaluate_agentic_visualizat
26
26
  from gooddata_eval.core.agentic.what_if import evaluate_agentic_what_if
27
27
  from gooddata_eval.core.config import ReasoningEffort
28
28
  from gooddata_eval.core.evaluators._llm_judge import JudgeResponseError
29
- from gooddata_eval.core.evaluators.visualization import requires_execution_of
29
+ from gooddata_eval.core.evaluators.visualization import expectation_options
30
30
  from gooddata_eval.core.models import AgenticEvalOutcome, CreatedVisualization, DatasetItem
31
31
  from gooddata_eval.core.runner import EvalReport, ItemReport
32
32
  from gooddata_eval.core.timing import TraceRecorder
@@ -119,6 +119,8 @@ def _parse_visualization_expected(expected_output: Any) -> list[CreatedVisualiza
119
119
  {"visualization": {...}} or {"visualization": [{...}]} <- single/multi candidate
120
120
  [{"visualization": {...}}, ...] <- bare list
121
121
  """
122
+ if isinstance(expected_output, dict) and expected_output.get("expects_visualization") is False:
123
+ return []
122
124
  if isinstance(expected_output, dict):
123
125
  raw_list = expected_output.get("expected_outputs")
124
126
  if raw_list is not None:
@@ -187,7 +189,7 @@ def _dispatch_agentic(
187
189
  workspace_id=workspace_id,
188
190
  question=item.question,
189
191
  expected_outputs=_parse_visualization_expected(eo),
190
- requires_execution=requires_execution_of(eo),
192
+ options=expectation_options(eo),
191
193
  k=k,
192
194
  gate=gate,
193
195
  agent_id=agent_id,
@@ -40,6 +40,7 @@ from gooddata_eval.core.models import (
40
40
  build_latency_breakdown,
41
41
  shift_and_index_events,
42
42
  )
43
+ from gooddata_eval.core.scoring import AAC_VISUALIZATION_TYPES, same_viz_type
43
44
  from gooddata_eval.core.timing import PhaseTimings, log_timer, sum_timings
44
45
 
45
46
  _DEFAULT_K = 1
@@ -582,6 +583,64 @@ def _check_date_bindings(widgets: list[dict], new_ids: set[str], expected: list[
582
583
  return failures
583
584
 
584
585
 
586
+ def _has_chart_types(expected_output: dict) -> bool:
587
+ """Whether any expected chart states the type it must be placed as."""
588
+ return any(isinstance(v, dict) and "chart_type" in v for v in expected_output.get("visualizations") or [])
589
+
590
+
591
+ def _bodies_by_id(*references: dict) -> dict[str, dict]:
592
+ """Every chart body the response's references carry, existing and authored, by chart id."""
593
+ bodies: dict[str, dict] = {}
594
+ for refs in references:
595
+ for key in ("visualizations", "new_visualizations"):
596
+ for body in refs.get(key) or []:
597
+ if isinstance(body, dict) and body.get("id"):
598
+ bodies[body["id"]] = body
599
+ return bodies
600
+
601
+
602
+ def _check_chart_types(
603
+ widgets: list[dict], new_ids: set[str], expected: list[dict], bodies: dict[str, dict]
604
+ ) -> tuple[list[str], list[str]]:
605
+ """Every widget of a chart that states a ``chart_type`` must show a chart of exactly that type.
606
+
607
+ A widget names only the chart id, so the type is read from the chart body the references
608
+ carry. The title alone cannot catch a chart authored as another type than the one asked
609
+ for, which is the silent swap GDAI-2054 rules out.
610
+
611
+ Returns the failures and the notes. A widget whose chart has no body in the references
612
+ cannot be checked: gen-ai carries a body only when it could resolve one, so a missing
613
+ body is a platform fault, noted as ``_check_references`` notes a missing id, and not
614
+ scored as the agent's.
615
+ """
616
+ failures: list[str] = []
617
+ notes: list[str] = []
618
+ for exp in expected:
619
+ if not isinstance(exp, dict) or "chart_type" not in exp:
620
+ continue
621
+ wanted = exp["chart_type"]
622
+ label = exp.get("title") if exp.get("id") is None else exp.get("id")
623
+ candidates = _matching_widgets(widgets, new_ids, exp)
624
+ if not candidates:
625
+ failures.append(f"cannot check the type of chart {label!r}: it is not on the dashboard")
626
+ continue
627
+ types: list[str] = []
628
+ for widget in candidates:
629
+ chart_type = (bodies.get(widget.get("visualization")) or {}).get("type")
630
+ if isinstance(chart_type, str):
631
+ types.append(chart_type)
632
+ else:
633
+ notes.append(
634
+ f"cannot check the type of chart {label!r}: the response's references carry no body "
635
+ f"for {widget.get('visualization')!r}"
636
+ )
637
+ # Every placement, as for date bindings: one right placement must not hide a wrong one.
638
+ if not all(same_viz_type(wanted, t) for t in types):
639
+ found = ", ".join(repr(t) for t in types)
640
+ failures.append(f"chart {label!r} is {found}, expected {wanted!r}")
641
+ return failures, notes
642
+
643
+
585
644
  def _tabs_of(dashboard: dict) -> list[dict]:
586
645
  """The dashboard's tabs, reading a document with root sections as its single tab."""
587
646
  tabs = dashboard.get("tabs")
@@ -936,6 +995,12 @@ def _validate_layout_expectations(expected_output: dict) -> None:
936
995
  for chart in expected_output.get("visualizations") or []:
937
996
  if isinstance(chart, dict) and "date_dataset" in chart and not _is_dataset_or_null(chart["date_dataset"]):
938
997
  raise ValueError(f"date_dataset must name a date dataset or be null, got {chart!r}")
998
+ if (
999
+ isinstance(chart, dict)
1000
+ and "chart_type" in chart
1001
+ and (not isinstance(chart["chart_type"], str) or chart["chart_type"] not in AAC_VISUALIZATION_TYPES)
1002
+ ):
1003
+ raise ValueError(f"chart_type must be an AAC visualization type, got {chart!r}")
939
1004
 
940
1005
  if "tabs" in expected_output:
941
1006
  tabs = expected_output["tabs"]
@@ -1037,6 +1102,7 @@ class _Applies:
1037
1102
  tab_filters: bool = False
1038
1103
  date_filters: bool = False
1039
1104
  preserved_widgets: bool = False
1105
+ chart_types: bool = False
1040
1106
 
1041
1107
  @classmethod
1042
1108
  def of(cls, expected_output: dict) -> _Applies:
@@ -1049,6 +1115,7 @@ class _Applies:
1049
1115
  tab_filters="tab_filters" in expected_output,
1050
1116
  date_filters="date_filters" in expected_output,
1051
1117
  preserved_widgets="preserved_widgets" in expected_output,
1118
+ chart_types=_has_chart_types(expected_output),
1052
1119
  )
1053
1120
 
1054
1121
 
@@ -1089,6 +1156,7 @@ class DashboardEvaluation:
1089
1156
  tab_filters_correct: bool = False
1090
1157
  date_filters_correct: bool = False
1091
1158
  widgets_preserved: bool = False
1159
+ chart_types_correct: bool = False
1092
1160
  failures: list[str] = field(default_factory=list)
1093
1161
  notes: list[str] = field(default_factory=list)
1094
1162
 
@@ -1129,6 +1197,8 @@ class DashboardEvaluation:
1129
1197
  checks["dashboard_date_filters_correct"] = self.date_filters_correct
1130
1198
  if self.applies.preserved_widgets:
1131
1199
  checks["dashboard_widgets_preserved"] = self.widgets_preserved
1200
+ if self.applies.chart_types:
1201
+ checks["dashboard_chart_types_correct"] = self.chart_types_correct
1132
1202
  return checks
1133
1203
 
1134
1204
  @property
@@ -1280,12 +1350,14 @@ def evaluate_dashboard_response(
1280
1350
  | {v.get("id") for v in patch_references.get("visualizations") or [] if v.get("id")}
1281
1351
  | new_ids
1282
1352
  )
1353
+ bodies = _bodies_by_id(base_references, patch_references)
1283
1354
  else:
1284
1355
  if actual_saved_id is not None:
1285
1356
  saved_failures.append(f"a new draft must not be saved yet, but it reports id {actual_saved_id!r}")
1286
1357
  document = base
1287
1358
  new_ids = {v.get("id") for v in base_references.get("new_visualizations") or [] if v.get("id")}
1288
1359
  known_ids = {v.get("id") for v in base_references.get("visualizations") or [] if v.get("id")} | new_ids
1360
+ bodies = _bodies_by_id(base_references)
1289
1361
 
1290
1362
  if document is None:
1291
1363
  return DashboardEvaluation(
@@ -1337,6 +1409,11 @@ def evaluate_dashboard_response(
1337
1409
  preserved_failures = (
1338
1410
  _check_preserved_widgets(widgets, expected_output["preserved_widgets"]) if applies.preserved_widgets else []
1339
1411
  )
1412
+ chart_type_failures, chart_type_notes = (
1413
+ _check_chart_types(widgets, new_ids, expected_output.get("visualizations") or [], bodies)
1414
+ if applies.chart_types
1415
+ else ([], [])
1416
+ )
1340
1417
 
1341
1418
  return DashboardEvaluation(
1342
1419
  drafted=True,
@@ -1356,6 +1433,7 @@ def evaluate_dashboard_response(
1356
1433
  tab_filters_correct=not tab_filter_failures,
1357
1434
  date_filters_correct=not date_filter_failures,
1358
1435
  widgets_preserved=not preserved_failures,
1436
+ chart_types_correct=not chart_type_failures,
1359
1437
  failures=[
1360
1438
  *chart_failures,
1361
1439
  *saved_failures,
@@ -1366,9 +1444,10 @@ def evaluate_dashboard_response(
1366
1444
  *tab_filter_failures,
1367
1445
  *date_filter_failures,
1368
1446
  *preserved_failures,
1447
+ *chart_type_failures,
1369
1448
  *new_failures,
1370
1449
  ],
1371
- notes=[*title_notes, *reference_notes],
1450
+ notes=[*title_notes, *reference_notes, *chart_type_notes],
1372
1451
  )
1373
1452
 
1374
1453
 
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import os
11
11
  import time
12
- from dataclasses import dataclass, field
12
+ from dataclasses import dataclass, field, replace
13
13
 
14
14
  from gooddata_eval.core.agentic._failed_runs import build_failed_runs
15
15
  from gooddata_eval.core.agentic._gate import (
@@ -34,10 +34,12 @@ from gooddata_eval.core.chat.sse_client import ChatClient, ChatError
34
34
  from gooddata_eval.core.config import ReasoningEffort
35
35
  from gooddata_eval.core.evaluators.visualization import (
36
36
  EvaluationResult,
37
+ ExpectationOptions,
37
38
  _check_visualization_skill_activated,
38
39
  _evaluate_against_candidates,
39
40
  evaluation_result_detail,
40
41
  with_execution,
42
+ with_options,
41
43
  )
42
44
  from gooddata_eval.core.langfuse.item_scope import item_scope
43
45
  from gooddata_eval.core.models import (
@@ -64,7 +66,8 @@ class RunResult:
64
66
  conversation_id: str
65
67
  actual_output: CreatedVisualization | None
66
68
  eval_result: EvaluationResult
67
- best_expected: CreatedVisualization
69
+ # None for an item that expects no chart.
70
+ best_expected: CreatedVisualization | None
68
71
  total_turns: int
69
72
  total_steps: int
70
73
  reasoning_steps: list[str] = field(default_factory=list)
@@ -76,6 +79,8 @@ class RunResult:
76
79
  exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
77
80
  # This run's own wall time, from just before its first chat call to the end of its scoring.
78
81
  run_latency_s: float = 0.0
82
+ # Everything the final turn said, which the answer patterns are matched against.
83
+ answer_text: str = ""
79
84
 
80
85
 
81
86
  @dataclass
@@ -196,8 +201,13 @@ def _execute_single_run(
196
201
  expected_outputs: list[CreatedVisualization],
197
202
  max_iterations: int = _DEFAULT_MAX_ITERATIONS,
198
203
  requires_execution: bool = False,
204
+ options: ExpectationOptions | None = None,
199
205
  ) -> RunResult:
200
206
  """Drive one full multi-turn conversation and evaluate the result."""
207
+ options = _effective_options(options, requires_execution)
208
+ one_turn = not options.expects_visualization or options.visualization_optional
209
+ if one_turn:
210
+ max_iterations = min(max_iterations, 1)
201
211
  total_turns = 0
202
212
  total_steps = 0
203
213
  all_tool_call_events: list[ToolCallEvent] = []
@@ -207,7 +217,8 @@ def _execute_single_run(
207
217
  turn_offset = 0.0 # each turn's call_ts/ts restarts near 0 -- shift by prior turns' wall time
208
218
  reasoning_index_offset = 0 # ditto for ReasoningStepEvent.index, which also restarts per turn
209
219
  tool_index_offset = 0 # ditto for ToolCallEvent.index
210
- simulated_response_guide = expected_outputs[0] # primary candidate guides the simulated user
220
+ # The primary candidate guides the simulated user. An item with no chart never replies.
221
+ simulated_response_guide = expected_outputs[0] if expected_outputs else None
211
222
 
212
223
  # Defaults to BUDGET_EXHAUSTED: every other exit assigns explicitly, so a loop that
213
224
  # simply runs out of range() is labelled correctly with no trailing else.
@@ -258,7 +269,11 @@ def _execute_single_run(
258
269
  if not response_text:
259
270
  exit_reason = LoopExit.AGENT_SILENT
260
271
  break
261
- if iteration >= max_iterations - 1:
272
+ if one_turn:
273
+ # Answering without a chart is an outcome this item accepts.
274
+ exit_reason = LoopExit.SUCCESS
275
+ break
276
+ if iteration >= max_iterations - 1 or simulated_response_guide is None:
262
277
  break
263
278
 
264
279
  try:
@@ -288,7 +303,18 @@ def _execute_single_run(
288
303
  actual_output = current_result.created_visualizations.objects[0]
289
304
 
290
305
  eval_result, best_expected = _evaluate_against_candidates(expected_outputs, actual_output, skill_activated)
291
- eval_result = with_execution(eval_result, all_tool_call_events, current_result.text_response, requires_execution)
306
+ eval_result = with_execution(
307
+ eval_result, all_tool_call_events, current_result.text_response, options.requires_execution
308
+ )
309
+ answer_text = render_answer_text(current_result)
310
+ eval_result = with_options(
311
+ eval_result,
312
+ options,
313
+ actual_output,
314
+ all_tool_call_events,
315
+ answer_text,
316
+ interrupted=exit_reason is LoopExit.CHAT_ERROR,
317
+ )
292
318
 
293
319
  return RunResult(
294
320
  conversation_id=conversation_id,
@@ -303,9 +329,16 @@ def _execute_single_run(
303
329
  reasoning_step_events=all_reasoning_step_events,
304
330
  exit_reason=exit_reason,
305
331
  run_latency_s=time.monotonic() - run_started,
332
+ answer_text=answer_text,
306
333
  )
307
334
 
308
335
 
336
+ def _effective_options(options: ExpectationOptions | None, requires_execution: bool) -> ExpectationOptions:
337
+ """``options`` with the separate ``requires_execution`` argument folded in."""
338
+ options = options or ExpectationOptions()
339
+ return replace(options, requires_execution=True) if requires_execution else options
340
+
341
+
309
342
  def run_agentic_visualization(
310
343
  host: str,
311
344
  token: str,
@@ -319,6 +352,7 @@ def run_agentic_visualization(
319
352
  agent_id: str | None = None,
320
353
  user_context: dict | None = None,
321
354
  requires_execution: bool = False,
355
+ options: ExpectationOptions | None = None,
322
356
  ) -> AgenticRunSummary:
323
357
  """Run K independent conversations and return evaluation results.
324
358
 
@@ -328,7 +362,18 @@ def run_agentic_visualization(
328
362
  conversations created by this function are deleted on completion.
329
363
 
330
364
  ``requires_execution`` fails a run whose agent built the chart but never ran it.
365
+
366
+ Raises:
367
+ ValueError: ``expected_outputs`` and ``options.expects_visualization`` disagree. An
368
+ empty candidate list is only right for an item that expects no chart, and the
369
+ scorer would otherwise read the default ``expects_visualization`` and fail it.
331
370
  """
371
+ expects_visualization = (options or ExpectationOptions()).expects_visualization
372
+ if expects_visualization != bool(expected_outputs):
373
+ raise ValueError(
374
+ "expected_outputs must list the candidate charts unless options.expects_visualization is false, "
375
+ f"got {len(expected_outputs)} candidate(s) with expects_visualization={expects_visualization}"
376
+ )
332
377
  client = ChatClient(
333
378
  host=host,
334
379
  token=token,
@@ -343,7 +388,9 @@ def run_agentic_visualization(
343
388
  conv_id_0 = initial_conversation_id if initial_conversation_id is not None else client.create_conversation()
344
389
  try:
345
390
  run_results.append(
346
- _execute_single_run(client, conv_id_0, question, expected_outputs, max_iterations, requires_execution)
391
+ _execute_single_run(
392
+ client, conv_id_0, question, expected_outputs, max_iterations, requires_execution, options
393
+ )
347
394
  )
348
395
  finally:
349
396
  if initial_conversation_id is None:
@@ -353,7 +400,9 @@ def run_agentic_visualization(
353
400
  conv_id = client.create_conversation()
354
401
  try:
355
402
  run_results.append(
356
- _execute_single_run(client, conv_id, question, expected_outputs, max_iterations, requires_execution)
403
+ _execute_single_run(
404
+ client, conv_id, question, expected_outputs, max_iterations, requires_execution, options
405
+ )
357
406
  )
358
407
  finally:
359
408
  client.delete_conversation(conv_id)
@@ -393,6 +442,68 @@ def _run_detail(run: RunResult) -> dict:
393
442
  }
394
443
 
395
444
 
445
+ def _strict_checks(ev: EvaluationResult) -> dict[str, bool]:
446
+ """The scores one run is gated on, by their Langfuse names.
447
+
448
+ An item that expects no chart is scored on that and on its answer only: the chart checks
449
+ would all read False and say nothing about it.
450
+ """
451
+ if ev.scored_without_chart:
452
+ checks = {"assertion-vis-no-chart": not ev.visualization_created}
453
+ else:
454
+ checks = {
455
+ "assertion-cross-ref-valid": ev.cross_ref_valid,
456
+ "assertion-vis-metric": ev.metrics_correct,
457
+ "assertion-vis-dimensions": ev.dimensions_correct,
458
+ "assertion-vis-filters": ev.filters_correct,
459
+ "assertion-vis-type": ev.viz_type_hard,
460
+ }
461
+ if ev.requires_execution:
462
+ checks["assertion-vis-executed"] = ev.executed
463
+ # Only when the expectation sorts: an item without a sort accepts any order, so the check
464
+ # would always read True and only dilute the quality score.
465
+ if ev.expected_sorts:
466
+ checks["assertion-vis-sort"] = ev.sorts_correct
467
+ if ev.buckets_correct is not None:
468
+ checks["assertion-vis-buckets"] = ev.buckets_correct
469
+ if ev.format_correct is not None:
470
+ checks["assertion-vis-format"] = ev.format_correct
471
+ if ev.part_present is not None:
472
+ checks["assertion-vis-part-present"] = ev.part_present
473
+ if ev.answer_correct is not None:
474
+ checks["assertion-vis-answer"] = ev.answer_correct
475
+ return checks
476
+
477
+
478
+ def _options_lines(ev: EvaluationResult, answer_text: str) -> str:
479
+ """The item-level verdicts for the failure summary; only the ones the item asks for."""
480
+ lines = ""
481
+ if ev.scored_without_chart:
482
+ lines += f" No Chart : {not ev.visualization_created}\n"
483
+ if ev.answer_correct is not None:
484
+ lines += f" Answer Correct : {ev.answer_correct}\n"
485
+ lines += "".join(f" {problem}\n" for problem in ev.answer_problems)
486
+ if ev.answer_problems:
487
+ lines += f" answer : {answer_text[:500]!r}\n"
488
+ if ev.format_correct is not None:
489
+ lines += f" Format Correct : {ev.format_correct}\n"
490
+ lines += "".join(f" {problem}\n" for problem in ev.format_problems)
491
+ if ev.part_present is not None:
492
+ lines += f" Part Present : {ev.part_present}\n"
493
+ return lines
494
+
495
+
496
+ def _buckets_line(ev: EvaluationResult) -> str:
497
+ """The bucket-by-bucket verdict for the failure summary, or "" when the item does not ask for it."""
498
+ lines = "".join(f" {problem}\n" for problem in ev.metric_layout_problems)
499
+ if ev.buckets_correct is None:
500
+ return lines
501
+ lines += f" Buckets Correct : {ev.buckets_correct}\n"
502
+ if not ev.buckets_correct:
503
+ lines += f" expected : {ev.expected_buckets}\n actual : {ev.actual_buckets}\n"
504
+ return lines
505
+
506
+
396
507
  def _filter_diff(category: str, ev: EvaluationResult) -> str:
397
508
  """Expected-vs-actual lines for one filter category, or "" when they matched.
398
509
 
@@ -426,6 +537,7 @@ def evaluate_agentic_visualization(
426
537
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
427
538
  user_context: dict | None = None,
428
539
  requires_execution: bool = False,
540
+ options: ExpectationOptions | None = None,
429
541
  gate: EvalGate = DEFAULT_GATE,
430
542
  ) -> AgenticEvalOutcome:
431
543
  """Run visualization evaluation, log to Langfuse, and raise VisualizationAssertionError on failure.
@@ -461,6 +573,7 @@ def evaluate_agentic_visualization(
461
573
  agent_id=agent_id,
462
574
  user_context=user_context,
463
575
  requires_execution=requires_execution,
576
+ options=options,
464
577
  )
465
578
 
466
579
  if langfuse is not None and dataset_item_id:
@@ -474,21 +587,12 @@ def evaluate_agentic_visualization(
474
587
  for run_idx, run in enumerate(summary.run_results):
475
588
  pt = ctx.trace(run.conversation_id)
476
589
  ev = run.eval_result
477
- strict_checks = {
478
- "assertion-cross-ref-valid": ev.cross_ref_valid,
479
- "assertion-vis-metric": ev.metrics_correct,
480
- "assertion-vis-dimensions": ev.dimensions_correct,
481
- "assertion-vis-filters": ev.filters_correct,
482
- "assertion-vis-type": ev.viz_type_hard,
483
- }
484
- if ev.requires_execution:
485
- strict_checks["assertion-vis-executed"] = ev.executed
590
+ strict_checks = _strict_checks(ev)
486
591
  with ctx.observe(pt, run_idx, conversation_id=run.conversation_id, output=strict_checks) as tid:
487
- ctx.score(tid, name="assertion-cross-ref-valid", value=ev.cross_ref_valid, data_type="BOOLEAN")
488
- ctx.score(tid, name="assertion-vis-metric", value=ev.metrics_correct, data_type="BOOLEAN")
489
- ctx.score(tid, name="assertion-vis-dimensions", value=ev.dimensions_correct, data_type="BOOLEAN")
490
- ctx.score(tid, name="assertion-vis-filters", value=ev.filters_correct, data_type="BOOLEAN")
491
- ctx.score(tid, name="assertion-vis-type", value=ev.viz_type_hard, data_type="BOOLEAN")
592
+ for score_name, value in strict_checks.items():
593
+ # Scored below on every item, gating or not.
594
+ if score_name != "assertion-vis-executed":
595
+ ctx.score(tid, name=score_name, value=value, data_type="BOOLEAN")
492
596
  ctx.score(tid, name="skill_selection", value=ev.skill_activated, data_type="BOOLEAN")
493
597
  # Scored on every item, gating or not, so the run rate is visible per model.
494
598
  ctx.score(tid, name="assertion-vis-executed", value=ev.executed, data_type="BOOLEAN")
@@ -557,7 +661,11 @@ def evaluate_agentic_visualization(
557
661
  n = len(expected_outputs)
558
662
  candidate_note = f" (closest of {n} candidates)" if n > 1 else ""
559
663
  cross_ref_detail = (" → " + "; ".join(ev.cross_ref_errors)) if ev.cross_ref_errors else ""
560
- expected_dump = best.best_expected.model_dump(exclude_none=True)
664
+ expected_dump = (
665
+ best.best_expected.model_dump(exclude_none=True)
666
+ if best.best_expected is not None
667
+ else {"expects_visualization": False}
668
+ )
561
669
  actual_dump = best.actual_output.model_dump(exclude_none=True) if best.actual_output else None
562
670
  exc = VisualizationAssertionError(
563
671
  "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
@@ -587,7 +695,9 @@ def evaluate_agentic_visualization(
587
695
  f" attribute : {ev.filter_attribute_score}\n"
588
696
  f"{_filter_diff('attribute', ev)}"
589
697
  f" Viz Type Hard : {ev.viz_type_hard}\n"
698
+ f"{_buckets_line(ev)}"
590
699
  f" Executed : {ev.executed}{' (required)' if ev.requires_execution else ''}\n"
700
+ f"{_options_lines(ev, best.answer_text)}"
591
701
  f" Stated Value Matches : {ev.stated_value_matches} (reported only)\n"
592
702
  "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
593
703
  )
@@ -52,7 +52,7 @@ _METADATA_SYNC_MARKER = "METADATA_SYNC_IN_PROGRESS"
52
52
  _TURN_INCOMPLETE_REASONS: frozenset[str] = frozenset({"max_iterations", "max_tokens", "content_filter"})
53
53
  # Stands in for the `id` a persisted visualization would carry, on the fallback path
54
54
  # where the agent's create_adhoc_visualization call failed and only its arguments survive.
55
- _ADHOC_VIZ_ID = "adhoc-visualization-not-persisted"
55
+ ADHOC_VIZ_ID = "adhoc-visualization-not-persisted"
56
56
 
57
57
  _KNOWN_PART_TYPES: frozenset[str] = frozenset(
58
58
  {
@@ -311,7 +311,8 @@ def _build_chat_result(acc: _SseAccumulator) -> ChatResult:
311
311
  # accessible). The last attempt is the agent's best answer.
312
312
  #
313
313
  # These are raw tool-call arguments, so they carry no `id` -- the server
314
- # mints it. The sentinel marks the chart as never persisted. Logged because
314
+ # mints it. The sentinel marks the chart as never persisted, and goes last so
315
+ # that an `id` in the arguments cannot hide that. Logged because
315
316
  # a turn that called the tool and answered without a chart is otherwise
316
317
  # invisible: the user saw no chart either.
317
318
  _log.warning(
@@ -320,7 +321,7 @@ def _build_chat_result(acc: _SseAccumulator) -> ChatResult:
320
321
  len(acc.adhoc_viz_args),
321
322
  )
322
323
  payload["createdVisualizations"] = {
323
- "objects": [{"id": _ADHOC_VIZ_ID, **acc.adhoc_viz_args[-1]}],
324
+ "objects": [{**acc.adhoc_viz_args[-1], "id": ADHOC_VIZ_ID}],
324
325
  "reasoning": "\n".join(acc.viz_reasoning_parts),
325
326
  }
326
327
  result = ChatResult.model_validate(payload)
@@ -146,13 +146,14 @@ def list_ids(directory: Path) -> set[str]:
146
146
  return ids
147
147
 
148
148
 
149
- def aliases(bucket: list[AacBucketRef | str]) -> list[str]:
149
+ def aliases(bucket: list[AacBucketRef | str] | list[AacBucketRef | str | None]) -> list[str]:
150
150
  """A bucket's field aliases.
151
151
 
152
152
  A bucket item is an alias, or an `AacBucketRef` carrying `format`, `axis`, `totals`
153
- or `display_as` -- presentation, which nothing here or in the scorer reads.
153
+ or `display_as` -- presentation, which nothing here reads. A null metric only holds a
154
+ position and names no field.
154
155
  """
155
- return [item if isinstance(item, str) else item.field for item in bucket]
156
+ return [item if isinstance(item, str) else item.field for item in bucket if item is not None]
156
157
 
157
158
 
158
159
  def _reject_unscorable(spec: CreatedVisualization) -> None:
@@ -164,7 +165,7 @@ def _reject_unscorable(spec: CreatedVisualization) -> None:
164
165
  field where `check_filters` never looks, and a repeater lists labels among its
165
166
  metrics. Teach the comparator about one and its line here can go.
166
167
  """
167
- for alias in aliases(spec.metrics):
168
+ for alias in aliases(spec.metrics) + aliases(spec.size_by):
168
169
  field = spec.query.fields[alias]
169
170
  if isinstance(field, str):
170
171
  using, aggregated = field, False
@@ -257,9 +258,12 @@ def convert(viz: dict) -> CreatedVisualization:
257
258
  # scorer never reads them, so they do not belong in an expected output.
258
259
  spec.config = None
259
260
  spec.query.filter_by = _normalize_filters(spec.query.filter_by)
260
- for bucket in ("metrics", "view_by", "segment_by", "columns", "rows"):
261
+ # A null metric holds a position: a bubble with no x measure is `[null, y]`, and dropping
262
+ # the null would move y onto the x axis.
263
+ spec.metrics = [None if item is None else aliases([item])[0] for item in spec.metrics]
264
+ for bucket in ("view_by", "segment_by", "columns", "rows", "size_by"):
261
265
  setattr(spec, bucket, aliases(getattr(spec, bucket)))
262
- if not spec.metrics:
266
+ if not aliases(spec.metrics):
263
267
  raise Unsupported("no measures")
264
268
  _reject_unscorable(spec)
265
269
  return spec