Skip to content

Commit 8cbca3c

Browse files
committed
fix(gooddata-eval): scope simulated-user pushback to the original request
generate_simulated_response() only saw the assistant's last message and the ground-truth MAQL, and was instructed to force every clause of that MAQL to be satisfied "even if the assistant's question doesn't explicitly ask about it" -- so it would inject filters/constraints the user's original request never mentioned, even when the assistant's proposal already matched it. - Thread the original question through (metric_skill.py's _execute_single_metric_run already has it in scope; conversation.py's TurnDefinition.message carries the same for multi-turn conversations) and rewrite the prompt to agree when the original request is already satisfied, only adding a clause when it's a reasonable reading of that request -- not an unconditional replay of expected_outputs[0]. - Add an explicit branch for the dominant real case: the assistant asking a clarifying question with no proposal yet. Without it, the simulated user could trivially agree ("nothing proposed yet" == "satisfied") and stall the conversation, burning iterations without ever supplying the agent a usable answer. - Replace fuzzy "is this filter a reasonable reading of the request" judgment with a deterministic _no_filter_hint(): when the ground-truth MAQL has no WHERE clause, the prompt explicitly tells the simulated user no filter is needed, closing the exact loophole that caused the bug. Matches WHERE as a standalone keyword outside {type/id} identifiers and quoted literals (reusing the existing _PROTECTED_RE / same rule as _casefold_outside_protected), so a substring like {metric/somewhere_sales} isn't mistaken for a real clause. - conversation.py's metric branch (forwards to metric_skill.generate_simulated_response) had 0% test coverage behind a bare `except Exception: pass` -- a future signature mismatch would silently fall through to the generic fallback prompt. Log the exception and add a direct unit test for the branch. - Restore the max_tokens >= 300 assertion, and reduce the new tests' reliance on exact prompt-prose assertions in favor of checking the interpolated data and the independently-testable _no_filter_hint() output. Verified locally: ran the full agent_metric_skill (8 cases) and agent_conversations (10 cases) suites against ecommerce_demo on tavern-frank-test -- 18/18 passing with this fix. QA-29094
1 parent 0e0f3dd commit 8cbca3c

4 files changed

Lines changed: 161 additions & 18 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,9 @@ def _get_sim_user_response(agent_message: str, turn: TurnDefinition, expected_ou
205205
generate_simulated_response,
206206
)
207207

208-
return generate_simulated_response(agent_message, expected_output)
209-
except Exception:
210-
pass
208+
return generate_simulated_response(agent_message, expected_output, turn.message)
209+
except Exception as exc:
210+
print(f"[SIM-USER] metric branch failed for turn {turn.turn_id}: {exc}")
211211

212212
# Generic fallback for other skill types or when expected_output is absent
213213
import os # noqa: PLC0415

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

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@
3030
# Everything else in MAQL (keywords, operators, numbers, punctuation) carries no
3131
# case-sensitive meaning, per the MAQL reference (SELECT/BY/WHERE/FOR PREVIOUS/etc.
3232
# are case-insensitive; only {..} identifiers and quoted literal values are not).
33-
_PROTECTED_RE = re.compile(r"\{[^}]*\}|\"[^\"]*\"|'[^']*'")
33+
# The quoted-literal alternatives consume \X escape sequences (including an escaped
34+
# quote) so an escaped quote inside the literal doesn't end the match early and leak
35+
# the rest of the literal's text as unprotected.
36+
_PROTECTED_RE = re.compile(r"\{[^}]*\}|\"(?:[^\"\\]|\\.)*\"|'(?:[^'\\]|\\.)*'")
3437

3538

3639
def _strip_outer_parens(s: str) -> str:
@@ -99,7 +102,26 @@ class SimulatedResponseError(RuntimeError):
99102
"""
100103

101104

102-
def generate_simulated_response(agent_message: str, expected_output: dict) -> str:
105+
def _no_filter_hint(expected_maql: str) -> str:
106+
"""Deterministic nudge for when the ground-truth MAQL has no WHERE clause.
107+
108+
Without this, whether to add a filter is left entirely to the simulating LLM's judgment
109+
of what the original request "implies" -- the same fuzzy reasoning that caused it to
110+
inject an unrequested filter in the first place (QA-29094). Strips {type/id} identifiers
111+
and quoted literals first (same protected-span rule as `_casefold_outside_protected`) so
112+
a "where" substring inside one of those -- e.g. `{metric/somewhere_sales}`, or a literal
113+
value containing the word -- doesn't get mistaken for a real WHERE clause.
114+
"""
115+
outside_protected = _PROTECTED_RE.sub(" ", expected_maql)
116+
if re.search(r"\bWHERE\b", outside_protected, re.IGNORECASE):
117+
return ""
118+
return (
119+
" This metric needs no filter. If the assistant asks about excluding or filtering "
120+
"anything (e.g. cancelled orders, a status, a date range), say no filter is needed."
121+
)
122+
123+
124+
def generate_simulated_response(agent_message: str, expected_output: dict, original_question: str) -> str:
103125
"""Generate a user reply to keep the metric-skill conversation going (gpt-4o-mini).
104126
105127
Raises:
@@ -119,12 +141,19 @@ def generate_simulated_response(agent_message: str, expected_output: dict) -> st
119141
expected_maql = expected_output.get("maql", "")
120142
prompt = (
121143
f"You are simulating a user in a conversation with a BI assistant that creates metrics. "
144+
f"The user's original request was: '{original_question}'. "
122145
f"The assistant said: '{agent_message}'. "
123146
f"The user's ground-truth intended metric is exactly this MAQL: {expected_maql}. "
124-
f"Reply as the user. You MUST ensure every clause of that MAQL (including any WHERE/filter "
125-
f"conditions) is eventually satisfied, and quote field/label identifiers verbatim from it -- "
126-
f"never paraphrase or drop a clause, even if the assistant's question doesn't explicitly ask "
127-
f"about it. If the assistant's offered options omit a required filter, add it yourself."
147+
f"Reply as the user. If the assistant is asking a clarifying question rather than proposing "
148+
f"a metric, answer that question directly using the ground-truth MAQL -- quote field/label "
149+
f"identifiers verbatim -- instead of merely agreeing. "
150+
f"If the assistant's proposal already satisfies the ORIGINAL REQUEST above, agree and confirm "
151+
f"-- do not introduce new requirements the original request never mentioned. "
152+
f"Only if the assistant's proposal is missing something the original request actually implies "
153+
f"(e.g. a filter/clause from the ground-truth MAQL that is a reasonable reading of the original "
154+
f"request), point it out and add it yourself, quoting field/label identifiers verbatim from the "
155+
f"ground-truth MAQL."
156+
f"{_no_filter_hint(expected_maql)}"
128157
)
129158
try:
130159
response = client.chat.completions.create(
@@ -281,7 +310,7 @@ def _execute_single_metric_run(
281310
if _iteration >= max_iterations - 1:
282311
break
283312
try:
284-
current_question = generate_simulated_response(response_text, primary_expected)
313+
current_question = generate_simulated_response(response_text, primary_expected, question)
285314
except SimulatedResponseError as exc:
286315
print(f"[SIM-USER] Simulated reply failed for conversation {conversation_id}: {exc}")
287316
break

packages/gooddata-eval/tests/test_agentic_conversation.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ConversationFixture,
99
TurnDefinition,
1010
TurnResult,
11+
_get_sim_user_response,
1112
_resolve_refs,
1213
evaluate_agentic_conversation,
1314
run_agentic_conversation,
@@ -107,6 +108,30 @@ def test_resolve_refs_substitutes():
107108
assert result == {"maql": "SELECT {metric/foo}"}
108109

109110

111+
def test_get_sim_user_response_metric_branch_forwards_the_turn_message():
112+
"""QA-29094 follow-up: every test in this file patches out `_get_sim_user_response`
113+
itself, so its metric branch (which forwards to
114+
``metric_skill.generate_simulated_response``) had 0% coverage -- a future signature
115+
change there would raise inside the bare ``except Exception`` and silently fall through
116+
to the generic fallback prompt instead of failing loudly."""
117+
turn = TurnDefinition(
118+
turn_id="t1",
119+
message="I need a metric for total ordered units",
120+
expected_skill="metric",
121+
expected_output_type="metric",
122+
)
123+
expected_output = {"maql": "SELECT SUM({fact/order_unit_quantity})"}
124+
125+
with patch(
126+
"gooddata_eval.core.agentic.metric_skill.generate_simulated_response",
127+
return_value="Yes, that works.",
128+
) as mock_sim:
129+
reply = _get_sim_user_response("Should I create this metric?", turn, expected_output)
130+
131+
assert reply == "Yes, that works."
132+
mock_sim.assert_called_once_with("Should I create this metric?", expected_output, turn.message)
133+
134+
110135
def test_run_agentic_conversation_single_turn():
111136
mock_client = MagicMock()
112137
mock_client.create_conversation.return_value = "conv-1"

packages/gooddata-eval/tests/test_agentic_metric_skill.py

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
SimulatedResponseError,
1414
_delete_metric,
1515
_extract_metric_result,
16+
_no_filter_hint,
1617
_normalize_maql,
1718
evaluate_agentic_metric_skill,
1819
generate_simulated_response,
@@ -91,6 +92,38 @@ def test_normalize_maql_removes_select_wrapper():
9192
assert _normalize_maql("(SELECT {metric/abc})") == "{metric/abc}"
9293

9394

95+
def test_no_filter_hint_is_empty_when_the_ground_truth_maql_has_a_where_clause():
96+
assert _no_filter_hint("SELECT {metric/foo} WHERE {label/status} = \"active\"") == ""
97+
98+
99+
def test_no_filter_hint_is_present_when_the_ground_truth_maql_has_no_where_clause():
100+
"""QA-29094 follow-up: whether to add a filter must not be left to the simulating LLM's
101+
judgment of what the original request "implies" -- that fuzzy reasoning is exactly what
102+
caused it to inject an unrequested filter in the first place."""
103+
hint = _no_filter_hint("SELECT SUM({fact/order_unit_quantity})")
104+
assert hint != ""
105+
assert "no filter is needed" in hint
106+
107+
108+
def test_no_filter_hint_ignores_where_inside_an_identifier():
109+
"""CodeRabbit finding on PR #1760: a naive substring check treats the "where" inside
110+
an identifier like {metric/somewhere_sales} as a real WHERE clause and wrongly stays
111+
silent -- it must be stripped as a protected span before matching."""
112+
assert _no_filter_hint("SELECT {metric/somewhere_sales}") != ""
113+
114+
115+
def test_no_filter_hint_ignores_where_inside_a_quoted_literal():
116+
assert _no_filter_hint('SELECT {metric/x} = "somewhere nearby"') != ""
117+
118+
119+
def test_no_filter_hint_ignores_where_inside_a_literal_with_an_escaped_quote():
120+
"""CodeRabbit finding on PR #1760: an escaped quote inside a quoted literal ended the
121+
protected-span match early, leaking the rest of the literal's text -- including a
122+
standalone WHERE -- as unprotected."""
123+
maql = 'SELECT {metric/x} = "Jane\\"s store WHERE something"'
124+
assert _no_filter_hint(maql) != ""
125+
126+
94127
def test_generate_simulated_response_prompt_preserves_maql_fidelity(monkeypatch):
95128
"""Regression test for a live-reproduced bug: the old prompt ("reply briefly",
96129
no instruction to cover clauses the assistant didn't ask about) let the
@@ -111,19 +144,73 @@ def test_generate_simulated_response_prompt_preserves_maql_fidelity(monkeypatch)
111144
monkeypatch.setitem(sys.modules, "openai", fake_openai_module)
112145

113146
expected_output = {"maql": 'SELECT {metric/spend_amount_-_cutcgco} WHERE {label/ecommerce_indicator_code} = "1"'}
114-
generate_simulated_response("Which base metric should I use?", expected_output)
147+
generate_simulated_response("Which base metric should I use?", expected_output, "I need a metric for spend amount")
115148

116149
call_kwargs = mock_client.chat.completions.create.call_args.kwargs
117150
sent_prompt = call_kwargs["messages"][0]["content"]
118151

119152
assert expected_output["maql"] in sent_prompt
120153
assert "verbatim" in sent_prompt
121-
assert "every clause" in sent_prompt
122-
assert "WHERE" in sent_prompt or "filter" in sent_prompt.lower()
123-
assert "reply briefly" not in sent_prompt.lower()
154+
assert "filter" in sent_prompt.lower()
155+
# Guards against a truncated reply mid-MAQL -- the LLM was cutting fidelity short under
156+
# the old, lower budget before this was raised (see the docstring above).
124157
assert call_kwargs["max_tokens"] >= 300
125158

126159

160+
def test_generate_simulated_response_prompt_agrees_when_the_original_request_is_already_satisfied(monkeypatch):
161+
"""Regression test for QA-29094: the old prompt told the simulated user to force every
162+
clause of the ground-truth MAQL regardless of what the original request actually asked
163+
for, so it would inject filters/constraints the user never mentioned even when the
164+
assistant's proposal already matched the request. The prompt must now carry the
165+
original request and instruct the simulated user to agree when it's already satisfied.
166+
"""
167+
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
168+
mock_client = MagicMock()
169+
mock_response = MagicMock()
170+
mock_response.choices = [MagicMock(message=MagicMock(content="ok"))]
171+
mock_client.chat.completions.create.return_value = mock_response
172+
fake_openai_module = types.SimpleNamespace(OpenAI=MagicMock(return_value=mock_client), OpenAIError=Exception)
173+
monkeypatch.setitem(sys.modules, "openai", fake_openai_module)
174+
175+
original_question = "I need a metric for total ordered units called Total Order Quantity"
176+
expected_output = {"maql": "SELECT SUM({fact/order_unit_quantity}) WHERE {fact/order_status} != 'cancelled'"}
177+
generate_simulated_response("Should I create this metric?", expected_output, original_question)
178+
179+
sent_prompt = mock_client.chat.completions.create.call_args.kwargs["messages"][0]["content"]
180+
181+
# Structural checks on the interpolated data -- robust to prompt-wording edits.
182+
assert original_question in sent_prompt
183+
assert expected_output["maql"] in sent_prompt
184+
assert "reply briefly" not in sent_prompt.lower()
185+
# A ground-truth MAQL with a WHERE clause must not trigger the no-filter-needed hint.
186+
assert "no filter is needed" not in sent_prompt
187+
188+
189+
def test_generate_simulated_response_prompt_handles_a_clarifying_question(monkeypatch):
190+
"""QA-29094 follow-up: the two-branch prompt ("already satisfies" / "missing something")
191+
both assume the assistant made a proposal -- but the dominant real case is the assistant
192+
asking a clarifying question first (no proposal exists yet to judge as satisfying or not).
193+
Without an explicit instruction, the simulating LLM could classify "nothing proposed yet"
194+
as trivially "satisfied" and reply "yes, that works", leaving the agent no closer to a
195+
usable metric and burning iterations."""
196+
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
197+
mock_client = MagicMock()
198+
mock_response = MagicMock()
199+
mock_response.choices = [MagicMock(message=MagicMock(content="ok"))]
200+
mock_client.chat.completions.create.return_value = mock_response
201+
fake_openai_module = types.SimpleNamespace(OpenAI=MagicMock(return_value=mock_client), OpenAIError=Exception)
202+
monkeypatch.setitem(sys.modules, "openai", fake_openai_module)
203+
204+
expected_output = {"maql": "SELECT SUM({fact/order_unit_quantity})"}
205+
generate_simulated_response("Which base metric should I use?", expected_output, "I need total ordered units")
206+
207+
sent_prompt = mock_client.chat.completions.create.call_args.kwargs["messages"][0]["content"]
208+
209+
assert "clarifying question" in sent_prompt
210+
# No WHERE clause in the ground truth -- the no-filter hint must fire here too.
211+
assert "no filter is needed" in sent_prompt
212+
213+
127214
def test_normalize_maql_is_case_insensitive_for_keywords():
128215
"""Regression test for a live-reproduced bug: 'FOR PREVIOUS(...)' vs
129216
'FOR Previous(...)' scored as a mismatch even though MAQL keywords are
@@ -228,7 +315,7 @@ def test_run_agentic_metric_skill_closes_client_on_no_result():
228315
mock_client.close.assert_called_once()
229316
assert summary.pass_at_k is False
230317
assert summary.best.metric_created is False
231-
mock_sim.assert_called_once_with("I will work on that.", {"maql": "SELECT {metric/foo}"})
318+
mock_sim.assert_called_once_with("I will work on that.", {"maql": "SELECT {metric/foo}"}, "Create metric foo")
232319

233320

234321
def test_run_agentic_metric_skill_uses_initial_conversation_for_run_0():
@@ -408,15 +495,15 @@ def test_generate_simulated_response_without_an_api_key():
408495
patch.dict(os.environ, {}, clear=True),
409496
pytest.raises(SimulatedResponseError, match="OPENAI_API_KEY"),
410497
):
411-
generate_simulated_response("Which brand field?", {"maql": "SELECT {metric/foo}"})
498+
generate_simulated_response("Which brand field?", {"maql": "SELECT {metric/foo}"}, "I need a metric for foo")
412499

413500

414501
def test_generate_simulated_response_without_the_openai_package():
415502
with (
416503
patch.dict(sys.modules, {"openai": None}),
417504
pytest.raises(SimulatedResponseError, match="openai package is required"),
418505
):
419-
generate_simulated_response("Which brand field?", {"maql": "SELECT {metric/foo}"})
506+
generate_simulated_response("Which brand field?", {"maql": "SELECT {metric/foo}"}, "I need a metric for foo")
420507

421508

422509
def test_run_agentic_metric_skill_fails_the_run_when_the_simulated_reply_cannot_be_generated():
@@ -448,7 +535,9 @@ def test_run_agentic_metric_skill_fails_the_run_when_the_simulated_reply_cannot_
448535
assert summary.best.metric_created is False
449536
assert summary.best.total_turns == 1.0
450537
mock_client.close.assert_called_once()
451-
mock_sim.assert_called_once_with("Which brand field should I count?", {"maql": "SELECT {metric/foo}"})
538+
mock_sim.assert_called_once_with(
539+
"Which brand field should I count?", {"maql": "SELECT {metric/foo}"}, "Create metric foo"
540+
)
452541

453542

454543
def test_run_agentic_metric_skill_accumulates_reasoning_steps_across_iterations():

0 commit comments

Comments
 (0)