gooddata-eval 1.69.1.dev1__tar.gz → 1.69.1.dev2__tar.gz

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.
Files changed (86) hide show
  1. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/PKG-INFO +2 -2
  2. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/pyproject.toml +2 -2
  3. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/_langfuse.py +20 -3
  4. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/alert_skill.py +13 -5
  5. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/conversation.py +16 -0
  6. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/general_question.py +2 -0
  7. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/guardrail.py +2 -0
  8. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/metric_skill.py +84 -27
  9. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/search_tool.py +2 -0
  10. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/visualization.py +2 -0
  11. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_agentic_conversation.py +122 -0
  12. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_agentic_metric_skill.py +80 -0
  13. gooddata_eval-1.69.1.dev2/tests/test_agentic_run_context.py +32 -0
  14. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/.gitignore +0 -0
  15. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/LICENSE.txt +0 -0
  16. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/Makefile +0 -0
  17. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/README.md +0 -0
  18. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/__init__.py +0 -0
  19. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/_version.py +0 -0
  20. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/cli/__init__.py +0 -0
  21. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/cli/agentic_runner.py +0 -0
  22. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/cli/main.py +0 -0
  23. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/__init__.py +0 -0
  24. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/__init__.py +0 -0
  25. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
  26. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/chat/__init__.py +0 -0
  27. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/chat/sse_client.py +0 -0
  28. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/config.py +0 -0
  29. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/connection.py +0 -0
  30. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/dataset/__init__.py +0 -0
  31. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/dataset/langfuse_source.py +0 -0
  32. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/dataset/local.py +0 -0
  33. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/__init__.py +0 -0
  34. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
  35. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
  36. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
  37. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/alert_skill.py +0 -0
  38. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/base.py +0 -0
  39. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
  40. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
  41. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
  42. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/search_tool.py +0 -0
  43. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/summary.py +0 -0
  44. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
  45. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
  46. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/langfuse/sink.py +0 -0
  47. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/models.py +0 -0
  48. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/reporting/__init__.py +0 -0
  49. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/reporting/console.py +0 -0
  50. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/reporting/json_report.py +0 -0
  51. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/runner.py +0 -0
  52. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/scoring.py +0 -0
  53. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/summary/__init__.py +0 -0
  54. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/summary/http_client.py +0 -0
  55. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/src/gooddata_eval/core/workspace.py +0 -0
  56. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/__init__.py +0 -0
  57. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/conftest.py +0 -0
  58. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
  59. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
  60. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/fixtures/sse_visualization_stream.txt +0 -0
  61. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_agentic_alert_skill.py +0 -0
  62. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_agentic_general_question.py +0 -0
  63. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_agentic_guardrail.py +0 -0
  64. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_agentic_search_tool.py +0 -0
  65. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_agentic_visualization.py +0 -0
  66. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_alert_skill_evaluator.py +0 -0
  67. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_cli.py +0 -0
  68. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_connection.py +0 -0
  69. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_deep_subset.py +0 -0
  70. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_langfuse_sink.py +0 -0
  71. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_langfuse_source.py +0 -0
  72. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_llm_judge.py +0 -0
  73. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_local_loader.py +0 -0
  74. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_metric_skill_evaluator.py +0 -0
  75. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_models.py +0 -0
  76. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_reporting.py +0 -0
  77. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_runner.py +0 -0
  78. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_scoring.py +0 -0
  79. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_search_tool_evaluator.py +0 -0
  80. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_sse_client.py +0 -0
  81. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_summary_client.py +0 -0
  82. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_summary_evaluator.py +0 -0
  83. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_text_evaluators.py +0 -0
  84. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_visualization_evaluator.py +0 -0
  85. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tests/test_workspace.py +0 -0
  86. {gooddata_eval-1.69.1.dev1 → gooddata_eval-1.69.1.dev2}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gooddata-eval
3
- Version: 1.69.1.dev1
3
+ Version: 1.69.1.dev2
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.69.1.dev1
20
+ Requires-Dist: gooddata-sdk~=1.69.1.dev2
21
21
  Requires-Dist: httpx<1.0,>=0.27
22
22
  Requires-Dist: orjson<4.0.0,>=3.9.15
23
23
  Requires-Dist: pydantic<3.0,>=2.6
@@ -1,7 +1,7 @@
1
1
  # (C) 2026 GoodData Corporation
2
2
  [project]
3
3
  name = "gooddata-eval"
4
- version = "1.69.1.dev1"
4
+ version = "1.69.1.dev2"
5
5
  description = "Evaluate the GoodData AI agent against your own questions and models."
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -11,7 +11,7 @@ authors = [
11
11
  keywords = ["gooddata", "ai", "evaluation", "llm", "analytics", "cli"]
12
12
  requires-python = ">=3.10"
13
13
  dependencies = [
14
- "gooddata-sdk~=1.69.1.dev1",
14
+ "gooddata-sdk~=1.69.1.dev2",
15
15
  "httpx>=0.27,<1.0",
16
16
  "orjson>=3.9.15,<4.0.0",
17
17
  "pydantic>=2.6,<3.0",
@@ -305,7 +305,7 @@ def observe(
305
305
  run_name=run_name,
306
306
  dataset_item_id=dataset_item_id,
307
307
  trace_id=trace_id,
308
- metadata=run_metadata or {"testing_framework": "tavern-e2e"},
308
+ metadata=run_metadata or {},
309
309
  run_description="",
310
310
  )
311
311
  _log.debug(
@@ -383,14 +383,31 @@ def build_run_context(
383
383
  dataset_name: str,
384
384
  run_timestamp: str | None,
385
385
  model_version_override: str | None,
386
+ run_metadata_extra: dict[str, Any] | None = None,
386
387
  ) -> tuple[str, dict[str, Any]]:
387
- """Return (run_name_base, run_metadata) with model version resolved from workspace API."""
388
+ """Return (run_name_base, run_metadata) with model version resolved from workspace API.
389
+
390
+ Args:
391
+ host: GoodData host URL.
392
+ token: API token used to resolve the workspace model version.
393
+ workspace_id: Workspace whose active LLM provider is read when no override is given.
394
+ dataset_name: Langfuse dataset name; used as the run-name prefix.
395
+ run_timestamp: Shared run timestamp for the run name (falls back to the current time).
396
+ model_version_override: Explicit model-version tag; when set it wins over the
397
+ workspace lookup.
398
+ run_metadata_extra: Optional extra key/values added to the dataset-run metadata
399
+ (e.g. a testing-framework tag or a CI run id for scoping). Default None keeps
400
+ behavior unchanged. The SDK-derived model_version is applied last and cannot
401
+ be overwritten by this dict.
402
+ """
388
403
  model = get_model_version(host, token, workspace_id, model_version_override)
389
404
  ts = run_timestamp or datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
390
405
  base = f"{dataset_name}_{ts}"
391
406
  if model:
392
407
  base = f"{base}_{model}"
393
- metadata: dict[str, Any] = {"testing_framework": "tavern-e2e"}
408
+ # Caller supplies its own run tags (e.g. testing_framework); model_version is applied
409
+ # last so the SDK-derived value cannot be overwritten by run_metadata_extra.
410
+ metadata: dict[str, Any] = dict(run_metadata_extra) if run_metadata_extra else {}
394
411
  if model:
395
412
  metadata["model_version"] = model
396
413
  return base, metadata
@@ -9,6 +9,8 @@ import re
9
9
  from dataclasses import dataclass
10
10
  from typing import Any
11
11
 
12
+ from gooddata_sdk import GoodDataSdk
13
+
12
14
  from gooddata_eval.core.agentic._catalog import CatalogMetricAlert
13
15
  from gooddata_eval.core.chat.sse_client import ChatClient
14
16
  from gooddata_eval.core.models import ToolCallEvent
@@ -183,11 +185,14 @@ def generate_simulated_alert_response(
183
185
  return response.choices[0].message.content or ""
184
186
 
185
187
 
186
- def _delete_alert(client: ChatClient, workspace_id: str, alert_id: str) -> None:
187
- host = str(client._base).split("/api/")[0]
188
- url = f"{host}/api/v1/entities/workspaces/{workspace_id}/automations/{alert_id}"
188
+ def _delete_alert(sdk: GoodDataSdk, workspace_id: str, alert_id: str) -> None:
189
+ """Best-effort delete of an alert (automation) created during evaluation.
190
+
191
+ Uses the GoodData SDK entities API rather than reimplementing the REST call.
192
+ Failures are logged, not raised.
193
+ """
189
194
  try:
190
- client._client.delete(url, headers=client._auth)
195
+ sdk._client.entities_api.delete_entity_automations(workspace_id, alert_id)
191
196
  except Exception as exc:
192
197
  print(f"[CLEANUP] Failed to delete alert {alert_id}: {exc}")
193
198
 
@@ -327,6 +332,7 @@ def run_agentic_alert_skill(
327
332
  expected = _normalize_expected_output(expected_output)
328
333
  run_results: list[AlertRunResult] = []
329
334
  client = ChatClient(host=host, token=token, workspace_id=workspace_id)
335
+ sdk = GoodDataSdk.create(host, token)
330
336
 
331
337
  def _run_once(conv_id: str) -> AlertRunResult:
332
338
  alert_id_to_delete: str | None = None
@@ -375,7 +381,7 @@ def run_agentic_alert_skill(
375
381
  )
376
382
  finally:
377
383
  if alert_id_to_delete:
378
- _delete_alert(client, workspace_id, alert_id_to_delete)
384
+ _delete_alert(sdk, workspace_id, alert_id_to_delete)
379
385
 
380
386
  try:
381
387
  conv_id_0 = initial_conversation_id if initial_conversation_id is not None else client.create_conversation()
@@ -438,6 +444,7 @@ def evaluate_agentic_alert_skill(
438
444
  dataset_name: str = "alert_skill",
439
445
  run_timestamp: str | None = None,
440
446
  model_version_override: str | None = None,
447
+ run_metadata_extra: dict | None = None,
441
448
  ) -> None:
442
449
  """Run alert-skill evaluation, log to Langfuse, and raise AlertSkillAssertionError on failure."""
443
450
  from datetime import datetime as _dt # noqa: PLC0415
@@ -475,6 +482,7 @@ def evaluate_agentic_alert_skill(
475
482
  dataset_name,
476
483
  run_timestamp,
477
484
  model_version_override,
485
+ run_metadata_extra,
478
486
  )
479
487
  traces_by_conv = find_traces_per_conversation(
480
488
  langfuse,
@@ -8,8 +8,10 @@ import re
8
8
  from dataclasses import dataclass
9
9
  from typing import Literal
10
10
 
11
+ from gooddata_sdk import GoodDataSdk
11
12
  from pydantic import BaseModel
12
13
 
14
+ from gooddata_eval.core.agentic.metric_skill import _delete_metric, _extract_created_metric_ids
13
15
  from gooddata_eval.core.chat.sse_client import ChatClient
14
16
  from gooddata_eval.core.models import ChatResult, ToolCallEvent
15
17
  from gooddata_eval.core.scoring import (
@@ -283,11 +285,16 @@ def run_agentic_conversation(
283
285
  replies before the agent produces the expected output.
284
286
  """
285
287
  client = ChatClient(host=host, token=token, workspace_id=workspace_id)
288
+ sdk = GoodDataSdk.create(host, token)
286
289
  turn_results: list[TurnResult] = []
287
290
  turn_outputs: dict[str, dict] = {}
288
291
  total_clarification_turns = 0
289
292
  conversation_id: str = ""
290
293
  owns_conversation = False
294
+ # Metrics created during this conversation, deleted after it completes so they do
295
+ # not persist in the (shared) workspace and get reused by a later test. Deferred to
296
+ # the end — a later turn may $ref a metric an earlier turn created.
297
+ created_metric_ids: list[str] = []
291
298
 
292
299
  try:
293
300
  if initial_conversation_id is not None:
@@ -335,6 +342,11 @@ def run_agentic_conversation(
335
342
  if metric_data:
336
343
  turn_outputs[turn.turn_id] = metric_data
337
344
 
345
+ # Track every metric created this turn (any turn may create one) for cleanup.
346
+ for metric_id in _extract_created_metric_ids(all_tool_calls):
347
+ if metric_id not in created_metric_ids:
348
+ created_metric_ids.append(metric_id)
349
+
338
350
  turn_results.append(
339
351
  TurnResult(
340
352
  turn_id=turn.turn_id,
@@ -351,6 +363,8 @@ def run_agentic_conversation(
351
363
  finally:
352
364
  if owns_conversation and conversation_id:
353
365
  client.delete_conversation(conversation_id)
366
+ for metric_id in created_metric_ids:
367
+ _delete_metric(sdk, workspace_id, metric_id)
354
368
  client.close()
355
369
 
356
370
  activated_all = {skill for tr in turn_results for skill in tr.activated_skills}
@@ -384,6 +398,7 @@ def evaluate_agentic_conversation(
384
398
  dataset_name: str = "conversation",
385
399
  run_timestamp: str | None = None,
386
400
  model_version_override: str | None = None,
401
+ run_metadata_extra: dict | None = None,
387
402
  ) -> None:
388
403
  """Run conversation evaluation, log to Langfuse, and raise on failure."""
389
404
  from datetime import datetime as _dt # noqa: PLC0415
@@ -419,6 +434,7 @@ def evaluate_agentic_conversation(
419
434
  dataset_name or fixture.dataset_name,
420
435
  run_timestamp,
421
436
  model_version_override,
437
+ run_metadata_extra,
422
438
  )
423
439
  traces_by_conv = find_traces_per_conversation(
424
440
  langfuse,
@@ -152,6 +152,7 @@ def evaluate_agentic_general_question(
152
152
  dataset_name: str = "general_question",
153
153
  run_timestamp: str | None = None,
154
154
  model_version_override: str | None = None,
155
+ run_metadata_extra: dict | None = None,
155
156
  ) -> None:
156
157
  """Run general-question evaluation, log to Langfuse, and raise on failure."""
157
158
  from datetime import datetime as _dt # noqa: PLC0415
@@ -188,6 +189,7 @@ def evaluate_agentic_general_question(
188
189
  dataset_name,
189
190
  run_timestamp,
190
191
  model_version_override,
192
+ run_metadata_extra,
191
193
  )
192
194
  traces_by_conv = find_traces_per_conversation(
193
195
  langfuse,
@@ -149,6 +149,7 @@ def evaluate_agentic_guardrail(
149
149
  dataset_name: str = "guardrail",
150
150
  run_timestamp: str | None = None,
151
151
  model_version_override: str | None = None,
152
+ run_metadata_extra: dict | None = None,
152
153
  ) -> None:
153
154
  """Run guardrail evaluation, log to Langfuse, and raise on failure."""
154
155
  from datetime import datetime as _dt # noqa: PLC0415
@@ -185,6 +186,7 @@ def evaluate_agentic_guardrail(
185
186
  dataset_name,
186
187
  run_timestamp,
187
188
  model_version_override,
189
+ run_metadata_extra,
188
190
  )
189
191
  traces_by_conv = find_traces_per_conversation(
190
192
  langfuse,
@@ -8,6 +8,8 @@ import re
8
8
  from dataclasses import dataclass
9
9
  from typing import Any
10
10
 
11
+ from gooddata_sdk import GoodDataSdk
12
+
11
13
  from gooddata_eval.core.chat.sse_client import ChatClient
12
14
  from gooddata_eval.core.models import ToolCallEvent
13
15
 
@@ -127,6 +129,41 @@ def _extract_metric_result(tool_call_events: list[ToolCallEvent]) -> dict | None
127
129
  return None
128
130
 
129
131
 
132
+ def _extract_created_metric_ids(tool_call_events: list[ToolCallEvent]) -> list[str]:
133
+ """Ids of every metric created by ``create_metric`` calls (a turn may create more than one).
134
+
135
+ Used for cleanup so no created metric leaks — unlike ``_extract_metric_result``, which
136
+ returns only the first result for MAQL evaluation. Shared with conversation evaluation.
137
+ """
138
+ metric_ids: list[str] = []
139
+ for tc in tool_call_events:
140
+ if tc.function_name != "create_metric" or not tc.result:
141
+ continue
142
+ result_data = tc.parsed_result()
143
+ if not result_data:
144
+ continue
145
+ data = result_data.get("data", result_data)
146
+ metric_id = data.get("metric_id") if isinstance(data, dict) else None
147
+ if metric_id and metric_id not in metric_ids:
148
+ metric_ids.append(metric_id)
149
+ return metric_ids
150
+
151
+
152
+ def _delete_metric(sdk: GoodDataSdk, workspace_id: str, metric_id: str) -> None:
153
+ """Delete a metric created during evaluation.
154
+
155
+ Eval runs share a persistent workspace, so a metric left behind is picked up by
156
+ a later test — the agent reuses it (returning ``SELECT {id}`` instead of full
157
+ MAQL) and the assertion fails. Deleting the created metric on the way out keeps
158
+ the workspace clean for the next run. Best-effort: failures are logged, not raised.
159
+ Mirrors ``alert_skill._delete_alert``.
160
+ """
161
+ try:
162
+ sdk._client.entities_api.delete_entity_metrics(workspace_id, metric_id)
163
+ except Exception as exc:
164
+ print(f"[CLEANUP] Failed to delete metric {metric_id}: {exc}")
165
+
166
+
130
167
  def _is_asking_clarification(text: str) -> bool:
131
168
  if not text:
132
169
  return False
@@ -136,41 +173,54 @@ def _is_asking_clarification(text: str) -> bool:
136
173
 
137
174
  def _execute_single_metric_run(
138
175
  client: ChatClient,
176
+ sdk: GoodDataSdk,
177
+ workspace_id: str,
139
178
  conversation_id: str,
140
179
  question: str,
141
180
  expected_outputs: list[dict],
142
181
  max_iterations: int,
143
182
  ) -> MetricRunResult:
144
- """Drive one full multi-turn metric-skill conversation and evaluate the result."""
183
+ """Drive one full multi-turn metric-skill conversation and evaluate the result.
184
+
185
+ Any metric the agent creates during this run is deleted on the way out (see
186
+ ``_delete_metric``) so it cannot leak into — and be reused by — a later test
187
+ sharing the workspace.
188
+ """
145
189
  primary_expected = expected_outputs[0] if expected_outputs else {}
146
190
  metric_result: dict | None = None
191
+ metric_id_to_delete: str | None = None
147
192
  turns = 0
148
193
  current_question = question
149
194
 
150
- for _iteration in range(max_iterations):
151
- turns += 1
152
- chat_result = client.send_message(conversation_id, current_question)
153
- candidate = _extract_metric_result(chat_result.tool_call_events or [])
154
- if candidate is not None:
155
- metric_result = candidate
156
- break
157
- response_text = (chat_result.text_response or "").strip()
158
- if _is_asking_clarification(response_text):
159
- current_question = generate_simulated_response(response_text, primary_expected)
160
- else:
161
- break
162
-
163
- actual_maql = (metric_result or {}).get("maql", "")
164
- metric_created = metric_result is not None
165
- maql_correct, _ = _best_maql_match(actual_maql, expected_outputs) if metric_created else (False, "")
166
- return MetricRunResult(
167
- conversation_id=conversation_id,
168
- metric_result=metric_result,
169
- metric_created=metric_created,
170
- actual_maql=actual_maql,
171
- maql_correct=maql_correct,
172
- total_turns=float(turns),
173
- )
195
+ try:
196
+ for _iteration in range(max_iterations):
197
+ turns += 1
198
+ chat_result = client.send_message(conversation_id, current_question)
199
+ candidate = _extract_metric_result(chat_result.tool_call_events or [])
200
+ if candidate is not None:
201
+ metric_result = candidate
202
+ metric_id_to_delete = candidate.get("metric_id")
203
+ break
204
+ response_text = (chat_result.text_response or "").strip()
205
+ if _is_asking_clarification(response_text):
206
+ current_question = generate_simulated_response(response_text, primary_expected)
207
+ else:
208
+ break
209
+
210
+ actual_maql = (metric_result or {}).get("maql", "")
211
+ metric_created = metric_result is not None
212
+ maql_correct, _ = _best_maql_match(actual_maql, expected_outputs) if metric_created else (False, "")
213
+ return MetricRunResult(
214
+ conversation_id=conversation_id,
215
+ metric_result=metric_result,
216
+ metric_created=metric_created,
217
+ actual_maql=actual_maql,
218
+ maql_correct=maql_correct,
219
+ total_turns=float(turns),
220
+ )
221
+ finally:
222
+ if metric_id_to_delete:
223
+ _delete_metric(sdk, workspace_id, metric_id_to_delete)
174
224
 
175
225
 
176
226
  def run_agentic_metric_skill(
@@ -191,12 +241,15 @@ def run_agentic_metric_skill(
191
241
  expected_outputs: list[dict] = expected_output if isinstance(expected_output, list) else [expected_output]
192
242
  run_results: list[MetricRunResult] = []
193
243
  client = ChatClient(host=host, token=token, workspace_id=workspace_id)
244
+ sdk = GoodDataSdk.create(host, token)
194
245
 
195
246
  try:
196
247
  conv_id_0 = initial_conversation_id if initial_conversation_id is not None else client.create_conversation()
197
248
  try:
198
249
  run_results.append(
199
- _execute_single_metric_run(client, conv_id_0, question, expected_outputs, max_iterations)
250
+ _execute_single_metric_run(
251
+ client, sdk, workspace_id, conv_id_0, question, expected_outputs, max_iterations
252
+ )
200
253
  )
201
254
  finally:
202
255
  if initial_conversation_id is None: # only delete conversations we created
@@ -206,7 +259,9 @@ def run_agentic_metric_skill(
206
259
  conv_id = client.create_conversation()
207
260
  try:
208
261
  run_results.append(
209
- _execute_single_metric_run(client, conv_id, question, expected_outputs, max_iterations)
262
+ _execute_single_metric_run(
263
+ client, sdk, workspace_id, conv_id, question, expected_outputs, max_iterations
264
+ )
210
265
  )
211
266
  finally:
212
267
  client.delete_conversation(conv_id)
@@ -244,6 +299,7 @@ def evaluate_agentic_metric_skill(
244
299
  dataset_name: str = "metric_skill",
245
300
  run_timestamp: str | None = None,
246
301
  model_version_override: str | None = None,
302
+ run_metadata_extra: dict | None = None,
247
303
  ) -> None:
248
304
  """Run metric-skill evaluation, log to Langfuse, and raise MetricSkillAssertionError on failure."""
249
305
  from datetime import datetime as _dt # noqa: PLC0415
@@ -281,6 +337,7 @@ def evaluate_agentic_metric_skill(
281
337
  dataset_name,
282
338
  run_timestamp,
283
339
  model_version_override,
340
+ run_metadata_extra,
284
341
  )
285
342
  traces_by_conv = find_traces_per_conversation(
286
343
  langfuse,
@@ -143,6 +143,7 @@ def evaluate_agentic_search_tool(
143
143
  dataset_name: str = "search",
144
144
  run_timestamp: str | None = None,
145
145
  model_version_override: str | None = None,
146
+ run_metadata_extra: dict | None = None,
146
147
  ) -> None:
147
148
  """Run search-tool evaluation, log to Langfuse, and raise SearchToolAssertionError on failure."""
148
149
  from datetime import datetime as _dt # noqa: PLC0415
@@ -179,6 +180,7 @@ def evaluate_agentic_search_tool(
179
180
  dataset_name,
180
181
  run_timestamp,
181
182
  model_version_override,
183
+ run_metadata_extra,
182
184
  )
183
185
  traces_by_conv = find_traces_per_conversation(
184
186
  langfuse,
@@ -263,6 +263,7 @@ def evaluate_agentic_visualization(
263
263
  dataset_name: str = "visualization",
264
264
  run_timestamp: str | None = None,
265
265
  model_version_override: str | None = None,
266
+ run_metadata_extra: dict | None = None,
266
267
  record_output_path: str | None = None,
267
268
  ) -> None:
268
269
  """Run visualization evaluation, log to Langfuse, and raise VisualizationAssertionError on failure."""
@@ -302,6 +303,7 @@ def evaluate_agentic_visualization(
302
303
  dataset_name,
303
304
  run_timestamp,
304
305
  model_version_override,
306
+ run_metadata_extra,
305
307
  )
306
308
  K = len(summary.run_results)
307
309
  traces_by_conv = find_traces_per_conversation(
@@ -2,6 +2,7 @@
2
2
  # SPDX-License-Identifier: LicenseRef-GoodData-Enterprise
3
3
  from unittest.mock import MagicMock, patch
4
4
 
5
+ import pytest
5
6
  from gooddata_eval.core.agentic.conversation import (
6
7
  ConversationFixture,
7
8
  TurnDefinition,
@@ -12,6 +13,29 @@ from gooddata_eval.core.agentic.conversation import (
12
13
  from gooddata_eval.core.models import ToolCallEvent
13
14
 
14
15
 
16
+ def _skills_tc(*skills):
17
+ tc = MagicMock(spec=ToolCallEvent)
18
+ tc.function_name = "set_skills"
19
+ tc.parsed_arguments = lambda: {"skills": list(skills)}
20
+ return tc
21
+
22
+
23
+ def _create_metric_tc(metric_id):
24
+ tc = MagicMock(spec=ToolCallEvent)
25
+ tc.function_name = "create_metric"
26
+ tc.result = "{}" # truthy so cleanup collection processes it; content comes from parsed_result
27
+ tc.parsed_result = lambda mid=metric_id: {"data": {"metric_id": mid, "maql": "SELECT 1"}}
28
+ return tc
29
+
30
+
31
+ def _metric_turn_result(tool_calls):
32
+ r = MagicMock()
33
+ r.text_response = "done"
34
+ r.created_visualizations = None
35
+ r.tool_call_events = tool_calls
36
+ return r
37
+
38
+
15
39
  def test_turn_definition_model():
16
40
  t = TurnDefinition(
17
41
  turn_id="t1",
@@ -169,3 +193,101 @@ def test_run_agentic_conversation_creates_and_deletes_conversation():
169
193
  assert result.conversation_id == "new-conv"
170
194
  mock_client.create_conversation.assert_called_once()
171
195
  mock_client.delete_conversation.assert_called_once_with("new-conv")
196
+
197
+
198
+ def test_run_agentic_conversation_deletes_created_metrics():
199
+ mock_client = MagicMock()
200
+ mock_client.create_conversation.return_value = "conv-1"
201
+ mock_client.send_message.return_value = _metric_turn_result([_skills_tc("metric"), _create_metric_tc("foo_metric")])
202
+
203
+ fixture = ConversationFixture(
204
+ id="test-metric",
205
+ expected_skills=["metric"],
206
+ turns=[
207
+ TurnDefinition(
208
+ turn_id="t1",
209
+ message="Create a metric counting x",
210
+ expected_skill="metric",
211
+ expected_output_type="metric",
212
+ )
213
+ ],
214
+ )
215
+ with (
216
+ patch("gooddata_eval.core.agentic.conversation.ChatClient", return_value=mock_client),
217
+ patch("gooddata_eval.core.agentic.conversation.GoodDataSdk") as mock_sdk_cls,
218
+ ):
219
+ mock_sdk = mock_sdk_cls.create.return_value
220
+ run_agentic_conversation(
221
+ host="http://host/api/v1/actions/workspaces/ws1/ai",
222
+ token="tok",
223
+ workspace_id="ws1",
224
+ fixture=fixture,
225
+ )
226
+ # The metric created during the conversation is deleted after it completes, via the SDK.
227
+ mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric")
228
+
229
+
230
+ def _two_metric_turn_fixture():
231
+ return ConversationFixture(
232
+ id="test-multi",
233
+ expected_skills=["metric"],
234
+ turns=[
235
+ TurnDefinition(
236
+ turn_id="t1", message="Create shared", expected_skill="metric", expected_output_type="metric"
237
+ ),
238
+ TurnDefinition(
239
+ turn_id="t2", message="Create extra", expected_skill="metric", expected_output_type="metric"
240
+ ),
241
+ ],
242
+ )
243
+
244
+
245
+ def test_run_agentic_conversation_deletes_every_unique_metric_across_turns():
246
+ mock_client = MagicMock()
247
+ mock_client.create_conversation.return_value = "conv-1"
248
+ # Turn 1 creates "shared"; turn 2 re-creates "shared" (duplicate) and adds "extra".
249
+ mock_client.send_message.side_effect = [
250
+ _metric_turn_result([_skills_tc("metric"), _create_metric_tc("shared")]),
251
+ _metric_turn_result([_skills_tc("metric"), _create_metric_tc("shared"), _create_metric_tc("extra")]),
252
+ ]
253
+
254
+ with (
255
+ patch("gooddata_eval.core.agentic.conversation.ChatClient", return_value=mock_client),
256
+ patch("gooddata_eval.core.agentic.conversation.GoodDataSdk") as mock_sdk_cls,
257
+ ):
258
+ mock_sdk = mock_sdk_cls.create.return_value
259
+ run_agentic_conversation(
260
+ host="http://host/api/v1/actions/workspaces/ws1/ai",
261
+ token="tok",
262
+ workspace_id="ws1",
263
+ fixture=_two_metric_turn_fixture(),
264
+ )
265
+
266
+ # Metrics from all turns are cleaned up, and each unique id is deleted exactly once.
267
+ deleted = sorted(c.args for c in mock_sdk._client.entities_api.delete_entity_metrics.call_args_list)
268
+ assert deleted == [("ws1", "extra"), ("ws1", "shared")]
269
+
270
+
271
+ def test_run_agentic_conversation_deletes_metrics_even_when_a_later_turn_raises():
272
+ mock_client = MagicMock()
273
+ mock_client.create_conversation.return_value = "conv-1"
274
+ # Turn 1 creates "m1"; turn 2 blows up mid-run — the finally must still clean up "m1".
275
+ mock_client.send_message.side_effect = [
276
+ _metric_turn_result([_skills_tc("metric"), _create_metric_tc("m1")]),
277
+ RuntimeError("boom"),
278
+ ]
279
+
280
+ with (
281
+ patch("gooddata_eval.core.agentic.conversation.ChatClient", return_value=mock_client),
282
+ patch("gooddata_eval.core.agentic.conversation.GoodDataSdk") as mock_sdk_cls,
283
+ pytest.raises(RuntimeError),
284
+ ):
285
+ mock_sdk = mock_sdk_cls.create.return_value
286
+ run_agentic_conversation(
287
+ host="http://host/api/v1/actions/workspaces/ws1/ai",
288
+ token="tok",
289
+ workspace_id="ws1",
290
+ fixture=_two_metric_turn_fixture(),
291
+ )
292
+
293
+ mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "m1")
@@ -2,9 +2,11 @@
2
2
  # SPDX-License-Identifier: LicenseRef-GoodData-Enterprise
3
3
  from unittest.mock import MagicMock, patch
4
4
 
5
+ import pytest
5
6
  from gooddata_eval.core.agentic.metric_skill import (
6
7
  AgenticMetricSummary,
7
8
  MetricRunResult,
9
+ _delete_metric,
8
10
  _normalize_maql,
9
11
  run_agentic_metric_skill,
10
12
  )
@@ -144,3 +146,81 @@ def test_run_agentic_metric_skill_creates_fresh_conversations_for_remaining_runs
144
146
  # Runs 1 and 2 always create fresh; run 0 uses existing-conv
145
147
  assert mock_client.create_conversation.call_count == 2
146
148
  assert mock_client.delete_conversation.call_count == 2
149
+
150
+
151
+ def test_delete_metric_uses_sdk_entities_api():
152
+ sdk = MagicMock()
153
+ _delete_metric(sdk, "ws1", "foo_metric")
154
+ sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric")
155
+
156
+
157
+ def test_delete_metric_swallows_failures():
158
+ sdk = MagicMock()
159
+ sdk._client.entities_api.delete_entity_metrics.side_effect = RuntimeError("500")
160
+ # Cleanup is best-effort — a failed delete is logged, never propagated.
161
+ _delete_metric(sdk, "ws1", "foo_metric")
162
+
163
+
164
+ def _create_metric_chat_result(metric_id: str = "foo_metric"):
165
+ return ChatResult.model_validate(
166
+ {
167
+ "textResponse": "done",
168
+ "toolCallEvents": [
169
+ {
170
+ "functionName": "create_metric",
171
+ "functionArguments": "{}",
172
+ "result": f'{{"data": {{"maql": "SELECT {{metric/foo}}", "metric_id": "{metric_id}"}}}}',
173
+ }
174
+ ],
175
+ "reasoningStepCount": 1,
176
+ }
177
+ )
178
+
179
+
180
+ def test_run_agentic_metric_skill_deletes_created_metric():
181
+ mock_client = MagicMock()
182
+ mock_client.create_conversation.return_value = "conv-1"
183
+ mock_client.send_message.return_value = _create_metric_chat_result()
184
+ with (
185
+ patch("gooddata_eval.core.agentic.metric_skill.ChatClient", return_value=mock_client),
186
+ patch("gooddata_eval.core.agentic.metric_skill.GoodDataSdk") as mock_sdk_cls,
187
+ ):
188
+ mock_sdk = mock_sdk_cls.create.return_value
189
+ run_agentic_metric_skill(
190
+ host="http://host/api/v1/actions/workspaces/ws1/ai",
191
+ token="tok",
192
+ workspace_id="ws1",
193
+ question="Create metric foo",
194
+ expected_output={"maql": "SELECT {metric/foo}"},
195
+ k=1,
196
+ max_iterations=1,
197
+ )
198
+ # The metric the run created is deleted on the way out, by its exact id, via the SDK.
199
+ mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric")
200
+
201
+
202
+ def test_run_agentic_metric_skill_deletes_metric_even_when_teardown_fails():
203
+ # A metric is created, then conversation teardown raises; the created metric must still
204
+ # have been cleaned up (its deletion happens inside the per-run finally, before teardown).
205
+ mock_client = MagicMock()
206
+ mock_client.create_conversation.return_value = "conv-1"
207
+ mock_client.send_message.return_value = _create_metric_chat_result()
208
+ mock_client.delete_conversation.side_effect = RuntimeError("teardown boom")
209
+
210
+ with (
211
+ patch("gooddata_eval.core.agentic.metric_skill.ChatClient", return_value=mock_client),
212
+ patch("gooddata_eval.core.agentic.metric_skill.GoodDataSdk") as mock_sdk_cls,
213
+ pytest.raises(RuntimeError),
214
+ ):
215
+ mock_sdk = mock_sdk_cls.create.return_value
216
+ run_agentic_metric_skill(
217
+ host="http://host/api/v1/actions/workspaces/ws1/ai",
218
+ token="tok",
219
+ workspace_id="ws1",
220
+ question="Create metric foo",
221
+ expected_output={"maql": "SELECT {metric/foo}"},
222
+ k=1,
223
+ max_iterations=1,
224
+ )
225
+
226
+ mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric")
@@ -0,0 +1,32 @@
1
+ # (C) 2026 GoodData Corporation
2
+ from gooddata_eval.core.agentic._langfuse import build_run_context
3
+
4
+ # model_version_override short-circuits get_model_version (no workspace API call).
5
+ _COMMON = {
6
+ "host": "h",
7
+ "token": "t",
8
+ "workspace_id": "w",
9
+ "dataset_name": "ds",
10
+ "run_timestamp": "2026-07-10_00-00-00",
11
+ "model_version_override": "m",
12
+ }
13
+
14
+
15
+ def test_build_run_context_includes_caller_extra_keys():
16
+ base, metadata = build_run_context(
17
+ **_COMMON, run_metadata_extra={"testing_framework": "tavern-e2e", "github_run_id": "run-123"}
18
+ )
19
+ assert base == "ds_2026-07-10_00-00-00_m"
20
+ assert metadata["testing_framework"] == "tavern-e2e"
21
+ assert metadata["github_run_id"] == "run-123"
22
+ assert metadata["model_version"] == "m"
23
+
24
+
25
+ def test_build_run_context_extra_cannot_override_model_version():
26
+ _, metadata = build_run_context(**_COMMON, run_metadata_extra={"model_version": "hack"})
27
+ assert metadata["model_version"] == "m" # SDK-derived value wins
28
+
29
+
30
+ def test_build_run_context_without_extra_has_only_model_version():
31
+ _, metadata = build_run_context(**_COMMON)
32
+ assert metadata == {"model_version": "m"}