Skip to content

Commit 17bcb5c

Browse files
Merge pull request #1707 from gooddata/fix-test
fix(gooddata-eval): make ranking attribute optional on 1-dim viz
2 parents 78904a0 + c265cc7 commit 17bcb5c

3 files changed

Lines changed: 195 additions & 20 deletions

File tree

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

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -63,29 +63,46 @@ def uri_to_display_name(uri: str) -> str:
6363

6464

6565
def validate_cross_references(viz: CreatedVisualization) -> tuple[bool, list[str]]:
66-
"""Validate ranking-filter `using`/`attribute` resolve to correct URI prefixes."""
66+
"""Validate ranking-filter `using`/`attribute` resolve to correct URI prefixes.
67+
68+
Always returns `(ok, errors)` — a malformed filter produces an error entry, never an
69+
exception. Anything unusable (None, empty, non-string) used to reach `.startswith()`
70+
or `dict.get()` and blow up with AttributeError/TypeError mid-evaluation.
71+
72+
`using` is required by the AAC schema, `attribute` is optional (see
73+
`_normalize_ranking_filter`), so an absent/None/empty `attribute` is accepted silently.
74+
"""
6775
errors: list[str] = []
6876
fields = viz.query.fields
6977
for filter_key, filter_dict in viz.query.filter_by.items():
7078
if filter_dict.get("type") != "ranking_filter":
7179
continue
72-
using_val = filter_dict.get("using", "")
73-
using_uri = _resolve_alias_to_uri(using_val, fields)
74-
field_def = fields.get(using_val)
75-
is_adhoc_agg = isinstance(field_def, AacQueryField) and bool(field_def.aggregation)
76-
if not using_uri.startswith(("metric/", "fact/")) and not is_adhoc_agg:
77-
errors.append(
78-
f"ranking filter '{filter_key}': using='{using_val}' "
79-
f"resolves to '{using_uri}' — expected a metric/ or fact/ URI"
80-
)
81-
if "attribute" in filter_dict:
82-
attr_val = filter_dict["attribute"]
83-
attr_uri = _resolve_alias_to_uri(attr_val, fields)
84-
if not attr_uri.startswith(("label/", "attribute/")):
80+
using_val = filter_dict.get("using")
81+
if not isinstance(using_val, str) or not using_val:
82+
errors.append(f"ranking filter '{filter_key}': using={using_val!r} — a metric/ or fact/ URI is required")
83+
else:
84+
using_uri = _resolve_alias_to_uri(using_val, fields)
85+
field_def = fields.get(using_val)
86+
is_adhoc_agg = isinstance(field_def, AacQueryField) and bool(field_def.aggregation)
87+
if not using_uri.startswith(("metric/", "fact/")) and not is_adhoc_agg:
8588
errors.append(
86-
f"ranking filter '{filter_key}': attribute='{attr_val}' "
87-
f"resolves to '{attr_uri}' — expected a label/ or attribute/ URI"
89+
f"ranking filter '{filter_key}': using='{using_val}' "
90+
f"resolves to '{using_uri}' — expected a metric/ or fact/ URI"
8891
)
92+
attr_val = filter_dict.get("attribute")
93+
if attr_val is None or attr_val == "":
94+
continue
95+
if not isinstance(attr_val, str):
96+
errors.append(
97+
f"ranking filter '{filter_key}': attribute={attr_val!r} — expected a label/ or attribute/ URI"
98+
)
99+
continue
100+
attr_uri = _resolve_alias_to_uri(attr_val, fields)
101+
if not attr_uri.startswith(("label/", "attribute/")):
102+
errors.append(
103+
f"ranking filter '{filter_key}': attribute='{attr_val}' "
104+
f"resolves to '{attr_uri}' — expected a label/ or attribute/ URI"
105+
)
89106
return len(errors) == 0, errors
90107

91108

@@ -99,11 +116,43 @@ def _normalize_date_filter(filter_dict: dict, _fields: dict) -> dict:
99116
}
100117

101118

102-
def _normalize_ranking_filter(filter_dict: dict, fields: dict[str, AacQueryField | str]) -> dict:
119+
def _sole_dimension_uri(viz: CreatedVisualization) -> str | None:
120+
"""URI of the visualization's only dimension, or None when it has zero or several."""
121+
dim_uris = get_dimension_uri_set(viz)
122+
return next(iter(dim_uris)) if len(dim_uris) == 1 else None
123+
124+
125+
def _normalize_ranking_filter(
126+
filter_dict: dict,
127+
fields: dict[str, AacQueryField | str],
128+
sole_dim_uri: str | None = None,
129+
) -> dict:
130+
"""Canonicalize a ranking filter so equivalent filters compare equal.
131+
132+
`attribute` is optional in the AAC schema (gen-ai models it as `NotRequired[str]` /
133+
`str | None`), and when it is omitted AFM ranks over every dimension of the result. For a
134+
single-dimension visualization that is exactly "rank by that one dimension", so an omitted
135+
attribute is filled in with `sole_dim_uri` instead of comparing as an empty string — the
136+
agent and the dataset may legitimately express the same filter either way.
137+
138+
The substitution is deliberately gated on there being exactly ONE dimension: with two or
139+
more, omitting `attribute` ranks over the dimension *tuple*, which is a different filter,
140+
so those stay strict. Callers pass the sole dimension of the visualization the filter
141+
belongs to, which makes the comparison symmetric — it does not matter which side omitted it.
142+
143+
Missing, None and "" are all treated as "not specified"; so is a non-string, which
144+
`validate_cross_references` reports separately rather than crashing the comparison.
145+
"""
146+
attr_val = filter_dict.get("attribute")
147+
if not isinstance(attr_val, str) or not attr_val:
148+
dim_uri = sole_dim_uri or ""
149+
else:
150+
dim_uri = _resolve_alias_to_uri(attr_val, fields)
151+
using_val = filter_dict.get("using")
103152
entry: dict = {
104153
"type": "ranking_filter",
105-
"metric_uri": _resolve_alias_to_uri(filter_dict.get("using", ""), fields),
106-
"dim_uri": _resolve_alias_to_uri(filter_dict.get("attribute", ""), fields),
154+
"metric_uri": _resolve_alias_to_uri(using_val, fields) if isinstance(using_val, str) else "",
155+
"dim_uri": dim_uri,
107156
}
108157
if "top" in filter_dict:
109158
entry["top"] = filter_dict["top"]
@@ -127,12 +176,13 @@ def _split_and_normalize_filters(viz: CreatedVisualization) -> tuple[set[str], s
127176
ranking_set: set[str] = set()
128177
attr_set: set[str] = set()
129178
fields = viz.query.fields
179+
sole_dim_uri = _sole_dimension_uri(viz)
130180
for filter_dict in viz.query.filter_by.values():
131181
ft = filter_dict.get("type")
132182
if ft == "date_filter":
133183
date_set.add(json.dumps(_normalize_date_filter(filter_dict, fields), sort_keys=True))
134184
elif ft == "ranking_filter":
135-
ranking_set.add(json.dumps(_normalize_ranking_filter(filter_dict, fields), sort_keys=True))
185+
ranking_set.add(json.dumps(_normalize_ranking_filter(filter_dict, fields, sole_dim_uri), sort_keys=True))
136186
elif ft == "attribute_filter":
137187
attr_set.add(json.dumps(_normalize_attribute_filter(filter_dict, fields), sort_keys=True))
138188
return date_set, ranking_set, attr_set

packages/gooddata-eval/tests/test_scoring.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,100 @@ def test_check_filters_exact_attribute_match():
6464
actual = _viz(query={"fields": {}, "filter_by": f})
6565
scores = check_filters(expected, actual)
6666
assert scores.all_ok is True
67+
68+
69+
# --- ranking-filter `attribute` is optional on single-dimension visualizations (QA-28615) ---
70+
#
71+
# `attribute` is NotRequired in the AAC schema and AFM ranks over the whole result when it is
72+
# absent, so on a one-dimension chart "omitted" and "the sole dimension" mean the same filter.
73+
# The comparator used to demand an exact match and failed those as filters_correct=False.
74+
75+
_M = {"m_sales": {"using": "metric/net_sales"}}
76+
# same URI behind two different aliases — normalization must be alias-independent
77+
_ONE_DIM_A = {**_M, "d_product_id": {"using": "label/product_id"}}
78+
_ONE_DIM_B = {**_M, "d_product": {"using": "label/product_id"}}
79+
_TWO_DIM = {**_M, "d_brand": {"using": "label/product_brand"}, "d_city": {"using": "label/customer_city"}}
80+
81+
82+
def _rank_viz(fields, dims, **filter_overrides):
83+
rank = {"type": "ranking_filter", "using": "m_sales", "top": 1, **filter_overrides}
84+
return _viz(
85+
type="bar_chart",
86+
query={"fields": fields, "filter_by": {"f_rank": rank}},
87+
metrics=["m_sales"],
88+
view_by=dims,
89+
)
90+
91+
92+
def test_ranking_attribute_optional_on_single_dimension_viz():
93+
"""Expected names the attribute, actual omits it — one dimension, so they are equivalent."""
94+
expected = _rank_viz(_ONE_DIM_A, ["d_product_id"], attribute="d_product_id")
95+
actual = _rank_viz(_ONE_DIM_B, ["d_product"])
96+
scores = check_filters(expected, actual)
97+
assert scores.ranking_ok is True
98+
assert scores.all_ok is True
99+
100+
101+
def test_ranking_attribute_optional_is_symmetric():
102+
"""Reverse direction: the dataset omits the attribute and the agent supplies it."""
103+
expected = _rank_viz(_ONE_DIM_A, ["d_product_id"])
104+
actual = _rank_viz(_ONE_DIM_B, ["d_product"], attribute="d_product")
105+
assert check_filters(expected, actual).ranking_ok is True
106+
107+
108+
def test_ranking_attribute_none_and_empty_are_the_same_as_omitted():
109+
expected = _rank_viz(_ONE_DIM_A, ["d_product_id"], attribute="d_product_id")
110+
for omitted in ({"attribute": None}, {"attribute": ""}):
111+
actual = _rank_viz(_ONE_DIM_B, ["d_product"], **omitted)
112+
assert check_filters(expected, actual).ranking_ok is True, omitted
113+
114+
115+
def test_ranking_attribute_still_required_on_multi_dimension_viz():
116+
"""Two dimensions: omitting the attribute ranks over the tuple, so it stays strict."""
117+
expected = _rank_viz(_TWO_DIM, ["d_brand", "d_city"], attribute="d_brand")
118+
actual = _rank_viz(_TWO_DIM, ["d_brand", "d_city"])
119+
assert check_filters(expected, actual).ranking_ok is False
120+
121+
122+
def test_ranking_attribute_omitted_does_not_mask_a_wrong_top_n():
123+
expected = _rank_viz(_ONE_DIM_A, ["d_product_id"], attribute="d_product_id", top=1)
124+
actual = _rank_viz(_ONE_DIM_B, ["d_product"], top=5)
125+
assert check_filters(expected, actual).ranking_ok is False
126+
127+
128+
def test_ranking_attribute_omitted_does_not_mask_a_wrong_dimension():
129+
expected = _rank_viz(_ONE_DIM_A, ["d_product_id"], attribute="d_product_id")
130+
actual = _rank_viz(_TWO_DIM, ["d_brand"]) # single dim, but a different one
131+
assert check_filters(expected, actual).ranking_ok is False
132+
133+
134+
def test_validate_cross_references_never_raises_on_empty_or_none_uris():
135+
"""Each of these used to raise AttributeError/TypeError instead of returning a score.
136+
137+
Every case carries its expected verdict: `attribute` is optional so None/"" are valid,
138+
while a non-string attribute or a missing/None `using` must be reported as an error.
139+
Asserting the verdict is what stops a malformed filter from silently passing as valid.
140+
"""
141+
cases = [
142+
({"type": "ranking_filter", "using": "m_sales", "top": 5, "attribute": None}, True),
143+
({"type": "ranking_filter", "using": "m_sales", "top": 5, "attribute": ""}, True),
144+
({"type": "ranking_filter", "using": "m_sales", "top": 5, "attribute": []}, False),
145+
({"type": "ranking_filter", "using": None, "top": 5}, False),
146+
({"type": "ranking_filter", "top": 5}, False),
147+
]
148+
for rank, expected_ok in cases:
149+
viz = _viz(query={"fields": _M, "filter_by": {"f_rank": rank}})
150+
ok, errors = validate_cross_references(viz)
151+
assert isinstance(ok, bool) and isinstance(errors, list), rank
152+
assert ok is expected_ok, rank
153+
assert bool(errors) is not expected_ok, rank
154+
155+
156+
def test_validate_cross_references_accepts_omitted_attribute_but_flags_missing_using():
157+
omitted = _viz(query={"fields": _M, "filter_by": {"f": {"type": "ranking_filter", "using": "m_sales", "top": 5}}})
158+
assert validate_cross_references(omitted) == (True, [])
159+
160+
no_using = _viz(query={"fields": _M, "filter_by": {"f": {"type": "ranking_filter", "top": 5}}})
161+
ok, errors = validate_cross_references(no_using)
162+
assert ok is False
163+
assert "is required" in errors[0]

packages/gooddata-eval/tests/test_visualization_evaluator.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,31 @@ def test_evaluator_skill_not_activated_when_wrong_skill_name():
9999
)
100100
result = ev.evaluate(_item(_expected()), chat)
101101
assert result.detail["skill_activated"] is False
102+
103+
104+
def _ranked(attribute: str | None, dim_alias: str = "d_q"):
105+
"""Single-dimension chart with a top-1 ranking filter, optionally naming the attribute."""
106+
rank = {"type": "ranking_filter", "using": "m_rev", "top": 1}
107+
if attribute is not None:
108+
rank["attribute"] = attribute
109+
return {
110+
"id": "x",
111+
"type": "column_chart",
112+
"query": {
113+
"fields": {"m_rev": {"using": "metric/revenue"}, dim_alias: {"using": "label/date.quarter"}},
114+
"filter_by": {"f_rank": rank},
115+
},
116+
"metrics": ["m_rev"],
117+
"view_by": [dim_alias],
118+
}
119+
120+
121+
def test_evaluator_passes_when_agent_omits_ranking_attribute_on_single_dim_viz():
122+
"""QA-28615: the omitted attribute resolves to the sole dimension, so the case must pass."""
123+
ev = get_evaluator("visualization")
124+
expected = _ranked("d_q")
125+
actual = _ranked(None, dim_alias="d_quarter") # different alias, attribute omitted
126+
result = ev.evaluate(_item(expected), _chat_result_with(actual))
127+
assert result.detail["filter_ranking_score"] is True
128+
assert result.detail["filters_correct"] is True
129+
assert result.passed is True

0 commit comments

Comments
 (0)