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.
- gooddata_eval/cli/agentic_runner.py +13 -0
- gooddata_eval/core/agentic/alert_skill.py +9 -1
- gooddata_eval/core/agentic/anomaly_detection.py +9 -1
- gooddata_eval/core/agentic/conversation.py +31 -2
- gooddata_eval/core/agentic/dashboard_skill.py +9 -1
- gooddata_eval/core/agentic/general_question.py +9 -9
- gooddata_eval/core/agentic/guardrail.py +9 -1
- gooddata_eval/core/agentic/kda_skill.py +9 -1
- gooddata_eval/core/agentic/metric_skill.py +55 -1
- gooddata_eval/core/agentic/report_skill.py +9 -1
- gooddata_eval/core/agentic/search_tool.py +9 -1
- gooddata_eval/core/agentic/visualization.py +37 -3
- gooddata_eval/core/agentic/what_if.py +9 -1
- gooddata_eval/core/chat/sse_client.py +68 -10
- gooddata_eval/core/dataset/langfuse_source.py +12 -3
- gooddata_eval/core/evaluators/visualization.py +145 -1
- {gooddata_eval-1.76.1.dev1.dist-info → gooddata_eval-1.76.1.dev3.dist-info}/METADATA +51 -2
- {gooddata_eval-1.76.1.dev1.dist-info → gooddata_eval-1.76.1.dev3.dist-info}/RECORD +21 -21
- {gooddata_eval-1.76.1.dev1.dist-info → gooddata_eval-1.76.1.dev3.dist-info}/WHEEL +0 -0
- {gooddata_eval-1.76.1.dev1.dist-info → gooddata_eval-1.76.1.dev3.dist-info}/entry_points.txt +0 -0
- {gooddata_eval-1.76.1.dev1.dist-info → gooddata_eval-1.76.1.dev3.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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` --
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
|
|
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
|
|
511
|
-
# unconditionally would quietly change every
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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=
|
|
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=
|
|
23
|
-
gooddata_eval/core/agentic/anomaly_detection.py,sha256=
|
|
24
|
-
gooddata_eval/core/agentic/conversation.py,sha256=
|
|
25
|
-
gooddata_eval/core/agentic/dashboard_skill.py,sha256=
|
|
26
|
-
gooddata_eval/core/agentic/general_question.py,sha256=
|
|
27
|
-
gooddata_eval/core/agentic/guardrail.py,sha256=
|
|
28
|
-
gooddata_eval/core/agentic/kda_skill.py,sha256
|
|
29
|
-
gooddata_eval/core/agentic/metric_skill.py,sha256=
|
|
30
|
-
gooddata_eval/core/agentic/report_skill.py,sha256=
|
|
31
|
-
gooddata_eval/core/agentic/search_tool.py,sha256=
|
|
32
|
-
gooddata_eval/core/agentic/visualization.py,sha256=
|
|
33
|
-
gooddata_eval/core/agentic/what_if.py,sha256
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
70
|
-
gooddata_eval-1.76.1.
|
|
71
|
-
gooddata_eval-1.76.1.
|
|
72
|
-
gooddata_eval-1.76.1.
|
|
73
|
-
gooddata_eval-1.76.1.
|
|
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,,
|
|
File without changes
|
{gooddata_eval-1.76.1.dev1.dist-info → gooddata_eval-1.76.1.dev3.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{gooddata_eval-1.76.1.dev1.dist-info → gooddata_eval-1.76.1.dev3.dist-info}/licenses/LICENSE.txt
RENAMED
|
File without changes
|