Skip to content

Commit e2e07d3

Browse files
myhoaiclaude
andcommitted
fix(gooddata-eval): three false-negative sources in eval scoring
QA-29230: capture every multipart response part, not just text/visualization/ alertProposal, and render them into what the judge and the simulated user see. QA-29226: normalize whitespace around MAQL punctuation before comparing, in one shared module the agentic and non-agentic metric comparators both use. QA-29225: read the fixture's anomaly granularity and stop telling the simulated user to refuse one, which deadlocked ANOMALY alert items. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 40634f7 commit e2e07d3

18 files changed

Lines changed: 460 additions & 135 deletions

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class CatalogMetricAlert:
2828
"""List of recipient email addresses."""
2929
filters: list | str | None = None
3030
"""Attribute filters applied to the alert condition."""
31+
granularity: str | None = None
32+
"""Detection interval for an ANOMALY alert (DAY/WEEK/MONTH/...). Not a date filter."""
3133

3234
@classmethod
3335
def from_dict(cls, d: dict) -> CatalogMetricAlert:
@@ -46,4 +48,5 @@ def from_dict(cls, d: dict) -> CatalogMetricAlert:
4648
metric_id=d.get("metric_id"),
4749
recipients=recipients,
4850
filters=d.get("filters"),
51+
granularity=d.get("granularity"),
4952
)

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
submit_trace_scoring,
2222
utc_now,
2323
)
24+
from gooddata_eval.core.chat.render import render_answer_text
2425
from gooddata_eval.core.chat.sse_client import ChatClient
2526
from gooddata_eval.core.config import ReasoningEffort
2627
from gooddata_eval.core.models import (
@@ -258,20 +259,33 @@ def generate_simulated_alert_response(
258259
)
259260
elif filters == []:
260261
filters_rule = (
261-
"5. Your alert must have NO filters and NO date/time window — it evaluates over all time. "
262-
"If the agent asks which time period each check should cover, or offers a choice such as "
263-
"'last Day / Week / Month', do NOT pick one: reply that you want no date filter at all, "
264-
"all time. Never invent a period, a granularity or an 'evaluate each run on a X basis' "
265-
"instruction the goal did not ask for.\n"
262+
"5. Your alert must have NO filters and NO date/time window on the metric — it evaluates "
263+
"over all time. If the agent asks which time period each check should cover, or offers a "
264+
"choice such as 'last Day / Week / Month', do NOT pick one: reply that you want no date "
265+
"filter at all, all time.\n"
266266
)
267267
else:
268268
filters_rule = (
269269
"5. Ask only for the filters your original request implies — do not invent an evaluation "
270-
"period, granularity or date window that was not requested. If the agent offers a choice "
270+
"period or date window that was not requested. If the agent offers a choice "
271271
"such as 'last Day / Week / Month' that your request never mentioned, say you do not want "
272272
"a date window.\n"
273273
)
274274

275+
if operator == "ANOMALY":
276+
granularity = expected.granularity or "day"
277+
anomaly_rule = (
278+
"7. This is an ANOMALY alert. Anomaly detection REQUIRES a time granularity, and that "
279+
f"granularity is NOT a date filter. State it in your first reply and repeat it whenever "
280+
f"asked: use {granularity} granularity. Rule 5 constrains filters on the metric only — it "
281+
"never applies to this detection interval, so never refuse to give one.\n"
282+
)
283+
else:
284+
anomaly_rule = (
285+
"7. Do not invent an evaluation period, a granularity or an 'evaluate each run on a X "
286+
"basis' instruction your goal never asked for.\n"
287+
)
288+
275289
original_request = f'Your original request to the agent was: "{question}"\n' if question else ""
276290

277291
system_prompt = (
@@ -295,8 +309,7 @@ def generate_simulated_alert_response(
295309
" Do not wait for the agent to ask — state it alongside the metric and condition answers.\n"
296310
+ filters_rule
297311
+ f"6. Proactively state how often you want to be alerted in your first reply: {trigger_request}. "
298-
" Repeat it if the agent proposes a different cadence.\n"
299-
"Reply concisely and directly."
312+
" Repeat it if the agent proposes a different cadence.\n" + anomaly_rule + "Reply concisely and directly."
300313
)
301314

302315
messages: list = [{"role": "system", "content": system_prompt}]
@@ -435,6 +448,8 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
435448

436449
filters = _normalize_expected_filters(expected)
437450

451+
granularity = _case_insensitive_get(expected, "granularity", "detection granularity")
452+
438453
return CatalogMetricAlert(
439454
operator=operator,
440455
threshold=threshold,
@@ -444,6 +459,7 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
444459
metric_id=metric_id,
445460
recipients=recipients,
446461
filters=filters,
462+
granularity=str(granularity).strip() if granularity else None,
447463
)
448464

449465

@@ -542,6 +558,8 @@ def _run_once(conv_id: str) -> AlertRunResult:
542558
response_text = (chat_result.text_response or "").strip()
543559
if not response_text and chat_result.alert_proposals:
544560
response_text = render_alert_proposal(chat_result.alert_proposals[-1])
561+
if not response_text:
562+
response_text = render_answer_text(chat_result)
545563
# Stop if agent gave a completely empty response (stuck)
546564
if not response_text and not chat_result.tool_call_events:
547565
break

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
)
2323
from gooddata_eval.core.agentic.alert_skill import render_alert_proposal
2424
from gooddata_eval.core.agentic.metric_skill import _delete_metric, _extract_created_metric_ids, _extract_metric_result
25+
from gooddata_eval.core.chat.render import render_answer_text
2526
from gooddata_eval.core.chat.sse_client import ChatClient
2627
from gooddata_eval.core.config import ReasoningEffort
2728
from gooddata_eval.core.models import (
@@ -214,7 +215,7 @@ def _check_output_correct(turn: TurnDefinition, chat_result: ChatResult) -> bool
214215
215216
Returns None when expected_output is absent (presence check only).
216217
"""
217-
from gooddata_eval.core.agentic.metric_skill import _normalize_maql # noqa: PLC0415
218+
from gooddata_eval.core.evaluators._maql import normalize_maql # noqa: PLC0415
218219

219220
otype = turn.expected_output_type
220221
expected = turn.expected_output
@@ -256,7 +257,7 @@ def _check_output_correct(turn: TurnDefinition, chat_result: ChatResult) -> bool
256257
metric_result = _extract_metric_result(chat_result.tool_call_events or [])
257258
if not metric_result:
258259
return False
259-
return _normalize_maql(metric_result.get("maql", "")) == _normalize_maql(expected.get("maql", ""))
260+
return normalize_maql(metric_result.get("maql", "")) == normalize_maql(expected.get("maql", ""))
260261

261262
return None
262263

@@ -451,6 +452,8 @@ def run_agentic_conversation(
451452
response_text = (chat_result.text_response or "").strip()
452453
if not response_text and chat_result.alert_proposals:
453454
response_text = render_alert_proposal(chat_result.alert_proposals[-1])
455+
if not response_text:
456+
response_text = render_answer_text(chat_result)
454457
if not response_text and not chat_result.tool_call_events:
455458
break
456459
if clarification_turns >= max_clarification_turns:

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
submit_trace_scoring,
1616
utc_now,
1717
)
18+
from gooddata_eval.core.chat.render import render_answer_text
1819
from gooddata_eval.core.chat.sse_client import ChatClient
1920
from gooddata_eval.core.config import ReasoningEffort
2021
from gooddata_eval.core.evaluators._llm_judge import JudgeResponseError, LLMJudge, score_run
@@ -112,7 +113,7 @@ def _run_single_general_question(
112113
item_started = time.monotonic()
113114
agent_started = time.monotonic()
114115
chat_result = client.send_message(conversation_id, question, user_context=user_context)
115-
actual_output = (chat_result.text_response or "").strip()
116+
actual_output = render_answer_text(chat_result)
116117
agent_elapsed = time.monotonic() - agent_started
117118
log_timer(
118119
f"[timer] general_question {conversation_id} GoodData response complete after "

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
submit_trace_scoring,
1515
utc_now,
1616
)
17+
from gooddata_eval.core.chat.render import render_answer_text
1718
from gooddata_eval.core.chat.sse_client import ChatClient
1819
from gooddata_eval.core.config import ReasoningEffort
1920
from gooddata_eval.core.evaluators._llm_judge import JudgeResponseError, LLMJudge, score_run
@@ -107,7 +108,7 @@ def _run_single_guardrail(
107108
and the remaining K-1) cannot drift -- they had already duplicated the whole body once.
108109
"""
109110
chat_result = client.send_message(conversation_id, question)
110-
actual_output = (chat_result.text_response or "").strip()
111+
actual_output = render_answer_text(chat_result)
111112
verdict = score_run(judge, input=question, expected_output=expected_output, actual_output=actual_output)
112113
return GuardrailResult(
113114
conversation_id=conversation_id,

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
submit_trace_scoring,
1717
utc_now,
1818
)
19+
from gooddata_eval.core.chat.render import render_answer_text
1920
from gooddata_eval.core.chat.sse_client import ChatClient
2021
from gooddata_eval.core.config import ReasoningEffort
2122
from gooddata_eval.core.models import (
@@ -286,7 +287,7 @@ def _accumulate(result: ChatResult) -> None:
286287
response_id = chat_result.response_id or response_id
287288
_accumulate(chat_result)
288289
create_args, execute_result = _extract_kda_calls(chat_result.tool_call_events or [])
289-
response_text = (chat_result.text_response or "").strip()
290+
response_text = render_answer_text(chat_result)
290291
turn_completed = chat_result.stream_ended and bool(response_text)
291292
if create_args is not None:
292293
# This turn's own time -- the turn that called create, not any earlier

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

Lines changed: 9 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
submit_trace_scoring,
2121
utc_now,
2222
)
23+
from gooddata_eval.core.chat.render import render_answer_text
2324
from gooddata_eval.core.chat.sse_client import ChatClient
2425
from gooddata_eval.core.config import ReasoningEffort
26+
from gooddata_eval.core.evaluators._maql import normalize_maql
2527
from gooddata_eval.core.models import (
2628
AgenticAssertionError,
2729
AgenticEvalOutcome,
@@ -40,76 +42,16 @@
4042
_DEFAULT_K = 1
4143
_DEFAULT_MAX_ITERATIONS = 7
4244

43-
_IFNULL_RE = re.compile(r"IFNULL\s*\([^,]+,\s*0\)", re.IGNORECASE)
44-
_SELECT_WRAP_RE = re.compile(r"^\s*\(\s*SELECT\s*\{([^}]+)\}\s*\)\s*$", re.IGNORECASE)
45-
_INNER_SELECT_RE = re.compile(r"\(\s*SELECT\s*\{([^}]+)\}\s*\)", re.IGNORECASE)
46-
# Matches whichever comes first: a {type/id} identifier reference or a quoted string
47-
# literal -- both are case-sensitive data and must survive casefolding untouched.
48-
# Everything else in MAQL (keywords, operators, numbers, punctuation) carries no
49-
# case-sensitive meaning, per the MAQL reference (SELECT/BY/WHERE/FOR PREVIOUS/etc.
50-
# are case-insensitive; only {..} identifiers and quoted literal values are not).
51-
# Feeds _normalize_maql, the scoring comparator (_best_maql_match) -- do not widen this
52-
# to handle \X escapes without confirming MAQL literals actually support backslash
53-
# escaping (unconfirmed; see PR #1760 review). A wrong guess here silently changes
54-
# maql_correct for the whole eval dataset, not just a hint. _no_where_clause_hint()
55-
# below has its own, separately-scoped regex for that reason.
56-
_PROTECTED_RE = re.compile(r"\{[^}]*\}|\"[^\"]*\"|'[^']*'")
57-
58-
59-
def _strip_outer_parens(s: str) -> str:
60-
"""Strip one balanced layer of outer () if they wrap the entire expression."""
61-
if not (s.startswith("(") and s.endswith(")")):
62-
return s
63-
depth = 0
64-
for i, ch in enumerate(s):
65-
if ch == "(":
66-
depth += 1
67-
elif ch == ")":
68-
depth -= 1
69-
if depth == 0 and i < len(s) - 1:
70-
return s # Closing paren found before end — not a simple outer wrapper
71-
return s[1:-1].strip()
72-
73-
74-
def _casefold_outside_protected(s: str) -> str:
75-
"""Lowercase MAQL keywords/operators while preserving case-sensitive {type/id}
76-
identifiers and quoted string literal values (e.g. WHERE {label/x} = "Active")."""
77-
parts = []
78-
last = 0
79-
for m in _PROTECTED_RE.finditer(s):
80-
parts.append(s[last : m.start()].lower())
81-
parts.append(m.group(0))
82-
last = m.end()
83-
parts.append(s[last:].lower())
84-
return "".join(parts)
85-
86-
87-
def _normalize_maql(maql: str) -> str:
88-
"""Semantic normalisation: strip whitespace, unwrap IFNULL/SELECT wrappers, casefold keywords."""
89-
if not maql:
90-
return ""
91-
m = maql.strip()
92-
m = _IFNULL_RE.sub(
93-
lambda mo: _strip_outer_parens(mo.group(0).split(",")[0].strip()[len("IFNULL(") :].strip()),
94-
m,
95-
)
96-
m = _SELECT_WRAP_RE.sub(r"{\1}", m)
97-
m = _INNER_SELECT_RE.sub(r"{\1}", m)
98-
m = re.sub(r"\{\s+", "{", m)
99-
m = re.sub(r"\s+\}", "}", m)
100-
m = re.sub(r"\s+", " ", m)
101-
return _casefold_outside_protected(m.strip())
102-
10345

10446
def _best_maql_match(actual_maql: str, expected_outputs: list[dict]) -> tuple[bool, str]:
10547
"""Try actual MAQL against every candidate; return (matched, best_expected_maql).
10648
10749
First match wins. First candidate is used for error reporting when none match.
10850
"""
109-
normalized_actual = _normalize_maql(actual_maql)
51+
normalized_actual = normalize_maql(actual_maql)
11052
for candidate in expected_outputs:
11153
expected_maql = candidate.get("maql", "")
112-
if normalized_actual == _normalize_maql(expected_maql):
54+
if normalized_actual == normalize_maql(expected_maql):
11355
return True, expected_maql
11456
return False, expected_outputs[0].get("maql", "") if expected_outputs else ""
11557

@@ -122,9 +64,9 @@ class SimulatedResponseError(RuntimeError):
12264
"""
12365

12466

125-
# Separate from _PROTECTED_RE on purpose: this one only feeds a same-turn LLM-prompt hint
126-
# (see _no_where_clause_hint), never the scoring comparator, so it can afford to consume
127-
# \X escape sequences inside quoted literals without risking maql_correct semantics.
67+
# Separate from evaluators._maql._PROTECTED_RE on purpose: this one only feeds a same-turn
68+
# LLM-prompt hint (see _no_where_clause_hint), never the scoring comparator, so it can afford
69+
# to consume \X escape sequences inside quoted literals without risking maql_correct semantics.
12870
_HINT_PROTECTED_RE = re.compile(r"\{[^}]*\}|\"(?:[^\"\\]|\\.)*\"|'(?:[^'\\]|\\.)*'")
12971

13072

@@ -342,6 +284,8 @@ def _execute_single_metric_run(
342284
metric_result = candidate
343285
break
344286
response_text = (chat_result.text_response or "").strip()
287+
if not response_text:
288+
response_text = render_answer_text(chat_result)
345289
if not response_text and not chat_result.tool_call_events:
346290
break
347291
if _iteration >= max_iterations - 1:

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
submit_trace_scoring,
2020
utc_now,
2121
)
22+
from gooddata_eval.core.chat.render import render_answer_text
2223
from gooddata_eval.core.chat.sse_client import ChatClient
2324
from gooddata_eval.core.config import ReasoningEffort
2425
from gooddata_eval.core.evaluators.visualization import (
@@ -207,12 +208,13 @@ def _execute_single_run(
207208
viz_produced = bool(current_result.created_visualizations and current_result.created_visualizations.objects)
208209
if viz_produced:
209210
break
210-
if not current_result.text_response:
211+
response_text = render_answer_text(current_result)
212+
if not response_text:
211213
break
212214
if iteration >= max_iterations - 1:
213215
break
214216

215-
follow_up = generate_simulated_response(current_result.text_response, simulated_response_guide)
217+
follow_up = generate_simulated_response(response_text, simulated_response_guide)
216218
current_result = client.send_message(conversation_id, follow_up)
217219

218220
skill_activated = _check_visualization_skill_activated(all_tool_call_events)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# (C) 2026 GoodData Corporation
2+
"""Render a chat turn's non-text parts into the prose an evaluator can read."""
3+
4+
import json
5+
6+
from gooddata_eval.core.models import ChatResult
7+
8+
_MAX_UNHANDLED_PART_CHARS = 2000
9+
10+
11+
def render_search_results(part: dict) -> str:
12+
"""Render one ``searchResults`` part as the list of objects it resolved."""
13+
objects = part.get("objects") or []
14+
if not objects:
15+
return ""
16+
lines = []
17+
for obj in objects:
18+
title = str(obj.get("title") or "").strip()
19+
obj_id = str(obj.get("id") or "").strip()
20+
obj_type = str(obj.get("type") or "").strip()
21+
label = f"{title} ({obj_type}/{obj_id})" if obj_id else title
22+
description = str(obj.get("description") or "").strip()
23+
lines.append(f"- {label}: {description}" if description else f"- {label}")
24+
requested = str(part.get("requestedObjectType") or "object").strip()
25+
return f"Search results ({len(objects)} {requested}):\n" + "\n".join(lines)
26+
27+
28+
def render_unhandled_part(part: dict) -> str:
29+
"""Best-effort rendering of a part gd-eval does not model, truncated to a sane size."""
30+
ptype = str(part.get("type") or "unknown")
31+
body = json.dumps({k: v for k, v in part.items() if k != "type"}, ensure_ascii=False)
32+
if len(body) > _MAX_UNHANDLED_PART_CHARS:
33+
body = body[:_MAX_UNHANDLED_PART_CHARS] + "… (truncated)"
34+
return f"[{ptype}]\n{body}"
35+
36+
37+
def render_answer_text(result: ChatResult) -> str:
38+
"""Everything the agent said this turn: prose plus the content-bearing parts.
39+
40+
Alert proposals are excluded -- ``agentic.alert_skill.render_alert_proposal`` renders
41+
those, and importing it here would close a cycle. Returns "" for a turn that produced
42+
nothing, so callers can keep using falsiness as their "the agent is stuck" signal.
43+
"""
44+
chunks = [(result.text_response or "").strip()]
45+
chunks += [render_search_results(part) for part in result.search_results]
46+
chunks += [render_unhandled_part(part) for part in result.unhandled_parts]
47+
return "\n\n".join(chunk for chunk in chunks if chunk)

0 commit comments

Comments
 (0)