gooddata-eval 1.76.1.dev4__tar.gz → 1.77.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 (143) hide show
  1. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/AGENTS.md +9 -1
  2. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/PKG-INFO +2 -2
  3. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/pyproject.toml +2 -2
  4. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/cli/agentic_runner.py +17 -6
  5. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/cli/main.py +22 -0
  6. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/__init__.py +22 -8
  7. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/_langfuse.py +201 -15
  8. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/_trace_linker.py +72 -27
  9. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/alert_skill.py +29 -25
  10. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/anomaly_detection.py +30 -26
  11. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/conversation.py +28 -27
  12. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/dashboard_skill.py +29 -25
  13. gooddata_eval-1.76.1.dev4/src/gooddata_eval/core/agentic/report_skill.py → gooddata_eval-1.77.1.dev1/src/gooddata_eval/core/agentic/document_skill.py +350 -169
  14. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/general_question.py +35 -27
  15. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/guardrail.py +35 -27
  16. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/kda_skill.py +29 -25
  17. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/metric_skill.py +29 -25
  18. gooddata_eval-1.77.1.dev1/src/gooddata_eval/core/agentic/report_skill.py +50 -0
  19. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/search_tool.py +28 -24
  20. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/visualization.py +29 -25
  21. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/what_if.py +30 -26
  22. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/chat/sse_client.py +47 -5
  23. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/config.py +2 -0
  24. gooddata_eval-1.77.1.dev1/src/gooddata_eval/core/langfuse/_env.py +78 -0
  25. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/langfuse/client.py +93 -20
  26. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/langfuse/experiment.py +23 -2
  27. gooddata_eval-1.77.1.dev1/src/gooddata_eval/core/langfuse/item_scope.py +162 -0
  28. gooddata_eval-1.77.1.dev1/src/gooddata_eval/core/langfuse/observations.py +194 -0
  29. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/langfuse/otlp.py +3 -3
  30. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/langfuse/sink.py +14 -10
  31. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/models.py +2 -0
  32. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/conftest.py +3 -0
  33. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_anomaly_detection.py +1 -1
  34. gooddata_eval-1.77.1.dev1/tests/test_agentic_document_skill.py +1324 -0
  35. gooddata_eval-1.77.1.dev1/tests/test_agentic_join_trace.py +481 -0
  36. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_observe_experiment.py +22 -0
  37. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_runner.py +98 -4
  38. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_what_if.py +1 -1
  39. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_chat_render.py +12 -10
  40. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_cli.py +90 -1
  41. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_langfuse_client.py +131 -5
  42. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_langfuse_e2e_fake_server.py +11 -7
  43. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_langfuse_env.py +75 -1
  44. gooddata_eval-1.77.1.dev1/tests/test_langfuse_item_scope.py +59 -0
  45. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_langfuse_observations.py +63 -3
  46. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_langfuse_otlp.py +2 -1
  47. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_langfuse_sink.py +28 -0
  48. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_sse_client.py +139 -0
  49. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_trace_linker.py +1 -1
  50. gooddata_eval-1.76.1.dev4/src/gooddata_eval/core/langfuse/_env.py +0 -39
  51. gooddata_eval-1.76.1.dev4/src/gooddata_eval/core/langfuse/observations.py +0 -125
  52. gooddata_eval-1.76.1.dev4/tests/test_agentic_report_skill.py +0 -843
  53. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/.gitignore +0 -0
  54. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/CLAUDE.md +0 -0
  55. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/LICENSE.txt +0 -0
  56. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/Makefile +0 -0
  57. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/README.md +0 -0
  58. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/scripts/verify_guardrail_refusal_criteria.py +0 -0
  59. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/__init__.py +0 -0
  60. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/_version.py +0 -0
  61. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/cli/__init__.py +0 -0
  62. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/__init__.py +0 -0
  63. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/_output.py +0 -0
  64. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/_catalog.py +0 -0
  65. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/_conversation_context.py +0 -0
  66. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/_failed_runs.py +0 -0
  67. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/agentic/_gate.py +0 -0
  68. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/chat/__init__.py +0 -0
  69. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/chat/render.py +0 -0
  70. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/connection.py +0 -0
  71. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/dataset/__init__.py +0 -0
  72. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/dataset/from_insights.py +0 -0
  73. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/dataset/langfuse_source.py +0 -0
  74. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/dataset/local.py +0 -0
  75. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/__init__.py +0 -0
  76. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/_deep_subset.py +0 -0
  77. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/_guardrail_criteria.py +0 -0
  78. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/_llm_judge.py +0 -0
  79. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/_maql.py +0 -0
  80. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/_text_utils.py +0 -0
  81. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/alert_skill.py +0 -0
  82. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/base.py +0 -0
  83. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/general_question.py +0 -0
  84. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/guardrail.py +0 -0
  85. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/metric_skill.py +0 -0
  86. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/search_tool.py +0 -0
  87. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/summary.py +0 -0
  88. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/evaluators/visualization.py +0 -0
  89. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/granularity.py +0 -0
  90. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/langfuse/__init__.py +0 -0
  91. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/reporting/__init__.py +0 -0
  92. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/reporting/console.py +0 -0
  93. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/reporting/html_report.py +0 -0
  94. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/reporting/json_report.py +0 -0
  95. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/reporting/report_template.html +0 -0
  96. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/runner.py +0 -0
  97. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/scoring.py +0 -0
  98. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/summary/__init__.py +0 -0
  99. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/summary/http_client.py +0 -0
  100. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/timing.py +0 -0
  101. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/src/gooddata_eval/core/workspace.py +0 -0
  102. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/__init__.py +0 -0
  103. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/_fake_langfuse.py +0 -0
  104. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/fixtures/sample_dataset/metric_skill_create.json +0 -0
  105. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/fixtures/sample_dataset/visualization_revenue.json +0 -0
  106. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/fixtures/sse_visualization_stream.txt +0 -0
  107. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_alert_skill.py +0 -0
  108. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_conversation.py +0 -0
  109. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_dashboard_skill.py +0 -0
  110. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_gate.py +0 -0
  111. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_general_question.py +0 -0
  112. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_guardrail.py +0 -0
  113. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_kda_skill.py +0 -0
  114. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_langfuse_trace.py +0 -0
  115. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_metric_skill.py +0 -0
  116. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_run_context.py +0 -0
  117. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_search_tool.py +0 -0
  118. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_agentic_visualization.py +0 -0
  119. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_alert_skill_evaluator.py +0 -0
  120. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_connection.py +0 -0
  121. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_deep_subset.py +0 -0
  122. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_fake_langfuse.py +0 -0
  123. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_from_insights.py +0 -0
  124. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_guardrail_criteria.py +0 -0
  125. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_html_report.py +0 -0
  126. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_langfuse_experiment.py +0 -0
  127. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_langfuse_source.py +0 -0
  128. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_llm_judge.py +0 -0
  129. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_local_loader.py +0 -0
  130. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_maql_normalize.py +0 -0
  131. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_metric_skill_evaluator.py +0 -0
  132. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_models.py +0 -0
  133. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_reporting.py +0 -0
  134. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_runner.py +0 -0
  135. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_scoring.py +0 -0
  136. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_search_tool_evaluator.py +0 -0
  137. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_summary_client.py +0 -0
  138. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_summary_evaluator.py +0 -0
  139. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_text_evaluators.py +0 -0
  140. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_timing.py +0 -0
  141. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_visualization_evaluator.py +0 -0
  142. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tests/test_workspace.py +0 -0
  143. {gooddata_eval-1.76.1.dev4 → gooddata_eval-1.77.1.dev1}/tox.ini +0 -0
@@ -29,7 +29,7 @@ experiment. The newest and most actively developed package in the repo.
29
29
  | `core/summary/` | HTTP client for the dedicated dashboard-summary endpoint — a single-shot chat backend, not reporting |
30
30
  | `core/dataset/` | dataset format, loading, and `from_insights.py` — dataset generation from a workspace's real insights |
31
31
  | `core/evaluators/` | single-shot evaluators and their registry |
32
- | `core/langfuse/` | the whole Langfuse v4 client: `_env` (base URL + credentials), `otlp` (OTLP/JSON encoding), `experiment` (root-span construction, score targets), `observations` (trace reads), `client` (httpx calls), `sink` (single-shot results as experiments) |
32
+ | `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) |
33
33
  | `core/reporting/` | console and JSON output rendering |
34
34
  | `core/scoring.py`, `core/runner.py` | scoring and orchestration |
35
35
  | `core/models.py` | `DatasetItem`, `ChatResult`, `ItemReport` and friends |
@@ -157,6 +157,7 @@ Run this before guessing a `--model` string.
157
157
  | `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` | `--langfuse`, `--langfuse-dataset` |
158
158
  | `GOODDATA_EVAL_CHAT_*` | SSE retry, backoff and timeout knobs |
159
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_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 |
160
161
  | `GD_EVAL_TIMERS` | same as `--timers` |
161
162
 
162
163
  A gitignored `.env` at the repo root is the normal place for these; load it with
@@ -190,6 +191,13 @@ ingestion has no pass/fail signal and inflates or misattributes per-item latency
190
191
  (`run_trace_link_inline` is the synchronous alternative). Do not "fix" a slow item by
191
192
  making trace scoring synchronous again.
192
193
 
194
+ **Turn `GOODDATA_EVAL_JOIN_GENAI_TRACE` off against a gen-ai that accepts caller baggage
195
+ but cannot join.** The trace and root ids are derived from the conversation id, and a conversation
196
+ counts as joined only when gen-ai reports that trace id on `response_started`. A gen-ai that
197
+ reads the baggage without that support tags its own traces with experiment ids under a root
198
+ gd-eval never exports. The same orphan is left when an item raises before its deferred
199
+ scoring runs: its turns sit in environment `sdk-experiment` without a root or scores.
200
+
193
201
  **A generated item's `expected_output` is copied, never invented — keep it that way.**
194
202
  `core/dataset/from_insights.py` converts each insight with the platform's own
195
203
  `declarative_visualization_to_aac()` (from `gooddata-code-convertors`, via `gooddata-sdk`),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gooddata-eval
3
- Version: 1.76.1.dev4
3
+ Version: 1.77.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.76.1.dev4
20
+ Requires-Dist: gooddata-sdk~=1.77.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
@@ -1,7 +1,7 @@
1
1
  # (C) 2026 GoodData Corporation
2
2
  [project]
3
3
  name = "gooddata-eval"
4
- version = "1.76.1.dev4"
4
+ version = "1.77.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.76.1.dev4",
14
+ "gooddata-sdk~=1.77.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",
@@ -15,11 +15,11 @@ from gooddata_eval.core.agentic.alert_skill import evaluate_agentic_alert_skill
15
15
  from gooddata_eval.core.agentic.anomaly_detection import evaluate_agentic_anomaly_detection
16
16
  from gooddata_eval.core.agentic.conversation import ConversationFixture, evaluate_agentic_conversation
17
17
  from gooddata_eval.core.agentic.dashboard_skill import evaluate_agentic_dashboard_skill
18
+ from gooddata_eval.core.agentic.document_skill import evaluate_agentic_document_skill
18
19
  from gooddata_eval.core.agentic.general_question import evaluate_agentic_general_question
19
20
  from gooddata_eval.core.agentic.guardrail import evaluate_agentic_guardrail
20
21
  from gooddata_eval.core.agentic.kda_skill import evaluate_agentic_kda_skill
21
22
  from gooddata_eval.core.agentic.metric_skill import evaluate_agentic_metric_skill
22
- from gooddata_eval.core.agentic.report_skill import evaluate_agentic_report_skill
23
23
  from gooddata_eval.core.agentic.search_tool import evaluate_agentic_search_tool
24
24
  from gooddata_eval.core.agentic.visualization import evaluate_agentic_visualization
25
25
  from gooddata_eval.core.agentic.what_if import evaluate_agentic_what_if
@@ -38,6 +38,7 @@ class _LfKw(TypedDict, total=False):
38
38
  model_version_override: str | None
39
39
  reasoning_effort: ReasoningEffort | None
40
40
  submit_trace_link: SubmitTraceLink
41
+ run_metadata_extra: dict[str, Any] | None
41
42
 
42
43
 
43
44
  AGENTIC_TEST_KINDS = frozenset(
@@ -47,7 +48,7 @@ AGENTIC_TEST_KINDS = frozenset(
47
48
  "agentic_metric_skill",
48
49
  "agentic_alert_skill",
49
50
  "agentic_dashboard_skill",
50
- "agentic_report_skill",
51
+ "agentic_document_skill",
51
52
  "agentic_search",
52
53
  "agentic_general_question",
53
54
  "agentic_guardrail",
@@ -98,8 +99,8 @@ PARALLEL_SAFE_TEST_KINDS = frozenset(
98
99
  #
99
100
  # agentic_dashboard_skill is absent by default rather than by evidence: gen-ai holds the draft and
100
101
  # any chart it authors in conversation state and writes neither until a user saves from the UI, so
101
- # it is a candidate for the allowlist once the dataset has runs behind it. agentic_report_skill is
102
- # absent for the same reason: the report draft stays in conversation state until a user saves it.
102
+ # it is a candidate for the allowlist once the dataset has runs behind it. agentic_document_skill is
103
+ # absent for the same reason: the document draft stays in conversation state until a user saves it.
103
104
  WORKSPACE_MUTATING_TEST_KINDS = frozenset(AGENTIC_TEST_KINDS) - PARALLEL_SAFE_TEST_KINDS
104
105
 
105
106
 
@@ -152,11 +153,13 @@ def _dispatch_agentic(
152
153
  agent_id: str | None = None,
153
154
  submit_trace_link: SubmitTraceLink = run_trace_link_inline,
154
155
  gate: EvalGate = DEFAULT_GATE,
156
+ run_metadata_extra: dict[str, Any] | None = None,
155
157
  ) -> AgenticEvalOutcome:
156
158
  """Call the appropriate evaluate_agentic_* function for the item's test_kind.
157
159
 
158
160
  `gate` reaches every kind except those in UNGATED_AGENTIC_TEST_KINDS, which have no K
159
161
  to gate over; the CLI refuses --gate power for a dataset containing one.
162
+ `run_metadata_extra` reaches every kind unchanged.
160
163
 
161
164
  Every evaluate_agentic_* function returns an AgenticEvalOutcome (reasoning_steps,
162
165
  conversation_id, response_id, detail) on success and attaches the same four attributes
@@ -172,6 +175,7 @@ def _dispatch_agentic(
172
175
  "model_version_override": model_version_override,
173
176
  "reasoning_effort": reasoning_effort,
174
177
  "submit_trace_link": submit_trace_link,
178
+ "run_metadata_extra": run_metadata_extra,
175
179
  }
176
180
 
177
181
  if kind in ("vis_agentic", "agentic_visualization"):
@@ -214,8 +218,8 @@ def _dispatch_agentic(
214
218
  user_context=item.user_context,
215
219
  **lf_kw,
216
220
  )
217
- elif kind == "agentic_report_skill":
218
- return evaluate_agentic_report_skill(
221
+ elif kind == "agentic_document_skill":
222
+ return evaluate_agentic_document_skill(
219
223
  host=host,
220
224
  token=token,
221
225
  workspace_id=workspace_id,
@@ -400,6 +404,7 @@ def run_agentic_items(
400
404
  agent_id: str | None = None,
401
405
  concurrency: int = 1,
402
406
  gate: EvalGate = DEFAULT_GATE,
407
+ tags: tuple[str, ...] = (),
403
408
  ) -> EvalReport:
404
409
  """Run agentic items through evaluate_agentic_* and return an EvalReport.
405
410
 
@@ -408,9 +413,14 @@ def run_agentic_items(
408
413
  from the parallel ones -- a metric being created and dropped mid-run would otherwise be
409
414
  visible to a catalog-reading item running alongside it.
410
415
 
416
+ ``tags`` are added to every Langfuse trace gd-eval writes for these items, after the
417
+ automatic ones. They reach Langfuse whenever trace linking runs, with or without
418
+ ``use_langfuse``.
419
+
411
420
  Results are collected in dataset order regardless of completion order.
412
421
  """
413
422
  langfuse = make_langfuse_client() if use_langfuse else None
423
+ run_metadata_extra: dict[str, Any] | None = {"tags": list(tags)} if tags else None
414
424
 
415
425
  report = EvalReport(model=model_version, gate=normalize_gate(gate))
416
426
  total = len(items)
@@ -452,6 +462,7 @@ def run_agentic_items(
452
462
  agent_id,
453
463
  submit_trace_link=linker.submit,
454
464
  gate=gate,
465
+ run_metadata_extra=run_metadata_extra,
455
466
  )
456
467
  if isinstance(outcome, AgenticEvalOutcome):
457
468
  reasoning_steps = outcome.reasoning_steps
@@ -183,6 +183,17 @@ def _build_parser() -> argparse.ArgumentParser:
183
183
  action="store_true",
184
184
  help="Log scores and traces to Langfuse (requires --langfuse-dataset and LANGFUSE_* env vars).",
185
185
  )
186
+ run.add_argument(
187
+ "--tag",
188
+ dest="tags",
189
+ action="append",
190
+ default=[],
191
+ type=_parse_tag,
192
+ metavar="TAG",
193
+ help="Custom tag for every Langfuse trace gd-eval writes, next to the automatic ones. "
194
+ "Repeat for several tags. Applied wherever traces are written: single-turn kinds only "
195
+ "with --langfuse, agentic kinds whenever LANGFUSE_* env vars are set.",
196
+ )
186
197
  run.add_argument(
187
198
  "--agent-id",
188
199
  dest="agent_id",
@@ -377,6 +388,14 @@ def _parse_model_arg(val: str) -> tuple[str | None, str]:
377
388
  return None, val
378
389
 
379
390
 
391
+ def _parse_tag(val: str) -> str:
392
+ """Strip one --tag value and reject a blank one, which Langfuse would store as an empty tag."""
393
+ tag = val.strip()
394
+ if not tag:
395
+ raise argparse.ArgumentTypeError("tag must not be blank")
396
+ return tag
397
+
398
+
380
399
  def _make_progress_callbacks(console: Console):
381
400
  """Build (on_item_start, on_run_done, on_item_done) callbacks that stream progress.
382
401
 
@@ -549,6 +568,7 @@ def _run(config: RunConfig) -> int:
549
568
  model_id=resolved.model_id,
550
569
  provider_type=resolved.provider_type,
551
570
  reasoning_effort=config.reasoning_effort,
571
+ tags=config.tags,
552
572
  )
553
573
 
554
574
  def on_langfuse_item_done(
@@ -574,6 +594,7 @@ def _run(config: RunConfig) -> int:
574
594
  model_version=resolved.model_id,
575
595
  reasoning_effort=config.reasoning_effort,
576
596
  use_langfuse=config.log_to_langfuse,
597
+ tags=config.tags,
577
598
  run_ts=run_ts,
578
599
  on_item_start=on_item_start,
579
600
  on_item_done=on_item_done,
@@ -728,6 +749,7 @@ def main(argv: list[str] | None = None) -> int:
728
749
  agent_id=args.agent_id or os.environ.get("GD_EVAL_AGENT_ID"),
729
750
  turn_timeout_s=args.turn_timeout,
730
751
  item_timeout_s=args.item_timeout,
752
+ tags=tuple(dict.fromkeys(args.tags)),
731
753
  )
732
754
  return _run(config)
733
755
  except (
@@ -24,6 +24,14 @@ from gooddata_eval.core.agentic.dashboard_skill import (
24
24
  evaluate_agentic_dashboard_skill,
25
25
  run_agentic_dashboard_skill,
26
26
  )
27
+ from gooddata_eval.core.agentic.document_skill import (
28
+ AgenticDocumentSummary,
29
+ DocumentEvaluation,
30
+ DocumentRunResult,
31
+ DocumentSkillAssertionError,
32
+ evaluate_agentic_document_skill,
33
+ run_agentic_document_skill,
34
+ )
27
35
  from gooddata_eval.core.agentic.general_question import (
28
36
  AgenticGeneralQuestionSummary,
29
37
  GeneralQuestionAssertionError,
@@ -53,14 +61,6 @@ from gooddata_eval.core.agentic.metric_skill import (
53
61
  evaluate_agentic_metric_skill,
54
62
  run_agentic_metric_skill,
55
63
  )
56
- from gooddata_eval.core.agentic.report_skill import (
57
- AgenticReportSummary,
58
- ReportEvaluation,
59
- ReportRunResult,
60
- ReportSkillAssertionError,
61
- evaluate_agentic_report_skill,
62
- run_agentic_report_skill,
63
- )
64
64
  from gooddata_eval.core.agentic.search_tool import (
65
65
  AgenticSearchSummary,
66
66
  SearchResult,
@@ -76,6 +76,14 @@ from gooddata_eval.core.agentic.visualization import (
76
76
  run_agentic_visualization,
77
77
  )
78
78
 
79
+ # Deprecated names of the document skill, kept for existing callers.
80
+ AgenticReportSummary = AgenticDocumentSummary
81
+ ReportEvaluation = DocumentEvaluation
82
+ ReportRunResult = DocumentRunResult
83
+ ReportSkillAssertionError = DocumentSkillAssertionError
84
+ evaluate_agentic_report_skill = evaluate_agentic_document_skill
85
+ run_agentic_report_skill = run_agentic_document_skill
86
+
79
87
  __all__ = [
80
88
  "AgenticAlertSummary",
81
89
  "AgenticDashboardSummary",
@@ -83,6 +91,7 @@ __all__ = [
83
91
  "AgenticGuardrailSummary",
84
92
  "AgenticKdaSummary",
85
93
  "AgenticMetricSummary",
94
+ "AgenticDocumentSummary",
86
95
  "AgenticReportSummary",
87
96
  "AgenticSearchSummary",
88
97
  "AgenticRunSummary",
@@ -95,6 +104,9 @@ __all__ = [
95
104
  "DashboardEvaluation",
96
105
  "DashboardRunResult",
97
106
  "DashboardSkillAssertionError",
107
+ "DocumentEvaluation",
108
+ "DocumentRunResult",
109
+ "DocumentSkillAssertionError",
98
110
  "GeneralQuestionAssertionError",
99
111
  "GeneralQuestionResult",
100
112
  "GuardrailAssertionError",
@@ -116,6 +128,7 @@ __all__ = [
116
128
  "evaluate_agentic_alert_skill",
117
129
  "evaluate_agentic_conversation",
118
130
  "evaluate_agentic_dashboard_skill",
131
+ "evaluate_agentic_document_skill",
119
132
  "evaluate_agentic_general_question",
120
133
  "evaluate_agentic_guardrail",
121
134
  "evaluate_agentic_kda_skill",
@@ -126,6 +139,7 @@ __all__ = [
126
139
  "run_agentic_alert_skill",
127
140
  "run_agentic_conversation",
128
141
  "run_agentic_dashboard_skill",
142
+ "run_agentic_document_skill",
129
143
  "run_agentic_general_question",
130
144
  "run_agentic_guardrail",
131
145
  "run_agentic_kda_skill",
@@ -3,6 +3,8 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import contextvars
7
+ import functools
6
8
  import logging
7
9
  import os
8
10
  import threading
@@ -10,7 +12,7 @@ import time
10
12
  from collections.abc import Iterator
11
13
  from contextlib import contextmanager
12
14
  from datetime import datetime, timedelta, timezone
13
- from typing import Any
15
+ from typing import TYPE_CHECKING, Any
14
16
 
15
17
  from gooddata_eval.core.agentic._trace_linker import link_cancel_event, linking_is_inline, warn_from_worker
16
18
  from gooddata_eval.core.config import ReasoningEffort, env_flag, normalize_reasoning_effort
@@ -21,12 +23,26 @@ from gooddata_eval.core.langfuse.experiment import (
21
23
  ExperimentRun,
22
24
  ScoreTarget,
23
25
  build_experiment_root_span,
26
+ trace_tags,
27
+ )
28
+ from gooddata_eval.core.langfuse.item_scope import (
29
+ SDK_EXPERIMENT_ENVIRONMENT,
30
+ ItemTraceScope,
31
+ JoinedRun,
32
+ joined_run,
33
+ joined_turns,
34
+ release_joined,
35
+ trace_ids_for,
24
36
  )
25
37
 
26
38
  # Part of this module's public surface: external callers import both names from here.
27
39
  from gooddata_eval.core.langfuse.observations import TraceSummary as _TraceObj # noqa: F401
40
+ from gooddata_eval.core.langfuse.observations import summarize_traces
28
41
  from gooddata_eval.core.langfuse.otlp import Span
29
42
 
43
+ if TYPE_CHECKING:
44
+ from gooddata_eval.core.agentic._trace_linker import RunIdentity
45
+
30
46
  _log = logging.getLogger(__name__)
31
47
 
32
48
 
@@ -115,16 +131,22 @@ def get_model_version(
115
131
  if override:
116
132
  return override
117
133
  try:
118
- from gooddata_sdk import GoodDataSdk # noqa: PLC0415
119
-
120
- sdk = GoodDataSdk.create(host, token)
121
- setting = sdk.catalog_workspace.get_workspace_setting(workspace_id, "activeLlmProvider")
122
- model = (setting.content or {}).get("defaultModelId") or None
123
- if model:
124
- return model
134
+ return _active_llm_model(host, token, workspace_id)
125
135
  except Exception:
126
- pass
127
- return ""
136
+ return ""
137
+
138
+
139
+ @functools.cache
140
+ def _active_llm_model(host: str, token: str, workspace_id: str) -> str:
141
+ """The workspace's default model id, read once per workspace; a failed read raises and is retried.
142
+
143
+ Every joined item resolves its run context before the first turn, on the item's own clock.
144
+ """
145
+ from gooddata_sdk import GoodDataSdk # noqa: PLC0415
146
+
147
+ sdk = GoodDataSdk.create(host, token)
148
+ setting = sdk.catalog_workspace.get_workspace_setting(workspace_id, "activeLlmProvider")
149
+ return (setting.content or {}).get("defaultModelId") or ""
128
150
 
129
151
 
130
152
  def _matches_session(trace: Any, session_id: str) -> bool:
@@ -200,6 +222,22 @@ def _wait_between_attempts(delay: float) -> bool:
200
222
  return not cancel.is_set()
201
223
 
202
224
 
225
+ def _fetch_joined_trace(langfuse: HttpxLangfuseClient, session_id: str, *_window: Any) -> list[Any]:
226
+ """The trace a joined conversation's gen-ai turns were put in, read by its id.
227
+
228
+ Empty until every joined turn's ``conversation.send_message`` row is ingested: a turn row
229
+ lands only when its turn ends, so an early read would cover the first turns alone.
230
+ ``joined_turns`` counts one per sent turn, but a turn the chat client retried after
231
+ gen-ai opened it leaves a row per attempt, so the count can be met while a later turn
232
+ is still in flight; the read then understates cost and latency.
233
+ """
234
+ trace_id = trace_ids_for(session_id)[0]
235
+ rows = langfuse.list_observations_for_trace(trace_id)
236
+ if sum(r.get("name") == "conversation.send_message" for r in rows) < joined_turns(session_id):
237
+ return []
238
+ return [t for t in summarize_traces(rows) if t.id == trace_id]
239
+
240
+
203
241
  def find_traces_per_conversation(
204
242
  langfuse: Any,
205
243
  conversation_ids: list[str],
@@ -245,12 +283,14 @@ def find_traces_per_conversation(
245
283
  break
246
284
  delay = _INITIAL_DELAY
247
285
  found: list[Any] = []
286
+ joined = joined_run(cid) is not None and isinstance(langfuse, HttpxLangfuseClient)
287
+ fetch = _fetch_joined_trace if joined else _fetch_traces_for_session
248
288
  for _attempt in range(_MAX_ATTEMPTS):
249
289
  # Attempt first, sleep only between attempts: a trace that is already ingested
250
290
  # when we look must cost nothing, which is the common case once linking is
251
291
  # batched to the end of the run.
252
292
  try:
253
- found = _fetch_traces_for_session(langfuse, cid, window_start, window_end, pad)
293
+ found = fetch(langfuse, cid, window_start, window_end, pad)
254
294
  except Exception as exc:
255
295
  _log.debug("Langfuse trace fetch failed for %s: %s", cid, exc)
256
296
  if found or time.monotonic() + delay > stop_at:
@@ -298,11 +338,19 @@ def _experiment_root_span(
298
338
  conversation_id: str | None,
299
339
  item_input: Any,
300
340
  output: Any,
341
+ joined_ids: tuple[str, str] | None = None,
301
342
  ) -> Span:
302
- """gd-eval's own root span for one (dataset item, run) -- the whole experiment item."""
343
+ """gd-eval's own root span for one (dataset item, run) -- the whole experiment item.
344
+
345
+ ``joined_ids`` is the ``(trace_id, span_id)`` gen-ai's turns were put under; the span then
346
+ takes those ids and the environment the langfuse SDK forced on those turns.
347
+ """
303
348
  start, end = _span_window(trace, window)
304
349
  session_id = conversation_id or getattr(trace, "session_id", None)
305
- tags = tuple(tag for tag in ("gd-eval", run_metadata.get("testing_framework")) if tag)
350
+ custom_tags = run_metadata.get("tags") or ()
351
+ if isinstance(custom_tags, str):
352
+ custom_tags = (custom_tags,)
353
+ tags = trace_tags("gd-eval", run_metadata.get("testing_framework"), *custom_tags)
306
354
  # One predicate for both the name and the input, so a falsy question cannot name the span
307
355
  # after the item while the input still says "question".
308
356
  has_input = item_input is not None
@@ -328,7 +376,9 @@ def _experiment_root_span(
328
376
  "conversation_id": session_id,
329
377
  },
330
378
  trace_metadata={"run_name": run_name},
331
- environment=os.environ.get("LANGFUSE_TRACING_ENVIRONMENT"),
379
+ environment=SDK_EXPERIMENT_ENVIRONMENT if joined_ids else os.environ.get("LANGFUSE_TRACING_ENVIRONMENT"),
380
+ trace_id=joined_ids[0] if joined_ids else None,
381
+ span_id=joined_ids[1] if joined_ids else None,
332
382
  )
333
383
 
334
384
 
@@ -377,6 +427,22 @@ def observe(
377
427
  yield None
378
428
  return
379
429
 
430
+ joined = joined_run(conversation_id)
431
+ if joined is not None and conversation_id:
432
+ yield _export_joined_root(
433
+ langfuse,
434
+ joined,
435
+ conversation_id,
436
+ dataset_item_id,
437
+ run_name,
438
+ run_metadata or {},
439
+ trace=trace,
440
+ window=window,
441
+ item_input=item_input,
442
+ output=output,
443
+ )
444
+ return
445
+
380
446
  try:
381
447
  dataset_id = langfuse.dataset_id_for_item(dataset_item_id)
382
448
  except Exception as exc:
@@ -431,6 +497,85 @@ def observe(
431
497
  yield ScoreTarget(trace_id, span.trace_id, span.span_id)
432
498
 
433
499
 
500
+ def _export_joined_root(
501
+ langfuse: HttpxLangfuseClient,
502
+ joined: JoinedRun,
503
+ conversation_id: str,
504
+ dataset_item_id: str,
505
+ run_name: str,
506
+ run_metadata: dict[str, Any],
507
+ *,
508
+ trace: Any,
509
+ window: tuple[datetime, datetime] | None,
510
+ item_input: Any,
511
+ output: Any,
512
+ ) -> ScoreTarget:
513
+ """Export the root of a joined conversation's trace and return its one score destination.
514
+
515
+ The root takes the trace and span ids gen-ai's turns were sent, and the experiment run
516
+ they were told they belong to, so Langfuse assembles one experiment item from both.
517
+ ``trace`` is that trace as read before scoring; its window spans every gen-ai row.
518
+ """
519
+ trace_id, span_id = trace_ids_for(conversation_id)
520
+ if joined.run_name != run_name:
521
+ _log.warning(
522
+ "Conversation %s joined run %s, not %s; its root follows the join.",
523
+ conversation_id,
524
+ joined.run_name,
525
+ run_name,
526
+ )
527
+ span = _experiment_root_span(
528
+ trace_id,
529
+ dataset_item_id,
530
+ joined.dataset_id,
531
+ joined.run_name,
532
+ run_metadata,
533
+ trace=trace,
534
+ window=window,
535
+ conversation_id=conversation_id,
536
+ item_input=item_input,
537
+ output=output,
538
+ joined_ids=(trace_id, span_id),
539
+ )
540
+ try:
541
+ langfuse.export_spans([span])
542
+ except Exception as exc:
543
+ _log.warning("Failed to export the experiment span for run %s: %s", joined.run_name, exc)
544
+ warn_from_worker(
545
+ f"[langfuse] WARNING: failed to export experiment span run={joined.run_name} item={dataset_item_id}: {exc}"
546
+ )
547
+ return ScoreTarget(trace_id)
548
+ finally:
549
+ release_joined(conversation_id)
550
+ return ScoreTarget(experiment_trace_id=trace_id, experiment_span_id=span_id)
551
+
552
+
553
+ # Scores held by an open ``collect_scores`` block on this thread, sent together when it ends.
554
+ _PENDING_SCORES: contextvars.ContextVar[list[dict[str, Any]] | None] = contextvars.ContextVar(
555
+ "gd_eval_pending_scores", default=None
556
+ )
557
+
558
+
559
+ @contextmanager
560
+ def collect_scores(langfuse: HttpxLangfuseClient) -> Iterator[None]:
561
+ """Hold every ``score_safe`` write made in the block and send them as one batch at its end.
562
+
563
+ The batch is sent even when the drain was cancelled meanwhile, without retrying, so the
564
+ scores made before the interrupt are kept.
565
+ """
566
+ pending: list[dict[str, Any]] = []
567
+ token = _PENDING_SCORES.set(pending)
568
+ try:
569
+ yield
570
+ finally:
571
+ _PENDING_SCORES.reset(token)
572
+ if pending:
573
+ try:
574
+ langfuse.create_scores(pending, wait=_wait_between_attempts)
575
+ except Exception as exc:
576
+ _log.warning("Failed to log %d scores: %s", len(pending), exc)
577
+
578
+
434
579
  def score_safe(langfuse: Any, trace_id: Any, **kwargs: Any) -> None:
435
580
  """Create one Langfuse score per destination the target names, ignoring errors.
436
581
 
@@ -444,7 +589,11 @@ def score_safe(langfuse: Any, trace_id: Any, **kwargs: Any) -> None:
444
589
  if _drain_is_cancelled():
445
590
  return
446
591
  targets = trace_id.destinations() if isinstance(trace_id, ScoreTarget) else [(str(trace_id), None)]
592
+ pending = _PENDING_SCORES.get()
447
593
  for target_id, observation_id in targets:
594
+ if pending is not None:
595
+ pending.append({"trace_id": target_id, "observation_id": observation_id, **kwargs})
596
+ continue
448
597
  extra = {"observation_id": observation_id} if observation_id else {}
449
598
  try:
450
599
  langfuse.create_score(trace_id=target_id, **kwargs, **extra)
@@ -493,6 +642,42 @@ def log_quality_and_value_scores(
493
642
  )
494
643
 
495
644
 
645
+ def run_context_for(identity: RunIdentity) -> tuple[str, dict[str, Any]]:
646
+ """``build_run_context`` for an item's ``RunIdentity``."""
647
+ return build_run_context(
648
+ identity.host,
649
+ identity.token,
650
+ identity.workspace_id,
651
+ identity.dataset_name,
652
+ identity.run_timestamp,
653
+ identity.model_version_override,
654
+ identity.run_metadata_extra,
655
+ identity.reasoning_effort,
656
+ )
657
+
658
+
659
+ def resolve_item_scope(
660
+ langfuse: Any, identity: RunIdentity, dataset_item_id: str, *, suffix_runs: bool
661
+ ) -> ItemTraceScope | None:
662
+ """The item's experiment scope, or None when ``observe`` could not export its root.
663
+
664
+ That is: a client other than ``HttpxLangfuseClient``, linking switched off, or a dataset
665
+ item Langfuse does not know. Sending the join keys then would leave gen-ai's turns under
666
+ a root that never arrives.
667
+ """
668
+ if not isinstance(langfuse, HttpxLangfuseClient) or env_flag(SKIP_ENV_VAR):
669
+ return None
670
+ try:
671
+ dataset_id = langfuse.dataset_id_for_item(dataset_item_id)
672
+ except Exception as exc:
673
+ _log.warning("Failed to resolve dataset item %s; its chat turns will not join: %s", dataset_item_id, exc)
674
+ return None
675
+ if dataset_id is None:
676
+ return None
677
+ base_name, run_metadata = run_context_for(identity)
678
+ return ItemTraceScope(base_name, suffix_runs, dataset_id, dataset_item_id, run_metadata)
679
+
680
+
496
681
  def build_run_context(
497
682
  host: str,
498
683
  token: str,
@@ -514,7 +699,8 @@ def build_run_context(
514
699
  model_version_override: Explicit model-version tag; when set it wins over the
515
700
  workspace lookup.
516
701
  run_metadata_extra: Optional extra key/values added to the dataset-run metadata
517
- (e.g. a testing-framework tag or a CI run id for scoping). Default None keeps
702
+ (e.g. a testing-framework tag or a CI run id for scoping). A `tags` list is
703
+ also added to the trace tags, after the automatic ones. Default None keeps
518
704
  behavior unchanged. The SDK-derived model_version is applied last and cannot
519
705
  be overwritten by this dict.
520
706
  reasoning_effort: Effort the run requested, stamped into both the run name and