Skip to content

Commit 73f0c22

Browse files
committed
feat(gooddata-eval): add KDA-skill agentic evaluator
Add kda_skill.py: the evaluator for the KDA (Key Driver Analysis) chatbot skill. Mirrors metric_skill/alert_skill's shape (run_agentic_*/ evaluate_agentic_*/*AssertionError), with a few KDA-specific pieces: - Scope (QA-28800): strict_pass gates only completion (kda_triggered, executed, success, turn_completed), not per-field correctness or latency -- those are logged as informational-only for a follow-up ticket. - A bounded (max_iterations=2) disambiguation safety net: if the agent asks a clarifying question instead of triggering KDA, a simulated user reply (gpt-4o-mini) nudges it forward. A failure in that helper ends only the current run (contained), not the whole evaluation. - KDA-turn latency for the daily report is read from Langfuse, but the trace to read it from is selected by which one actually made the create_key_driver_analysis/execute_key_driver_analysis tool call (paginated observation lookup) -- not by picking the largest-latency trace in the session, which can pick the wrong turn when a case spans more than one (a disambiguation exchange, or a transient-retry the chat SDK does internally). - classify_kda_report_bucket() classifies each run into pass (completed, <=60s) / failed (completed, slower) / error (didn't complete) for a separate daily-report reducer to read back via three Langfuse boolean scores -- distinct from strict_pass, which continues to gate the CI assertion on completion only. _langfuse.py: add an additive Observations API (paginated) and an optional select= override on find_traces_per_conversation (default unchanged: max latency) so KDA's trace selection doesn't touch the default other skills use. JIRA: QA-28800 risk: nonprod
1 parent 17bcb5c commit 73f0c22

3 files changed

Lines changed: 564 additions & 3 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",

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

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import os
99
import time
1010
import uuid
11-
from collections.abc import Iterator
11+
from collections.abc import Callable, Iterator
1212
from contextlib import contextmanager
1313
from datetime import datetime, timedelta, timezone
1414
from typing import Any
@@ -56,6 +56,35 @@ def _ts(v: Any) -> str:
5656
return _TraceListResult([_TraceObj(t) for t in resp.json().get("data", [])])
5757

5858

59+
class _ObservationObj:
60+
"""Duck-type wrapper around a raw Langfuse observation dict."""
61+
62+
def __init__(self, raw: dict) -> None:
63+
self.id: str = raw.get("id", "")
64+
self.name: str | None = raw.get("name")
65+
self.output: Any = raw.get("output")
66+
67+
68+
class _ObservationListResult:
69+
def __init__(self, data: list[_ObservationObj], total_pages: int) -> None:
70+
self.data = data
71+
self.total_pages = total_pages
72+
73+
74+
class _ObservationsAPI:
75+
def __init__(self, client: httpx.Client) -> None:
76+
self._client = client
77+
78+
def list(self, trace_id: str, page: int = 1, limit: int = 100) -> _ObservationListResult:
79+
resp = self._client.get("/api/public/observations", params={"traceId": trace_id, "page": page, "limit": limit})
80+
resp.raise_for_status()
81+
body = resp.json()
82+
meta = body.get("meta") or {}
83+
return _ObservationListResult(
84+
[_ObservationObj(o) for o in body.get("data", [])], total_pages=int(meta.get("totalPages") or 1)
85+
)
86+
87+
5988
class _DatasetRunItemsAPI:
6089
def __init__(self, client: httpx.Client) -> None:
6190
self._client = client
@@ -83,6 +112,7 @@ def create(
83112
class _LangfuseAPI:
84113
def __init__(self, client: httpx.Client) -> None:
85114
self.trace = _TraceAPI(client)
115+
self.observations = _ObservationsAPI(client)
86116
self.dataset_run_items = _DatasetRunItemsAPI(client)
87117

88118

@@ -247,11 +277,16 @@ def find_traces_per_conversation(
247277
langfuse: Any,
248278
conversation_ids: list[str],
249279
window_start: datetime,
280+
select: Callable[[list[Any]], Any | None] | None = None,
250281
) -> dict[str, Any]:
251-
"""Poll Langfuse until traces matching all conversation_ids are found or retries exhaust."""
282+
"""Check Langfuse for the trace(s) matching each conversation_id; get latency, picking
283+
the right turn via ``select`` (default: largest latency) -- e.g. KDA passes a selector
284+
that picks the turn that actually made the KDA tool call.
285+
"""
252286
if bool(os.environ.get(SKIP_ENV_VAR)):
253287
return dict.fromkeys(conversation_ids)
254288

289+
select = select or (lambda found: max(found, key=lambda t: getattr(t, "latency", None) or 0.0))
255290
by_conv: dict[str, Any] = dict.fromkeys(conversation_ids)
256291
window_end = datetime.now(timezone.utc)
257292
pad = timedelta(seconds=_WINDOW_PADDING_SEC)
@@ -269,7 +304,7 @@ def find_traces_per_conversation(
269304
break
270305
delay *= _BACKOFF
271306
if found:
272-
by_conv[cid] = max(found, key=lambda t: getattr(t, "latency", None) or 0.0)
307+
by_conv[cid] = select(found)
273308
else:
274309
_log.warning(
275310
"[langfuse] No trace found for conversation %s in window [%s, %s]", cid, window_start, window_end

0 commit comments

Comments
 (0)