Skip to content

Commit 4828198

Browse files
authored
Merge pull request #1784 from gooddata/jt/gdai-2175-eval-alert-attributes
fix(gooddata-eval): assert alert group-by attributes
2 parents a6edd0d + 3bd2a29 commit 4828198

3 files changed

Lines changed: 308 additions & 1 deletion

File tree

packages/gooddata-eval/src/gooddata_eval/core/agentic/_catalog.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class CatalogMetricAlert:
2828
"""List of recipient email addresses."""
2929
filters: list | str | None = None
3030
"""Attribute filters applied to the alert condition."""
31+
attributes: list | None = None
32+
"""Expected group-by attributes; ``None`` means the fixture states no expectation."""
3133

3234
@classmethod
3335
def from_dict(cls, d: dict) -> CatalogMetricAlert:
@@ -46,4 +48,5 @@ def from_dict(cls, d: dict) -> CatalogMetricAlert:
4648
metric_id=d.get("metric_id"),
4749
recipients=recipients,
4850
filters=d.get("filters"),
51+
attributes=d.get("attributes"),
4952
)

packages/gooddata-eval/src/gooddata_eval/core/agentic/alert_skill.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,78 @@ def _check_filters(expected: CatalogMetricAlert, actual_args: dict) -> bool:
127127
return _deep_subset(exp_filters, act_filters)
128128

129129

130+
def _attribute_label_ids(items: list, *, side: str) -> list[str]:
131+
"""Canonicalise group-by entries to bare label ids, whatever spelling they arrive in.
132+
133+
The two sides of the comparison speak different vocabularies for the same grouping.
134+
Fixtures author the AAC tool-input form, ``{"using": "label/x"}``; ``create_metric_alert``
135+
receives the resolved AFM form, ``{"localIdentifier": "a0", "label": {"identifier":
136+
{"id": "x", "type": "label"}}}``, forwarded verbatim from ``prepare_metric_alert_proposal``.
137+
Identity is therefore the only thing they can be compared on.
138+
139+
A shape not listed here, or a URI prefix other than ``label/``, raises: ``label/x`` and
140+
``attribute/x`` are different objects, and an unknown spelling must fail loudly rather
141+
than quietly compare unequal.
142+
"""
143+
if not isinstance(items, list):
144+
raise ValueError(f"Unrecognised {side} group-by attributes, expected a list: {items!r}")
145+
ids: list[str] = []
146+
for item in items:
147+
raw: object = None
148+
if isinstance(item, str):
149+
raw = item
150+
elif isinstance(item, dict):
151+
label = item.get("label")
152+
identifier = item.get("identifier")
153+
if isinstance(item.get("using"), str):
154+
raw = item["using"]
155+
elif isinstance(label, dict) and isinstance(label.get("identifier"), dict):
156+
raw = label["identifier"].get("id")
157+
elif isinstance(identifier, dict):
158+
raw = identifier.get("id")
159+
if not isinstance(raw, str) or not raw:
160+
raise ValueError(f"Unrecognised {side} group-by attribute entry: {item!r}")
161+
prefix, slash, rest = raw.partition("/")
162+
if not slash:
163+
ids.append(raw)
164+
elif prefix == "label" and rest:
165+
ids.append(rest)
166+
else:
167+
raise ValueError(f"Unrecognised {side} group-by attribute reference: {raw!r}")
168+
return ids
169+
170+
171+
def _check_attributes(expected: CatalogMetricAlert, actual_args: dict) -> bool:
172+
"""Compare group-by identity only.
173+
174+
Per-entry properties — ``showAllValues``, the converter-assigned ``localIdentifier`` —
175+
are deliberately not asserted, and the comparison is a multiset so entry order does not
176+
matter.
177+
"""
178+
exp_attributes = expected.attributes
179+
if exp_attributes is None:
180+
return True
181+
act_attributes = actual_args.get("attributes")
182+
if act_attributes is None:
183+
# Arguments are raw `json.loads` output, where an unset nullable argument arrives as
184+
# null rather than absent. Both spellings of "no grouping" have to land on [], which
185+
# is why this is not `actual_args.get("attributes", [])`.
186+
act_attributes = []
187+
elif not isinstance(act_attributes, list):
188+
# An argument that is not a list of groupings is the agent answering wrongly, so it
189+
# scores False. Raising instead would make the runner record an ERROR, and errored
190+
# items are excluded from the failure count — a malformed answer must not rank above
191+
# a merely wrong one. An unreadable *entry* still raises, in `_attribute_label_ids`:
192+
# entries are typed at the tool boundary, so the plausible cause there is the wire
193+
# format moving, which has to be unmissable.
194+
return False
195+
if not exp_attributes:
196+
return not act_attributes
197+
exp_ids = sorted(_attribute_label_ids(exp_attributes, side="expected"))
198+
act_ids = sorted(_attribute_label_ids(act_attributes, side="actual"))
199+
return exp_ids == act_ids
200+
201+
130202
def _check_metric(expected: CatalogMetricAlert, actual_args: dict) -> bool:
131203
if not expected.metric_id:
132204
return True
@@ -335,6 +407,7 @@ class AlertEvaluation:
335407
filters_correct: bool
336408
metric_correct: bool
337409
recipients_correct: bool
410+
attributes_correct: bool = True
338411

339412
@property
340413
def strict_pass(self) -> bool:
@@ -347,6 +420,7 @@ def strict_pass(self) -> bool:
347420
self.filters_correct,
348421
self.metric_correct,
349422
self.recipients_correct,
423+
self.attributes_correct,
350424
]
351425
)
352426

@@ -410,6 +484,35 @@ def _normalize_expected_filters(expected: dict) -> list | str | None:
410484
return None
411485

412486

487+
_NO_GROUPING_MARKERS = ("none", "no grouping")
488+
489+
490+
def _normalize_expected_attributes(expected: dict) -> list | None:
491+
"""
492+
* ``Attributes`` list -> that list (exact expectation)
493+
* "None" / "no grouping" -> ``[]`` (stated: no group-by; extras fail)
494+
* absent, or other prose -> ``None`` (unstated; grouping not asserted)
495+
496+
A date narrows an alert as a group-by as well as a filter, and a group-by makes it fire
497+
per period value instead of on the latest one — so ``[]`` has to be expressible separately
498+
from "absent", exactly as it is for ``filters``.
499+
500+
The simulated user is told nothing about groupings, so a non-empty expectation requires the
501+
item's own question to request that grouping; ``[]`` needs no such support, because the
502+
simulated user does not invent a grouping and the check verifies it did not.
503+
"""
504+
attributes = _case_insensitive_get(expected, "attributes")
505+
if isinstance(attributes, list):
506+
# Validated here so a malformed fixture fails before the run spends an API call.
507+
_attribute_label_ids(attributes, side="expected")
508+
return attributes
509+
if attributes is None:
510+
return None
511+
if isinstance(attributes, str):
512+
return [] if any(kw in attributes.lower() for kw in _NO_GROUPING_MARKERS) else None
513+
raise ValueError(f"Attributes expectation must be a list or a display string, got {type(attributes).__name__}")
514+
515+
413516
def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
414517
"""Parse expected_output dict into CatalogMetricAlert, accepting display-format or internal-format keys."""
415518
operator = _case_insensitive_get(expected, "operator") or "GREATER_THAN"
@@ -434,6 +537,7 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
434537
recipients = list(raw_recip)
435538

436539
filters = _normalize_expected_filters(expected)
540+
attributes = _normalize_expected_attributes(expected)
437541

438542
return CatalogMetricAlert(
439543
operator=operator,
@@ -444,6 +548,7 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
444548
metric_id=metric_id,
445549
recipients=recipients,
446550
filters=filters,
551+
attributes=attributes,
447552
)
448553

449554

@@ -564,6 +669,7 @@ def _run_once(conv_id: str) -> AlertRunResult:
564669
filters_correct=tool_called and _check_filters(expected, actual_args),
565670
metric_correct=tool_called and _check_metric(expected, actual_args),
566671
recipients_correct=tool_called and _check_recipients(expected, actual_args, sdk=sdk),
672+
attributes_correct=tool_called and _check_attributes(expected, actual_args),
567673
)
568674
return AlertRunResult(
569675
conversation_id=conv_id,
@@ -609,6 +715,7 @@ def _run_once(conv_id: str) -> AlertRunResult:
609715
r.eval.filters_correct,
610716
r.eval.metric_correct,
611717
r.eval.recipients_correct,
718+
r.eval.attributes_correct,
612719
]
613720
),
614721
)
@@ -683,6 +790,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
683790
"filters_correct": ev.filters_correct,
684791
"metric_correct": ev.metric_correct,
685792
"recipients_correct": ev.recipients_correct,
793+
"attributes_correct": ev.attributes_correct,
686794
}
687795
with ctx.observe(pt, run_idx) as tid:
688796
for score_name, value in strict_checks.items():
@@ -729,6 +837,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
729837
"filters_correct": ev.filters_correct,
730838
"metric_correct": ev.metric_correct,
731839
"recipients_correct": ev.recipients_correct,
840+
"attributes_correct": ev.attributes_correct,
732841
"actual_alert_arguments": best.actual_alert_arguments,
733842
"latency_breakdown": build_latency_breakdown(best.tool_call_events, best.reasoning_step_events),
734843
}
@@ -739,7 +848,8 @@ def _write_scores(ctx: RunTraceContext) -> None:
739848
f"alert_created={ev.alert_created}, operator_correct={ev.operator_correct}, "
740849
f"threshold_correct={ev.threshold_correct}, trigger_correct={ev.trigger_correct}, "
741850
f"filters_correct={ev.filters_correct}, metric_correct={ev.metric_correct}, "
742-
f"recipients_correct={ev.recipients_correct}. "
851+
f"recipients_correct={ev.recipients_correct}, "
852+
f"attributes_correct={ev.attributes_correct}. "
743853
f"Actual args: {best.actual_alert_arguments}"
744854
)
745855
exc.reasoning_steps = best.reasoning_steps

0 commit comments

Comments
 (0)