gooddata-eval 1.78.0__py3-none-any.whl → 1.78.1.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gooddata_eval/cli/agentic_runner.py +4 -2
- gooddata_eval/cli/main.py +17 -11
- gooddata_eval/core/_retry.py +71 -0
- gooddata_eval/core/agentic/_conversation_context.py +59 -10
- gooddata_eval/core/agentic/dashboard_skill.py +80 -1
- gooddata_eval/core/agentic/visualization.py +132 -22
- gooddata_eval/core/chat/sse_client.py +17 -62
- gooddata_eval/core/dataset/from_insights.py +10 -6
- gooddata_eval/core/dataset/langfuse_source.py +29 -2
- gooddata_eval/core/evaluators/__init__.py +5 -2
- gooddata_eval/core/evaluators/_rubric.py +94 -0
- gooddata_eval/core/evaluators/summary.py +12 -64
- gooddata_eval/core/evaluators/takeaway.py +245 -0
- gooddata_eval/core/evaluators/visualization.py +296 -6
- gooddata_eval/core/models.py +61 -4
- gooddata_eval/core/number_format.py +179 -0
- gooddata_eval/core/scoring.py +148 -8
- gooddata_eval/core/takeaway/__init__.py +1 -0
- gooddata_eval/core/takeaway/evaluate.py +275 -0
- gooddata_eval/core/takeaway/http_client.py +104 -0
- {gooddata_eval-1.78.0.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/METADATA +123 -6
- {gooddata_eval-1.78.0.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/RECORD +25 -18
- {gooddata_eval-1.78.0.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/WHEEL +0 -0
- {gooddata_eval-1.78.0.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/entry_points.txt +0 -0
- {gooddata_eval-1.78.0.dist-info → gooddata_eval-1.78.1.dev2.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -26,7 +26,7 @@ from gooddata_eval.core.agentic.visualization import evaluate_agentic_visualizat
|
|
|
26
26
|
from gooddata_eval.core.agentic.what_if import evaluate_agentic_what_if
|
|
27
27
|
from gooddata_eval.core.config import ReasoningEffort
|
|
28
28
|
from gooddata_eval.core.evaluators._llm_judge import JudgeResponseError
|
|
29
|
-
from gooddata_eval.core.evaluators.visualization import
|
|
29
|
+
from gooddata_eval.core.evaluators.visualization import expectation_options
|
|
30
30
|
from gooddata_eval.core.models import AgenticEvalOutcome, CreatedVisualization, DatasetItem
|
|
31
31
|
from gooddata_eval.core.runner import EvalReport, ItemReport
|
|
32
32
|
from gooddata_eval.core.timing import TraceRecorder
|
|
@@ -119,6 +119,8 @@ def _parse_visualization_expected(expected_output: Any) -> list[CreatedVisualiza
|
|
|
119
119
|
{"visualization": {...}} or {"visualization": [{...}]} <- single/multi candidate
|
|
120
120
|
[{"visualization": {...}}, ...] <- bare list
|
|
121
121
|
"""
|
|
122
|
+
if isinstance(expected_output, dict) and expected_output.get("expects_visualization") is False:
|
|
123
|
+
return []
|
|
122
124
|
if isinstance(expected_output, dict):
|
|
123
125
|
raw_list = expected_output.get("expected_outputs")
|
|
124
126
|
if raw_list is not None:
|
|
@@ -187,7 +189,7 @@ def _dispatch_agentic(
|
|
|
187
189
|
workspace_id=workspace_id,
|
|
188
190
|
question=item.question,
|
|
189
191
|
expected_outputs=_parse_visualization_expected(eo),
|
|
190
|
-
|
|
192
|
+
options=expectation_options(eo),
|
|
191
193
|
k=k,
|
|
192
194
|
gate=gate,
|
|
193
195
|
agent_id=agent_id,
|
gooddata_eval/cli/main.py
CHANGED
|
@@ -7,7 +7,7 @@ import sys
|
|
|
7
7
|
import threading
|
|
8
8
|
from datetime import datetime, timezone
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import get_args
|
|
10
|
+
from typing import Any, get_args
|
|
11
11
|
|
|
12
12
|
import httpx
|
|
13
13
|
from gooddata_api_client.exceptions import ApiException, ApiTypeError
|
|
@@ -36,32 +36,31 @@ from gooddata_eval.core.reporting.html_report import load_report_files, write_ht
|
|
|
36
36
|
from gooddata_eval.core.reporting.json_report import build_multi_model_report, write_multi_model_report
|
|
37
37
|
from gooddata_eval.core.runner import ItemReport, run_items
|
|
38
38
|
from gooddata_eval.core.summary.http_client import SummaryClient
|
|
39
|
+
from gooddata_eval.core.takeaway.http_client import TakeawayClient
|
|
39
40
|
from gooddata_eval.core.timing import TIMERS_ENV_VAR
|
|
40
41
|
from gooddata_eval.core.workspace import ModelResolutionError, WorkspaceModelController
|
|
41
42
|
|
|
42
43
|
_EXIT_OK = 0
|
|
43
44
|
_EXIT_OPERATIONAL_ERROR = 2
|
|
44
|
-
_SUMMARY_TEST_KIND = "dashboard_summary"
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
class _RoutingBackend:
|
|
48
48
|
"""Dispatch each item to the right backend by test_kind.
|
|
49
49
|
|
|
50
|
-
`
|
|
51
|
-
|
|
50
|
+
Kinds in `by_kind` go to their dedicated endpoint client (`dashboard_summary` to the
|
|
51
|
+
summary endpoint, `document_takeaway` to the takeaway endpoint); everything else uses
|
|
52
|
+
the conversational chat endpoint.
|
|
52
53
|
"""
|
|
53
54
|
|
|
54
|
-
def __init__(self, chat: ChatClient,
|
|
55
|
+
def __init__(self, chat: ChatClient, by_kind: dict[str, Any]):
|
|
55
56
|
self._chat = chat
|
|
56
|
-
self.
|
|
57
|
+
self._by_kind = by_kind
|
|
57
58
|
|
|
58
59
|
def ask(self, item: DatasetItem) -> ChatResult:
|
|
59
|
-
|
|
60
|
-
return self._summary.ask(item)
|
|
61
|
-
return self._chat.ask(item)
|
|
60
|
+
return self._by_kind.get(item.test_kind, self._chat).ask(item)
|
|
62
61
|
|
|
63
62
|
def close(self) -> None:
|
|
64
|
-
for backend in (self._chat, self.
|
|
63
|
+
for backend in (self._chat, *self._by_kind.values()):
|
|
65
64
|
if hasattr(backend, "close"):
|
|
66
65
|
backend.close()
|
|
67
66
|
|
|
@@ -614,7 +613,14 @@ def _run(config: RunConfig) -> int:
|
|
|
614
613
|
turn_timeout_s=config.turn_timeout_s,
|
|
615
614
|
item_timeout_s=config.item_timeout_s,
|
|
616
615
|
),
|
|
617
|
-
|
|
616
|
+
{
|
|
617
|
+
"dashboard_summary": SummaryClient(
|
|
618
|
+
host=config.host, token=config.token, workspace_id=config.workspace_id
|
|
619
|
+
),
|
|
620
|
+
"document_takeaway": TakeawayClient(
|
|
621
|
+
host=config.host, token=config.token, workspace_id=config.workspace_id
|
|
622
|
+
),
|
|
623
|
+
},
|
|
618
624
|
)
|
|
619
625
|
try:
|
|
620
626
|
single_report = run_items(
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# (C) 2026 GoodData Corporation
|
|
2
|
+
"""Bounded exponential-backoff retry shared by the gen-ai HTTP clients.
|
|
3
|
+
|
|
4
|
+
The budget is read from the ``GOODDATA_EVAL_CHAT_*`` environment on every call, so the chat
|
|
5
|
+
and takeaway clients retune together.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import TypeVar
|
|
15
|
+
|
|
16
|
+
_log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
# 500 is here on evidence, not on principle: in one visualization eval batch it hard-failed
|
|
21
|
+
# 10 of 56 runs with zero retry attempts, and every affected question scored normally when the
|
|
22
|
+
# same question/model ran again the next day -- i.e. transient gen-ai faults, not deterministic
|
|
23
|
+
# server bugs. A genuinely deterministic 500 still terminates, just after the bounded backoff.
|
|
24
|
+
RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def int_env(name: str, default: int) -> int:
|
|
28
|
+
"""Read an int from the environment, falling back to ``default`` when unset or blank."""
|
|
29
|
+
raw = os.getenv(name)
|
|
30
|
+
return int(raw) if raw else default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def float_env(name: str, default: float) -> float:
|
|
34
|
+
"""Read a float from the environment, falling back to ``default`` when unset or blank."""
|
|
35
|
+
raw = os.getenv(name)
|
|
36
|
+
return float(raw) if raw else default
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Retry budget defaults, giving a ~2 min worst-case cap per send (5/10/20/40/60s).
|
|
40
|
+
# Each is overridable via env so CI can retune without cutting a new release --
|
|
41
|
+
# read per call rather than at import, so an exported value cannot silently
|
|
42
|
+
# rewrite what a test that patches these attributes expects.
|
|
43
|
+
_MAX_RETRIES_DEFAULT = 5
|
|
44
|
+
_INITIAL_BACKOFF_S_DEFAULT = 5.0
|
|
45
|
+
_BACKOFF_FACTOR_DEFAULT = 2.0
|
|
46
|
+
_MAX_BACKOFF_S_DEFAULT = 60.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def retry_transient(operation: Callable[[], T], *, is_retryable: Callable[[Exception], bool]) -> T:
|
|
50
|
+
"""Run ``operation``; retry retryable failures with bounded exponential backoff."""
|
|
51
|
+
max_retries = int_env("GOODDATA_EVAL_CHAT_MAX_RETRIES", _MAX_RETRIES_DEFAULT)
|
|
52
|
+
delay = float_env("GOODDATA_EVAL_CHAT_INITIAL_BACKOFF_S", _INITIAL_BACKOFF_S_DEFAULT)
|
|
53
|
+
factor = float_env("GOODDATA_EVAL_CHAT_BACKOFF_FACTOR", _BACKOFF_FACTOR_DEFAULT)
|
|
54
|
+
max_backoff = float_env("GOODDATA_EVAL_CHAT_MAX_BACKOFF_S", _MAX_BACKOFF_S_DEFAULT)
|
|
55
|
+
for attempt in range(max_retries + 1): # 0..N => N retries + 1 initial attempt
|
|
56
|
+
try:
|
|
57
|
+
return operation()
|
|
58
|
+
except Exception as exc: # noqa: PERF203 — retry loop: per-attempt try/except is intentional
|
|
59
|
+
if attempt == max_retries or not is_retryable(exc):
|
|
60
|
+
raise
|
|
61
|
+
sleep_s = min(delay, max_backoff)
|
|
62
|
+
_log.warning(
|
|
63
|
+
"Transient gen-ai error (attempt %d/%d): %s; retrying in %.0fs",
|
|
64
|
+
attempt + 1,
|
|
65
|
+
max_retries + 1,
|
|
66
|
+
exc,
|
|
67
|
+
sleep_s,
|
|
68
|
+
)
|
|
69
|
+
time.sleep(sleep_s)
|
|
70
|
+
delay *= factor
|
|
71
|
+
raise AssertionError("unreachable") # loop either returns or raises
|
|
@@ -37,14 +37,36 @@ CONFIRMATION_REPLY = "Yes, go ahead."
|
|
|
37
37
|
# Asked to pick from a list (which dashboard to bind an alert to), a real user picks one.
|
|
38
38
|
NO_ANSWER_REPLY = "Use your best judgement and go ahead. If you need me to pick from a list, take the first option."
|
|
39
39
|
|
|
40
|
-
# Part of the metric and alert skills' designed flow, not a clarification
|
|
40
|
+
# Part of the metric and alert skills' designed flow, not a clarification; so is a retry of a
|
|
41
|
+
# corrected proposal. Keep `use` and `run` out: "Which one should I use?" is a choice question.
|
|
42
|
+
_RETRY_VERBS = r"retry|try\s+(?:(?:it|this|that)\s+)?(?:again|once\s+more)|resubmit"
|
|
43
|
+
_APPROVAL_VERBS = rf"go\s+ahead|proceed|create|save|update|apply|{_RETRY_VERBS}"
|
|
44
|
+
_SHOULD_I = r"\b(?:should|shall|may|can)\s+i\s+"
|
|
45
|
+
_WANT_ME_TO = r"\b(?:do\s+you\s+want|would\s+you\s+like)\s+me\s+to\s+"
|
|
41
46
|
_CONFIRMATION_RE = re.compile(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
r"|\bwould\s+you\s+like\s+me\s+to\s+(?:go\s+ahead|proceed|create|save|update|apply)"
|
|
47
|
+
rf"{_SHOULD_I}(?:{_APPROVAL_VERBS}|add|set\s+(?:it|this)\s+up)\b"
|
|
48
|
+
rf"|{_WANT_ME_TO}(?:{_APPROVAL_VERBS})\b"
|
|
45
49
|
r"|\bplease\s+confirm\b|\bconfirm\s+(?:if|whether|that)\b",
|
|
46
50
|
re.IGNORECASE,
|
|
47
51
|
)
|
|
52
|
+
# "Should I retry with gross or net revenue?" offers a choice, so it stays a question, as does
|
|
53
|
+
# a second option asked right after ("Should I retry? Or adjust the formula first?"). The
|
|
54
|
+
# clause runs to the sentence end: a dot inside a token ("2.5") does not end it. Only a
|
|
55
|
+
# prose "or"/"Or" counts: MAQL writes its OR in uppercase. An "or" that hands the turn back
|
|
56
|
+
# to the user ("..., or would you like to adjust it?", "or not?") is not a second option.
|
|
57
|
+
_NOT_AN_OPTION = (
|
|
58
|
+
r"not\b|(?:would|do)\s+you\s+(?:like|want|prefer)\s+to\b|(?:is|are)\s+there\b"
|
|
59
|
+
r"|anything\b|something\b|if\s+you\b|let\s+me\s+know\b"
|
|
60
|
+
)
|
|
61
|
+
_RETRY_CHOICE_RE = re.compile(
|
|
62
|
+
rf"(?:{_SHOULD_I}|{_WANT_ME_TO})(?:{_RETRY_VERBS})\b(?:[^?.!]|\.(?=\S))*"
|
|
63
|
+
rf"(?:[?.!]\s+)?(?:(?-i:\b[Oo]r\b)|\balternatively\b)(?!\s+(?:{_NOT_AN_OPTION}))",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
# Inline code, {type/id} references and quoted literals are the proposal itself; an OR
|
|
67
|
+
# inside a formula is not a choice offered to the user. A single quote counts only outside a
|
|
68
|
+
# word, so the apostrophes in "don't ... it's" do not mask the text between them.
|
|
69
|
+
_FORMULA_SPAN_RE = re.compile(r"`+[^`]*`+|\{[^{}]*\}|\"[^\"\n]*\"|(?<!\w)'[^'\n]*'(?!\w)")
|
|
48
70
|
|
|
49
71
|
# Checked after _CONFIRMATION_RE, so "please confirm" stays a confirmation.
|
|
50
72
|
_REQUEST_RE = re.compile(
|
|
@@ -77,7 +99,7 @@ def classify_reply(chat_result: ChatResult, rendered_text: str) -> ReplyKind:
|
|
|
77
99
|
text = (rendered_text or "").strip()
|
|
78
100
|
if not text:
|
|
79
101
|
return "no_action"
|
|
80
|
-
if _CONFIRMATION_RE.search(text):
|
|
102
|
+
if _CONFIRMATION_RE.search(text) and not _RETRY_CHOICE_RE.search(_FORMULA_SPAN_RE.sub(" ", text)):
|
|
81
103
|
return "confirmation"
|
|
82
104
|
if "?" in text or _REQUEST_RE.search(text):
|
|
83
105
|
return "question"
|
|
@@ -238,6 +260,33 @@ class ClarificationJudge:
|
|
|
238
260
|
|
|
239
261
|
_SIM_USER_MODEL = "gpt-4o"
|
|
240
262
|
|
|
263
|
+
# The simulated user plays a business user: choosing the formula is the assistant's job.
|
|
264
|
+
_NO_DEFINITIONS = (
|
|
265
|
+
"Never write MAQL, SQL, formulas or object definitions. If asked to approve one, reply that it is fine to go ahead."
|
|
266
|
+
)
|
|
267
|
+
# An uppercase SELECT counts once a reference, `(`, `*`, DISTINCT or a call follows it, so
|
|
268
|
+
# "SELECT the second option" and "Select the first one" are left alone; in any case it counts
|
|
269
|
+
# once an aggregation call follows, as in "select sum(revenue)". An
|
|
270
|
+
# aggregation call is a formula even without SELECT: in uppercase ("SUM (units)") or in any
|
|
271
|
+
# case with the parenthesis attached ("sum(units) / count(orders)"), so prose such as
|
|
272
|
+
# "Count (all) is fine" is left alone. Lowercase SQL counts by its shape (`*`, a column list,
|
|
273
|
+
# or a table followed by a clause), so "Select the first option from the list" is left alone.
|
|
274
|
+
_MAQL_RE = re.compile(
|
|
275
|
+
r"\bSELECT\s+(?:[{<(*]|DISTINCT\b|\w+\s*\()|(?i:\bselect\s+(?:sum|count|avg|min|max|median|distinct)\s*\()"
|
|
276
|
+
r"|(?i:\bselect\s+(?:\*|[\w.]+(?:\s*,\s*[\w.]+)+)\s+from\b)"
|
|
277
|
+
r"|(?i:\bselect\b[^?!]*?\bfrom\s+[\w.]+\s+(?:where|group\s+by|order\s+by|join)\b)"
|
|
278
|
+
r"|\b(?:SUM|COUNT|AVG|MIN|MAX|MEDIAN)\s*\(|(?i:\b(?:sum|count|avg|min|max|median)\()"
|
|
279
|
+
r"|[{<](?:metric|fact|label|attribute|dataset)/"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _without_maql(reply: str | None) -> str | None:
|
|
284
|
+
"""A reply that slipped MAQL in past the prompt leaves the choice to the assistant.
|
|
285
|
+
|
|
286
|
+
Both callers answer a question, so the fallback must answer one too, not just approve.
|
|
287
|
+
"""
|
|
288
|
+
return NO_ANSWER_REPLY if reply and _MAQL_RE.search(reply) else reply
|
|
289
|
+
|
|
241
290
|
|
|
242
291
|
def _chat(prompt_system: str, prompt_user: str) -> str | None:
|
|
243
292
|
try:
|
|
@@ -271,12 +320,12 @@ def reply_from_facts(transcript: str, question: str, facts: list[str]) -> str:
|
|
|
271
320
|
return NO_ANSWER_REPLY
|
|
272
321
|
reply = _chat(
|
|
273
322
|
"You play a business user talking to an analytics assistant. Answer its question briefly, "
|
|
274
|
-
"using ONLY the facts listed below. Never invent requirements. If the facts
|
|
275
|
-
f"the question, reply exactly: {NO_ANSWER_REPLY}",
|
|
323
|
+
f"using ONLY the facts listed below. Never invent requirements. {_NO_DEFINITIONS} If the facts "
|
|
324
|
+
f"do not answer the question, reply exactly: {NO_ANSWER_REPLY}",
|
|
276
325
|
f"Conversation so far:\n{transcript}\n\nThe assistant now asks:\n{question}\n\n"
|
|
277
326
|
"Facts you know:\n" + "\n".join(f"- {f}" for f in facts),
|
|
278
327
|
)
|
|
279
|
-
return reply or NO_ANSWER_REPLY
|
|
328
|
+
return _without_maql(reply) or NO_ANSWER_REPLY
|
|
280
329
|
|
|
281
330
|
|
|
282
331
|
def reply_restating(transcript: str, question: str) -> str:
|
|
@@ -289,7 +338,7 @@ def reply_restating(transcript: str, question: str) -> str:
|
|
|
289
338
|
"You play a business user talking to an analytics assistant. The assistant just asked for "
|
|
290
339
|
"something that was already said or found earlier in the conversation. Reply briefly, "
|
|
291
340
|
"starting with 'As I said earlier,' and repeat only that information, taken from the "
|
|
292
|
-
"conversation. Do not add anything new.",
|
|
341
|
+
f"conversation. Do not add anything new. {_NO_DEFINITIONS}",
|
|
293
342
|
f"Conversation so far:\n{transcript}\n\nThe assistant now asks:\n{question}",
|
|
294
343
|
)
|
|
295
|
-
return reply or "As I said earlier in this conversation — please use what we already established."
|
|
344
|
+
return _without_maql(reply) or "As I said earlier in this conversation — please use what we already established."
|
|
@@ -40,6 +40,7 @@ from gooddata_eval.core.models import (
|
|
|
40
40
|
build_latency_breakdown,
|
|
41
41
|
shift_and_index_events,
|
|
42
42
|
)
|
|
43
|
+
from gooddata_eval.core.scoring import AAC_VISUALIZATION_TYPES, same_viz_type
|
|
43
44
|
from gooddata_eval.core.timing import PhaseTimings, log_timer, sum_timings
|
|
44
45
|
|
|
45
46
|
_DEFAULT_K = 1
|
|
@@ -582,6 +583,64 @@ def _check_date_bindings(widgets: list[dict], new_ids: set[str], expected: list[
|
|
|
582
583
|
return failures
|
|
583
584
|
|
|
584
585
|
|
|
586
|
+
def _has_chart_types(expected_output: dict) -> bool:
|
|
587
|
+
"""Whether any expected chart states the type it must be placed as."""
|
|
588
|
+
return any(isinstance(v, dict) and "chart_type" in v for v in expected_output.get("visualizations") or [])
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _bodies_by_id(*references: dict) -> dict[str, dict]:
|
|
592
|
+
"""Every chart body the response's references carry, existing and authored, by chart id."""
|
|
593
|
+
bodies: dict[str, dict] = {}
|
|
594
|
+
for refs in references:
|
|
595
|
+
for key in ("visualizations", "new_visualizations"):
|
|
596
|
+
for body in refs.get(key) or []:
|
|
597
|
+
if isinstance(body, dict) and body.get("id"):
|
|
598
|
+
bodies[body["id"]] = body
|
|
599
|
+
return bodies
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _check_chart_types(
|
|
603
|
+
widgets: list[dict], new_ids: set[str], expected: list[dict], bodies: dict[str, dict]
|
|
604
|
+
) -> tuple[list[str], list[str]]:
|
|
605
|
+
"""Every widget of a chart that states a ``chart_type`` must show a chart of exactly that type.
|
|
606
|
+
|
|
607
|
+
A widget names only the chart id, so the type is read from the chart body the references
|
|
608
|
+
carry. The title alone cannot catch a chart authored as another type than the one asked
|
|
609
|
+
for, which is the silent swap GDAI-2054 rules out.
|
|
610
|
+
|
|
611
|
+
Returns the failures and the notes. A widget whose chart has no body in the references
|
|
612
|
+
cannot be checked: gen-ai carries a body only when it could resolve one, so a missing
|
|
613
|
+
body is a platform fault, noted as ``_check_references`` notes a missing id, and not
|
|
614
|
+
scored as the agent's.
|
|
615
|
+
"""
|
|
616
|
+
failures: list[str] = []
|
|
617
|
+
notes: list[str] = []
|
|
618
|
+
for exp in expected:
|
|
619
|
+
if not isinstance(exp, dict) or "chart_type" not in exp:
|
|
620
|
+
continue
|
|
621
|
+
wanted = exp["chart_type"]
|
|
622
|
+
label = exp.get("title") if exp.get("id") is None else exp.get("id")
|
|
623
|
+
candidates = _matching_widgets(widgets, new_ids, exp)
|
|
624
|
+
if not candidates:
|
|
625
|
+
failures.append(f"cannot check the type of chart {label!r}: it is not on the dashboard")
|
|
626
|
+
continue
|
|
627
|
+
types: list[str] = []
|
|
628
|
+
for widget in candidates:
|
|
629
|
+
chart_type = (bodies.get(widget.get("visualization")) or {}).get("type")
|
|
630
|
+
if isinstance(chart_type, str):
|
|
631
|
+
types.append(chart_type)
|
|
632
|
+
else:
|
|
633
|
+
notes.append(
|
|
634
|
+
f"cannot check the type of chart {label!r}: the response's references carry no body "
|
|
635
|
+
f"for {widget.get('visualization')!r}"
|
|
636
|
+
)
|
|
637
|
+
# Every placement, as for date bindings: one right placement must not hide a wrong one.
|
|
638
|
+
if not all(same_viz_type(wanted, t) for t in types):
|
|
639
|
+
found = ", ".join(repr(t) for t in types)
|
|
640
|
+
failures.append(f"chart {label!r} is {found}, expected {wanted!r}")
|
|
641
|
+
return failures, notes
|
|
642
|
+
|
|
643
|
+
|
|
585
644
|
def _tabs_of(dashboard: dict) -> list[dict]:
|
|
586
645
|
"""The dashboard's tabs, reading a document with root sections as its single tab."""
|
|
587
646
|
tabs = dashboard.get("tabs")
|
|
@@ -936,6 +995,12 @@ def _validate_layout_expectations(expected_output: dict) -> None:
|
|
|
936
995
|
for chart in expected_output.get("visualizations") or []:
|
|
937
996
|
if isinstance(chart, dict) and "date_dataset" in chart and not _is_dataset_or_null(chart["date_dataset"]):
|
|
938
997
|
raise ValueError(f"date_dataset must name a date dataset or be null, got {chart!r}")
|
|
998
|
+
if (
|
|
999
|
+
isinstance(chart, dict)
|
|
1000
|
+
and "chart_type" in chart
|
|
1001
|
+
and (not isinstance(chart["chart_type"], str) or chart["chart_type"] not in AAC_VISUALIZATION_TYPES)
|
|
1002
|
+
):
|
|
1003
|
+
raise ValueError(f"chart_type must be an AAC visualization type, got {chart!r}")
|
|
939
1004
|
|
|
940
1005
|
if "tabs" in expected_output:
|
|
941
1006
|
tabs = expected_output["tabs"]
|
|
@@ -1037,6 +1102,7 @@ class _Applies:
|
|
|
1037
1102
|
tab_filters: bool = False
|
|
1038
1103
|
date_filters: bool = False
|
|
1039
1104
|
preserved_widgets: bool = False
|
|
1105
|
+
chart_types: bool = False
|
|
1040
1106
|
|
|
1041
1107
|
@classmethod
|
|
1042
1108
|
def of(cls, expected_output: dict) -> _Applies:
|
|
@@ -1049,6 +1115,7 @@ class _Applies:
|
|
|
1049
1115
|
tab_filters="tab_filters" in expected_output,
|
|
1050
1116
|
date_filters="date_filters" in expected_output,
|
|
1051
1117
|
preserved_widgets="preserved_widgets" in expected_output,
|
|
1118
|
+
chart_types=_has_chart_types(expected_output),
|
|
1052
1119
|
)
|
|
1053
1120
|
|
|
1054
1121
|
|
|
@@ -1089,6 +1156,7 @@ class DashboardEvaluation:
|
|
|
1089
1156
|
tab_filters_correct: bool = False
|
|
1090
1157
|
date_filters_correct: bool = False
|
|
1091
1158
|
widgets_preserved: bool = False
|
|
1159
|
+
chart_types_correct: bool = False
|
|
1092
1160
|
failures: list[str] = field(default_factory=list)
|
|
1093
1161
|
notes: list[str] = field(default_factory=list)
|
|
1094
1162
|
|
|
@@ -1129,6 +1197,8 @@ class DashboardEvaluation:
|
|
|
1129
1197
|
checks["dashboard_date_filters_correct"] = self.date_filters_correct
|
|
1130
1198
|
if self.applies.preserved_widgets:
|
|
1131
1199
|
checks["dashboard_widgets_preserved"] = self.widgets_preserved
|
|
1200
|
+
if self.applies.chart_types:
|
|
1201
|
+
checks["dashboard_chart_types_correct"] = self.chart_types_correct
|
|
1132
1202
|
return checks
|
|
1133
1203
|
|
|
1134
1204
|
@property
|
|
@@ -1280,12 +1350,14 @@ def evaluate_dashboard_response(
|
|
|
1280
1350
|
| {v.get("id") for v in patch_references.get("visualizations") or [] if v.get("id")}
|
|
1281
1351
|
| new_ids
|
|
1282
1352
|
)
|
|
1353
|
+
bodies = _bodies_by_id(base_references, patch_references)
|
|
1283
1354
|
else:
|
|
1284
1355
|
if actual_saved_id is not None:
|
|
1285
1356
|
saved_failures.append(f"a new draft must not be saved yet, but it reports id {actual_saved_id!r}")
|
|
1286
1357
|
document = base
|
|
1287
1358
|
new_ids = {v.get("id") for v in base_references.get("new_visualizations") or [] if v.get("id")}
|
|
1288
1359
|
known_ids = {v.get("id") for v in base_references.get("visualizations") or [] if v.get("id")} | new_ids
|
|
1360
|
+
bodies = _bodies_by_id(base_references)
|
|
1289
1361
|
|
|
1290
1362
|
if document is None:
|
|
1291
1363
|
return DashboardEvaluation(
|
|
@@ -1337,6 +1409,11 @@ def evaluate_dashboard_response(
|
|
|
1337
1409
|
preserved_failures = (
|
|
1338
1410
|
_check_preserved_widgets(widgets, expected_output["preserved_widgets"]) if applies.preserved_widgets else []
|
|
1339
1411
|
)
|
|
1412
|
+
chart_type_failures, chart_type_notes = (
|
|
1413
|
+
_check_chart_types(widgets, new_ids, expected_output.get("visualizations") or [], bodies)
|
|
1414
|
+
if applies.chart_types
|
|
1415
|
+
else ([], [])
|
|
1416
|
+
)
|
|
1340
1417
|
|
|
1341
1418
|
return DashboardEvaluation(
|
|
1342
1419
|
drafted=True,
|
|
@@ -1356,6 +1433,7 @@ def evaluate_dashboard_response(
|
|
|
1356
1433
|
tab_filters_correct=not tab_filter_failures,
|
|
1357
1434
|
date_filters_correct=not date_filter_failures,
|
|
1358
1435
|
widgets_preserved=not preserved_failures,
|
|
1436
|
+
chart_types_correct=not chart_type_failures,
|
|
1359
1437
|
failures=[
|
|
1360
1438
|
*chart_failures,
|
|
1361
1439
|
*saved_failures,
|
|
@@ -1366,9 +1444,10 @@ def evaluate_dashboard_response(
|
|
|
1366
1444
|
*tab_filter_failures,
|
|
1367
1445
|
*date_filter_failures,
|
|
1368
1446
|
*preserved_failures,
|
|
1447
|
+
*chart_type_failures,
|
|
1369
1448
|
*new_failures,
|
|
1370
1449
|
],
|
|
1371
|
-
notes=[*title_notes, *reference_notes],
|
|
1450
|
+
notes=[*title_notes, *reference_notes, *chart_type_notes],
|
|
1372
1451
|
)
|
|
1373
1452
|
|
|
1374
1453
|
|