Skip to content

Commit 3236e41

Browse files
Merge pull request #1657 from gooddata/zmu/gdai-1830-eval-dedup-agentic-vis
refactor(eval): move all agentic evaluation logic into gooddata_eval SDK
2 parents 5da6dcf + 7f59137 commit 3236e41

29 files changed

Lines changed: 4246 additions & 22 deletions

‎packages/gooddata-eval/pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ gd-eval = "gooddata_eval.cli.main:main"
3939
Source = "https://github.com/gooddata/gooddata-python-sdk"
4040

4141
[dependency-groups]
42+
dev = [
43+
"pytest>=8.3.5",
44+
]
4245
test = [
4346
"pytest~=8.3.4",
4447
"pytest-cov~=6.0.0",
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
# (C) 2026 GoodData Corporation. All rights reserved.
2+
"""Agentic evaluation runner for gd-eval CLI — handles multi-turn agentic test kinds."""
3+
4+
from __future__ import annotations
5+
6+
import time
7+
from typing import Any, TypedDict
8+
9+
from gooddata_eval.core.agentic._langfuse import HttpxLangfuseClient, make_langfuse_client
10+
from gooddata_eval.core.agentic.alert_skill import evaluate_agentic_alert_skill
11+
from gooddata_eval.core.agentic.conversation import ConversationFixture, evaluate_agentic_conversation
12+
from gooddata_eval.core.agentic.general_question import evaluate_agentic_general_question
13+
from gooddata_eval.core.agentic.guardrail import evaluate_agentic_guardrail
14+
from gooddata_eval.core.agentic.metric_skill import evaluate_agentic_metric_skill
15+
from gooddata_eval.core.agentic.search_tool import evaluate_agentic_search_tool
16+
from gooddata_eval.core.agentic.visualization import evaluate_agentic_visualization
17+
from gooddata_eval.core.models import CreatedVisualization, DatasetItem
18+
from gooddata_eval.core.runner import EvalReport, ItemReport
19+
20+
_LfKw = TypedDict(
21+
"_LfKw",
22+
{
23+
"langfuse": Any,
24+
"dataset_item_id": str,
25+
"dataset_name": str,
26+
"run_timestamp": str,
27+
"model_version_override": str | None,
28+
},
29+
total=False,
30+
)
31+
32+
AGENTIC_TEST_KINDS = frozenset(
33+
{
34+
"vis_agentic", # production: expected_output.visualization (single/multi CreatedVisualization)
35+
"agentic_visualization", # experimental: expected_output.expected_outputs (multi-candidate)
36+
"agentic_metric_skill",
37+
"agentic_alert_skill",
38+
"agentic_search",
39+
"agentic_general_question",
40+
"agentic_guardrail",
41+
"agentic_conversation",
42+
}
43+
)
44+
45+
46+
def _parse_visualization_expected(expected_output: Any) -> list[CreatedVisualization]:
47+
"""Parse expected_output into a list of CreatedVisualization candidates.
48+
49+
Accepts:
50+
{"expected_outputs": [{"visualization": {...}}, ...]} <- agentic fixture format
51+
{"visualization": {...}} or {"visualization": [{...}]} <- single/multi candidate
52+
[{"visualization": {...}}, ...] <- bare list
53+
"""
54+
if isinstance(expected_output, dict):
55+
raw_list = expected_output.get("expected_outputs")
56+
if raw_list is not None:
57+
return [
58+
CreatedVisualization.model_validate(v.get("visualization", v) if isinstance(v, dict) else v)
59+
for v in raw_list
60+
]
61+
raw_viz = expected_output.get("visualization")
62+
if raw_viz is not None:
63+
if isinstance(raw_viz, list):
64+
return [CreatedVisualization.model_validate(v) for v in raw_viz]
65+
return [CreatedVisualization.model_validate(raw_viz)]
66+
if isinstance(expected_output, list):
67+
return [
68+
CreatedVisualization.model_validate(v.get("visualization", v) if isinstance(v, dict) else v)
69+
for v in expected_output
70+
]
71+
raise ValueError(
72+
f"Cannot parse agentic_visualization expected_output: {type(expected_output).__name__}. "
73+
'Expected {"expected_outputs": [...]} or {"visualization": {...}}.'
74+
)
75+
76+
77+
def _dispatch_agentic(
78+
item: DatasetItem,
79+
host: str,
80+
token: str,
81+
workspace_id: str,
82+
k: int,
83+
langfuse: Any,
84+
run_ts: str,
85+
model_version_override: str | None,
86+
) -> None:
87+
"""Call the appropriate evaluate_agentic_* function for the item's test_kind."""
88+
kind = item.test_kind
89+
eo = item.expected_output
90+
lf_kw: _LfKw = {
91+
"langfuse": langfuse,
92+
"dataset_item_id": item.id,
93+
"dataset_name": item.dataset_name,
94+
"run_timestamp": run_ts,
95+
"model_version_override": model_version_override,
96+
}
97+
98+
if kind in ("vis_agentic", "agentic_visualization"):
99+
evaluate_agentic_visualization(
100+
host=host,
101+
token=token,
102+
workspace_id=workspace_id,
103+
question=item.question,
104+
expected_outputs=_parse_visualization_expected(eo),
105+
k=k,
106+
**lf_kw,
107+
)
108+
elif kind == "agentic_metric_skill":
109+
evaluate_agentic_metric_skill(
110+
host=host,
111+
token=token,
112+
workspace_id=workspace_id,
113+
question=item.question,
114+
expected_output=eo if isinstance(eo, dict) else {},
115+
k=k,
116+
**lf_kw,
117+
)
118+
elif kind == "agentic_alert_skill":
119+
evaluate_agentic_alert_skill(
120+
host=host,
121+
token=token,
122+
workspace_id=workspace_id,
123+
question=item.question,
124+
expected_output=eo if isinstance(eo, dict) else {},
125+
k=k,
126+
**lf_kw,
127+
)
128+
elif kind == "agentic_search":
129+
eo_dict = eo if isinstance(eo, dict) else {}
130+
tool_call = eo_dict.get("tool_call", {})
131+
expected_args = tool_call.get("function_arguments", eo_dict)
132+
evaluate_agentic_search_tool(
133+
host=host,
134+
token=token,
135+
workspace_id=workspace_id,
136+
question=item.question,
137+
expected_tool_call=expected_args,
138+
k=k,
139+
**lf_kw,
140+
)
141+
elif kind == "agentic_general_question":
142+
evaluate_agentic_general_question(
143+
host=host,
144+
token=token,
145+
workspace_id=workspace_id,
146+
question=item.question,
147+
expected_output=eo if isinstance(eo, str) else str(eo),
148+
k=k,
149+
**lf_kw,
150+
)
151+
elif kind == "agentic_guardrail":
152+
evaluate_agentic_guardrail(
153+
host=host,
154+
token=token,
155+
workspace_id=workspace_id,
156+
question=item.question,
157+
expected_output=eo if isinstance(eo, str) else str(eo),
158+
k=k,
159+
**lf_kw,
160+
)
161+
elif kind == "agentic_conversation":
162+
fixture_data = eo.get("fixture") or eo if isinstance(eo, dict) else {}
163+
evaluate_agentic_conversation(
164+
host=host,
165+
token=token,
166+
workspace_id=workspace_id,
167+
fixture=ConversationFixture.model_validate(fixture_data),
168+
**lf_kw,
169+
)
170+
else:
171+
raise ValueError(f"Unknown agentic test kind: {kind!r}")
172+
173+
174+
def run_agentic_items(
175+
items: list[DatasetItem],
176+
host: str,
177+
token: str,
178+
workspace_id: str,
179+
*,
180+
k: int = 2,
181+
model_version: str | None = None,
182+
use_langfuse: bool = False,
183+
run_ts: str,
184+
on_item_start: Any = None,
185+
on_item_done: Any = None,
186+
) -> EvalReport:
187+
"""Run agentic items through evaluate_agentic_* and return an EvalReport."""
188+
langfuse = make_langfuse_client() if use_langfuse else None
189+
190+
report = EvalReport(model=model_version)
191+
total = len(items)
192+
193+
for index, item in enumerate(items, start=1):
194+
if on_item_start is not None:
195+
try:
196+
on_item_start(index, total, item)
197+
except Exception:
198+
pass
199+
200+
item_report = ItemReport(
201+
id=item.id,
202+
dataset_name=item.dataset_name,
203+
test_kind=item.test_kind,
204+
question=item.question,
205+
)
206+
t0 = time.perf_counter()
207+
try:
208+
_dispatch_agentic(item, host, token, workspace_id, k, langfuse, run_ts, model_version)
209+
item_report.pass_at_k = True
210+
item_report.runs = k
211+
except AssertionError as exc:
212+
item_report.pass_at_k = False
213+
item_report.runs = k
214+
print(f"[agentic] {item.id} FAIL: {exc}", flush=True)
215+
except Exception as exc:
216+
item_report.error = f"{type(exc).__name__}: {exc}"
217+
item_report.runs = 0
218+
finally:
219+
item_report.latency_s = time.perf_counter() - t0
220+
221+
if on_item_done is not None:
222+
try:
223+
on_item_done(index, total, item_report)
224+
except Exception:
225+
pass
226+
227+
report.items.append(item_report)
228+
229+
if langfuse is not None:
230+
try:
231+
langfuse.flush()
232+
langfuse.close()
233+
except Exception:
234+
pass
235+
236+
return report

‎packages/gooddata-eval/src/gooddata_eval/cli/main.py‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from gooddata_eval.core.models import ChatResult, DatasetItem
2020
from gooddata_eval.core.reporting.console import render_comparison, render_console
2121
from gooddata_eval.core.reporting.json_report import write_multi_model_report
22+
from gooddata_eval.cli.agentic_runner import AGENTIC_TEST_KINDS, run_agentic_items
2223
from gooddata_eval.core.runner import ItemReport, run_items
2324
from gooddata_eval.core.summary.http_client import SummaryClient
2425
from gooddata_eval.core.workspace import ModelResolutionError, WorkspaceModelController
@@ -62,6 +63,17 @@ def _build_parser() -> argparse.ArgumentParser:
6263
source = run.add_mutually_exclusive_group(required=True)
6364
source.add_argument("--dataset", help="Path to a folder of dataset JSON files.")
6465
source.add_argument("--langfuse-dataset", dest="langfuse_dataset", help="Langfuse dataset name.")
66+
run.add_argument(
67+
"--kind",
68+
dest="kind",
69+
default="visualization",
70+
metavar="TEST_KIND",
71+
help=(
72+
"Default test kind for dataset items that don't embed one. "
73+
"Use 'vis_agentic', 'agentic_visualization', 'agentic_metric_skill', etc. for multi-turn agentic eval. "
74+
"(default: visualization)"
75+
),
76+
)
6577
run.add_argument(
6678
"--model",
6779
action="append",
@@ -165,7 +177,7 @@ def _load_dataset(config: RunConfig):
165177

166178
if config.langfuse_dataset is None: # pragma: no cover - argparse mutually-exclusive group guarantees one is set
167179
raise ValueError("Either --dataset or --langfuse-dataset is required.")
168-
return load_langfuse_dataset(config.langfuse_dataset)
180+
return load_langfuse_dataset(config.langfuse_dataset, default_test_kind=config.kind)
169181

170182

171183
def _list_models(host: str, token: str, workspace_id: str | None) -> int:
@@ -228,6 +240,8 @@ def _run(config: RunConfig) -> int:
228240
return _EXIT_OPERATIONAL_ERROR
229241

230242
items = _load_dataset(config)
243+
agentic_items = [i for i in items if i.test_kind in AGENTIC_TEST_KINDS]
244+
non_agentic_items = [i for i in items if i.test_kind not in AGENTIC_TEST_KINDS]
231245
models = config.models or []
232246
run_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H-%M")
233247
n_models = len(models) if models else 1
@@ -287,13 +301,30 @@ def on_langfuse_item_done(
287301
) -> None:
288302
_sink.log_item(report, dataset_item_id=report.id)
289303

304+
# --- agentic items (multi-turn, use evaluate_agentic_*) ---
305+
agentic_report = None
306+
if agentic_items:
307+
agentic_report = run_agentic_items(
308+
agentic_items,
309+
host=config.host,
310+
token=config.token,
311+
workspace_id=config.workspace_id,
312+
k=config.runs,
313+
model_version=resolved.model_id,
314+
use_langfuse=config.log_to_langfuse,
315+
run_ts=run_ts,
316+
on_item_start=on_item_start,
317+
on_item_done=on_item_done,
318+
)
319+
320+
# --- non-agentic items (single-turn, use Evaluator) ---
290321
backend = _RoutingBackend(
291322
ChatClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
292323
SummaryClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
293324
)
294325
try:
295-
report = run_items(
296-
items,
326+
single_report = run_items(
327+
non_agentic_items,
297328
backend,
298329
runs=config.runs,
299330
model=resolved.model_id,
@@ -310,6 +341,20 @@ def on_langfuse_item_done(
310341
if hasattr(backend, "close"):
311342
backend.close()
312343

344+
# merge into a single report for display/export
345+
from gooddata_eval.core.runner import EvalReport # noqa: PLC0415
346+
347+
report = EvalReport(
348+
model=resolved.model_id,
349+
provider_name=resolved.provider_name or resolved.provider_id,
350+
provider_type=resolved.provider_type,
351+
workspace_id=config.workspace_id,
352+
)
353+
if agentic_report is not None:
354+
report.items.extend(agentic_report.items)
355+
report.items.extend(single_report.items)
356+
report.wall_clock_s = (agentic_report.wall_clock_s if agentic_report else 0.0) + single_report.wall_clock_s
357+
313358
skipped_kinds = sorted({i.test_kind for i in report.items if i.skipped})
314359
if skipped_kinds:
315360
print(
@@ -363,6 +408,7 @@ def main(argv: list[str] | None = None) -> int:
363408
json_path=Path(args.json_path) if args.json_path else None,
364409
log_to_langfuse=args.langfuse,
365410
quiet=args.quiet,
411+
kind=args.kind,
366412
)
367413
return _run(config)
368414
except (

0 commit comments

Comments
 (0)