gooddata-eval 1.68.1.dev2__tar.gz → 1.68.1.dev3__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.dev3}/PKG-INFO +2 -2
  2. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/pyproject.toml +2 -2
  3. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/chat/sse_client.py +104 -11
  4. gooddata_eval-1.68.1.dev3/tests/test_sse_client.py +224 -0
  5. gooddata_eval-1.68.1.dev2/tests/test_sse_client.py +0 -79
  6. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/.gitignore +0 -0
  7. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/LICENSE.txt +0 -0
  8. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/Makefile +0 -0
  9. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/README.md +0 -0
  10. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/__init__.py +0 -0
  11. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/_version.py +0 -0
  12. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/cli/__init__.py +0 -0
  13. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/cli/agentic_runner.py +0 -0
  14. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/cli/main.py +0 -0
  15. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/__init__.py +0 -0
  16. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/__init__.py +0 -0
  17. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
  18. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/_langfuse.py +0 -0
  19. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/alert_skill.py +0 -0
  20. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/conversation.py +0 -0
  21. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/general_question.py +0 -0
  22. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/guardrail.py +0 -0
  23. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/metric_skill.py +0 -0
  24. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/search_tool.py +0 -0
  25. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/visualization.py +0 -0
  26. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/chat/__init__.py +0 -0
  27. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/config.py +0 -0
  28. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/connection.py +0 -0
  29. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/dataset/__init__.py +0 -0
  30. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/dataset/langfuse_source.py +0 -0
  31. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/dataset/local.py +0 -0
  32. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/__init__.py +0 -0
  33. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
  34. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
  35. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
  36. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/alert_skill.py +0 -0
  37. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/base.py +0 -0
  38. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
  39. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
  40. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
  41. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/search_tool.py +0 -0
  42. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/summary.py +0 -0
  43. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
  44. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
  45. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/langfuse/sink.py +0 -0
  46. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/models.py +0 -0
  47. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/reporting/__init__.py +0 -0
  48. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/reporting/console.py +0 -0
  49. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/reporting/json_report.py +0 -0
  50. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/runner.py +0 -0
  51. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/scoring.py +0 -0
  52. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/summary/__init__.py +0 -0
  53. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/summary/http_client.py +0 -0
  54. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/workspace.py +0 -0
  55. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/__init__.py +0 -0
  56. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/conftest.py +0 -0
  57. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
  58. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
  59. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/fixtures/sse_visualization_stream.txt +0 -0
  60. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_alert_skill.py +0 -0
  61. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_conversation.py +0 -0
  62. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_general_question.py +0 -0
  63. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_guardrail.py +0 -0
  64. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_metric_skill.py +0 -0
  65. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_search_tool.py +0 -0
  66. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_visualization.py +0 -0
  67. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_alert_skill_evaluator.py +0 -0
  68. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_cli.py +0 -0
  69. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_connection.py +0 -0
  70. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_deep_subset.py +0 -0
  71. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_langfuse_sink.py +0 -0
  72. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_langfuse_source.py +0 -0
  73. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_llm_judge.py +0 -0
  74. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_local_loader.py +0 -0
  75. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_metric_skill_evaluator.py +0 -0
  76. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_models.py +0 -0
  77. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_reporting.py +0 -0
  78. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_runner.py +0 -0
  79. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_scoring.py +0 -0
  80. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_search_tool_evaluator.py +0 -0
  81. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_summary_client.py +0 -0
  82. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_summary_evaluator.py +0 -0
  83. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_text_evaluators.py +0 -0
  84. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_visualization_evaluator.py +0 -0
  85. {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/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.dev3
4
4
  Summary: Evaluate the GoodData AI agent against your own questions and models.
5
5
  Project-URL: Source, https://github.com/gooddata/gooddata-python-sdk
6
6
  Author-email: GoodData <support@gooddata.com>
@@ -17,7 +17,7 @@ Classifier: Topic :: Scientific/Engineering
17
17
  Classifier: Topic :: Software Development
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
- Requires-Dist: gooddata-sdk~=1.68.1.dev2
20
+ Requires-Dist: gooddata-sdk~=1.68.1.dev3
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.dev3"
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.dev3",
15
15
  "httpx>=0.27,<1.0",
16
16
  "orjson>=3.9.15,<4.0.0",
17
17
  "pydantic>=2.6,<3.0",
@@ -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."""
@@ -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"