Skip to content

Commit 0b20d26

Browse files
authored
Merge pull request #1646 from gooddata/rr/summary-endpoint-eval
feat(eval): evaluate the dashboard-summary skill via the /summary endpoint
2 parents 73a34c6 + ed3303d commit 0b20d26

19 files changed

Lines changed: 601 additions & 26 deletions

File tree

packages/gooddata-eval/README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,44 @@ A dataset is a folder of `.json` files, one per question:
163163
```
164164

165165
Supported `test_kind` values: `visualization`, `metric_skill`, `alert_skill`,
166-
`search_tool`, `general_question`, `guardrail`.
166+
`search_tool`, `general_question`, `guardrail`, `dashboard_summary`.
167+
168+
### `dashboard_summary` items
169+
170+
Summary items call the dedicated summary endpoint
171+
(`POST /api/v1/ai/workspaces/{ws}/summary`) instead of the chat endpoint, so
172+
they carry an extra `summary_input` block, and the `expected_output` is a
173+
**rubric** rather than an exact answer (summaries are free text):
174+
175+
```json
176+
{
177+
"id": "summary-001",
178+
"dataset_name": "summary_pilot",
179+
"test_kind": "dashboard_summary",
180+
"question": "Summarize the Sales Overview dashboard.",
181+
"summary_input": {
182+
"dashboard_id": "sales_overview"
183+
},
184+
"expected_output": {
185+
"must_include": ["States the overall revenue trend", "Identifies the top segment"],
186+
"must_not_include": ["Numbers or segments not present in the visualizations"],
187+
"rubric": ["Reads as a coherent business summary"]
188+
}
189+
}
190+
```
191+
192+
`summary_input` requires only `dashboard_id` (the endpoint summarizes the whole
193+
dashboard). Optional fields narrow the scope: `visualizations` (list of ids),
194+
`filter_context` (AFM filters), `tab_id`, and `format_hint`.
195+
196+
The `expected_output` rubric:
197+
198+
- `must_include` — facts a good summary must contain; **all** must pass for the item to pass.
199+
- `must_not_include` — hallucination/accuracy guards; **any** violation fails the item.
200+
- `rubric` — soft quality dimensions; they affect `quality_score` but do not gate pass/fail.
201+
202+
Each criterion is scored independently by the LLM judge, so `quality_score`
203+
is the fraction of satisfied criteria.
167204

168205
## Supported test kinds
169206

@@ -175,6 +212,7 @@ Supported `test_kind` values: `visualization`, `metric_skill`, `alert_skill`,
175212
| `search_tool` | `search_objects` tool call (correct function called = pass; correct arguments = quality score) ||
176213
| `general_question` | Text answer judged by LLM | `[llm-judge]` |
177214
| `guardrail` | Refusal/redirect (visualization response auto-fails) | `[llm-judge]` |
215+
| `dashboard_summary` | Dashboard summary (via `/summary` endpoint) scored against a rubric by LLM | `[llm-judge]` |
178216

179217
## Optional extras
180218

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,38 @@
1616
from gooddata_eval.core.connection import ConnectionError_, resolve_connection
1717
from gooddata_eval.core.dataset.local import load_local_dataset
1818
from gooddata_eval.core.langfuse.sink import LangfuseSink
19-
from gooddata_eval.core.models import DatasetItem
19+
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
2222
from gooddata_eval.core.runner import ItemReport, run_items
23+
from gooddata_eval.core.summary.http_client import SummaryClient
2324
from gooddata_eval.core.workspace import ModelResolutionError, WorkspaceModelController
2425

2526
_EXIT_OK = 0
2627
_EXIT_OPERATIONAL_ERROR = 2
28+
_SUMMARY_TEST_KIND = "dashboard_summary"
29+
30+
31+
class _RoutingBackend:
32+
"""Dispatch each item to the right backend by test_kind.
33+
34+
`dashboard_summary` items go to the dedicated summary endpoint; everything
35+
else uses the conversational chat endpoint.
36+
"""
37+
38+
def __init__(self, chat: ChatClient, summary: SummaryClient):
39+
self._chat = chat
40+
self._summary = summary
41+
42+
def ask(self, item: DatasetItem) -> ChatResult:
43+
if item.test_kind == _SUMMARY_TEST_KIND:
44+
return self._summary.ask(item)
45+
return self._chat.ask(item)
46+
47+
def close(self) -> None:
48+
for backend in (self._chat, self._summary):
49+
if hasattr(backend, "close"):
50+
backend.close()
2751

2852

2953
def _build_parser() -> argparse.ArgumentParser:
@@ -263,7 +287,10 @@ def on_langfuse_item_done(
263287
) -> None:
264288
_sink.log_item(report, dataset_item_id=report.id)
265289

266-
backend = ChatClient(host=config.host, token=config.token, workspace_id=config.workspace_id)
290+
backend = _RoutingBackend(
291+
ChatClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
292+
SummaryClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
293+
)
267294
try:
268295
report = run_items(
269296
items,

packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import httpx
2121

22-
from gooddata_eval.core.models import ChatResult
22+
from gooddata_eval.core.models import ChatResult, DatasetItem
2323

2424
SSE_DATA_PREFIX = "data: "
2525

@@ -169,11 +169,11 @@ def _send_message(self, conversation_id: str, question: str) -> ChatResult:
169169
resp.raise_for_status()
170170
return parse_sse_lines(resp.iter_lines())
171171

172-
def ask(self, question: str) -> ChatResult:
172+
def ask(self, item: DatasetItem) -> ChatResult:
173173
"""Run one single-turn conversation: create, send, parse, clean up."""
174174
conversation_id = self._create_conversation()
175175
try:
176-
return self._send_message(conversation_id, question)
176+
return self._send_message(conversation_id, item.question)
177177
finally:
178178
self._delete_conversation(conversation_id)
179179

packages/gooddata-eval/src/gooddata_eval/core/dataset/langfuse_source.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import httpx
1919

20-
from gooddata_eval.core.models import DatasetItem
20+
from gooddata_eval.core.models import DatasetItem, SummaryInput
2121

2222
_DEFAULT_HOST = "https://cloud.langfuse.com"
2323
_PAGE_SIZE = 100
@@ -47,6 +47,24 @@ def _question_from_input(raw_input: Any) -> str:
4747
raise ValueError(f"Unsupported Langfuse item input shape: {raw_input!r}")
4848

4949

50+
def _summary_input_from_raw(raw: dict, expected_output: Any) -> SummaryInput | None:
51+
"""Locate a dashboard_summary item's `summary_input`.
52+
53+
Langfuse items have no dedicated field for it, so accept it (in priority
54+
order) from the item input object, the item metadata, or the expectedOutput.
55+
"""
56+
candidate: Any = None
57+
raw_input = raw.get("input")
58+
metadata = raw.get("metadata")
59+
if isinstance(raw_input, dict) and isinstance(raw_input.get("summary_input"), dict):
60+
candidate = raw_input["summary_input"]
61+
elif isinstance(metadata, dict) and isinstance(metadata.get("summary_input"), dict):
62+
candidate = metadata["summary_input"]
63+
elif isinstance(expected_output, dict) and isinstance(expected_output.get("summary_input"), dict):
64+
candidate = expected_output["summary_input"]
65+
return SummaryInput.model_validate(candidate) if candidate is not None else None
66+
67+
5068
def _item_from_raw(raw: dict, *, dataset_name: str, test_kind: str) -> DatasetItem:
5169
"""Map a Langfuse REST API dataset-item dict to a DatasetItem."""
5270
# REST API returns camelCase: expectedOutput, not expected_output
@@ -60,6 +78,7 @@ def _item_from_raw(raw: dict, *, dataset_name: str, test_kind: str) -> DatasetIt
6078
test_kind=resolved_kind,
6179
question=_question_from_input(raw.get("input")),
6280
expected_output=expected_output,
81+
summary_input=_summary_input_from_raw(raw, expected_output),
6382
)
6483

6584

packages/gooddata-eval/src/gooddata_eval/core/evaluators/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,18 @@
2020
)
2121
}
2222

23-
# LLM-judge evaluators (general_question, guardrail) require the [llm-judge] extra.
24-
# Their modules are imported lazily on first use so the CLI starts without openai.
23+
# LLM-judge evaluators (general_question, guardrail, dashboard_summary) require the
24+
# [llm-judge] extra. Their modules are imported lazily on first use so the CLI
25+
# starts without openai.
2526
_LAZY_EVALUATOR_MODULES: dict[str, str] = {
2627
"general_question": "gooddata_eval.core.evaluators.general_question",
2728
"guardrail": "gooddata_eval.core.evaluators.guardrail",
29+
"dashboard_summary": "gooddata_eval.core.evaluators.summary",
2830
}
2931
_LAZY_EVALUATOR_CLASSES: dict[str, str] = {
3032
"general_question": "GeneralQuestionEvaluator",
3133
"guardrail": "GuardrailEvaluator",
34+
"dashboard_summary": "DashboardSummaryEvaluator",
3235
}
3336

3437

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# (C) 2026 GoodData Corporation
2+
"""Evaluator for dashboard_summary: rubric-based LLM-as-judge scoring.
3+
4+
Summaries are free text, so we do not match strings. Instead, `expected_output`
5+
is a rubric of checkable criteria:
6+
7+
{
8+
"must_include": ["...facts a good summary must contain..."],
9+
"must_not_include": ["...things a good summary must avoid (hallucinations)..."],
10+
"rubric": ["...soft quality dimensions..."]
11+
}
12+
13+
Each criterion is scored independently by the judge (True/False), so the
14+
runner's `quality_score` becomes the fraction of satisfied criteria. The item
15+
*passes* only when every `must_include` is satisfied and no `must_not_include`
16+
is violated; `rubric` items contribute to quality but do not gate pass/fail.
17+
18+
As a fallback, a non-dict `expected_output` is treated as a single rubric
19+
criterion (same behaviour as `general_question`).
20+
"""
21+
22+
from typing import Any
23+
24+
from gooddata_eval.core.evaluators._llm_judge import LLMJudge
25+
from gooddata_eval.core.evaluators._text_utils import extract_text
26+
from gooddata_eval.core.evaluators.base import ItemEvaluation
27+
from gooddata_eval.core.models import ChatResult, DatasetItem
28+
29+
_POSITIVE_STEPS = [
30+
"Read the INPUT (the user's request) and the EXPECTED OUTPUT (one criterion the summary must satisfy).",
31+
"Read the ACTUAL OUTPUT (the generated summary).",
32+
"Score 1 if the actual output clearly satisfies the criterion (allow paraphrasing and reasonable numeric tolerance).",
33+
"Score 0 if the criterion is missing, contradicted, or only partially addressed.",
34+
]
35+
36+
# For must_not_include we ask the judge a plain presence question and invert the
37+
# result in code. Scoring "does the summary AVOID X?" via a field labelled
38+
# EXPECTED OUTPUT is unreliable: the model reads the forbidden behaviour as
39+
# desired and flips the verdict. Detecting presence (no negation, no
40+
# contradictory label) is far more robust.
41+
_VIOLATION_STEPS = [
42+
"Read the CHARACTERISTIC described in EXPECTED OUTPUT.",
43+
"Read the ACTUAL OUTPUT (the generated summary).",
44+
"Score 1 if the actual output clearly exhibits the described characteristic.",
45+
"Score 0 if it does not exhibit it.",
46+
]
47+
48+
49+
class DashboardSummaryEvaluator:
50+
test_kind = "dashboard_summary"
51+
52+
def __init__(self):
53+
self._positive_judge = LLMJudge(evaluation_steps=_POSITIVE_STEPS)
54+
self._violation_judge = LLMJudge(evaluation_steps=_VIOLATION_STEPS)
55+
56+
@staticmethod
57+
def _criteria(expected_output: Any) -> tuple[list[str], list[str], list[str]]:
58+
if isinstance(expected_output, dict):
59+
must_include = [str(c) for c in expected_output.get("must_include", [])]
60+
must_not_include = [str(c) for c in expected_output.get("must_not_include", [])]
61+
rubric = [str(c) for c in expected_output.get("rubric", [])]
62+
if must_include or must_not_include or rubric:
63+
return must_include, must_not_include, rubric
64+
# Fallback: treat the whole expected_output as a single gating criterion
65+
# (same pass/fail semantics as general_question).
66+
return [str(expected_output)], [], []
67+
68+
def evaluate(self, item: DatasetItem, chat_result: ChatResult) -> ItemEvaluation:
69+
actual = extract_text(chat_result)
70+
must_include, must_not_include, rubric = self._criteria(item.expected_output)
71+
72+
detail: dict[str, Any] = {"actual_output": actual}
73+
passed = True
74+
75+
for i, criterion in enumerate(must_include):
76+
ok, reason = self._positive_judge.score(item.question, criterion, actual)
77+
detail[f"include_{i}"] = ok
78+
detail[f"include_{i}_reason"] = reason
79+
passed = passed and ok
80+
81+
for i, criterion in enumerate(must_not_include):
82+
violated, reason = self._violation_judge.score(item.question, criterion, actual)
83+
ok = not violated # True == characteristic absent == correctly avoided
84+
detail[f"exclude_{i}"] = ok
85+
detail[f"exclude_{i}_reason"] = reason
86+
passed = passed and ok
87+
88+
for i, criterion in enumerate(rubric):
89+
ok, reason = self._positive_judge.score(item.question, criterion, actual)
90+
detail[f"rubric_{i}"] = ok
91+
detail[f"rubric_{i}_reason"] = reason
92+
93+
bool_checks = [v for v in detail.values() if isinstance(v, bool)]
94+
quality = sum(1 for v in bool_checks if v) / len(bool_checks) if bool_checks else 0.0
95+
96+
return ItemEvaluation(passed=passed, rank_key=(int(passed), quality), detail=detail)

packages/gooddata-eval/src/gooddata_eval/core/langfuse/sink.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ def _event(event_type: str, body: dict[str, Any]) -> dict[str, Any]:
8989
"id": trace_id,
9090
"timestamp": now,
9191
"name": f"gd-eval: {report.question[:80]}",
92+
# Expose the model on a first-class trace field so Langfuse
93+
# dashboards can filter / break down by it ("Version"); trace
94+
# metadata is not available as a breakdown dimension.
95+
"version": self._model_id or None,
9296
"input": {"question": report.question},
9397
"output": report.best_detail,
9498
"metadata": {

packages/gooddata-eval/src/gooddata_eval/core/models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,23 @@ class ChatResult(BaseModel):
8585
tool_call_events: list[ToolCallEvent] = Field(default_factory=list, alias="toolCallEvents")
8686

8787

88+
class SummaryInput(BaseModel):
89+
"""Structured input for the `dashboard_summary` test kind.
90+
91+
Maps onto the dedicated summary endpoint's request body
92+
(`POST /api/v1/ai/workspaces/{ws}/summary`). Authored in snake_case in the
93+
dataset; the SummaryClient maps it to the endpoint's camelCase fields.
94+
"""
95+
96+
model_config = ConfigDict(extra="ignore")
97+
98+
dashboard_id: str
99+
visualizations: list[str] | None = None
100+
filter_context: list[dict] | None = None
101+
tab_id: str | None = None
102+
format_hint: str | None = None
103+
104+
88105
class DatasetItem(BaseModel):
89106
"""Common dataset envelope. `expected_output` stays raw; each evaluator parses its own shape."""
90107

@@ -95,3 +112,5 @@ class DatasetItem(BaseModel):
95112
test_kind: str
96113
question: str
97114
expected_output: Any
115+
# Only used by the `dashboard_summary` test kind; ignored by all others.
116+
summary_input: SummaryInput | None = None

packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,11 @@ def render_console(report: EvalReport, *, console: Console | None = None) -> str
3232
elif item.pass_at_k:
3333
result, notes = "PASS", ""
3434
else:
35-
d = item.best_detail
36-
failing = [
37-
k
38-
for k in ("metrics_correct", "dimensions_correct", "filters_correct", "viz_type_hard")
39-
if d.get(k) is False
40-
]
41-
notes = "failed: " + ", ".join(failing) if failing else "no visualization created"
35+
# Evaluator-agnostic: report whichever boolean checks came back False
36+
# (visualization uses metrics_correct/…; dashboard_summary uses
37+
# include_*/exclude_*/rubric_*). Falls back to a generic message.
38+
failing = [k for k, v in item.best_detail.items() if v is False]
39+
notes = "failed: " + ", ".join(failing) if failing else "did not pass strict checks"
4240
result = "FAIL"
4341
latency = "-" if item.runs == 0 else f"{item.latency_s:.2f}s"
4442
avg = "-" if item.runs == 0 else f"{item.avg_latency_s:.2f}s"

packages/gooddata-eval/src/gooddata_eval/core/runner.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515

1616
class ChatBackend(Protocol):
17-
def ask(self, question: str) -> ChatResult: ...
17+
# Receives the whole item so backends can use per-item context beyond the
18+
# question text (e.g. dashboard_summary needs item.summary_input).
19+
def ask(self, item: DatasetItem) -> ChatResult: ...
1820

1921

2022
@dataclass
@@ -109,7 +111,7 @@ def _run_one_item(
109111
try:
110112
for run_index in range(1, runs + 1):
111113
t0 = time.perf_counter()
112-
chat_result = backend.ask(item.question)
114+
chat_result = backend.ask(item)
113115
evaluation = evaluator.evaluate(item, chat_result)
114116
latency = time.perf_counter() - t0
115117
report.runs += 1

0 commit comments

Comments
 (0)