gooddata-eval 1.69.0__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.69.0 → gooddata_eval-1.69.1.dev1}/PKG-INFO +2 -2
  2. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/pyproject.toml +3 -2
  3. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/cli/agentic_runner.py +9 -12
  4. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/cli/main.py +14 -2
  5. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/alert_skill.py +9 -5
  6. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/conversation.py +3 -1
  7. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/general_question.py +3 -1
  8. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/guardrail.py +3 -1
  9. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/metric_skill.py +4 -3
  10. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/search_tool.py +3 -1
  11. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/visualization.py +3 -1
  12. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/chat/sse_client.py +32 -5
  13. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/config.py +1 -0
  14. {gooddata_eval-1.69.0 → 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.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/models.py +2 -0
  17. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/reporting/json_report.py +2 -0
  18. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/runner.py +7 -1
  19. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_alert_skill.py +20 -2
  20. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_conversation.py +1 -2
  21. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_general_question.py +0 -2
  22. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_guardrail.py +0 -2
  23. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_search_tool.py +0 -2
  24. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_visualization.py +5 -8
  25. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_alert_skill_evaluator.py +17 -0
  26. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_cli.py +67 -8
  27. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_langfuse_source.py +2 -1
  28. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_runner.py +25 -0
  29. {gooddata_eval-1.69.0 → 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.69.0/src/gooddata_eval/core/evaluators/search_tool.py +0 -40
  32. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/.gitignore +0 -0
  33. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/LICENSE.txt +0 -0
  34. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/Makefile +0 -0
  35. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/README.md +0 -0
  36. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/__init__.py +0 -0
  37. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/_version.py +0 -0
  38. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/cli/__init__.py +0 -0
  39. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/__init__.py +0 -0
  40. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/__init__.py +0 -0
  41. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
  42. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/agentic/_langfuse.py +0 -0
  43. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/chat/__init__.py +0 -0
  44. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/connection.py +0 -0
  45. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/dataset/__init__.py +0 -0
  46. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/dataset/langfuse_source.py +0 -0
  47. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/dataset/local.py +0 -0
  48. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/__init__.py +0 -0
  49. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
  50. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
  51. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
  52. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/base.py +0 -0
  53. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
  54. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
  55. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
  56. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/summary.py +0 -0
  57. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
  58. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
  59. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/langfuse/sink.py +0 -0
  60. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/reporting/__init__.py +0 -0
  61. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/reporting/console.py +0 -0
  62. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/scoring.py +0 -0
  63. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/summary/__init__.py +0 -0
  64. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/summary/http_client.py +0 -0
  65. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/src/gooddata_eval/core/workspace.py +0 -0
  66. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/__init__.py +0 -0
  67. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/conftest.py +0 -0
  68. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
  69. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
  70. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/fixtures/sse_visualization_stream.txt +0 -0
  71. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_agentic_metric_skill.py +0 -0
  72. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_connection.py +0 -0
  73. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_deep_subset.py +0 -0
  74. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_langfuse_sink.py +0 -0
  75. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_llm_judge.py +0 -0
  76. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_local_loader.py +0 -0
  77. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_metric_skill_evaluator.py +0 -0
  78. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_models.py +0 -0
  79. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_reporting.py +0 -0
  80. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_scoring.py +0 -0
  81. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_search_tool_evaluator.py +0 -0
  82. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_summary_client.py +0 -0
  83. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_summary_evaluator.py +0 -0
  84. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_text_evaluators.py +0 -0
  85. {gooddata_eval-1.69.0 → gooddata_eval-1.69.1.dev1}/tests/test_visualization_evaluator.py +0 -0
  86. {gooddata_eval-1.69.0 → 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.69.0
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.69.0
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.69.0"
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.69.0",
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
  {
@@ -12,6 +12,7 @@ from gooddata_api_client.exceptions import ApiException
12
12
  from rich.console import Console
13
13
  from rich.table import Table
14
14
 
15
+ from gooddata_eval.cli.agentic_runner import AGENTIC_TEST_KINDS, run_agentic_items
15
16
  from gooddata_eval.core.chat.sse_client import ChatClient
16
17
  from gooddata_eval.core.config import RunConfig
17
18
  from gooddata_eval.core.connection import ConnectionError_, resolve_connection
@@ -20,7 +21,6 @@ from gooddata_eval.core.langfuse.sink import LangfuseSink
20
21
  from gooddata_eval.core.models import ChatResult, DatasetItem
21
22
  from gooddata_eval.core.reporting.console import render_comparison, render_console
22
23
  from gooddata_eval.core.reporting.json_report import write_multi_model_report
23
- from gooddata_eval.cli.agentic_runner import AGENTIC_TEST_KINDS, run_agentic_items
24
24
  from gooddata_eval.core.runner import ItemReport, run_items
25
25
  from gooddata_eval.core.summary.http_client import SummaryClient
26
26
  from gooddata_eval.core.workspace import ModelResolutionError, WorkspaceModelController
@@ -98,6 +98,12 @@ def _build_parser() -> argparse.ArgumentParser:
98
98
  )
99
99
  run.add_argument("--json", dest="json_path", help="Write a JSON report to this path.")
100
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
+ )
101
107
  run.add_argument(
102
108
  "--langfuse",
103
109
  action="store_true",
@@ -331,7 +337,12 @@ def _run(config: RunConfig) -> int:
331
337
 
332
338
  # --- non-agentic items (single-turn, use Evaluator) ---
333
339
  backend = _RoutingBackend(
334
- 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
+ ),
335
346
  SummaryClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
336
347
  )
337
348
  try:
@@ -421,6 +432,7 @@ def main(argv: list[str] | None = None) -> int:
421
432
  log_to_langfuse=args.langfuse,
422
433
  quiet=args.quiet,
423
434
  kind=args.kind,
435
+ preserve_failed=args.preserve_failed,
424
436
  )
425
437
  return _run(config)
426
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",
@@ -58,6 +58,23 @@ def test_alert_evaluator_skips_absent_field():
58
58
  assert result.passed is True
59
59
 
60
60
 
61
+ def test_alert_evaluator_defaults_missing_trigger_to_always():
62
+ # The agent may omit `trigger` (or send null) -> backend default is ALWAYS,
63
+ # so an "Every time" expectation must still pass instead of a false-negative.
64
+ expected = {"Operator": "GREATER_THAN", "Threshold": "150", "Trigger": "Every time"}
65
+ ev = get_evaluator("alert_skill")
66
+
67
+ absent = ev.evaluate(_item(expected), _chat_with_alert({"operator": "GREATER_THAN", "threshold": 150}))
68
+ assert absent.passed is True
69
+ assert absent.detail["trigger_correct"] is True
70
+
71
+ null = ev.evaluate(
72
+ _item(expected), _chat_with_alert({"operator": "GREATER_THAN", "threshold": 150, "trigger": None})
73
+ )
74
+ assert null.passed is True
75
+ assert null.detail["trigger_correct"] is True
76
+
77
+
61
78
  def test_alert_evaluator_fails_when_no_tool_call():
62
79
  result = get_evaluator("alert_skill").evaluate(
63
80
  _item({"Operator": "LESS_THAN"}), ChatResult.model_validate({"textResponse": "here is the alert"})
@@ -1,4 +1,7 @@
1
1
  # (C) 2026 GoodData Corporation
2
+ import io
3
+ from concurrent.futures import ThreadPoolExecutor, as_completed
4
+
2
5
  import httpx
3
6
  import orjson
4
7
  import pytest
@@ -86,7 +89,8 @@ def test_cli_run_end_to_end(monkeypatch, tmp_path, fixtures_dir):
86
89
  ]
87
90
  )
88
91
  assert exit_code == 0
89
- assert orjson.loads(out.read_bytes())["runs"]["gpt-5.2"]["summary"]["passed"] == 1
92
+ # run keys are provider-prefixed (provider_name/model) to stay collision-free across providers
93
+ assert orjson.loads(out.read_bytes())["runs"]["Test Provider/gpt-5.2"]["summary"]["passed"] == 1
90
94
 
91
95
 
92
96
  def test_cli_operational_error_exits_nonzero(monkeypatch, fixtures_dir):
@@ -405,9 +409,10 @@ def test_cli_multimodel_writes_nested_json(monkeypatch, tmp_path, fixtures_dir):
405
409
  ]
406
410
  )
407
411
  data = orjson.loads(out.read_bytes())
408
- assert data["models"] == ["gpt-5.2", "gpt-4o"]
412
+ # keys are provider-prefixed (provider_name/model); provider_name is "P" here
413
+ assert data["models"] == ["P/gpt-5.2", "P/gpt-4o"]
409
414
  assert "runs" in data and "comparison" in data
410
- assert data["comparison"]["gpt-5.2"]["passed"] == 1
415
+ assert data["comparison"]["P/gpt-5.2"]["passed"] == 1
411
416
 
412
417
 
413
418
  def test_cli_restore_fires_even_when_model_loop_raises(monkeypatch, fixtures_dir):
@@ -512,6 +517,62 @@ def test_cli_rejects_zero_concurrency(monkeypatch, fixtures_dir):
512
517
  assert exit_code == 2
513
518
 
514
519
 
520
+ def test_cli_preserve_failed_flag_parsed(monkeypatch, fixtures_dir):
521
+ """--preserve-failed sets preserve_failed=True in RunConfig and is passed to ChatClient."""
522
+ monkeypatch.setattr(cli_main, "resolve_connection", lambda host, token, profile: ("https://h", "tok"))
523
+ captured_kwargs: dict = {}
524
+
525
+ class _FakeController:
526
+ def __init__(self, *a, **k): ...
527
+ def get_active(self):
528
+ return ActiveLlmProvider(provider_id="p", default_model_id="gpt-5.2")
529
+
530
+ def resolve_and_activate(self, requested, provider=None):
531
+ return ResolvedModel(provider_id="p", model_id="gpt-5.2", switched=False, provider_name="P")
532
+
533
+ def restore(self, original): ...
534
+ def close(self): ...
535
+
536
+ monkeypatch.setattr(cli_main, "WorkspaceModelController", _FakeController)
537
+
538
+ original_chat_client = cli_main.ChatClient
539
+
540
+ def _capture_chat_client(**kwargs):
541
+ captured_kwargs.update(kwargs)
542
+ return object()
543
+
544
+ monkeypatch.setattr(cli_main, "ChatClient", _capture_chat_client)
545
+
546
+ def _fake_run(items, backend, *, runs, model, workspace_id, **kw):
547
+ return EvalReport(
548
+ model=model,
549
+ workspace_id=workspace_id,
550
+ items=[
551
+ ItemReport(id="i1", dataset_name="d", test_kind="visualization", question="q", pass_at_k=True, runs=1)
552
+ ],
553
+ )
554
+
555
+ monkeypatch.setattr(cli_main, "run_items", _fake_run)
556
+
557
+ exit_code = cli_main.main(
558
+ [
559
+ "run",
560
+ "--host",
561
+ "https://h",
562
+ "--token",
563
+ "tok",
564
+ "--workspace",
565
+ "ws1",
566
+ "--dataset",
567
+ str(fixtures_dir / "sample_dataset"),
568
+ "--preserve-failed",
569
+ "--quiet",
570
+ ]
571
+ )
572
+ assert exit_code == 0
573
+ assert captured_kwargs.get("preserve_failed") is True
574
+
575
+
515
576
  def test_cli_rejects_negative_concurrency(monkeypatch, fixtures_dir):
516
577
  monkeypatch.setattr(cli_main, "resolve_connection", lambda host, token, profile: ("https://h", "tok"))
517
578
  exit_code = cli_main.main(
@@ -534,10 +595,6 @@ def test_cli_rejects_negative_concurrency(monkeypatch, fixtures_dir):
534
595
 
535
596
  def test_progress_callbacks_thread_safe():
536
597
  """Verify progress callbacks can be called from multiple threads without error."""
537
- import io
538
- import threading
539
- from concurrent.futures import ThreadPoolExecutor, as_completed
540
-
541
598
  console = Console(file=io.StringIO(), force_terminal=False)
542
599
  on_item_start, on_run_done, on_item_done = cli_main._make_progress_callbacks(console)
543
600
 
@@ -554,7 +611,9 @@ def test_progress_callbacks_thread_safe():
554
611
  )
555
612
  on_item_start(index, 100, item)
556
613
  on_run_done(index, 100, 1, 1, index % 2 == 0, 1.5)
557
- report = ItemReport(id=f"test-{index}", dataset_name="test", test_kind="general_question")
614
+ report = ItemReport(
615
+ id=f"test-{index}", dataset_name="test", test_kind="general_question", question=f"Question {index}"
616
+ )
558
617
  report.runs = 1
559
618
  report.latency_s = 1.5
560
619
  report.pass_at_k = index % 2 == 0
@@ -19,7 +19,8 @@ def test_item_from_raw_dict_input():
19
19
  item = _item_from_raw(raw, dataset_name="ds", test_kind="visualization")
20
20
  assert item.id == "lf-1"
21
21
  assert item.question == "Show revenue"
22
- assert item.test_kind == "visualization"
22
+ # expected_output carries a "visualization" key, so _infer_test_kind classifies it as production agentic vis
23
+ assert item.test_kind == "vis_agentic"
23
24
  assert item.dataset_name == "ds"
24
25
 
25
26
 
@@ -233,6 +233,31 @@ def test_run_items_concurrency_callbacks_fire_for_all_items():
233
233
  assert sorted(done_ids) == [f"c{i}" for i in range(5)]
234
234
 
235
235
 
236
+ def test_run_items_error_report_includes_conversation_id():
237
+ """When an exception has a conversation_id, it appears in the error report."""
238
+
239
+ class _ConvIdBackend:
240
+ def ask(self, item: DatasetItem) -> ChatResult:
241
+ exc = RuntimeError("agent error")
242
+ exc.conversation_id = "conv-xyz" # type: ignore[attr-defined]
243
+ raise exc
244
+
245
+ report = run_items([_item()], _ConvIdBackend(), runs=1)
246
+ assert report.errored == 1
247
+ assert "conversation_id=conv-xyz" in report.items[0].error
248
+
249
+
250
+ def test_run_items_error_report_omits_conversation_id_when_absent():
251
+ """When no conversation_id is set, the error string has no bracket suffix."""
252
+
253
+ class _PlainBackend:
254
+ def ask(self, item: DatasetItem) -> ChatResult:
255
+ raise RuntimeError("plain error")
256
+
257
+ report = run_items([_item()], _PlainBackend(), runs=1)
258
+ assert "conversation_id" not in report.items[0].error
259
+
260
+
236
261
  def test_run_items_callback_exception_is_logged_not_swallowed(capsys):
237
262
  """A raising callback prints a traceback to stderr but the run continues."""
238
263
  backend = _FakeBackend([_chat_with(_viz_obj())] * 2)
@@ -5,6 +5,7 @@ import httpx
5
5
  import pytest
6
6
  from gooddata_eval.core.chat import sse_client as sse_mod
7
7
  from gooddata_eval.core.chat.sse_client import ChatClient, ChatError, TransientChatError, parse_sse_lines
8
+ from gooddata_eval.core.models import DatasetItem
8
9
 
9
10
 
10
11
  def test_parse_sse_lines_collects_text_and_visualization(fixtures_dir):
@@ -222,3 +223,120 @@ def test_float_env_uses_default_when_unset(monkeypatch):
222
223
  def test_float_env_reads_override(monkeypatch):
223
224
  monkeypatch.setenv("GD_TEST_FLOAT", "1.5")
224
225
  assert sse_mod._float_env("GD_TEST_FLOAT", 5.0) == 1.5
226
+
227
+
228
+ def test_parse_sse_lines_captures_response_id_from_event_data():
229
+ """response_id is captured from the top-level event_data."""
230
+ lines = [
231
+ 'data: {"responseId": "resp-123", "item": {"role": "assistant", "content": {"type": "text", "text": "hi"}}}',
232
+ ]
233
+ result = parse_sse_lines(lines)
234
+ assert result.response_id == "resp-123"
235
+
236
+
237
+ def test_parse_sse_lines_captures_response_id_from_item():
238
+ """response_id is captured from item when not present at top level."""
239
+ lines = [
240
+ 'data: {"item": {"role": "assistant", "responseId": "resp-456", "content": {"type": "text", "text": "hi"}}}',
241
+ ]
242
+ result = parse_sse_lines(lines)
243
+ assert result.response_id == "resp-456"
244
+
245
+
246
+ def test_parse_sse_lines_first_response_id_wins():
247
+ """Only the first responseId encountered is kept."""
248
+ lines = [
249
+ 'data: {"responseId": "first", "item": {"role": "assistant", "content": {"type": "text", "text": "a"}}}',
250
+ 'data: {"responseId": "second", "item": {"role": "assistant", "content": {"type": "text", "text": "b"}}}',
251
+ ]
252
+ result = parse_sse_lines(lines)
253
+ assert result.response_id == "first"
254
+
255
+
256
+ def test_ask_attaches_conversation_id_to_result(monkeypatch):
257
+ """ChatClient.ask() sets conversation_id on the returned ChatResult."""
258
+ calls = []
259
+
260
+ def handler(request):
261
+ if request.method == "POST" and request.url.path.endswith("/conversations"):
262
+ return httpx.Response(200, json={"conversationId": "conv-abc"})
263
+ if request.method == "POST" and "messages" in str(request.url):
264
+ return httpx.Response(200, content=_OK_SSE)
265
+ if request.method == "DELETE":
266
+ calls.append("delete")
267
+ return httpx.Response(204)
268
+ return httpx.Response(404)
269
+
270
+ client = _client_with_handler(handler)
271
+ item = DatasetItem(id="t1", dataset_name="d", test_kind="visualization", question="q", expected_output={})
272
+ result = client.ask(item)
273
+ assert result.conversation_id == "conv-abc"
274
+ assert "delete" in calls # conversation cleaned up on success
275
+
276
+
277
+ def test_ask_preserve_failed_keeps_conversation_on_error(monkeypatch):
278
+ """With preserve_failed=True, failed conversations are not deleted."""
279
+ calls = []
280
+
281
+ def handler(request):
282
+ if request.method == "POST" and request.url.path.endswith("/conversations"):
283
+ return httpx.Response(200, json={"conversationId": "conv-fail"})
284
+ if request.method == "POST" and "messages" in str(request.url):
285
+ return httpx.Response(200, content=_NONRETRY_SSE)
286
+ if request.method == "DELETE":
287
+ calls.append("delete")
288
+ return httpx.Response(204)
289
+ return httpx.Response(404)
290
+
291
+ monkeypatch.setattr(sse_mod.time, "sleep", lambda s: None)
292
+ client = ChatClient(host="https://example.invalid", token="t", workspace_id="w", preserve_failed=True)
293
+ client._client = httpx.Client(transport=httpx.MockTransport(handler))
294
+
295
+ item = DatasetItem(id="t1", dataset_name="d", test_kind="visualization", question="q", expected_output={})
296
+ with pytest.raises(ChatError):
297
+ client.ask(item)
298
+ assert "delete" not in calls # conversation preserved
299
+
300
+
301
+ def test_ask_without_preserve_failed_deletes_on_error(monkeypatch):
302
+ """Without preserve_failed, conversations are deleted even on error."""
303
+ calls = []
304
+
305
+ def handler(request):
306
+ if request.method == "POST" and request.url.path.endswith("/conversations"):
307
+ return httpx.Response(200, json={"conversationId": "conv-del"})
308
+ if request.method == "POST" and "messages" in str(request.url):
309
+ return httpx.Response(200, content=_NONRETRY_SSE)
310
+ if request.method == "DELETE":
311
+ calls.append("delete")
312
+ return httpx.Response(204)
313
+ return httpx.Response(404)
314
+
315
+ monkeypatch.setattr(sse_mod.time, "sleep", lambda s: None)
316
+ client = _client_with_handler(handler)
317
+
318
+ item = DatasetItem(id="t1", dataset_name="d", test_kind="visualization", question="q", expected_output={})
319
+ with pytest.raises(ChatError):
320
+ client.ask(item)
321
+ assert "delete" in calls # conversation deleted
322
+
323
+
324
+ def test_ask_attaches_conversation_id_to_exception(monkeypatch):
325
+ """On failure, conversation_id is attached to the raised exception."""
326
+
327
+ def handler(request):
328
+ if request.method == "POST" and request.url.path.endswith("/conversations"):
329
+ return httpx.Response(200, json={"conversationId": "conv-exc"})
330
+ if request.method == "POST" and "messages" in str(request.url):
331
+ return httpx.Response(200, content=_NONRETRY_SSE)
332
+ if request.method == "DELETE":
333
+ return httpx.Response(204)
334
+ return httpx.Response(404)
335
+
336
+ monkeypatch.setattr(sse_mod.time, "sleep", lambda s: None)
337
+ client = _client_with_handler(handler)
338
+
339
+ item = DatasetItem(id="t1", dataset_name="d", test_kind="visualization", question="q", expected_output={})
340
+ with pytest.raises(ChatError) as ei:
341
+ client.ask(item)
342
+ assert ei.value.conversation_id == "conv-exc"
@@ -0,0 +1,16 @@
1
+ # (C) 2026 GoodData Corporation
2
+ [tox]
3
+ envlist = py3{10,11,12,13,14}
4
+
5
+ [testenv]
6
+ runner = uv-venv-lock-runner
7
+ package = wheel
8
+ wheel_build_env = .pkg
9
+ extras =
10
+ llm-judge
11
+ dependency_groups =
12
+ test
13
+ setenv =
14
+ COVERAGE_CORE=sysmon
15
+ commands =
16
+ pytest -v --cov --cov-report=xml tests {posargs} --json-report --json-report-file=.json-report-{envname}.json
@@ -1,40 +0,0 @@
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 _args_match(actual_args: dict, expected_args: dict) -> bool:
9
- if sorted(actual_args.get("keywords") or []) != sorted(expected_args.get("keywords") or []):
10
- return False
11
- if sorted(actual_args.get("object_types") or []) != sorted(expected_args.get("object_types") or []):
12
- return False
13
- if actual_args.get("limit") != expected_args.get("limit"):
14
- return False
15
- return actual_args.get("emit_widget") == expected_args.get("emit_widget")
16
-
17
-
18
- class SearchToolEvaluator:
19
- test_kind = "search_tool"
20
-
21
- def evaluate(self, item: DatasetItem, chat_result: ChatResult) -> ItemEvaluation:
22
- expected_call = (item.expected_output or {}).get("tool_call", {})
23
- expected_fn = expected_call.get("function_name", "search_objects")
24
- expected_args = expected_call.get("function_arguments", {})
25
-
26
- matching_events = [ev for ev in chat_result.tool_call_events if ev.function_name == expected_fn]
27
- tool_selection = len(matching_events) > 0
28
- tool_correctness = any(_args_match(ev.parsed_arguments(), expected_args) for ev in matching_events)
29
-
30
- # tool_selection is the hard gate; tool_correctness is scored but not blocking
31
- return ItemEvaluation(
32
- passed=tool_selection,
33
- rank_key=(int(tool_selection), int(tool_correctness)),
34
- detail={
35
- "tool_selection": tool_selection,
36
- "tool_correctness": tool_correctness,
37
- "expected_function": expected_fn,
38
- "calls_found": len(matching_events),
39
- },
40
- )