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.
@@ -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 requires_execution_of
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
- requires_execution=requires_execution_of(eo),
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
- `dashboard_summary` items go to the dedicated summary endpoint; everything
51
- else uses the conversational chat endpoint.
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, summary: SummaryClient):
55
+ def __init__(self, chat: ChatClient, by_kind: dict[str, Any]):
55
56
  self._chat = chat
56
- self._summary = summary
57
+ self._by_kind = by_kind
57
58
 
58
59
  def ask(self, item: DatasetItem) -> ChatResult:
59
- if item.test_kind == _SUMMARY_TEST_KIND:
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._summary):
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
- SummaryClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
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
- r"\b(?:should|shall|may|can)\s+i\s+(?:go\s+ahead|proceed|create|save|update|apply|add|set\s+(?:it|this)\s+up)"
43
- r"|\bdo\s+you\s+want\s+me\s+to\s+(?:go\s+ahead|proceed|create|save|update|apply)"
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 do not answer "
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