gooddata-eval 1.79.1.dev1__tar.gz → 1.79.2.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 (156) hide show
  1. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/PKG-INFO +2 -2
  2. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/pyproject.toml +2 -2
  3. gooddata_eval-1.79.2.dev1/src/gooddata_eval/core/agentic/_clarification.py +511 -0
  4. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/_conversation_context.py +16 -0
  5. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/conversation.py +262 -3
  6. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/chat/sse_client.py +30 -4
  7. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/models.py +3 -0
  8. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_conversation.py +1009 -1
  9. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_sse_client.py +46 -0
  10. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/.gitignore +0 -0
  11. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/AGENTS.md +0 -0
  12. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/CLAUDE.md +0 -0
  13. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/LICENSE.txt +0 -0
  14. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/Makefile +0 -0
  15. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/README.md +0 -0
  16. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/scripts/calibrate_takeaway_judge.py +0 -0
  17. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/scripts/verify_guardrail_refusal_criteria.py +0 -0
  18. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/__init__.py +0 -0
  19. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/_version.py +0 -0
  20. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/cli/__init__.py +0 -0
  21. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/cli/agentic_runner.py +0 -0
  22. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/cli/main.py +0 -0
  23. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/__init__.py +0 -0
  24. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/_output.py +0 -0
  25. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/_retry.py +0 -0
  26. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/__init__.py +0 -0
  27. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
  28. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/_failed_runs.py +0 -0
  29. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/_gate.py +0 -0
  30. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/_langfuse.py +0 -0
  31. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/_trace_linker.py +0 -0
  32. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/alert_skill.py +0 -0
  33. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/anomaly_detection.py +0 -0
  34. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/dashboard_skill.py +0 -0
  35. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/document_skill.py +0 -0
  36. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/general_question.py +0 -0
  37. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/guardrail.py +0 -0
  38. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/kda_skill.py +0 -0
  39. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/metric_skill.py +0 -0
  40. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/report_skill.py +0 -0
  41. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/search_tool.py +0 -0
  42. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/visualization.py +0 -0
  43. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/agentic/what_if.py +0 -0
  44. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/chat/__init__.py +0 -0
  45. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/chat/render.py +0 -0
  46. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/config.py +0 -0
  47. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/connection.py +0 -0
  48. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/dataset/__init__.py +0 -0
  49. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/dataset/from_insights.py +0 -0
  50. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/dataset/langfuse_source.py +0 -0
  51. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/dataset/local.py +0 -0
  52. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/__init__.py +0 -0
  53. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
  54. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/_guardrail_criteria.py +0 -0
  55. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
  56. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/_maql.py +0 -0
  57. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/_rubric.py +0 -0
  58. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
  59. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/alert_skill.py +0 -0
  60. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/base.py +0 -0
  61. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
  62. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
  63. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
  64. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/search_tool.py +0 -0
  65. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/summary.py +0 -0
  66. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/takeaway.py +0 -0
  67. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
  68. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/granularity.py +0 -0
  69. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/interaction.py +0 -0
  70. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
  71. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/langfuse/_env.py +0 -0
  72. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/langfuse/client.py +0 -0
  73. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/langfuse/experiment.py +0 -0
  74. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/langfuse/item_scope.py +0 -0
  75. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/langfuse/observations.py +0 -0
  76. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/langfuse/otlp.py +0 -0
  77. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/langfuse/sink.py +0 -0
  78. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/number_format.py +0 -0
  79. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/reporting/__init__.py +0 -0
  80. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/reporting/console.py +0 -0
  81. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/reporting/html_report.py +0 -0
  82. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/reporting/json_report.py +0 -0
  83. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/reporting/report_template.html +0 -0
  84. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/runner.py +0 -0
  85. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/scoring.py +0 -0
  86. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/summary/__init__.py +0 -0
  87. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/summary/http_client.py +0 -0
  88. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/takeaway/__init__.py +0 -0
  89. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/takeaway/evaluate.py +0 -0
  90. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/takeaway/http_client.py +0 -0
  91. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/timing.py +0 -0
  92. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/src/gooddata_eval/core/workspace.py +0 -0
  93. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/__init__.py +0 -0
  94. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/_fake_langfuse.py +0 -0
  95. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/conftest.py +0 -0
  96. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
  97. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
  98. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/fixtures/sse_visualization_stream.txt +0 -0
  99. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/fixtures/takeaway_calibration.json +0 -0
  100. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_alert_skill.py +0 -0
  101. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_anomaly_detection.py +0 -0
  102. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_dashboard_skill.py +0 -0
  103. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_document_skill.py +0 -0
  104. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_gate.py +0 -0
  105. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_general_question.py +0 -0
  106. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_guardrail.py +0 -0
  107. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_join_trace.py +0 -0
  108. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_kda_skill.py +0 -0
  109. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_langfuse_trace.py +0 -0
  110. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_metric_skill.py +0 -0
  111. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_observe_experiment.py +0 -0
  112. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_run_context.py +0 -0
  113. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_runner.py +0 -0
  114. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_search_tool.py +0 -0
  115. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_visualization.py +0 -0
  116. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_agentic_what_if.py +0 -0
  117. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_alert_skill_evaluator.py +0 -0
  118. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_chat_render.py +0 -0
  119. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_cli.py +0 -0
  120. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_connection.py +0 -0
  121. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_deep_subset.py +0 -0
  122. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_fake_langfuse.py +0 -0
  123. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_from_insights.py +0 -0
  124. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_guardrail_criteria.py +0 -0
  125. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_html_report.py +0 -0
  126. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_interaction.py +0 -0
  127. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_langfuse_client.py +0 -0
  128. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_langfuse_e2e_fake_server.py +0 -0
  129. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_langfuse_env.py +0 -0
  130. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_langfuse_experiment.py +0 -0
  131. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_langfuse_item_scope.py +0 -0
  132. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_langfuse_observations.py +0 -0
  133. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_langfuse_otlp.py +0 -0
  134. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_langfuse_sink.py +0 -0
  135. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_langfuse_source.py +0 -0
  136. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_llm_judge.py +0 -0
  137. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_local_loader.py +0 -0
  138. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_maql_normalize.py +0 -0
  139. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_metric_skill_evaluator.py +0 -0
  140. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_models.py +0 -0
  141. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_number_format.py +0 -0
  142. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_reporting.py +0 -0
  143. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_runner.py +0 -0
  144. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_scoring.py +0 -0
  145. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_search_tool_evaluator.py +0 -0
  146. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_summary_client.py +0 -0
  147. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_summary_evaluator.py +0 -0
  148. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_takeaway_client.py +0 -0
  149. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_takeaway_evaluate.py +0 -0
  150. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_takeaway_evaluator.py +0 -0
  151. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_text_evaluators.py +0 -0
  152. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_timing.py +0 -0
  153. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_trace_linker.py +0 -0
  154. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_visualization_evaluator.py +0 -0
  155. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tests/test_workspace.py +0 -0
  156. {gooddata_eval-1.79.1.dev1 → gooddata_eval-1.79.2.dev1}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gooddata-eval
3
- Version: 1.79.1.dev1
3
+ Version: 1.79.2.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.79.1.dev1
20
+ Requires-Dist: gooddata-sdk~=1.79.2.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
@@ -1,7 +1,7 @@
1
1
  # (C) 2026 GoodData Corporation
2
2
  [project]
3
3
  name = "gooddata-eval"
4
- version = "1.79.1.dev1"
4
+ version = "1.79.2.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.79.1.dev1",
14
+ "gooddata-sdk~=1.79.2.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,511 @@
1
+ # (C) 2026 GoodData Corporation. All rights reserved.
2
+ """Whether a conversation turn's first reply asked the clarifying question its fixture expects."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import functools
7
+ import re
8
+ from collections.abc import Callable
9
+ from typing import Any, Literal
10
+
11
+ from gooddata_api_client.exceptions import ApiException, NotFoundException
12
+ from gooddata_sdk import GoodDataSdk
13
+ from gooddata_sdk.utils import load_all_entities
14
+ from pydantic import BaseModel, Field, model_validator
15
+ from urllib3.exceptions import HTTPError
16
+
17
+ from gooddata_eval.core._retry import RETRYABLE_STATUS_CODES, retry_transient
18
+ from gooddata_eval.core.models import ChatResult
19
+
20
+ ClarificationForm = Literal["questionnaire", "text"]
21
+
22
+ _REF_RE = re.compile(r"\{([A-Za-z_]+)/([^{}]+)\}")
23
+ # The entity kinds a `{type/id}` option reference can name. An attribute and its label are
24
+ # spelled either way by the agent, so each accepts the other. A date dataset is a dataset.
25
+ _REF_ENTITIES: dict[str, tuple[str, ...]] = {
26
+ "metric": ("metrics",),
27
+ "fact": ("facts",),
28
+ "dataset": ("datasets",),
29
+ "date": ("datasets",),
30
+ "attribute": ("attributes", "labels"),
31
+ "date_attribute": ("attributes", "labels"),
32
+ "label": ("labels", "attributes"),
33
+ "parameter": ("parameters",),
34
+ "dashboard": ("analyticalDashboards",),
35
+ "analyticalDashboard": ("analyticalDashboards",),
36
+ "visualization": ("visualizationObjects",),
37
+ "visualizationObject": ("visualizationObjects",),
38
+ "insight": ("visualizationObjects",),
39
+ }
40
+ # Reference types gen-ai writes that no entity listing holds, so grounding cannot check them.
41
+ _UNLISTED_REF_TYPES = frozenset({"computed_attribute", "filter_context", "document", "report"})
42
+ _ENTITY_KINDS = tuple(sorted({kind for kinds in _REF_ENTITIES.values() for kind in kinds}))
43
+ _PAGE_SIZE = 1000
44
+ # The EntitiesApi listing of each entity kind.
45
+ _LIST_METHODS: dict[str, str] = {
46
+ "analyticalDashboards": "get_all_entities_analytical_dashboards",
47
+ "attributes": "get_all_entities_attributes",
48
+ "datasets": "get_all_entities_datasets",
49
+ "facts": "get_all_entities_facts",
50
+ "labels": "get_all_entities_labels",
51
+ "metrics": "get_all_entities_metrics",
52
+ "parameters": "get_all_entities_parameters",
53
+ "visualizationObjects": "get_all_entities_visualization_objects",
54
+ }
55
+ # A shorter skipped option, such as "No" or "Q1", also occurs in text that assumes nothing, so it
56
+ # never counts as named.
57
+ _MIN_NAMED_OPTION_CHARS = 3
58
+
59
+
60
+ OptionTitleKind = Literal["metric", "attribute", "fact", "dataset", "dashboard", "visualization"]
61
+ _TITLE_ENTITIES: dict[str, str] = {
62
+ "metric": "metrics",
63
+ "attribute": "attributes",
64
+ "fact": "facts",
65
+ "dataset": "datasets",
66
+ "dashboard": "analyticalDashboards",
67
+ "visualization": "visualizationObjects",
68
+ }
69
+ # What may follow a title in an option that adds context, as two charts with one title need.
70
+ _TITLE_CONTEXT_SEPARATORS = (" (", " [", " - ", " – ", " — ", ": ", ", ")
71
+ _GENERIC_ANSWERS = frozenset(
72
+ {"yes", "no", "ok", "okay", "cancel", "confirm", "continue", "proceed", "skip", "not now", "go ahead"}
73
+ )
74
+ # How an option may spell a chart type whose `local:<type>` id is not a word of its own.
75
+ _CHART_TYPE_WORDS: dict[str, tuple[str, ...]] = {
76
+ "combo2": ("combo",),
77
+ "pushpin": ("geo", "map", "pushpin"),
78
+ "heatmap": ("heatmap", "heat map"),
79
+ "treemap": ("treemap", "tree map"),
80
+ "dependencywheel": ("dependency wheel",),
81
+ }
82
+
83
+
84
+ class QuestionOptions(BaseModel):
85
+ """One question's options: the ``type/id`` references they name, and the options that name none."""
86
+
87
+ refs: list[str] = Field(default_factory=list)
88
+ plain: list[str] = Field(default_factory=list)
89
+
90
+
91
+ class FirstReply(BaseModel):
92
+ """What the turn's first assistant reply asked, read before any simulated-user answer."""
93
+
94
+ asked: bool
95
+ form: ClarificationForm | None = None
96
+ questions: list[QuestionOptions] = Field(default_factory=list)
97
+
98
+ @property
99
+ def question_count(self) -> int:
100
+ return len(self.questions)
101
+
102
+ @property
103
+ def option_refs(self) -> list[str]:
104
+ return [ref for q in self.questions for ref in q.refs]
105
+
106
+ @property
107
+ def plain_options(self) -> list[str]:
108
+ return [option for q in self.questions for option in q.plain]
109
+
110
+ @property
111
+ def has_options(self) -> bool:
112
+ return any(q.refs or q.plain for q in self.questions)
113
+
114
+
115
+ def read_first_reply(chat_result: ChatResult, *, asked_in_text: bool) -> FirstReply:
116
+ """The reply's clarifying questions; a ``clarifyingQuestions`` part makes it a questionnaire.
117
+
118
+ ``asked_in_text`` is the caller's reading of the prose, for a question without a part.
119
+ """
120
+ parts = [p for p in chat_result.unhandled_parts or [] if p.get("type") == "clarifyingQuestions"]
121
+ if not parts:
122
+ return FirstReply(asked=asked_in_text, form="text" if asked_in_text else None)
123
+ questions = []
124
+ for question in (q for p in parts for q in p.get("questions") or []):
125
+ options = _options(question)
126
+ questions.append(
127
+ QuestionOptions(
128
+ refs=[f"{kind}/{obj_id}" for text in options for kind, obj_id in _REF_RE.findall(text)],
129
+ plain=[text for text in options if not _REF_RE.search(text)],
130
+ )
131
+ )
132
+ return FirstReply(asked=True, form="questionnaire", questions=questions)
133
+
134
+
135
+ class WorkspaceObjects(BaseModel):
136
+ """The workspace's objects as the option checks read them: ``<entity kind>/<id>`` and their titles."""
137
+
138
+ refs: set[str] = Field(default_factory=set)
139
+ # `<entity kind>/<id>` -> the title, casefolded with whitespace collapsed.
140
+ titles: dict[str, str] = Field(default_factory=dict)
141
+ # `visualizationObjects/<id>` -> the chart type, `local:donut` read as `donut`.
142
+ chart_types: dict[str, str] = Field(default_factory=dict)
143
+
144
+ def titles_of(self, entity_kind: str) -> set[str]:
145
+ return {title for ref, title in self.titles.items() if ref.startswith(f"{entity_kind}/")}
146
+
147
+
148
+ def _title_key(text: str) -> str:
149
+ return " ".join(text.casefold().split())
150
+
151
+
152
+ def _is_transient(exc: Exception) -> bool:
153
+ if isinstance(exc, ApiException):
154
+ return exc.status in RETRYABLE_STATUS_CODES
155
+ return isinstance(exc, HTTPError)
156
+
157
+
158
+ def _page_with_retry(list_page: Callable[..., Any], **page: int) -> Any:
159
+ return retry_transient(lambda: list_page(**page), is_retryable=_is_transient)
160
+
161
+
162
+ def fetch_workspace_objects(sdk: GoodDataSdk, workspace_id: str) -> WorkspaceObjects:
163
+ """Every ``<entity kind>/<id>`` and title the workspace holds, inherited objects included.
164
+
165
+ A page that fails with a transient error is retried as a chat message is. A kind the server does
166
+ not know (404) has no objects. Any other failure of the entity API is raised.
167
+ """
168
+ found = WorkspaceObjects()
169
+ api = sdk._client.entities_api
170
+ for kind in _ENTITY_KINDS:
171
+ list_page = functools.partial(getattr(api, _LIST_METHODS[kind]), workspace_id, _check_return_type=False)
172
+ try:
173
+ data = load_all_entities(functools.partial(_page_with_retry, list_page), page_size=_PAGE_SIZE).data
174
+ except NotFoundException:
175
+ continue
176
+ for obj in data:
177
+ if not isinstance(obj, dict) or not obj.get("id"):
178
+ continue
179
+ ref = f"{kind}/{obj['id']}"
180
+ found.refs.add(ref)
181
+ raw_attributes = obj.get("attributes")
182
+ attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}
183
+ title = attributes.get("title")
184
+ if isinstance(title, str) and title:
185
+ found.titles[ref] = _title_key(title)
186
+ raw_content = attributes.get("content")
187
+ content: dict[str, Any] = raw_content if isinstance(raw_content, dict) else {}
188
+ chart_url = str(content.get("visualizationUrl") or "")
189
+ if chart_url.startswith("local:"):
190
+ found.chart_types[ref] = chart_url.removeprefix("local:")
191
+ return found
192
+
193
+
194
+ def ungrounded_refs(refs: list[str], known: set[str]) -> list[str]:
195
+ """The references that name no object of the workspace, or a kind it has none of.
196
+
197
+ A type of ``_UNLISTED_REF_TYPES`` is left unchecked.
198
+ """
199
+ missing: list[str] = []
200
+ for ref in refs:
201
+ ref_type, _, obj_id = ref.partition("/")
202
+ if ref_type in _UNLISTED_REF_TYPES:
203
+ continue
204
+ kinds = _REF_ENTITIES.get(ref_type, ())
205
+ if not any(f"{kind}/{obj_id}" in known for kind in kinds):
206
+ missing.append(ref)
207
+ return missing
208
+
209
+
210
+ def _is_titled(option: str, titles: set[str]) -> bool:
211
+ key = _title_key(option)
212
+ return key in titles or any(key.startswith(title + sep) for title in titles for sep in _TITLE_CONTEXT_SEPARATORS)
213
+
214
+
215
+ def untitled_options(options: list[str], objects: WorkspaceObjects, kind: OptionTitleKind) -> list[str]:
216
+ """The options that are not the title of a ``kind`` object, alone or followed by added context."""
217
+ titles = objects.titles_of(_TITLE_ENTITIES[kind])
218
+ return [option for option in options if not _is_titled(option, titles)]
219
+
220
+
221
+ def _off_in_scope(
222
+ questions: list[QuestionOptions], off_of: Callable[[QuestionOptions], tuple[int, list[str]]]
223
+ ) -> list[str]:
224
+ """The options an option check fails, read only from the questions the check is about.
225
+
226
+ ``off_of`` gives a question's count of options the check reads and the ones it fails. A questionnaire
227
+ can ask about more than one thing, such as a metric next to a channel: a question with at least one
228
+ option the check accepts is about it, and a question with none is about something else and left out.
229
+ When no question is about it, every option the check read fails.
230
+ """
231
+ checked = [off_of(q) for q in questions]
232
+ about = [off for read, off in checked if len(off) < read]
233
+ return [option for off in (about or [off for _, off in checked]) for option in off]
234
+
235
+
236
+ def untitled_question_options(reply: FirstReply, objects: WorkspaceObjects, kind: OptionTitleKind) -> list[str]:
237
+ """The plain-text options that are not the title of a ``kind`` object, in the questions about that kind."""
238
+ return _off_in_scope(reply.questions, lambda q: (len(q.plain), untitled_options(q.plain, objects, kind)))
239
+
240
+
241
+ def _entity_refs(ref: str) -> set[str]:
242
+ ref_type, _, obj_id = ref.partition("/")
243
+ return {f"{kind}/{obj_id}" for kind in _REF_ENTITIES.get(ref_type, ())}
244
+
245
+
246
+ def is_object_ref(ref: str) -> bool:
247
+ """Whether ``ref`` is ``type/id`` with a type an option reference can name."""
248
+ ref_type, sep, obj_id = ref.partition("/")
249
+ return bool(sep and obj_id) and ref_type in _REF_ENTITIES
250
+
251
+
252
+ def _names_chart_type(option: str, chart_type: str) -> bool:
253
+ key = _title_key(option)
254
+ return any(re.search(rf"\b{re.escape(word)}\b", key) for word in _CHART_TYPE_WORDS.get(chart_type, (chart_type,)))
255
+
256
+
257
+ def off_candidate_options(reply: FirstReply, objects: WorkspaceObjects, candidates: list[str]) -> list[str]:
258
+ """The options that name none of ``candidates``, in the questions that offer at least one of them.
259
+
260
+ An option names a candidate by reference, by its title plus context, or by its chart type when
261
+ no other candidate has that type: "Donut chart" tells two same-titled charts apart only then.
262
+ A chart type does not count in an option that starts with the title of an object outside the candidates.
263
+ """
264
+ wanted = set().union(*(_entity_refs(c) for c in candidates))
265
+ titles = {objects.titles[ref] for ref in wanted if ref in objects.titles}
266
+ kinds = {ref.partition("/")[0] for ref in wanted}
267
+ other_titles = {
268
+ title for ref, title in objects.titles.items() if ref not in wanted and ref.partition("/")[0] in kinds
269
+ } - titles
270
+ types = [objects.chart_types[ref] for ref in wanted if ref in objects.chart_types]
271
+ unique_types = {chart_type for chart_type in types if types.count(chart_type) == 1}
272
+
273
+ def names_a_candidate_type(option: str) -> bool:
274
+ return any(_names_chart_type(option, t) for t in unique_types) and not _is_titled(option, other_titles)
275
+
276
+ def off_of(question: QuestionOptions) -> tuple[int, list[str]]:
277
+ off = [ref for ref in question.refs if not (_entity_refs(ref) & wanted)]
278
+ off += [
279
+ option for option in question.plain if not _is_titled(option, titles) and not names_a_candidate_type(option)
280
+ ]
281
+ return len(question.refs) + len(question.plain), off
282
+
283
+ return _off_in_scope(reply.questions, off_of)
284
+
285
+
286
+ def clarification_mismatches(
287
+ reply: FirstReply,
288
+ *,
289
+ expects_clarification: bool,
290
+ expected_form: ClarificationForm | None,
291
+ max_questions: int | None,
292
+ objects: WorkspaceObjects | None,
293
+ grounded: bool,
294
+ titled_as: OptionTitleKind | None,
295
+ candidates: list[str] | None = None,
296
+ reads_questionnaire: bool = False,
297
+ ) -> list[str]:
298
+ """How the first reply differs from the expected clarification; empty when it matches.
299
+
300
+ ``grounded`` checks the ``{type/id}`` options of every question; ``titled_as`` checks the plain-text
301
+ ones against that kind's titles; ``candidates`` requires every option to name one of those objects.
302
+ The last two read only the questions they are about (see ``_off_in_scope``). All three are skipped
303
+ when ``objects`` is None: the caller reports the turn ungraded then.
304
+ ``reads_questionnaire`` fails a prose question, which has no options for these checks to read.
305
+ """
306
+ if not expects_clarification:
307
+ return [f"asked a clarifying question ({reply.form}) the request does not need"] if reply.asked else []
308
+ if not reply.asked:
309
+ return ["expected a clarifying question, but the first reply asked none"]
310
+ out: list[str] = []
311
+ if expected_form is not None and reply.form != expected_form:
312
+ out.append(f"clarification form: expected {expected_form}, got {reply.form}")
313
+ elif reads_questionnaire and reply.form != "questionnaire":
314
+ out.append(f"the turn checks a questionnaire, but the first reply asked as {reply.form}")
315
+ if max_questions is not None and reply.question_count > max_questions:
316
+ out.append(f"asked {reply.question_count} questions, at most {max_questions} expected")
317
+ if objects is not None and grounded:
318
+ missing = ungrounded_refs(reply.option_refs, objects.refs)
319
+ if missing:
320
+ out.append(f"options name objects the workspace does not have: {missing}")
321
+ if objects is not None and titled_as is not None:
322
+ untitled = untitled_question_options(reply, objects, titled_as)
323
+ if untitled:
324
+ out.append(f"options are not the title of a {titled_as} of the workspace: {untitled}")
325
+ if objects is not None and candidates:
326
+ off = off_candidate_options(reply, objects, candidates)
327
+ if off:
328
+ out.append(f"options name none of {candidates}: {off}")
329
+ return out
330
+
331
+
332
+ class ClarifyingPick(BaseModel):
333
+ """An option the fixture picks in a questionnaire.
334
+
335
+ ``option`` is a ``type/id`` reference the option names, or the option's whole text. A list names
336
+ the spellings of one option, since the agent writes it as a reference or as plain text.
337
+ ``question`` limits the pick to that position; without it, the first question offering the
338
+ option takes it.
339
+ """
340
+
341
+ option: str | list[str]
342
+ question: int | None = Field(default=None, ge=0)
343
+
344
+ @property
345
+ def spellings(self) -> list[str]:
346
+ return [self.option] if isinstance(self.option, str) else list(self.option)
347
+
348
+ @property
349
+ def label(self) -> str:
350
+ return " | ".join(self.spellings)
351
+
352
+ @model_validator(mode="after")
353
+ def _names_an_option(self) -> ClarifyingPick:
354
+ if not self.spellings or not all(s.strip() for s in self.spellings):
355
+ raise ValueError("option needs at least one non-empty spelling")
356
+ return self
357
+
358
+
359
+ class ClarifyingAnswers(BaseModel):
360
+ """The ``clarifyingAnswers`` for one questionnaire, and what they left out."""
361
+
362
+ answers: list[dict] = Field(default_factory=list)
363
+ skipped: list[dict] = Field(default_factory=list)
364
+ matched_picks: list[str] = Field(default_factory=list)
365
+
366
+ def render(self) -> str:
367
+ """The text gen-ai builds for the model from these answers: every question, a skipped one as Skipped."""
368
+ sections = [(a["questionIndex"], a["questionText"], a["answer"]["optionText"]) for a in self.answers]
369
+ sections += [(s["index"], s["text"], "Skipped") for s in self.skipped]
370
+ return "\n\n".join(f"**{text}**\n\n{answer}" for _, text, answer in sorted(sections, key=lambda s: s[0]))
371
+
372
+
373
+ def _all_questions(chat_result: ChatResult) -> list[dict]:
374
+ parts = [p for p in chat_result.unhandled_parts or [] if p.get("type") == "clarifyingQuestions"]
375
+ return [q for p in parts for q in p.get("questions") or []]
376
+
377
+
378
+ def _questions(chat_result: ChatResult) -> list[dict]:
379
+ """The questions of the item the answers name, numbered as gen-ai numbers them: across that item's parts."""
380
+ item_id = chat_result.clarifying_questions_item_id
381
+ parts = [
382
+ p
383
+ for p in chat_result.unhandled_parts or []
384
+ if p.get("type") == "clarifyingQuestions" and p.get("itemId") in (None, item_id)
385
+ ]
386
+ return [q for p in parts for q in p.get("questions") or []]
387
+
388
+
389
+ def _options(question: dict) -> list[str]:
390
+ return [str(o.get("text") or "") for o in (question.get("control") or {}).get("options") or []]
391
+
392
+
393
+ def _plain(text: str) -> str:
394
+ return " ".join(_REF_RE.sub(" ", text).lower().split())
395
+
396
+
397
+ def _same_object(ref: str, other: str) -> bool:
398
+ return ref == other or bool(_entity_refs(ref) & _entity_refs(other))
399
+
400
+
401
+ def option_matches(option_text: str, wanted: str) -> bool:
402
+ """Whether an option is the one a pick names, by its ``{type/id}`` reference or its whole text.
403
+
404
+ A reference matches the spellings grounding accepts for the same object, such as ``attribute/x`` and ``label/x``.
405
+ """
406
+ ref = wanted.strip().strip("{}")
407
+ if "/" in ref and any(_same_object(ref, f"{kind}/{obj_id}") for kind, obj_id in _REF_RE.findall(option_text)):
408
+ return True
409
+ return option_text.strip().lower() == wanted.strip().lower()
410
+
411
+
412
+ def build_clarifying_answers(chat_result: ChatResult, picks: list[ClarifyingPick]) -> ClarifyingAnswers:
413
+ """Answer the reply's questionnaire with the picks; a question no pick matches is skipped."""
414
+ questions = _questions(chat_result)
415
+ out = ClarifyingAnswers()
416
+ answered: set[int] = set()
417
+ for pick in picks:
418
+ for qi, question in enumerate(questions):
419
+ if qi in answered or (pick.question is not None and pick.question != qi):
420
+ continue
421
+ oi = next(
422
+ (
423
+ i
424
+ for i, text in enumerate(_options(question))
425
+ if any(option_matches(text, spelling) for spelling in pick.spellings)
426
+ ),
427
+ None,
428
+ )
429
+ if oi is None:
430
+ continue
431
+ answered.add(qi)
432
+ out.matched_picks.append(pick.label)
433
+ out.answers.append(
434
+ {
435
+ "questionIndex": qi,
436
+ "questionText": question.get("text") or "",
437
+ "answer": {"type": "singleChoice", "optionIndex": oi, "optionText": _options(question)[oi]},
438
+ }
439
+ )
440
+ break
441
+ out.answers.sort(key=lambda a: a["questionIndex"])
442
+ out.skipped = [
443
+ {"index": qi, "text": q.get("text") or "", "options": _options(q)}
444
+ for qi, q in enumerate(questions)
445
+ if qi not in answered
446
+ ]
447
+ return out
448
+
449
+
450
+ def _names_option(text: str, option: str) -> bool:
451
+ """Whether ``text`` names the option: one of its references whole, or its words as whole words."""
452
+ refs_in_text = [f"{kind}/{obj_id}" for kind, obj_id in _REF_RE.findall(text)]
453
+ for kind, obj_id in _REF_RE.findall(option):
454
+ if any(_same_object(f"{kind}/{obj_id}", ref) for ref in refs_in_text):
455
+ return True
456
+ label = _plain(option)
457
+ return (
458
+ len(label) >= _MIN_NAMED_OPTION_CHARS
459
+ and re.search(rf"(?<!\w){re.escape(label)}(?!\w)", _plain(text)) is not None
460
+ )
461
+
462
+
463
+ def _same_question(question: dict, skipped: dict) -> bool:
464
+ """Whether ``question`` asks ``skipped`` again: the same text, or the same options that name something.
465
+
466
+ Options such as Yes and No fit any question, a confirmation too, so they count only with the same text.
467
+ """
468
+ if _plain(question.get("text") or "") == _plain(skipped["text"]):
469
+ return True
470
+ options = sorted(_options(question))
471
+ specific = any(_plain(option).strip(".!") not in _GENERIC_ANSWERS for option in options)
472
+ return specific and options == sorted(skipped["options"])
473
+
474
+
475
+ def after_answers_mismatches(
476
+ *,
477
+ unmatched_picks: list[str],
478
+ skipped: list[dict],
479
+ replies_after: list[tuple[str, ChatResult, bool]],
480
+ states_skipped_assumption: bool,
481
+ skipped_questions_not_reasked: bool,
482
+ expects_confirmation_after_answers: bool,
483
+ ) -> list[str]:
484
+ """How the turn after its structured answers differs from the fixture; empty when it matches.
485
+
486
+ ``replies_after`` holds each later reply with its kind (``output`` or a ``classify_reply`` kind) and
487
+ whether it answers a ``clarifyingAnswers`` message directly.
488
+
489
+ ``states_skipped_assumption`` stands in for "the agent says what it assumed": it passes when any reply
490
+ after the answers names an option of each skipped question. It cannot tell an assumption from a
491
+ mention, and it does not need the assumption in the final reply, since a reply that asks for
492
+ confirmation usually states it.
493
+ """
494
+ out = [f"no questionnaire offered the picked option {pick!r}" for pick in unmatched_picks]
495
+ if states_skipped_assumption:
496
+ texts = [reply.text_response or "" for _, reply, _ in replies_after]
497
+ out.extend(
498
+ f"no reply after the answers names an option of the skipped question {question['text']!r}"
499
+ for question in skipped
500
+ if not any(_names_option(text, option) for text in texts for option in question["options"])
501
+ )
502
+ if skipped_questions_not_reasked:
503
+ for question in skipped:
504
+ asked_again = any(
505
+ _same_question(later, question) for _, reply, _ in replies_after for later in _all_questions(reply)
506
+ )
507
+ if asked_again:
508
+ out.append(f"asked the skipped question {question['text']!r} again")
509
+ if expects_confirmation_after_answers and any(kind == "output" and follows for kind, _, follows in replies_after):
510
+ out.append("acted on the answers in the same reply, without waiting for confirmation")
511
+ return out
@@ -68,6 +68,8 @@ _RETRY_CHOICE_RE = re.compile(
68
68
  # word, so the apostrophes in "don't ... it's" do not mask the text between them.
69
69
  _FORMULA_SPAN_RE = re.compile(r"`+[^`]*`+|\{[^{}]*\}|\"[^\"\n]*\"|(?<!\w)'[^'\n]*'(?!\w)")
70
70
 
71
+ # Where one sentence ends and the next starts, for asks_in_text.
72
+ _SENTENCE_END_RE = re.compile(r"(?<=[.?!])\s+")
71
73
  # Checked after _CONFIRMATION_RE, so "please confirm" stays a confirmation.
72
74
  _REQUEST_RE = re.compile(
73
75
  r"\bplease\s+(?:select|choose|pick|specify|provide|tell\s+me|let\s+me\s+know|clarify|share)\b"
@@ -106,6 +108,20 @@ def classify_reply(chat_result: ChatResult, rendered_text: str) -> ReplyKind:
106
108
  return "no_action"
107
109
 
108
110
 
111
+ def asks_in_text(chat_result: ChatResult, rendered_text: str) -> bool:
112
+ """Whether a reply without the expected output asks the user something, read for the clarification checks.
113
+
114
+ ``classify_reply`` reads "Did you mean A or B? Please confirm." as a confirmation, which is right for
115
+ what the simulated user says back. Here it is a question: a sentence that ends in a question mark and
116
+ is not itself a confirmation phrase asks, whatever confirmation follows it.
117
+ """
118
+ kind = classify_reply(chat_result, rendered_text)
119
+ if kind != "confirmation" or chat_result.alert_proposals:
120
+ return kind == "question"
121
+ sentences = _SENTENCE_END_RE.split(rendered_text.strip())
122
+ return any(s.rstrip().endswith("?") and not _CONFIRMATION_RE.search(s) for s in sentences)
123
+
124
+
109
125
  @dataclass
110
126
  class TranscriptEntry:
111
127
  role: Literal["user", "assistant"]