Skip to content

Commit 0a14007

Browse files
refactor(eval): move all agentic evaluation logic into gooddata_eval SDK
- Add agentic runners for metric_skill, alert_skill, search_tool, general_question, guardrail, and conversation test kinds - agentic_search pass_at_k requires only tool_selected (matches original Tavern behavior; tool_correctness is a Langfuse quality metric only) - Expose evaluate_agentic_* functions for use by Tavern thin shims - Update uv.lock JIRA: GDAI-1830 risk: nonprod
1 parent 017c555 commit 0a14007

26 files changed

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

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

Lines changed: 48 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 '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,19 @@ 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+
report = EvalReport(
347+
model=resolved.model_id,
348+
provider_name=resolved.provider_name or resolved.provider_id,
349+
provider_type=resolved.provider_type,
350+
workspace_id=config.workspace_id,
351+
)
352+
if agentic_report is not None:
353+
report.items.extend(agentic_report.items)
354+
report.items.extend(single_report.items)
355+
report.wall_clock_s = (agentic_report.wall_clock_s if agentic_report else 0.0) + single_report.wall_clock_s
356+
313357
skipped_kinds = sorted({i.test_kind for i in report.items if i.skipped})
314358
if skipped_kinds:
315359
print(
@@ -363,6 +407,7 @@ def main(argv: list[str] | None = None) -> int:
363407
json_path=Path(args.json_path) if args.json_path else None,
364408
log_to_langfuse=args.langfuse,
365409
quiet=args.quiet,
410+
kind=args.kind,
366411
)
367412
return _run(config)
368413
except (

0 commit comments

Comments
 (0)