55
66import logging
77import os
8- import re
98from dataclasses import dataclass
109
1110from gooddata_eval .core .chat .sse_client import ChatClient
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