Skip to content

Commit 1a4e1a3

Browse files
Merge pull request #1745 from gooddata/snapshot-master-47b32167-to-rel/dev
[bot] Merge master/47b32167 into rel/dev
2 parents d776a56 + 47b3216 commit 1a4e1a3

2 files changed

Lines changed: 112 additions & 3 deletions

File tree

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,31 @@ def _check_metric(expected: CatalogMetricAlert, actual_args: dict) -> bool:
119119
return expected.metric_id == act_metric
120120

121121

122-
def _check_recipients(expected: CatalogMetricAlert, actual_args: dict) -> bool:
122+
def _resolve_internal_recipient_ids(sdk: GoodDataSdk, emails: list[str]) -> set[str]:
123+
"""Best-effort map of expected recipient emails to internal GoodData user ids.
124+
125+
Some notification channels are workspace-restricted to internal users --
126+
`create_metric_alert` then addresses the alert by internal user id
127+
(`internal_recipients`), never by email, so an expected email has to be
128+
resolved before it can be compared against that field. Failures (no
129+
matching user, no permission, network error) are swallowed: the caller
130+
treats an empty result the same as "this delivery path doesn't match",
131+
which is correct -- it doesn't mean the alert itself failed.
132+
"""
133+
if not emails:
134+
return set()
135+
try:
136+
# RSQL quoted-string escaping: backslash first, then the enclosing quote char,
137+
# or an email like o'hara@example.com breaks the filter into invalid RSQL.
138+
escaped = [email.replace("\\", "\\\\").replace("'", "\\'") for email in emails]
139+
quoted = ",".join(f"'{email}'" for email in escaped)
140+
resp = sdk._client.entities_api.get_all_entities_users(filter=f"email=in=({quoted})")
141+
return {u.id for u in (resp.data or [])}
142+
except Exception:
143+
return set()
144+
145+
146+
def _check_recipients(expected: CatalogMetricAlert, actual_args: dict, sdk: GoodDataSdk | None = None) -> bool:
123147
if not expected.recipients:
124148
return True
125149
act_recip_raw = actual_args.get("recipients", actual_args.get("external_recipients"))
@@ -134,7 +158,14 @@ def _check_recipients(expected: CatalogMetricAlert, actual_args: dict) -> bool:
134158
act_recip = act_recip_raw
135159
else:
136160
act_recip = []
137-
return set(expected.recipients) == set(act_recip or [])
161+
if set(expected.recipients) == set(act_recip or []):
162+
return True
163+
act_internal = actual_args.get("internal_recipients")
164+
if sdk is not None and isinstance(act_internal, list) and act_internal:
165+
internal_recipient_ids = _resolve_internal_recipient_ids(sdk, expected.recipients)
166+
if internal_recipient_ids & set(act_internal):
167+
return True
168+
return False
138169

139170

140171
def generate_simulated_alert_response(
@@ -485,7 +516,7 @@ def _run_once(conv_id: str) -> AlertRunResult:
485516
trigger_correct=tool_called and _check_trigger(expected, actual_args),
486517
filters_correct=tool_called and _check_filters(expected, actual_args),
487518
metric_correct=tool_called and _check_metric(expected, actual_args),
488-
recipients_correct=tool_called and _check_recipients(expected, actual_args),
519+
recipients_correct=tool_called and _check_recipients(expected, actual_args, sdk=sdk),
489520
)
490521
return AlertRunResult(
491522
conversation_id=conv_id,

packages/gooddata-eval/tests/test_agentic_alert_skill.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from gooddata_eval.core.agentic.alert_skill import (
66
AlertEvaluation,
77
_check_filters,
8+
_check_recipients,
89
_check_trigger,
910
_deep_subset,
1011
_normalize_expected_output,
@@ -141,6 +142,83 @@ def test_normalize_expected_filters_treats_prose_filters_column_as_unspecified()
141142
assert _check_filters(expected, {"filters": [_ATTR_FILTER]}) is True
142143

143144

145+
def test_check_recipients_matches_external_recipients_without_sdk():
146+
# The common path never needs a network call at all -- confirms adding the
147+
# internal_recipients fallback doesn't force a lookup when it isn't needed.
148+
expected = _normalize_expected_output({"Recipients": ["user@example.com"]})
149+
mock_sdk = MagicMock()
150+
assert _check_recipients(expected, {"recipients": ["user@example.com"]}, sdk=mock_sdk) is True
151+
mock_sdk._client.entities_api.get_all_entities_users.assert_not_called()
152+
153+
154+
def test_check_recipients_matches_internal_recipients_via_resolved_user_id():
155+
# Some notification channels are workspace-restricted to internal users --
156+
# create_metric_alert then addresses the alert by internal user id via
157+
# `internal_recipients`, never by email, so the plain email/external-recipients
158+
# comparison alone can never match this delivery path.
159+
expected = _normalize_expected_output({"Recipients": ["user@example.com"]})
160+
mock_sdk = MagicMock()
161+
mock_sdk._client.entities_api.get_all_entities_users.return_value.data = [
162+
MagicMock(id="user.abc123"),
163+
]
164+
assert _check_recipients(expected, {"internal_recipients": ["user.abc123"]}, sdk=mock_sdk) is True
165+
mock_sdk._client.entities_api.get_all_entities_users.assert_called_once_with(filter="email=in=('user@example.com')")
166+
167+
168+
def test_check_recipients_escapes_apostrophe_in_email_for_rsql_filter():
169+
# o'hara@example.com must not break the RSQL filter string -- the apostrophe
170+
# has to be escaped before interpolation, same as the query engine requires.
171+
expected = _normalize_expected_output({"Recipients": ["o'hara@example.com"]})
172+
mock_sdk = MagicMock()
173+
mock_sdk._client.entities_api.get_all_entities_users.return_value.data = [
174+
MagicMock(id="user.abc123"),
175+
]
176+
assert _check_recipients(expected, {"internal_recipients": ["user.abc123"]}, sdk=mock_sdk) is True
177+
mock_sdk._client.entities_api.get_all_entities_users.assert_called_once_with(
178+
filter="email=in=('o\\'hara@example.com')"
179+
)
180+
181+
182+
def test_check_recipients_resolves_multiple_emails_in_a_single_bulk_request():
183+
# N expected recipients must cost one request, not N -- confirmed against the
184+
# live Users entities API that RSQL `=in=(...)` returns only the matching subset.
185+
expected = _normalize_expected_output({"Recipients": ["a@example.com", "b@example.com"]})
186+
mock_sdk = MagicMock()
187+
mock_sdk._client.entities_api.get_all_entities_users.return_value.data = [
188+
MagicMock(id="user.a"),
189+
MagicMock(id="user.b"),
190+
]
191+
assert _check_recipients(expected, {"internal_recipients": ["user.a", "user.b"]}, sdk=mock_sdk) is True
192+
mock_sdk._client.entities_api.get_all_entities_users.assert_called_once_with(
193+
filter="email=in=('a@example.com','b@example.com')"
194+
)
195+
196+
197+
def test_check_recipients_internal_recipients_mismatch_still_fails():
198+
expected = _normalize_expected_output({"Recipients": ["user@example.com"]})
199+
mock_sdk = MagicMock()
200+
mock_sdk._client.entities_api.get_all_entities_users.return_value.data = [
201+
MagicMock(id="someone.else"),
202+
]
203+
assert _check_recipients(expected, {"internal_recipients": ["user.abc123"]}, sdk=mock_sdk) is False
204+
205+
206+
def test_check_recipients_internal_recipients_without_sdk_fails_gracefully():
207+
# No sdk available to resolve the email -> no crash, just no match (the plain
208+
# external-recipients comparison already ran and failed by this point).
209+
expected = _normalize_expected_output({"Recipients": ["user@example.com"]})
210+
assert _check_recipients(expected, {"internal_recipients": ["user.abc123"]}, sdk=None) is False
211+
212+
213+
def test_check_recipients_resolution_failure_fails_gracefully():
214+
# A lookup error (permissions, network) must not crash the evaluation --
215+
# it just means this comparison path can't match, same as no sdk at all.
216+
expected = _normalize_expected_output({"Recipients": ["user@example.com"]})
217+
mock_sdk = MagicMock()
218+
mock_sdk._client.entities_api.get_all_entities_users.side_effect = RuntimeError("boom")
219+
assert _check_recipients(expected, {"internal_recipients": ["user.abc123"]}, sdk=mock_sdk) is False
220+
221+
144222
def test_alert_evaluation_strict_pass():
145223
ev = AlertEvaluation(
146224
alert_created=True,

0 commit comments

Comments
 (0)