gooddata-eval 1.68.1.dev2__py3-none-any.whl → 1.68.1.dev4__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.
@@ -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"
@@ -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
@@ -8,20 +8,20 @@ gooddata_eval/core/config.py,sha256=c0lgsILU70VJejhJGSZJmyne8i2uNXDiZEXRRKJEOgs,
8
8
  gooddata_eval/core/connection.py,sha256=mh-Do5-Ti0-DXXikfCIH10eTTUbKuZUAS_sLoPNDJ5I,1229
9
9
  gooddata_eval/core/models.py,sha256=g7bi7OGxnADGidVQmJLNuvehddSoW_-4e4YhEFSSkKs,3989
10
10
  gooddata_eval/core/runner.py,sha256=vRo2SwRKRjkSNCp6tse4tSGRWC3zBPFdjAnM5rMg05M,8064
11
- gooddata_eval/core/scoring.py,sha256=eGn72OuPc_Hnm6oNmoWkr-HzFOJP06Yay7BRy6BEN9Q,5852
11
+ gooddata_eval/core/scoring.py,sha256=7ku48WEXtZl8EhowKikMiBmKfH7BDoZxHVvEOY8h3Rk,6007
12
12
  gooddata_eval/core/workspace.py,sha256=S8exiy5gpDtcRtT8-i2fIRALF34Pbo0J46xm8X_ldus,11846
13
13
  gooddata_eval/core/agentic/__init__.py,sha256=nPjJ9sbnJVvsAlo9QMQlnPFgLNTZRzy9-0IKlnRcU8Q,2671
14
14
  gooddata_eval/core/agentic/_catalog.py,sha256=eCaeyvLF-FCN0J3UnXSHaKtufU6HvrM2mxnmgyXsY1U,2085
15
15
  gooddata_eval/core/agentic/_langfuse.py,sha256=zJiZTz5xloWQBunwxXzdOY3NrA0S4QPFc6d3iB-cgkE,13432
16
- gooddata_eval/core/agentic/alert_skill.py,sha256=dOHEnE3823P4tx0FgoPcdPcu-i-qpju20LZFR6N8pBU,19138
17
- gooddata_eval/core/agentic/conversation.py,sha256=Ynq9Rg6vVXjpHRe76r34-KPISp-N9TZxm9rk2cNcibg,17513
16
+ gooddata_eval/core/agentic/alert_skill.py,sha256=h5i-GVJOAUzgMhIF5yGKIz7JL3f6QXKZAKz6gcN7vpY,19702
17
+ gooddata_eval/core/agentic/conversation.py,sha256=tTpN1AySPGDbYcpwY2G0J38lRYiU5sqvbdDJBOyPQHc,17542
18
18
  gooddata_eval/core/agentic/general_question.py,sha256=piPY5XOMjH4Xwl2qN0D5KWBL1RJjYaP63oX1eXp-7yU,7978
19
19
  gooddata_eval/core/agentic/guardrail.py,sha256=-vZg-jeL2LYKGaLesuu7Yg1AIEn-9bfDK130EMdw_7s,7865
20
20
  gooddata_eval/core/agentic/metric_skill.py,sha256=jFqVsxNEx-7IHa4v8aNLkDzD9OogDsgV4O3UJkiVDFw,11388
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.dev4.dist-info/METADATA,sha256=7eLGgge0gSIxqwAWeU3kPoPa4VjLC-WH0tQne-KWbS8,9513
48
+ gooddata_eval-1.68.1.dev4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
49
+ gooddata_eval-1.68.1.dev4.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
50
+ gooddata_eval-1.68.1.dev4.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
51
+ gooddata_eval-1.68.1.dev4.dist-info/RECORD,,