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.
- gooddata_eval/cli/agentic_runner.py +4 -2
- gooddata_eval/core/agentic/dashboard_skill.py +80 -1
- gooddata_eval/core/agentic/visualization.py +132 -22
- gooddata_eval/core/chat/sse_client.py +4 -3
- gooddata_eval/core/dataset/from_insights.py +10 -6
- gooddata_eval/core/evaluators/visualization.py +296 -6
- gooddata_eval/core/models.py +20 -4
- gooddata_eval/core/number_format.py +179 -0
- gooddata_eval/core/scoring.py +148 -8
- {gooddata_eval-1.78.1.dev1.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/METADATA +59 -2
- {gooddata_eval-1.78.1.dev1.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/RECORD +14 -13
- {gooddata_eval-1.78.1.dev1.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/WHEEL +0 -0
- {gooddata_eval-1.78.1.dev1.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/entry_points.txt +0 -0
- {gooddata_eval-1.78.1.dev1.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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": [{
|
|
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
|
|
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
|
-
|
|
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
|