Skip to content

Commit 4308f3f

Browse files
committed
feat(gooddata-eval): add KDA-skill agentic evaluator
Adds an agentic evaluation runner for the Key Driver Analysis (KDA) skill: drives the create/execute KDA tool-call flow through a live chat session, evaluates completion (kda_triggered/executed/success/turn_completed) plus informational per-field correctness (Measure/Date Attribute/Periods/ Filters/Summary, all kda_-prefixed scores, not yet gated on strict_pass), and logs whole-turn latency plus pass_at_k/pass_power_k for gdc-nas's combo_report.py to bucket into its own daily-report table. Trace selection is deliberately NOT attempted here: a conversation can have several Langfuse traces sharing one session_id (title generation, a disambiguation turn, the actual KDA turn), and picking the right one requires checking observations, which are ingested asynchronously just like latency. This module links whichever trace the default (max-latency) selector finds, like every other skill; combo_report.py resolves the real KDA trace itself, well after the run, when ingestion has settled -- and owns that logic entirely rather than importing it from here, since the cross-repo import never actually resolves in the report-generation job. Also from PR review, including two follow-up independent re-reviews: - log_quality_and_value_scores (used by every agentic skill, not just KDA) no longer treats an unresolved latency/cost as the worst possible outcome -- drops that weighted term and renormalizes instead. - Dedupe _is_asking_clarification (copy-pasted across kda_skill.py, metric_skill.py, and a drifted third copy in conversation.py) into a shared _clarification.py. Only the bare "?"-anywhere check is tightened to require the message end on a question; the "could you"/"please"/"clarif" substring checks stay as broad as before, since they weren't the source of the original false-positive and conversation.py's multi-turn driver relies on their recall. - All 6 informational KDA scores are kda_-prefixed, closing off any future collision with another skill's own score of the same shape. - pass_power_k is now logged (mirrors visualization.py's own pass_at_K/pass_power_K) instead of being computed and discarded. - Replaced an unconditional print() with _log.info, named the 0.01-absolute-tolerance magic number, and fixed a couple of stale/ missing comments (a docstring reference to a removed function, a missing copyright header). JIRA: QA-28800
1 parent d1ab1ad commit 4308f3f

9 files changed

Lines changed: 1162 additions & 22 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030
evaluate_agentic_guardrail,
3131
run_agentic_guardrail,
3232
)
33+
from gooddata_eval.core.agentic.kda_skill import (
34+
AgenticKdaSummary,
35+
KdaEvaluation,
36+
KdaRunResult,
37+
KdaSkillAssertionError,
38+
evaluate_agentic_kda_skill,
39+
run_agentic_kda_skill,
40+
)
3341
from gooddata_eval.core.agentic.metric_skill import (
3442
AgenticMetricSummary,
3543
MetricRunResult,
@@ -56,6 +64,7 @@
5664
"AgenticAlertSummary",
5765
"AgenticGeneralQuestionSummary",
5866
"AgenticGuardrailSummary",
67+
"AgenticKdaSummary",
5968
"AgenticMetricSummary",
6069
"AgenticSearchSummary",
6170
"AgenticRunSummary",
@@ -69,6 +78,9 @@
6978
"GeneralQuestionResult",
7079
"GuardrailAssertionError",
7180
"GuardrailResult",
81+
"KdaEvaluation",
82+
"KdaRunResult",
83+
"KdaSkillAssertionError",
7284
"MetricRunResult",
7385
"MetricSkillAssertionError",
7486
"RunResult",
@@ -81,13 +93,15 @@
8193
"evaluate_agentic_conversation",
8294
"evaluate_agentic_general_question",
8395
"evaluate_agentic_guardrail",
96+
"evaluate_agentic_kda_skill",
8497
"evaluate_agentic_metric_skill",
8598
"evaluate_agentic_search_tool",
8699
"evaluate_agentic_visualization",
87100
"run_agentic_alert_skill",
88101
"run_agentic_conversation",
89102
"run_agentic_general_question",
90103
"run_agentic_guardrail",
104+
"run_agentic_kda_skill",
91105
"run_agentic_metric_skill",
92106
"run_agentic_search_tool",
93107
"run_agentic_visualization",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# (C) 2026 GoodData Corporation. All rights reserved.
2+
"""Shared "is this a clarifying question?" heuristic for agentic skill runners."""
3+
4+
from __future__ import annotations
5+
6+
7+
def is_asking_clarification(text: str) -> bool:
8+
"""True if ``text`` reads as the agent asking the user for input, not a final answer.
9+
10+
Only the bare ``"?"``-anywhere check is tightened to require the message actually END
11+
on a question -- a "?" anywhere in the text also matches a final answer that merely
12+
quotes or rhetorically references a question, which would wrongly keep a single-turn
13+
case going into a simulated-reply retry and could mask a real turn-1 failure behind an
14+
artificial turn-2 pass. The other phrase checks stay substring-anywhere as before: they
15+
weren't the source of that false-positive, and conversation.py's multi-turn, multi-skill
16+
driver (up to 20 clarification rounds, not just KDA's single-turn case) relies on their
17+
broader recall -- narrowing them too would risk the opposite failure, a real
18+
disambiguation message going undetected and being graded as if it were the final answer.
19+
"""
20+
if not text:
21+
return False
22+
t = text.strip().lower()
23+
if t.endswith("?"):
24+
return True
25+
return "could you" in t or "please" in t or "clarif" in t

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ def __init__(self, raw: dict) -> None:
3131
self.id: str = raw.get("id", "")
3232
self.metadata: dict = raw.get("metadata") or {}
3333
self.session_id: str | None = raw.get("sessionId") or raw.get("session_id")
34-
self.latency: float = float(raw.get("latency") or 0.0)
34+
# None (missing/null) is preserved, not coerced to 0.0 -- a trace that hasn't
35+
# finished ingesting has UNKNOWN latency, not zero latency, and callers (e.g.
36+
# log_quality_and_value_scores below, and every skill's own `pt.latency if pt
37+
# else None` gating) rely on that distinction to not treat "unknown" as the best
38+
# possible outcome.
39+
self.latency: float | None = float(raw["latency"]) if raw.get("latency") is not None else None
3540
self.total_cost: float = float(raw.get("totalCost") or raw.get("total_cost") or 0.0)
3641

3742

@@ -358,11 +363,24 @@ def log_quality_and_value_scores(
358363
data_type="NUMERIC",
359364
comment=f"{passed}/{total} strict checks passed",
360365
)
361-
speed = 0.0 if latency_sec is None else max(0.0, 1.0 - latency_sec / _MAX_LATENCY_SEC)
362-
cost_factor = 0.0 if cost_usd is None else max(0.0, 1.0 - cost_usd / _MAX_COST_USD)
363-
value = _QUALITY_WEIGHT * quality + _SPEED_WEIGHT * speed + _COST_WEIGHT * cost_factor
366+
# An unresolved latency/cost (trace not yet settled, price not available) is UNKNOWN,
367+
# not the best (1.0) or worst (0.0) possible outcome -- substituting either would
368+
# silently pull value_score toward one extreme. Drop that weighted term instead and
369+
# renormalize over whichever components do have a real value, so value_score always
370+
# reflects only the signals actually measured for this run.
371+
components = [(_QUALITY_WEIGHT, quality)]
372+
speed = None if latency_sec is None else max(0.0, 1.0 - latency_sec / _MAX_LATENCY_SEC)
373+
if speed is not None:
374+
components.append((_SPEED_WEIGHT, speed))
375+
cost_factor = None if cost_usd is None else max(0.0, 1.0 - cost_usd / _MAX_COST_USD)
376+
if cost_factor is not None:
377+
components.append((_COST_WEIGHT, cost_factor))
378+
weight_total = sum(w for w, _ in components)
379+
value = sum(w * v for w, v in components) / weight_total
364380
latency_str = "unknown" if latency_sec is None else f"{latency_sec:.2f}s"
365381
cost_str = "unknown" if cost_usd is None else f"${cost_usd:.4f}"
382+
speed_str = "n/a" if speed is None else f"{speed:.2f}"
383+
cost_factor_str = "n/a" if cost_factor is None else f"{cost_factor:.2f}"
366384
score_safe(
367385
langfuse,
368386
trace_id,
@@ -371,8 +389,8 @@ def log_quality_and_value_scores(
371389
data_type="NUMERIC",
372390
comment=(
373391
f"{_QUALITY_WEIGHT}*quality({quality:.2f}) + "
374-
f"{_SPEED_WEIGHT}*speed({speed:.2f}) + "
375-
f"{_COST_WEIGHT}*cost({cost_factor:.2f}); "
392+
f"{_SPEED_WEIGHT}*speed({speed_str}) + "
393+
f"{_COST_WEIGHT}*cost({cost_factor_str}), renormalized /{weight_total:.1f}; "
376394
f"latency={latency_str}; cost={cost_str}"
377395
),
378396
)

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from gooddata_sdk import GoodDataSdk
1212
from pydantic import BaseModel
1313

14+
from gooddata_eval.core.agentic._clarification import is_asking_clarification
1415
from gooddata_eval.core.agentic.alert_skill import render_alert_proposal
1516
from gooddata_eval.core.agentic.metric_skill import _delete_metric, _extract_created_metric_ids
1617
from gooddata_eval.core.chat.sse_client import ChatClient
@@ -192,13 +193,6 @@ def _check_output_correct(turn: TurnDefinition, chat_result: ChatResult) -> bool
192193
return None
193194

194195

195-
def _is_asking_clarification(text: str) -> bool:
196-
if not text:
197-
return False
198-
t = text.lower()
199-
return "?" in t or "could you" in t or "please" in t or "clarif" in t
200-
201-
202196
def _get_sim_user_response(agent_message: str, turn: TurnDefinition, expected_output: dict | None) -> str:
203197
"""Generate a simulated user reply to an agent clarification question."""
204198
otype = turn.expected_output_type
@@ -327,7 +321,7 @@ def run_agentic_conversation(
327321
response_text = (chat_result.text_response or "").strip()
328322
if not response_text and chat_result.alert_proposals:
329323
response_text = render_alert_proposal(chat_result.alert_proposals[-1])
330-
asking = _is_asking_clarification(response_text) or bool(chat_result.alert_proposals)
324+
asking = is_asking_clarification(response_text) or bool(chat_result.alert_proposals)
331325
if asking and clarification_turns < max_clarification_turns:
332326
clarification_turns += 1
333327
total_clarification_turns += 1

0 commit comments

Comments
 (0)