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.
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/PKG-INFO +2 -2
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/pyproject.toml +2 -2
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/chat/sse_client.py +104 -11
- gooddata_eval-1.68.1.dev3/tests/test_sse_client.py +224 -0
- gooddata_eval-1.68.1.dev2/tests/test_sse_client.py +0 -79
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/.gitignore +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/LICENSE.txt +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/Makefile +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/README.md +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/_version.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/cli/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/cli/agentic_runner.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/cli/main.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/_langfuse.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/alert_skill.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/conversation.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/general_question.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/guardrail.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/metric_skill.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/search_tool.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/visualization.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/chat/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/config.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/connection.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/dataset/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/dataset/langfuse_source.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/dataset/local.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/alert_skill.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/base.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/search_tool.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/summary.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/langfuse/sink.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/models.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/reporting/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/reporting/console.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/reporting/json_report.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/runner.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/scoring.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/summary/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/summary/http_client.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/workspace.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/__init__.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/conftest.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/fixtures/sse_visualization_stream.txt +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_alert_skill.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_conversation.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_general_question.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_guardrail.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_metric_skill.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_search_tool.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_visualization.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_alert_skill_evaluator.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_cli.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_connection.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_deep_subset.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_langfuse_sink.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_langfuse_source.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_llm_judge.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_local_loader.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_metric_skill_evaluator.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_models.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_reporting.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_runner.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_scoring.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_search_tool_evaluator.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_summary_client.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_summary_evaluator.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_text_evaluators.py +0 -0
- {gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_visualization_evaluator.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/chat/sse_client.py
RENAMED
|
@@ -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."""
|
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/cli/agentic_runner.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/__init__.py
RENAMED
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/_catalog.py
RENAMED
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/_langfuse.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/agentic/guardrail.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/chat/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/connection.py
RENAMED
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/dataset/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/dataset/local.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/evaluators/summary.py
RENAMED
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/langfuse/__init__.py
RENAMED
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/langfuse/sink.py
RENAMED
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/reporting/__init__.py
RENAMED
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/reporting/console.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/src/gooddata_eval/core/summary/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/fixtures/sse_visualization_stream.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_agentic_general_question.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_metric_skill_evaluator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gooddata_eval-1.68.1.dev2 → gooddata_eval-1.68.1.dev3}/tests/test_visualization_evaluator.py
RENAMED
|
File without changes
|
|
File without changes
|