gooddata-eval 1.68.1.dev2__tar.gz → 1.68.1.dev4__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 (85) hide show
  1. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/PKG-INFO +2 -2
  2. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/pyproject.toml +2 -2
  3. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/alert_skill.py +31 -14
  4. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/conversation.py +1 -1
  5. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/chat/sse_client.py +104 -11
  6. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/scoring.py +3 -1
  7. gooddata_eval-1.68.1.dev4/tests/test_sse_client.py +224 -0
  8. gooddata_eval-1.68.1.dev2/tests/test_sse_client.py +0 -79
  9. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/.gitignore +0 -0
  10. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/LICENSE.txt +0 -0
  11. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/Makefile +0 -0
  12. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/README.md +0 -0
  13. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/__init__.py +0 -0
  14. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/_version.py +0 -0
  15. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/cli/__init__.py +0 -0
  16. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/cli/agentic_runner.py +0 -0
  17. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/cli/main.py +0 -0
  18. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/__init__.py +0 -0
  19. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/__init__.py +0 -0
  20. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
  21. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/_langfuse.py +0 -0
  22. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/general_question.py +0 -0
  23. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/guardrail.py +0 -0
  24. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/metric_skill.py +0 -0
  25. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/search_tool.py +0 -0
  26. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/agentic/visualization.py +0 -0
  27. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/chat/__init__.py +0 -0
  28. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/config.py +0 -0
  29. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/connection.py +0 -0
  30. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/dataset/__init__.py +0 -0
  31. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/dataset/langfuse_source.py +0 -0
  32. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/dataset/local.py +0 -0
  33. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/__init__.py +0 -0
  34. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
  35. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
  36. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
  37. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/alert_skill.py +0 -0
  38. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/base.py +0 -0
  39. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
  40. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
  41. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
  42. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/search_tool.py +0 -0
  43. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/summary.py +0 -0
  44. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
  45. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
  46. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/langfuse/sink.py +0 -0
  47. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/models.py +0 -0
  48. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/reporting/__init__.py +0 -0
  49. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/reporting/console.py +0 -0
  50. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/reporting/json_report.py +0 -0
  51. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/runner.py +0 -0
  52. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/summary/__init__.py +0 -0
  53. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/summary/http_client.py +0 -0
  54. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/src/gooddata_eval/core/workspace.py +0 -0
  55. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/__init__.py +0 -0
  56. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/conftest.py +0 -0
  57. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
  58. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
  59. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/fixtures/sse_visualization_stream.txt +0 -0
  60. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_agentic_alert_skill.py +0 -0
  61. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_agentic_conversation.py +0 -0
  62. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_agentic_general_question.py +0 -0
  63. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_agentic_guardrail.py +0 -0
  64. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_agentic_metric_skill.py +0 -0
  65. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_agentic_search_tool.py +0 -0
  66. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_agentic_visualization.py +0 -0
  67. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_alert_skill_evaluator.py +0 -0
  68. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_cli.py +0 -0
  69. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_connection.py +0 -0
  70. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_deep_subset.py +0 -0
  71. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_langfuse_sink.py +0 -0
  72. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_langfuse_source.py +0 -0
  73. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_llm_judge.py +0 -0
  74. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_local_loader.py +0 -0
  75. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_metric_skill_evaluator.py +0 -0
  76. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_models.py +0 -0
  77. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_reporting.py +0 -0
  78. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_runner.py +0 -0
  79. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_scoring.py +0 -0
  80. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_search_tool_evaluator.py +0 -0
  81. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_summary_client.py +0 -0
  82. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_summary_evaluator.py +0 -0
  83. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_text_evaluators.py +0 -0
  84. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/tests/test_visualization_evaluator.py +0 -0
  85. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev4}/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.dev2
3
+ Version: 1.68.1.dev4
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.dev2
20
+ Requires-Dist: gooddata-sdk~=1.68.1.dev4
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.dev2"
4
+ version = "1.68.1.dev4"
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.dev2",
14
+ "gooddata-sdk~=1.68.1.dev4",
15
15
  "httpx>=0.27,<1.0",
16
16
  "orjson>=3.9.15,<4.0.0",
17
17
  "pydantic>=2.6,<3.0",
@@ -77,6 +77,8 @@ def _check_threshold(expected: CatalogMetricAlert, actual_args: dict) -> bool:
77
77
 
78
78
 
79
79
  def _check_trigger(expected: CatalogMetricAlert, actual_args: dict) -> bool:
80
+ if expected.operator == "ANOMALY":
81
+ return True
80
82
  exp_trigger = expected.trigger
81
83
  act_trigger = actual_args.get("trigger", actual_args.get("triggerMode", "ALWAYS"))
82
84
  if exp_trigger in _ALWAYS_TRIGGER_VALUES:
@@ -235,27 +237,42 @@ class AgenticAlertSummary:
235
237
  best: AlertRunResult
236
238
 
237
239
 
240
+ def _case_insensitive_get(d: dict, *keys: str) -> Any:
241
+ """Look up a value by key, preferring an exact match then a case-insensitive one."""
242
+ for k in keys:
243
+ if k in d:
244
+ return d[k]
245
+ lowered = {str(k).lower(): v for k, v in d.items()}
246
+ for k in keys:
247
+ if k.lower() in lowered:
248
+ return lowered[k.lower()]
249
+ return None
250
+
251
+
238
252
  def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
239
253
  """Parse expected_output dict into CatalogMetricAlert, accepting display-format or internal-format keys."""
240
- operator = expected.get("operator") or expected.get("Operator") or "GREATER_THAN"
241
- threshold = expected.get("threshold") or expected.get("Threshold")
242
- threshold_from = expected.get("threshold_from")
243
- threshold_to = expected.get("threshold_to")
244
- trigger = expected.get("trigger") or expected.get("Trigger") or "not specified"
245
-
246
- metric_id = expected.get("metric_id")
247
- if not metric_id and "Metric" in expected:
248
- m = re.search(r"\(([^)]+)\)\s*$", str(expected["Metric"]))
249
- if m:
250
- metric_id = m.group(1).strip()
251
-
252
- raw_recip = expected.get("recipients") or expected.get("Recipient(s)") or []
254
+ operator = _case_insensitive_get(expected, "operator") or "GREATER_THAN"
255
+ threshold = _case_insensitive_get(expected, "threshold")
256
+ threshold_from = _case_insensitive_get(expected, "threshold_from")
257
+ threshold_to = _case_insensitive_get(expected, "threshold_to")
258
+
259
+ trigger = _case_insensitive_get(expected, "trigger") or "not specified"
260
+ trigger = _TRIGGER_DISPLAY_TO_API.get(trigger, trigger)
261
+
262
+ metric_id = _case_insensitive_get(expected, "metric_id")
263
+ if not metric_id:
264
+ metric_disp = _case_insensitive_get(expected, "metric")
265
+ if metric_disp:
266
+ m = re.search(r"\(([^)]+)\)\s*$", str(metric_disp))
267
+ metric_id = m.group(1).strip() if m else None
268
+
269
+ raw_recip = _case_insensitive_get(expected, "recipients", "recipient(s)") or []
253
270
  if isinstance(raw_recip, str):
254
271
  recipients = [r.strip() for r in raw_recip.replace(";", ",").split(",") if r.strip()]
255
272
  else:
256
273
  recipients = list(raw_recip)
257
274
 
258
- filters = expected.get("filters") or expected.get("Time window/Filters")
275
+ filters = _case_insensitive_get(expected, "filters")
259
276
  if isinstance(filters, str) and any(kw in filters for kw in ("None", "All time")):
260
277
  filters = None
261
278
 
@@ -102,7 +102,7 @@ def _activated_skills(tool_call_events: list[ToolCallEvent]) -> list[str]:
102
102
  if tc.function_name != "set_skills":
103
103
  continue
104
104
  args = tc.parsed_arguments() or {}
105
- skills.extend(args.get("skills", []))
105
+ skills.extend(args.get("skill_names") or args.get("skills") or [])
106
106
  return list(set(skills))
107
107
 
108
108
 
@@ -14,15 +14,88 @@ protocol, not on this class.
14
14
  """
15
15
 
16
16
  import json
17
+ import logging
18
+ import os
19
+ import time
17
20
  from dataclasses import dataclass, field
18
- from typing import Any, Iterable
21
+ from typing import Any, Callable, Iterable, TypeVar
19
22
 
20
23
  import httpx
21
24
 
22
25
  from gooddata_eval.core.models import ChatResult, DatasetItem
23
26
 
27
+ _log = logging.getLogger(__name__)
28
+
24
29
  SSE_DATA_PREFIX = "data: "
25
30
 
31
+ _RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 502, 503, 504})
32
+ _METADATA_SYNC_MARKER = "METADATA_SYNC_IN_PROGRESS"
33
+
34
+
35
+ class ChatError(RuntimeError):
36
+ """Non-retryable error reported by the chat SSE stream."""
37
+
38
+ def __init__(self, message: str, *, status_code: int | None = None, detail: str | None = None) -> None:
39
+ super().__init__(message)
40
+ self.status_code = status_code
41
+ self.detail = detail
42
+
43
+
44
+ class TransientChatError(ChatError):
45
+ """Retryable transient error: gen-ai temporarily unavailable or still syncing metadata."""
46
+
47
+
48
+ def _int_env(name: str, default: int) -> int:
49
+ """Read an int from the environment, falling back to ``default`` when unset or blank."""
50
+ raw = os.getenv(name)
51
+ return int(raw) if raw else default
52
+
53
+
54
+ def _float_env(name: str, default: float) -> float:
55
+ """Read a float from the environment, falling back to ``default`` when unset or blank."""
56
+ raw = os.getenv(name)
57
+ return float(raw) if raw else default
58
+
59
+
60
+ # Retry budget. Defaults give a ~2 min worst-case cap per send (5/10/20/40/60s);
61
+ # overridable via env so CI can retune without cutting a new gooddata-eval release.
62
+ _MAX_RETRIES = _int_env("GOODDATA_EVAL_CHAT_MAX_RETRIES", 5)
63
+ _INITIAL_BACKOFF_S = _float_env("GOODDATA_EVAL_CHAT_INITIAL_BACKOFF_S", 5.0)
64
+ _BACKOFF_FACTOR = _float_env("GOODDATA_EVAL_CHAT_BACKOFF_FACTOR", 2.0)
65
+ _MAX_BACKOFF_S = _float_env("GOODDATA_EVAL_CHAT_MAX_BACKOFF_S", 60.0)
66
+
67
+ T = TypeVar("T")
68
+
69
+
70
+ def _is_retryable_exc(exc: Exception) -> bool:
71
+ if isinstance(exc, TransientChatError):
72
+ return True
73
+ if isinstance(exc, httpx.HTTPStatusError):
74
+ return exc.response.status_code in _RETRYABLE_STATUS_CODES
75
+ return False
76
+
77
+
78
+ def _retry_transient(operation: Callable[[], T], *, is_retryable: Callable[[Exception], bool]) -> T:
79
+ """Run ``operation``; retry retryable failures with bounded exponential backoff."""
80
+ delay = _INITIAL_BACKOFF_S
81
+ for attempt in range(_MAX_RETRIES + 1): # 0..N => N retries + 1 initial attempt
82
+ try:
83
+ return operation()
84
+ except Exception as exc: # noqa: PERF203 — retry loop: per-attempt try/except is intentional
85
+ if attempt == _MAX_RETRIES or not is_retryable(exc):
86
+ raise
87
+ sleep_s = min(delay, _MAX_BACKOFF_S)
88
+ _log.warning(
89
+ "Transient gen-ai error (attempt %d/%d): %s; retrying in %.0fs",
90
+ attempt + 1,
91
+ _MAX_RETRIES + 1,
92
+ exc,
93
+ sleep_s,
94
+ )
95
+ time.sleep(sleep_s)
96
+ delay *= _BACKOFF_FACTOR
97
+ raise AssertionError("unreachable") # loop either returns or raises
98
+
26
99
 
27
100
  @dataclass
28
101
  class _SseAccumulator:
@@ -114,12 +187,23 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
114
187
  if not line or line.startswith("event: ") or not line.startswith(SSE_DATA_PREFIX):
115
188
  continue
116
189
  data_str = line[len(SSE_DATA_PREFIX) :]
190
+ if _METADATA_SYNC_MARKER in data_str:
191
+ raise TransientChatError(
192
+ f"SSE transient error: {_METADATA_SYNC_MARKER}",
193
+ status_code=None,
194
+ detail=None,
195
+ )
117
196
  try:
118
197
  event_data = json.loads(data_str)
119
198
  except json.JSONDecodeError:
120
199
  continue
121
200
  if "statusCode" in event_data:
122
- raise RuntimeError(f"SSE error {event_data.get('statusCode')}: {event_data.get('detail')}")
201
+ code = event_data.get("statusCode")
202
+ detail = event_data.get("detail")
203
+ message = f"SSE error {code}: {detail}"
204
+ if code in _RETRYABLE_STATUS_CODES:
205
+ raise TransientChatError(message, status_code=code, detail=detail)
206
+ raise ChatError(message, status_code=code, detail=detail)
123
207
  item = event_data.get("item")
124
208
  if not item:
125
209
  continue
@@ -149,12 +233,17 @@ class ChatClient:
149
233
  self._client = httpx.Client(timeout=timeout)
150
234
 
151
235
  def create_conversation(self) -> str:
152
- resp = self._client.post(self._base, headers={**self._auth, "Content-Type": "application/json"})
153
- resp.raise_for_status()
154
- body = resp.json()
155
- if "conversationId" not in body:
156
- raise ValueError(f"GoodData /chat/conversations response missing 'conversationId': {body}")
157
- return body["conversationId"]
236
+ def _do() -> str:
237
+ resp = self._client.post(self._base, headers={**self._auth, "Content-Type": "application/json"})
238
+ resp.raise_for_status()
239
+ body = resp.json()
240
+ if "conversationId" not in body:
241
+ raise ValueError(f"GoodData /chat/conversations response missing 'conversationId': {body}")
242
+ return body["conversationId"]
243
+
244
+ # NOTE: retrying create is not idempotent — a created-then-503 can leak an
245
+ # orphaned (ephemeral) conversation. Acceptable for eval; do not reuse blindly.
246
+ return _retry_transient(_do, is_retryable=_is_retryable_exc)
158
247
 
159
248
  def delete_conversation(self, conversation_id: str) -> None:
160
249
  try:
@@ -166,9 +255,13 @@ class ChatClient:
166
255
  url = f"{self._base}/{conversation_id}/messages"
167
256
  headers = {**self._auth, "Accept": "text/event-stream", "Content-Type": "application/json"}
168
257
  body = {"item": {"role": "user", "content": {"type": "text", "text": question}}}
169
- with self._client.stream("POST", url, json=body, headers=headers) as resp:
170
- resp.raise_for_status()
171
- return parse_sse_lines(resp.iter_lines())
258
+
259
+ def _do() -> ChatResult:
260
+ with self._client.stream("POST", url, json=body, headers=headers) as resp:
261
+ resp.raise_for_status()
262
+ return parse_sse_lines(resp.iter_lines())
263
+
264
+ return _retry_transient(_do, is_retryable=_is_retryable_exc)
172
265
 
173
266
  def ask(self, item: DatasetItem) -> ChatResult:
174
267
  """Run one single-turn conversation: create, send, parse, clean up."""
@@ -71,7 +71,9 @@ def validate_cross_references(viz: CreatedVisualization) -> tuple[bool, list[str
71
71
  continue
72
72
  using_val = filter_dict.get("using", "")
73
73
  using_uri = _resolve_alias_to_uri(using_val, fields)
74
- if not using_uri.startswith(("metric/", "fact/")):
74
+ field_def = fields.get(using_val)
75
+ is_adhoc_agg = isinstance(field_def, AacQueryField) and bool(field_def.aggregation)
76
+ if not using_uri.startswith(("metric/", "fact/")) and not is_adhoc_agg:
75
77
  errors.append(
76
78
  f"ranking filter '{filter_key}': using='{using_val}' "
77
79
  f"resolves to '{using_uri}' — expected a metric/ or fact/ URI"
@@ -0,0 +1,224 @@
1
+ # (C) 2026 GoodData Corporation
2
+ import json
3
+
4
+ import httpx
5
+ import pytest
6
+ from gooddata_eval.core.chat import sse_client as sse_mod
7
+ from gooddata_eval.core.chat.sse_client import ChatClient, ChatError, TransientChatError, parse_sse_lines
8
+
9
+
10
+ def test_parse_sse_lines_collects_text_and_visualization(fixtures_dir):
11
+ lines = (fixtures_dir / "sse_visualization_stream.txt").read_text().splitlines()
12
+ result = parse_sse_lines(lines)
13
+ assert result.text_response == "Here is your chart"
14
+ assert result.created_visualizations is not None
15
+ assert result.created_visualizations.objects[0].id == "v1"
16
+ assert result.created_visualizations.objects[0].type == "column_chart"
17
+
18
+
19
+ def test_parse_sse_lines_raises_on_error_event():
20
+ lines = ['data: {"statusCode": 500, "detail": "boom"}']
21
+ with pytest.raises(RuntimeError, match="SSE error 500"):
22
+ parse_sse_lines(lines)
23
+
24
+
25
+ def test_parse_sse_lines_ignores_non_data_lines():
26
+ result = parse_sse_lines(["event: ping", "", ": comment"])
27
+ assert result.text_response is None
28
+ assert result.created_visualizations is None
29
+
30
+
31
+ def test_parse_sse_lines_falls_back_to_adhoc_viz_when_multipart_viz_is_null():
32
+ """Visualization from create_adhoc_visualization args used when multipart viz is null."""
33
+ viz_def = {
34
+ "id": "total_sales_by_month",
35
+ "type": "line_chart",
36
+ "query": {"fields": {"m": {"using": "metric/total_sales"}}, "filter_by": {}},
37
+ "metrics": ["m"],
38
+ "view_by": [],
39
+ }
40
+ lines = [
41
+ # agent calls create_adhoc_visualization — stash the viz
42
+ f'data: {{"item": {{"role": "assistant", "content": {{"type": "toolCall", "callId": "c1", "name": "create_adhoc_visualization", "arguments": {{"visualization": {json.dumps(viz_def)}}}}}}}}}',
43
+ # data source fails
44
+ 'data: {"item": {"role": "tool", "content": {"type": "toolResult", "callId": "c1", "result": "{"status": "error", "message": "Data source does not exist"}"}}}',
45
+ # final multipart — visualization is null
46
+ 'data: {"item": {"role": "assistant", "content": {"type": "multipart", "parts": [{"type": "text", "text": "Could not create"}, {"type": "visualization", "visualization": null}]}}}',
47
+ ]
48
+ result = parse_sse_lines(lines)
49
+ assert result.created_visualizations is not None
50
+ assert result.created_visualizations.objects[0].id == "total_sales_by_month"
51
+ assert result.created_visualizations.objects[0].type == "line_chart"
52
+
53
+
54
+ def test_parse_sse_lines_counts_reasoning_steps():
55
+ lines = [
56
+ 'data: {"item": {"role": "assistant", "content": {"type": "reasoning", "summary": "step one"}}}',
57
+ 'data: {"item": {"role": "assistant", "content": {"type": "reasoning", "summary": "step two"}}}',
58
+ 'data: {"item": {"role": "assistant", "content": {"type": "text", "text": "Done"}}}',
59
+ ]
60
+ result = parse_sse_lines(lines)
61
+ assert result.reasoning_step_count == 2
62
+ assert result.text_response == "Done"
63
+
64
+
65
+ def test_parse_sse_lines_prefers_multipart_viz_over_adhoc_fallback():
66
+ """Real multipart visualization takes priority over adhoc tool call stash."""
67
+
68
+ adhoc_viz = {"id": "adhoc", "type": "table", "query": {"fields": {}, "filter_by": {}}}
69
+ real_viz = {
70
+ "id": "real",
71
+ "type": "column_chart",
72
+ "query": {"fields": {"m": {"using": "metric/rev"}}, "filter_by": {}},
73
+ "metrics": ["m"],
74
+ "view_by": [],
75
+ }
76
+ lines = [
77
+ f'data: {{"item": {{"role": "assistant", "content": {{"type": "toolCall", "callId": "c1", "name": "create_adhoc_visualization", "arguments": {{"visualization": {json.dumps(adhoc_viz)}}}}}}}}}',
78
+ f'data: {{"item": {{"role": "assistant", "content": {{"type": "multipart", "parts": [{{"type": "visualization", "visualization": {json.dumps(real_viz)}}}]}}}}}}',
79
+ ]
80
+ result = parse_sse_lines(lines)
81
+ assert result.created_visualizations.objects[0].id == "real"
82
+
83
+
84
+ @pytest.mark.parametrize("code", [429, 502, 503, 504])
85
+ def test_parse_sse_lines_transient_status_codes(code):
86
+ with pytest.raises(TransientChatError) as ei:
87
+ parse_sse_lines([f'data: {{"statusCode": {code}, "detail": null}}'])
88
+ assert ei.value.status_code == code
89
+
90
+
91
+ def test_parse_sse_lines_metadata_sync_is_transient():
92
+ with pytest.raises(TransientChatError):
93
+ parse_sse_lines(['data: {"reasonCode": "METADATA_SYNC_IN_PROGRESS"}'])
94
+
95
+
96
+ def test_parse_sse_lines_metadata_sync_marker_in_malformed_json_is_transient():
97
+ # marker present but the data payload is not valid JSON -> still transient, not swallowed
98
+ with pytest.raises(TransientChatError):
99
+ parse_sse_lines(["data: {bad json METADATA_SYNC_IN_PROGRESS"])
100
+
101
+
102
+ def test_parse_sse_lines_non_retryable_status_is_chat_error_not_transient():
103
+ with pytest.raises(ChatError) as ei:
104
+ parse_sse_lines(['data: {"statusCode": 400, "detail": "bad"}'])
105
+ assert not isinstance(ei.value, TransientChatError)
106
+ assert ei.value.status_code == 400
107
+
108
+
109
+ def _client_with_handler(handler):
110
+ client = ChatClient(host="https://example.invalid", token="t", workspace_id="w")
111
+ client._client = httpx.Client(transport=httpx.MockTransport(handler))
112
+ return client
113
+
114
+
115
+ _TRANSIENT_SSE = b'data: {"statusCode": 503, "detail": null}\n'
116
+ _NONRETRY_SSE = b'data: {"statusCode": 400, "detail": "bad"}\n'
117
+ _OK_SSE = b'data: {"item": {"role": "assistant", "content": {"type": "text", "text": "ok"}}}\n'
118
+
119
+
120
+ def test_send_message_retries_transient_then_succeeds(monkeypatch):
121
+ sleeps = []
122
+ monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
123
+ calls = {"n": 0}
124
+
125
+ def handler(request):
126
+ calls["n"] += 1
127
+ return httpx.Response(200, content=_TRANSIENT_SSE if calls["n"] < 3 else _OK_SSE)
128
+
129
+ client = _client_with_handler(handler)
130
+ result = client.send_message("conv", "q")
131
+ assert result.text_response == "ok"
132
+ assert calls["n"] == 3
133
+ assert sleeps == [5, 10]
134
+
135
+
136
+ def test_send_message_backoff_schedule_then_raises(monkeypatch):
137
+ sleeps = []
138
+ monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
139
+ calls = {"n": 0}
140
+
141
+ def handler(request):
142
+ calls["n"] += 1
143
+ return httpx.Response(200, content=_TRANSIENT_SSE)
144
+
145
+ client = _client_with_handler(handler)
146
+ with pytest.raises(TransientChatError):
147
+ client.send_message("conv", "q")
148
+ assert calls["n"] == 6 # 1 initial + 5 retries
149
+ assert sleeps == [5, 10, 20, 40, 60]
150
+
151
+
152
+ def test_send_message_does_not_retry_non_transient(monkeypatch):
153
+ sleeps = []
154
+ monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
155
+ calls = {"n": 0}
156
+
157
+ def handler(request):
158
+ calls["n"] += 1
159
+ return httpx.Response(200, content=_NONRETRY_SSE)
160
+
161
+ client = _client_with_handler(handler)
162
+ with pytest.raises(ChatError) as ei:
163
+ client.send_message("conv", "q")
164
+ assert not isinstance(ei.value, TransientChatError)
165
+ assert calls["n"] == 1
166
+ assert sleeps == []
167
+
168
+
169
+ def test_create_conversation_retries_then_succeeds(monkeypatch):
170
+ sleeps = []
171
+ monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
172
+ calls = {"n": 0}
173
+
174
+ def handler(request):
175
+ calls["n"] += 1
176
+ if calls["n"] < 3:
177
+ return httpx.Response(503)
178
+ return httpx.Response(200, json={"conversationId": "abc"})
179
+
180
+ client = _client_with_handler(handler)
181
+ assert client.create_conversation() == "abc"
182
+ assert calls["n"] == 3
183
+ assert sleeps == [5, 10]
184
+
185
+
186
+ def test_create_conversation_does_not_retry_4xx(monkeypatch):
187
+ sleeps = []
188
+ monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
189
+ calls = {"n": 0}
190
+
191
+ def handler(request):
192
+ calls["n"] += 1
193
+ return httpx.Response(400)
194
+
195
+ client = _client_with_handler(handler)
196
+ with pytest.raises(httpx.HTTPStatusError):
197
+ client.create_conversation()
198
+ assert calls["n"] == 1
199
+ assert sleeps == []
200
+
201
+
202
+ def test_int_env_uses_default_when_unset(monkeypatch):
203
+ monkeypatch.delenv("GD_TEST_INT", raising=False)
204
+ assert sse_mod._int_env("GD_TEST_INT", 5) == 5
205
+
206
+
207
+ def test_int_env_uses_default_when_blank(monkeypatch):
208
+ monkeypatch.setenv("GD_TEST_INT", "")
209
+ assert sse_mod._int_env("GD_TEST_INT", 5) == 5
210
+
211
+
212
+ def test_int_env_reads_override(monkeypatch):
213
+ monkeypatch.setenv("GD_TEST_INT", "2")
214
+ assert sse_mod._int_env("GD_TEST_INT", 5) == 2
215
+
216
+
217
+ def test_float_env_uses_default_when_unset(monkeypatch):
218
+ monkeypatch.delenv("GD_TEST_FLOAT", raising=False)
219
+ assert sse_mod._float_env("GD_TEST_FLOAT", 5.0) == 5.0
220
+
221
+
222
+ def test_float_env_reads_override(monkeypatch):
223
+ monkeypatch.setenv("GD_TEST_FLOAT", "1.5")
224
+ assert sse_mod._float_env("GD_TEST_FLOAT", 5.0) == 1.5
@@ -1,79 +0,0 @@
1
- # (C) 2026 GoodData Corporation
2
- import json
3
-
4
- import pytest
5
- from gooddata_eval.core.chat.sse_client import parse_sse_lines
6
-
7
-
8
- def test_parse_sse_lines_collects_text_and_visualization(fixtures_dir):
9
- lines = (fixtures_dir / "sse_visualization_stream.txt").read_text().splitlines()
10
- result = parse_sse_lines(lines)
11
- assert result.text_response == "Here is your chart"
12
- assert result.created_visualizations is not None
13
- assert result.created_visualizations.objects[0].id == "v1"
14
- assert result.created_visualizations.objects[0].type == "column_chart"
15
-
16
-
17
- def test_parse_sse_lines_raises_on_error_event():
18
- lines = ['data: {"statusCode": 500, "detail": "boom"}']
19
- with pytest.raises(RuntimeError, match="SSE error 500"):
20
- parse_sse_lines(lines)
21
-
22
-
23
- def test_parse_sse_lines_ignores_non_data_lines():
24
- result = parse_sse_lines(["event: ping", "", ": comment"])
25
- assert result.text_response is None
26
- assert result.created_visualizations is None
27
-
28
-
29
- def test_parse_sse_lines_falls_back_to_adhoc_viz_when_multipart_viz_is_null():
30
- """Visualization from create_adhoc_visualization args used when multipart viz is null."""
31
- viz_def = {
32
- "id": "total_sales_by_month",
33
- "type": "line_chart",
34
- "query": {"fields": {"m": {"using": "metric/total_sales"}}, "filter_by": {}},
35
- "metrics": ["m"],
36
- "view_by": [],
37
- }
38
- lines = [
39
- # agent calls create_adhoc_visualization — stash the viz
40
- f'data: {{"item": {{"role": "assistant", "content": {{"type": "toolCall", "callId": "c1", "name": "create_adhoc_visualization", "arguments": {{"visualization": {json.dumps(viz_def)}}}}}}}}}',
41
- # data source fails
42
- 'data: {"item": {"role": "tool", "content": {"type": "toolResult", "callId": "c1", "result": "{"status": "error", "message": "Data source does not exist"}"}}}',
43
- # final multipart — visualization is null
44
- 'data: {"item": {"role": "assistant", "content": {"type": "multipart", "parts": [{"type": "text", "text": "Could not create"}, {"type": "visualization", "visualization": null}]}}}',
45
- ]
46
- result = parse_sse_lines(lines)
47
- assert result.created_visualizations is not None
48
- assert result.created_visualizations.objects[0].id == "total_sales_by_month"
49
- assert result.created_visualizations.objects[0].type == "line_chart"
50
-
51
-
52
- def test_parse_sse_lines_counts_reasoning_steps():
53
- lines = [
54
- 'data: {"item": {"role": "assistant", "content": {"type": "reasoning", "summary": "step one"}}}',
55
- 'data: {"item": {"role": "assistant", "content": {"type": "reasoning", "summary": "step two"}}}',
56
- 'data: {"item": {"role": "assistant", "content": {"type": "text", "text": "Done"}}}',
57
- ]
58
- result = parse_sse_lines(lines)
59
- assert result.reasoning_step_count == 2
60
- assert result.text_response == "Done"
61
-
62
-
63
- def test_parse_sse_lines_prefers_multipart_viz_over_adhoc_fallback():
64
- """Real multipart visualization takes priority over adhoc tool call stash."""
65
-
66
- adhoc_viz = {"id": "adhoc", "type": "table", "query": {"fields": {}, "filter_by": {}}}
67
- real_viz = {
68
- "id": "real",
69
- "type": "column_chart",
70
- "query": {"fields": {"m": {"using": "metric/rev"}}, "filter_by": {}},
71
- "metrics": ["m"],
72
- "view_by": [],
73
- }
74
- lines = [
75
- f'data: {{"item": {{"role": "assistant", "content": {{"type": "toolCall", "callId": "c1", "name": "create_adhoc_visualization", "arguments": {{"visualization": {json.dumps(adhoc_viz)}}}}}}}}}',
76
- f'data: {{"item": {{"role": "assistant", "content": {{"type": "multipart", "parts": [{{"type": "visualization", "visualization": {json.dumps(real_viz)}}}]}}}}}}',
77
- ]
78
- result = parse_sse_lines(lines)
79
- assert result.created_visualizations.objects[0].id == "real"