gooddata-eval 1.77.0__tar.gz → 1.77.1.dev2__tar.gz

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.
Files changed (140) hide show
  1. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/PKG-INFO +2 -2
  2. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/pyproject.toml +2 -2
  3. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/alert_skill.py +124 -17
  4. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/document_skill.py +1 -1
  5. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_alert_skill.py +209 -1
  6. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_document_skill.py +2 -2
  7. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/.gitignore +0 -0
  8. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/AGENTS.md +0 -0
  9. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/CLAUDE.md +0 -0
  10. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/LICENSE.txt +0 -0
  11. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/Makefile +0 -0
  12. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/README.md +0 -0
  13. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/scripts/verify_guardrail_refusal_criteria.py +0 -0
  14. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/__init__.py +0 -0
  15. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/_version.py +0 -0
  16. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/cli/__init__.py +0 -0
  17. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/cli/agentic_runner.py +0 -0
  18. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/cli/main.py +0 -0
  19. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/__init__.py +0 -0
  20. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/_output.py +0 -0
  21. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/__init__.py +0 -0
  22. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
  23. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/_conversation_context.py +0 -0
  24. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/_failed_runs.py +0 -0
  25. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/_gate.py +0 -0
  26. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/_langfuse.py +0 -0
  27. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/_trace_linker.py +0 -0
  28. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/anomaly_detection.py +0 -0
  29. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/conversation.py +0 -0
  30. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/dashboard_skill.py +0 -0
  31. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/general_question.py +0 -0
  32. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/guardrail.py +0 -0
  33. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/kda_skill.py +0 -0
  34. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/metric_skill.py +0 -0
  35. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/report_skill.py +0 -0
  36. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/search_tool.py +0 -0
  37. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/visualization.py +0 -0
  38. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/agentic/what_if.py +0 -0
  39. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/chat/__init__.py +0 -0
  40. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/chat/render.py +0 -0
  41. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/chat/sse_client.py +0 -0
  42. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/config.py +0 -0
  43. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/connection.py +0 -0
  44. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/dataset/__init__.py +0 -0
  45. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/dataset/from_insights.py +0 -0
  46. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/dataset/langfuse_source.py +0 -0
  47. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/dataset/local.py +0 -0
  48. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/__init__.py +0 -0
  49. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
  50. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/_guardrail_criteria.py +0 -0
  51. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
  52. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/_maql.py +0 -0
  53. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
  54. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/alert_skill.py +0 -0
  55. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/base.py +0 -0
  56. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
  57. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
  58. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
  59. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/search_tool.py +0 -0
  60. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/summary.py +0 -0
  61. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
  62. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/granularity.py +0 -0
  63. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
  64. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/langfuse/_env.py +0 -0
  65. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/langfuse/client.py +0 -0
  66. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/langfuse/experiment.py +0 -0
  67. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/langfuse/item_scope.py +0 -0
  68. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/langfuse/observations.py +0 -0
  69. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/langfuse/otlp.py +0 -0
  70. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/langfuse/sink.py +0 -0
  71. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/models.py +0 -0
  72. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/reporting/__init__.py +0 -0
  73. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/reporting/console.py +0 -0
  74. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/reporting/html_report.py +0 -0
  75. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/reporting/json_report.py +0 -0
  76. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/reporting/report_template.html +0 -0
  77. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/runner.py +0 -0
  78. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/scoring.py +0 -0
  79. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/summary/__init__.py +0 -0
  80. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/summary/http_client.py +0 -0
  81. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/timing.py +0 -0
  82. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/src/gooddata_eval/core/workspace.py +0 -0
  83. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/__init__.py +0 -0
  84. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/_fake_langfuse.py +0 -0
  85. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/conftest.py +0 -0
  86. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
  87. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
  88. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/fixtures/sse_visualization_stream.txt +0 -0
  89. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_anomaly_detection.py +0 -0
  90. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_conversation.py +0 -0
  91. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_dashboard_skill.py +0 -0
  92. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_gate.py +0 -0
  93. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_general_question.py +0 -0
  94. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_guardrail.py +0 -0
  95. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_join_trace.py +0 -0
  96. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_kda_skill.py +0 -0
  97. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_langfuse_trace.py +0 -0
  98. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_metric_skill.py +0 -0
  99. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_observe_experiment.py +0 -0
  100. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_run_context.py +0 -0
  101. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_runner.py +0 -0
  102. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_search_tool.py +0 -0
  103. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_visualization.py +0 -0
  104. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_agentic_what_if.py +0 -0
  105. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_alert_skill_evaluator.py +0 -0
  106. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_chat_render.py +0 -0
  107. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_cli.py +0 -0
  108. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_connection.py +0 -0
  109. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_deep_subset.py +0 -0
  110. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_fake_langfuse.py +0 -0
  111. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_from_insights.py +0 -0
  112. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_guardrail_criteria.py +0 -0
  113. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_html_report.py +0 -0
  114. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_langfuse_client.py +0 -0
  115. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_langfuse_e2e_fake_server.py +0 -0
  116. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_langfuse_env.py +0 -0
  117. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_langfuse_experiment.py +0 -0
  118. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_langfuse_item_scope.py +0 -0
  119. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_langfuse_observations.py +0 -0
  120. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_langfuse_otlp.py +0 -0
  121. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_langfuse_sink.py +0 -0
  122. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_langfuse_source.py +0 -0
  123. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_llm_judge.py +0 -0
  124. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_local_loader.py +0 -0
  125. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_maql_normalize.py +0 -0
  126. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_metric_skill_evaluator.py +0 -0
  127. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_models.py +0 -0
  128. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_reporting.py +0 -0
  129. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_runner.py +0 -0
  130. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_scoring.py +0 -0
  131. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_search_tool_evaluator.py +0 -0
  132. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_sse_client.py +0 -0
  133. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_summary_client.py +0 -0
  134. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_summary_evaluator.py +0 -0
  135. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_text_evaluators.py +0 -0
  136. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_timing.py +0 -0
  137. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_trace_linker.py +0 -0
  138. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_visualization_evaluator.py +0 -0
  139. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tests/test_workspace.py +0 -0
  140. {gooddata_eval-1.77.0 → gooddata_eval-1.77.1.dev2}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gooddata-eval
3
- Version: 1.77.0
3
+ Version: 1.77.1.dev2
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.77.0
20
+ Requires-Dist: gooddata-sdk~=1.77.1.dev2
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
@@ -1,7 +1,7 @@
1
1
  # (C) 2026 GoodData Corporation
2
2
  [project]
3
3
  name = "gooddata-eval"
4
- version = "1.77.0"
4
+ version = "1.77.1.dev2"
5
5
  description = "Evaluate the GoodData AI agent against your own questions and models."
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -11,7 +11,7 @@ authors = [
11
11
  keywords = ["gooddata", "ai", "evaluation", "llm", "analytics", "cli"]
12
12
  requires-python = ">=3.10"
13
13
  dependencies = [
14
- "gooddata-sdk~=1.77.0",
14
+ "gooddata-sdk~=1.77.1.dev2",
15
15
  "httpx>=0.27,<1.0",
16
16
  "jsonpatch>=1.33,<2.0",
17
17
  "orjson>=3.9.15,<4.0.0",
@@ -9,6 +9,7 @@ import re
9
9
  from dataclasses import dataclass, field
10
10
  from typing import Any
11
11
 
12
+ import httpx
12
13
  from gooddata_sdk import GoodDataSdk
13
14
 
14
15
  from gooddata_eval.core.agentic._catalog import AnomalyDetectionGranularity, CatalogMetricAlert
@@ -54,6 +55,11 @@ _DEFAULT_MAX_ITERATIONS = 6
54
55
 
55
56
  _TRIGGER_DISPLAY_TO_API = {"Every time": "ALWAYS", "One time": "ONCE"}
56
57
  _ALWAYS_TRIGGER_VALUES = {"Every time", "ALWAYS", "not specified"}
58
+ _RELATIVE_OPERATORS = ("INCREASES_BY", "DECREASES_BY", "CHANGES_BY")
59
+
60
+ # Expected-recipient spellings that mean the owner of the API token.
61
+ _CURRENT_USER_RECIPIENT = "me"
62
+ _CURRENT_USER_MARKERS = {"me", "myself", "current user", "current_user"}
57
63
 
58
64
  _TRIGGER_INSTRUCTIONS = {
59
65
  "ALWAYS": (
@@ -242,9 +248,9 @@ def _resolve_internal_recipient_ids(sdk: GoodDataSdk, emails: list[str]) -> set[
242
248
  `create_metric_alert` then addresses the alert by internal user id
243
249
  (`internal_recipients`), never by email, so an expected email has to be
244
250
  resolved before it can be compared against that field. Failures (no
245
- matching user, no permission, network error) are swallowed: the caller
246
- treats an empty result the same as "this delivery path doesn't match",
247
- which is correct -- it doesn't mean the alert itself failed.
251
+ matching user, no permission, network error) are logged and return an
252
+ empty set: the caller treats that the same as "this delivery path doesn't
253
+ match", which is correct -- it doesn't mean the alert itself failed.
248
254
  """
249
255
  if not emails:
250
256
  return set()
@@ -254,12 +260,48 @@ def _resolve_internal_recipient_ids(sdk: GoodDataSdk, emails: list[str]) -> set[
254
260
  escaped = [email.replace("\\", "\\\\").replace("'", "\\'") for email in emails]
255
261
  quoted = ",".join(f"'{email}'" for email in escaped)
256
262
  resp = sdk._client.entities_api.get_all_entities_users(filter=f"email=in=({quoted})")
257
- return {u.id for u in (resp.data or [])}
258
- except Exception:
263
+ ids = {u.id for u in (resp.data or [])}
264
+ except Exception as exc:
265
+ print(f"[RECIPIENTS] Looking up users by {len(emails)} recipient email(s) failed: {exc}")
259
266
  return set()
267
+ if not ids:
268
+ print(
269
+ f"[RECIPIENTS] No user has any of the {len(emails)} recipient email(s); internal recipients cannot match."
270
+ )
271
+ return ids
272
+
273
+
274
+ def _resolve_current_user_id(host: str, token: str) -> str | None:
275
+ """Id of the user that owns ``token``, or None if the profile cannot be read.
276
+
277
+ The current user is matched by id, not email: that user may have no email at all.
278
+ """
279
+ try:
280
+ resp = httpx.get(
281
+ f"{host.rstrip('/')}/api/v1/profile",
282
+ headers={"Authorization": f"Bearer {token}"},
283
+ timeout=30.0,
284
+ )
285
+ resp.raise_for_status()
286
+ user_id = resp.json().get("userId")
287
+ except Exception as exc:
288
+ print(f"[RECIPIENTS] Resolving the current user failed: {exc}")
289
+ return None
290
+ return user_id if isinstance(user_id, str) and user_id else None
291
+
260
292
 
293
+ def _split_current_user(recipients: list[str]) -> tuple[bool, list[str]]:
294
+ """Whether ``recipients`` names the current user, and the email recipients besides it."""
295
+ emails = [r for r in recipients if r != _CURRENT_USER_RECIPIENT]
296
+ return len(emails) != len(recipients), emails
261
297
 
262
- def _check_recipients(expected: CatalogMetricAlert, actual_args: dict, sdk: GoodDataSdk | None = None) -> bool:
298
+
299
+ def _check_recipients(
300
+ expected: CatalogMetricAlert,
301
+ actual_args: dict,
302
+ sdk: GoodDataSdk | None = None,
303
+ current_user_id: str | None = None,
304
+ ) -> bool:
263
305
  if not expected.recipients:
264
306
  return True
265
307
  act_recip_raw = actual_args.get("recipients", actual_args.get("external_recipients"))
@@ -274,8 +316,6 @@ def _check_recipients(expected: CatalogMetricAlert, actual_args: dict, sdk: Good
274
316
  act_recip = act_recip_raw
275
317
  else:
276
318
  act_recip = []
277
- if set(expected.recipients) == set(act_recip or []):
278
- return True
279
319
  act_internal_raw = actual_args.get("internal_recipients")
280
320
  # internal_recipients is declared `anyOf: [array of string, string, null]` in the
281
321
  # create_metric_alert tool schema -- a single id as a bare string is schema-legal,
@@ -287,8 +327,27 @@ def _check_recipients(expected: CatalogMetricAlert, actual_args: dict, sdk: Good
287
327
  act_internal = act_internal_raw
288
328
  else:
289
329
  act_internal = []
330
+ wants_current_user, expected_emails = _split_current_user(expected.recipients)
331
+ if wants_current_user:
332
+ if current_user_id is None or current_user_id not in act_internal:
333
+ return False
334
+ other_internal = set(act_internal) - {current_user_id}
335
+ external = set(act_recip or [])
336
+ if external - set(expected_emails):
337
+ return False
338
+ # An email not sent externally must arrive as its user's id (workspace-restricted channel).
339
+ unmatched = [e for e in expected_emails if e not in external]
340
+ if not unmatched:
341
+ return not other_internal
342
+ if sdk is None or not other_internal:
343
+ return False
344
+ # One lookup per email: a bulk lookup returns only the users that exist, hiding a missing one.
345
+ ids_per_email = [_resolve_internal_recipient_ids(sdk, [email]) for email in unmatched]
346
+ return all(ids_per_email) and other_internal == set().union(*ids_per_email)
347
+ if set(expected_emails) == set(act_recip or []):
348
+ return True
290
349
  if sdk is not None and act_internal:
291
- internal_recipient_ids = _resolve_internal_recipient_ids(sdk, expected.recipients)
350
+ internal_recipient_ids = _resolve_internal_recipient_ids(sdk, expected_emails)
292
351
  if internal_recipient_ids & set(act_internal):
293
352
  return True
294
353
  return False
@@ -299,6 +358,7 @@ def generate_simulated_alert_response(
299
358
  expected: CatalogMetricAlert,
300
359
  conversation_history: list,
301
360
  question: str = "",
361
+ current_user_id: str | None = None,
302
362
  ) -> str:
303
363
  """Stateful sim-user reply for alert-skill conversation (gpt-4o).
304
364
 
@@ -306,6 +366,8 @@ def generate_simulated_alert_response(
306
366
  history — the opening question went straight to the agent, never to the sim-user — so
307
367
  without it rule 5's "the filters your original request implies" refers to text the model
308
368
  cannot see. Optional (defaults to "") to keep the signature backwards compatible.
369
+
370
+ ``current_user_id`` lets the sim-user recognise itself as the recipient of a "me" alert.
309
371
  """
310
372
  if _OpenAI is None:
311
373
  raise RuntimeError(
@@ -333,7 +395,30 @@ def generate_simulated_alert_response(
333
395
  threshold = expected.threshold
334
396
  else:
335
397
  threshold = "not specified"
336
- recipients = ", ".join(expected.recipients) if expected.recipients else "not specified"
398
+ wants_current_user, emails = _split_current_user(expected.recipients)
399
+ recipient_names = list(emails)
400
+ if wants_current_user:
401
+ user_id_note = f", user id {current_user_id}" if current_user_id else ""
402
+ recipient_names.insert(0, f"me (yourself, the current user{user_id_note})")
403
+ recipients = ", ".join(recipient_names) if recipient_names else "not specified"
404
+ if wants_current_user and not emails:
405
+ # An email given here would make the agent send the alert to it instead of the current user.
406
+ recipients_rule = (
407
+ "4. The alert is for you yourself. Never give an email address and never ask for one to "
408
+ "be added. A proposal whose recipient is you — shown as 'You', the current user or your "
409
+ "own user id — has the correct recipient.\n"
410
+ )
411
+ elif wants_current_user:
412
+ recipients_rule = (
413
+ f"4. Proactively state the email recipients in your first reply: {', '.join(emails)}, "
414
+ "and say the alert should also go to you yourself. A recipient shown as 'You', the "
415
+ "current user or your own user id is you.\n"
416
+ )
417
+ else:
418
+ recipients_rule = (
419
+ "4. Proactively include your email recipient in your first reply. "
420
+ " Do not wait for the agent to ask — state it alongside the metric and condition answers.\n"
421
+ )
337
422
  trigger = expected.trigger
338
423
  filters = expected.filters
339
424
 
@@ -378,14 +463,25 @@ def generate_simulated_alert_response(
378
463
  # and scoring an item against an interval it never asked for is the defect this rule
379
464
  # exists to undo.
380
465
  granularity = (expected.granularity or AnomalyDetectionGranularity.DAY).value
381
- anomaly_rule = (
466
+ period_rule = (
382
467
  "7. This is an ANOMALY alert. Anomaly detection REQUIRES a time granularity, and that "
383
468
  f"granularity is NOT a date filter. State it in your first reply and repeat it whenever "
384
469
  f"asked: use {granularity} granularity. Rule 5 constrains filters on the metric only — it "
385
470
  "never applies to this detection interval, so never refuse to give one.\n"
386
471
  )
472
+ elif operator in _RELATIVE_OPERATORS:
473
+ # The item names no comparison date or period; delegating is an answer the agent accepts,
474
+ # refusing loops until the turn budget runs out.
475
+ period_rule = (
476
+ "7. This is a comparison alert: keep the comparison your original request describes. The "
477
+ "agent may need a comparison date and a period length (day, week, month, quarter or "
478
+ "year), and those are NOT a date filter. Rule 5 constrains filters on the metric only — "
479
+ "it never applies to the comparison. If the agent asks which date or which period the "
480
+ "comparison should use, do not refuse and do not name one yourself: hand the choice back, "
481
+ "for example 'Pick whichever date and period fit best.'\n"
482
+ )
387
483
  else:
388
- anomaly_rule = (
484
+ period_rule = (
389
485
  "7. Do not invent an evaluation period, a granularity or an 'evaluate each run on a X "
390
486
  "basis' instruction your goal never asked for.\n"
391
487
  )
@@ -409,11 +505,10 @@ def generate_simulated_alert_response(
409
505
  " when every one of those fields matches your goal.\n"
410
506
  " A field your goal reports as 'not specified' is one you have NO expectation about: "
411
507
  " accept whatever the agent chose for it and never ask for it to be removed.\n"
412
- "4. Proactively include your email recipient in your first reply. "
413
- " Do not wait for the agent to ask — state it alongside the metric and condition answers.\n"
508
+ + recipients_rule
414
509
  + filters_rule
415
510
  + f"6. Proactively state how often you want to be alerted in your first reply: {trigger_request}. "
416
- " Repeat it if the agent proposes a different cadence.\n" + anomaly_rule + "Reply concisely and directly."
511
+ " Repeat it if the agent proposes a different cadence.\n" + period_rule + "Reply concisely and directly."
417
512
  )
418
513
 
419
514
  messages: list = [{"role": "system", "content": system_prompt}]
@@ -590,6 +685,7 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
590
685
  recipients = [r.strip() for r in raw_recip.replace(";", ",").split(",") if r.strip()]
591
686
  else:
592
687
  recipients = list(raw_recip)
688
+ recipients = [_CURRENT_USER_RECIPIENT if str(r).strip().lower() in _CURRENT_USER_MARKERS else r for r in recipients]
593
689
 
594
690
  filters = _normalize_expected_filters(expected)
595
691
  attributes = _normalize_expected_attributes(expected)
@@ -665,6 +761,12 @@ def run_agentic_alert_skill(
665
761
  ) -> AgenticAlertSummary:
666
762
  """Run the alert-skill agentic evaluation K times and return a summary."""
667
763
  expected = _normalize_expected_output(expected_output)
764
+ current_user_id: str | None = None
765
+ if _CURRENT_USER_RECIPIENT in expected.recipients:
766
+ current_user_id = _resolve_current_user_id(host, token)
767
+ if current_user_id is None:
768
+ # A harness fault: report it as an item error, not as the agent's recipient mistake.
769
+ raise RuntimeError("Cannot score a current-user alert: the token owner's user id could not be resolved")
668
770
  run_results: list[AlertRunResult] = []
669
771
  client = ChatClient(
670
772
  host=host,
@@ -761,7 +863,11 @@ def run_agentic_alert_skill(
761
863
  # exit reason to classify the run as an error rather than an agent failure.
762
864
  try:
763
865
  follow_up = generate_simulated_alert_response(
764
- response_text, expected, conversation_history, question=question
866
+ response_text,
867
+ expected,
868
+ conversation_history,
869
+ question=question,
870
+ current_user_id=current_user_id,
765
871
  )
766
872
  except Exception as exc: # noqa: BLE001 -- harness-side fault; end only this run
767
873
  print(f"[SIM-USER] Simulated reply failed for conversation {conv_id}: {exc}")
@@ -779,7 +885,8 @@ def run_agentic_alert_skill(
779
885
  trigger_correct=tool_called and _check_trigger(expected, actual_args),
780
886
  filters_correct=tool_called and _check_filters(expected, actual_args),
781
887
  metric_correct=tool_called and _check_metric(expected, actual_args),
782
- recipients_correct=tool_called and _check_recipients(expected, actual_args, sdk=sdk),
888
+ recipients_correct=tool_called
889
+ and _check_recipients(expected, actual_args, sdk=sdk, current_user_id=current_user_id),
783
890
  attributes_correct=tool_called and _check_attributes(expected, actual_args),
784
891
  granularity_correct=tool_called and _check_granularity(expected, actual_args),
785
892
  )
@@ -1017,7 +1017,7 @@ def evaluate_agentic_document_skill(
1017
1017
  elif not best.evaluation.skill_activated:
1018
1018
  skill_note = (
1019
1019
  f" set_skills never activated {_BUILDER_SKILL}: either the copilot routed elsewhere, or the skill"
1020
- " is not registered. It registers only with enableGenAiDocumentBuilderSkill and the org's"
1020
+ " is not registered. It registers only with enableGenAiReportBuilderSkill and the org's"
1021
1021
  " enableBusinessBriefingReportsApp both on."
1022
1022
  )
1023
1023
  exc = DocumentSkillAssertionError(
@@ -15,6 +15,7 @@ from gooddata_eval.core.agentic.alert_skill import (
15
15
  _check_trigger,
16
16
  _deep_subset,
17
17
  _normalize_expected_output,
18
+ _resolve_current_user_id,
18
19
  _to_number,
19
20
  evaluate_agentic_alert_skill,
20
21
  generate_simulated_alert_response,
@@ -289,6 +290,202 @@ def test_check_recipients_resolution_failure_fails_gracefully():
289
290
  assert _check_recipients(expected, {"internal_recipients": ["user.abc123"]}, sdk=mock_sdk) is False
290
291
 
291
292
 
293
+ def test_check_recipients_logs_when_no_user_has_the_expected_email(capsys: pytest.CaptureFixture[str]) -> None:
294
+ expected = _normalize_expected_output({"Recipients": ["user@example.com"]})
295
+ mock_sdk = MagicMock()
296
+ mock_sdk._client.entities_api.get_all_entities_users.return_value.data = []
297
+ assert _check_recipients(expected, {"internal_recipients": ["user.abc123"]}, sdk=mock_sdk) is False
298
+ out = capsys.readouterr().out
299
+ assert "No user has any of the 1 recipient email(s)" in out
300
+ assert "user@example.com" not in out
301
+
302
+
303
+ def test_check_recipients_logs_a_failed_email_lookup(capsys: pytest.CaptureFixture[str]) -> None:
304
+ expected = _normalize_expected_output({"Recipients": ["user@example.com"]})
305
+ mock_sdk = MagicMock()
306
+ mock_sdk._client.entities_api.get_all_entities_users.side_effect = RuntimeError("boom")
307
+ assert _check_recipients(expected, {"internal_recipients": ["user.abc123"]}, sdk=mock_sdk) is False
308
+ assert "failed: boom" in capsys.readouterr().out
309
+
310
+
311
+ # --- Current user as the recipient ------------------------------------------------------
312
+
313
+
314
+ @pytest.mark.parametrize("spelling", ["me", "Me", " current user ", "current_user", "myself"])
315
+ def test_current_user_recipient_spellings_normalize_to_me(spelling: str) -> None:
316
+ assert _normalize_expected_output({"Recipient(s)": spelling}).recipients == ["me"]
317
+
318
+
319
+ def test_check_recipients_accepts_the_current_user_by_id() -> None:
320
+ expected = _normalize_expected_output({"Recipient(s)": "me"})
321
+ mock_sdk = MagicMock()
322
+ assert _check_recipients(expected, {"internal_recipients": ["admin"]}, sdk=mock_sdk, current_user_id="admin")
323
+ assert _check_recipients(expected, {"internal_recipients": "admin"}, sdk=mock_sdk, current_user_id="admin")
324
+ mock_sdk._client.entities_api.get_all_entities_users.assert_not_called()
325
+
326
+
327
+ @pytest.mark.parametrize(
328
+ "actual_args",
329
+ [
330
+ {"internal_recipients": ["someone.else"]},
331
+ {"external_recipients": ["admin@gooddata.com"]},
332
+ {"internal_recipients": ["admin", "someone.else"]},
333
+ {"internal_recipients": ["admin"], "external_recipients": ["admin@gooddata.com"]},
334
+ {},
335
+ ],
336
+ )
337
+ def test_check_recipients_rejects_anything_but_exactly_the_current_user(actual_args: dict) -> None:
338
+ expected = _normalize_expected_output({"Recipient(s)": "me"})
339
+ assert _check_recipients(expected, actual_args, sdk=MagicMock(), current_user_id="admin") is False
340
+
341
+
342
+ def test_check_recipients_fails_when_the_current_user_is_unknown() -> None:
343
+ expected = _normalize_expected_output({"Recipient(s)": "me"})
344
+ assert _check_recipients(expected, {"internal_recipients": ["admin"]}, current_user_id=None) is False
345
+
346
+
347
+ def test_check_recipients_accepts_the_current_user_alongside_an_email() -> None:
348
+ expected = _normalize_expected_output({"Recipient(s)": "me, bob@example.com"})
349
+ args = {"internal_recipients": ["admin"], "external_recipients": ["bob@example.com"]}
350
+ assert _check_recipients(expected, args, current_user_id="admin") is True
351
+ assert _check_recipients(expected, {"internal_recipients": ["admin"]}, current_user_id="admin") is False
352
+ extra = {"internal_recipients": ["admin", "carol.id"], "external_recipients": ["bob@example.com"]}
353
+ assert _check_recipients(expected, extra, current_user_id="admin") is False
354
+
355
+
356
+ def test_check_recipients_accepts_an_email_delivered_as_a_user_id_next_to_the_current_user() -> None:
357
+ expected = _normalize_expected_output({"Recipient(s)": "me, bob@example.com"})
358
+ mock_sdk = MagicMock()
359
+ mock_sdk._client.entities_api.get_all_entities_users.return_value.data = [MagicMock(id="bob.id")]
360
+ args = {"internal_recipients": ["admin", "bob.id"]}
361
+ assert _check_recipients(expected, args, sdk=mock_sdk, current_user_id="admin") is True
362
+ mock_sdk._client.entities_api.get_all_entities_users.assert_called_once_with(filter="email=in=('bob@example.com')")
363
+
364
+
365
+ def test_check_recipients_rejects_a_user_id_that_matches_no_expected_email() -> None:
366
+ expected = _normalize_expected_output({"Recipient(s)": "me, bob@example.com"})
367
+ mock_sdk = MagicMock()
368
+ mock_sdk._client.entities_api.get_all_entities_users.return_value.data = [MagicMock(id="bob.id")]
369
+ args = {"internal_recipients": ["admin", "bob.id", "carol.id"]}
370
+ assert _check_recipients(expected, args, sdk=mock_sdk, current_user_id="admin") is False
371
+
372
+
373
+ def test_check_recipients_rejects_an_expected_email_that_resolves_to_no_user() -> None:
374
+ expected = _normalize_expected_output({"Recipient(s)": "me, bob@example.com, carol@example.com"})
375
+ mock_sdk = MagicMock()
376
+ bob, nobody = MagicMock(data=[MagicMock(id="bob.id")]), MagicMock(data=[])
377
+ mock_sdk._client.entities_api.get_all_entities_users.side_effect = [bob, nobody]
378
+ args = {"internal_recipients": ["admin", "bob.id"]}
379
+ assert _check_recipients(expected, args, sdk=mock_sdk, current_user_id="admin") is False
380
+
381
+
382
+ def test_resolve_current_user_id_reads_the_profile() -> None:
383
+ with patch("gooddata_eval.core.agentic.alert_skill.httpx.get") as mock_get:
384
+ mock_get.return_value.json.return_value = {"userId": "admin", "organizationId": "org"}
385
+ assert _resolve_current_user_id("http://host/", "tok") == "admin"
386
+ mock_get.assert_called_once_with(
387
+ "http://host/api/v1/profile", headers={"Authorization": "Bearer tok"}, timeout=30.0
388
+ )
389
+
390
+
391
+ def test_resolve_current_user_id_logs_and_returns_none_on_failure(capsys: pytest.CaptureFixture[str]) -> None:
392
+ with patch("gooddata_eval.core.agentic.alert_skill.httpx.get", side_effect=RuntimeError("down")):
393
+ assert _resolve_current_user_id("http://host", "tok") is None
394
+ assert "Resolving the current user failed: down" in capsys.readouterr().out
395
+
396
+
397
+ def test_sim_user_for_a_current_user_alert_gives_no_email() -> None:
398
+ prompt = _sim_user_prompt({"Operator": "LESS_THAN", "Recipient(s)": "me"}, current_user_id="admin")
399
+ assert "recipients=me (yourself, the current user, user id admin)" in prompt
400
+ assert "Never give an email address" in prompt
401
+ assert "'You'" in prompt
402
+ assert "Proactively include your email recipient" not in prompt
403
+
404
+
405
+ def test_sim_user_still_volunteers_an_email_recipient() -> None:
406
+ prompt = _sim_user_prompt({"Operator": "LESS_THAN", "Recipient(s)": "bob@example.com"})
407
+ assert "recipients=bob@example.com" in prompt
408
+ assert "Proactively include your email recipient" in prompt
409
+
410
+
411
+ def test_run_agentic_alert_skill_scores_a_current_user_alert() -> None:
412
+ asked_turn = ChatResult.model_validate({"text_response": "Which dashboard?", "tool_call_events": []})
413
+ created_turn = ChatResult.model_validate(
414
+ {
415
+ "text_response": "Alert created.",
416
+ "tool_call_events": [
417
+ {
418
+ "functionName": "create_metric_alert",
419
+ "functionArguments": '{"operator": "LESS_THAN", "threshold": 30000, "internal_recipients": ["admin"]}',
420
+ "result": '{"id": "alert-1"}',
421
+ }
422
+ ],
423
+ }
424
+ )
425
+ mock_client = MagicMock()
426
+ mock_client.send_message.side_effect = [asked_turn, created_turn]
427
+
428
+ with (
429
+ _patched(mock_client, simulated_reply="Any dashboard is fine.", delete_alert=True) as mock_sim,
430
+ patch("gooddata_eval.core.agentic.alert_skill._resolve_current_user_id", return_value="admin") as mock_me,
431
+ ):
432
+ summary = run_agentic_alert_skill(
433
+ host="http://host",
434
+ token="tok",
435
+ workspace_id="ws1",
436
+ question="Alert me when profit drops below 30000.",
437
+ expected_output={"Operator": "LESS_THAN", "Threshold": "30000", "Recipient(s)": "me"},
438
+ k=1,
439
+ max_iterations=6,
440
+ initial_conversation_id="conv-1",
441
+ )
442
+
443
+ mock_me.assert_called_once_with("http://host", "tok")
444
+ assert mock_sim.call_args.kwargs["current_user_id"] == "admin"
445
+ assert summary.best.eval.recipients_correct is True
446
+
447
+
448
+ def test_run_agentic_alert_skill_skips_the_profile_lookup_for_email_recipients() -> None:
449
+ mock_client = MagicMock()
450
+ mock_client.send_message.return_value = _no_alert_chat_result()
451
+ with (
452
+ _patched(mock_client, simulated_reply="ok"),
453
+ patch("gooddata_eval.core.agentic.alert_skill._resolve_current_user_id") as mock_me,
454
+ ):
455
+ run_agentic_alert_skill(
456
+ host="http://host",
457
+ token="tok",
458
+ workspace_id="ws1",
459
+ question="Alert me",
460
+ expected_output={"Operator": "LESS_THAN", "Recipient(s)": "admin@gooddata.com"},
461
+ k=1,
462
+ max_iterations=1,
463
+ initial_conversation_id="conv-1",
464
+ )
465
+ mock_me.assert_not_called()
466
+
467
+
468
+ def test_run_agentic_alert_skill_errors_when_the_current_user_cannot_be_resolved() -> None:
469
+ mock_client = MagicMock()
470
+ with (
471
+ _patched(mock_client, simulated_reply="ok") as mock_sim,
472
+ patch("gooddata_eval.core.agentic.alert_skill._resolve_current_user_id", return_value=None),
473
+ pytest.raises(RuntimeError, match="user id could not be resolved"),
474
+ ):
475
+ run_agentic_alert_skill(
476
+ host="http://host",
477
+ token="tok",
478
+ workspace_id="ws1",
479
+ question="Alert me",
480
+ expected_output={"Operator": "LESS_THAN", "Recipient(s)": "me"},
481
+ k=1,
482
+ max_iterations=1,
483
+ initial_conversation_id="conv-1",
484
+ )
485
+ mock_client.send_message.assert_not_called()
486
+ mock_sim.assert_not_called()
487
+
488
+
292
489
  def test_alert_evaluation_strict_pass():
293
490
  ev = AlertEvaluation(
294
491
  alert_created=True,
@@ -382,7 +579,7 @@ def test_run_agentic_alert_skill_creates_fresh_conversations_for_remaining_runs(
382
579
  # summary that plainly read "Trigger: once per month".
383
580
 
384
581
 
385
- def _sim_user_prompt(expected_output: dict, question: str = "") -> str:
582
+ def _sim_user_prompt(expected_output: dict, question: str = "", current_user_id: str | None = None) -> str:
386
583
  """Run the sim-user against a stub OpenAI client and return the system prompt it built."""
387
584
  fake_openai = MagicMock()
388
585
  fake_openai.return_value.chat.completions.create.return_value = MagicMock(
@@ -397,6 +594,7 @@ def _sim_user_prompt(expected_output: dict, question: str = "") -> str:
397
594
  _normalize_expected_output(expected_output),
398
595
  [],
399
596
  question=question,
597
+ current_user_id=current_user_id,
400
598
  )
401
599
  call = fake_openai.return_value.chat.completions.create.call_args
402
600
  return call.kwargs["messages"][0]["content"]
@@ -942,6 +1140,16 @@ def test_sim_user_still_refuses_an_unrequested_granularity_for_a_normal_alert():
942
1140
  assert "no date filter at all" in prompt
943
1141
 
944
1142
 
1143
+ @pytest.mark.parametrize("operator", ["INCREASES_BY", "DECREASES_BY", "CHANGES_BY"])
1144
+ def test_sim_user_delegates_the_comparison_period_of_a_relative_alert(operator: str) -> None:
1145
+ prompt = _sim_user_prompt({"Operator": operator, "Time window/Filters": "None (All time)"})
1146
+ rule_7 = prompt.split("7.", 1)[1]
1147
+ assert "NOT a date filter" in rule_7
1148
+ assert "never applies to the comparison" in rule_7
1149
+ assert "Pick whichever date and period fit best" in rule_7
1150
+ assert "Do not invent an evaluation period, a granularity" not in prompt
1151
+
1152
+
945
1153
  def test_sim_user_falls_back_to_day_when_an_anomaly_item_states_no_granularity():
946
1154
  prompt = _sim_user_prompt({"Operator": "ANOMALY"})
947
1155
  assert "use DAY granularity" in prompt
@@ -523,7 +523,7 @@ def test_a_failing_item_on_the_report_names_points_at_the_rename_not_the_flags(
523
523
  evaluate_agentic_document_skill("https://h", "tok", "ws", "Create a document", {}, max_iterations=1)
524
524
 
525
525
  assert "gen-ai still sends the Report names" in str(raised.value)
526
- assert "enableGenAiDocumentBuilderSkill" not in str(raised.value)
526
+ assert "enableGenAiReportBuilderSkill" not in str(raised.value)
527
527
 
528
528
 
529
529
  def test_a_user_context_with_view_report_fails_before_any_request(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -669,7 +669,7 @@ def test_a_failing_item_without_document_builder_points_at_the_flags(monkeypatch
669
669
  _install_client(monkeypatch, _ScriptedChatClient([turn]))
670
670
  with pytest.raises(
671
671
  DocumentSkillAssertionError,
672
- match="enableGenAiDocumentBuilderSkill and the org.s enableBusinessBriefingReportsApp",
672
+ match="enableGenAiReportBuilderSkill and the org.s enableBusinessBriefingReportsApp",
673
673
  ):
674
674
  evaluate_agentic_document_skill("https://h", "tok", "ws", "Create a document", {}, max_iterations=1)
675
675