gooddata-eval 1.78.1.dev2__py3-none-any.whl → 1.78.1.dev4__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.
@@ -67,6 +67,12 @@ class CatalogMetricAlert:
67
67
  """Expected group-by attributes; ``None`` means the fixture states no expectation."""
68
68
  granularity: AnomalyDetectionGranularity | None = None
69
69
  """Detection interval for an ANOMALY alert (DAY/WEEK/MONTH/...). Not a date filter."""
70
+ comparison: dict[str, str | int | float | None] | None = None
71
+ """Expected period comparison as ``create_metric_alert`` argument names; ``None`` means unasserted."""
72
+ title: str | None = None
73
+ """Alert name the user asked for; ``None`` means unasserted."""
74
+ dashboard_id: str | None = None
75
+ """Dashboard the alert must be bound to; ``None`` means unasserted."""
70
76
 
71
77
  @classmethod
72
78
  def from_dict(cls, d: dict) -> CatalogMetricAlert:
@@ -87,4 +93,7 @@ class CatalogMetricAlert:
87
93
  filters=d.get("filters"),
88
94
  attributes=d.get("attributes"),
89
95
  granularity=AnomalyDetectionGranularity.parse(d.get("granularity")),
96
+ comparison=d.get("comparison"),
97
+ title=d.get("title"),
98
+ dashboard_id=d.get("dashboard_id"),
90
99
  )
@@ -58,6 +58,37 @@ _TRIGGER_DISPLAY_TO_API = {"Every time": "ALWAYS", "One time": "ONCE"}
58
58
  _ALWAYS_TRIGGER_VALUES = {"Every time", "ALWAYS", "not specified"}
59
59
  _RELATIVE_OPERATORS = ("INCREASES_BY", "DECREASES_BY", "CHANGES_BY")
60
60
 
61
+ # Fixture spelling of each period-comparison property -> the create_metric_alert argument.
62
+ _COMPARISON_ARGS = {
63
+ "kind": "comparison_kind",
64
+ "periods_ago": "comparison_periods_ago",
65
+ "date_dataset_id": "comparison_date_dataset_id",
66
+ "date_attribute_id": "comparison_date_attribute_id",
67
+ "arithmetic_operator": "arithmetic_operator",
68
+ }
69
+ # Compared verbatim: object ids are case-sensitive, unlike the enum-valued properties.
70
+ _COMPARISON_ID_ARGS = ("comparison_date_dataset_id", "comparison_date_attribute_id")
71
+ # What create_metric_alert applies when the argument is left out, per its own contract. A
72
+ # metric-to-metric comparison has no periods, so periods_ago defaults only for a period kind.
73
+ _COMPARISON_ARG_DEFAULTS: dict[str, object] = {"comparison_periods_ago": 1, "arithmetic_operator": "DIFFERENCE"}
74
+ _PERIOD_COMPARISON_KINDS = ("PREVIOUS_PERIOD", "SAME_PERIOD_PRIOR_YEAR")
75
+ _NO_COMPARISON_MARKERS = ("none", "no comparison")
76
+
77
+ # AlertEvaluation checks, in report order. Every run is scored on the first group; the second only
78
+ # when the item states the matching expectation.
79
+ _ALERT_CHECKS = (
80
+ "alert_created",
81
+ "operator_correct",
82
+ "threshold_correct",
83
+ "trigger_correct",
84
+ "filters_correct",
85
+ "metric_correct",
86
+ "recipients_correct",
87
+ "attributes_correct",
88
+ "granularity_correct",
89
+ )
90
+ _CONDITIONAL_ALERT_CHECKS = ("comparison_correct", "title_correct", "dashboard_correct")
91
+
61
92
  # Expected-recipient spellings that mean the owner of the API token.
62
93
  _CURRENT_USER_RECIPIENT = "me"
63
94
  _CURRENT_USER_MARKERS = {"me", "myself", "current user", "current_user"}
@@ -94,6 +125,29 @@ def _parse_recipients(recipients_str: str | None) -> list[str] | None:
94
125
  return [r.strip() for r in recipients_str.replace(";", ",").split(",") if r.strip()]
95
126
 
96
127
 
128
+ def _recipient_list(value: object, *, split_bare: bool) -> list[str]:
129
+ """A recipient argument as a list, decoded the way gen-ai does before calling the server.
130
+
131
+ The tool schema declares both recipient fields ``anyOf: [array of string, string, null]``,
132
+ and some models send the list JSON-encoded as a string (``'["admin"]'``). Empty or
133
+ ``"null"`` means none; any other string that is not a JSON list of strings is a bare
134
+ value -- one user id, or with ``split_bare`` a comma/semicolon-separated email list
135
+ (gen-ai forwards a bare string as one entry; the split is this checker's own leniency).
136
+ A non-string list entry is kept as its ``str()`` so it compares unequal instead of raising.
137
+ """
138
+ if isinstance(value, list):
139
+ return [str(item) for item in value]
140
+ if not isinstance(value, str) or value.strip() in ("", "null"):
141
+ return []
142
+ try:
143
+ parsed = json.loads(value)
144
+ except ValueError:
145
+ parsed = None
146
+ if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed):
147
+ return parsed
148
+ return (_parse_recipients(value) or []) if split_bare else [value]
149
+
150
+
97
151
  def _deep_subset(expected: object, actual: object) -> bool:
98
152
  """Return True if expected is a recursive subset of actual."""
99
153
  if isinstance(expected, dict) and isinstance(actual, dict):
@@ -234,6 +288,66 @@ def _check_granularity(expected: CatalogMetricAlert, actual_args: dict) -> bool:
234
288
  return str(actual).strip().upper() == expected.granularity.value
235
289
 
236
290
 
291
+ def _canonical_comparison_value(arg: str, value: object) -> str | int | float | None:
292
+ """Fold one comparison value so fixture and tool-argument spellings compare equal."""
293
+ if value is None or str(value).strip() == "":
294
+ return None
295
+ if arg == "comparison_periods_ago":
296
+ return _to_number(value)
297
+ if arg in _COMPARISON_ID_ARGS:
298
+ return str(value).strip()
299
+ return str(value).strip().upper()
300
+
301
+
302
+ def _check_comparison(expected: CatalogMetricAlert, actual_args: dict) -> bool:
303
+ """Compare only the comparison properties the fixture states.
304
+
305
+ ``None`` means unasserted. A stated ``comparison_kind`` of ``None`` asserts the alert has
306
+ no comparison at all. ``comparison_kind`` is read as ``create_metric_alert`` resolves it:
307
+ left out next to a ``compare_metric_id`` it is METRIC. Any other argument the agent left out
308
+ on an alert that does compare counts as the value ``create_metric_alert`` applies for it, so
309
+ omitting ``comparison_periods_ago`` matches 1 on a period comparison; a METRIC comparison has
310
+ no periods, and with no comparison there is nothing to default.
311
+ The comparison period itself (DAY, MONTH, ...) is not checked here: it is the granularity
312
+ of the comparison date filter, which ``filters`` asserts.
313
+ """
314
+ if expected.comparison is None:
315
+ return True
316
+ kind = actual_args.get("comparison_kind") or ("METRIC" if actual_args.get("compare_metric_id") else None)
317
+ for arg, exp_value in expected.comparison.items():
318
+ act_raw = kind if arg == "comparison_kind" else actual_args.get(arg)
319
+ defaults_apply = arg != "comparison_periods_ago" or str(kind).upper() in _PERIOD_COMPARISON_KINDS
320
+ if act_raw is None and kind is not None and defaults_apply:
321
+ act_raw = _COMPARISON_ARG_DEFAULTS.get(arg)
322
+ # Only the fixture side of an object id is trimmed: a padded id is a different id.
323
+ actual = act_raw if arg in _COMPARISON_ID_ARGS else _canonical_comparison_value(arg, act_raw)
324
+ if actual != exp_value:
325
+ return False
326
+ return True
327
+
328
+
329
+ def _check_title(expected: CatalogMetricAlert, actual_args: dict) -> bool:
330
+ """Exact match on the alert name when the fixture states one.
331
+
332
+ Only the fixture side is trimmed: a name saved with padding is not the name the user asked for.
333
+
334
+ An omitted ``title`` fails: ``create_metric_alert`` then saves an auto-generated name, even
335
+ when ``automation_id`` carries a slug of the requested one.
336
+ """
337
+ if expected.title is None:
338
+ return True
339
+ actual = actual_args.get("title")
340
+ return isinstance(actual, str) and actual == expected.title
341
+
342
+
343
+ def _check_dashboard(expected: CatalogMetricAlert, actual_args: dict) -> bool:
344
+ """Compare the bound dashboard id when the fixture states one; the actual id verbatim."""
345
+ if expected.dashboard_id is None:
346
+ return True
347
+ actual = actual_args.get("dashboard_id")
348
+ return isinstance(actual, str) and actual == expected.dashboard_id
349
+
350
+
237
351
  def _check_metric(expected: CatalogMetricAlert, actual_args: dict) -> bool:
238
352
  if not expected.metric_id:
239
353
  return True
@@ -305,35 +419,14 @@ def _check_recipients(
305
419
  ) -> bool:
306
420
  if not expected.recipients:
307
421
  return True
308
- act_recip_raw = actual_args.get("recipients", actual_args.get("external_recipients"))
309
- if isinstance(act_recip_raw, str):
310
- # external_recipients is JSON-encoded (e.g. '["email@example.com"]')
311
- try:
312
- parsed = json.loads(act_recip_raw)
313
- act_recip = parsed if isinstance(parsed, list) else _parse_recipients(act_recip_raw)
314
- except (json.JSONDecodeError, ValueError):
315
- act_recip = _parse_recipients(act_recip_raw)
316
- elif isinstance(act_recip_raw, list):
317
- act_recip = act_recip_raw
318
- else:
319
- act_recip = []
320
- act_internal_raw = actual_args.get("internal_recipients")
321
- # internal_recipients is declared `anyOf: [array of string, string, null]` in the
322
- # create_metric_alert tool schema -- a single id as a bare string is schema-legal,
323
- # not a malformed call, so it needs the same string/list normalization already
324
- # applied to recipients/external_recipients above.
325
- if isinstance(act_internal_raw, str):
326
- act_internal = [act_internal_raw]
327
- elif isinstance(act_internal_raw, list):
328
- act_internal = act_internal_raw
329
- else:
330
- act_internal = []
422
+ act_recip = _recipient_list(actual_args.get("recipients", actual_args.get("external_recipients")), split_bare=True)
423
+ act_internal = _recipient_list(actual_args.get("internal_recipients"), split_bare=False)
331
424
  wants_current_user, expected_emails = _split_current_user(expected.recipients)
332
425
  if wants_current_user:
333
426
  if current_user_id is None or current_user_id not in act_internal:
334
427
  return False
335
428
  other_internal = set(act_internal) - {current_user_id}
336
- external = set(act_recip or [])
429
+ external = set(act_recip)
337
430
  if external - set(expected_emails):
338
431
  return False
339
432
  # An email not sent externally must arrive as its user's id (workspace-restricted channel).
@@ -345,7 +438,7 @@ def _check_recipients(
345
438
  # One lookup per email: a bulk lookup returns only the users that exist, hiding a missing one.
346
439
  ids_per_email = [_resolve_internal_recipient_ids(sdk, [email]) for email in unmatched]
347
440
  return all(ids_per_email) and other_internal == set().union(*ids_per_email)
348
- if set(expected_emails) == set(act_recip or []):
441
+ if set(expected_emails) == set(act_recip):
349
442
  return True
350
443
  if sdk is not None and act_internal:
351
444
  internal_recipient_ids = _resolve_internal_recipient_ids(sdk, expected_emails)
@@ -489,6 +582,15 @@ def generate_simulated_alert_response(
489
582
 
490
583
  original_request = f'Your original request to the agent was: "{question}"\n' if question else ""
491
584
 
585
+ # Only a stated title is enforced: an unstated one is the agent's to choose, and telling the
586
+ # sim-user nothing keeps it from objecting to a generated name.
587
+ title_rule = (
588
+ f'8. The alert must be named exactly "{expected.title}". If a summary or proposal shows a '
589
+ "different name, do NOT confirm: state that exact name and ask the agent to use it.\n"
590
+ if expected.title
591
+ else ""
592
+ )
593
+
492
594
  system_prompt = (
493
595
  "You are a user requesting creation of an alert for a metric from an AI agent. "
494
596
  "Respond naturally but always steer toward the exact values you were given.\n"
@@ -509,7 +611,10 @@ def generate_simulated_alert_response(
509
611
  + recipients_rule
510
612
  + filters_rule
511
613
  + f"6. Proactively state how often you want to be alerted in your first reply: {trigger_request}. "
512
- " Repeat it if the agent proposes a different cadence.\n" + period_rule + "Reply concisely and directly."
614
+ " Repeat it if the agent proposes a different cadence.\n"
615
+ + period_rule
616
+ + title_rule
617
+ + "Reply concisely and directly."
513
618
  )
514
619
 
515
620
  messages: list = [{"role": "system", "content": system_prompt}]
@@ -550,22 +655,28 @@ class AlertEvaluation:
550
655
  recipients_correct: bool
551
656
  attributes_correct: bool = True
552
657
  granularity_correct: bool = True
658
+ # ``None``: the item states no such expectation, so the check is neither scored nor gated.
659
+ comparison_correct: bool | None = None
660
+ title_correct: bool | None = None
661
+ dashboard_correct: bool | None = None
662
+
663
+ def checks(self) -> dict[str, bool]:
664
+ """Every check that ran, by name; strict_pass requires all of them.
665
+
666
+ The conditional checks are left out when unasserted, so an item that names no title does
667
+ not report a ``title_correct`` it was never scored on, and a refusal on it is not counted
668
+ as a title failure.
669
+ """
670
+ ran: dict[str, bool] = {name: getattr(self, name) for name in _ALERT_CHECKS}
671
+ for name in _CONDITIONAL_ALERT_CHECKS:
672
+ value = getattr(self, name)
673
+ if value is not None:
674
+ ran[name] = value
675
+ return ran
553
676
 
554
677
  @property
555
678
  def strict_pass(self) -> bool:
556
- return all(
557
- [
558
- self.alert_created,
559
- self.operator_correct,
560
- self.threshold_correct,
561
- self.trigger_correct,
562
- self.filters_correct,
563
- self.metric_correct,
564
- self.recipients_correct,
565
- self.attributes_correct,
566
- self.granularity_correct,
567
- ]
568
- )
679
+ return all(self.checks().values())
569
680
 
570
681
 
571
682
  @dataclass
@@ -666,6 +777,68 @@ def _normalize_expected_attributes(expected: dict) -> list | None:
666
777
  raise ValueError(f"Attributes expectation must be a list or a display string, got {type(attributes).__name__}")
667
778
 
668
779
 
780
+ def _normalize_expected_comparison(expected: dict) -> dict[str, str | int | float | None] | None:
781
+ """
782
+ * ``Comparison`` object -> its stated properties, keyed by tool-argument name
783
+ * "None" / "no comparison" -> ``{"comparison_kind": None}`` (stated: no comparison)
784
+ * absent, empty, or prose -> ``None`` (unstated; comparison not asserted)
785
+
786
+ Each property of the object is asserted only when it carries a value, so an item can pin
787
+ the comparison kind without also pinning the date dataset the agent may pick.
788
+ """
789
+ comparison = _case_insensitive_get(expected, "comparison")
790
+ if comparison is None:
791
+ return None
792
+ if isinstance(comparison, str):
793
+ return {"comparison_kind": None} if any(kw in comparison.lower() for kw in _NO_COMPARISON_MARKERS) else None
794
+ if not isinstance(comparison, dict):
795
+ raise ValueError(f"Comparison expectation must be an object or a display string, got {comparison!r}")
796
+ stated: dict[str, str | int | float | None] = {}
797
+ for key, value in comparison.items():
798
+ name = str(key).strip().lower()
799
+ arg = _COMPARISON_ARGS.get(name) or (name if name in _COMPARISON_ARGS.values() else None)
800
+ if arg is None:
801
+ # Validated here so a misspelled property fails before the run spends an API call.
802
+ raise ValueError(f"Unrecognised comparison property {key!r}; expected one of {sorted(_COMPARISON_ARGS)}")
803
+ canonical = _canonical_comparison_value(arg, value)
804
+ if canonical is None and arg == "comparison_periods_ago" and str(value or "").strip():
805
+ raise ValueError(f"Comparison periods_ago must be a number, got {value!r}")
806
+ if canonical is not None:
807
+ stated[arg] = canonical
808
+ return stated or None
809
+
810
+
811
+ def _normalize_expected_text(expected: dict, *keys: str) -> str | None:
812
+ """A stated string expectation, or ``None`` when absent or blank (unasserted)."""
813
+ value = _case_insensitive_get(expected, *keys)
814
+ if value is None:
815
+ return None
816
+ if not isinstance(value, str):
817
+ raise ValueError(f"{keys[0]} expectation must be a string, got {value!r}")
818
+ return value.strip() or None
819
+
820
+
821
+ def _normalize_expected_dashboard(expected: dict) -> str | None:
822
+ """
823
+ * ``dashboard_id`` -> that id
824
+ * ``Dashboard`` "Title (id)" -> the id in the trailing parentheses, as ``Metric`` is read
825
+ * absent / empty -> ``None`` (unstated; binding not asserted)
826
+
827
+ A ``Dashboard`` without a trailing ``(id)`` raises: a title alone can never equal the
828
+ ``dashboard_id`` the tool receives, so the item would fail on every run.
829
+ """
830
+ dashboard_id = _normalize_expected_text(expected, "dashboard_id")
831
+ if dashboard_id is not None:
832
+ return dashboard_id
833
+ display = _normalize_expected_text(expected, "dashboard")
834
+ if display is None:
835
+ return None
836
+ parsed = _parse_metric_id(display)
837
+ if parsed is None:
838
+ raise ValueError(f'Dashboard expectation must be "Title (id)", got {display!r}; use dashboard_id for a bare id')
839
+ return parsed
840
+
841
+
669
842
  def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
670
843
  """Parse expected_output dict into CatalogMetricAlert, accepting display-format or internal-format keys."""
671
844
  operator = _case_insensitive_get(expected, "operator") or "GREATER_THAN"
@@ -696,6 +869,9 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
696
869
  granularity = AnomalyDetectionGranularity.parse(
697
870
  _case_insensitive_get(expected, "granularity", "detection granularity")
698
871
  )
872
+ comparison = _normalize_expected_comparison(expected)
873
+ title = _normalize_expected_text(expected, "title")
874
+ dashboard_id = _normalize_expected_dashboard(expected)
699
875
 
700
876
  return CatalogMetricAlert(
701
877
  operator=operator,
@@ -708,6 +884,9 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
708
884
  filters=filters,
709
885
  attributes=attributes,
710
886
  granularity=granularity,
887
+ comparison=comparison,
888
+ title=title,
889
+ dashboard_id=dashboard_id,
711
890
  )
712
891
 
713
892
 
@@ -893,6 +1072,13 @@ def run_agentic_alert_skill(
893
1072
  and _check_recipients(expected, actual_args, sdk=sdk, current_user_id=current_user_id),
894
1073
  attributes_correct=tool_called and _check_attributes(expected, actual_args),
895
1074
  granularity_correct=tool_called and _check_granularity(expected, actual_args),
1075
+ comparison_correct=None
1076
+ if expected.comparison is None
1077
+ else tool_called and _check_comparison(expected, actual_args),
1078
+ title_correct=None if expected.title is None else tool_called and _check_title(expected, actual_args),
1079
+ dashboard_correct=None
1080
+ if expected.dashboard_id is None
1081
+ else tool_called and _check_dashboard(expected, actual_args),
896
1082
  )
897
1083
  return AlertRunResult(
898
1084
  conversation_id=conv_id,
@@ -934,19 +1120,7 @@ def run_agentic_alert_skill(
934
1120
  pass_power_k = all(r.eval.strict_pass for r in run_results)
935
1121
  best = max(
936
1122
  run_results,
937
- key=lambda r: sum(
938
- [
939
- r.eval.alert_created,
940
- r.eval.operator_correct,
941
- r.eval.threshold_correct,
942
- r.eval.trigger_correct,
943
- r.eval.filters_correct,
944
- r.eval.metric_correct,
945
- r.eval.recipients_correct,
946
- r.eval.attributes_correct,
947
- r.eval.granularity_correct,
948
- ]
949
- ),
1123
+ key=lambda r: sum(r.eval.checks().values()),
950
1124
  )
951
1125
  return AgenticAlertSummary(
952
1126
  run_results=run_results,
@@ -966,17 +1140,8 @@ def _run_detail(run: AlertRunResult) -> dict:
966
1140
  Extracted so a failing run is described by exactly the same keys as the winning run --
967
1141
  for this kind that means the full per-field check breakdown and the arguments the run sent.
968
1142
  """
969
- ev = run.eval
970
1143
  return {
971
- "alert_created": ev.alert_created,
972
- "operator_correct": ev.operator_correct,
973
- "threshold_correct": ev.threshold_correct,
974
- "trigger_correct": ev.trigger_correct,
975
- "filters_correct": ev.filters_correct,
976
- "metric_correct": ev.metric_correct,
977
- "recipients_correct": ev.recipients_correct,
978
- "attributes_correct": ev.attributes_correct,
979
- "granularity_correct": ev.granularity_correct,
1144
+ **run.eval.checks(),
980
1145
  "actual_alert_arguments": run.actual_alert_arguments,
981
1146
  # Why the loop stopped. alert_created=False alone cannot tell a refusal from a run
982
1147
  # that hit max_iterations while still on track -- see LoopExit. Per-run rather than
@@ -1052,18 +1217,7 @@ def evaluate_agentic_alert_skill(
1052
1217
 
1053
1218
  for run_idx, run in enumerate(summary.run_results):
1054
1219
  pt = ctx.trace(run.conversation_id)
1055
- ev = run.eval
1056
- strict_checks = {
1057
- "alert_created": ev.alert_created,
1058
- "operator_correct": ev.operator_correct,
1059
- "threshold_correct": ev.threshold_correct,
1060
- "trigger_correct": ev.trigger_correct,
1061
- "filters_correct": ev.filters_correct,
1062
- "metric_correct": ev.metric_correct,
1063
- "recipients_correct": ev.recipients_correct,
1064
- "attributes_correct": ev.attributes_correct,
1065
- "granularity_correct": ev.granularity_correct,
1066
- }
1220
+ strict_checks = run.eval.checks()
1067
1221
  with ctx.observe(pt, run_idx, conversation_id=run.conversation_id, output=strict_checks) as tid:
1068
1222
  for score_name, value in strict_checks.items():
1069
1223
  ctx.score(tid, name=score_name, value=float(value), data_type="BOOLEAN")
@@ -1112,13 +1266,8 @@ def evaluate_agentic_alert_skill(
1112
1266
  gate_note = gate_failure_note(gate, runs_passed, runs_effective)
1113
1267
  exc = AlertSkillAssertionError(
1114
1268
  f"Alert skill assertion failed. {gate_note} strict_pass={ev.strict_pass}. "
1115
- f"alert_created={ev.alert_created}, operator_correct={ev.operator_correct}, "
1116
- f"threshold_correct={ev.threshold_correct}, trigger_correct={ev.trigger_correct}, "
1117
- f"filters_correct={ev.filters_correct}, metric_correct={ev.metric_correct}, "
1118
- f"recipients_correct={ev.recipients_correct}, "
1119
- f"attributes_correct={ev.attributes_correct}, "
1120
- f"granularity_correct={ev.granularity_correct}. "
1121
- f"Actual args: {best.actual_alert_arguments}"
1269
+ + ", ".join(f"{name}={value}" for name, value in ev.checks().items())
1270
+ + f". Actual args: {best.actual_alert_arguments}"
1122
1271
  )
1123
1272
  exc.reasoning_steps = best.reasoning_steps
1124
1273
  exc.conversation_id = best.conversation_id
@@ -32,7 +32,7 @@ from gooddata_eval.core.agentic._trace_linker import (
32
32
  from gooddata_eval.core.agentic.dashboard_skill import _extract_tool_result, _skill_activated
33
33
  from gooddata_eval.core.chat.render import render_answer_text
34
34
  from gooddata_eval.core.chat.sse_client import ChatClient
35
- from gooddata_eval.core.config import ReasoningEffort
35
+ from gooddata_eval.core.config import DocumentFormat, ReasoningEffort, normalize_document_format
36
36
  from gooddata_eval.core.evaluators._llm_judge import JudgeResponseError, LLMJudge, score_run
37
37
  from gooddata_eval.core.langfuse.item_scope import item_scope
38
38
  from gooddata_eval.core.models import (
@@ -801,18 +801,24 @@ def run_agentic_document_skill(
801
801
  agent_id: str | None = None,
802
802
  judge: LLMJudge | None = None,
803
803
  user_context: dict | None = None,
804
+ document_format: DocumentFormat | None = None,
804
805
  ) -> AgenticDocumentSummary:
805
806
  """Run the document-skill agentic evaluation K times and return a summary.
806
807
 
807
808
  The narrative judge is built only for a fixture that states a ``narrative``, so an item
808
809
  without one needs neither the llm-judge extra nor ``OPENAI_API_KEY``.
809
810
 
811
+ ``document_format`` is sent on every turn of every run, see ``ChatClient``; None leaves
812
+ the format to the server's ``enablePublisherV2`` feature flag.
813
+
810
814
  Raises:
811
815
  ValueError: the fixture is unusable — see ``_validate_expectation`` and
812
- ``_validate_user_context``.
816
+ ``_validate_user_context``, or ``document_format`` is not a format gen-ai
817
+ accepts. Raised before any chat request.
813
818
  """
814
819
  _validate_expectation(expected_output)
815
820
  _validate_user_context(user_context)
821
+ document_format = normalize_document_format(document_format)
816
822
  if judge is None and _has_narrative(expected_output):
817
823
  judge = LLMJudge(_NARRATIVE_EVALUATION_STEPS)
818
824
  run_results: list[DocumentRunResult] = []
@@ -823,6 +829,7 @@ def run_agentic_document_skill(
823
829
  reasoning_effort=reasoning_effort,
824
830
  agent_id=agent_id,
825
831
  user_context=user_context,
832
+ document_format=document_format,
826
833
  )
827
834
 
828
835
  try:
@@ -882,20 +889,32 @@ def evaluate_agentic_document_skill(
882
889
  gate: EvalGate = DEFAULT_GATE,
883
890
  judge: LLMJudge | None = None,
884
891
  user_context: dict | None = None,
892
+ document_format: DocumentFormat | None = None,
885
893
  ) -> AgenticEvalOutcome:
886
894
  """Run document-skill evaluation, log to Langfuse, and raise on failure.
887
895
 
888
896
  Returns the best run's outcome on success; on failure the same values are attached to the
889
897
  raised ``DocumentSkillAssertionError`` so callers can retrieve them either way.
890
898
 
899
+ ``document_format`` (``aac-v1``/``aac-v2``) asks gen-ai for that document content format
900
+ on every turn and is recorded as ``document_format`` in the dataset-run metadata; None
901
+ leaves it to the server's ``enablePublisherV2`` feature flag.
902
+
891
903
  Raises:
892
904
  DocumentSkillAssertionError: the gate did not pass.
893
- ValueError: the fixture is unusable — see ``_validate_expectation`` and
894
- ``_validate_user_context``. Raised before any
895
- request, so it means a fixture to fix rather than a result to read.
905
+ ValueError: the fixture is unusable, see ``_validate_expectation`` and
906
+ ``_validate_user_context``. Raised before any chat request, so it means a
907
+ fixture to fix rather than a result to read. Also raised, equally before any
908
+ request, when the caller passes a ``document_format`` gen-ai does not accept.
896
909
  JudgeResponseError: the fixture states a narrative and the judge returned no readable
897
910
  verdict for any run -- an item without a result, not K failures.
898
911
  """
912
+ document_format = normalize_document_format(document_format)
913
+ if document_format is not None:
914
+ # Metadata only, not part of the run name: it tells runs apart only when their names
915
+ # already differ, so two runs that share a run_timestamp, dataset and model but not the
916
+ # format still collide on one dataset-run name.
917
+ run_metadata_extra = {**(run_metadata_extra or {}), "document_format": document_format}
899
918
  identity = RunIdentity(
900
919
  host,
901
920
  token,
@@ -921,6 +940,7 @@ def evaluate_agentic_document_skill(
921
940
  agent_id=agent_id,
922
941
  judge=judge,
923
942
  user_context=user_context,
943
+ document_format=document_format,
924
944
  )
925
945
 
926
946
  if langfuse is not None and dataset_item_id:
@@ -26,7 +26,12 @@ from urllib.parse import quote
26
26
  import httpx
27
27
 
28
28
  from gooddata_eval.core._retry import RETRYABLE_STATUS_CODES, float_env, int_env, retry_transient
29
- from gooddata_eval.core.config import ReasoningEffort, normalize_reasoning_effort
29
+ from gooddata_eval.core.config import (
30
+ DocumentFormat,
31
+ ReasoningEffort,
32
+ normalize_document_format,
33
+ normalize_reasoning_effort,
34
+ )
30
35
  from gooddata_eval.core.langfuse.item_scope import JoinedRun, baggage_entries, current_scope, mark_joined, trace_ids_for
31
36
  from gooddata_eval.core.models import ChatResult, DatasetItem
32
37
  from gooddata_eval.core.timing import note_trace_latency
@@ -460,6 +465,7 @@ class ChatClient:
460
465
  reasoning_effort: ReasoningEffort | None = None,
461
466
  agent_id: str | None = None,
462
467
  user_context: dict[str, Any] | None = None,
468
+ document_format: DocumentFormat | None = None,
463
469
  ):
464
470
  """Create a chat client bound to one workspace.
465
471
 
@@ -476,6 +482,13 @@ class ChatClient:
476
482
  back to it: a caller that changes or clears the context per message builds the
477
483
  client without one. The server grounds answers in it only while
478
484
  the ``enableAiContextSetup`` feature flag is on for the organization.
485
+
486
+ ``document_format`` (``aac-v1``/``aac-v2``) is sent as ``options.documentFormat``
487
+ on every message and selects the document content format gen-ai drafts in for
488
+ that turn. gen-ai keeps a draft in the format of the turn that wrote it and a
489
+ turn in the other format cannot continue it, so follow-up and clarification
490
+ turns carry it too. When None the key is omitted and the server picks the format
491
+ from the ``enablePublisherV2`` feature flag. Any other value raises ``ValueError``.
479
492
  """
480
493
  self._base = f"{host.rstrip('/')}/api/v1/ai/workspaces/{workspace_id}/chat/conversations"
481
494
  self._auth = {"Authorization": f"Bearer {token}", **_trace_baggage()}
@@ -494,6 +507,7 @@ class ChatClient:
494
507
  self._client = httpx.Client(timeout=http_timeout)
495
508
  self._preserve_failed = preserve_failed
496
509
  self._reasoning_effort = normalize_reasoning_effort(reasoning_effort)
510
+ self._document_format = normalize_document_format(document_format)
497
511
  self._agent_id = agent_id
498
512
  self._user_context = user_context
499
513
  # Run index of each conversation this client has sent to, in first-send order: every
@@ -531,8 +545,13 @@ class ChatClient:
531
545
  join_headers, joined = self._join_item_trace(conversation_id)
532
546
  headers = {**self._auth, "Accept": "text/event-stream", "Content-Type": "application/json", **join_headers}
533
547
  body: dict[str, Any] = {"item": {"role": "user", "content": {"type": "text", "text": question}}}
548
+ options: dict[str, str] = {}
534
549
  if self._reasoning_effort is not None:
535
- body["options"] = {"reasoningEffort": self._reasoning_effort}
550
+ options["reasoningEffort"] = self._reasoning_effort
551
+ if self._document_format is not None:
552
+ options["documentFormat"] = self._document_format
553
+ if options:
554
+ body["options"] = options
536
555
  # A per-call context overrides the client's. Only sent when there is one: gen-ai
537
556
  # accepts an explicit null, so assigning unconditionally would quietly change every
538
557
  # request that has no attachment.
@@ -33,6 +33,9 @@ def judge_model() -> str:
33
33
  ReasoningEffort = Literal["LOW", "MEDIUM", "HIGH"]
34
34
  """Effort values the AI chat endpoint accepts, uppercase as the server enum requires."""
35
35
 
36
+ DocumentFormat = Literal["aac-v1", "aac-v2"]
37
+ """Document content formats the AI chat endpoint accepts as ``options.documentFormat``."""
38
+
36
39
  EvalGate = Literal["any", "power"]
37
40
  """`any` = pass@K, `power` = pass^K. Behaviour is in core/agentic/_gate.py; the type lives here
38
41
  because putting it there would make config import the agentic package, whose __init__ imports
@@ -73,6 +76,22 @@ def normalize_reasoning_effort(value: str | None) -> ReasoningEffort | None:
73
76
  return cast("ReasoningEffort", candidate)
74
77
 
75
78
 
79
+ def normalize_document_format(value: str | None) -> DocumentFormat | None:
80
+ """Canonical document format, or None when unset.
81
+
82
+ gen-ai rejects any value outside `DocumentFormat`, so an unknown value raises here,
83
+ before a run sends its first request.
84
+ """
85
+ if value is None:
86
+ return None
87
+ candidate = value.strip().lower()
88
+ if not candidate:
89
+ return None
90
+ if candidate not in get_args(DocumentFormat):
91
+ raise ValueError(f"Invalid document format {value!r}; expected one of {', '.join(get_args(DocumentFormat))}.")
92
+ return cast("DocumentFormat", candidate)
93
+
94
+
76
95
  @dataclass
77
96
  class RunConfig:
78
97
  host: str
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gooddata-eval
3
- Version: 1.78.1.dev2
3
+ Version: 1.78.1.dev4
4
4
  Summary: Evaluate the GoodData AI agent against your own questions and models.
5
5
  Project-URL: Source, https://github.com/gooddata/gooddata-python-sdk
6
6
  Author-email: GoodData <support@gooddata.com>
@@ -17,7 +17,7 @@ Classifier: Topic :: Scientific/Engineering
17
17
  Classifier: Topic :: Software Development
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
- Requires-Dist: gooddata-sdk~=1.78.1.dev2
20
+ Requires-Dist: gooddata-sdk~=1.78.1.dev4
21
21
  Requires-Dist: httpx<1.0,>=0.27
22
22
  Requires-Dist: jsonpatch<2.0,>=1.33
23
23
  Requires-Dist: orjson<4.0.0,>=3.9.15
@@ -6,7 +6,7 @@ gooddata_eval/cli/main.py,sha256=ZZv9pViwV4h21LPMrBOUCH99-oyM9dd2AFpGj9NWEJI,344
6
6
  gooddata_eval/core/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
7
7
  gooddata_eval/core/_output.py,sha256=sFbNtKg1lx431jpb15jISLlOXGTZ-r_KodfaTVyka6I,580
8
8
  gooddata_eval/core/_retry.py,sha256=86ZPWu6ahCYC2GLvcbwjHTtj2RrGPOuxIFlCbtmNpvw,2990
9
- gooddata_eval/core/config.py,sha256=HegNbWb4csXXGaOZiFkplhsr8FmVK7OJL_lDKne1Sdg,4080
9
+ gooddata_eval/core/config.py,sha256=hla5t294uHC5Q9bzrhPjsdkwEcetLsEH-GqGAhs94b4,4823
10
10
  gooddata_eval/core/connection.py,sha256=mh-Do5-Ti0-DXXikfCIH10eTTUbKuZUAS_sLoPNDJ5I,1229
11
11
  gooddata_eval/core/granularity.py,sha256=CbniwgxtVj0oyf41UjeeZbBzOpeisSBlAfdkL79e6O0,3932
12
12
  gooddata_eval/core/interaction.py,sha256=PUaJ1zdMwXBKjoNXNFXVgt_b8_i0LWdbMZndEQ3F6UM,2093
@@ -17,17 +17,17 @@ gooddata_eval/core/scoring.py,sha256=buY7ZoO08Dt59m2PPhFeNOyix5yYlf4mVxFyOn-aibk
17
17
  gooddata_eval/core/timing.py,sha256=1grXpr48OH4GcAmeobhjwnRWqoMJwcjj-LLpHdS_raY,7789
18
18
  gooddata_eval/core/workspace.py,sha256=S8exiy5gpDtcRtT8-i2fIRALF34Pbo0J46xm8X_ldus,11846
19
19
  gooddata_eval/core/agentic/__init__.py,sha256=i2g22ousTYXi5FZeN5X0WVpa67RQXThwli8KcYfIYxM,4432
20
- gooddata_eval/core/agentic/_catalog.py,sha256=bzDGtiXVHdtJSSMCzV8qYp4p_BxmO7f7GifGNTPfvSA,3687
20
+ gooddata_eval/core/agentic/_catalog.py,sha256=My4SP5DteSJlp2WOQ66iemJkX3ANmdqrsgANDEm6u10,4194
21
21
  gooddata_eval/core/agentic/_conversation_context.py,sha256=43ryGOQ9kTLLrzRxBphUn_87290vTZQ6oqWLnzjKYbA,16270
22
22
  gooddata_eval/core/agentic/_failed_runs.py,sha256=iXN80ORMrIKEhV1QKGbyEZPP2w__mLPSrkm3hh-dTwM,3048
23
23
  gooddata_eval/core/agentic/_gate.py,sha256=irI4eWOCfmsbKd6l-HdpoAlnWkq2wjo4b5crGU_Er1o,2631
24
24
  gooddata_eval/core/agentic/_langfuse.py,sha256=NWhPgpd2NkYmAPBiT8VCOd_cddhhsmDGmcWmsvyGLGs,29729
25
25
  gooddata_eval/core/agentic/_trace_linker.py,sha256=mSY_7UDZU97r5qcaDxMoyPQ46944XzbzFbMRd74G87k,15760
26
- gooddata_eval/core/agentic/alert_skill.py,sha256=5bsyWnHJVxDFk-Tr1e31F3P8vHB4HXJaM5TtrWue35Y,52537
26
+ gooddata_eval/core/agentic/alert_skill.py,sha256=Me7YrAAGWG6iqzJR18JdhVsri4LG4J1itIUdzgboZU0,60105
27
27
  gooddata_eval/core/agentic/anomaly_detection.py,sha256=H_rKx6fCX3S11HzGAQGhy092YMmGj4foFBmrx6U-LHE,27701
28
28
  gooddata_eval/core/agentic/conversation.py,sha256=_OLlST1twnSPnOQGfuuFCFKu92uwLVh2YBBA_nOW-5w,60004
29
29
  gooddata_eval/core/agentic/dashboard_skill.py,sha256=PnFq1JjaMpAjQkzgklRkEFFKEs4WWhORikDVw5qjbaE,84630
30
- gooddata_eval/core/agentic/document_skill.py,sha256=Z9jzWhOjaPmQze1IeDrc08pg0XB31_NTWBRQkxXf178,43859
30
+ gooddata_eval/core/agentic/document_skill.py,sha256=Uukqc6voa3lXbcSVgfzQyPH_kEFu3jrbrN9EDIMIuYA,45231
31
31
  gooddata_eval/core/agentic/general_question.py,sha256=pOVYlJF9eKZwTqdPVsrH4X1OC-tXI7CMCFDdPNdBIMw,17758
32
32
  gooddata_eval/core/agentic/guardrail.py,sha256=1Yz5QVVWo5GCOA8OKSy5efeysaaXB3bXcr_BT-Qu8tY,16398
33
33
  gooddata_eval/core/agentic/kda_skill.py,sha256=2mav9itclavID28U4fORSR9p-iVTrddMyTHLKh8HxdE,26154
@@ -38,7 +38,7 @@ gooddata_eval/core/agentic/visualization.py,sha256=o4RVS-nRCVbVXjbE_jHuGzgW8x3hR
38
38
  gooddata_eval/core/agentic/what_if.py,sha256=v5u9Yl45QGpVD2IHvPhVW_XOixI10IK4N-5_zP9Yymw,26365
39
39
  gooddata_eval/core/chat/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
40
40
  gooddata_eval/core/chat/render.py,sha256=CqsKN8__DlaiJj_bmcPZ1poc5LfUqtz7EzKRDbks4gU,2082
41
- gooddata_eval/core/chat/sse_client.py,sha256=UGmghNUQHGM5NMBtAAJj6Z9jQpgs0ROfqH9egw3Tgvo,31006
41
+ gooddata_eval/core/chat/sse_client.py,sha256=yGUbWg7gr7ycVfEpvpmGUCIQ38nW0jiMmuJB9_va4UY,31921
42
42
  gooddata_eval/core/dataset/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
43
43
  gooddata_eval/core/dataset/from_insights.py,sha256=2eYh_84070t2BMeW2XPLAwzrJs7vKbFYMeCppvkDiEo,40880
44
44
  gooddata_eval/core/dataset/langfuse_source.py,sha256=II-6fenEPhdyfckrL0iVdJpavjv67wUvy7OIuvHntrg,8697
@@ -77,8 +77,8 @@ gooddata_eval/core/summary/http_client.py,sha256=Umae42fiiJQ0e4hv63NRROHYvcFAGgX
77
77
  gooddata_eval/core/takeaway/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
78
78
  gooddata_eval/core/takeaway/evaluate.py,sha256=ei7DLSlG_Ove_R551Ofdw8WISf178XknU58jBWxB_is,11585
79
79
  gooddata_eval/core/takeaway/http_client.py,sha256=yc3FNPArjfcSdAE7naRf7g_aW7BbJhwsfSEvAFd67Ik,4141
80
- gooddata_eval-1.78.1.dev2.dist-info/METADATA,sha256=n-O7qyx_gp7werx53dJUr2y_GyY3dooBZlUDlrwhLnk,60351
81
- gooddata_eval-1.78.1.dev2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
82
- gooddata_eval-1.78.1.dev2.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
83
- gooddata_eval-1.78.1.dev2.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
84
- gooddata_eval-1.78.1.dev2.dist-info/RECORD,,
80
+ gooddata_eval-1.78.1.dev4.dist-info/METADATA,sha256=uQZy3GjLJ3V1W9B9RR9kX6fLzD5-Var8lEQApCzH3iA,60351
81
+ gooddata_eval-1.78.1.dev4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
82
+ gooddata_eval-1.78.1.dev4.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
83
+ gooddata_eval-1.78.1.dev4.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
84
+ gooddata_eval-1.78.1.dev4.dist-info/RECORD,,