gooddata-eval 1.76.1.dev4__py3-none-any.whl → 1.77.1.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gooddata_eval/cli/agentic_runner.py +17 -6
- gooddata_eval/cli/main.py +22 -0
- gooddata_eval/core/agentic/__init__.py +22 -8
- gooddata_eval/core/agentic/_langfuse.py +201 -15
- gooddata_eval/core/agentic/_trace_linker.py +72 -27
- gooddata_eval/core/agentic/alert_skill.py +29 -25
- gooddata_eval/core/agentic/anomaly_detection.py +30 -26
- gooddata_eval/core/agentic/conversation.py +28 -27
- gooddata_eval/core/agentic/dashboard_skill.py +29 -25
- gooddata_eval/core/agentic/document_skill.py +1046 -0
- gooddata_eval/core/agentic/general_question.py +35 -27
- gooddata_eval/core/agentic/guardrail.py +35 -27
- gooddata_eval/core/agentic/kda_skill.py +29 -25
- gooddata_eval/core/agentic/metric_skill.py +29 -25
- gooddata_eval/core/agentic/report_skill.py +41 -856
- gooddata_eval/core/agentic/search_tool.py +28 -24
- gooddata_eval/core/agentic/visualization.py +29 -25
- gooddata_eval/core/agentic/what_if.py +30 -26
- gooddata_eval/core/chat/sse_client.py +47 -5
- gooddata_eval/core/config.py +2 -0
- gooddata_eval/core/langfuse/_env.py +40 -1
- gooddata_eval/core/langfuse/client.py +93 -20
- gooddata_eval/core/langfuse/experiment.py +23 -2
- gooddata_eval/core/langfuse/item_scope.py +162 -0
- gooddata_eval/core/langfuse/observations.py +86 -17
- gooddata_eval/core/langfuse/otlp.py +3 -3
- gooddata_eval/core/langfuse/sink.py +14 -10
- gooddata_eval/core/models.py +2 -0
- {gooddata_eval-1.76.1.dev4.dist-info → gooddata_eval-1.77.1.dev1.dist-info}/METADATA +2 -2
- {gooddata_eval-1.76.1.dev4.dist-info → gooddata_eval-1.77.1.dev1.dist-info}/RECORD +33 -31
- {gooddata_eval-1.76.1.dev4.dist-info → gooddata_eval-1.77.1.dev1.dist-info}/WHEEL +0 -0
- {gooddata_eval-1.76.1.dev4.dist-info → gooddata_eval-1.77.1.dev1.dist-info}/entry_points.txt +0 -0
- {gooddata_eval-1.76.1.dev4.dist-info → gooddata_eval-1.77.1.dev1.dist-info}/licenses/LICENSE.txt +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
|
-
"
|
|
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.
|
|
102
|
-
# absent for the same reason: the
|
|
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 == "
|
|
218
|
-
return
|
|
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
|
gooddata_eval/cli/main.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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 =
|
|
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
|
-
|
|
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).
|
|
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
|