Skip to content

Commit 357532e

Browse files
authored
Merge pull request #1685 from gooddata/QA-28379-eval-metric-cleanup
fix(gooddata-eval): delete metrics created during agentic eval runs
2 parents c31c1ec + 16b8722 commit 357532e

5 files changed

Lines changed: 309 additions & 32 deletions

File tree

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from dataclasses import dataclass
1010
from typing import Any
1111

12+
from gooddata_sdk import GoodDataSdk
13+
1214
from gooddata_eval.core.agentic._catalog import CatalogMetricAlert
1315
from gooddata_eval.core.chat.sse_client import ChatClient
1416
from gooddata_eval.core.models import ToolCallEvent
@@ -183,11 +185,14 @@ def generate_simulated_alert_response(
183185
return response.choices[0].message.content or ""
184186

185187

186-
def _delete_alert(client: ChatClient, workspace_id: str, alert_id: str) -> None:
187-
host = str(client._base).split("/api/")[0]
188-
url = f"{host}/api/v1/entities/workspaces/{workspace_id}/automations/{alert_id}"
188+
def _delete_alert(sdk: GoodDataSdk, workspace_id: str, alert_id: str) -> None:
189+
"""Best-effort delete of an alert (automation) created during evaluation.
190+
191+
Uses the GoodData SDK entities API rather than reimplementing the REST call.
192+
Failures are logged, not raised.
193+
"""
189194
try:
190-
client._client.delete(url, headers=client._auth)
195+
sdk._client.entities_api.delete_entity_automations(workspace_id, alert_id)
191196
except Exception as exc:
192197
print(f"[CLEANUP] Failed to delete alert {alert_id}: {exc}")
193198

@@ -327,6 +332,7 @@ def run_agentic_alert_skill(
327332
expected = _normalize_expected_output(expected_output)
328333
run_results: list[AlertRunResult] = []
329334
client = ChatClient(host=host, token=token, workspace_id=workspace_id)
335+
sdk = GoodDataSdk.create(host, token)
330336

331337
def _run_once(conv_id: str) -> AlertRunResult:
332338
alert_id_to_delete: str | None = None
@@ -375,7 +381,7 @@ def _run_once(conv_id: str) -> AlertRunResult:
375381
)
376382
finally:
377383
if alert_id_to_delete:
378-
_delete_alert(client, workspace_id, alert_id_to_delete)
384+
_delete_alert(sdk, workspace_id, alert_id_to_delete)
379385

380386
try:
381387
conv_id_0 = initial_conversation_id if initial_conversation_id is not None else client.create_conversation()

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from dataclasses import dataclass
99
from typing import Literal
1010

11+
from gooddata_sdk import GoodDataSdk
1112
from pydantic import BaseModel
1213

14+
from gooddata_eval.core.agentic.metric_skill import _delete_metric, _extract_created_metric_ids
1315
from gooddata_eval.core.chat.sse_client import ChatClient
1416
from gooddata_eval.core.models import ChatResult, ToolCallEvent
1517
from gooddata_eval.core.scoring import (
@@ -283,11 +285,16 @@ def run_agentic_conversation(
283285
replies before the agent produces the expected output.
284286
"""
285287
client = ChatClient(host=host, token=token, workspace_id=workspace_id)
288+
sdk = GoodDataSdk.create(host, token)
286289
turn_results: list[TurnResult] = []
287290
turn_outputs: dict[str, dict] = {}
288291
total_clarification_turns = 0
289292
conversation_id: str = ""
290293
owns_conversation = False
294+
# Metrics created during this conversation, deleted after it completes so they do
295+
# not persist in the (shared) workspace and get reused by a later test. Deferred to
296+
# the end — a later turn may $ref a metric an earlier turn created.
297+
created_metric_ids: list[str] = []
291298

292299
try:
293300
if initial_conversation_id is not None:
@@ -335,6 +342,11 @@ def run_agentic_conversation(
335342
if metric_data:
336343
turn_outputs[turn.turn_id] = metric_data
337344

345+
# Track every metric created this turn (any turn may create one) for cleanup.
346+
for metric_id in _extract_created_metric_ids(all_tool_calls):
347+
if metric_id not in created_metric_ids:
348+
created_metric_ids.append(metric_id)
349+
338350
turn_results.append(
339351
TurnResult(
340352
turn_id=turn.turn_id,
@@ -351,6 +363,8 @@ def run_agentic_conversation(
351363
finally:
352364
if owns_conversation and conversation_id:
353365
client.delete_conversation(conversation_id)
366+
for metric_id in created_metric_ids:
367+
_delete_metric(sdk, workspace_id, metric_id)
354368
client.close()
355369

356370
activated_all = {skill for tr in turn_results for skill in tr.activated_skills}

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

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from dataclasses import dataclass
99
from typing import Any
1010

11+
from gooddata_sdk import GoodDataSdk
12+
1113
from gooddata_eval.core.chat.sse_client import ChatClient
1214
from gooddata_eval.core.models import ToolCallEvent
1315

@@ -127,6 +129,41 @@ def _extract_metric_result(tool_call_events: list[ToolCallEvent]) -> dict | None
127129
return None
128130

129131

132+
def _extract_created_metric_ids(tool_call_events: list[ToolCallEvent]) -> list[str]:
133+
"""Ids of every metric created by ``create_metric`` calls (a turn may create more than one).
134+
135+
Used for cleanup so no created metric leaks — unlike ``_extract_metric_result``, which
136+
returns only the first result for MAQL evaluation. Shared with conversation evaluation.
137+
"""
138+
metric_ids: list[str] = []
139+
for tc in tool_call_events:
140+
if tc.function_name != "create_metric" or not tc.result:
141+
continue
142+
result_data = tc.parsed_result()
143+
if not result_data:
144+
continue
145+
data = result_data.get("data", result_data)
146+
metric_id = data.get("metric_id") if isinstance(data, dict) else None
147+
if metric_id and metric_id not in metric_ids:
148+
metric_ids.append(metric_id)
149+
return metric_ids
150+
151+
152+
def _delete_metric(sdk: GoodDataSdk, workspace_id: str, metric_id: str) -> None:
153+
"""Delete a metric created during evaluation.
154+
155+
Eval runs share a persistent workspace, so a metric left behind is picked up by
156+
a later test — the agent reuses it (returning ``SELECT {id}`` instead of full
157+
MAQL) and the assertion fails. Deleting the created metric on the way out keeps
158+
the workspace clean for the next run. Best-effort: failures are logged, not raised.
159+
Mirrors ``alert_skill._delete_alert``.
160+
"""
161+
try:
162+
sdk._client.entities_api.delete_entity_metrics(workspace_id, metric_id)
163+
except Exception as exc:
164+
print(f"[CLEANUP] Failed to delete metric {metric_id}: {exc}")
165+
166+
130167
def _is_asking_clarification(text: str) -> bool:
131168
if not text:
132169
return False
@@ -136,41 +173,54 @@ def _is_asking_clarification(text: str) -> bool:
136173

137174
def _execute_single_metric_run(
138175
client: ChatClient,
176+
sdk: GoodDataSdk,
177+
workspace_id: str,
139178
conversation_id: str,
140179
question: str,
141180
expected_outputs: list[dict],
142181
max_iterations: int,
143182
) -> MetricRunResult:
144-
"""Drive one full multi-turn metric-skill conversation and evaluate the result."""
183+
"""Drive one full multi-turn metric-skill conversation and evaluate the result.
184+
185+
Any metric the agent creates during this run is deleted on the way out (see
186+
``_delete_metric``) so it cannot leak into — and be reused by — a later test
187+
sharing the workspace.
188+
"""
145189
primary_expected = expected_outputs[0] if expected_outputs else {}
146190
metric_result: dict | None = None
191+
metric_id_to_delete: str | None = None
147192
turns = 0
148193
current_question = question
149194

150-
for _iteration in range(max_iterations):
151-
turns += 1
152-
chat_result = client.send_message(conversation_id, current_question)
153-
candidate = _extract_metric_result(chat_result.tool_call_events or [])
154-
if candidate is not None:
155-
metric_result = candidate
156-
break
157-
response_text = (chat_result.text_response or "").strip()
158-
if _is_asking_clarification(response_text):
159-
current_question = generate_simulated_response(response_text, primary_expected)
160-
else:
161-
break
162-
163-
actual_maql = (metric_result or {}).get("maql", "")
164-
metric_created = metric_result is not None
165-
maql_correct, _ = _best_maql_match(actual_maql, expected_outputs) if metric_created else (False, "")
166-
return MetricRunResult(
167-
conversation_id=conversation_id,
168-
metric_result=metric_result,
169-
metric_created=metric_created,
170-
actual_maql=actual_maql,
171-
maql_correct=maql_correct,
172-
total_turns=float(turns),
173-
)
195+
try:
196+
for _iteration in range(max_iterations):
197+
turns += 1
198+
chat_result = client.send_message(conversation_id, current_question)
199+
candidate = _extract_metric_result(chat_result.tool_call_events or [])
200+
if candidate is not None:
201+
metric_result = candidate
202+
metric_id_to_delete = candidate.get("metric_id")
203+
break
204+
response_text = (chat_result.text_response or "").strip()
205+
if _is_asking_clarification(response_text):
206+
current_question = generate_simulated_response(response_text, primary_expected)
207+
else:
208+
break
209+
210+
actual_maql = (metric_result or {}).get("maql", "")
211+
metric_created = metric_result is not None
212+
maql_correct, _ = _best_maql_match(actual_maql, expected_outputs) if metric_created else (False, "")
213+
return MetricRunResult(
214+
conversation_id=conversation_id,
215+
metric_result=metric_result,
216+
metric_created=metric_created,
217+
actual_maql=actual_maql,
218+
maql_correct=maql_correct,
219+
total_turns=float(turns),
220+
)
221+
finally:
222+
if metric_id_to_delete:
223+
_delete_metric(sdk, workspace_id, metric_id_to_delete)
174224

175225

176226
def run_agentic_metric_skill(
@@ -191,12 +241,15 @@ def run_agentic_metric_skill(
191241
expected_outputs: list[dict] = expected_output if isinstance(expected_output, list) else [expected_output]
192242
run_results: list[MetricRunResult] = []
193243
client = ChatClient(host=host, token=token, workspace_id=workspace_id)
244+
sdk = GoodDataSdk.create(host, token)
194245

195246
try:
196247
conv_id_0 = initial_conversation_id if initial_conversation_id is not None else client.create_conversation()
197248
try:
198249
run_results.append(
199-
_execute_single_metric_run(client, conv_id_0, question, expected_outputs, max_iterations)
250+
_execute_single_metric_run(
251+
client, sdk, workspace_id, conv_id_0, question, expected_outputs, max_iterations
252+
)
200253
)
201254
finally:
202255
if initial_conversation_id is None: # only delete conversations we created
@@ -206,7 +259,9 @@ def run_agentic_metric_skill(
206259
conv_id = client.create_conversation()
207260
try:
208261
run_results.append(
209-
_execute_single_metric_run(client, conv_id, question, expected_outputs, max_iterations)
262+
_execute_single_metric_run(
263+
client, sdk, workspace_id, conv_id, question, expected_outputs, max_iterations
264+
)
210265
)
211266
finally:
212267
client.delete_conversation(conv_id)

packages/gooddata-eval/tests/test_agentic_conversation.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# SPDX-License-Identifier: LicenseRef-GoodData-Enterprise
33
from unittest.mock import MagicMock, patch
44

5+
import pytest
56
from gooddata_eval.core.agentic.conversation import (
67
ConversationFixture,
78
TurnDefinition,
@@ -12,6 +13,29 @@
1213
from gooddata_eval.core.models import ToolCallEvent
1314

1415

16+
def _skills_tc(*skills):
17+
tc = MagicMock(spec=ToolCallEvent)
18+
tc.function_name = "set_skills"
19+
tc.parsed_arguments = lambda: {"skills": list(skills)}
20+
return tc
21+
22+
23+
def _create_metric_tc(metric_id):
24+
tc = MagicMock(spec=ToolCallEvent)
25+
tc.function_name = "create_metric"
26+
tc.result = "{}" # truthy so cleanup collection processes it; content comes from parsed_result
27+
tc.parsed_result = lambda mid=metric_id: {"data": {"metric_id": mid, "maql": "SELECT 1"}}
28+
return tc
29+
30+
31+
def _metric_turn_result(tool_calls):
32+
r = MagicMock()
33+
r.text_response = "done"
34+
r.created_visualizations = None
35+
r.tool_call_events = tool_calls
36+
return r
37+
38+
1539
def test_turn_definition_model():
1640
t = TurnDefinition(
1741
turn_id="t1",
@@ -169,3 +193,101 @@ def test_run_agentic_conversation_creates_and_deletes_conversation():
169193
assert result.conversation_id == "new-conv"
170194
mock_client.create_conversation.assert_called_once()
171195
mock_client.delete_conversation.assert_called_once_with("new-conv")
196+
197+
198+
def test_run_agentic_conversation_deletes_created_metrics():
199+
mock_client = MagicMock()
200+
mock_client.create_conversation.return_value = "conv-1"
201+
mock_client.send_message.return_value = _metric_turn_result([_skills_tc("metric"), _create_metric_tc("foo_metric")])
202+
203+
fixture = ConversationFixture(
204+
id="test-metric",
205+
expected_skills=["metric"],
206+
turns=[
207+
TurnDefinition(
208+
turn_id="t1",
209+
message="Create a metric counting x",
210+
expected_skill="metric",
211+
expected_output_type="metric",
212+
)
213+
],
214+
)
215+
with (
216+
patch("gooddata_eval.core.agentic.conversation.ChatClient", return_value=mock_client),
217+
patch("gooddata_eval.core.agentic.conversation.GoodDataSdk") as mock_sdk_cls,
218+
):
219+
mock_sdk = mock_sdk_cls.create.return_value
220+
run_agentic_conversation(
221+
host="http://host/api/v1/actions/workspaces/ws1/ai",
222+
token="tok",
223+
workspace_id="ws1",
224+
fixture=fixture,
225+
)
226+
# The metric created during the conversation is deleted after it completes, via the SDK.
227+
mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric")
228+
229+
230+
def _two_metric_turn_fixture():
231+
return ConversationFixture(
232+
id="test-multi",
233+
expected_skills=["metric"],
234+
turns=[
235+
TurnDefinition(
236+
turn_id="t1", message="Create shared", expected_skill="metric", expected_output_type="metric"
237+
),
238+
TurnDefinition(
239+
turn_id="t2", message="Create extra", expected_skill="metric", expected_output_type="metric"
240+
),
241+
],
242+
)
243+
244+
245+
def test_run_agentic_conversation_deletes_every_unique_metric_across_turns():
246+
mock_client = MagicMock()
247+
mock_client.create_conversation.return_value = "conv-1"
248+
# Turn 1 creates "shared"; turn 2 re-creates "shared" (duplicate) and adds "extra".
249+
mock_client.send_message.side_effect = [
250+
_metric_turn_result([_skills_tc("metric"), _create_metric_tc("shared")]),
251+
_metric_turn_result([_skills_tc("metric"), _create_metric_tc("shared"), _create_metric_tc("extra")]),
252+
]
253+
254+
with (
255+
patch("gooddata_eval.core.agentic.conversation.ChatClient", return_value=mock_client),
256+
patch("gooddata_eval.core.agentic.conversation.GoodDataSdk") as mock_sdk_cls,
257+
):
258+
mock_sdk = mock_sdk_cls.create.return_value
259+
run_agentic_conversation(
260+
host="http://host/api/v1/actions/workspaces/ws1/ai",
261+
token="tok",
262+
workspace_id="ws1",
263+
fixture=_two_metric_turn_fixture(),
264+
)
265+
266+
# Metrics from all turns are cleaned up, and each unique id is deleted exactly once.
267+
deleted = sorted(c.args for c in mock_sdk._client.entities_api.delete_entity_metrics.call_args_list)
268+
assert deleted == [("ws1", "extra"), ("ws1", "shared")]
269+
270+
271+
def test_run_agentic_conversation_deletes_metrics_even_when_a_later_turn_raises():
272+
mock_client = MagicMock()
273+
mock_client.create_conversation.return_value = "conv-1"
274+
# Turn 1 creates "m1"; turn 2 blows up mid-run — the finally must still clean up "m1".
275+
mock_client.send_message.side_effect = [
276+
_metric_turn_result([_skills_tc("metric"), _create_metric_tc("m1")]),
277+
RuntimeError("boom"),
278+
]
279+
280+
with (
281+
patch("gooddata_eval.core.agentic.conversation.ChatClient", return_value=mock_client),
282+
patch("gooddata_eval.core.agentic.conversation.GoodDataSdk") as mock_sdk_cls,
283+
pytest.raises(RuntimeError),
284+
):
285+
mock_sdk = mock_sdk_cls.create.return_value
286+
run_agentic_conversation(
287+
host="http://host/api/v1/actions/workspaces/ws1/ai",
288+
token="tok",
289+
workspace_id="ws1",
290+
fixture=_two_metric_turn_fixture(),
291+
)
292+
293+
mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "m1")

0 commit comments

Comments
 (0)