trodo-python 2.14.0__tar.gz → 2.16.0__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.
- {trodo_python-2.14.0 → trodo_python-2.16.0}/PKG-INFO +1 -1
- {trodo_python-2.14.0 → trodo_python-2.16.0}/pyproject.toml +1 -1
- trodo_python-2.16.0/tests/test_datasets.py +102 -0
- trodo_python-2.16.0/tests/test_experiments.py +120 -0
- trodo_python-2.16.0/tests/test_prompt_trace.py +107 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/__init__.py +62 -1
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/api/endpoints.py +3 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/api/http_client.py +10 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/client.py +18 -0
- trodo_python-2.16.0/trodo/managers/dataset_manager.py +74 -0
- trodo_python-2.16.0/trodo/managers/experiment_manager.py +105 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/managers/prompt_manager.py +14 -5
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/otel/auto_instrument.py +9 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/otel/context.py +4 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/otel/processor.py +3 -0
- trodo_python-2.16.0/trodo/otel/prompt_trace.py +94 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/otel/wrap_agent.py +17 -2
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/prompts/types.py +23 -1
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo_python.egg-info/PKG-INFO +1 -1
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo_python.egg-info/SOURCES.txt +6 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/README.md +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/setup.cfg +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_error_enrichment.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_prompts.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/prompts/__init__.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/prompts/compile.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/prompts/template.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/types.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo/user_context.py +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.14.0 → trodo_python-2.16.0}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Dataset ingest — DatasetManager.append.
|
|
2
|
+
|
|
3
|
+
Uses a fake HTTP client so no network is touched; asserts on the exact
|
|
4
|
+
ref + body sent to the backend and the response returned to the caller.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from trodo.managers.dataset_manager import DatasetManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeHttp:
|
|
13
|
+
"""Records the last append call; mirrors the prompt-test fakeApi."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, response=None):
|
|
16
|
+
self._response = response or {
|
|
17
|
+
"dataset_id": "ds_1",
|
|
18
|
+
"dataset_name": "support-eval",
|
|
19
|
+
"appended": 1,
|
|
20
|
+
"item_count": 1,
|
|
21
|
+
}
|
|
22
|
+
self.calls = 0
|
|
23
|
+
self.last_ref = None
|
|
24
|
+
self.last_body = None
|
|
25
|
+
|
|
26
|
+
def append_dataset_items(self, ref, body):
|
|
27
|
+
self.calls += 1
|
|
28
|
+
self.last_ref = ref
|
|
29
|
+
self.last_body = body
|
|
30
|
+
return self._response
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_append_posts_ref_and_body_and_returns_response():
|
|
34
|
+
http = FakeHttp()
|
|
35
|
+
mgr = DatasetManager(http)
|
|
36
|
+
res = mgr.append(
|
|
37
|
+
"support-eval",
|
|
38
|
+
[{"input": {"q": "where is my order"}, "expected_output": "tracking link"}],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
assert http.calls == 1
|
|
42
|
+
assert http.last_ref == "support-eval"
|
|
43
|
+
assert http.last_body == {
|
|
44
|
+
"items": [
|
|
45
|
+
{"input": {"q": "where is my order"}, "expected_output": "tracking link"}
|
|
46
|
+
],
|
|
47
|
+
"create": True,
|
|
48
|
+
}
|
|
49
|
+
assert res["dataset_id"] == "ds_1"
|
|
50
|
+
assert res["item_count"] == 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_append_wraps_a_single_dict_into_a_list():
|
|
54
|
+
http = FakeHttp()
|
|
55
|
+
DatasetManager(http).append("ds", {"input": {"x": 1}})
|
|
56
|
+
assert http.last_body["items"] == [{"input": {"x": 1}}]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_append_drops_none_optional_fields():
|
|
60
|
+
http = FakeHttp()
|
|
61
|
+
DatasetManager(http).append(
|
|
62
|
+
"ds",
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
"input": {"x": 1},
|
|
66
|
+
"expected_output": None,
|
|
67
|
+
"metadata": {"k": "v"},
|
|
68
|
+
"source_trace_ref": None,
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
# None fields dropped; present ones kept.
|
|
73
|
+
assert http.last_body["items"] == [{"input": {"x": 1}, "metadata": {"k": "v"}}]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_append_passes_create_false_through():
|
|
77
|
+
http = FakeHttp()
|
|
78
|
+
DatasetManager(http).append("ds", [{"input": {"x": 1}}], create=False)
|
|
79
|
+
assert http.last_body["create"] is False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_append_keeps_source_trace_ref():
|
|
83
|
+
http = FakeHttp()
|
|
84
|
+
DatasetManager(http).append(
|
|
85
|
+
"ds", [{"input": {"x": 1}, "source_trace_ref": {"run_id": "r1"}}]
|
|
86
|
+
)
|
|
87
|
+
assert http.last_body["items"][0]["source_trace_ref"] == {"run_id": "r1"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_append_requires_a_ref():
|
|
91
|
+
with pytest.raises(ValueError, match="requires a dataset ref"):
|
|
92
|
+
DatasetManager(FakeHttp()).append("", [{"input": {"x": 1}}])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_append_requires_items():
|
|
96
|
+
with pytest.raises(ValueError, match="requires items"):
|
|
97
|
+
DatasetManager(FakeHttp()).append("ds", [])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_append_requires_input_on_each_item():
|
|
101
|
+
with pytest.raises(ValueError, match="requires an 'input'"):
|
|
102
|
+
DatasetManager(FakeHttp()).append("ds", [{"expected_output": "x"}])
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Experiment ingest — ExperimentManager.ingest.
|
|
2
|
+
|
|
3
|
+
Uses a fake HTTP client so no network is touched; asserts on the exact body
|
|
4
|
+
posted to experiments/ingest and the experiment dict returned to the caller.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from trodo.managers.experiment_manager import ExperimentManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeHttp:
|
|
13
|
+
"""Records the last ingest call; mirrors the prompt-test fakeApi."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, response=None):
|
|
16
|
+
self._response = response if response is not None else {
|
|
17
|
+
"experiment": {"id": "exp_1", "status": "grading"}
|
|
18
|
+
}
|
|
19
|
+
self.calls = 0
|
|
20
|
+
self.last_body = None
|
|
21
|
+
|
|
22
|
+
def ingest_experiment(self, body):
|
|
23
|
+
self.calls += 1
|
|
24
|
+
self.last_body = body
|
|
25
|
+
return self._response
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_ingest_posts_body_and_returns_experiment():
|
|
29
|
+
http = FakeHttp()
|
|
30
|
+
mgr = ExperimentManager(http)
|
|
31
|
+
exp = mgr.ingest(
|
|
32
|
+
"support-eval",
|
|
33
|
+
[{"item_position": 0, "output": "here is your tracking link"}],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
assert http.calls == 1
|
|
37
|
+
assert http.last_body == {
|
|
38
|
+
"dataset": "support-eval",
|
|
39
|
+
"outputs": [{"item_position": 0, "output": "here is your tracking link"}],
|
|
40
|
+
}
|
|
41
|
+
assert exp == {"id": "exp_1", "status": "grading"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_ingest_includes_all_optional_fields_when_set():
|
|
45
|
+
http = FakeHttp()
|
|
46
|
+
ExperimentManager(http).ingest(
|
|
47
|
+
"ds",
|
|
48
|
+
[{"item_position": 1, "output": "o"}],
|
|
49
|
+
name="run-1",
|
|
50
|
+
task_name="qa",
|
|
51
|
+
judge={"credential_id": "cred_1", "provider": "openai", "model": "gpt-4o"},
|
|
52
|
+
evaluator_ids=["eval_a", "eval_b"],
|
|
53
|
+
dataset_version_no=3,
|
|
54
|
+
)
|
|
55
|
+
assert http.last_body == {
|
|
56
|
+
"dataset": "ds",
|
|
57
|
+
"outputs": [{"item_position": 1, "output": "o"}],
|
|
58
|
+
"name": "run-1",
|
|
59
|
+
"task_name": "qa",
|
|
60
|
+
"judge": {"credential_id": "cred_1", "provider": "openai", "model": "gpt-4o"},
|
|
61
|
+
"evaluator_ids": ["eval_a", "eval_b"],
|
|
62
|
+
"dataset_version_no": 3,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_ingest_omits_unset_optional_fields():
|
|
67
|
+
http = FakeHttp()
|
|
68
|
+
ExperimentManager(http).ingest("ds", [{"item_position": 0, "output": "o"}])
|
|
69
|
+
assert set(http.last_body.keys()) == {"dataset", "outputs"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_ingest_drops_none_per_output_fields():
|
|
73
|
+
http = FakeHttp()
|
|
74
|
+
ExperimentManager(http).ingest(
|
|
75
|
+
"ds",
|
|
76
|
+
[
|
|
77
|
+
{
|
|
78
|
+
"item_position": 0,
|
|
79
|
+
"output": "o",
|
|
80
|
+
"expected_output": "gold",
|
|
81
|
+
"query": None,
|
|
82
|
+
"context": None,
|
|
83
|
+
"cost_usd": 0.0021,
|
|
84
|
+
"latency_ms": None,
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
assert http.last_body["outputs"] == [
|
|
89
|
+
{"item_position": 0, "output": "o", "expected_output": "gold", "cost_usd": 0.0021}
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_ingest_keeps_item_position_zero():
|
|
94
|
+
# item_position 0 is falsy but valid — must not be dropped.
|
|
95
|
+
http = FakeHttp()
|
|
96
|
+
ExperimentManager(http).ingest("ds", [{"item_position": 0, "output": "o"}])
|
|
97
|
+
assert http.last_body["outputs"][0]["item_position"] == 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_ingest_returns_raw_response_when_no_experiment_key():
|
|
101
|
+
http = FakeHttp(response={"error": "bad"})
|
|
102
|
+
out = ExperimentManager(http).ingest("ds", [{"item_position": 0, "output": "o"}])
|
|
103
|
+
assert out == {"error": "bad"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_ingest_requires_a_dataset():
|
|
107
|
+
with pytest.raises(ValueError, match="requires a dataset"):
|
|
108
|
+
ExperimentManager(FakeHttp()).ingest("", [{"item_position": 0, "output": "o"}])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_ingest_requires_outputs():
|
|
112
|
+
with pytest.raises(ValueError, match="requires outputs"):
|
|
113
|
+
ExperimentManager(FakeHttp()).ingest("ds", [])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_ingest_requires_item_position_and_output():
|
|
117
|
+
with pytest.raises(ValueError, match="item_position"):
|
|
118
|
+
ExperimentManager(FakeHttp()).ingest("ds", [{"output": "o"}])
|
|
119
|
+
with pytest.raises(ValueError, match="requires an 'output'"):
|
|
120
|
+
ExperimentManager(FakeHttp()).ingest("ds", [{"item_position": 0}])
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Prompt -> span traceability (mirrors the Node promptTrace.test.ts).
|
|
2
|
+
|
|
3
|
+
Compiling a managed prompt inside a wrap_agent/span scope must stamp the exact
|
|
4
|
+
version (by immutable hash) onto the emitted span, and aggregate every prompt
|
|
5
|
+
used onto the run. Outside a run, it's a silent no-op.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from trodo.managers.prompt_manager import PromptManager
|
|
10
|
+
from trodo.otel.wrap_agent import span, wrap_agent
|
|
11
|
+
|
|
12
|
+
PROMPT_WIRE = {
|
|
13
|
+
"name": "refund-agent",
|
|
14
|
+
"version": 3,
|
|
15
|
+
"version_hash": "a3f9c2e8d1b04756",
|
|
16
|
+
"content_hash": "ccccontent1234",
|
|
17
|
+
"parent_hash": "pppparent5678",
|
|
18
|
+
"labels": ["production"],
|
|
19
|
+
"tags": [],
|
|
20
|
+
"messages": [{"role": "system", "content": [{"type": "text", "text": "Help {{q}}"}]}],
|
|
21
|
+
"model": {"provider": "openai", "model": "gpt-4o"},
|
|
22
|
+
"tools": [],
|
|
23
|
+
"response_format": None,
|
|
24
|
+
"variables": [{"name": "q", "type": "string"}],
|
|
25
|
+
"updated_at": "2026-07-01T00:00:00Z",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PromptHttp:
|
|
30
|
+
def __init__(self, prompt=None, fail=False):
|
|
31
|
+
self._prompt = prompt
|
|
32
|
+
self.fail = fail
|
|
33
|
+
|
|
34
|
+
def get_prompt(self, name, label=None, version=None):
|
|
35
|
+
if self.fail:
|
|
36
|
+
raise RuntimeError("unreachable")
|
|
37
|
+
return {"prompt": dict(self._prompt)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_stamps_version_hash_on_span_and_aggregates_on_run(processor, http):
|
|
41
|
+
mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
|
|
42
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
43
|
+
p = mgr.get("refund-agent", cache_ttl_seconds=0)
|
|
44
|
+
with span("llm", kind="llm"):
|
|
45
|
+
p.compile(q="my order")
|
|
46
|
+
|
|
47
|
+
ingest = http.run_ingest[0]
|
|
48
|
+
spans = ingest["spans"]
|
|
49
|
+
llm = next(s for s in spans if s["name"] == "llm")
|
|
50
|
+
attrs = llm["attributes"]
|
|
51
|
+
assert attrs["trodo.prompt.name"] == "refund-agent"
|
|
52
|
+
assert attrs["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
|
|
53
|
+
assert attrs["trodo.prompt.label"] == "production" # default selector
|
|
54
|
+
assert attrs["trodo.prompt.content_hash"] == "ccccontent1234"
|
|
55
|
+
|
|
56
|
+
used = ingest["run"]["attributes"]["trodo.prompts"]
|
|
57
|
+
assert len(used) == 1
|
|
58
|
+
assert used[0]["version_hash"] == "a3f9c2e8d1b04756"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_records_label_and_omits_it_for_version_pin(processor, http):
|
|
62
|
+
mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
|
|
63
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
64
|
+
staged = mgr.get("refund-agent", label="staging", cache_ttl_seconds=0)
|
|
65
|
+
with span("s1"):
|
|
66
|
+
staged.compile(q="x")
|
|
67
|
+
pinned = mgr.get("refund-agent", version="a3f9c2", cache_ttl_seconds=0)
|
|
68
|
+
with span("s2"):
|
|
69
|
+
pinned.compile(q="y")
|
|
70
|
+
|
|
71
|
+
spans = http.run_ingest[0]["spans"]
|
|
72
|
+
s1 = next(s for s in spans if s["name"] == "s1")["attributes"]
|
|
73
|
+
s2 = next(s for s in spans if s["name"] == "s2")["attributes"]
|
|
74
|
+
assert s1["trodo.prompt.label"] == "staging"
|
|
75
|
+
assert "trodo.prompt.label" not in s2 # pinned by hash -> no label
|
|
76
|
+
assert s2["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_no_op_outside_a_run(processor, http):
|
|
80
|
+
mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
|
|
81
|
+
p = mgr.get("refund-agent", cache_ttl_seconds=0)
|
|
82
|
+
# Must not raise despite there being no active run context.
|
|
83
|
+
p.compile(q="z")
|
|
84
|
+
assert len(http.run_ingest) == 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_fallback_prompt_is_not_traced(processor, http):
|
|
88
|
+
mgr = PromptManager(PromptHttp(fail=True))
|
|
89
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
90
|
+
p = mgr.get(
|
|
91
|
+
"refund-agent",
|
|
92
|
+
cache_ttl_seconds=0,
|
|
93
|
+
max_retries=0,
|
|
94
|
+
fallback={
|
|
95
|
+
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi {{q}}"}]}],
|
|
96
|
+
"variables": [{"name": "q"}],
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
assert p.is_fallback is True
|
|
100
|
+
with span("llm"):
|
|
101
|
+
p.compile(q="x")
|
|
102
|
+
|
|
103
|
+
spans = http.run_ingest[0]["spans"]
|
|
104
|
+
attrs = next(s for s in spans if s["name"] == "llm").get("attributes") or {}
|
|
105
|
+
assert "trodo.prompt.name" not in attrs
|
|
106
|
+
# to_dict() drops None, so an untraced run has no 'attributes' key at all.
|
|
107
|
+
assert "attributes" not in http.run_ingest[0]["run"]
|
|
@@ -41,7 +41,7 @@ Downstream microservice (join the caller's run instead of making a new one):
|
|
|
41
41
|
|
|
42
42
|
from __future__ import annotations
|
|
43
43
|
|
|
44
|
-
__version__ = "2.
|
|
44
|
+
__version__ = "2.16.0"
|
|
45
45
|
|
|
46
46
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
47
47
|
|
|
@@ -119,6 +119,9 @@ __all__ = [
|
|
|
119
119
|
"TemplateError",
|
|
120
120
|
"ManagedPrompt",
|
|
121
121
|
"PromptSummary",
|
|
122
|
+
# Datasets & experiments ingest
|
|
123
|
+
"append_dataset",
|
|
124
|
+
"ingest_experiment",
|
|
122
125
|
]
|
|
123
126
|
|
|
124
127
|
# ============================================================================
|
|
@@ -344,6 +347,64 @@ def render_template(
|
|
|
344
347
|
return _render_template(template, merged, strict=strict)
|
|
345
348
|
|
|
346
349
|
|
|
350
|
+
# ----------------------------------------------------------------------------
|
|
351
|
+
# Datasets & experiments ingest
|
|
352
|
+
# ----------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def append_dataset(
|
|
355
|
+
ref: str,
|
|
356
|
+
items: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
357
|
+
create: bool = True,
|
|
358
|
+
) -> Dict[str, Any]:
|
|
359
|
+
"""Append items to a dataset by UUID or name.
|
|
360
|
+
|
|
361
|
+
*items* is a list of dicts shaped ``{"input": {<var>: value, ...},
|
|
362
|
+
"expected_output"?: str, "metadata"?: dict, "source_trace_ref"?: dict}``;
|
|
363
|
+
a single dict is accepted and wrapped. When *ref* is a name that doesn't
|
|
364
|
+
exist, ``create=True`` (default) auto-creates the dataset::
|
|
365
|
+
|
|
366
|
+
trodo.append_dataset("support-eval", [
|
|
367
|
+
{"input": {"q": "where is my order"}, "expected_output": "tracking link"},
|
|
368
|
+
])
|
|
369
|
+
|
|
370
|
+
Returns ``{"dataset_id", "dataset_name", "appended", "item_count"}``.
|
|
371
|
+
"""
|
|
372
|
+
return _get_client().datasets.append(ref, items, create=create)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def ingest_experiment(
|
|
376
|
+
dataset: str,
|
|
377
|
+
outputs: List[Dict[str, Any]],
|
|
378
|
+
name: Optional[str] = None,
|
|
379
|
+
task_name: Optional[str] = None,
|
|
380
|
+
judge: Optional[Dict[str, Any]] = None,
|
|
381
|
+
evaluator_ids: Optional[List[str]] = None,
|
|
382
|
+
dataset_version_no: Optional[int] = None,
|
|
383
|
+
) -> Dict[str, Any]:
|
|
384
|
+
"""Ingest a batch of model *outputs* for *dataset* and grade them.
|
|
385
|
+
|
|
386
|
+
*outputs* is a list of dicts shaped ``{"item_position": int, "output": str,
|
|
387
|
+
"expected_output"?, "query"?, "context"?, "cost_usd"?, "latency_ms"?}``.
|
|
388
|
+
Configure grading with ``judge`` (``{"credential_id", "provider", "model"}``)
|
|
389
|
+
and/or ``evaluator_ids``::
|
|
390
|
+
|
|
391
|
+
exp = trodo.ingest_experiment("support-eval", [
|
|
392
|
+
{"item_position": 0, "output": "here is your tracking link"},
|
|
393
|
+
], judge={"credential_id": "cred_1", "provider": "openai", "model": "gpt-4o"})
|
|
394
|
+
|
|
395
|
+
Returns the experiment dict.
|
|
396
|
+
"""
|
|
397
|
+
return _get_client().experiments.ingest(
|
|
398
|
+
dataset,
|
|
399
|
+
outputs,
|
|
400
|
+
name=name,
|
|
401
|
+
task_name=task_name,
|
|
402
|
+
judge=judge,
|
|
403
|
+
evaluator_ids=evaluator_ids,
|
|
404
|
+
dataset_version_no=dataset_version_no,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
347
408
|
def enable_auto_events() -> None:
|
|
348
409
|
_get_client().enable_auto_events()
|
|
349
410
|
|
|
@@ -23,3 +23,6 @@ RUNS_INGEST = "/api/sdk/runs/ingest"
|
|
|
23
23
|
RUNS_START = "/api/sdk/runs/start"
|
|
24
24
|
RUNS_BASE = "/api/sdk/runs" # /runs/{run_id}/end, /spans, /feedback
|
|
25
25
|
OTLP_TRACES = "/api/sdk/otel/v1/traces"
|
|
26
|
+
# Datasets & experiments ingest
|
|
27
|
+
DATASETS_BASE = "/api/sdk/datasets" # /datasets/{ref}/items
|
|
28
|
+
EXPERIMENTS_INGEST = "/api/sdk/experiments/ingest"
|
|
@@ -133,6 +133,16 @@ class HttpClient:
|
|
|
133
133
|
def list_prompts(self) -> ApiResult:
|
|
134
134
|
return self._get("/api/sdk/prompts")
|
|
135
135
|
|
|
136
|
+
def append_dataset_items(self, ref: str, body: Dict[str, Any]) -> ApiResult:
|
|
137
|
+
"""Append items to a dataset by UUID or name (name is URL-encoded)."""
|
|
138
|
+
from urllib.parse import quote
|
|
139
|
+
return self._request(
|
|
140
|
+
f"/api/sdk/datasets/{quote(str(ref), safe='')}/items", body
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def ingest_experiment(self, body: Dict[str, Any]) -> ApiResult:
|
|
144
|
+
return self._request("/api/sdk/experiments/ingest", body)
|
|
145
|
+
|
|
136
146
|
def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
|
|
137
147
|
return self._request("/api/sdk/track", {"sessionData": session_data})
|
|
138
148
|
|
|
@@ -77,6 +77,8 @@ class TrodoClient:
|
|
|
77
77
|
|
|
78
78
|
self._session_manager = SessionManager()
|
|
79
79
|
self._prompts = None # lazily-built PromptManager
|
|
80
|
+
self._datasets = None # lazily-built DatasetManager
|
|
81
|
+
self._experiments = None # lazily-built ExperimentManager
|
|
80
82
|
|
|
81
83
|
if batch_enabled:
|
|
82
84
|
self._event_queue: Optional[EventQueue] = EventQueue(batch_size)
|
|
@@ -125,6 +127,22 @@ class TrodoClient:
|
|
|
125
127
|
self._prompts = PromptManager(self._http)
|
|
126
128
|
return self._prompts
|
|
127
129
|
|
|
130
|
+
@property
|
|
131
|
+
def datasets(self):
|
|
132
|
+
"""Append items to the team's evaluation datasets."""
|
|
133
|
+
if self._datasets is None:
|
|
134
|
+
from .managers.dataset_manager import DatasetManager
|
|
135
|
+
self._datasets = DatasetManager(self._http)
|
|
136
|
+
return self._datasets
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def experiments(self):
|
|
140
|
+
"""Ingest experiment result batches for server-side grading."""
|
|
141
|
+
if self._experiments is None:
|
|
142
|
+
from .managers.experiment_manager import ExperimentManager
|
|
143
|
+
self._experiments = ExperimentManager(self._http)
|
|
144
|
+
return self._experiments
|
|
145
|
+
|
|
128
146
|
# --------------------------------------------------------------------------
|
|
129
147
|
# Primary pattern: for_user()
|
|
130
148
|
# --------------------------------------------------------------------------
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Dataset ingest — append evaluation items to a Trodo dataset.
|
|
2
|
+
|
|
3
|
+
Datasets are the fixed inputs an experiment runs against: each item is a set of
|
|
4
|
+
variable values (``input``) plus an optional gold answer (``expected_output``)
|
|
5
|
+
and free-form ``metadata``. This manager appends items to a dataset addressed by
|
|
6
|
+
UUID or by name; when a name is used and no such dataset exists it is
|
|
7
|
+
auto-created (``create=True``, the default), so a first append bootstraps the
|
|
8
|
+
dataset without a separate create call.
|
|
9
|
+
|
|
10
|
+
Mirrors ``sdks/trodo-node-sdk/src/managers/DatasetManager.ts`` in shape.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, List, Union
|
|
16
|
+
|
|
17
|
+
__all__ = ["DatasetManager"]
|
|
18
|
+
|
|
19
|
+
# The optional per-item fields, in wire order. ``input`` is always required.
|
|
20
|
+
_ITEM_OPTIONAL = ("expected_output", "metadata", "source_trace_ref")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _clean_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
24
|
+
if not isinstance(item, dict):
|
|
25
|
+
raise ValueError("trodo: each dataset item must be a dict")
|
|
26
|
+
if "input" not in item or item["input"] is None:
|
|
27
|
+
raise ValueError("trodo: each dataset item requires an 'input' dict")
|
|
28
|
+
out: Dict[str, Any] = {"input": item["input"]}
|
|
29
|
+
for key in _ITEM_OPTIONAL:
|
|
30
|
+
if item.get(key) is not None:
|
|
31
|
+
out[key] = item[key]
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DatasetManager:
|
|
36
|
+
"""Append items to the team's evaluation datasets."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, http_client: Any) -> None:
|
|
39
|
+
self._http = http_client
|
|
40
|
+
|
|
41
|
+
def append(
|
|
42
|
+
self,
|
|
43
|
+
ref: str,
|
|
44
|
+
items: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
45
|
+
create: bool = True,
|
|
46
|
+
) -> Dict[str, Any]:
|
|
47
|
+
"""Append one or more items to the dataset *ref* (a UUID or a name).
|
|
48
|
+
|
|
49
|
+
*items* is a list of dicts shaped ``{"input": {<var>: value, ...},
|
|
50
|
+
"expected_output"?: str, "metadata"?: dict, "source_trace_ref"?: dict}``;
|
|
51
|
+
a single dict is accepted and wrapped into a one-item list. ``None``
|
|
52
|
+
optional fields are dropped from the wire payload.
|
|
53
|
+
|
|
54
|
+
When *ref* is a name that doesn't exist yet, ``create=True`` (default)
|
|
55
|
+
auto-creates the dataset; pass ``create=False`` to require it to exist.
|
|
56
|
+
|
|
57
|
+
Returns the parsed response dict:
|
|
58
|
+
``{"dataset_id", "dataset_name", "appended", "item_count"}``.
|
|
59
|
+
|
|
60
|
+
:raises ValueError: if *ref* is empty, *items* is empty, or an item has
|
|
61
|
+
no ``input``.
|
|
62
|
+
"""
|
|
63
|
+
if not ref:
|
|
64
|
+
raise ValueError("trodo: append_dataset(ref) requires a dataset ref")
|
|
65
|
+
if isinstance(items, dict):
|
|
66
|
+
items = [items]
|
|
67
|
+
if not items:
|
|
68
|
+
raise ValueError("trodo: append_dataset(ref, items) requires items")
|
|
69
|
+
|
|
70
|
+
body = {
|
|
71
|
+
"items": [_clean_item(i) for i in items],
|
|
72
|
+
"create": bool(create),
|
|
73
|
+
}
|
|
74
|
+
return self._http.append_dataset_items(ref, body)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Experiment ingest — record a batch of model outputs against a dataset.
|
|
2
|
+
|
|
3
|
+
An experiment takes a dataset (the fixed inputs) and a set of *outputs* your
|
|
4
|
+
model produced for each item, then grades them server-side using the judge
|
|
5
|
+
and/or evaluators you name. You run the model yourself and hand Trodo the
|
|
6
|
+
results; Trodo scores and stores them so runs are comparable over time.
|
|
7
|
+
|
|
8
|
+
``outputs`` are matched to dataset items by ``item_position`` (0-based),
|
|
9
|
+
carrying the produced ``output`` and optional per-row context (``query``,
|
|
10
|
+
``context``), cost/latency telemetry, and a per-row ``expected_output`` override.
|
|
11
|
+
|
|
12
|
+
Mirrors ``sdks/trodo-node-sdk/src/managers/ExperimentManager.ts`` in shape.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
__all__ = ["ExperimentManager"]
|
|
20
|
+
|
|
21
|
+
# Optional per-output fields, in wire order. ``item_position`` + ``output`` are
|
|
22
|
+
# always required.
|
|
23
|
+
_OUTPUT_OPTIONAL = (
|
|
24
|
+
"expected_output",
|
|
25
|
+
"query",
|
|
26
|
+
"context",
|
|
27
|
+
"cost_usd",
|
|
28
|
+
"latency_ms",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clean_output(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
33
|
+
if not isinstance(output, dict):
|
|
34
|
+
raise ValueError("trodo: each experiment output must be a dict")
|
|
35
|
+
if output.get("item_position") is None:
|
|
36
|
+
raise ValueError("trodo: each output requires an 'item_position' (int)")
|
|
37
|
+
if output.get("output") is None:
|
|
38
|
+
raise ValueError("trodo: each output requires an 'output' (str)")
|
|
39
|
+
out: Dict[str, Any] = {
|
|
40
|
+
"item_position": output["item_position"],
|
|
41
|
+
"output": output["output"],
|
|
42
|
+
}
|
|
43
|
+
for key in _OUTPUT_OPTIONAL:
|
|
44
|
+
if output.get(key) is not None:
|
|
45
|
+
out[key] = output[key]
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ExperimentManager:
|
|
50
|
+
"""Ingest experiment result batches for server-side grading."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, http_client: Any) -> None:
|
|
53
|
+
self._http = http_client
|
|
54
|
+
|
|
55
|
+
def ingest(
|
|
56
|
+
self,
|
|
57
|
+
dataset: str,
|
|
58
|
+
outputs: List[Dict[str, Any]],
|
|
59
|
+
name: Optional[str] = None,
|
|
60
|
+
task_name: Optional[str] = None,
|
|
61
|
+
judge: Optional[Dict[str, Any]] = None,
|
|
62
|
+
evaluator_ids: Optional[List[str]] = None,
|
|
63
|
+
dataset_version_no: Optional[int] = None,
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""Ingest a batch of *outputs* for *dataset* and return the experiment.
|
|
66
|
+
|
|
67
|
+
*dataset* is a dataset name or UUID. *outputs* is a list of dicts shaped
|
|
68
|
+
``{"item_position": int, "output": str, "expected_output"?, "query"?,
|
|
69
|
+
"context"?, "cost_usd"?, "latency_ms"?}``. ``None`` optional fields are
|
|
70
|
+
dropped from the wire payload.
|
|
71
|
+
|
|
72
|
+
Grading is configured server-side: pass ``judge`` (``{"credential_id",
|
|
73
|
+
"provider", "model"}``) for an LLM judge and/or ``evaluator_ids`` for
|
|
74
|
+
named evaluators. ``dataset_version_no`` pins the dataset version graded
|
|
75
|
+
against.
|
|
76
|
+
|
|
77
|
+
Returns the experiment dict from ``{"experiment": {...}}``.
|
|
78
|
+
|
|
79
|
+
:raises ValueError: if *dataset* is empty, *outputs* is empty, or an
|
|
80
|
+
output is missing ``item_position``/``output``.
|
|
81
|
+
"""
|
|
82
|
+
if not dataset:
|
|
83
|
+
raise ValueError("trodo: ingest_experiment(dataset) requires a dataset")
|
|
84
|
+
if not outputs:
|
|
85
|
+
raise ValueError("trodo: ingest_experiment requires outputs")
|
|
86
|
+
|
|
87
|
+
body: Dict[str, Any] = {
|
|
88
|
+
"dataset": dataset,
|
|
89
|
+
"outputs": [_clean_output(o) for o in outputs],
|
|
90
|
+
}
|
|
91
|
+
if name is not None:
|
|
92
|
+
body["name"] = name
|
|
93
|
+
if task_name is not None:
|
|
94
|
+
body["task_name"] = task_name
|
|
95
|
+
if judge is not None:
|
|
96
|
+
body["judge"] = judge
|
|
97
|
+
if evaluator_ids is not None:
|
|
98
|
+
body["evaluator_ids"] = evaluator_ids
|
|
99
|
+
if dataset_version_no is not None:
|
|
100
|
+
body["dataset_version_no"] = dataset_version_no
|
|
101
|
+
|
|
102
|
+
res = self._http.ingest_experiment(body)
|
|
103
|
+
if isinstance(res, dict) and "experiment" in res:
|
|
104
|
+
return res["experiment"]
|
|
105
|
+
return res
|
|
@@ -67,7 +67,11 @@ def _to_variables(raw: Any) -> List[PromptVariable]:
|
|
|
67
67
|
return out
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def _to_prompt(
|
|
70
|
+
def _to_prompt(
|
|
71
|
+
raw: Dict[str, Any],
|
|
72
|
+
is_fallback: bool = False,
|
|
73
|
+
trace_label: Optional[str] = None,
|
|
74
|
+
) -> ManagedPrompt:
|
|
71
75
|
return ManagedPrompt(
|
|
72
76
|
name=str(raw.get("name") or ""),
|
|
73
77
|
description=raw.get("description"),
|
|
@@ -84,6 +88,7 @@ def _to_prompt(raw: Dict[str, Any], is_fallback: bool = False) -> ManagedPrompt:
|
|
|
84
88
|
variables=_to_variables(raw.get("variables")),
|
|
85
89
|
updated_at=raw.get("updated_at") or raw.get("updatedAt"),
|
|
86
90
|
is_fallback=is_fallback,
|
|
91
|
+
trace_label=trace_label,
|
|
87
92
|
)
|
|
88
93
|
|
|
89
94
|
|
|
@@ -204,6 +209,10 @@ class PromptManager:
|
|
|
204
209
|
|
|
205
210
|
key = _cache_key(name, version, label)
|
|
206
211
|
ttl = DEFAULT_TTL_SECONDS if cache_ttl_seconds is None else float(cache_ttl_seconds)
|
|
212
|
+
# The deploy label this fetch followed, for prompt traceability. Explicit
|
|
213
|
+
# label wins; with neither label nor version pinned the SDK follows
|
|
214
|
+
# ``production``; a version pin has no label (the hash is the identity).
|
|
215
|
+
trace_label = label if label else ("production" if version is None else None)
|
|
207
216
|
|
|
208
217
|
def fetcher() -> Dict[str, Any]:
|
|
209
218
|
res = _with_retry(
|
|
@@ -223,24 +232,24 @@ class PromptManager:
|
|
|
223
232
|
if ttl > 0:
|
|
224
233
|
fresh = self._cache.get_fresh(key)
|
|
225
234
|
if fresh is not None:
|
|
226
|
-
return _to_prompt(fresh)
|
|
235
|
+
return _to_prompt(fresh, trace_label=trace_label)
|
|
227
236
|
|
|
228
237
|
stale = self._cache.get_stale(key)
|
|
229
238
|
if stale is not None:
|
|
230
239
|
# Serve immediately, refresh behind the caller's back. A slow or
|
|
231
240
|
# dead API costs latency on nobody's request.
|
|
232
241
|
self._cache.revalidate(key, fetcher, ttl)
|
|
233
|
-
return _to_prompt(stale)
|
|
242
|
+
return _to_prompt(stale, trace_label=trace_label)
|
|
234
243
|
|
|
235
244
|
try:
|
|
236
245
|
raw = fetcher()
|
|
237
246
|
if ttl > 0:
|
|
238
247
|
self._cache.set(key, raw, ttl)
|
|
239
|
-
return _to_prompt(raw)
|
|
248
|
+
return _to_prompt(raw, trace_label=trace_label)
|
|
240
249
|
except Exception:
|
|
241
250
|
stale = self._cache.get_stale(key)
|
|
242
251
|
if stale is not None:
|
|
243
|
-
return _to_prompt(stale)
|
|
252
|
+
return _to_prompt(stale, trace_label=trace_label)
|
|
244
253
|
if fallback:
|
|
245
254
|
return _to_prompt(
|
|
246
255
|
{
|
|
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
|
|
|
14
14
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
15
15
|
|
|
16
16
|
from .context import get_active_context
|
|
17
|
+
from .prompt_trace import prompt_attributes
|
|
17
18
|
from .processor import TrodoSpan, TrodoSpanProcessor
|
|
18
19
|
|
|
19
20
|
# Always-on, one-shot warnings. Silent skips (missing opentelemetry-sdk) are the
|
|
@@ -376,6 +377,14 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
|
|
|
376
377
|
set_attr(_ATTR_TRODO_RUN_ID, ctx.run_id)
|
|
377
378
|
if ctx.span_id:
|
|
378
379
|
set_attr(_ATTR_TRODO_PARENT_SPAN_ID, ctx.span_id)
|
|
380
|
+
# Prompt traceability: stamp the exact managed-prompt version
|
|
381
|
+
# compiled just before this (auto-instrumented) provider call.
|
|
382
|
+
# Pinned by immutable version_hash, so a later label move never
|
|
383
|
+
# rewrites what a past span ran.
|
|
384
|
+
state = getattr(ctx, "prompt_state", None)
|
|
385
|
+
current = state.get("current") if isinstance(state, dict) else None
|
|
386
|
+
for k, v in prompt_attributes(current).items():
|
|
387
|
+
set_attr(k, v)
|
|
379
388
|
except Exception:
|
|
380
389
|
pass # never break user code
|
|
381
390
|
|
|
@@ -16,6 +16,10 @@ class ActiveSpanContext:
|
|
|
16
16
|
parent_span_id: Optional[str]
|
|
17
17
|
team_site_id: str
|
|
18
18
|
processor: object # TrodoSpanProcessor — avoid circular import
|
|
19
|
+
# Per-run prompt-traceability accumulator ({"current", "all"}), shared by
|
|
20
|
+
# reference across the run's whole span tree. Records which managed prompt
|
|
21
|
+
# version each span used. See otel/prompt_trace.py.
|
|
22
|
+
prompt_state: Optional[dict] = None
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
_active: contextvars.ContextVar[Optional[ActiveSpanContext]] = contextvars.ContextVar(
|
|
@@ -33,6 +33,9 @@ class TrodoRun:
|
|
|
33
33
|
# free-text error_summary).
|
|
34
34
|
error_type: Optional[str] = None
|
|
35
35
|
metadata: Optional[Dict[str, Any]] = None
|
|
36
|
+
# Free-form run-level attributes (e.g. {"trodo.prompts": [...]} — the set of
|
|
37
|
+
# managed-prompt versions used across the run, for prompt traceability).
|
|
38
|
+
attributes: Optional[Dict[str, Any]] = None
|
|
36
39
|
# Aggregates summed from child spans at finalisation.
|
|
37
40
|
total_tokens_in: Optional[int] = None
|
|
38
41
|
total_tokens_out: Optional[int] = None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Prompt -> span traceability.
|
|
2
|
+
|
|
3
|
+
When a managed prompt is compiled inside a ``wrap_agent`` / ``span`` scope, we
|
|
4
|
+
record which exact version was used so every span emitted in that scope carries
|
|
5
|
+
it. The identity is the immutable ``version_hash`` — so a span always shows the
|
|
6
|
+
exact version that ran, even if a deploy label (e.g. ``production``) is later
|
|
7
|
+
moved to a different version.
|
|
8
|
+
|
|
9
|
+
The link is best-effort and never raises: if there is no active run context
|
|
10
|
+
(the prompt was compiled outside ``wrap_agent``), recording is a silent no-op.
|
|
11
|
+
|
|
12
|
+
Mirrors ``sdks/trodo-node-sdk/src/otel/promptTrace.ts``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from .context import get_active_context
|
|
20
|
+
|
|
21
|
+
# A PromptRef is a plain dict: {name, version_hash?, content_hash?, label?}.
|
|
22
|
+
PromptRef = Dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def new_prompt_state() -> Dict[str, Any]:
|
|
26
|
+
"""Per-run accumulator, shared by reference across the run's span tree."""
|
|
27
|
+
return {"current": None, "all": []}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ref_key(ref: PromptRef) -> str:
|
|
31
|
+
return ref.get("version_hash") or ref.get("name") or ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def record_compiled_prompt(ref: PromptRef) -> None:
|
|
35
|
+
"""Record that a prompt was just compiled.
|
|
36
|
+
|
|
37
|
+
Updates the active run's prompt state (most-recent + the deduped set) and,
|
|
38
|
+
in OTLP mode where a real OpenTelemetry span is current, stamps that span.
|
|
39
|
+
"""
|
|
40
|
+
if not ref or not ref.get("name"):
|
|
41
|
+
return
|
|
42
|
+
active = get_active_context()
|
|
43
|
+
state = getattr(active, "prompt_state", None) if active is not None else None
|
|
44
|
+
if isinstance(state, dict):
|
|
45
|
+
state["current"] = ref
|
|
46
|
+
key = _ref_key(ref)
|
|
47
|
+
used: List[PromptRef] = state["all"]
|
|
48
|
+
if not any(_ref_key(r) == key for r in used):
|
|
49
|
+
used.append(ref)
|
|
50
|
+
_stamp_active_otel_span(ref)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def prompt_attributes(ref: Optional[PromptRef]) -> Dict[str, str]:
|
|
54
|
+
"""Flatten a PromptRef to primitive span attributes (None values omitted)."""
|
|
55
|
+
out: Dict[str, str] = {}
|
|
56
|
+
if not ref or not ref.get("name"):
|
|
57
|
+
return out
|
|
58
|
+
out["trodo.prompt.name"] = ref["name"]
|
|
59
|
+
if ref.get("version_hash"):
|
|
60
|
+
out["trodo.prompt.version_hash"] = ref["version_hash"]
|
|
61
|
+
if ref.get("label"):
|
|
62
|
+
out["trodo.prompt.label"] = ref["label"]
|
|
63
|
+
if ref.get("content_hash"):
|
|
64
|
+
out["trodo.prompt.content_hash"] = ref["content_hash"]
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def merge_prompt_attrs(
|
|
69
|
+
active: Any, attrs: Optional[Dict[str, Any]]
|
|
70
|
+
) -> Optional[Dict[str, Any]]:
|
|
71
|
+
"""Merge the active run's most-recently-compiled prompt into span attributes."""
|
|
72
|
+
merged: Dict[str, Any] = dict(attrs or {})
|
|
73
|
+
state = getattr(active, "prompt_state", None) if active is not None else None
|
|
74
|
+
if isinstance(state, dict):
|
|
75
|
+
merged.update(prompt_attributes(state.get("current")))
|
|
76
|
+
return merged or None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _stamp_active_otel_span(ref: PromptRef) -> None:
|
|
80
|
+
"""OTLP mode only: stamp the current real OTel span, if one exists.
|
|
81
|
+
|
|
82
|
+
Wrapped in try/except — ``opentelemetry`` is an optional dependency present
|
|
83
|
+
only when the user runs in OTLP mode.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
from opentelemetry import trace # type: ignore
|
|
87
|
+
|
|
88
|
+
span = trace.get_current_span()
|
|
89
|
+
if span is None:
|
|
90
|
+
return
|
|
91
|
+
for k, v in prompt_attributes(ref).items():
|
|
92
|
+
span.set_attribute(k, v)
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
pass # not in OTLP mode / opentelemetry not installed — no-op
|
|
@@ -32,6 +32,7 @@ from datetime import datetime, timezone
|
|
|
32
32
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
33
33
|
|
|
34
34
|
from .context import ActiveSpanContext, get_active_context, run_with_context
|
|
35
|
+
from .prompt_trace import merge_prompt_attrs, new_prompt_state
|
|
35
36
|
from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
|
|
36
37
|
from .transport import get_transport_mode, get_otel_tracer, get_otel_helpers
|
|
37
38
|
|
|
@@ -515,6 +516,7 @@ class wrap_agent:
|
|
|
515
516
|
self._parent_run_id = parent_run_id
|
|
516
517
|
self._metadata = metadata
|
|
517
518
|
self._ctx_mgr: Optional[run_with_context] = None
|
|
519
|
+
self._ctx: Optional[ActiveSpanContext] = None
|
|
518
520
|
self._started_ms: float = 0.0
|
|
519
521
|
self._started_iso: str = ""
|
|
520
522
|
self.handle: Optional[RunHandle] = None
|
|
@@ -551,7 +553,9 @@ class wrap_agent:
|
|
|
551
553
|
parent_span_id=None,
|
|
552
554
|
team_site_id=self._team_site_id,
|
|
553
555
|
processor=self._processor,
|
|
556
|
+
prompt_state=new_prompt_state(),
|
|
554
557
|
)
|
|
558
|
+
self._ctx = ctx
|
|
555
559
|
self._ctx_mgr = run_with_context(ctx)
|
|
556
560
|
self._ctx_mgr.__enter__()
|
|
557
561
|
return self.handle
|
|
@@ -596,6 +600,13 @@ class wrap_agent:
|
|
|
596
600
|
error_summary=error_summary,
|
|
597
601
|
error_type=error_type,
|
|
598
602
|
metadata={**(self._metadata or {}), **self.handle.metadata} or None,
|
|
603
|
+
attributes=(
|
|
604
|
+
{"trodo.prompts": self._ctx.prompt_state["all"]}
|
|
605
|
+
if self._ctx is not None
|
|
606
|
+
and self._ctx.prompt_state
|
|
607
|
+
and self._ctx.prompt_state["all"]
|
|
608
|
+
else None
|
|
609
|
+
),
|
|
599
610
|
total_tokens_in=agg["total_tokens_in"],
|
|
600
611
|
total_tokens_out=agg["total_tokens_out"],
|
|
601
612
|
total_cost=agg["total_cost"],
|
|
@@ -709,6 +720,7 @@ class join_run:
|
|
|
709
720
|
self._input = _prepare_value(input) if input is not None else None
|
|
710
721
|
self._attributes = attributes
|
|
711
722
|
self._ctx_mgr: Optional[run_with_context] = None
|
|
723
|
+
self._ctx: Optional[ActiveSpanContext] = None
|
|
712
724
|
self._started_ms: float = 0.0
|
|
713
725
|
self._started_iso: str = ""
|
|
714
726
|
self._span_id: str = ""
|
|
@@ -731,7 +743,9 @@ class join_run:
|
|
|
731
743
|
parent_span_id=self._parent_span_id,
|
|
732
744
|
team_site_id=self._team_site_id,
|
|
733
745
|
processor=self._processor,
|
|
746
|
+
prompt_state=new_prompt_state(),
|
|
734
747
|
)
|
|
748
|
+
self._ctx = ctx
|
|
735
749
|
self._ctx_mgr = run_with_context(ctx)
|
|
736
750
|
self._ctx_mgr.__enter__()
|
|
737
751
|
return self.handle
|
|
@@ -771,7 +785,7 @@ class join_run:
|
|
|
771
785
|
cost_details=self.handle.cost_details,
|
|
772
786
|
temperature=self.handle.temperature,
|
|
773
787
|
tool_name=self.handle.tool_name,
|
|
774
|
-
attributes=self.handle.attributes
|
|
788
|
+
attributes=merge_prompt_attrs(self._ctx, self.handle.attributes),
|
|
775
789
|
)
|
|
776
790
|
try:
|
|
777
791
|
self._processor.append_spans(self._run_id, [trodo_span])
|
|
@@ -833,6 +847,7 @@ class span:
|
|
|
833
847
|
parent_span_id=self._active.span_id,
|
|
834
848
|
team_site_id=self._active.team_site_id,
|
|
835
849
|
processor=self._active.processor,
|
|
850
|
+
prompt_state=self._active.prompt_state,
|
|
836
851
|
)
|
|
837
852
|
self._ctx_mgr = run_with_context(child)
|
|
838
853
|
self._ctx_mgr.__enter__()
|
|
@@ -877,7 +892,7 @@ class span:
|
|
|
877
892
|
cost_details=self.handle.cost_details,
|
|
878
893
|
temperature=self.handle.temperature,
|
|
879
894
|
tool_name=self.handle.tool_name,
|
|
880
|
-
attributes=self.handle.attributes
|
|
895
|
+
attributes=merge_prompt_attrs(self._active, self.handle.attributes),
|
|
881
896
|
)
|
|
882
897
|
processor: TrodoSpanProcessor = self._active.processor # type: ignore[assignment]
|
|
883
898
|
processor.enqueue_span(trodo_span)
|
|
@@ -73,6 +73,10 @@ class ManagedPrompt:
|
|
|
73
73
|
#: True when this came from the ``fallback`` argument because the API was
|
|
74
74
|
#: unreachable and nothing was cached.
|
|
75
75
|
is_fallback: bool = False
|
|
76
|
+
#: The deploy label this fetch followed (for prompt traceability). Set by
|
|
77
|
+
#: PromptManager.get(); not part of the wire contract. ``repr=False`` /
|
|
78
|
+
#: ``compare=False`` so it never affects equality or reprs.
|
|
79
|
+
trace_label: Optional[str] = field(default=None, repr=False, compare=False)
|
|
76
80
|
|
|
77
81
|
def compile(self, variables: Optional[Dict[str, Any]] = None, **kwargs: Any) -> CompiledPrompt:
|
|
78
82
|
"""Compile with variable values, as a dict or as keyword arguments."""
|
|
@@ -80,7 +84,25 @@ class ManagedPrompt:
|
|
|
80
84
|
|
|
81
85
|
values = dict(variables or {})
|
|
82
86
|
values.update(kwargs)
|
|
83
|
-
|
|
87
|
+
compiled = compile_prompt(self, values)
|
|
88
|
+
# Prompt traceability: record the exact version compiled so any span
|
|
89
|
+
# emitted in the surrounding wrap_agent/span scope carries it. No-op
|
|
90
|
+
# outside a run context. Never records a fallback (no real hash).
|
|
91
|
+
if not self.is_fallback:
|
|
92
|
+
try:
|
|
93
|
+
from ..otel.prompt_trace import record_compiled_prompt
|
|
94
|
+
|
|
95
|
+
record_compiled_prompt(
|
|
96
|
+
{
|
|
97
|
+
"name": self.name,
|
|
98
|
+
"version_hash": self.version_hash,
|
|
99
|
+
"content_hash": self.content_hash,
|
|
100
|
+
"label": self.trace_label,
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
except Exception: # noqa: BLE001
|
|
104
|
+
pass # traceability must never break compile
|
|
105
|
+
return compiled
|
|
84
106
|
|
|
85
107
|
|
|
86
108
|
@dataclass
|
|
@@ -3,10 +3,13 @@ pyproject.toml
|
|
|
3
3
|
tests/test_anon_distinct_id.py
|
|
4
4
|
tests/test_auto_instrument_fixes.py
|
|
5
5
|
tests/test_cross_process_session.py
|
|
6
|
+
tests/test_datasets.py
|
|
6
7
|
tests/test_end_run.py
|
|
7
8
|
tests/test_error_enrichment.py
|
|
9
|
+
tests/test_experiments.py
|
|
8
10
|
tests/test_llm_usage_cost.py
|
|
9
11
|
tests/test_processor_methods.py
|
|
12
|
+
tests/test_prompt_trace.py
|
|
10
13
|
tests/test_prompts.py
|
|
11
14
|
tests/test_register_otel.py
|
|
12
15
|
tests/test_start_run.py
|
|
@@ -22,6 +25,8 @@ trodo/api/http_client.py
|
|
|
22
25
|
trodo/auto/__init__.py
|
|
23
26
|
trodo/auto/auto_event_manager.py
|
|
24
27
|
trodo/managers/__init__.py
|
|
28
|
+
trodo/managers/dataset_manager.py
|
|
29
|
+
trodo/managers/experiment_manager.py
|
|
25
30
|
trodo/managers/group_manager.py
|
|
26
31
|
trodo/managers/people_manager.py
|
|
27
32
|
trodo/managers/prompt_manager.py
|
|
@@ -30,6 +35,7 @@ trodo/otel/auto_instrument.py
|
|
|
30
35
|
trodo/otel/context.py
|
|
31
36
|
trodo/otel/helpers.py
|
|
32
37
|
trodo/otel/processor.py
|
|
38
|
+
trodo/otel/prompt_trace.py
|
|
33
39
|
trodo/otel/register.py
|
|
34
40
|
trodo/otel/transport.py
|
|
35
41
|
trodo/otel/wrap_agent.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|