Skip to content

Commit 378f6c6

Browse files
Merge pull request #1724 from gooddata/snapshot-master-8e972931-to-rel/dev
[bot] Merge master/8e972931 into rel/dev
2 parents bcd9193 + 8e97293 commit 378f6c6

2 files changed

Lines changed: 353 additions & 20 deletions

File tree

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

Lines changed: 112 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@
2727
_TRIGGER_DISPLAY_TO_API = {"Every time": "ALWAYS", "One time": "ONCE"}
2828
_ALWAYS_TRIGGER_VALUES = {"Every time", "ALWAYS", "not specified"}
2929

30+
_TRIGGER_INSTRUCTIONS = {
31+
"ALWAYS": (
32+
"alert me EVERY TIME the condition is met — not once per day, week or month, and not only the first time"
33+
),
34+
"ONCE": "alert me ONLY THE FIRST TIME the condition is met, then stop",
35+
}
36+
3037

3138
def _to_number(value: object) -> float | int | None:
3239
"""Convert string/number to int or float, None on failure."""
@@ -94,9 +101,11 @@ def _check_trigger(expected: CatalogMetricAlert, actual_args: dict) -> bool:
94101

95102
def _check_filters(expected: CatalogMetricAlert, actual_args: dict) -> bool:
96103
exp_filters = expected.filters
97-
act_filters = actual_args.get("filters", actual_args.get("attribute_filters"))
98-
if not exp_filters:
104+
act_filters = actual_args.get("filters", actual_args.get("attribute_filters")) or []
105+
if exp_filters is None:
99106
return True
107+
if not exp_filters:
108+
return not act_filters
100109
if not act_filters:
101110
return False
102111
return _deep_subset(exp_filters, act_filters)
@@ -132,8 +141,15 @@ def generate_simulated_alert_response(
132141
agent_message: str,
133142
expected: CatalogMetricAlert,
134143
conversation_history: list,
144+
question: str = "",
135145
) -> str:
136-
"""Stateful sim-user reply for alert-skill conversation (gpt-4o)."""
146+
"""Stateful sim-user reply for alert-skill conversation (gpt-4o).
147+
148+
``question`` is the fixture's original request. The sim-user is first called with an empty
149+
history — the opening question went straight to the agent, never to the sim-user — so
150+
without it rule 5's "the filters your original request implies" refers to text the model
151+
cannot see. Optional (defaults to "") to keep the signature backwards compatible.
152+
"""
137153
if _OpenAI is None:
138154
raise RuntimeError(
139155
"openai package is required for generate_simulated_alert_response. "
@@ -147,30 +163,83 @@ def generate_simulated_alert_response(
147163

148164
metric = expected.metric_id or "not specified"
149165
operator = expected.operator
150-
threshold = expected.threshold if expected.threshold is not None else "not specified"
166+
# BETWEEN / NOT_BETWEEN carry their value in threshold_from/threshold_to, so `threshold` is
167+
# None for them. Rule 3 asks the sim-user to verify the threshold, and reporting "not
168+
# specified" made it demand the agent delete both bounds of a BETWEEN condition — an
169+
# impossible request that burned every iteration without the alert ever being created.
170+
threshold: str | float | int
171+
if expected.operator in ("BETWEEN", "NOT_BETWEEN") and (
172+
expected.threshold_from is not None or expected.threshold_to is not None
173+
):
174+
threshold = f"between {expected.threshold_from} and {expected.threshold_to}"
175+
elif expected.threshold is not None:
176+
threshold = expected.threshold
177+
else:
178+
threshold = "not specified"
151179
recipients = ", ".join(expected.recipients) if expected.recipients else "not specified"
152180
trigger = expected.trigger
153181
filters = expected.filters
154182

155-
trigger_line = (
156-
f"5. Proactively tell the agent the trigger is '{trigger}' in your first reply.\n"
157-
if trigger not in _ALWAYS_TRIGGER_VALUES
158-
else ""
159-
)
183+
# "not specified" is the normalizer's stand-in for an absent trigger, which the product
184+
# persists as its ALWAYS default and `_check_trigger` asserts as ALWAYS. Both the cadence to
185+
# ask for (rule 6) and the goal text (rule 1) use the resolved value: reporting the raw
186+
# placeholder made rule 3 treat the trigger as unconstrained, so the sim-user would confirm a
187+
# ONCE/ONCE_PER_INTERVAL proposal that the assertion then failed.
188+
trigger_key = "ALWAYS" if trigger in _ALWAYS_TRIGGER_VALUES else trigger
189+
trigger_request = _TRIGGER_INSTRUCTIONS.get(trigger_key, f"set the trigger to {trigger}")
190+
191+
# Three branches, matching the three states of `expected.filters`. `[]` and `None` must not
192+
# share one: telling the sim-user "you want NO filters" on an unstated expectation makes it
193+
# refuse filters the request genuinely implies (e.g. "orders from the United States"), which
194+
# quietly turns that fixture into a weaker test rather than a failing one.
195+
if filters:
196+
filters_rule = (
197+
f"5. Your alert needs exactly these filters and NOTHING else: {filters}. "
198+
"If the agent offers, proposes or asks about any further date/time window, "
199+
"evaluation period or granularity, refuse it and repeat that these are the only "
200+
"filters you want.\n"
201+
)
202+
elif filters == []:
203+
filters_rule = (
204+
"5. Your alert must have NO filters and NO date/time window — it evaluates over all time. "
205+
"If the agent asks which time period each check should cover, or offers a choice such as "
206+
"'last Day / Week / Month', do NOT pick one: reply that you want no date filter at all, "
207+
"all time. Never invent a period, a granularity or an 'evaluate each run on a X basis' "
208+
"instruction the goal did not ask for.\n"
209+
)
210+
else:
211+
filters_rule = (
212+
"5. Ask only for the filters your original request implies — do not invent an evaluation "
213+
"period, granularity or date window that was not requested. If the agent offers a choice "
214+
"such as 'last Day / Week / Month' that your request never mentioned, say you do not want "
215+
"a date window.\n"
216+
)
217+
218+
original_request = f'Your original request to the agent was: "{question}"\n' if question else ""
219+
160220
system_prompt = (
161221
"You are a user requesting creation of an alert for a metric from an AI agent. "
162222
"Respond naturally but always steer toward the exact values you were given.\n"
163-
"Rules you MUST follow:\n"
223+
+ original_request
224+
+ "Rules you MUST follow:\n"
164225
f"1. Your goal: metric={metric}, operator={operator}, threshold={threshold}, "
165-
f"recipients={recipients}, trigger={trigger}" + (f", filters={filters}" if filters else "") + ".\n"
226+
f"recipients={recipients}, trigger={trigger_key}" + (f", filters={filters}" if filters else "") + ".\n"
166227
"2. Never revert or change a decision that was already confirmed in a previous turn.\n"
167-
"3. If the agent shows a final summary and asks for confirmation, verify that the "
168-
" recipients match your goal. If they differ, correct them. "
169-
" Once recipients are correct, say 'Yes, please proceed to create the alert.'\n"
228+
"3. If the agent shows a final summary, an alert proposal or asks for confirmation, check "
229+
" ALL of these against your goal: recipients, trigger (how often you are alerted), "
230+
" filters / time window, threshold and operator. If ANY of them differs — for example the "
231+
" summary says 'once per day/week/month' but your goal is every time, or it lists a date "
232+
" filter you never asked for — do NOT confirm: name the wrong field, state the correct "
233+
" value and ask the agent to fix it. Say 'Yes, please proceed to create the alert.' ONLY "
234+
" when every one of those fields matches your goal.\n"
235+
" A field your goal reports as 'not specified' is one you have NO expectation about: "
236+
" accept whatever the agent chose for it and never ask for it to be removed.\n"
170237
"4. Proactively include your email recipient in your first reply. "
171238
" Do not wait for the agent to ask — state it alongside the metric and condition answers.\n"
172-
+ trigger_line
173-
+ "Reply concisely and directly."
239+
+ filters_rule
240+
+ f"6. Proactively state how often you want to be alerted in your first reply: {trigger_request}. "
241+
" Repeat it if the agent proposes a different cadence.\n"
242+
"Reply concisely and directly."
174243
)
175244

176245
messages: list = [{"role": "system", "content": system_prompt}]
@@ -257,6 +326,29 @@ def _case_insensitive_get(d: dict, *keys: str) -> Any:
257326
return None
258327

259328

329+
_NO_FILTER_MARKERS = ("none", "all time")
330+
331+
332+
def _normalize_expected_filters(expected: dict) -> list | str | None:
333+
"""
334+
* ``Filters`` list -> that list (exact expectation)
335+
* "None (All time)" in either -> ``[]`` (stated: no filters; extras fail)
336+
* anything else / absent -> ``None`` (unstated; filters not asserted)
337+
"""
338+
filters = _case_insensitive_get(expected, "filters")
339+
if isinstance(filters, list):
340+
return filters
341+
time_window = _case_insensitive_get(expected, "time window/filters", "time_window")
342+
for candidate in (filters, time_window):
343+
if isinstance(candidate, str) and any(kw in candidate.lower() for kw in _NO_FILTER_MARKERS):
344+
return []
345+
# Prose that is not a no-filter marker ("Product Category = X") describes a filter without
346+
# encoding it, so it cannot be compared: returning it made `_check_filters` fall through to
347+
# `_deep_subset(str, list)`, which can never match. `None` is what the contract above
348+
# promises — the sim-user derives such filters from the original request instead.
349+
return None
350+
351+
260352
def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
261353
"""Parse expected_output dict into CatalogMetricAlert, accepting display-format or internal-format keys."""
262354
operator = _case_insensitive_get(expected, "operator") or "GREATER_THAN"
@@ -280,9 +372,7 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
280372
else:
281373
recipients = list(raw_recip)
282374

283-
filters = _case_insensitive_get(expected, "filters")
284-
if isinstance(filters, str) and any(kw in filters for kw in ("None", "All time")):
285-
filters = None
375+
filters = _normalize_expected_filters(expected)
286376

287377
return CatalogMetricAlert(
288378
operator=operator,
@@ -377,7 +467,9 @@ def _run_once(conv_id: str) -> AlertRunResult:
377467
# Stop before generating a follow-up for the last iteration
378468
if _iteration >= max_iterations - 1:
379469
break
380-
follow_up = generate_simulated_alert_response(response_text, expected, conversation_history)
470+
follow_up = generate_simulated_alert_response(
471+
response_text, expected, conversation_history, question=question
472+
)
381473
# Record this exchange so the next call has full history
382474
conversation_history.append({"role": "assistant", "content": response_text})
383475
conversation_history.append({"role": "user", "content": follow_up})

0 commit comments

Comments
 (0)