gooddata-eval 1.68.1.dev2__py3-none-any.whl → 1.68.1.dev3__py3-none-any.whl

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.
@@ -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."""
@@ -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
@@ -21,7 +21,7 @@ gooddata_eval/core/agentic/metric_skill.py,sha256=jFqVsxNEx-7IHa4v8aNLkDzD9OogDs
21
21
  gooddata_eval/core/agentic/search_tool.py,sha256=EiWuzwdWUTY8H6wLST1rBB-fvc4meMazLKhiy0nl9K0,7634
22
22
  gooddata_eval/core/agentic/visualization.py,sha256=ScSfx4-4lwHswqF_0ugOrIFu5d4tlAQf6CxaFVV9_mE,16743
23
23
  gooddata_eval/core/chat/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
24
- gooddata_eval/core/chat/sse_client.py,sha256=LVjThACrLQFXoeRo_HiUMAQPx1ixEQUXbw1q0C6_oWk,7573
24
+ gooddata_eval/core/chat/sse_client.py,sha256=1FVi2HWf2-uHMro7uWSiTiQTMEbh6WhVvQW8flnxymY,11205
25
25
  gooddata_eval/core/dataset/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
26
26
  gooddata_eval/core/dataset/langfuse_source.py,sha256=R2KzNx6LxAUtDZwHL5_UW5CJ6q-QnQGW7AhLZKBTDKU,5589
27
27
  gooddata_eval/core/dataset/local.py,sha256=qoFFtRx_bNwlT1U37o8CKkSPwCad3JpmHPcNRuGSbjE,1233
@@ -44,8 +44,8 @@ gooddata_eval/core/reporting/console.py,sha256=kCHt4sCSaSRh0ZWqaSEJOl6_Jgb6uiouS
44
44
  gooddata_eval/core/reporting/json_report.py,sha256=NIA_PvnZ0PbJnXz_zZauxc1gGnzqlLD_ouVqWeNmjtw,2837
45
45
  gooddata_eval/core/summary/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
46
46
  gooddata_eval/core/summary/http_client.py,sha256=dPArJ6zoCu1W9xmYXFA1WMDNsSOkhn3gaCpYgCUp3gk,2174
47
- gooddata_eval-1.68.1.dev2.dist-info/METADATA,sha256=UJ6kD_VVKrTS38_cgQFLH_7Dc5YCjBClr0KbQ13jePU,9513
48
- gooddata_eval-1.68.1.dev2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
49
- gooddata_eval-1.68.1.dev2.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
50
- gooddata_eval-1.68.1.dev2.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
51
- gooddata_eval-1.68.1.dev2.dist-info/RECORD,,
47
+ gooddata_eval-1.68.1.dev3.dist-info/METADATA,sha256=ZGte7olPAbOw-BM69NnUhCskaAP4lSQRwwFTnapqJJM,9513
48
+ gooddata_eval-1.68.1.dev3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
49
+ gooddata_eval-1.68.1.dev3.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
50
+ gooddata_eval-1.68.1.dev3.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
51
+ gooddata_eval-1.68.1.dev3.dist-info/RECORD,,