Skip to content

Commit fda5ee6

Browse files
xvalovicclaude
andcommitted
feat(gooddata-eval): generate eval datasets from a workspace's insights
Hand-authoring eval questions means writing a question and then guessing the metric, dimension and filter it should produce, which is how a dataset ends up full of questions the data model cannot answer. `gd-eval generate` inverts that. It reads the charts a customer already built via the declarative analytics model, translates each visible insight's buckets, sorts and filters into an `expected_output.visualization` spec, and only then asks an LLM to write the analyst question that chart answers. The expected output is copied out of a live object rather than invented, so every question is answerable in the real LDM by construction and the LLM only writes English. Anything inexpressible is skipped with a printed reason rather than approximated, and a question that contradicts its own spec is a hard error: ranking words require a real sort, filter words a real filter, a breakdown clause a non-empty view_by. One rewrite is attempted, then the item is dropped. `--enrich-ranked N` derives ranked items, because analysts sort in Analytical Designer and save without persisting the sort, leaving that coverage near zero on real models. Adding a limit to a definition that already executes cannot make it unanswerable. The budget goes to the best-grounded first: insights whose own title promised a ranking their definition never implemented, then ranking filters added to a plain breakdown. Derived items carry `derived_from` and `derived_basis` so a pass rate over them stays separable. Only a ranking filter is derived, never a sort: on the same base a ranking is the stronger item, since "the top 3 X by Y" has one correct spec while "X sorted by Y" leaves the direction to the reader. The sort a question asks for is now graded, which it previously was not. `sort_by` was written into every fixture and read by nobody -- the evaluator loads `expected_output.visualization` into `CreatedVisualization`, which declared no such field and is configured `extra="ignore"`, so pydantic discarded it and `strict_pass` covered cross-references, metrics, dimensions, filters and chart type only. A question saying "sorted by Order id ascending" -- seven of forty on one real workspace -- asked for something no check saw. `AacQuery` gains `sort_by`, `check_sorts` compares it, and `strict_pass` counts it. Entries stay raw dicts for the same reason `filter_by` does: the agent adds keys this does not read, and a typed model would reject a chart that is otherwise correct. The comparison uses the shape the agent emits, taken from recorded runs: `{type: metric_sort, direction, metrics: [alias]}` and `{type: attribute_sort, direction, by: alias}`, with one build sending both `by` and `metrics` on a metric sort -- so the entry's own `type` decides which key names the fields, never whichever key is present. Aliases resolve to uris and date granularities fold to one spelling, as filters already do, and order is significant: sorted by region then revenue is not sorted by revenue then region. The check is deliberately not symmetric with the filter ones. An empty `sort_by` records that the fixture has no sort, not that the chart must be unsorted -- a generated item inherits that emptiness from an insight whose author sorted in Analytical Designer and saved without the sort sticking. A spurious filter changes which rows a reader sees and is always wrong, while a volunteered sort changes only their order, and ascending on a time axis is what any renderer picks unprompted. So a required sort is enforced and a volunteered one is free; the cost is that a wrong sort over an unsorted fixture goes ungraded, the lesser error while `[]` cannot distinguish "unsorted" from "unrecorded". A tiebreak the agent appends after the recorded sorts is free as well: "state descending" is satisfied by "state descending, then city", so the recorded sorts must lead and match in order, and anything after them is not compared. Grading a previously ungraded dimension means items that passed while omitting a sort their fixture records now fail. Re-baseline before comparing a run against an older one. The declarative-to-AAC mapping is not ours. `convert()` calls the platform's own `declarative_visualization_to_aac()` (gooddata-code-convertors, via gooddata-sdk): both definitions the evaluator compares are platform output, so the platform's conversion is the right owner, and on one production workspace it converts every insight where the hand-written mapping had covered 44 of 62. What stays ours is deciding what the evaluator cannot yet score -- derived measures, measure-level filters, a repeater's label among its metrics -- each skipped with a printed reason, and stripping the no-op filters AD saves for an "All" selection, which would otherwise let a question claim a filter its chart lacks. A map's `location` bucket is skipped on purpose: it holds a rendering label, and a question built from it reads as "broken down by City pushpin latitude". Chart type names are the convertor's, which are also the agent's. One granularity is patched: the convertor maps `GDC.time.week_us` to `WEEK_US`, which is not a platform enum; the SDK's own table says `WEEK`. Two classes of question are unwinnable however well the agent behaves, and both are reported: a name the model carries more than once (one workspace has six labels titled "Product Title"), droppable with `--skip-ambiguous`; and a date granularity's cyclical twin, since MONTH walks consecutive months while MONTH_OF_YEAR stacks every January. Granularities move to `core/granularity.py`, shared with scoring, which also folds `attribute/x.month` and `label/x.month` to one uri -- a date dataset exposes each granularity as an attribute whose only label carries the same id, and comparing the raw strings failed a chart that was correct. Alias resolution and the uri-to-title fallback come from `core/scoring.py`: one copy, which is the one the evaluator scores against. The package's `AGENTS.md` gains a section running the whole pipeline -- generate, run, report, models -- with the connection precedence, the snapshot loop that makes generation iterable offline, and the environment variables each subcommand reads. `generate` and `report` were not mentioned there at all. `openai` joins the package's `dev` dependency group, so a plain `uv run` in a fresh clone has the phrasing step and the LLM judge without naming the extra. It stays under `optional-dependencies` for anyone installing from PyPI, and every import site is still guarded or deferred. Three fixes from the first live run over a customer workspace. A ranking filter with no `attribute` ranks the full dimension tuple, so the "top N <dimension>" shorthand in the writer's brief is only true with one dimension; with two it told the writer a within-group scope the filter lacks, and the agent built what the question said. `CreatedVisualization.id` is optional: the agent sometimes omits it, nothing scores on it, and a required field turned a scorable chart into an errored item. jira: AIS-48 risk: high Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hbj7SaGm6ucimov4NeMwqt
1 parent 1071fd6 commit fda5ee6

14 files changed

Lines changed: 3441 additions & 11 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ packages/gooddata-sdk/tests/export/exports/default/
4444
/packages/gooddata-eval/datasets/
4545
# MCP tool logs, written to a relative path by whatever is started from the repo root
4646
/logs/
47+
48+
# editor swap files
49+
*.swp

packages/gooddata-eval/AGENTS.md

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ experiment. The newest and most actively developed package in the repo.
99

1010
## Owns
1111

12-
- The `gd-eval` CLI (`gd-eval run`, `gd-eval models`)
12+
- The `gd-eval` CLI (`generate`, `run`, `report`, `models`)
1313
- Dataset loading and the evaluation run loop
1414
- Per-capability evaluators and their scoring
1515
- Result reporting, and pushing experiments, scores and trace links to Langfuse
@@ -27,7 +27,7 @@ experiment. The newest and most actively developed package in the repo.
2727
| `core/agentic/` | multi-turn agentic evaluation per capability, **plus** all Langfuse trace polling and linking (`_langfuse.py`, `_trace_linker.py`) |
2828
| `core/chat/` | SSE client for the agent's streaming chat endpoint |
2929
| `core/summary/` | HTTP client for the dedicated dashboard-summary endpoint — a single-shot chat backend, not reporting |
30-
| `core/dataset/` | dataset format and loading |
30+
| `core/dataset/` | dataset format, loading, and `from_insights.py` — dataset generation from a workspace's real insights |
3131
| `core/evaluators/` | single-shot evaluators and their registry |
3232
| `core/langfuse/` | the whole Langfuse v4 client: `_env` (base URL + credentials), `otlp` (OTLP/JSON encoding), `experiment` (root-span construction, score targets), `observations` (trace reads), `client` (httpx calls), `sink` (single-shot results as experiments) |
3333
| `core/reporting/` | console and JSON output rendering |
@@ -61,6 +61,107 @@ its own shape. `test_kind` on the item is what labels the result, not the evalua
6161
which is why `knowledge_question` can reuse `GeneralQuestionEvaluator` verbatim.
6262
`dashboard_summary` items additionally need `summary_input`.
6363

64+
## Running the pipeline
65+
66+
Four subcommands, in the order you use them. Everything runs through `uv`; never a bare
67+
`python`. There is no build step -- `uv run` syncs the environment from `uv.lock` on first
68+
use, so a fresh clone needs nothing but:
69+
70+
```bash
71+
uv run --package gooddata-eval gd-eval <subcommand> --help
72+
```
73+
74+
`openai` is an optional extra (`llm-judge`) so the published package stays installable
75+
without it, but the `dev` dependency group pulls it in, which is why a plain `uv run` here
76+
has the phrasing step and the LLM judge. Installing `gooddata-eval` from PyPI does not --
77+
there the extra is explicit, and every `openai` import site is guarded or deferred.
78+
79+
Connection is the same for every subcommand that talks to the platform: `--host` +
80+
`--token`, or `GOODDATA_TOKEN` in the environment, or `--profile <name>` reading
81+
`~/.gooddata/profiles.yaml`. Precedence is flags > env > profile.
82+
83+
### 1. `generate` — build a dataset from a workspace
84+
85+
Reverse-engineers `visualization` items out of the charts a workspace already has, so the
86+
expected output is copied from a live object rather than invented. Needs `OPENAI_API_KEY`
87+
for the phrasing step, or `--no-phrase` to emit mechanical `Show <title>` questions.
88+
89+
```bash
90+
uv run --package gooddata-eval gd-eval generate \
91+
--host "$GOODDATA_HOST" --workspace "$WORKSPACE_ID" \
92+
--dataset-name ecommerce --out ./datasets/ecommerce \
93+
--snapshot-out /tmp/ws.json \
94+
--phrase-model gpt-4o --enrich-ranked 5 --skip-ambiguous
95+
```
96+
97+
Iterate offline instead of re-fetching: `--snapshot-out` writes everything the generator
98+
read as one JSON file, and `--snapshot-in` replays it with no host, token or network. Add
99+
`--dry-run` to print the shape counts and each spec's brief without writing anything —
100+
the fastest way to see what a workspace yields.
101+
102+
Quality gates fail the command (exit 1) below `--min-questions` (15), `--min-shapes` (3)
103+
or `--min-filtered` (1). Lower them for a smoke test; do not lower them to ship a dataset.
104+
`--langfuse-out` additionally writes a Langfuse-importable file, and `--id-prefix` rewrites
105+
ids on that export only, because Langfuse item ids are unique per project.
106+
107+
Insights the AAC spec cannot express without guessing are skipped with a printed reason
108+
(`SKIP <id>: derived measure (previousPeriodMeasure)`). Read those — they are the
109+
generator telling you what it refused to invent, not noise.
110+
111+
### 2. `run` — evaluate
112+
113+
```bash
114+
uv run --package gooddata-eval gd-eval run \
115+
--host "$GOODDATA_HOST" --workspace "$WORKSPACE_ID" \
116+
--dataset ./datasets/ecommerce --kind visualization \
117+
--model gpt-5.2 --model ProviderName/gpt-4o \
118+
--runs 3 --gate power --concurrency 4 \
119+
--json ./results/run.json --html ./results/run.html
120+
```
121+
122+
`--dataset` reads a local folder; `--langfuse-dataset` pulls one by name instead. `--kind`
123+
only supplies a default for items that do not carry their own `test_kind`. Repeat
124+
`--model` to compare models in one run. `--runs` with `--gate power` measures stability
125+
(every run must pass) rather than pass@K. `--langfuse` pushes the run as a scored
126+
experiment, needing `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY`.
127+
128+
`--concurrency` is capped for you where it matters: kinds that create workspace objects
129+
run one at a time regardless, see the parallel-safety gotcha below.
130+
131+
### 3. `report` — compare runs
132+
133+
```bash
134+
uv run --package gooddata-eval gd-eval report \
135+
./results/*.json -o ./results/comparison.html --title "luna vs 4o" --redact
136+
```
137+
138+
Several JSON reports become side-by-side columns keyed by file name. `--redact` is the
139+
customer-safe form: conversation ids, response ids and raw reasoning dropped, model names
140+
replaced with "Model A", "Model B".
141+
142+
### 4. `models` — what the org has configured
143+
144+
```bash
145+
uv run --package gooddata-eval gd-eval models --host "$GOODDATA_HOST"
146+
```
147+
148+
Run this before guessing a `--model` string.
149+
150+
### Environment
151+
152+
| Variable | Used by |
153+
|---|---|
154+
| `GOODDATA_TOKEN` | every platform-facing subcommand |
155+
| `OPENAI_API_KEY` | `generate` phrasing, and the LLM-as-judge evaluators |
156+
| `GD_EVAL_JUDGE_MODEL` | judge model, same as `--judge-model` |
157+
| `GD_EVAL_AGENT_ID` | which agent to drive, same as `--agent-id` |
158+
| `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` | `--langfuse`, `--langfuse-dataset` |
159+
| `GOODDATA_EVAL_CHAT_*` | SSE retry, backoff and timeout knobs |
160+
| `GD_EVAL_TIMERS` | same as `--timers` |
161+
162+
A gitignored `.env` at the repo root is the normal place for these; load it with
163+
`set -a && . ./.env && set +a` before the command.
164+
64165
## Gotchas
65166

66167
**Adding an evaluator is a registry change, not a naming convention.** Single-shot kinds go
@@ -89,6 +190,23 @@ ingestion has no pass/fail signal and inflates or misattributes per-item latency
89190
(`run_trace_link_inline` is the synchronous alternative). Do not "fix" a slow item by
90191
making trace scoring synchronous again.
91192

193+
**A generated item's `expected_output` is copied, never invented — keep it that way.**
194+
`core/dataset/from_insights.py` converts each insight with the platform's own
195+
`declarative_visualization_to_aac()` (from `gooddata-code-convertors`, via `gooddata-sdk`),
196+
so the mapping is not ours to get wrong. What is ours is deciding what the evaluator cannot
197+
yet score — derived measures and measure-level filters convert fine and then compare wrong,
198+
so they are skipped with a printed reason — and stripping the no-op filters AD saves for an
199+
"All" selection, which would otherwise let a question claim a filter its chart lacks. Teach
200+
the comparator about a construct and the matching skip can go; do not make one convert by
201+
hand. Chart type names are the convertor's, which are also the agent's — do not rename them. One
202+
granularity is patched in `CONVERTOR_GRANULARITY_FIXES`: `week_us``WEEK_US` is a convertor
203+
bug, the platform enum is `WEEK`.
204+
205+
**The snapshot is a plain-JSON contract.** `--snapshot-in`/`--snapshot-out` is what makes
206+
the generator testable offline and iterable without re-fetching, and it is why the
207+
generator reads the declarative analytics model rather than `sdk.visualizations`. Anything
208+
that changes the fetch shape invalidates every saved snapshot.
209+
92210
**Scoring weights do not sum to 1.** `quality_score` is the fraction of boolean-valued keys
93211
in `best_detail` that are true, falling back to `pass_at_k` when there are none (text
94212
evaluators). `value_score` is `0.6 * quality + 0.2 * speed` — the 0.8 total is what the

packages/gooddata-eval/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ Source = "https://github.com/gooddata/gooddata-python-sdk"
4141
[dependency-groups]
4242
dev = [
4343
"pytest>=8.3.5",
44+
# The extra itself, so a plain `uv run` has the phrasing step and the LLM judge.
45+
# It stays optional for anyone installing the published package.
46+
"gooddata-eval[llm-judge]",
4447
]
4548
test = [
4649
"pytest~=9.1.1",

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
normalize_gate,
2727
)
2828
from gooddata_eval.core.connection import ConnectionError_, resolve_connection
29+
from gooddata_eval.core.dataset.from_insights import generate as generate_from_insights
2930
from gooddata_eval.core.dataset.local import load_local_dataset
3031
from gooddata_eval.core.evaluators import supported_test_kinds
3132
from gooddata_eval.core.langfuse.sink import LangfuseSink
@@ -208,6 +209,80 @@ def _build_parser() -> argparse.ArgumentParser:
208209
"model names with 'Model A', 'Model B', ...",
209210
)
210211

212+
gen = sub.add_parser(
213+
"generate",
214+
help="Generate a visualization dataset by reverse-engineering a workspace's insights.",
215+
)
216+
gen.add_argument("--host", help="GoodData host URL.")
217+
gen.add_argument("--token", help="API token (or set GOODDATA_TOKEN).")
218+
gen.add_argument("--profile", help="Profile name in ~/.gooddata/profiles.yaml.")
219+
gen.add_argument("--workspace", help="Workspace id to read insights from.")
220+
gen.add_argument(
221+
"--dataset-name", dest="dataset_name", required=True, help="`dataset_name` written into every item."
222+
)
223+
gen.add_argument("--out", help="Output folder for the dataset JSON files (default: ./<dataset-name>).")
224+
gen.add_argument(
225+
"--dashboard",
226+
action="append",
227+
default=[],
228+
help="Restrict to insights placed on this dashboard (repeatable). Default: the whole workspace.",
229+
)
230+
gen.add_argument(
231+
"--snapshot-in", dest="snapshot_in", help="Replay a saved model snapshot instead of calling the API."
232+
)
233+
gen.add_argument("--snapshot-out", dest="snapshot_out", help="Save the fetched model snapshot for later replay.")
234+
gen.add_argument("--langfuse-out", dest="langfuse_out", help="Also write a Langfuse-importable dataset JSON here.")
235+
gen.add_argument(
236+
"--id-prefix",
237+
dest="id_prefix",
238+
default="",
239+
help="Prefix every exported Langfuse item id. Langfuse ids are unique per PROJECT, so "
240+
"carrying an item into a second dataset under its original id is a 409.",
241+
)
242+
gen.add_argument(
243+
"--no-phrase", dest="no_phrase", action="store_true", help="Skip the LLM step; emit mechanical questions."
244+
)
245+
gen.add_argument(
246+
"--phrase-model", dest="phrase_model", default="gpt-4o", help="OpenAI model for the phrasing step."
247+
)
248+
gen.add_argument(
249+
"--no-viz-type", dest="no_viz_type", action="store_true", help="Always blank the expected chart type."
250+
)
251+
gen.add_argument(
252+
"--min-questions", dest="min_questions", type=int, default=15, help="Fail below this many questions."
253+
)
254+
gen.add_argument(
255+
"--min-shapes", dest="min_shapes", type=int, default=3, help="Fail below this many distinct question shapes."
256+
)
257+
gen.add_argument(
258+
"--min-filtered",
259+
dest="min_filtered",
260+
type=int,
261+
default=1,
262+
help="Fail below this many questions carrying a filter.",
263+
)
264+
gen.add_argument(
265+
"--enrich-ranked",
266+
dest="enrich_ranked",
267+
type=int,
268+
default=0,
269+
metavar="N",
270+
help="Additionally derive up to N ranked questions. Best-grounded first: insights whose "
271+
"own title promised a ranking their definition never implemented ('Top Returned Reasons' "
272+
"saved with no sort) are implemented as the title asks, then ranking filters this "
273+
"generator adds to a plain breakdown, then sort-only variants. Use when the workspace has "
274+
"no ranked insights of its own. Derived items carry `derived_from` and `derived_basis`. "
275+
"Default: 0 (off).",
276+
)
277+
gen.add_argument(
278+
"--skip-ambiguous",
279+
dest="skip_ambiguous",
280+
action="store_true",
281+
help="Drop items whose metric or dimension name matches more than one object in the model "
282+
"(loop has six labels titled 'Product Title'). Such a question cannot say which object it "
283+
"means, so a defensible answer still scores zero. Reported either way.",
284+
)
285+
gen.add_argument("--dry-run", dest="dry_run", action="store_true", help="Report only; write nothing.")
211286
models_cmd = sub.add_parser("models", help="List LLM providers and models configured in the org.")
212287
models_cmd.add_argument("--host", help="GoodData host URL.")
213288
models_cmd.add_argument("--token", help="API token (or set GOODDATA_TOKEN).")
@@ -612,6 +687,23 @@ def _report(args: argparse.Namespace) -> int:
612687
return _EXIT_OK
613688

614689

690+
def _generate(args: argparse.Namespace) -> int:
691+
"""`gd-eval generate` -- reverse-engineer a dataset from a workspace's insights."""
692+
if not args.snapshot_in and not args.workspace:
693+
print("error: generate needs --workspace, or --snapshot-in to replay a saved model.", file=sys.stderr)
694+
return _EXIT_OPERATIONAL_ERROR
695+
if args.out is None:
696+
args.out = args.dataset_name
697+
698+
def sdk_factory():
699+
from gooddata_sdk import GoodDataSdk # noqa: PLC0415
700+
701+
host, token = resolve_connection(host=args.host, token=args.token, profile=args.profile)
702+
return GoodDataSdk.create(host, token)
703+
704+
return generate_from_insights(args, sdk_factory)
705+
706+
615707
def main(argv: list[str] | None = None) -> int:
616708
args = parse_args(argv if argv is not None else sys.argv[1:])
617709
_apply_timer_flag(getattr(args, "timers", False))
@@ -625,6 +717,9 @@ def main(argv: list[str] | None = None) -> int:
625717
if args.command == "report":
626718
return _report(args)
627719

720+
if args.command == "generate":
721+
return _generate(args)
722+
628723
host, token = resolve_connection(host=args.host, token=args.token, profile=args.profile)
629724
if args.command == "models":
630725
return _list_models(host, token, getattr(args, "workspace", None))

0 commit comments

Comments
 (0)