gooddata-eval 1.78.0__tar.gz → 1.78.1.dev1__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 (154) hide show
  1. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/AGENTS.md +14 -9
  2. gooddata_eval-1.78.1.dev1/Makefile +6 -0
  3. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/PKG-INFO +66 -6
  4. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/README.md +64 -4
  5. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/pyproject.toml +2 -2
  6. gooddata_eval-1.78.1.dev1/scripts/calibrate_takeaway_judge.py +75 -0
  7. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/cli/main.py +17 -11
  8. gooddata_eval-1.78.1.dev1/src/gooddata_eval/core/_retry.py +71 -0
  9. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/_conversation_context.py +59 -10
  10. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/chat/sse_client.py +13 -59
  11. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/dataset/langfuse_source.py +29 -2
  12. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/__init__.py +5 -2
  13. gooddata_eval-1.78.1.dev1/src/gooddata_eval/core/evaluators/_rubric.py +94 -0
  14. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/summary.py +12 -64
  15. gooddata_eval-1.78.1.dev1/src/gooddata_eval/core/evaluators/takeaway.py +245 -0
  16. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/models.py +41 -0
  17. gooddata_eval-1.78.1.dev1/src/gooddata_eval/core/takeaway/evaluate.py +275 -0
  18. gooddata_eval-1.78.1.dev1/src/gooddata_eval/core/takeaway/http_client.py +104 -0
  19. gooddata_eval-1.78.1.dev1/tests/__init__.py +1 -0
  20. gooddata_eval-1.78.1.dev1/tests/fixtures/takeaway_calibration.json +68 -0
  21. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_conversation.py +115 -0
  22. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_cli.py +51 -0
  23. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_langfuse_source.py +96 -0
  24. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_models.py +50 -0
  25. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_runner.py +1 -0
  26. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_sse_client.py +6 -0
  27. gooddata_eval-1.78.1.dev1/tests/test_takeaway_client.py +257 -0
  28. gooddata_eval-1.78.1.dev1/tests/test_takeaway_evaluate.py +349 -0
  29. gooddata_eval-1.78.1.dev1/tests/test_takeaway_evaluator.py +425 -0
  30. gooddata_eval-1.78.0/Makefile +0 -2
  31. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/.gitignore +0 -0
  32. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/CLAUDE.md +0 -0
  33. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/LICENSE.txt +0 -0
  34. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/scripts/verify_guardrail_refusal_criteria.py +0 -0
  35. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/__init__.py +0 -0
  36. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/_version.py +0 -0
  37. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/cli/__init__.py +0 -0
  38. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/cli/agentic_runner.py +0 -0
  39. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/__init__.py +0 -0
  40. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/_output.py +0 -0
  41. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/__init__.py +0 -0
  42. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
  43. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/_failed_runs.py +0 -0
  44. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/_gate.py +0 -0
  45. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/_langfuse.py +0 -0
  46. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/_trace_linker.py +0 -0
  47. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/alert_skill.py +0 -0
  48. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/anomaly_detection.py +0 -0
  49. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/conversation.py +0 -0
  50. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/dashboard_skill.py +0 -0
  51. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/document_skill.py +0 -0
  52. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/general_question.py +0 -0
  53. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/guardrail.py +0 -0
  54. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/kda_skill.py +0 -0
  55. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/metric_skill.py +0 -0
  56. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/report_skill.py +0 -0
  57. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/search_tool.py +0 -0
  58. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/visualization.py +0 -0
  59. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/agentic/what_if.py +0 -0
  60. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/chat/__init__.py +0 -0
  61. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/chat/render.py +0 -0
  62. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/config.py +0 -0
  63. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/connection.py +0 -0
  64. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/dataset/__init__.py +0 -0
  65. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/dataset/from_insights.py +0 -0
  66. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/dataset/local.py +0 -0
  67. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
  68. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/_guardrail_criteria.py +0 -0
  69. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
  70. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/_maql.py +0 -0
  71. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
  72. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/alert_skill.py +0 -0
  73. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/base.py +0 -0
  74. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
  75. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
  76. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
  77. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/search_tool.py +0 -0
  78. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
  79. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/granularity.py +0 -0
  80. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/interaction.py +0 -0
  81. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
  82. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/langfuse/_env.py +0 -0
  83. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/langfuse/client.py +0 -0
  84. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/langfuse/experiment.py +0 -0
  85. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/langfuse/item_scope.py +0 -0
  86. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/langfuse/observations.py +0 -0
  87. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/langfuse/otlp.py +0 -0
  88. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/langfuse/sink.py +0 -0
  89. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/reporting/__init__.py +0 -0
  90. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/reporting/console.py +0 -0
  91. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/reporting/html_report.py +0 -0
  92. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/reporting/json_report.py +0 -0
  93. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/reporting/report_template.html +0 -0
  94. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/runner.py +0 -0
  95. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/scoring.py +0 -0
  96. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/summary/__init__.py +0 -0
  97. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/summary/http_client.py +0 -0
  98. {gooddata_eval-1.78.0/tests → gooddata_eval-1.78.1.dev1/src/gooddata_eval/core/takeaway}/__init__.py +0 -0
  99. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/timing.py +0 -0
  100. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/src/gooddata_eval/core/workspace.py +0 -0
  101. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/_fake_langfuse.py +0 -0
  102. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/conftest.py +0 -0
  103. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
  104. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
  105. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/fixtures/sse_visualization_stream.txt +0 -0
  106. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_alert_skill.py +0 -0
  107. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_anomaly_detection.py +0 -0
  108. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_dashboard_skill.py +0 -0
  109. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_document_skill.py +0 -0
  110. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_gate.py +0 -0
  111. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_general_question.py +0 -0
  112. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_guardrail.py +0 -0
  113. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_join_trace.py +0 -0
  114. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_kda_skill.py +0 -0
  115. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_langfuse_trace.py +0 -0
  116. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_metric_skill.py +0 -0
  117. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_observe_experiment.py +0 -0
  118. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_run_context.py +0 -0
  119. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_runner.py +0 -0
  120. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_search_tool.py +0 -0
  121. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_visualization.py +0 -0
  122. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_agentic_what_if.py +0 -0
  123. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_alert_skill_evaluator.py +0 -0
  124. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_chat_render.py +0 -0
  125. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_connection.py +0 -0
  126. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_deep_subset.py +0 -0
  127. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_fake_langfuse.py +0 -0
  128. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_from_insights.py +0 -0
  129. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_guardrail_criteria.py +0 -0
  130. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_html_report.py +0 -0
  131. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_interaction.py +0 -0
  132. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_langfuse_client.py +0 -0
  133. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_langfuse_e2e_fake_server.py +0 -0
  134. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_langfuse_env.py +0 -0
  135. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_langfuse_experiment.py +0 -0
  136. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_langfuse_item_scope.py +0 -0
  137. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_langfuse_observations.py +0 -0
  138. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_langfuse_otlp.py +0 -0
  139. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_langfuse_sink.py +0 -0
  140. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_llm_judge.py +0 -0
  141. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_local_loader.py +0 -0
  142. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_maql_normalize.py +0 -0
  143. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_metric_skill_evaluator.py +0 -0
  144. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_reporting.py +0 -0
  145. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_scoring.py +0 -0
  146. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_search_tool_evaluator.py +0 -0
  147. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_summary_client.py +0 -0
  148. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_summary_evaluator.py +0 -0
  149. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_text_evaluators.py +0 -0
  150. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_timing.py +0 -0
  151. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_trace_linker.py +0 -0
  152. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_visualization_evaluator.py +0 -0
  153. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tests/test_workspace.py +0 -0
  154. {gooddata_eval-1.78.0 → gooddata_eval-1.78.1.dev1}/tox.ini +0 -0
@@ -27,6 +27,7 @@ experiment. The newest and most actively developed package in the repo.
27
27
  | `core/agentic/` | multi-turn agentic evaluation per capability, **plus** all Langfuse trace polling and linking (`_langfuse.py`, `_trace_linker.py`) |
28
28
  | `core/chat/` | SSE client for the agent's streaming chat endpoint |
29
29
  | `core/summary/` | HTTP client for the dedicated dashboard-summary endpoint — a single-shot chat backend, not reporting |
30
+ | `core/takeaway/` | HTTP client for the AI Publisher takeaway endpoint — a single-shot backend like `core/summary/`, keeping the whole response on `ChatResult.raw_response` — plus `evaluate_document_takeaway`, the K-run, gate and Langfuse entry point the Tavern shim calls |
30
31
  | `core/dataset/` | dataset format, loading, and `from_insights.py` — dataset generation from a workspace's real insights |
31
32
  | `core/evaluators/` | single-shot evaluators and their registry |
32
33
  | `core/langfuse/` | the whole Langfuse v4 client: `_env` (base URL + credentials), `otlp` (OTLP/JSON encoding), `experiment` (root-span construction, score targets), `item_scope` (an item's experiment scope sent to gen-ai as baggage, and which conversations joined), `observations` (trace reads), `client` (httpx calls), `sink` (single-shot results as experiments) |
@@ -59,7 +60,8 @@ implementation, sometimes under different `test_kind` strings (`search_tool` vs
59
60
  `expected_output: Any`. `expected_output` is deliberately untyped — each evaluator parses
60
61
  its own shape. `test_kind` on the item is what labels the result, not the evaluator class,
61
62
  which is why `knowledge_question` can reuse `GeneralQuestionEvaluator` verbatim.
62
- `dashboard_summary` items additionally need `summary_input`.
63
+ `dashboard_summary` items additionally need `summary_input`, `document_takeaway` items
64
+ `takeaway_input`.
63
65
 
64
66
  ## Running the pipeline
65
67
 
@@ -155,8 +157,8 @@ Run this before guessing a `--model` string.
155
157
  | `GD_EVAL_JUDGE_MODEL` | judge model, same as `--judge-model` |
156
158
  | `GD_EVAL_AGENT_ID` | which agent to drive, same as `--agent-id` |
157
159
  | `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` | `--langfuse`, `--langfuse-dataset` |
158
- | `GOODDATA_EVAL_CHAT_*` | SSE retry, backoff and timeout knobs |
159
- | `GOODDATA_EVAL_TRACE_LABELS` | `key=value,...` labels stamped on every observation of a chat conversation via W3C baggage; `model_version` also sets the observation `version`, so set it only for a one-model run: every `--model` in one process gets the same labels. Keys must match `[A-Za-z0-9_-]+`; others are skipped with a warning. Dashboard-summary runs are not labelled |
160
+ | `GOODDATA_EVAL_CHAT_*` | retry and backoff knobs shared by the chat and takeaway clients (`core/_retry.py`), and the SSE turn and item timeouts |
161
+ | `GOODDATA_EVAL_TRACE_LABELS` | `key=value,...` labels stamped on every observation of a chat conversation via W3C baggage; `model_version` also sets the observation `version`, so set it only for a one-model run: every `--model` in one process gets the same labels. Keys must match `[A-Za-z0-9_-]+`; others are skipped with a warning. Dashboard-summary and document-takeaway runs are not labelled |
160
162
  | `GOODDATA_EVAL_JOIN_GENAI_TRACE` | on unless set to `0`, `false`, `no`, `off` or empty. An agentic item's chat turns ask gen-ai to open them under gd-eval's experiment root, so each item is one Langfuse trace with its scores written once. A gen-ai that drops caller baggage keeps today's separate traces; see the gotcha below |
161
163
  | `GD_EVAL_TIMERS` | same as `--timers` |
162
164
 
@@ -181,12 +183,15 @@ That default is correct: agent tool calls create real server-side objects (metri
181
183
  Adding a kind to `PARALLEL_SAFE_TEST_KINDS` is a deliberate assertion that it is read-only,
182
184
  which nothing in the package can prove for you.
183
185
 
184
- **The SSE client's retry predicate is load-bearing.** `core/chat/sse_client.py` retries
185
- 429/502/503/504 and `httpx.RemoteProtocolError` (a mid-stream disconnect) with exponential
186
- backoff, and treats a `METADATA_SYNC_IN_PROGRESS` payload as transient. The
187
- `RemoteProtocolError` case was added after it was confirmed live to contaminate a small
188
- percentage of visualization runs with a hard fail and no retry. Narrowing that predicate
189
- reintroduces the problem.
186
+ **The SSE client's retry predicate is load-bearing.** The backoff loop and the retryable
187
+ status set (429/500/502/503/504) live in `core/_retry.py`, shared by the chat and takeaway
188
+ clients and tuned by the same `GOODDATA_EVAL_CHAT_*` knobs. `core/chat/sse_client.py`
189
+ retries that status set and `httpx.RemoteProtocolError` (a mid-stream disconnect), and
190
+ treats a `METADATA_SYNC_IN_PROGRESS` payload as transient. The `RemoteProtocolError` case
191
+ was added after it was confirmed live to contaminate a small percentage of visualization
192
+ runs with a hard fail and no retry. Narrowing that predicate reintroduces the problem. The
193
+ takeaway client retries the same status set plus connection failures, but not a read
194
+ timeout: a slow page would spend the whole timeout again on every attempt.
190
195
 
191
196
  **Langfuse trace linking is deliberately off the item critical path.** Polling for trace
192
197
  ingestion has no pass/fail signal and inflates or misattributes per-item latency, so
@@ -0,0 +1,6 @@
1
+ # (C) 2026 GoodData Corporation
2
+ include ../../project_common.mk
3
+
4
+ .PHONY: calibrate-takeaway
5
+ calibrate-takeaway:
6
+ uv run --package gooddata-eval --extra llm-judge python scripts/calibrate_takeaway_judge.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gooddata-eval
3
- Version: 1.78.0
3
+ Version: 1.78.1.dev1
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.0
20
+ Requires-Dist: gooddata-sdk~=1.78.1.dev1
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
@@ -161,8 +161,8 @@ gd-eval run \
161
161
  | `--runs K` | `2` | Independent runs per item. |
162
162
  | `--gate` | `any` | Which verdict decides an item: `any` = pass@K (a run passing is enough), `power` = pass^K (every run must pass, so the verdict measures stability). Identical at `--runs 1`. Kinds that repeat K runs only — `power` is refused when the dataset also has non-agentic items or `agentic_conversation`, which are always decided on pass@K. |
163
163
  | `--concurrency K` | `1` | Number of items evaluated concurrently. `1` = sequential (default). Increase to load-test the agent under simultaneous requests — see *Concurrency and workspace safety* below. |
164
- | `--judge-model MODEL` | `gpt-4o` | Model used for LLM-as-judge scoring — `agentic_general_question`, `agentic_guardrail`, `general_question`, `guardrail` and `dashboard_summary`. Also settable via `GD_EVAL_JUDGE_MODEL`. Two things to weigh before changing it: the gpt-5 family rejects `temperature=0`, so verdicts stop being reproducible (the run warns when this happens); and choosing the same model the agent runs means the judge grades its own family's output. |
165
- | `--reasoning-effort LEVEL` | server default | `LOW`, `MEDIUM` or `HIGH`, sent as `options.reasoningEffort` on every chat message. Requires the `enableGenAiReasoningEffort` feature flag on the target organization — without it the server ignores the value. Applies to chat items only; `dashboard_summary` items go through the summary endpoint, which has no such option. |
164
+ | `--judge-model MODEL` | `gpt-4o` | Model used for LLM-as-judge scoring — `agentic_general_question`, `agentic_guardrail`, `general_question`, `guardrail`, `dashboard_summary` and `document_takeaway`. Also settable via `GD_EVAL_JUDGE_MODEL`. Two things to weigh before changing it: the gpt-5 family rejects `temperature=0`, so verdicts stop being reproducible (the run warns when this happens); and choosing the same model the agent runs means the judge grades its own family's output. |
165
+ | `--reasoning-effort LEVEL` | server default | `LOW`, `MEDIUM` or `HIGH`, sent as `options.reasoningEffort` on every chat message. Requires the `enableGenAiReasoningEffort` feature flag on the target organization — without it the server ignores the value. Applies to chat items only; `dashboard_summary` and `document_takeaway` items go through their own endpoints, which have no such option. |
166
166
 
167
167
  **Concurrency and workspace safety.** Agentic kinds that create workspace objects
168
168
  (`agentic_metric_skill`, `agentic_alert_skill`, `agentic_conversation`, `agentic_kda_skill`) always run one at a
@@ -736,7 +736,7 @@ A dataset is a folder of `.json` files, one per question:
736
736
  ```
737
737
 
738
738
  Supported `test_kind` values: `visualization`, `metric_skill`, `alert_skill`,
739
- `search_tool`, `general_question`, `guardrail`, `dashboard_summary`.
739
+ `search_tool`, `general_question`, `guardrail`, `dashboard_summary`, `document_takeaway`.
740
740
 
741
741
  ### `dashboard_summary` items
742
742
 
@@ -775,6 +775,64 @@ The `expected_output` rubric:
775
775
  Each criterion is scored independently by the LLM judge, so `quality_score`
776
776
  is the fraction of satisfied criteria.
777
777
 
778
+ ### `document_takeaway` items
779
+
780
+ Takeaway items call the AI Publisher takeaway endpoint
781
+ (`POST /api/v1/ai/workspaces/{ws}/takeaway`) with a `takeaway_input` block, authored in
782
+ snake_case and sent as the endpoint's camelCase body. In a Langfuse dataset the block goes
783
+ in the item's `metadata` (or its `input` object), and `input` can stay a plain case label;
784
+ an item carrying `takeaway_input` is a `document_takeaway` item unless it declares another
785
+ `test_kind`. The endpoint needs the `enableGenAiReportTakeaways` feature flag.
786
+
787
+ ```json
788
+ {
789
+ "id": "takeaway-001",
790
+ "dataset_name": "agent_document_takeaway",
791
+ "test_kind": "document_takeaway",
792
+ "question": "P01 default format, Sales page, no instructions",
793
+ "takeaway_input": {
794
+ "visualizations": ["<visualization id>", {"visualization_id": "<id>", "filters": []}],
795
+ "filter_context": [],
796
+ "instructions": {"document": null, "slot": null, "format_hint": null},
797
+ "max_characters": 300
798
+ },
799
+ "expected_output": {
800
+ "no_data": false,
801
+ "format": {"no_lists": false},
802
+ "must_include": ["States one finding that connects at least two of the charts"],
803
+ "must_not_include": [],
804
+ "rubric": []
805
+ }
806
+ }
807
+ ```
808
+
809
+ `takeaway_input` requires `visualizations`; unknown keys are rejected. Every
810
+ `expected_output` key is optional:
811
+
812
+ - `no_data` — whether the endpoint must answer `noData`; when either side says no data,
813
+ nothing else is graded.
814
+ - `format` — overrides of the deterministic checks, which gate pass/fail: `bold_headline`,
815
+ `max_headline_words` (10), `max_sentences` (2), `max_words` (50), `single_paragraph`,
816
+ `no_lists`, `no_headings`. `false` or `null` switches a check off; unknown keys are
817
+ rejected. `length` (`max_characters`), `no_ids` and `no_meta` always apply.
818
+ - `default_rules` — `false` drops the default judge criteria (one finding; no executive
819
+ summary, chart walk-through, background opener, figures, dates, causes the data cannot
820
+ show, chart description).
821
+ - `must_include`, `must_not_include`, `rubric` — the item's own judge criteria, graded like
822
+ `dashboard_summary`.
823
+
824
+ `word_count` and `headline_word_count` are reported in the item detail. The default judge
825
+ criteria are calibrated with `make calibrate-takeaway` (needs `OPENAI_API_KEY`); re-run it
826
+ after changing their wording.
827
+
828
+ The client retries 429/5xx answers and connection failures. A request error that survives
829
+ the retries is handled differently by the two entry points: `gd-eval run` aborts the item
830
+ with that error, whatever `--runs` is, while the CI entry point
831
+ (`evaluate_document_takeaway`) records it as an errored run, keeps the item's other runs,
832
+ and raises only when no run completed. A read timeout is not retried, and the CI entry
833
+ point records it as an errored run too. Any other 4xx and an unsupported URL scheme are
834
+ never retried and fail the item in both.
835
+
778
836
  ### Items asked in a dashboard context (`user_context`)
779
837
 
780
838
  An item can be asked the way a user asks from an open dashboard or an attached widget.
@@ -782,7 +840,8 @@ Its `user_context` is sent verbatim as `userContext` on every chat message of th
782
840
  follow-up and clarification messages included — the server treats a message without one
783
841
  as a cleared context. Requires the `enableAiContextSetup` feature flag on the target
784
842
  organization — without it the server ignores the dashboard view. Applies to chat items
785
- only; `dashboard_summary` items carry their scope in `summary_input` instead. No
843
+ only; `dashboard_summary` and `document_takeaway` items carry their scope in
844
+ `summary_input` / `takeaway_input` instead. No
786
845
  `userContext` key is sent at all when neither the item nor, for `agentic_conversation`,
787
846
  the current turn sets one.
788
847
 
@@ -835,6 +894,7 @@ from that turn on until another turn changes it, as the attached context does in
835
894
  | `general_question` | Text answer judged by LLM | `[llm-judge]` |
836
895
  | `guardrail` | Refusal/redirect (visualization response auto-fails) | `[llm-judge]` |
837
896
  | `dashboard_summary` | Dashboard summary (via `/summary` endpoint) scored against a rubric by LLM | `[llm-judge]` |
897
+ | `document_takeaway` | One short takeaway for a page (via `/takeaway` endpoint): deterministic format checks plus a rubric scored by LLM | `[llm-judge]` |
838
898
 
839
899
  ## Optional extras
840
900
 
@@ -132,8 +132,8 @@ gd-eval run \
132
132
  | `--runs K` | `2` | Independent runs per item. |
133
133
  | `--gate` | `any` | Which verdict decides an item: `any` = pass@K (a run passing is enough), `power` = pass^K (every run must pass, so the verdict measures stability). Identical at `--runs 1`. Kinds that repeat K runs only — `power` is refused when the dataset also has non-agentic items or `agentic_conversation`, which are always decided on pass@K. |
134
134
  | `--concurrency K` | `1` | Number of items evaluated concurrently. `1` = sequential (default). Increase to load-test the agent under simultaneous requests — see *Concurrency and workspace safety* below. |
135
- | `--judge-model MODEL` | `gpt-4o` | Model used for LLM-as-judge scoring — `agentic_general_question`, `agentic_guardrail`, `general_question`, `guardrail` and `dashboard_summary`. Also settable via `GD_EVAL_JUDGE_MODEL`. Two things to weigh before changing it: the gpt-5 family rejects `temperature=0`, so verdicts stop being reproducible (the run warns when this happens); and choosing the same model the agent runs means the judge grades its own family's output. |
136
- | `--reasoning-effort LEVEL` | server default | `LOW`, `MEDIUM` or `HIGH`, sent as `options.reasoningEffort` on every chat message. Requires the `enableGenAiReasoningEffort` feature flag on the target organization — without it the server ignores the value. Applies to chat items only; `dashboard_summary` items go through the summary endpoint, which has no such option. |
135
+ | `--judge-model MODEL` | `gpt-4o` | Model used for LLM-as-judge scoring — `agentic_general_question`, `agentic_guardrail`, `general_question`, `guardrail`, `dashboard_summary` and `document_takeaway`. Also settable via `GD_EVAL_JUDGE_MODEL`. Two things to weigh before changing it: the gpt-5 family rejects `temperature=0`, so verdicts stop being reproducible (the run warns when this happens); and choosing the same model the agent runs means the judge grades its own family's output. |
136
+ | `--reasoning-effort LEVEL` | server default | `LOW`, `MEDIUM` or `HIGH`, sent as `options.reasoningEffort` on every chat message. Requires the `enableGenAiReasoningEffort` feature flag on the target organization — without it the server ignores the value. Applies to chat items only; `dashboard_summary` and `document_takeaway` items go through their own endpoints, which have no such option. |
137
137
 
138
138
  **Concurrency and workspace safety.** Agentic kinds that create workspace objects
139
139
  (`agentic_metric_skill`, `agentic_alert_skill`, `agentic_conversation`, `agentic_kda_skill`) always run one at a
@@ -707,7 +707,7 @@ A dataset is a folder of `.json` files, one per question:
707
707
  ```
708
708
 
709
709
  Supported `test_kind` values: `visualization`, `metric_skill`, `alert_skill`,
710
- `search_tool`, `general_question`, `guardrail`, `dashboard_summary`.
710
+ `search_tool`, `general_question`, `guardrail`, `dashboard_summary`, `document_takeaway`.
711
711
 
712
712
  ### `dashboard_summary` items
713
713
 
@@ -746,6 +746,64 @@ The `expected_output` rubric:
746
746
  Each criterion is scored independently by the LLM judge, so `quality_score`
747
747
  is the fraction of satisfied criteria.
748
748
 
749
+ ### `document_takeaway` items
750
+
751
+ Takeaway items call the AI Publisher takeaway endpoint
752
+ (`POST /api/v1/ai/workspaces/{ws}/takeaway`) with a `takeaway_input` block, authored in
753
+ snake_case and sent as the endpoint's camelCase body. In a Langfuse dataset the block goes
754
+ in the item's `metadata` (or its `input` object), and `input` can stay a plain case label;
755
+ an item carrying `takeaway_input` is a `document_takeaway` item unless it declares another
756
+ `test_kind`. The endpoint needs the `enableGenAiReportTakeaways` feature flag.
757
+
758
+ ```json
759
+ {
760
+ "id": "takeaway-001",
761
+ "dataset_name": "agent_document_takeaway",
762
+ "test_kind": "document_takeaway",
763
+ "question": "P01 default format, Sales page, no instructions",
764
+ "takeaway_input": {
765
+ "visualizations": ["<visualization id>", {"visualization_id": "<id>", "filters": []}],
766
+ "filter_context": [],
767
+ "instructions": {"document": null, "slot": null, "format_hint": null},
768
+ "max_characters": 300
769
+ },
770
+ "expected_output": {
771
+ "no_data": false,
772
+ "format": {"no_lists": false},
773
+ "must_include": ["States one finding that connects at least two of the charts"],
774
+ "must_not_include": [],
775
+ "rubric": []
776
+ }
777
+ }
778
+ ```
779
+
780
+ `takeaway_input` requires `visualizations`; unknown keys are rejected. Every
781
+ `expected_output` key is optional:
782
+
783
+ - `no_data` — whether the endpoint must answer `noData`; when either side says no data,
784
+ nothing else is graded.
785
+ - `format` — overrides of the deterministic checks, which gate pass/fail: `bold_headline`,
786
+ `max_headline_words` (10), `max_sentences` (2), `max_words` (50), `single_paragraph`,
787
+ `no_lists`, `no_headings`. `false` or `null` switches a check off; unknown keys are
788
+ rejected. `length` (`max_characters`), `no_ids` and `no_meta` always apply.
789
+ - `default_rules` — `false` drops the default judge criteria (one finding; no executive
790
+ summary, chart walk-through, background opener, figures, dates, causes the data cannot
791
+ show, chart description).
792
+ - `must_include`, `must_not_include`, `rubric` — the item's own judge criteria, graded like
793
+ `dashboard_summary`.
794
+
795
+ `word_count` and `headline_word_count` are reported in the item detail. The default judge
796
+ criteria are calibrated with `make calibrate-takeaway` (needs `OPENAI_API_KEY`); re-run it
797
+ after changing their wording.
798
+
799
+ The client retries 429/5xx answers and connection failures. A request error that survives
800
+ the retries is handled differently by the two entry points: `gd-eval run` aborts the item
801
+ with that error, whatever `--runs` is, while the CI entry point
802
+ (`evaluate_document_takeaway`) records it as an errored run, keeps the item's other runs,
803
+ and raises only when no run completed. A read timeout is not retried, and the CI entry
804
+ point records it as an errored run too. Any other 4xx and an unsupported URL scheme are
805
+ never retried and fail the item in both.
806
+
749
807
  ### Items asked in a dashboard context (`user_context`)
750
808
 
751
809
  An item can be asked the way a user asks from an open dashboard or an attached widget.
@@ -753,7 +811,8 @@ Its `user_context` is sent verbatim as `userContext` on every chat message of th
753
811
  follow-up and clarification messages included — the server treats a message without one
754
812
  as a cleared context. Requires the `enableAiContextSetup` feature flag on the target
755
813
  organization — without it the server ignores the dashboard view. Applies to chat items
756
- only; `dashboard_summary` items carry their scope in `summary_input` instead. No
814
+ only; `dashboard_summary` and `document_takeaway` items carry their scope in
815
+ `summary_input` / `takeaway_input` instead. No
757
816
  `userContext` key is sent at all when neither the item nor, for `agentic_conversation`,
758
817
  the current turn sets one.
759
818
 
@@ -806,6 +865,7 @@ from that turn on until another turn changes it, as the attached context does in
806
865
  | `general_question` | Text answer judged by LLM | `[llm-judge]` |
807
866
  | `guardrail` | Refusal/redirect (visualization response auto-fails) | `[llm-judge]` |
808
867
  | `dashboard_summary` | Dashboard summary (via `/summary` endpoint) scored against a rubric by LLM | `[llm-judge]` |
868
+ | `document_takeaway` | One short takeaway for a page (via `/takeaway` endpoint): deterministic format checks plus a rubric scored by LLM | `[llm-judge]` |
809
869
 
810
870
  ## Optional extras
811
871
 
@@ -1,7 +1,7 @@
1
1
  # (C) 2026 GoodData Corporation
2
2
  [project]
3
3
  name = "gooddata-eval"
4
- version = "1.78.0"
4
+ version = "1.78.1.dev1"
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.78.0",
14
+ "gooddata-sdk~=1.78.1.dev1",
15
15
  "httpx>=0.27,<1.0",
16
16
  "jsonpatch>=1.33,<2.0",
17
17
  "orjson>=3.9.15,<4.0.0",
@@ -0,0 +1,75 @@
1
+ # (C) 2026 GoodData Corporation
2
+ """Does the real judge put each calibration text on the right side of every default criterion?
3
+
4
+ tests/test_takeaway_evaluator.py pins the wiring and the criterion wording; it cannot pin
5
+ the verdict, because the verdict is an LLM's. This does, against the texts in
6
+ tests/fixtures/takeaway_calibration.json:
7
+
8
+ - a "pass" text must satisfy every default judge criterion on every run;
9
+ - a "fail" text must violate each criterion it names on every run.
10
+
11
+ Run it after changing any DEFAULT_MUST_INCLUDE / DEFAULT_MUST_NOT_INCLUDE wording:
12
+
13
+ OPENAI_API_KEY=... make calibrate-takeaway
14
+
15
+ It is a script rather than a test because CI has no OPENAI_API_KEY. Exits 1 on any mismatch.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from gooddata_eval.core.evaluators.takeaway import (
24
+ DEFAULT_MUST_INCLUDE,
25
+ DEFAULT_MUST_NOT_INCLUDE,
26
+ DocumentTakeawayEvaluator,
27
+ )
28
+ from gooddata_eval.core.models import ChatResult, DatasetItem, TakeawayInput
29
+
30
+ RUNS = 3
31
+ FIXTURE = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "takeaway_calibration.json"
32
+
33
+ CRITERIA = {f"default_include_{k}": k for k in DEFAULT_MUST_INCLUDE} | {
34
+ f"default_exclude_{k}": k for k in DEFAULT_MUST_NOT_INCLUDE
35
+ }
36
+
37
+
38
+ def main() -> int:
39
+ if not os.environ.get("OPENAI_API_KEY"):
40
+ raise SystemExit("OPENAI_API_KEY required")
41
+ fixture = json.loads(FIXTURE.read_text())
42
+ evaluator = DocumentTakeawayEvaluator()
43
+ print(f"judge model: {evaluator._positive_judge.model}, runs: {RUNS}")
44
+ mismatches = 0
45
+ for case in fixture["texts"]:
46
+ item = DatasetItem(
47
+ id=case["label"],
48
+ dataset_name="takeaway_calibration",
49
+ test_kind="document_takeaway",
50
+ question=fixture["question"],
51
+ expected_output={},
52
+ takeaway_input=TakeawayInput(visualizations=["calibration"]),
53
+ )
54
+ chat = ChatResult(text_response=case["text"], raw_response={"takeaway": case["text"], "noData": False})
55
+ satisfied = dict.fromkeys(CRITERIA.values(), 0)
56
+ for _ in range(RUNS):
57
+ detail = evaluator.evaluate(item, chat).detail
58
+ for key, name in CRITERIA.items():
59
+ satisfied[name] += detail.get(key) is True
60
+ want_pass = case["want"] == "pass"
61
+ must = dict.fromkeys(satisfied, RUNS) if want_pass else dict.fromkeys(case["criteria"], 0)
62
+ print(f"\n{case['label']} (want {case['want']})")
63
+ for name, count in satisfied.items():
64
+ flag = ""
65
+ if name in must:
66
+ ok = count == must[name]
67
+ mismatches += not ok
68
+ flag = "OK" if ok else "MISMATCH"
69
+ print(f" {name:20} satisfied {count}/{RUNS} {flag}")
70
+ print(f"\n{'all calibration texts on the right side' if not mismatches else f'{mismatches} mismatch(es)'}")
71
+ return 1 if mismatches else 0
72
+
73
+
74
+ if __name__ == "__main__":
75
+ sys.exit(main())
@@ -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."