Skip to content

Commit 6a31c81

Browse files
Merge pull request #1734 from gooddata/snapshot-master-d5e0fa12-to-rel/dev
[bot] Merge master/d5e0fa12 into rel/dev
2 parents b684031 + d5e0fa1 commit 6a31c81

2 files changed

Lines changed: 256 additions & 85 deletions

File tree

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

Lines changed: 89 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import logging
77
import os
8-
import re
98
from dataclasses import dataclass
109

1110
from gooddata_eval.core.chat.sse_client import ChatClient
@@ -15,36 +14,79 @@
1514
_log = logging.getLogger(__name__)
1615

1716
_DEFAULT_K = 1
18-
# Disambiguation safety net only (create+execute always run together in the same
19-
# turn) -- 3 covers metric and period each needing their own clarifying question.
20-
_DEFAULT_MAX_ITERATIONS = 3
17+
# Disambiguation safety net only (create+execute always run together in the same turn) --
18+
# 3 real questions' worth (metric, period, +1 slack) since a simulated reply is now sent on
19+
# every non-final turn (see run_agentic_kda_skill), not just ones classified as a question.
20+
_DEFAULT_MAX_ITERATIONS = 4
2121

2222

23-
def _is_asking_kda_clarification(text: str) -> bool:
24-
"""True if ``text`` reads as the agent asking for input, not a final answer.
25-
26-
KDA-specific, not shared with metric_skill.py/conversation.py -- each skill's
27-
disambiguation heuristic has already drifted independently. Requires the text to
28-
end on "?" (a "?" anywhere also matches a final answer that merely quotes one).
23+
def _build_period_hint(expected_output: dict) -> str | None:
24+
"""Build a period hint from whichever of expected_output's Date Attribute/Analyzed
25+
Period/Reference Period are present -- a question about only one of them (e.g. "which
26+
date dimension?") must still get an answerable hint, not None just because the other
27+
two are absent.
28+
"""
29+
date_attr = expected_output.get("Date Attribute")
30+
analyzed = expected_output.get("Analyzed Period")
31+
reference_period = expected_output.get("Reference Period")
32+
if not (date_attr or analyzed or reference_period):
33+
return None
34+
parts = []
35+
if date_attr:
36+
parts.append(date_attr)
37+
if analyzed and reference_period:
38+
parts.append(f"comparing {analyzed} to {reference_period}")
39+
elif analyzed:
40+
parts.append(f"period {analyzed}")
41+
elif reference_period:
42+
parts.append(f"compared to {reference_period}")
43+
return ", ".join(parts)
44+
45+
46+
def _build_clarification_prompt(
47+
agent_message: str, measure_candidates: dict | list[dict] | None, period_hint: str | None
48+
) -> str:
49+
"""Build the simulated-user prompt, referencing only whatever candidates/period-hint
50+
are actually usable -- an empty/None candidate must drop the "acceptable metric/fact"
51+
clause entirely rather than assert a literal "None" as if it were a real option.
2952
"""
30-
if not text:
31-
return False
32-
t = text.strip().lower()
33-
if t.endswith("?"):
34-
return True
35-
# "To clarify, ..." means "in other words" (a final answer), not a request for one --
36-
# strip it first so "clarif" below only matches genuine clarification requests.
37-
t = re.sub(r"^(just )?to clarify,?\s*", "", t)
38-
return "could you" in t or "please provide" in t or "clarif" in t
39-
40-
41-
def generate_simulated_kda_response(agent_message: str, measure_candidates: dict | list[dict] | None) -> str:
53+
candidates = [
54+
c for c in (measure_candidates if isinstance(measure_candidates, list) else [measure_candidates]) if c
55+
]
56+
reference = ""
57+
if candidates:
58+
candidate_desc = "; or ".join(
59+
f"{c.get('type')} '{c.get('id')}'" + (f" (aggregation {c['aggregation']})" if c.get("aggregation") else "")
60+
for c in candidates
61+
)
62+
reference = f"an acceptable metric/fact is {candidate_desc}"
63+
if period_hint:
64+
reference = (
65+
f"{reference}; the intended time period is {period_hint}"
66+
if reference
67+
else f"the intended time period is {period_hint}"
68+
)
69+
return (
70+
f"You are simulating a user in a conversation with a BI assistant that runs key driver "
71+
f"analysis. The assistant asked: '{agent_message}'. "
72+
+ (f"For reference, {reference}. " if reference else "")
73+
+ "Reply briefly as the user, answering whichever of those the assistant actually asked about."
74+
)
75+
76+
77+
def generate_simulated_kda_response(
78+
agent_message: str,
79+
measure_candidates: dict | list[dict] | None,
80+
period_hint: str | None = None,
81+
) -> str:
4282
"""Generate a user reply to keep the KDA-skill conversation going (gpt-4o-mini).
4383
44-
Used only when the agent asks a clarifying question instead of triggering KDA
45-
directly. Picks *any* candidate from ``measure_candidates`` -- scope only needs KDA
46-
to trigger, not the resulting measure to be exactly right. Always OpenAI regardless
47-
of the combo's own provider -- this is test-harness plumbing, not the system under test.
84+
Called on any turn that didn't trigger KDA, whatever the agent's response actually
85+
said -- most often a clarifying question about the measure, the period, or both, so
86+
both are given as reference and the reply answers whichever was actually asked.
87+
Scope only needs KDA to trigger, not the resulting measure/period to be exactly
88+
right. Always OpenAI regardless of the combo's own provider -- this is
89+
test-harness plumbing, not the system under test.
4890
"""
4991
try:
5092
from openai import OpenAI # noqa: PLC0415
@@ -56,17 +98,7 @@ def generate_simulated_kda_response(agent_message: str, measure_candidates: dict
5698
raise OSError("OPENAI_API_KEY environment variable is not set")
5799

58100
client = OpenAI(api_key=api_key)
59-
candidates = measure_candidates if isinstance(measure_candidates, list) else [measure_candidates or {}]
60-
candidate_desc = "; or ".join(
61-
f"{c.get('type')} '{c.get('id')}'" + (f" (aggregation {c['aggregation']})" if c.get("aggregation") else "")
62-
for c in candidates
63-
)
64-
prompt = (
65-
f"You are simulating a user in a conversation with a BI assistant that runs key driver "
66-
f"analysis. The assistant said: '{agent_message}'. "
67-
f"The user is happy to proceed with any of the following: {candidate_desc}. "
68-
f"Reply briefly as the user, picking whichever of those the assistant offered."
69-
)
101+
prompt = _build_clarification_prompt(agent_message, measure_candidates, period_hint)
70102
response = client.chat.completions.create(
71103
model="gpt-4o-mini",
72104
messages=[{"role": "user", "content": prompt}],
@@ -171,9 +203,12 @@ def run_agentic_kda_skill(
171203
172204
Each run is normally one message, one turn -- create and execute are always called
173205
together in the same turn (the skill's own system prompt: "NO confirmation needed").
174-
The only thing that can extend a run up to ``max_iterations`` turns is the agent
175-
asking a clarifying question instead of triggering KDA directly; a simulated user
176-
reply nudges it forward.
206+
A run only extends past turn 1, up to ``max_iterations``, when the agent's response
207+
has no create call and isn't empty; a simulated user reply is then always sent, with
208+
no attempt to classify whether the text was actually asking for input (matching
209+
visualization.py/alert_skill.py's own break conditions) -- missing a genuine
210+
clarifying question hard-fails the run, while sending one after an unrecognized final
211+
answer only costs one harmless extra turn, so the asymmetry favors never guessing.
177212
"""
178213
if k < 1:
179214
# k=0 or negative would otherwise silently run once, indistinguishable from k=1.
@@ -212,17 +247,22 @@ def _run_once(conv_id: str) -> KdaRunResult:
212247
# execute tool isn't available at all when data-sharing is off for the org).
213248
turn_wall_clock_sec = chat_result.turn_wall_clock_sec
214249
break
250+
if not response_text:
251+
break
215252
if iteration >= max_iterations - 1:
216253
break
217-
if _is_asking_kda_clarification(response_text):
218-
measure_candidates = expected_output.get("Measure") if isinstance(expected_output, dict) else None
219-
try:
220-
current_question = generate_simulated_kda_response(response_text, measure_candidates)
221-
disambiguated = True
222-
except Exception as exc: # noqa: BLE001 -- safety net, not the assertion; end only this run
223-
_log.warning("Simulated KDA user reply failed for conversation %s: %s", conv_id, exc)
224-
break
225-
else:
254+
# No text classification -- matches visualization.py/alert_skill.py: break only on
255+
# the goal signal (create_args set) or an empty response, otherwise always send a
256+
# simulated reply. A false positive (agent had already given a final answer) costs
257+
# one harmless extra turn; a false negative (missing a genuine clarifying question)
258+
# would hard-fail the run, so the asymmetry favors never trying to tell them apart.
259+
measure_candidates = expected_output.get("Measure") if isinstance(expected_output, dict) else None
260+
period_hint = _build_period_hint(expected_output) if isinstance(expected_output, dict) else None
261+
try:
262+
current_question = generate_simulated_kda_response(response_text, measure_candidates, period_hint)
263+
disambiguated = True
264+
except Exception as exc: # noqa: BLE001 -- safety net, not the assertion; end only this run
265+
_log.warning("Simulated KDA user reply failed for conversation %s: %s", conv_id, exc)
226266
break
227267

228268
ev = _evaluate_run(create_args, execute_result, turn_completed, disambiguated)

0 commit comments

Comments
 (0)