gooddata-eval 1.76.1.dev1__py3-none-any.whl → 1.76.1.dev3__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.
@@ -24,6 +24,7 @@ from gooddata_eval.core.agentic.search_tool import evaluate_agentic_search_tool
24
24
  from gooddata_eval.core.agentic.visualization import evaluate_agentic_visualization
25
25
  from gooddata_eval.core.agentic.what_if import evaluate_agentic_what_if
26
26
  from gooddata_eval.core.config import ReasoningEffort
27
+ from gooddata_eval.core.evaluators.visualization import requires_execution_of
27
28
  from gooddata_eval.core.models import AgenticEvalOutcome, CreatedVisualization, DatasetItem
28
29
  from gooddata_eval.core.runner import EvalReport, ItemReport
29
30
 
@@ -179,9 +180,11 @@ def _dispatch_agentic(
179
180
  workspace_id=workspace_id,
180
181
  question=item.question,
181
182
  expected_outputs=_parse_visualization_expected(eo),
183
+ requires_execution=requires_execution_of(eo),
182
184
  k=k,
183
185
  gate=gate,
184
186
  agent_id=agent_id,
187
+ user_context=item.user_context,
185
188
  **lf_kw,
186
189
  )
187
190
  elif kind == "agentic_metric_skill":
@@ -194,6 +197,7 @@ def _dispatch_agentic(
194
197
  k=k,
195
198
  gate=gate,
196
199
  agent_id=agent_id,
200
+ user_context=item.user_context,
197
201
  **lf_kw,
198
202
  )
199
203
  elif kind == "agentic_dashboard_skill":
@@ -206,6 +210,7 @@ def _dispatch_agentic(
206
210
  k=k,
207
211
  gate=gate,
208
212
  agent_id=agent_id,
213
+ user_context=item.user_context,
209
214
  **lf_kw,
210
215
  )
211
216
  elif kind == "agentic_report_skill":
@@ -218,6 +223,7 @@ def _dispatch_agentic(
218
223
  k=k,
219
224
  gate=gate,
220
225
  agent_id=agent_id,
226
+ user_context=item.user_context,
221
227
  **lf_kw,
222
228
  )
223
229
  elif kind == "agentic_alert_skill":
@@ -230,6 +236,7 @@ def _dispatch_agentic(
230
236
  k=k,
231
237
  gate=gate,
232
238
  agent_id=agent_id,
239
+ user_context=item.user_context,
233
240
  **lf_kw,
234
241
  )
235
242
  elif kind == "agentic_search":
@@ -245,6 +252,7 @@ def _dispatch_agentic(
245
252
  k=k,
246
253
  gate=gate,
247
254
  agent_id=agent_id,
255
+ user_context=item.user_context,
248
256
  **lf_kw,
249
257
  )
250
258
  elif kind == "agentic_general_question":
@@ -270,6 +278,7 @@ def _dispatch_agentic(
270
278
  k=k,
271
279
  gate=gate,
272
280
  agent_id=agent_id,
281
+ user_context=item.user_context,
273
282
  **lf_kw,
274
283
  )
275
284
  elif kind == "agentic_kda_skill":
@@ -282,6 +291,7 @@ def _dispatch_agentic(
282
291
  k=k,
283
292
  gate=gate,
284
293
  agent_id=agent_id,
294
+ user_context=item.user_context,
285
295
  **lf_kw,
286
296
  )
287
297
  elif kind == "agentic_what_if":
@@ -293,6 +303,7 @@ def _dispatch_agentic(
293
303
  expected_output=eo if isinstance(eo, dict) else {},
294
304
  k=k,
295
305
  agent_id=agent_id,
306
+ user_context=item.user_context,
296
307
  **lf_kw,
297
308
  )
298
309
  elif kind == "agentic_anomaly_detection":
@@ -305,6 +316,7 @@ def _dispatch_agentic(
305
316
  k=k,
306
317
  gate=gate,
307
318
  agent_id=agent_id,
319
+ user_context=item.user_context,
308
320
  **lf_kw,
309
321
  )
310
322
  elif kind == "agentic_conversation":
@@ -315,6 +327,7 @@ def _dispatch_agentic(
315
327
  workspace_id=workspace_id,
316
328
  fixture=ConversationFixture.model_validate(fixture_data),
317
329
  agent_id=agent_id,
330
+ user_context=item.user_context,
318
331
  **lf_kw,
319
332
  )
320
333
  else:
@@ -659,12 +659,18 @@ def run_agentic_alert_skill(
659
659
  initial_conversation_id: str | None = None,
660
660
  reasoning_effort: ReasoningEffort | None = None,
661
661
  agent_id: str | None = None,
662
+ user_context: dict | None = None,
662
663
  ) -> AgenticAlertSummary:
663
664
  """Run the alert-skill agentic evaluation K times and return a summary."""
664
665
  expected = _normalize_expected_output(expected_output)
665
666
  run_results: list[AlertRunResult] = []
666
667
  client = ChatClient(
667
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
668
+ host=host,
669
+ token=token,
670
+ workspace_id=workspace_id,
671
+ reasoning_effort=reasoning_effort,
672
+ agent_id=agent_id,
673
+ user_context=user_context,
668
674
  )
669
675
  sdk = GoodDataSdk.create(host, token)
670
676
 
@@ -858,6 +864,7 @@ def evaluate_agentic_alert_skill(
858
864
  run_metadata_extra: dict | None = None,
859
865
  reasoning_effort: ReasoningEffort | None = None,
860
866
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
867
+ user_context: dict | None = None,
861
868
  gate: EvalGate = DEFAULT_GATE,
862
869
  ) -> AgenticEvalOutcome:
863
870
  """Run alert-skill evaluation, log to Langfuse, and raise AlertSkillAssertionError on failure.
@@ -881,6 +888,7 @@ def evaluate_agentic_alert_skill(
881
888
  initial_conversation_id=initial_conversation_id,
882
889
  reasoning_effort=reasoning_effort,
883
890
  agent_id=agent_id,
891
+ user_context=user_context,
884
892
  )
885
893
 
886
894
  if langfuse is not None and dataset_item_id:
@@ -344,6 +344,7 @@ def run_agentic_anomaly_detection(
344
344
  initial_conversation_id: str | None = None,
345
345
  reasoning_effort: ReasoningEffort | None = None,
346
346
  agent_id: str | None = None,
347
+ user_context: dict | None = None,
347
348
  ) -> AgenticAnomalySummary:
348
349
  """Run the anomaly-detection agentic evaluation K times and return a summary.
349
350
 
@@ -356,7 +357,12 @@ def run_agentic_anomaly_detection(
356
357
  raise ValueError(f"k must be >= 1, got {k}")
357
358
  run_results: list[AnomalyRunResult] = []
358
359
  client = ChatClient(
359
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
360
+ host=host,
361
+ token=token,
362
+ workspace_id=workspace_id,
363
+ reasoning_effort=reasoning_effort,
364
+ agent_id=agent_id,
365
+ user_context=user_context,
360
366
  )
361
367
 
362
368
  def _run_once(conv_id: str) -> AnomalyRunResult:
@@ -520,6 +526,7 @@ def evaluate_agentic_anomaly_detection(
520
526
  run_metadata_extra: dict | None = None,
521
527
  reasoning_effort: ReasoningEffort | None = None,
522
528
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
529
+ user_context: dict | None = None,
523
530
  ) -> AgenticEvalOutcome:
524
531
  """Run anomaly-detection evaluation, log to Langfuse, and raise on failure."""
525
532
  langfuse, window_start = open_trace_window(langfuse)
@@ -534,6 +541,7 @@ def evaluate_agentic_anomaly_detection(
534
541
  initial_conversation_id=initial_conversation_id,
535
542
  reasoning_effort=reasoning_effort,
536
543
  agent_id=agent_id,
544
+ user_context=user_context,
537
545
  )
538
546
 
539
547
  if langfuse is not None and dataset_item_id:
@@ -24,7 +24,7 @@ from datetime import date
24
24
  from typing import ClassVar, Literal
25
25
 
26
26
  from gooddata_sdk import GoodDataSdk
27
- from pydantic import BaseModel, Field, ValidationError
27
+ from pydantic import BaseModel, Field, ValidationError, model_validator
28
28
 
29
29
  from gooddata_eval.core.agentic._conversation_context import (
30
30
  CONFIRMATION_REPLY,
@@ -100,6 +100,12 @@ class TurnDefinition(BaseModel):
100
100
  over. ``set_answers`` are what the user replies, in order, when the assistant asks a
101
101
  legitimate question on this turn -- the only knowledge the context-mode simulated user
102
102
  has beyond the conversation itself.
103
+
104
+ ``user_context`` is the ``userContext`` (dashboard/widget scope) the user has attached
105
+ from this turn on, as in the UI: it sticks to later turns until another turn sets it,
106
+ and setting it to null clears it. A turn that leaves it out keeps the current one. Its
107
+ clarification replies carry it too, since gen-ai treats a message without a context as
108
+ cleared.
103
109
  """
104
110
 
105
111
  turn_id: str
@@ -112,6 +118,7 @@ class TurnDefinition(BaseModel):
112
118
  expected_tool_args: dict | None = None
113
119
  depends_on: list[str] = Field(default_factory=list)
114
120
  set_answers: list[str] = Field(default_factory=list)
121
+ user_context: dict | None = None
115
122
 
116
123
 
117
124
  class ConversationFixture(BaseModel):
@@ -122,6 +129,17 @@ class ConversationFixture(BaseModel):
122
129
  expected_skills: list[str]
123
130
  turns: list[TurnDefinition]
124
131
 
132
+ @model_validator(mode="before")
133
+ @classmethod
134
+ def _reject_fixture_level_user_context(cls, data: object) -> object:
135
+ # Unknown keys are ignored, so a context placed here would be dropped without a trace.
136
+ if isinstance(data, dict) and "user_context" in data:
137
+ raise ValueError(
138
+ "user_context is not read from the conversation fixture; put it in the item "
139
+ "metadata or input (the context of the first turn), or on a turn"
140
+ )
141
+ return data
142
+
125
143
 
126
144
  ReplyRecordKind = Literal["confirmation", "question", "no_action", "turn_incomplete"]
127
145
 
@@ -696,6 +714,7 @@ def run_agentic_conversation(
696
714
  mode: ConversationMode | None = None,
697
715
  clarification_judge: ClarificationJudge | None = None,
698
716
  fresh_conversation_per_turn: bool = False,
717
+ user_context: dict | None = None,
699
718
  ) -> ConversationResult:
700
719
  """Run a multi-turn, multi-skill conversation evaluation (no K-runs).
701
720
 
@@ -706,6 +725,9 @@ def run_agentic_conversation(
706
725
  ``fresh_conversation_per_turn`` sends every turn into a new conversation instead, so the
707
726
  agent sees no history. It is the no-memory baseline a context score is calibrated
708
727
  against: context-dependent turns are expected to fail there.
728
+
729
+ ``user_context`` is the context attached when the conversation starts; a turn's own
730
+ ``user_context`` replaces it from that turn on.
709
731
  """
710
732
  resolved_mode = resolve_conversation_mode(mode)
711
733
  if fresh_conversation_per_turn and initial_conversation_id is not None:
@@ -747,6 +769,7 @@ def run_agentic_conversation(
747
769
  turn_offset = 0.0
748
770
  tool_index_offset = 0
749
771
  reasoning_index_offset = 0
772
+ current_context = user_context
750
773
 
751
774
  try:
752
775
  if initial_conversation_id is not None:
@@ -761,6 +784,10 @@ def run_agentic_conversation(
761
784
  conversation_id = client.create_conversation()
762
785
  transcript = []
763
786
  active_skills = set()
787
+ # Before the skip below: a turn whose expectation cannot resolve still changed what
788
+ # the user has attached. Only a turn that names the field changes it; null clears it.
789
+ if "user_context" in turn.model_fields_set:
790
+ current_context = turn.user_context
764
791
  try:
765
792
  resolved_expected = _resolve_refs(turn.expected_output, turn_outputs)
766
793
  resolved_alternatives = [
@@ -813,7 +840,7 @@ def run_agentic_conversation(
813
840
  transcript.append(TranscriptEntry("user", current_message))
814
841
  incomplete = False
815
842
  try:
816
- chat_result = client.send_message(conversation_id, current_message)
843
+ chat_result = client.send_message(conversation_id, current_message, user_context=current_context)
817
844
  except TurnIncompleteError as exc:
818
845
  chat_result = exc.partial_result or ChatResult()
819
846
  incomplete = True
@@ -1054,6 +1081,7 @@ def evaluate_agentic_conversation(
1054
1081
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
1055
1082
  mode: ConversationMode | None = None,
1056
1083
  clarification_judge: ClarificationJudge | None = None,
1084
+ user_context: dict | None = None,
1057
1085
  ) -> AgenticEvalOutcome:
1058
1086
  """Run conversation evaluation, log to Langfuse, and raise on failure.
1059
1087
 
@@ -1078,6 +1106,7 @@ def evaluate_agentic_conversation(
1078
1106
  agent_id=agent_id,
1079
1107
  mode=resolved_mode,
1080
1108
  clarification_judge=clarification_judge,
1109
+ user_context=user_context,
1081
1110
  )
1082
1111
  passed = result.context_success if resolved_mode == "context" else result.conversation_success
1083
1112
 
@@ -955,6 +955,7 @@ def run_agentic_dashboard_skill(
955
955
  initial_conversation_id: str | None = None,
956
956
  reasoning_effort: ReasoningEffort | None = None,
957
957
  agent_id: str | None = None,
958
+ user_context: dict | None = None,
958
959
  ) -> AgenticDashboardSummary:
959
960
  """Run the dashboard-skill agentic evaluation K times and return a summary.
960
961
 
@@ -964,7 +965,12 @@ def run_agentic_dashboard_skill(
964
965
  _validate_expectation(expected_output)
965
966
  run_results: list[DashboardRunResult] = []
966
967
  client = ChatClient(
967
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
968
+ host=host,
969
+ token=token,
970
+ workspace_id=workspace_id,
971
+ reasoning_effort=reasoning_effort,
972
+ agent_id=agent_id,
973
+ user_context=user_context,
968
974
  )
969
975
 
970
976
  try:
@@ -1021,6 +1027,7 @@ def evaluate_agentic_dashboard_skill(
1021
1027
  run_metadata_extra: dict | None = None,
1022
1028
  reasoning_effort: ReasoningEffort | None = None,
1023
1029
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
1030
+ user_context: dict | None = None,
1024
1031
  gate: EvalGate = DEFAULT_GATE,
1025
1032
  ) -> AgenticEvalOutcome:
1026
1033
  """Run dashboard-skill evaluation, log to Langfuse, and raise on failure.
@@ -1045,6 +1052,7 @@ def evaluate_agentic_dashboard_skill(
1045
1052
  initial_conversation_id=initial_conversation_id,
1046
1053
  reasoning_effort=reasoning_effort,
1047
1054
  agent_id=agent_id,
1055
+ user_context=user_context,
1048
1056
  )
1049
1057
 
1050
1058
  if langfuse is not None and dataset_item_id:
@@ -116,11 +116,10 @@ def _run_single_general_question(
116
116
  conversation_id: str,
117
117
  question: str,
118
118
  expected_output: str,
119
- user_context: dict | None = None,
120
119
  ) -> GeneralQuestionResult:
121
120
  item_started = time.monotonic()
122
121
  agent_started = time.monotonic()
123
- chat_result = client.send_message(conversation_id, question, user_context=user_context)
122
+ chat_result = client.send_message(conversation_id, question)
124
123
  actual_output = render_answer_text(chat_result)
125
124
  agent_elapsed = time.monotonic() - agent_started
126
125
  log_timer(
@@ -169,16 +168,19 @@ def run_agentic_general_question(
169
168
  """Run the general-question agentic evaluation K times and return a summary."""
170
169
  run_results: list[GeneralQuestionResult] = []
171
170
  client = ChatClient(
172
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
171
+ host=host,
172
+ token=token,
173
+ workspace_id=workspace_id,
174
+ reasoning_effort=reasoning_effort,
175
+ agent_id=agent_id,
176
+ user_context=user_context,
173
177
  )
174
178
  judge = LLMJudge(_GENERAL_QUESTION_EVALUATION_STEPS)
175
179
 
176
180
  try:
177
181
  conv_id_0 = initial_conversation_id if initial_conversation_id is not None else client.create_conversation()
178
182
  try:
179
- run_results.append(
180
- _run_single_general_question(client, judge, conv_id_0, question, expected_output, user_context)
181
- )
183
+ run_results.append(_run_single_general_question(client, judge, conv_id_0, question, expected_output))
182
184
  finally:
183
185
  if initial_conversation_id is None:
184
186
  client.delete_conversation(conv_id_0)
@@ -186,9 +188,7 @@ def run_agentic_general_question(
186
188
  for _ in range(1, k):
187
189
  conv_id = client.create_conversation()
188
190
  try:
189
- run_results.append(
190
- _run_single_general_question(client, judge, conv_id, question, expected_output, user_context)
191
- )
191
+ run_results.append(_run_single_general_question(client, judge, conv_id, question, expected_output))
192
192
  finally:
193
193
  client.delete_conversation(conv_id)
194
194
  finally:
@@ -144,11 +144,17 @@ def run_agentic_guardrail(
144
144
  initial_conversation_id: str | None = None,
145
145
  reasoning_effort: ReasoningEffort | None = None,
146
146
  agent_id: str | None = None,
147
+ user_context: dict | None = None,
147
148
  ) -> AgenticGuardrailSummary:
148
149
  """Run the guardrail agentic evaluation K times and return a summary."""
149
150
  run_results: list[GuardrailResult] = []
150
151
  client = ChatClient(
151
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
152
+ host=host,
153
+ token=token,
154
+ workspace_id=workspace_id,
155
+ reasoning_effort=reasoning_effort,
156
+ agent_id=agent_id,
157
+ user_context=user_context,
152
158
  )
153
159
  judge = LLMJudge(_GUARDRAIL_EVALUATION_STEPS)
154
160
 
@@ -206,6 +212,7 @@ def evaluate_agentic_guardrail(
206
212
  run_metadata_extra: dict | None = None,
207
213
  reasoning_effort: ReasoningEffort | None = None,
208
214
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
215
+ user_context: dict | None = None,
209
216
  gate: EvalGate = DEFAULT_GATE,
210
217
  ) -> AgenticEvalOutcome:
211
218
  """Run guardrail evaluation, log to Langfuse, and raise GuardrailAssertionError on failure.
@@ -226,6 +233,7 @@ def evaluate_agentic_guardrail(
226
233
  initial_conversation_id=initial_conversation_id,
227
234
  reasoning_effort=reasoning_effort,
228
235
  agent_id=agent_id,
236
+ user_context=user_context,
229
237
  )
230
238
 
231
239
  if langfuse is not None and dataset_item_id:
@@ -239,6 +239,7 @@ def run_agentic_kda_skill(
239
239
  initial_conversation_id: str | None = None,
240
240
  reasoning_effort: ReasoningEffort | None = None,
241
241
  agent_id: str | None = None,
242
+ user_context: dict | None = None,
242
243
  ) -> AgenticKdaSummary:
243
244
  """Run the KDA-skill agentic evaluation K times and return a summary.
244
245
 
@@ -256,7 +257,12 @@ def run_agentic_kda_skill(
256
257
  raise ValueError(f"k must be >= 1, got {k}")
257
258
  run_results: list[KdaRunResult] = []
258
259
  client = ChatClient(
259
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
260
+ host=host,
261
+ token=token,
262
+ workspace_id=workspace_id,
263
+ reasoning_effort=reasoning_effort,
264
+ agent_id=agent_id,
265
+ user_context=user_context,
260
266
  )
261
267
 
262
268
  def _run_once(conv_id: str) -> KdaRunResult:
@@ -424,6 +430,7 @@ def evaluate_agentic_kda_skill(
424
430
  run_metadata_extra: dict | None = None,
425
431
  reasoning_effort: ReasoningEffort | None = None,
426
432
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
433
+ user_context: dict | None = None,
427
434
  gate: EvalGate = DEFAULT_GATE,
428
435
  ) -> AgenticEvalOutcome:
429
436
  """Run KDA-skill evaluation, log to Langfuse, and raise KdaSkillAssertionError on failure.
@@ -444,6 +451,7 @@ def evaluate_agentic_kda_skill(
444
451
  initial_conversation_id=initial_conversation_id,
445
452
  reasoning_effort=reasoning_effort,
446
453
  agent_id=agent_id,
454
+ user_context=user_context,
447
455
  )
448
456
 
449
457
  if langfuse is not None and dataset_item_id:
@@ -9,6 +9,7 @@ import time
9
9
  from dataclasses import dataclass, field
10
10
  from typing import Any
11
11
 
12
+ from gooddata_api_client.exceptions import NotFoundException
12
13
  from gooddata_sdk import GoodDataSdk
13
14
 
14
15
  from gooddata_eval.core.agentic._gate import (
@@ -50,6 +51,9 @@ except ImportError:
50
51
 
51
52
  _DEFAULT_K = 1
52
53
  _DEFAULT_MAX_ITERATIONS = 7
54
+ # Seconds to wait before each check that a deleted metric stayed deleted, so the checks fall
55
+ # 3, 6 and 12 seconds after the delete; see _delete_metric.
56
+ _DELETE_RECHECK_DELAYS_S: tuple[float, ...] = (3.0, 3.0, 6.0)
53
57
 
54
58
 
55
59
  def _best_maql_match(actual_maql: str, expected_outputs: list[dict]) -> tuple[bool, str]:
@@ -238,11 +242,53 @@ def _delete_metric(sdk: GoodDataSdk, workspace_id: str, metric_id: str) -> None:
238
242
  MAQL) and the assertion fails. Deleting the created metric on the way out keeps
239
243
  the workspace clean for the next run. Best-effort: failures are logged, not raised.
240
244
  Mirrors ``alert_skill._delete_alert``.
245
+
246
+ A delete does not stay done on its own. ``create_metric`` reads the whole analytics
247
+ model and writes it back, so a concurrent test whose read preceded this delete
248
+ restores the metric when its write lands. The metric is therefore checked again after
249
+ each of ``_DELETE_RECHECK_DELAYS_S`` and deleted again while it is still there.
241
250
  """
251
+ if not _try_delete_metric(sdk, workspace_id, metric_id):
252
+ return
253
+ for delay in _DELETE_RECHECK_DELAYS_S:
254
+ time.sleep(delay)
255
+ if not _metric_exists(sdk, workspace_id, metric_id):
256
+ return
257
+ print(f"[CLEANUP] Metric {metric_id} is back or could not be checked; deleting again")
258
+ if not _try_delete_metric(sdk, workspace_id, metric_id):
259
+ return
260
+ if _DELETE_RECHECK_DELAYS_S:
261
+ # Every check found it back, so the last delete is unverified: say so rather than
262
+ # report a clean workspace. The post-run reset is what removes it for good.
263
+ print(
264
+ f"[CLEANUP] Metric {metric_id} was back or unchecked at all {len(_DELETE_RECHECK_DELAYS_S)} checks; "
265
+ "the last delete is not verified and the metric may remain"
266
+ )
267
+
268
+
269
+ def _try_delete_metric(sdk: GoodDataSdk, workspace_id: str, metric_id: str) -> bool:
270
+ """Delete the metric; True when it is gone afterwards, a 404 included."""
242
271
  try:
243
272
  sdk._client.entities_api.delete_entity_metrics(workspace_id, metric_id)
273
+ except NotFoundException:
274
+ return True
244
275
  except Exception as exc:
245
276
  print(f"[CLEANUP] Failed to delete metric {metric_id}: {exc}")
277
+ return False
278
+ return True
279
+
280
+
281
+ def _metric_exists(sdk: GoodDataSdk, workspace_id: str, metric_id: str) -> bool:
282
+ """Whether the metric may still be in the workspace. Only a 404 reads as absent; any other
283
+ lookup failure reads as present, so the caller deletes again rather than leave a restored
284
+ metric behind. The recheck delays bound how often that happens."""
285
+ try:
286
+ sdk._client.entities_api.get_entity_metrics(workspace_id, metric_id)
287
+ except NotFoundException:
288
+ return False
289
+ except Exception as exc:
290
+ print(f"[CLEANUP] Could not check metric {metric_id}: {exc}")
291
+ return True
246
292
 
247
293
 
248
294
  def _execute_single_metric_run(
@@ -396,6 +442,7 @@ def run_agentic_metric_skill(
396
442
  initial_conversation_id: str | None = None,
397
443
  reasoning_effort: ReasoningEffort | None = None,
398
444
  agent_id: str | None = None,
445
+ user_context: dict | None = None,
399
446
  ) -> AgenticMetricSummary:
400
447
  """Run the metric-skill agentic evaluation K times and return a summary.
401
448
 
@@ -405,7 +452,12 @@ def run_agentic_metric_skill(
405
452
  expected_outputs: list[dict] = expected_output if isinstance(expected_output, list) else [expected_output]
406
453
  run_results: list[MetricRunResult] = []
407
454
  client = ChatClient(
408
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
455
+ host=host,
456
+ token=token,
457
+ workspace_id=workspace_id,
458
+ reasoning_effort=reasoning_effort,
459
+ agent_id=agent_id,
460
+ user_context=user_context,
409
461
  )
410
462
  sdk = GoodDataSdk.create(host, token)
411
463
 
@@ -467,6 +519,7 @@ def evaluate_agentic_metric_skill(
467
519
  run_metadata_extra: dict | None = None,
468
520
  reasoning_effort: ReasoningEffort | None = None,
469
521
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
522
+ user_context: dict | None = None,
470
523
  gate: EvalGate = DEFAULT_GATE,
471
524
  ) -> AgenticEvalOutcome:
472
525
  """Run metric-skill evaluation, log to Langfuse, and raise MetricSkillAssertionError on failure.
@@ -490,6 +543,7 @@ def evaluate_agentic_metric_skill(
490
543
  initial_conversation_id=initial_conversation_id,
491
544
  reasoning_effort=reasoning_effort,
492
545
  agent_id=agent_id,
546
+ user_context=user_context,
493
547
  )
494
548
 
495
549
  if langfuse is not None and dataset_item_id:
@@ -625,6 +625,7 @@ def run_agentic_report_skill(
625
625
  reasoning_effort: ReasoningEffort | None = None,
626
626
  agent_id: str | None = None,
627
627
  judge: LLMJudge | None = None,
628
+ user_context: dict | None = None,
628
629
  ) -> AgenticReportSummary:
629
630
  """Run the report-skill agentic evaluation K times and return a summary.
630
631
 
@@ -639,7 +640,12 @@ def run_agentic_report_skill(
639
640
  judge = LLMJudge(_NARRATIVE_EVALUATION_STEPS)
640
641
  run_results: list[ReportRunResult] = []
641
642
  client = ChatClient(
642
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
643
+ host=host,
644
+ token=token,
645
+ workspace_id=workspace_id,
646
+ reasoning_effort=reasoning_effort,
647
+ agent_id=agent_id,
648
+ user_context=user_context,
643
649
  )
644
650
 
645
651
  try:
@@ -698,6 +704,7 @@ def evaluate_agentic_report_skill(
698
704
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
699
705
  gate: EvalGate = DEFAULT_GATE,
700
706
  judge: LLMJudge | None = None,
707
+ user_context: dict | None = None,
701
708
  ) -> AgenticEvalOutcome:
702
709
  """Run report-skill evaluation, log to Langfuse, and raise on failure.
703
710
 
@@ -724,6 +731,7 @@ def evaluate_agentic_report_skill(
724
731
  reasoning_effort=reasoning_effort,
725
732
  agent_id=agent_id,
726
733
  judge=judge,
734
+ user_context=user_context,
727
735
  )
728
736
 
729
737
  if langfuse is not None and dataset_item_id:
@@ -97,12 +97,18 @@ def run_agentic_search_tool(
97
97
  initial_conversation_id: str | None = None,
98
98
  reasoning_effort: ReasoningEffort | None = None,
99
99
  agent_id: str | None = None,
100
+ user_context: dict | None = None,
100
101
  ) -> AgenticSearchSummary:
101
102
  """Run the search-tool agentic evaluation K times (single-turn each)."""
102
103
  run_results: list[SearchResult] = []
103
104
 
104
105
  client = ChatClient(
105
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
106
+ host=host,
107
+ token=token,
108
+ workspace_id=workspace_id,
109
+ reasoning_effort=reasoning_effort,
110
+ agent_id=agent_id,
111
+ user_context=user_context,
106
112
  )
107
113
  try:
108
114
  conv_id_0 = initial_conversation_id if initial_conversation_id is not None else client.create_conversation()
@@ -185,6 +191,7 @@ def evaluate_agentic_search_tool(
185
191
  run_metadata_extra: dict | None = None,
186
192
  reasoning_effort: ReasoningEffort | None = None,
187
193
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
194
+ user_context: dict | None = None,
188
195
  gate: EvalGate = DEFAULT_GATE,
189
196
  ) -> AgenticEvalOutcome:
190
197
  """Run search-tool evaluation, log to Langfuse, and raise SearchToolAssertionError on failure.
@@ -204,6 +211,7 @@ def evaluate_agentic_search_tool(
204
211
  initial_conversation_id=initial_conversation_id,
205
212
  reasoning_effort=reasoning_effort,
206
213
  agent_id=agent_id,
214
+ user_context=user_context,
207
215
  )
208
216
 
209
217
  if langfuse is not None and dataset_item_id:
@@ -35,6 +35,7 @@ from gooddata_eval.core.evaluators.visualization import (
35
35
  _check_visualization_skill_activated,
36
36
  _evaluate_against_candidates,
37
37
  evaluation_result_detail,
38
+ with_execution,
38
39
  )
39
40
  from gooddata_eval.core.models import (
40
41
  AgenticAssertionError,
@@ -189,6 +190,7 @@ def _execute_single_run(
189
190
  question: str,
190
191
  expected_outputs: list[CreatedVisualization],
191
192
  max_iterations: int = _DEFAULT_MAX_ITERATIONS,
193
+ requires_execution: bool = False,
192
194
  ) -> RunResult:
193
195
  """Drive one full multi-turn conversation and evaluate the result."""
194
196
  total_turns = 0
@@ -214,6 +216,9 @@ def _execute_single_run(
214
216
  # K-run already completed along with any exit_reason.
215
217
  print(f"[CHAT] send_message failed for conversation {conversation_id}: {exc}")
216
218
  current_result = getattr(exc, "partial_result", None) or ChatResult()
219
+ # The loop that collects events does not run after CHAT_ERROR, so a partial result's
220
+ # chart executions are added here.
221
+ all_tool_call_events.extend(current_result.tool_call_events)
217
222
  exit_reason = LoopExit.CHAT_ERROR
218
223
  # Counted here, not at the top of the loop: this request is sent unconditionally, so
219
224
  # with max_iterations=0 the loop body never runs and total_turns would report 0 turns
@@ -265,6 +270,9 @@ def _execute_single_run(
265
270
  partial = getattr(exc, "partial_result", None)
266
271
  if partial is not None:
267
272
  current_result = partial
273
+ # The loop adds a turn's events at its next pass, which a break skips. The
274
+ # partial result is scored, so its chart executions count too.
275
+ all_tool_call_events.extend(partial.tool_call_events)
268
276
  exit_reason = LoopExit.CHAT_ERROR
269
277
  break
270
278
 
@@ -274,6 +282,7 @@ def _execute_single_run(
274
282
  actual_output = current_result.created_visualizations.objects[0]
275
283
 
276
284
  eval_result, best_expected = _evaluate_against_candidates(expected_outputs, actual_output, skill_activated)
285
+ eval_result = with_execution(eval_result, all_tool_call_events, current_result.text_response, requires_execution)
277
286
 
278
287
  return RunResult(
279
288
  conversation_id=conversation_id,
@@ -301,6 +310,8 @@ def run_agentic_visualization(
301
310
  initial_conversation_id: str | None = None,
302
311
  reasoning_effort: ReasoningEffort | None = None,
303
312
  agent_id: str | None = None,
313
+ user_context: dict | None = None,
314
+ requires_execution: bool = False,
304
315
  ) -> AgenticRunSummary:
305
316
  """Run K independent conversations and return evaluation results.
306
317
 
@@ -308,16 +319,25 @@ def run_agentic_visualization(
308
319
  (e.g. one created by a Tavern YAML POST). Subsequent runs always create
309
320
  fresh conversations. Caller-supplied conversations are not deleted; all
310
321
  conversations created by this function are deleted on completion.
322
+
323
+ ``requires_execution`` fails a run whose agent built the chart but never ran it.
311
324
  """
312
325
  client = ChatClient(
313
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
326
+ host=host,
327
+ token=token,
328
+ workspace_id=workspace_id,
329
+ reasoning_effort=reasoning_effort,
330
+ agent_id=agent_id,
331
+ user_context=user_context,
314
332
  )
315
333
  run_results: list[RunResult] = []
316
334
 
317
335
  try:
318
336
  conv_id_0 = initial_conversation_id if initial_conversation_id is not None else client.create_conversation()
319
337
  try:
320
- run_results.append(_execute_single_run(client, conv_id_0, question, expected_outputs, max_iterations))
338
+ run_results.append(
339
+ _execute_single_run(client, conv_id_0, question, expected_outputs, max_iterations, requires_execution)
340
+ )
321
341
  finally:
322
342
  if initial_conversation_id is None:
323
343
  client.delete_conversation(conv_id_0)
@@ -325,7 +345,9 @@ def run_agentic_visualization(
325
345
  for _ in range(1, k):
326
346
  conv_id = client.create_conversation()
327
347
  try:
328
- run_results.append(_execute_single_run(client, conv_id, question, expected_outputs, max_iterations))
348
+ run_results.append(
349
+ _execute_single_run(client, conv_id, question, expected_outputs, max_iterations, requires_execution)
350
+ )
329
351
  finally:
330
352
  client.delete_conversation(conv_id)
331
353
  finally:
@@ -378,6 +400,8 @@ def evaluate_agentic_visualization(
378
400
  record_output_path: str | None = None,
379
401
  reasoning_effort: ReasoningEffort | None = None,
380
402
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
403
+ user_context: dict | None = None,
404
+ requires_execution: bool = False,
381
405
  gate: EvalGate = DEFAULT_GATE,
382
406
  ) -> AgenticEvalOutcome:
383
407
  """Run visualization evaluation, log to Langfuse, and raise VisualizationAssertionError on failure.
@@ -400,6 +424,8 @@ def evaluate_agentic_visualization(
400
424
  initial_conversation_id=initial_conversation_id,
401
425
  reasoning_effort=reasoning_effort,
402
426
  agent_id=agent_id,
427
+ user_context=user_context,
428
+ requires_execution=requires_execution,
403
429
  )
404
430
 
405
431
  if langfuse is not None and dataset_item_id:
@@ -420,6 +446,8 @@ def evaluate_agentic_visualization(
420
446
  "assertion-vis-filters": ev.filters_correct,
421
447
  "assertion-vis-type": ev.viz_type_hard,
422
448
  }
449
+ if ev.requires_execution:
450
+ strict_checks["assertion-vis-executed"] = ev.executed
423
451
  with ctx.observe(pt, run_idx, conversation_id=run.conversation_id, output=strict_checks) as tid:
424
452
  ctx.score(tid, name="assertion-cross-ref-valid", value=ev.cross_ref_valid, data_type="BOOLEAN")
425
453
  ctx.score(tid, name="assertion-vis-metric", value=ev.metrics_correct, data_type="BOOLEAN")
@@ -427,6 +455,10 @@ def evaluate_agentic_visualization(
427
455
  ctx.score(tid, name="assertion-vis-filters", value=ev.filters_correct, data_type="BOOLEAN")
428
456
  ctx.score(tid, name="assertion-vis-type", value=ev.viz_type_hard, data_type="BOOLEAN")
429
457
  ctx.score(tid, name="skill_selection", value=ev.skill_activated, data_type="BOOLEAN")
458
+ # Scored on every item, gating or not, so the run rate is visible per model.
459
+ ctx.score(tid, name="assertion-vis-executed", value=ev.executed, data_type="BOOLEAN")
460
+ if ev.stated_value_matches is not None:
461
+ ctx.score(tid, name="stated-value-matches", value=ev.stated_value_matches, data_type="BOOLEAN")
430
462
  # Superseded by log_gate_scores' K-stable names, kept until the readers
431
463
  # migrate: gdc-nas combo_report.py matches on the literal "pass_at_2".
432
464
  ctx.score(tid, name=f"pass_at_{K}", value=summary.pass_at_k, data_type="BOOLEAN")
@@ -524,6 +556,8 @@ def evaluate_agentic_visualization(
524
556
  f" attribute : {ev.filter_attribute_score}\n"
525
557
  f"{_filter_diff('attribute', ev)}"
526
558
  f" Viz Type Hard : {ev.viz_type_hard}\n"
559
+ f" Executed : {ev.executed}{' (required)' if ev.requires_execution else ''}\n"
560
+ f" Stated Value Matches : {ev.stated_value_matches} (reported only)\n"
527
561
  "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
528
562
  )
529
563
  exc.reasoning_steps = best.reasoning_steps
@@ -293,6 +293,7 @@ def run_agentic_what_if(
293
293
  initial_conversation_id: str | None = None,
294
294
  reasoning_effort: ReasoningEffort | None = None,
295
295
  agent_id: str | None = None,
296
+ user_context: dict | None = None,
296
297
  ) -> AgenticWhatIfSummary:
297
298
  """Run the what-if agentic evaluation K times and return a summary.
298
299
 
@@ -305,7 +306,12 @@ def run_agentic_what_if(
305
306
  raise ValueError(f"k must be >= 1, got {k}")
306
307
  run_results: list[WhatIfRunResult] = []
307
308
  client = ChatClient(
308
- host=host, token=token, workspace_id=workspace_id, reasoning_effort=reasoning_effort, agent_id=agent_id
309
+ host=host,
310
+ token=token,
311
+ workspace_id=workspace_id,
312
+ reasoning_effort=reasoning_effort,
313
+ agent_id=agent_id,
314
+ user_context=user_context,
309
315
  )
310
316
 
311
317
  def _run_once(conv_id: str) -> WhatIfRunResult:
@@ -472,6 +478,7 @@ def evaluate_agentic_what_if(
472
478
  run_metadata_extra: dict | None = None,
473
479
  reasoning_effort: ReasoningEffort | None = None,
474
480
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
481
+ user_context: dict | None = None,
475
482
  ) -> AgenticEvalOutcome:
476
483
  """Run what-if evaluation, log to Langfuse, and raise WhatIfAssertionError on failure."""
477
484
  langfuse, window_start = open_trace_window(langfuse)
@@ -486,6 +493,7 @@ def evaluate_agentic_what_if(
486
493
  initial_conversation_id=initial_conversation_id,
487
494
  reasoning_effort=reasoning_effort,
488
495
  agent_id=agent_id,
496
+ user_context=user_context,
489
497
  )
490
498
 
491
499
  if langfuse is not None and dataset_item_id:
@@ -13,12 +13,15 @@ module is the single place to swap — the runner only depends on the ChatBacken
13
13
  protocol, not on this class.
14
14
  """
15
15
 
16
+ import functools
16
17
  import json
17
18
  import logging
18
19
  import os
20
+ import re
19
21
  import time
20
22
  from dataclasses import dataclass, field
21
23
  from typing import Any, Callable, Iterable, TypeVar
24
+ from urllib.parse import quote
22
25
 
23
26
  import httpx
24
27
 
@@ -135,6 +138,45 @@ _TURN_TIMEOUT_S = _float_env("GOODDATA_EVAL_CHAT_TURN_TIMEOUT_S", 0.0)
135
138
  # 4-turn conversation run to 4x the budget, which is not what a user would sit through.
136
139
  _ITEM_TIMEOUT_S = _float_env("GOODDATA_EVAL_CHAT_ITEM_TIMEOUT_S", 0.0)
137
140
 
141
+ # Comma-separated `key=value` labels, e.g. `model_version=gpt-5.2,github_run_id=42`, sent as
142
+ # W3C baggage on every chat request. gen-ai's Langfuse span processor copies `langfuse_*`
143
+ # baggage onto each span it starts, so every observation of the conversation -- the cost-bearing
144
+ # generations, retries and the title trace included -- carries them from creation. Langfuse v4
145
+ # cannot add them to an observation afterwards. A value cannot contain a comma, and a key must be
146
+ # a plain token (letters, digits, `_`, `-`); any other key is skipped with a warning, because httpx
147
+ # refuses a non-ASCII header and gen-ai's OTel propagator drops or rewrites the rest.
148
+ _TRACE_LABELS_ENV = "GOODDATA_EVAL_TRACE_LABELS"
149
+ _TRACE_LABEL_KEY = re.compile(r"[A-Za-z0-9_-]+")
150
+
151
+
152
+ @functools.cache
153
+ def _warn_skipped_trace_label(key: str) -> None:
154
+ """Warn once per key per process: every ChatClient re-reads the labels."""
155
+ _log.warning("%s: skipping label with key %r, keys must match [A-Za-z0-9_-]+", _TRACE_LABELS_ENV, key)
156
+
157
+
158
+ def _trace_baggage() -> dict[str, str]:
159
+ """The `baggage` header for the labels in ``GOODDATA_EVAL_TRACE_LABELS``, or none.
160
+
161
+ Each label becomes trace metadata, and ``model_version`` -- the combo the report groups
162
+ on -- also becomes the observation ``version``, the dimension Langfuse groups cost by.
163
+ Values are percent-encoded: a space or comma would otherwise end the baggage entry.
164
+ """
165
+ labels: dict[str, str] = {}
166
+ for pair in os.getenv(_TRACE_LABELS_ENV, "").split(","):
167
+ key, sep, value = pair.partition("=")
168
+ key = key.strip()
169
+ if not sep or not key:
170
+ continue
171
+ if _TRACE_LABEL_KEY.fullmatch(key):
172
+ labels[key] = value.strip()
173
+ else:
174
+ _warn_skipped_trace_label(key)
175
+ entries = [f"langfuse_metadata_{k}={quote(v, safe='')}" for k, v in labels.items()]
176
+ if labels.get("model_version"):
177
+ entries.insert(0, f"langfuse_version={quote(labels['model_version'], safe='')}")
178
+ return {"baggage": ",".join(entries)} if entries else {}
179
+
138
180
 
139
181
  def set_default_turn_timeout(seconds: float | None) -> None:
140
182
  """Set the per-turn budget every ChatClient built afterwards inherits.
@@ -308,11 +350,15 @@ def _build_chat_result(acc: _SseAccumulator) -> ChatResult:
308
350
  # create_adhoc_visualization but the call failed (e.g. data source not
309
351
  # accessible). The last attempt is the agent's best answer.
310
352
  #
311
- # These are raw tool-call arguments, so they carry no `id` -- nothing was
312
- # ever persisted. CreatedVisualization requires one, so synthesize a
313
- # sentinel rather than letting the whole ChatResult fail to validate:
314
- # dropping the turn entirely would score a stalled data source as a
315
- # content failure, which is exactly what this fallback exists to prevent.
353
+ # These are raw tool-call arguments, so they carry no `id` -- the server
354
+ # mints it. The sentinel marks the chart as never persisted. Logged because
355
+ # a turn that called the tool and answered without a chart is otherwise
356
+ # invisible: the user saw no chart either.
357
+ _log.warning(
358
+ "create_adhoc_visualization was called %d time(s) but the answer has no visualization part; "
359
+ "scoring the last call's arguments",
360
+ len(acc.adhoc_viz_args),
361
+ )
316
362
  payload["createdVisualizations"] = {
317
363
  "objects": [{"id": _ADHOC_VIZ_ID, **acc.adhoc_viz_args[-1]}],
318
364
  "reasoning": "\n".join(acc.viz_reasoning_parts),
@@ -447,6 +493,7 @@ class ChatClient:
447
493
  preserve_failed: bool = False,
448
494
  reasoning_effort: ReasoningEffort | None = None,
449
495
  agent_id: str | None = None,
496
+ user_context: dict[str, Any] | None = None,
450
497
  ):
451
498
  """Create a chat client bound to one workspace.
452
499
 
@@ -455,9 +502,17 @@ class ChatClient:
455
502
  entirely and the server keeps its own default. The server honours it only
456
503
  while the ``enableGenAiReasoningEffort`` feature flag is on for the
457
504
  organization, so setting it is a request rather than a guarantee.
505
+
506
+ ``user_context`` is sent as ``userContext`` on every message unless a call passes
507
+ its own. gen-ai scopes each message by the context it carries and treats a message
508
+ without one as cleared, so an agentic run's follow-up and clarification turns need
509
+ it as much as the first question does. A call cannot clear it, since ``None`` falls
510
+ back to it: a caller that changes or clears the context per message builds the
511
+ client without one. The server grounds answers in it only while
512
+ the ``enableAiContextSetup`` feature flag is on for the organization.
458
513
  """
459
514
  self._base = f"{host.rstrip('/')}/api/v1/ai/workspaces/{workspace_id}/chat/conversations"
460
- self._auth = {"Authorization": f"Bearer {token}"}
515
+ self._auth = {"Authorization": f"Bearer {token}", **_trace_baggage()}
461
516
  # 0/None disables the cap. Also lowered onto the read timeout: the wall-clock check
462
517
  # fires between events, so a turn that goes silent needs the transport to give up too.
463
518
  budget = _TURN_TIMEOUT_S if turn_timeout_s is None else turn_timeout_s
@@ -474,6 +529,7 @@ class ChatClient:
474
529
  self._preserve_failed = preserve_failed
475
530
  self._reasoning_effort = normalize_reasoning_effort(reasoning_effort)
476
531
  self._agent_id = agent_id
532
+ self._user_context = user_context
477
533
 
478
534
  def create_conversation(self) -> str:
479
535
  def _do() -> str:
@@ -507,10 +563,12 @@ class ChatClient:
507
563
  body: dict[str, Any] = {"item": {"role": "user", "content": {"type": "text", "text": question}}}
508
564
  if self._reasoning_effort is not None:
509
565
  body["options"] = {"reasoningEffort": self._reasoning_effort}
510
- # Only when there is one: gen-ai accepts an explicit null, so assigning
511
- # unconditionally would quietly change every request that has no attachment.
512
- if user_context is not None:
513
- body["userContext"] = user_context
566
+ # A per-call context overrides the client's. Only sent when there is one: gen-ai
567
+ # accepts an explicit null, so assigning unconditionally would quietly change every
568
+ # request that has no attachment.
569
+ context = user_context if user_context is not None else self._user_context
570
+ if context is not None:
571
+ body["userContext"] = context
514
572
 
515
573
  def _do() -> ChatResult:
516
574
  # Set fresh on every retry attempt (before opening this attempt's stream, so its
@@ -66,10 +66,19 @@ def _user_context_from_raw(raw: dict) -> dict[str, Any] | None:
66
66
  item input object or the item metadata. An item carrying an attachment (a WIDGET or
67
67
  VIEW descriptor) is meaningless without it: it degrades into a bare question the
68
68
  agent has no way to answer, and then fails for a reason that has nothing to do with
69
- what the item was written to test. So this has to survive the round trip.
69
+ what the item was written to test. So this has to survive the round trip, and a
70
+ value that is not an object raises rather than being skipped, for the same reason.
70
71
  """
71
- found = _first_of(dict, "user_context", raw.get("input"), raw.get("metadata"))
72
- return cast("dict[str, Any]", found) if found is not None else None
72
+ for source in (raw.get("input"), raw.get("metadata")):
73
+ if not isinstance(source, dict) or source.get("user_context") is None:
74
+ continue
75
+ found = source["user_context"]
76
+ if not isinstance(found, dict):
77
+ raise ValueError(
78
+ f"Langfuse item {raw.get('id')!r}: user_context must be a JSON object, got {type(found).__name__}"
79
+ )
80
+ return cast("dict[str, Any]", found)
81
+ return None
73
82
 
74
83
 
75
84
  def _infer_test_kind(expected_output: object, default: str, metadata: object = None) -> str:
@@ -1,8 +1,10 @@
1
1
  # (C) 2026 GoodData Corporation
2
2
  """Agentic visualization evaluator — ported from gdc-nas tavern-e2e app/vis_agentic.py."""
3
3
 
4
- from dataclasses import dataclass
4
+ import re
5
+ from dataclasses import dataclass, replace
5
6
  from datetime import date
7
+ from typing import Any
6
8
 
7
9
  from gooddata_eval.core.evaluators.base import ItemEvaluation
8
10
  from gooddata_eval.core.models import (
@@ -51,6 +53,14 @@ class EvaluationResult:
51
53
  actual_filters: dict[str, list[str]]
52
54
  expected_sorts: list[str]
53
55
  actual_sorts: list[str]
56
+ # Whether the agent ran the chart it built (see `execution_signals`). It gates
57
+ # `strict_pass` only on an item whose expected output sets `requires_execution`: a
58
+ # chart the user only looks at needs no run, a question that asks for a number does.
59
+ executed: bool = False
60
+ requires_execution: bool = False
61
+ # Whether a number in the reply matches a value the chart returned. None when the reply
62
+ # states no number. Reported only: reading numbers out of prose has false hits.
63
+ stated_value_matches: bool | None = None
54
64
 
55
65
  @property
56
66
  def strict_pass(self) -> bool:
@@ -62,6 +72,7 @@ class EvaluationResult:
62
72
  and self.filters_correct
63
73
  and self.sorts_correct
64
74
  and self.viz_type_hard
75
+ and (self.executed or not self.requires_execution)
65
76
  )
66
77
 
67
78
  @property
@@ -78,6 +89,128 @@ class EvaluationResult:
78
89
  )
79
90
 
80
91
 
92
+ # A number as prose writes it: a sign before or after an optional currency symbol, thousands
93
+ # separators, decimals, then an optional K/M/B scale or a percent sign. Not preceded or
94
+ # followed by a word character, so "Q3", "viz_1" and "2x" are not read as numbers.
95
+ _NUMBER_RE = re.compile(
96
+ r"(?<![\w.])(?P<sign>[-+]?)[$€£]?(?P<sign2>[-+]?)(?P<int>\d{1,3}(?:,\d{3})+|\d+)(?:\.(?P<frac>\d+))?"
97
+ r"\s?(?P<suffix>[kKmMbB%])?(?!\w)"
98
+ )
99
+ _SCALES = {"k": 1e3, "m": 1e6, "b": 1e9}
100
+
101
+
102
+ def _numbers_in(text: str) -> list[tuple[float, int, str]]:
103
+ """(value, decimals, suffix) for every number written in ``text``; suffix is lowercased."""
104
+ out: list[tuple[float, int, str]] = []
105
+ for m in _NUMBER_RE.finditer(text):
106
+ integer, fraction = m.group("int").replace(",", ""), m.group("frac") or ""
107
+ suffix = (m.group("suffix") or "").lower()
108
+ value = float(f"{integer}.{fraction}" if fraction else integer)
109
+ if "-" in (m.group("sign"), m.group("sign2")):
110
+ value = -value
111
+ out.append((value, len(fraction), suffix))
112
+ return out
113
+
114
+
115
+ def _stated_matches(stated: tuple[float, int, str], raw: list[float], formatted: list[tuple[float, str]]) -> bool:
116
+ """Whether one number from the reply is a rounding of a returned value."""
117
+ value, decimals, suffix = stated
118
+ half_step = 0.5 * 10**-decimals + 1e-9
119
+ # Same number and same scale: "1.2" does not quote a formatted "1.2M".
120
+ if any(abs(f - value) < 1e-9 and f_suffix == suffix for f, f_suffix in formatted):
121
+ return True
122
+ for v in raw:
123
+ # A percent states the fraction times 100: "10%" quotes 0.1, and "0.1%" does not.
124
+ if suffix == "%":
125
+ if abs(v * 100 - value) <= half_step:
126
+ return True
127
+ continue
128
+ scale = _SCALES.get(suffix, 1.0)
129
+ if abs(v - value * scale) <= half_step * scale:
130
+ return True
131
+ return False
132
+
133
+
134
+ def _row_values(data: Any) -> tuple[list[float], list[tuple[float, str]]]:
135
+ """Numeric cells of an execution result: the raw `rows`, and the (number, scale suffix)
136
+ pairs read out of `formatted_rows` -- the display strings the agent is told to quote.
137
+ A malformed result yields nothing rather than raising."""
138
+ raw: list[float] = []
139
+ formatted: list[tuple[float, str]] = []
140
+ if not isinstance(data, dict):
141
+ return raw, formatted
142
+ rows, formatted_rows = data.get("rows"), data.get("formatted_rows")
143
+ for row in rows if isinstance(rows, list) else []:
144
+ if isinstance(row, dict):
145
+ raw += [float(v) for v in row.values() if isinstance(v, (int, float)) and not isinstance(v, bool)]
146
+ for row in formatted_rows if isinstance(formatted_rows, list) else []:
147
+ if isinstance(row, dict):
148
+ for v in row.values():
149
+ if isinstance(v, str):
150
+ formatted += [(n[0], n[2]) for n in _numbers_in(v)]
151
+ return raw, formatted
152
+
153
+
154
+ def execution_signals(tool_call_events: list[ToolCallEvent], reply_text: str | None) -> tuple[bool, bool | None]:
155
+ """``(executed, stated_value_matches)`` for one conversation.
156
+
157
+ ``executed`` is True when ``execute_visualization`` succeeded for a ref that a successful
158
+ ``create_adhoc_visualization`` in the same conversation returned. The chart part of the
159
+ answer carries no ref, so this cannot tell which of several built charts was shown.
160
+
161
+ ``stated_value_matches`` is None when the reply states no number. Otherwise it is True
162
+ when one of those numbers is a rounding of a value an execution returned, and False when
163
+ none is -- including when nothing was executed, so the agent could not know the value.
164
+ """
165
+ created: set[str] = set()
166
+ for tc in tool_call_events:
167
+ if tc.function_name == "create_adhoc_visualization":
168
+ result = tc.parsed_result()
169
+ if not isinstance(result, dict):
170
+ continue
171
+ ref = result.get("ref") if result.get("status", "success") == "success" else None
172
+ if isinstance(ref, str):
173
+ created.add(ref)
174
+ raw: list[float] = []
175
+ formatted: list[tuple[float, str]] = []
176
+ executed = False
177
+ for tc in tool_call_events:
178
+ if tc.function_name != "execute_visualization":
179
+ continue
180
+ result = tc.parsed_result()
181
+ args = tc.parsed_arguments()
182
+ if not isinstance(result, dict) or not isinstance(args, dict):
183
+ continue
184
+ ref = args.get("visualization_ref")
185
+ if result.get("success") is True and isinstance(ref, str) and ref in created:
186
+ executed = True
187
+ r, f = _row_values(result.get("data"))
188
+ raw += r
189
+ formatted += f
190
+ stated = _numbers_in(reply_text or "")
191
+ if not stated:
192
+ return executed, None
193
+ return executed, any(_stated_matches(n, raw, formatted) for n in stated)
194
+
195
+
196
+ def with_execution(
197
+ ev: EvaluationResult,
198
+ tool_call_events: list[ToolCallEvent],
199
+ reply_text: str | None,
200
+ requires_execution: bool,
201
+ ) -> EvaluationResult:
202
+ """``ev`` with the execution signals of the conversation that produced it."""
203
+ executed, stated_value_matches = execution_signals(tool_call_events, reply_text)
204
+ return replace(
205
+ ev, executed=executed, requires_execution=requires_execution, stated_value_matches=stated_value_matches
206
+ )
207
+
208
+
209
+ def requires_execution_of(expected_output: object) -> bool:
210
+ """The item-level `requires_execution` flag; absent or not a boolean reads as False."""
211
+ return isinstance(expected_output, dict) and expected_output.get("requires_execution") is True
212
+
213
+
81
214
  def _check_visualization_skill_activated(tool_call_events: list[ToolCallEvent]) -> bool:
82
215
  """Return True if set_skills was called with 'visualization' in skill_names."""
83
216
  for tc in tool_call_events:
@@ -216,6 +349,14 @@ def evaluation_result_detail(ev: EvaluationResult) -> dict:
216
349
  "actual_filters": ev.actual_filters,
217
350
  "expected_sorts": ev.expected_sorts,
218
351
  "actual_sorts": ev.actual_sorts,
352
+ # Nested: `quality_score` counts every top-level boolean, and a check an item does not
353
+ # gate on must not lower it. `executed` joins the top level only when it gates.
354
+ "execution": {
355
+ "executed": ev.executed,
356
+ "required": ev.requires_execution,
357
+ "stated_value_matches": ev.stated_value_matches,
358
+ },
359
+ **({"executed": ev.executed} if ev.requires_execution else {}),
219
360
  }
220
361
 
221
362
 
@@ -227,6 +368,9 @@ class VisualizationEvaluator:
227
368
  actual = _extract_actual(chat_result)
228
369
  skill_activated = _check_visualization_skill_activated(chat_result.tool_call_events)
229
370
  ev, _best_expected = _evaluate_against_candidates(candidates, actual, skill_activated)
371
+ ev = with_execution(
372
+ ev, chat_result.tool_call_events, chat_result.text_response, requires_execution_of(item.expected_output)
373
+ )
230
374
  return ItemEvaluation(
231
375
  passed=ev.strict_pass,
232
376
  rank_key=(ev.strict_pass, ev.strict_checks_passed_count),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gooddata-eval
3
- Version: 1.76.1.dev1
3
+ Version: 1.76.1.dev3
4
4
  Summary: Evaluate the GoodData AI agent against your own questions and models.
5
5
  Project-URL: Source, https://github.com/gooddata/gooddata-python-sdk
6
6
  Author-email: GoodData <support@gooddata.com>
@@ -17,7 +17,7 @@ Classifier: Topic :: Scientific/Engineering
17
17
  Classifier: Topic :: Software Development
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
- Requires-Dist: gooddata-sdk~=1.76.1.dev1
20
+ Requires-Dist: gooddata-sdk~=1.76.1.dev3
21
21
  Requires-Dist: httpx<1.0,>=0.27
22
22
  Requires-Dist: jsonpatch<2.0,>=1.33
23
23
  Requires-Dist: orjson<4.0.0,>=3.9.15
@@ -603,6 +603,55 @@ The `expected_output` rubric:
603
603
  Each criterion is scored independently by the LLM judge, so `quality_score`
604
604
  is the fraction of satisfied criteria.
605
605
 
606
+ ### Items asked in a dashboard context (`user_context`)
607
+
608
+ An item can be asked the way a user asks from an open dashboard or an attached widget.
609
+ Its `user_context` is sent verbatim as `userContext` on every chat message of the item,
610
+ follow-up and clarification messages included — the server treats a message without one
611
+ as a cleared context. Requires the `enableAiContextSetup` feature flag on the target
612
+ organization — without it the server ignores the dashboard view. Applies to chat items
613
+ only; `dashboard_summary` items carry their scope in `summary_input` instead. No
614
+ `userContext` key is sent at all when neither the item nor, for `agentic_conversation`,
615
+ the current turn sets one.
616
+
617
+ ```json
618
+ {
619
+ "id": "ctx-001",
620
+ "dataset_name": "my_dataset_ctx",
621
+ "test_kind": "general_question",
622
+ "question": "Which dashboard am I looking at, and what does it cover?",
623
+ "user_context": {
624
+ "view": {
625
+ "dashboard": {
626
+ "id": "sales_overview",
627
+ "title": "Sales Overview",
628
+ "widgets": [
629
+ {"widgetType": "insight", "widgetId": "w1", "title": "Revenue by Month", "visualizationId": "revenue_by_month"}
630
+ ]
631
+ }
632
+ }
633
+ },
634
+ "expected_output": "Names the Sales Overview dashboard and summarizes its charts."
635
+ }
636
+ ```
637
+
638
+ The schema belongs to the AI chat API (`UserContext`: `view.dashboard`,
639
+ `referencedObjects`, `activeObject`), so it is not validated here beyond being an object.
640
+ In a Langfuse dataset, put it in the item `metadata` (or in the `input` object) under
641
+ `user_context`. An object in `input` wins and `metadata` is then not read; a missing or
642
+ `null` value in `input` falls back to `metadata`. The value used must be an object or `null`;
643
+ anything else fails the dataset load.
644
+
645
+ `agentic_conversation` items take the item's `user_context` as the context of the first
646
+ turn. A turn in `expected_output.turns` can set its own `user_context`, which applies
647
+ from that turn on until another turn changes it, as the attached context does in the UI:
648
+
649
+ | Turn | Context sent |
650
+ |---|---|
651
+ | no `user_context` key | the current one, unchanged |
652
+ | `"user_context": {...}` | this one, from this turn on |
653
+ | `"user_context": null` | none, from this turn on |
654
+
606
655
  ## Supported test kinds
607
656
 
608
657
  | test_kind | What the agent must produce | Extra required |
@@ -1,7 +1,7 @@
1
1
  gooddata_eval/__init__.py,sha256=9Nz0iJXzkJWiL40PijM6AMpAqQOH-dbYNfDtDysBPYc,186
2
2
  gooddata_eval/_version.py,sha256=L4nT6RJQ2vc_F9stvO59xZv92xsRUqbpUh1sGlb-UNU,195
3
3
  gooddata_eval/cli/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
4
- gooddata_eval/cli/agentic_runner.py,sha256=9_yvTGEoYgbsZfwn9pnVY9Hv2qEBEdYhUrTLiMbv7FA,23638
4
+ gooddata_eval/cli/agentic_runner.py,sha256=hAihzRS0LCTkRRggyboOp7dXd5HoehoFU8LpBU6PJ1c,24258
5
5
  gooddata_eval/cli/main.py,sha256=ry-XbCcNEb6Obm5hfiRo6etGY9aQvFT9UbAt_pPmbA0,32919
6
6
  gooddata_eval/core/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
7
7
  gooddata_eval/core/_output.py,sha256=sFbNtKg1lx431jpb15jISLlOXGTZ-r_KodfaTVyka6I,580
@@ -19,24 +19,24 @@ gooddata_eval/core/agentic/_conversation_context.py,sha256=WzF_HJzf3fvDJ7EfvekBu
19
19
  gooddata_eval/core/agentic/_gate.py,sha256=irI4eWOCfmsbKd6l-HdpoAlnWkq2wjo4b5crGU_Er1o,2631
20
20
  gooddata_eval/core/agentic/_langfuse.py,sha256=7V1PFQbWpRnFz0a3wklcI-ROYjGC1wGQv2QCfQqpdws,22604
21
21
  gooddata_eval/core/agentic/_trace_linker.py,sha256=iXMTxIXUsF5WNSen7v_K8JJ2j1_37lh-2VAGqQxxmkI,13538
22
- gooddata_eval/core/agentic/alert_skill.py,sha256=CSwTR9OdlI6rUp7J4Wo0rDq0fK8mY57hJ3w5Q2Wpeds,45287
23
- gooddata_eval/core/agentic/anomaly_detection.py,sha256=tV7TB4RPELSMzpWaiW87JEkYndTO8aeAe5qcQzkpCmk,26538
24
- gooddata_eval/core/agentic/conversation.py,sha256=qR278ztVm2lzA9ntjDiIzNiELBYi5uCsWRh1-ifxKNs,57625
25
- gooddata_eval/core/agentic/dashboard_skill.py,sha256=4lu7v6VeIR_h9FUFBrpD57upA0Rd2Kox1ZP_X-7jQaQ,51854
26
- gooddata_eval/core/agentic/general_question.py,sha256=Jp3FaS4Tvmfo0dx9iceiZ3Fr1QzJKDl7fVZZDetS22I,14684
27
- gooddata_eval/core/agentic/guardrail.py,sha256=rYpnsun9mLOSYucWfg1dbdrpbzQcz2V3eLwgII8dD0w,13145
28
- gooddata_eval/core/agentic/kda_skill.py,sha256=-XP6DeWz8XFzAxcvWyMFWy5aVM2_ZhCliF73VXV-EbU,24256
29
- gooddata_eval/core/agentic/metric_skill.py,sha256=778uBZvJ8bFTAO1N4nOj7rKoln0JhzTh_rX5bZD89mw,25779
30
- gooddata_eval/core/agentic/report_skill.py,sha256=fwxhoOaDrMc9CjbO5ANpgLSXqYUcUPUFTMxkVcOVfhk,34254
31
- gooddata_eval/core/agentic/search_tool.py,sha256=qZDYpYFLf4-TbpEgVCgAYEUruR9_FG1AYLTKwS8Klbk,11038
32
- gooddata_eval/core/agentic/visualization.py,sha256=s81CgeVKAm2FUa9Fsqk9x90k4kMjny-y9dEQab245S0,24331
33
- gooddata_eval/core/agentic/what_if.py,sha256=V_4D29pIdFO2wBUgtF0-85oiK6gBWgpN3BfPXBLW24g,24526
22
+ gooddata_eval/core/agentic/alert_skill.py,sha256=KLHazw98foUgCgeeIKH5Q_C5hXIr3j-GqbGZF1UZCEM,45466
23
+ gooddata_eval/core/agentic/anomaly_detection.py,sha256=ckt4Y3kOUmW1EArfJPbUtvSNUz_RmzAIXo-iIK4wvmc,26717
24
+ gooddata_eval/core/agentic/conversation.py,sha256=rssoR0DlFvW6MMeddfP4teL5zh3UpfZw4fFGbk889jw,59199
25
+ gooddata_eval/core/agentic/dashboard_skill.py,sha256=4ITF44SYzonRhKjGYmcsa-3Bf-YjNohzgw7NWxhIjWI,52033
26
+ gooddata_eval/core/agentic/general_question.py,sha256=742pP3ILGjrcmHWj2mS5vmoop_IxyHuYidqggy_iBU0,14591
27
+ gooddata_eval/core/agentic/guardrail.py,sha256=IfCzsRslwAfsEWnhVwwXBMezQPGhN6YCpu8wZ2ofAGo,13324
28
+ gooddata_eval/core/agentic/kda_skill.py,sha256=ZP6_Xikk0LPwW5yCYvaePqHAy4lpnb3DxwslUo83LhM,24435
29
+ gooddata_eval/core/agentic/metric_skill.py,sha256=PHT09IKpEYQX2d_CkJYv_ZuMywOcrijfMXXPlLfUev8,28249
30
+ gooddata_eval/core/agentic/report_skill.py,sha256=TLoGYmRDkHy2jXcmMbIDwXlohWTXbhN9nuL5uRRJBZE,34433
31
+ gooddata_eval/core/agentic/search_tool.py,sha256=k1rdY7lX0bQr68PlO6-B1RcGbWwtUwygZ4nrGmgkclA,11217
32
+ gooddata_eval/core/agentic/visualization.py,sha256=4g1nu-F7H54nttIdpmlBfipr6FS8m-J4jtilktGtYhc,26137
33
+ gooddata_eval/core/agentic/what_if.py,sha256=-1eJAvYiZdraTjb1m03JlMtJwQmyel52v2vGQEVd2Ts,24705
34
34
  gooddata_eval/core/chat/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
35
35
  gooddata_eval/core/chat/render.py,sha256=CqsKN8__DlaiJj_bmcPZ1poc5LfUqtz7EzKRDbks4gU,2082
36
- gooddata_eval/core/chat/sse_client.py,sha256=gj0GeVeVNMbqZh-CexhWTXwoyRxiFWs9wp8Xr_0QNMU,26337
36
+ gooddata_eval/core/chat/sse_client.py,sha256=jvNAu1EXlc1ViqHJ1NC0C99A05An8QS7MMPj63_Wnw4,29420
37
37
  gooddata_eval/core/dataset/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
38
38
  gooddata_eval/core/dataset/from_insights.py,sha256=QxVprksX-l1Z4yLiOQOK8ZaUK5z64SCjdQym3cip99s,40520
39
- gooddata_eval/core/dataset/langfuse_source.py,sha256=s4HvKYP2lOPLp2yFtM5zjIU0Sec2aDwwYcwZH5CXijg,6910
39
+ gooddata_eval/core/dataset/langfuse_source.py,sha256=6-nwFHbzVLEESkxEZoYPgDWRcPoXkohaYcdVKpxIRAY,7306
40
40
  gooddata_eval/core/dataset/local.py,sha256=qoFFtRx_bNwlT1U37o8CKkSPwCad3JpmHPcNRuGSbjE,1233
41
41
  gooddata_eval/core/evaluators/__init__.py,sha256=luJlcrwMVuyyr2I8ohfNPnlRHRa_cl-Kb19jRe7eEyQ,3130
42
42
  gooddata_eval/core/evaluators/_deep_subset.py,sha256=TonPUfkUZL4q1a9t3cczDC2vSF54ynEDDQRxP6p-fvc,1407
@@ -51,7 +51,7 @@ gooddata_eval/core/evaluators/guardrail.py,sha256=J3sX0v_ImbV-GC3c_vMtQrn9Sn0sy-
51
51
  gooddata_eval/core/evaluators/metric_skill.py,sha256=ckQvo-TX_bwGWcTkxaHMztMcg7lYBegH5uFOK4wfNSo,3005
52
52
  gooddata_eval/core/evaluators/search_tool.py,sha256=c6iK_tHMaDaUjYuo5EnAYDGe53gTDTQtUl226hZq0JY,3070
53
53
  gooddata_eval/core/evaluators/summary.py,sha256=aBEW62nCbjKjdkHzLJnqhrPFz0DkJIlJDSYCd8iP7nA,7021
54
- gooddata_eval/core/evaluators/visualization.py,sha256=EEWEEpLl3lKlFTyyL4UAtkAwjgDx1vg_ZMNO3ByCvzs,9381
54
+ gooddata_eval/core/evaluators/visualization.py,sha256=lFHbReZh1DnS0Bgx6iTgRbeN7YZWoKxGvrkCgAelgEw,16194
55
55
  gooddata_eval/core/langfuse/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
56
56
  gooddata_eval/core/langfuse/_env.py,sha256=UMXQblsVleWE7KbKu_mqRXdctss7Hh2eQVAmyFT2yIA,1322
57
57
  gooddata_eval/core/langfuse/client.py,sha256=xyZWGRwzloFQVWPcKU_PAC74DU0hCCHKj-J0nybl5xI,7802
@@ -66,8 +66,8 @@ gooddata_eval/core/reporting/json_report.py,sha256=q3Jirc_Zhvzg5lkG_kM6JdnViNLe6
66
66
  gooddata_eval/core/reporting/report_template.html,sha256=vLkl95OZsm3dMdijv5zda-15wYPE5UvTs4uKeWnjCiI,22332
67
67
  gooddata_eval/core/summary/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
68
68
  gooddata_eval/core/summary/http_client.py,sha256=dPArJ6zoCu1W9xmYXFA1WMDNsSOkhn3gaCpYgCUp3gk,2174
69
- gooddata_eval-1.76.1.dev1.dist-info/METADATA,sha256=lO3G-2EYRBjp4OWTb0xBHHfJ5QI1t9mv5TBGihlNi9U,35069
70
- gooddata_eval-1.76.1.dev1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
71
- gooddata_eval-1.76.1.dev1.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
72
- gooddata_eval-1.76.1.dev1.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
73
- gooddata_eval-1.76.1.dev1.dist-info/RECORD,,
69
+ gooddata_eval-1.76.1.dev3.dist-info/METADATA,sha256=SwIoGDdCBtQkcQvZXliFCJ7eVqeMmTKVQjwyad8jDPE,37256
70
+ gooddata_eval-1.76.1.dev3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
71
+ gooddata_eval-1.76.1.dev3.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
72
+ gooddata_eval-1.76.1.dev3.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
73
+ gooddata_eval-1.76.1.dev3.dist-info/RECORD,,