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.
- gooddata_eval/core/agentic/alert_skill.py +31 -14
- gooddata_eval/core/agentic/conversation.py +1 -1
- gooddata_eval/core/chat/sse_client.py +104 -11
- gooddata_eval/core/scoring.py +3 -1
- {gooddata_eval-1.68.1.dev2.dist-info → gooddata_eval-1.68.1.dev4.dist-info}/METADATA +2 -2
- {gooddata_eval-1.68.1.dev2.dist-info → gooddata_eval-1.68.1.dev4.dist-info}/RECORD +9 -9
- {gooddata_eval-1.68.1.dev2.dist-info → gooddata_eval-1.68.1.dev4.dist-info}/WHEEL +0 -0
- {gooddata_eval-1.68.1.dev2.dist-info → gooddata_eval-1.68.1.dev4.dist-info}/entry_points.txt +0 -0
- {gooddata_eval-1.68.1.dev2.dist-info → gooddata_eval-1.68.1.dev4.dist-info}/licenses/LICENSE.txt +0 -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
|
|
241
|
-
threshold = expected
|
|
242
|
-
threshold_from = expected
|
|
243
|
-
threshold_to = expected
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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."""
|
gooddata_eval/core/scoring.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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=
|
|
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=
|
|
17
|
-
gooddata_eval/core/agentic/conversation.py,sha256=
|
|
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=
|
|
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.
|
|
48
|
-
gooddata_eval-1.68.1.
|
|
49
|
-
gooddata_eval-1.68.1.
|
|
50
|
-
gooddata_eval-1.68.1.
|
|
51
|
-
gooddata_eval-1.68.1.
|
|
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,,
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2.dist-info → gooddata_eval-1.68.1.dev4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{gooddata_eval-1.68.1.dev2.dist-info → gooddata_eval-1.68.1.dev4.dist-info}/licenses/LICENSE.txt
RENAMED
|
File without changes
|