gooddata-eval 1.68.1.dev4__tar.gz → 1.69.1.dev1__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.68.1.dev4 → gooddata_eval-1.69.1.dev1}/PKG-INFO +2 -2
  2. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/pyproject.toml +3 -2
  3. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/cli/agentic_runner.py +9 -12
  4. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/cli/main.py +30 -6
  5. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/alert_skill.py +9 -5
  6. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/conversation.py +3 -1
  7. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/general_question.py +3 -1
  8. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/guardrail.py +3 -1
  9. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/metric_skill.py +4 -3
  10. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/search_tool.py +3 -1
  11. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/visualization.py +3 -1
  12. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/chat/sse_client.py +32 -5
  13. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/config.py +1 -0
  14. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/alert_skill.py +3 -1
  15. gooddata_eval-1.69.1.dev1/src/gooddata_eval/core/evaluators/search_tool.py +59 -0
  16. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/models.py +2 -0
  17. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/reporting/json_report.py +2 -0
  18. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/runner.py +7 -1
  19. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_alert_skill.py +20 -2
  20. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_conversation.py +1 -2
  21. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_general_question.py +0 -2
  22. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_guardrail.py +0 -2
  23. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_search_tool.py +0 -2
  24. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_visualization.py +5 -8
  25. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_alert_skill_evaluator.py +17 -0
  26. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_cli.py +103 -3
  27. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_langfuse_source.py +2 -1
  28. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_runner.py +25 -0
  29. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_sse_client.py +118 -0
  30. gooddata_eval-1.69.1.dev1/tox.ini +16 -0
  31. gooddata_eval-1.68.1.dev4/src/gooddata_eval/core/evaluators/search_tool.py +0 -40
  32. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/.gitignore +0 -0
  33. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/LICENSE.txt +0 -0
  34. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/Makefile +0 -0
  35. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/README.md +0 -0
  36. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/__init__.py +0 -0
  37. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/_version.py +0 -0
  38. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/cli/__init__.py +0 -0
  39. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/__init__.py +0 -0
  40. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/__init__.py +0 -0
  41. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
  42. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/_langfuse.py +0 -0
  43. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/chat/__init__.py +0 -0
  44. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/connection.py +0 -0
  45. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/dataset/__init__.py +0 -0
  46. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/dataset/langfuse_source.py +0 -0
  47. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/dataset/local.py +0 -0
  48. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/__init__.py +0 -0
  49. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
  50. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
  51. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
  52. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/base.py +0 -0
  53. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
  54. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
  55. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
  56. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/summary.py +0 -0
  57. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
  58. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
  59. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/langfuse/sink.py +0 -0
  60. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/reporting/__init__.py +0 -0
  61. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/reporting/console.py +0 -0
  62. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/scoring.py +0 -0
  63. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/summary/__init__.py +0 -0
  64. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/summary/http_client.py +0 -0
  65. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/workspace.py +0 -0
  66. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/__init__.py +0 -0
  67. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/conftest.py +0 -0
  68. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
  69. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
  70. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/fixtures/sse_visualization_stream.txt +0 -0
  71. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_metric_skill.py +0 -0
  72. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_connection.py +0 -0
  73. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_deep_subset.py +0 -0
  74. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_langfuse_sink.py +0 -0
  75. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_llm_judge.py +0 -0
  76. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_local_loader.py +0 -0
  77. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_metric_skill_evaluator.py +0 -0
  78. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_models.py +0 -0
  79. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_reporting.py +0 -0
  80. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_scoring.py +0 -0
  81. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_search_tool_evaluator.py +0 -0
  82. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_summary_client.py +0 -0
  83. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_summary_evaluator.py +0 -0
  84. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_text_evaluators.py +0 -0
  85. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_visualization_evaluator.py +0 -0
  86. {gooddata_eval-1.68.1.dev4 → gooddata_eval-1.69.1.dev1}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gooddata-eval
3
- Version: 1.68.1.dev4
3
+ Version: 1.69.1.dev1
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.68.1.dev4
20
+ Requires-Dist: gooddata-sdk~=1.69.1.dev1
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.68.1.dev4"
4
+ version = "1.69.1.dev1"
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.68.1.dev4",
14
+ "gooddata-sdk~=1.69.1.dev1",
15
15
  "httpx>=0.27,<1.0",
16
16
  "orjson>=3.9.15,<4.0.0",
17
17
  "pydantic>=2.6,<3.0",
@@ -45,6 +45,7 @@ dev = [
45
45
  test = [
46
46
  "pytest~=8.3.4",
47
47
  "pytest-cov~=6.0.0",
48
+ "pytest-json-report==1.5.0",
48
49
  "pytest-mock>=3.14.0",
49
50
  ]
50
51
 
@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
  import time
7
7
  from typing import Any, TypedDict
8
8
 
9
- from gooddata_eval.core.agentic._langfuse import HttpxLangfuseClient, make_langfuse_client
9
+ from gooddata_eval.core.agentic._langfuse import make_langfuse_client
10
10
  from gooddata_eval.core.agentic.alert_skill import evaluate_agentic_alert_skill
11
11
  from gooddata_eval.core.agentic.conversation import ConversationFixture, evaluate_agentic_conversation
12
12
  from gooddata_eval.core.agentic.general_question import evaluate_agentic_general_question
@@ -17,17 +17,14 @@ from gooddata_eval.core.agentic.visualization import evaluate_agentic_visualizat
17
17
  from gooddata_eval.core.models import CreatedVisualization, DatasetItem
18
18
  from gooddata_eval.core.runner import EvalReport, ItemReport
19
19
 
20
- _LfKw = TypedDict(
21
- "_LfKw",
22
- {
23
- "langfuse": Any,
24
- "dataset_item_id": str,
25
- "dataset_name": str,
26
- "run_timestamp": str,
27
- "model_version_override": str | None,
28
- },
29
- total=False,
30
- )
20
+
21
+ class _LfKw(TypedDict, total=False):
22
+ langfuse: Any
23
+ dataset_item_id: str
24
+ dataset_name: str
25
+ run_timestamp: str
26
+ model_version_override: str | None
27
+
31
28
 
32
29
  AGENTIC_TEST_KINDS = frozenset(
33
30
  {
@@ -3,6 +3,7 @@
3
3
 
4
4
  import argparse
5
5
  import sys
6
+ import threading
6
7
  from datetime import datetime, timezone
7
8
  from pathlib import Path
8
9
 
@@ -11,6 +12,7 @@ from gooddata_api_client.exceptions import ApiException
11
12
  from rich.console import Console
12
13
  from rich.table import Table
13
14
 
15
+ from gooddata_eval.cli.agentic_runner import AGENTIC_TEST_KINDS, run_agentic_items
14
16
  from gooddata_eval.core.chat.sse_client import ChatClient
15
17
  from gooddata_eval.core.config import RunConfig
16
18
  from gooddata_eval.core.connection import ConnectionError_, resolve_connection
@@ -19,7 +21,6 @@ from gooddata_eval.core.langfuse.sink import LangfuseSink
19
21
  from gooddata_eval.core.models import ChatResult, DatasetItem
20
22
  from gooddata_eval.core.reporting.console import render_comparison, render_console
21
23
  from gooddata_eval.core.reporting.json_report import write_multi_model_report
22
- from gooddata_eval.cli.agentic_runner import AGENTIC_TEST_KINDS, run_agentic_items
23
24
  from gooddata_eval.core.runner import ItemReport, run_items
24
25
  from gooddata_eval.core.summary.http_client import SummaryClient
25
26
  from gooddata_eval.core.workspace import ModelResolutionError, WorkspaceModelController
@@ -97,6 +98,12 @@ def _build_parser() -> argparse.ArgumentParser:
97
98
  )
98
99
  run.add_argument("--json", dest="json_path", help="Write a JSON report to this path.")
99
100
  run.add_argument("--quiet", action="store_true", help="Suppress per-item progress output.")
101
+ run.add_argument(
102
+ "--preserve-failed",
103
+ action="store_true",
104
+ dest="preserve_failed",
105
+ help="Keep failed conversations on the server for post-mortem inspection.",
106
+ )
100
107
  run.add_argument(
101
108
  "--langfuse",
102
109
  action="store_true",
@@ -139,14 +146,24 @@ def _parse_model_arg(val: str) -> tuple[str | None, str]:
139
146
 
140
147
 
141
148
  def _make_progress_callbacks(console: Console):
142
- """Build (on_item_start, on_run_done, on_item_done) callbacks that stream progress."""
149
+ """Build (on_item_start, on_run_done, on_item_done) callbacks that stream progress.
150
+
151
+ A threading lock guards all console.print() calls so that concurrent
152
+ ``--concurrency 2+`` workers do not deadlock when stdout is piped
153
+ (e.g. running in a background process).
154
+ """
155
+ _print_lock = threading.Lock()
143
156
 
144
157
  def on_item_start(index: int, total: int, item: DatasetItem) -> None:
145
- console.print(f"[dim]\\[{index}/{total}][/dim] [cyan]{item.id}[/cyan] {_truncate(item.question)}")
158
+ with _print_lock:
159
+ console.print(f"[dim]\\[{index}/{total}][/dim] [cyan]{item.id}[/cyan] {_truncate(item.question)}")
146
160
 
147
161
  def on_run_done(index: int, total: int, run_index: int, runs: int, passed: bool, latency: float) -> None:
148
162
  tag = "[green]pass[/green]" if passed else "[red]fail[/red]"
149
- console.print(f"[dim]\\[{index}/{total}][/dim] run {run_index}/{runs} {tag} [dim]{latency:.2f}s[/dim]")
163
+ with _print_lock:
164
+ console.print(
165
+ f"[dim]\\[{index}/{total}][/dim] run {run_index}/{runs} {tag} [dim]{latency:.2f}s[/dim]"
166
+ )
150
167
 
151
168
  def on_item_done(index: int, total: int, report: ItemReport) -> None:
152
169
  if report.skipped:
@@ -165,7 +182,8 @@ def _make_progress_callbacks(console: Console):
165
182
  f" [dim]({report.latency_s:.2f}s total, {report.avg_latency_s:.2f}s avg, "
166
183
  f"quality={quality_str}, {report.runs} run(s))[/dim]"
167
184
  )
168
- console.print(f"[dim]\\[{index}/{total}][/dim] -> {tag} [cyan]{report.id}[/cyan]{suffix}")
185
+ with _print_lock:
186
+ console.print(f"[dim]\\[{index}/{total}][/dim] -> {tag} [cyan]{report.id}[/cyan]{suffix}")
169
187
 
170
188
  return on_item_start, on_run_done, on_item_done
171
189
 
@@ -319,7 +337,12 @@ def _run(config: RunConfig) -> int:
319
337
 
320
338
  # --- non-agentic items (single-turn, use Evaluator) ---
321
339
  backend = _RoutingBackend(
322
- ChatClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
340
+ ChatClient(
341
+ host=config.host,
342
+ token=config.token,
343
+ workspace_id=config.workspace_id,
344
+ preserve_failed=config.preserve_failed,
345
+ ),
323
346
  SummaryClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
324
347
  )
325
348
  try:
@@ -409,6 +432,7 @@ def main(argv: list[str] | None = None) -> int:
409
432
  log_to_langfuse=args.langfuse,
410
433
  quiet=args.quiet,
411
434
  kind=args.kind,
435
+ preserve_failed=args.preserve_failed,
412
436
  )
413
437
  return _run(config)
414
438
  except (
@@ -7,12 +7,10 @@ import json
7
7
  import os
8
8
  import re
9
9
  from dataclasses import dataclass
10
-
11
10
  from typing import Any
12
11
 
13
- from gooddata_eval.core.chat.sse_client import ChatClient
14
12
  from gooddata_eval.core.agentic._catalog import CatalogMetricAlert
15
-
13
+ from gooddata_eval.core.chat.sse_client import ChatClient
16
14
  from gooddata_eval.core.models import ToolCallEvent
17
15
 
18
16
  try:
@@ -80,7 +78,11 @@ def _check_trigger(expected: CatalogMetricAlert, actual_args: dict) -> bool:
80
78
  if expected.operator == "ANOMALY":
81
79
  return True
82
80
  exp_trigger = expected.trigger
83
- act_trigger = actual_args.get("trigger", actual_args.get("triggerMode", "ALWAYS"))
81
+ # A missing OR explicit-null trigger means "unset" -> the product persists the
82
+ # default ALWAYS ("Every time"). `.get(k, default)` only returns the default when
83
+ # the key is ABSENT, but create_metric_alert serialises unset params as
84
+ # `trigger: null`, so chain with `or` to also cover the present-but-None case.
85
+ act_trigger = actual_args.get("trigger") or actual_args.get("triggerMode") or "ALWAYS"
84
86
  if exp_trigger in _ALWAYS_TRIGGER_VALUES:
85
87
  return act_trigger in {"ALWAYS", "Every time"}
86
88
  act_api = _TRIGGER_DISPLAY_TO_API.get(act_trigger, act_trigger)
@@ -438,7 +440,9 @@ def evaluate_agentic_alert_skill(
438
440
  model_version_override: str | None = None,
439
441
  ) -> None:
440
442
  """Run alert-skill evaluation, log to Langfuse, and raise AlertSkillAssertionError on failure."""
441
- from datetime import datetime as _dt, timezone as _tz # noqa: PLC0415
443
+ from datetime import datetime as _dt # noqa: PLC0415
444
+ from datetime import timezone as _tz # noqa: PLC0415
445
+
442
446
  from gooddata_eval.core.agentic._langfuse import try_make_langfuse_client # noqa: PLC0415
443
447
 
444
448
  if langfuse is None:
@@ -386,7 +386,9 @@ def evaluate_agentic_conversation(
386
386
  model_version_override: str | None = None,
387
387
  ) -> None:
388
388
  """Run conversation evaluation, log to Langfuse, and raise on failure."""
389
- from datetime import datetime as _dt, timezone as _tz # noqa: PLC0415
389
+ from datetime import datetime as _dt # noqa: PLC0415
390
+ from datetime import timezone as _tz # noqa: PLC0415
391
+
390
392
  from gooddata_eval.core.agentic._langfuse import try_make_langfuse_client # noqa: PLC0415
391
393
 
392
394
  if langfuse is None:
@@ -154,7 +154,9 @@ def evaluate_agentic_general_question(
154
154
  model_version_override: str | None = None,
155
155
  ) -> None:
156
156
  """Run general-question evaluation, log to Langfuse, and raise on failure."""
157
- from datetime import datetime as _dt, timezone as _tz # noqa: PLC0415
157
+ from datetime import datetime as _dt # noqa: PLC0415
158
+ from datetime import timezone as _tz # noqa: PLC0415
159
+
158
160
  from gooddata_eval.core.agentic._langfuse import try_make_langfuse_client # noqa: PLC0415
159
161
 
160
162
  if langfuse is None:
@@ -151,7 +151,9 @@ def evaluate_agentic_guardrail(
151
151
  model_version_override: str | None = None,
152
152
  ) -> None:
153
153
  """Run guardrail evaluation, log to Langfuse, and raise on failure."""
154
- from datetime import datetime as _dt, timezone as _tz # noqa: PLC0415
154
+ from datetime import datetime as _dt # noqa: PLC0415
155
+ from datetime import timezone as _tz # noqa: PLC0415
156
+
155
157
  from gooddata_eval.core.agentic._langfuse import try_make_langfuse_client # noqa: PLC0415
156
158
 
157
159
  if langfuse is None:
@@ -3,11 +3,10 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- from typing import Any
7
-
8
6
  import os
9
7
  import re
10
8
  from dataclasses import dataclass
9
+ from typing import Any
11
10
 
12
11
  from gooddata_eval.core.chat.sse_client import ChatClient
13
12
  from gooddata_eval.core.models import ToolCallEvent
@@ -247,7 +246,9 @@ def evaluate_agentic_metric_skill(
247
246
  model_version_override: str | None = None,
248
247
  ) -> None:
249
248
  """Run metric-skill evaluation, log to Langfuse, and raise MetricSkillAssertionError on failure."""
250
- from datetime import datetime as _dt, timezone as _tz # noqa: PLC0415
249
+ from datetime import datetime as _dt # noqa: PLC0415
250
+ from datetime import timezone as _tz # noqa: PLC0415
251
+
251
252
  from gooddata_eval.core.agentic._langfuse import try_make_langfuse_client # noqa: PLC0415
252
253
 
253
254
  if langfuse is None:
@@ -145,7 +145,9 @@ def evaluate_agentic_search_tool(
145
145
  model_version_override: str | None = None,
146
146
  ) -> None:
147
147
  """Run search-tool evaluation, log to Langfuse, and raise SearchToolAssertionError on failure."""
148
- from datetime import datetime as _dt, timezone as _tz # noqa: PLC0415
148
+ from datetime import datetime as _dt # noqa: PLC0415
149
+ from datetime import timezone as _tz # noqa: PLC0415
150
+
149
151
  from gooddata_eval.core.agentic._langfuse import try_make_langfuse_client # noqa: PLC0415
150
152
 
151
153
  if langfuse is None:
@@ -267,7 +267,9 @@ def evaluate_agentic_visualization(
267
267
  ) -> None:
268
268
  """Run visualization evaluation, log to Langfuse, and raise VisualizationAssertionError on failure."""
269
269
  import json as _json # noqa: PLC0415
270
- from datetime import datetime as _dt, timezone as _tz # noqa: PLC0415
270
+ from datetime import datetime as _dt # noqa: PLC0415
271
+ from datetime import timezone as _tz # noqa: PLC0415
272
+
271
273
  from gooddata_eval.core.agentic._langfuse import try_make_langfuse_client # noqa: PLC0415
272
274
 
273
275
  if langfuse is None:
@@ -106,6 +106,7 @@ class _SseAccumulator:
106
106
  call_id_to_event_index: dict[str, int] = field(default_factory=dict)
107
107
  reasoning_steps: list[dict[str, Any]] = field(default_factory=list)
108
108
  adhoc_viz_args: list[dict[str, Any]] = field(default_factory=list)
109
+ response_id: str | None = None
109
110
 
110
111
 
111
112
  def _handle_text(content: dict[str, Any], acc: _SseAccumulator) -> None:
@@ -176,7 +177,9 @@ def _build_chat_result(acc: _SseAccumulator) -> ChatResult:
176
177
  "objects": [acc.adhoc_viz_args[-1]],
177
178
  "reasoning": "\n".join(acc.viz_reasoning_parts),
178
179
  }
179
- return ChatResult.model_validate(payload)
180
+ result = ChatResult.model_validate(payload)
181
+ result.response_id = acc.response_id
182
+ return result
180
183
 
181
184
 
182
185
  def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
@@ -204,9 +207,13 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
204
207
  if code in _RETRYABLE_STATUS_CODES:
205
208
  raise TransientChatError(message, status_code=code, detail=detail)
206
209
  raise ChatError(message, status_code=code, detail=detail)
210
+ if event_data.get("responseId") and not acc.response_id:
211
+ acc.response_id = event_data["responseId"]
207
212
  item = event_data.get("item")
208
213
  if not item:
209
214
  continue
215
+ if item.get("responseId") and not acc.response_id:
216
+ acc.response_id = item["responseId"]
210
217
  role = item.get("role")
211
218
  content: dict[str, Any] = item.get("content") or {}
212
219
  ctype = content.get("type")
@@ -227,10 +234,13 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
227
234
  class ChatClient:
228
235
  """Single-turn AI chat client over the GoodData AI conversation endpoints."""
229
236
 
230
- def __init__(self, host: str, token: str, workspace_id: str, *, timeout: float = 300.0):
237
+ def __init__(
238
+ self, host: str, token: str, workspace_id: str, *, timeout: float = 300.0, preserve_failed: bool = False
239
+ ):
231
240
  self._base = f"{host.rstrip('/')}/api/v1/ai/workspaces/{workspace_id}/chat/conversations"
232
241
  self._auth = {"Authorization": f"Bearer {token}"}
233
242
  self._client = httpx.Client(timeout=timeout)
243
+ self._preserve_failed = preserve_failed
234
244
 
235
245
  def create_conversation(self) -> str:
236
246
  def _do() -> str:
@@ -264,12 +274,29 @@ class ChatClient:
264
274
  return _retry_transient(_do, is_retryable=_is_retryable_exc)
265
275
 
266
276
  def ask(self, item: DatasetItem) -> ChatResult:
267
- """Run one single-turn conversation: create, send, parse, clean up."""
277
+ """Run one conversation: create, send, parse, clean up.
278
+
279
+ The conversation_id is attached to the returned ChatResult for tracing.
280
+ When ``preserve_failed`` is set, failed conversations are kept on the
281
+ server so they can be inspected after the run; the conversation_id is
282
+ attached to the raised exception as well.
283
+ """
268
284
  conversation_id = self.create_conversation()
285
+ success = False
269
286
  try:
270
- return self.send_message(conversation_id, item.question)
287
+ result = self.send_message(conversation_id, item.question)
288
+ result.conversation_id = conversation_id
289
+ success = True
290
+ return result
291
+ except Exception as exc:
292
+ try:
293
+ object.__setattr__(exc, "conversation_id", conversation_id)
294
+ except TypeError:
295
+ pass # C-extension exception that rejects __setattr__
296
+ raise
271
297
  finally:
272
- self.delete_conversation(conversation_id)
298
+ if success or not self._preserve_failed:
299
+ self.delete_conversation(conversation_id)
273
300
 
274
301
  def close(self) -> None:
275
302
  self._client.close()
@@ -19,3 +19,4 @@ class RunConfig:
19
19
  log_to_langfuse: bool = False
20
20
  quiet: bool = False
21
21
  kind: str = "visualization"
22
+ preserve_failed: bool = False
@@ -78,7 +78,9 @@ class AlertSkillEvaluator:
78
78
 
79
79
  if "Trigger" in expected:
80
80
  expected_trigger = _TRIGGER_MAP.get(expected["Trigger"], expected["Trigger"])
81
- trigger_correct = args.get("trigger") == expected_trigger
81
+ # Omitted/null trigger persists as the product default ALWAYS ("Every time").
82
+ actual_trigger = args.get("trigger") or "ALWAYS"
83
+ trigger_correct = actual_trigger == expected_trigger
82
84
 
83
85
  if "Filters" in expected:
84
86
  actual_filters = args.get("filters") or []
@@ -0,0 +1,59 @@
1
+ # (C) 2026 GoodData Corporation
2
+ """Evaluator for search_tool: agent must call the catalog search with expected parameters."""
3
+
4
+ from gooddata_eval.core.evaluators.base import ItemEvaluation
5
+ from gooddata_eval.core.models import ChatResult, DatasetItem
6
+
7
+
8
+ def _normalize_str_list(value: object, *, lowercase: bool = False) -> list[str]:
9
+ # Arguments come from raw model-emitted JSON. The search_objects schema
10
+ # declares keywords/object_types as list[str], but a malformed tool call may
11
+ # send a non-list or non-string entries. Drop the offending entries defensively
12
+ # so bad input can't raise (.lower()/sorted() on a non-str) and abort the whole
13
+ # evaluation run; a non-list collapses to [] and the surviving strings are
14
+ # still compared normally.
15
+ if not isinstance(value, list):
16
+ return []
17
+ items = [item for item in value if isinstance(item, str)]
18
+ return sorted(item.lower() if lowercase else item for item in items)
19
+
20
+
21
+ def _args_match(actual_args: dict, expected_args: dict) -> bool:
22
+ # Only keywords and object_types determine semantic correctness.
23
+ # limit is optional with a server-side default; emit_widget was renamed to
24
+ # user_requested_search in the tool schema — neither affects search quality.
25
+ actual_kw = _normalize_str_list(actual_args.get("keywords"), lowercase=True)
26
+ expected_kw = _normalize_str_list(expected_args.get("keywords"), lowercase=True)
27
+ if actual_kw != expected_kw:
28
+ return False
29
+ # object_types is compared case-sensitively (no lowercase=True): they are
30
+ # controlled ObjectType StrEnum values the model emits verbatim ("metric",
31
+ # "dashboard"), so a case mismatch is a genuine error, not a formatting quirk.
32
+ return _normalize_str_list(actual_args.get("object_types")) == _normalize_str_list(
33
+ expected_args.get("object_types")
34
+ )
35
+
36
+
37
+ class SearchToolEvaluator:
38
+ test_kind = "search_tool"
39
+
40
+ def evaluate(self, item: DatasetItem, chat_result: ChatResult) -> ItemEvaluation:
41
+ expected_call = (item.expected_output or {}).get("tool_call", {})
42
+ expected_fn = expected_call.get("function_name", "search_objects")
43
+ expected_args = expected_call.get("function_arguments", {})
44
+
45
+ matching_events = [ev for ev in chat_result.tool_call_events if ev.function_name == expected_fn]
46
+ tool_selection = len(matching_events) > 0
47
+ tool_correctness = any(_args_match(ev.parsed_arguments(), expected_args) for ev in matching_events)
48
+
49
+ # tool_selection is the hard gate; tool_correctness is scored but not blocking
50
+ return ItemEvaluation(
51
+ passed=tool_selection,
52
+ rank_key=(int(tool_selection), int(tool_correctness)),
53
+ detail={
54
+ "tool_selection": tool_selection,
55
+ "tool_correctness": tool_correctness,
56
+ "expected_function": expected_fn,
57
+ "calls_found": len(matching_events),
58
+ },
59
+ )
@@ -94,6 +94,8 @@ class ChatResult(BaseModel):
94
94
  created_visualizations: CreatedVisualizations | None = Field(default=None, alias="createdVisualizations")
95
95
  tool_call_events: list[ToolCallEvent] = Field(default_factory=list, alias="toolCallEvents")
96
96
  reasoning_step_count: int = Field(default=0, alias="reasoningStepCount")
97
+ conversation_id: str | None = Field(default=None, alias="conversationId")
98
+ response_id: str | None = Field(default=None, alias="responseId")
97
99
 
98
100
 
99
101
  class SummaryInput(BaseModel):
@@ -34,6 +34,8 @@ def _build_run_dict(report: EvalReport) -> dict:
34
34
  "latency_s": round(item.latency_s, 3),
35
35
  "avg_latency_s": round(item.avg_latency_s, 3),
36
36
  "detail": item.best_detail,
37
+ "conversation_id": item.conversation_id,
38
+ "response_id": item.response_id,
37
39
  }
38
40
  for item in report.items
39
41
  },
@@ -31,6 +31,8 @@ class ItemReport:
31
31
  runs: int = 0
32
32
  latency_s: float = 0.0 # total wall-clock across this item's runs
33
33
  best_detail: dict = field(default_factory=dict)
34
+ conversation_id: str | None = None
35
+ response_id: str | None = None
34
36
 
35
37
  @property
36
38
  def avg_latency_s(self) -> float:
@@ -112,6 +114,8 @@ def _run_one_item(
112
114
  for run_index in range(1, runs + 1):
113
115
  t0 = time.perf_counter()
114
116
  chat_result = backend.ask(item)
117
+ report.conversation_id = getattr(chat_result, "conversation_id", None) or report.conversation_id
118
+ report.response_id = getattr(chat_result, "response_id", None) or report.response_id
115
119
  evaluation = evaluator.evaluate(item, chat_result)
116
120
  latency = time.perf_counter() - t0
117
121
  report.runs += 1
@@ -123,7 +127,9 @@ def _run_one_item(
123
127
  if on_run_done is not None:
124
128
  on_run_done(run_index, runs, evaluation.passed, latency)
125
129
  except Exception as e: # agent/network/parse failure for this item
126
- report.error = f"{type(e).__name__}: {e}"
130
+ conv_id = getattr(e, "conversation_id", None)
131
+ report.conversation_id = conv_id or report.conversation_id
132
+ report.error = f"{type(e).__name__}: {e}" + (f" [conversation_id={conv_id}]" if conv_id else "")
127
133
  if best is not None:
128
134
  report.best_detail = best.detail
129
135
  return report
@@ -3,10 +3,10 @@
3
3
  from unittest.mock import MagicMock, patch
4
4
 
5
5
  from gooddata_eval.core.agentic.alert_skill import (
6
- AgenticAlertSummary,
7
6
  AlertEvaluation,
8
- AlertRunResult,
7
+ _check_trigger,
9
8
  _deep_subset,
9
+ _normalize_expected_output,
10
10
  _to_number,
11
11
  run_agentic_alert_skill,
12
12
  )
@@ -33,6 +33,24 @@ def test_deep_subset_missing_key():
33
33
  assert _deep_subset({"a": 1, "c": 3}, {"a": 1}) is False
34
34
 
35
35
 
36
+ def test_check_trigger_missing_or_null_defaults_to_always():
37
+ # "Every time" is the product default -> the agent may omit the trigger arg
38
+ # entirely, or serialise it as null. Both must count as ALWAYS, not a mismatch.
39
+ expected = _normalize_expected_output({"Operator": "GREATER_THAN", "Trigger": "Every time"})
40
+ assert _check_trigger(expected, {"operator": "GREATER_THAN"}) is True # key absent
41
+ assert _check_trigger(expected, {"trigger": None}) is True # present-but-null (the bug)
42
+ assert _check_trigger(expected, {"trigger": "ALWAYS"}) is True
43
+
44
+
45
+ def test_check_trigger_once_needs_explicit_once():
46
+ # A "One time" expectation must still require an explicit ONCE - the null-default
47
+ # fix must not turn a wrong/absent trigger into a pass here.
48
+ expected = _normalize_expected_output({"Operator": "LESS_THAN", "Trigger": "One time"})
49
+ assert _check_trigger(expected, {"trigger": "ONCE"}) is True
50
+ assert _check_trigger(expected, {"trigger": None}) is False # null != ONCE
51
+ assert _check_trigger(expected, {"trigger": "ONCE_PER_INTERVAL"}) is False # real model error stays a fail
52
+
53
+
36
54
  def test_alert_evaluation_strict_pass():
37
55
  ev = AlertEvaluation(
38
56
  alert_created=True,
@@ -4,13 +4,12 @@ from unittest.mock import MagicMock, patch
4
4
 
5
5
  from gooddata_eval.core.agentic.conversation import (
6
6
  ConversationFixture,
7
- ConversationResult,
8
7
  TurnDefinition,
9
8
  TurnResult,
10
9
  _resolve_refs,
11
10
  run_agentic_conversation,
12
11
  )
13
- from gooddata_eval.core.models import ChatResult, ToolCallEvent
12
+ from gooddata_eval.core.models import ToolCallEvent
14
13
 
15
14
 
16
15
  def test_turn_definition_model():
@@ -3,11 +3,9 @@
3
3
  from unittest.mock import MagicMock, patch
4
4
 
5
5
  from gooddata_eval.core.agentic.general_question import (
6
- AgenticGeneralQuestionSummary,
7
6
  GeneralQuestionResult,
8
7
  run_agentic_general_question,
9
8
  )
10
- from gooddata_eval.core.models import ChatResult
11
9
 
12
10
 
13
11
  def test_general_question_result_fields():
@@ -3,11 +3,9 @@
3
3
  from unittest.mock import MagicMock, patch
4
4
 
5
5
  from gooddata_eval.core.agentic.guardrail import (
6
- AgenticGuardrailSummary,
7
6
  GuardrailResult,
8
7
  run_agentic_guardrail,
9
8
  )
10
- from gooddata_eval.core.models import ChatResult
11
9
 
12
10
 
13
11
  def test_guardrail_result_fields():
@@ -3,8 +3,6 @@
3
3
  from unittest.mock import MagicMock, patch
4
4
 
5
5
  from gooddata_eval.core.agentic.search_tool import (
6
- AgenticSearchSummary,
7
- SearchResult,
8
6
  _tool_correctness,
9
7
  _tool_selection,
10
8
  run_agentic_search_tool,
@@ -4,14 +4,9 @@
4
4
  All tests mock ChatClient so no network is needed.
5
5
  """
6
6
 
7
- from dataclasses import dataclass
8
7
  from unittest.mock import MagicMock, call, patch
9
8
 
10
- import pytest
11
-
12
9
  from gooddata_eval.core.agentic.visualization import (
13
- AgenticRunSummary,
14
- RunResult,
15
10
  _execute_single_run,
16
11
  run_agentic_visualization,
17
12
  )
@@ -154,7 +149,8 @@ def test_run_agentic_visualization_uses_initial_conversation_for_run_0():
154
149
  # create_conversation should NOT be called for run 0
155
150
  instance.create_conversation.assert_not_called()
156
151
  instance.send_message.assert_called_once_with("existing-conv", "Show revenue")
157
- instance.delete_conversation.assert_called_once_with("existing-conv")
152
+ # the caller-supplied conversation is left intact; the function only deletes conversations it created
153
+ instance.delete_conversation.assert_not_called()
158
154
  assert len(summary.run_results) == 1
159
155
 
160
156
 
@@ -176,7 +172,8 @@ def test_run_agentic_visualization_creates_fresh_conversations_for_remaining_run
176
172
  )
177
173
 
178
174
  assert instance.create_conversation.call_count == 1 # only for run 1
179
- assert instance.delete_conversation.call_count == 2 # existing-conv + fresh-1
175
+ # only the self-created fresh-1 is deleted; the caller-supplied existing-conv is left intact
176
+ instance.delete_conversation.assert_called_once_with("fresh-1")
180
177
  assert len(summary.run_results) == 2
181
178
 
182
179
 
@@ -233,7 +230,7 @@ def test_run_agentic_visualization_creates_conversation_when_no_initial_id():
233
230
  instance.create_conversation.side_effect = ["new-0", "new-1"]
234
231
  instance.send_message.return_value = _chat_with_viz()
235
232
 
236
- summary = run_agentic_visualization(
233
+ run_agentic_visualization(
237
234
  host="https://example.com",
238
235
  token="tok",
239
236
  workspace_id="ws",