trodo-python 2.15.0__tar.gz → 2.17.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.15.0 → trodo_python-2.17.0}/PKG-INFO +1 -1
- {trodo_python-2.15.0 → trodo_python-2.17.0}/pyproject.toml +1 -1
- trodo_python-2.17.0/tests/test_datasets.py +102 -0
- trodo_python-2.17.0/tests/test_experiments.py +176 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/__init__.py +115 -1
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/api/endpoints.py +7 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/api/http_client.py +32 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/client.py +18 -0
- trodo_python-2.17.0/trodo/managers/dataset_manager.py +74 -0
- trodo_python-2.17.0/trodo/managers/experiment_manager.py +286 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo_python.egg-info/PKG-INFO +1 -1
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo_python.egg-info/SOURCES.txt +4 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/README.md +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/setup.cfg +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_error_enrichment.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_prompt_trace.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_prompts.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/managers/prompt_manager.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/otel/processor.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/otel/prompt_trace.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/otel/wrap_agent.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/prompts/__init__.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/prompts/compile.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/prompts/template.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/prompts/types.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/types.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo/user_context.py +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.15.0 → trodo_python-2.17.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,176 @@
|
|
|
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}])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class FakeManagedHttp(FakeHttp):
|
|
124
|
+
def __init__(self):
|
|
125
|
+
super().__init__()
|
|
126
|
+
self.create_body = None
|
|
127
|
+
self.run_body = None
|
|
128
|
+
self.compare_args = None
|
|
129
|
+
|
|
130
|
+
def create_experiment(self, body):
|
|
131
|
+
self.create_body = body
|
|
132
|
+
return {"experiment": {"id": "exp_p", "status": "pending"}}
|
|
133
|
+
|
|
134
|
+
def run_experiment(self, body):
|
|
135
|
+
self.run_body = body
|
|
136
|
+
return {"experiment": {"id": "exp_done", "status": "completed"}}
|
|
137
|
+
|
|
138
|
+
def get_experiment(self, experiment_id):
|
|
139
|
+
return {"experiment": {"id": experiment_id, "status": "completed"}}
|
|
140
|
+
|
|
141
|
+
def compare_experiments(self, a_id, b_id, strict=False):
|
|
142
|
+
self.compare_args = (a_id, b_id, strict)
|
|
143
|
+
return {"a": {"id": a_id}, "b": {"id": b_id}, "join": "dataset_item_id"}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_create_posts_managed_body():
|
|
147
|
+
http = FakeManagedHttp()
|
|
148
|
+
exp = ExperimentManager(http).create(
|
|
149
|
+
dataset="golden",
|
|
150
|
+
models=[{"credential_id": "1", "provider": "openai", "model": "gpt-4o"}],
|
|
151
|
+
prompt_ref={"name": "support", "version": "abc"},
|
|
152
|
+
scorer_ids=[1, 2],
|
|
153
|
+
)
|
|
154
|
+
assert http.create_body["dataset_id"] == "golden"
|
|
155
|
+
assert http.create_body["scorer_ids"] == [1, 2]
|
|
156
|
+
assert exp["status"] == "pending"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_run_posts_and_returns_completed():
|
|
160
|
+
http = FakeManagedHttp()
|
|
161
|
+
exp = ExperimentManager(http).run(
|
|
162
|
+
dataset="golden",
|
|
163
|
+
models=[{"credential_id": "1", "provider": "openai", "model": "gpt-4o"}],
|
|
164
|
+
name="ci",
|
|
165
|
+
)
|
|
166
|
+
assert http.run_body["name"] == "ci"
|
|
167
|
+
assert exp["status"] == "completed"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_get_and_compare():
|
|
171
|
+
http = FakeManagedHttp()
|
|
172
|
+
mgr = ExperimentManager(http)
|
|
173
|
+
assert mgr.get("exp_1")["id"] == "exp_1"
|
|
174
|
+
res = mgr.compare("a", "b", strict=True)
|
|
175
|
+
assert http.compare_args == ("a", "b", True)
|
|
176
|
+
assert res["join"] == "dataset_item_id"
|
|
@@ -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,14 @@ __all__ = [
|
|
|
119
119
|
"TemplateError",
|
|
120
120
|
"ManagedPrompt",
|
|
121
121
|
"PromptSummary",
|
|
122
|
+
# Datasets & experiments
|
|
123
|
+
"append_dataset",
|
|
124
|
+
"ingest_experiment",
|
|
125
|
+
"create_experiment",
|
|
126
|
+
"run_experiment",
|
|
127
|
+
"get_experiment",
|
|
128
|
+
"wait_experiment",
|
|
129
|
+
"compare_experiments",
|
|
122
130
|
]
|
|
123
131
|
|
|
124
132
|
# ============================================================================
|
|
@@ -344,6 +352,112 @@ def render_template(
|
|
|
344
352
|
return _render_template(template, merged, strict=strict)
|
|
345
353
|
|
|
346
354
|
|
|
355
|
+
# ----------------------------------------------------------------------------
|
|
356
|
+
# Datasets & experiments ingest
|
|
357
|
+
# ----------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
def append_dataset(
|
|
360
|
+
ref: str,
|
|
361
|
+
items: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
362
|
+
create: bool = True,
|
|
363
|
+
) -> Dict[str, Any]:
|
|
364
|
+
"""Append items to a dataset by UUID or name.
|
|
365
|
+
|
|
366
|
+
*items* is a list of dicts shaped ``{"input": {<var>: value, ...},
|
|
367
|
+
"expected_output"?: str, "metadata"?: dict, "source_trace_ref"?: dict}``;
|
|
368
|
+
a single dict is accepted and wrapped. When *ref* is a name that doesn't
|
|
369
|
+
exist, ``create=True`` (default) auto-creates the dataset::
|
|
370
|
+
|
|
371
|
+
trodo.append_dataset("support-eval", [
|
|
372
|
+
{"input": {"q": "where is my order"}, "expected_output": "tracking link"},
|
|
373
|
+
])
|
|
374
|
+
|
|
375
|
+
Returns ``{"dataset_id", "dataset_name", "appended", "item_count"}``.
|
|
376
|
+
"""
|
|
377
|
+
return _get_client().datasets.append(ref, items, create=create)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def ingest_experiment(
|
|
381
|
+
dataset: str,
|
|
382
|
+
outputs: List[Dict[str, Any]],
|
|
383
|
+
name: Optional[str] = None,
|
|
384
|
+
task_name: Optional[str] = None,
|
|
385
|
+
judge: Optional[Dict[str, Any]] = None,
|
|
386
|
+
evaluator_ids: Optional[List[str]] = None,
|
|
387
|
+
dataset_version_no: Optional[int] = None,
|
|
388
|
+
) -> Dict[str, Any]:
|
|
389
|
+
"""Ingest a batch of model *outputs* for *dataset* and grade them.
|
|
390
|
+
|
|
391
|
+
*outputs* is a list of dicts shaped ``{"item_position": int, "output": str,
|
|
392
|
+
"expected_output"?, "query"?, "context"?, "cost_usd"?, "latency_ms"?}``.
|
|
393
|
+
Configure grading with ``judge`` (``{"credential_id", "provider", "model"}``)
|
|
394
|
+
and/or ``evaluator_ids``::
|
|
395
|
+
|
|
396
|
+
exp = trodo.ingest_experiment("support-eval", [
|
|
397
|
+
{"item_position": 0, "output": "here is your tracking link"},
|
|
398
|
+
], judge={"credential_id": "cred_1", "provider": "openai", "model": "gpt-4o"})
|
|
399
|
+
|
|
400
|
+
Returns the experiment dict.
|
|
401
|
+
"""
|
|
402
|
+
return _get_client().experiments.ingest(
|
|
403
|
+
dataset,
|
|
404
|
+
outputs,
|
|
405
|
+
name=name,
|
|
406
|
+
task_name=task_name,
|
|
407
|
+
judge=judge,
|
|
408
|
+
evaluator_ids=evaluator_ids,
|
|
409
|
+
dataset_version_no=dataset_version_no,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def create_experiment(
|
|
414
|
+
*,
|
|
415
|
+
dataset: Optional[str] = None,
|
|
416
|
+
dataset_id: Optional[str] = None,
|
|
417
|
+
models: List[Dict[str, Any]],
|
|
418
|
+
**kwargs: Any,
|
|
419
|
+
) -> Dict[str, Any]:
|
|
420
|
+
"""Create an experiment (immutable refs, status pending)."""
|
|
421
|
+
return _get_client().experiments.create(
|
|
422
|
+
dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def run_experiment(
|
|
427
|
+
*,
|
|
428
|
+
dataset: Optional[str] = None,
|
|
429
|
+
dataset_id: Optional[str] = None,
|
|
430
|
+
models: List[Dict[str, Any]],
|
|
431
|
+
**kwargs: Any,
|
|
432
|
+
) -> Dict[str, Any]:
|
|
433
|
+
"""Create and run an experiment synchronously."""
|
|
434
|
+
return _get_client().experiments.run(
|
|
435
|
+
dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def get_experiment(experiment_id: str) -> Dict[str, Any]:
|
|
440
|
+
"""Fetch an experiment by id."""
|
|
441
|
+
return _get_client().experiments.get(experiment_id)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def wait_experiment(
|
|
445
|
+
experiment_id: str,
|
|
446
|
+
*,
|
|
447
|
+
interval_ms: int = 2500,
|
|
448
|
+
timeout_ms: int = 600_000,
|
|
449
|
+
) -> Dict[str, Any]:
|
|
450
|
+
"""Poll until the experiment completes or fails."""
|
|
451
|
+
return _get_client().experiments.wait(
|
|
452
|
+
experiment_id, interval_ms=interval_ms, timeout_ms=timeout_ms
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def compare_experiments(a_id: str, b_id: str, *, strict: bool = False) -> Dict[str, Any]:
|
|
457
|
+
"""Compare two experiments side by side."""
|
|
458
|
+
return _get_client().experiments.compare(a_id, b_id, strict=strict)
|
|
459
|
+
|
|
460
|
+
|
|
347
461
|
def enable_auto_events() -> None:
|
|
348
462
|
_get_client().enable_auto_events()
|
|
349
463
|
|
|
@@ -23,3 +23,10 @@ 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
|
|
27
|
+
DATASETS_BASE = "/api/sdk/datasets" # /datasets/{ref}/items
|
|
28
|
+
EXPERIMENTS = "/api/sdk/experiments"
|
|
29
|
+
EXPERIMENTS_INGEST = "/api/sdk/experiments/ingest"
|
|
30
|
+
EXPERIMENTS_CREATE = "/api/sdk/experiments/create"
|
|
31
|
+
EXPERIMENTS_RUN = "/api/sdk/experiments/run"
|
|
32
|
+
EXPERIMENTS_COMPARE = "/api/sdk/experiments/compare"
|
|
@@ -133,6 +133,38 @@ 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
|
+
|
|
146
|
+
def create_experiment(self, body: Dict[str, Any]) -> ApiResult:
|
|
147
|
+
return self._request("/api/sdk/experiments/create", body)
|
|
148
|
+
|
|
149
|
+
def run_experiment(self, body: Dict[str, Any]) -> ApiResult:
|
|
150
|
+
return self._request("/api/sdk/experiments/run", body)
|
|
151
|
+
|
|
152
|
+
def run_experiment_by_id(self, experiment_id: str) -> ApiResult:
|
|
153
|
+
from urllib.parse import quote
|
|
154
|
+
return self._request(f"/api/sdk/experiments/{quote(str(experiment_id), safe='')}/run", {})
|
|
155
|
+
|
|
156
|
+
def get_experiment(self, experiment_id: str) -> ApiResult:
|
|
157
|
+
from urllib.parse import quote
|
|
158
|
+
return self._get(f"/api/sdk/experiments/{quote(str(experiment_id), safe='')}")
|
|
159
|
+
|
|
160
|
+
def compare_experiments(
|
|
161
|
+
self, a_id: str, b_id: str, strict: bool = False
|
|
162
|
+
) -> ApiResult:
|
|
163
|
+
params: Dict[str, Any] = {"a": a_id, "b": b_id}
|
|
164
|
+
if strict:
|
|
165
|
+
params["strict"] = "true"
|
|
166
|
+
return self._get("/api/sdk/experiments/compare", params)
|
|
167
|
+
|
|
136
168
|
def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
|
|
137
169
|
return self._request("/api/sdk/track", {"sessionData": session_data})
|
|
138
170
|
|
|
@@ -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
|
+
"""Managed experiment runs + external ingest 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,286 @@
|
|
|
1
|
+
"""Experiment manager — managed runs and external ingest.
|
|
2
|
+
|
|
3
|
+
Managed experiments (prompt + model over a dataset snapshot) use
|
|
4
|
+
``create``, ``run``, ``get``, ``wait``, and ``compare``.
|
|
5
|
+
External agent outputs use ``ingest``.
|
|
6
|
+
|
|
7
|
+
Mirrors ``sdks/trodo-node-sdk/src/managers/ExperimentManager.ts``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, List, Optional, Union
|
|
14
|
+
|
|
15
|
+
__all__ = ["ExperimentManager"]
|
|
16
|
+
|
|
17
|
+
_TERMINAL = frozenset({"completed", "failed"})
|
|
18
|
+
|
|
19
|
+
# Optional per-output fields for ingest
|
|
20
|
+
_OUTPUT_OPTIONAL = (
|
|
21
|
+
"item_position",
|
|
22
|
+
"dataset_item_id",
|
|
23
|
+
"expected_output",
|
|
24
|
+
"query",
|
|
25
|
+
"context",
|
|
26
|
+
"cost_usd",
|
|
27
|
+
"latency_ms",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _clean_output(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
32
|
+
if not isinstance(output, dict):
|
|
33
|
+
raise ValueError("trodo: each experiment output must be a dict")
|
|
34
|
+
if output.get("output") is None:
|
|
35
|
+
raise ValueError("trodo: each output requires an 'output' (str)")
|
|
36
|
+
if output.get("item_position") is None and output.get("dataset_item_id") is None:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"trodo: each output requires 'item_position' and/or 'dataset_item_id'"
|
|
39
|
+
)
|
|
40
|
+
out: Dict[str, Any] = {"output": output["output"]}
|
|
41
|
+
for key in _OUTPUT_OPTIONAL:
|
|
42
|
+
if output.get(key) is not None:
|
|
43
|
+
out[key] = output[key]
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _unwrap_experiment(res: Any) -> Dict[str, Any]:
|
|
48
|
+
if isinstance(res, dict) and "experiment" in res:
|
|
49
|
+
return res["experiment"]
|
|
50
|
+
return res if isinstance(res, dict) else {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_managed_body(
|
|
54
|
+
*,
|
|
55
|
+
dataset: Optional[str] = None,
|
|
56
|
+
dataset_id: Optional[str] = None,
|
|
57
|
+
dataset_version_no: Optional[int] = None,
|
|
58
|
+
name: Optional[str] = None,
|
|
59
|
+
prompt_ref: Optional[Dict[str, Any]] = None,
|
|
60
|
+
prompt_version_id: Optional[str] = None,
|
|
61
|
+
prompt_id: Optional[str] = None,
|
|
62
|
+
use_prompt: Optional[bool] = None,
|
|
63
|
+
models: Optional[List[Dict[str, Any]]] = None,
|
|
64
|
+
scorer_ids: Optional[List[Union[str, int]]] = None,
|
|
65
|
+
evaluator_ids: Optional[List[Union[str, int]]] = None,
|
|
66
|
+
deep_eval: Optional[bool] = None,
|
|
67
|
+
stability_runs: Optional[int] = None,
|
|
68
|
+
row_limit: Optional[int] = None,
|
|
69
|
+
concurrency: Optional[int] = None,
|
|
70
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
71
|
+
) -> Dict[str, Any]:
|
|
72
|
+
body: Dict[str, Any] = {"models": models or []}
|
|
73
|
+
ds = dataset_id or dataset
|
|
74
|
+
if ds:
|
|
75
|
+
body["dataset_id"] = ds
|
|
76
|
+
if dataset_version_no is not None:
|
|
77
|
+
body["dataset_version_no"] = dataset_version_no
|
|
78
|
+
if name is not None:
|
|
79
|
+
body["name"] = name
|
|
80
|
+
if prompt_ref is not None:
|
|
81
|
+
body["prompt_ref"] = prompt_ref
|
|
82
|
+
if prompt_version_id is not None:
|
|
83
|
+
body["prompt_version_id"] = prompt_version_id
|
|
84
|
+
if prompt_id is not None:
|
|
85
|
+
body["prompt_id"] = prompt_id
|
|
86
|
+
if use_prompt is False:
|
|
87
|
+
body["use_prompt"] = False
|
|
88
|
+
ids = scorer_ids if scorer_ids is not None else evaluator_ids
|
|
89
|
+
if ids is not None:
|
|
90
|
+
body["scorer_ids"] = ids
|
|
91
|
+
body["evaluator_ids"] = ids
|
|
92
|
+
if deep_eval is not None:
|
|
93
|
+
body["deep_eval"] = deep_eval
|
|
94
|
+
if stability_runs is not None:
|
|
95
|
+
body["stability_runs"] = stability_runs
|
|
96
|
+
if row_limit is not None:
|
|
97
|
+
body["row_limit"] = row_limit
|
|
98
|
+
if concurrency is not None:
|
|
99
|
+
body["concurrency"] = concurrency
|
|
100
|
+
if metadata is not None:
|
|
101
|
+
body["metadata"] = metadata
|
|
102
|
+
return body
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ExperimentManager:
|
|
106
|
+
"""Run and inspect Trodo experiments programmatically."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, http_client: Any) -> None:
|
|
109
|
+
self._http = http_client
|
|
110
|
+
|
|
111
|
+
def ingest(
|
|
112
|
+
self,
|
|
113
|
+
dataset: str,
|
|
114
|
+
outputs: List[Dict[str, Any]],
|
|
115
|
+
name: Optional[str] = None,
|
|
116
|
+
task_name: Optional[str] = None,
|
|
117
|
+
judge: Optional[Dict[str, Any]] = None,
|
|
118
|
+
evaluator_ids: Optional[List[str]] = None,
|
|
119
|
+
dataset_version_no: Optional[int] = None,
|
|
120
|
+
) -> Dict[str, Any]:
|
|
121
|
+
"""Ingest externally-produced outputs and grade them (external task path)."""
|
|
122
|
+
if not dataset:
|
|
123
|
+
raise ValueError("trodo: ingest_experiment(dataset) requires a dataset")
|
|
124
|
+
if not outputs:
|
|
125
|
+
raise ValueError("trodo: ingest_experiment requires outputs")
|
|
126
|
+
|
|
127
|
+
body: Dict[str, Any] = {
|
|
128
|
+
"dataset": dataset,
|
|
129
|
+
"outputs": [_clean_output(o) for o in outputs],
|
|
130
|
+
}
|
|
131
|
+
if name is not None:
|
|
132
|
+
body["name"] = name
|
|
133
|
+
if task_name is not None:
|
|
134
|
+
body["task_name"] = task_name
|
|
135
|
+
if judge is not None:
|
|
136
|
+
body["judge"] = judge
|
|
137
|
+
if evaluator_ids is not None:
|
|
138
|
+
body["evaluator_ids"] = evaluator_ids
|
|
139
|
+
if dataset_version_no is not None:
|
|
140
|
+
body["dataset_version_no"] = dataset_version_no
|
|
141
|
+
|
|
142
|
+
return _unwrap_experiment(self._http.ingest_experiment(body))
|
|
143
|
+
|
|
144
|
+
def create(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
dataset: Optional[str] = None,
|
|
148
|
+
dataset_id: Optional[str] = None,
|
|
149
|
+
models: List[Dict[str, Any]],
|
|
150
|
+
dataset_version_no: Optional[int] = None,
|
|
151
|
+
name: Optional[str] = None,
|
|
152
|
+
prompt_ref: Optional[Dict[str, Any]] = None,
|
|
153
|
+
prompt_version_id: Optional[str] = None,
|
|
154
|
+
prompt_id: Optional[str] = None,
|
|
155
|
+
use_prompt: Optional[bool] = None,
|
|
156
|
+
scorer_ids: Optional[List[Union[str, int]]] = None,
|
|
157
|
+
evaluator_ids: Optional[List[Union[str, int]]] = None,
|
|
158
|
+
deep_eval: Optional[bool] = None,
|
|
159
|
+
stability_runs: Optional[int] = None,
|
|
160
|
+
row_limit: Optional[int] = None,
|
|
161
|
+
concurrency: Optional[int] = None,
|
|
162
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
163
|
+
) -> Dict[str, Any]:
|
|
164
|
+
"""Create an experiment (immutable refs, status pending)."""
|
|
165
|
+
self._validate_run_params(dataset, dataset_id, models)
|
|
166
|
+
body = _build_managed_body(
|
|
167
|
+
dataset=dataset,
|
|
168
|
+
dataset_id=dataset_id,
|
|
169
|
+
dataset_version_no=dataset_version_no,
|
|
170
|
+
name=name,
|
|
171
|
+
prompt_ref=prompt_ref,
|
|
172
|
+
prompt_version_id=prompt_version_id,
|
|
173
|
+
prompt_id=prompt_id,
|
|
174
|
+
use_prompt=use_prompt,
|
|
175
|
+
models=models,
|
|
176
|
+
scorer_ids=scorer_ids,
|
|
177
|
+
evaluator_ids=evaluator_ids,
|
|
178
|
+
deep_eval=deep_eval,
|
|
179
|
+
stability_runs=stability_runs,
|
|
180
|
+
row_limit=row_limit,
|
|
181
|
+
concurrency=concurrency,
|
|
182
|
+
metadata=metadata,
|
|
183
|
+
)
|
|
184
|
+
return _unwrap_experiment(self._http.create_experiment(body))
|
|
185
|
+
|
|
186
|
+
def run(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
dataset: Optional[str] = None,
|
|
190
|
+
dataset_id: Optional[str] = None,
|
|
191
|
+
models: List[Dict[str, Any]],
|
|
192
|
+
dataset_version_no: Optional[int] = None,
|
|
193
|
+
name: Optional[str] = None,
|
|
194
|
+
prompt_ref: Optional[Dict[str, Any]] = None,
|
|
195
|
+
prompt_version_id: Optional[str] = None,
|
|
196
|
+
prompt_id: Optional[str] = None,
|
|
197
|
+
use_prompt: Optional[bool] = None,
|
|
198
|
+
scorer_ids: Optional[List[Union[str, int]]] = None,
|
|
199
|
+
evaluator_ids: Optional[List[Union[str, int]]] = None,
|
|
200
|
+
deep_eval: Optional[bool] = None,
|
|
201
|
+
stability_runs: Optional[int] = None,
|
|
202
|
+
row_limit: Optional[int] = None,
|
|
203
|
+
concurrency: Optional[int] = None,
|
|
204
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
205
|
+
) -> Dict[str, Any]:
|
|
206
|
+
"""Create and execute an experiment synchronously."""
|
|
207
|
+
self._validate_run_params(dataset, dataset_id, models)
|
|
208
|
+
body = _build_managed_body(
|
|
209
|
+
dataset=dataset,
|
|
210
|
+
dataset_id=dataset_id,
|
|
211
|
+
dataset_version_no=dataset_version_no,
|
|
212
|
+
name=name,
|
|
213
|
+
prompt_ref=prompt_ref,
|
|
214
|
+
prompt_version_id=prompt_version_id,
|
|
215
|
+
prompt_id=prompt_id,
|
|
216
|
+
use_prompt=use_prompt,
|
|
217
|
+
models=models,
|
|
218
|
+
scorer_ids=scorer_ids,
|
|
219
|
+
evaluator_ids=evaluator_ids,
|
|
220
|
+
deep_eval=deep_eval,
|
|
221
|
+
stability_runs=stability_runs,
|
|
222
|
+
row_limit=row_limit,
|
|
223
|
+
concurrency=concurrency,
|
|
224
|
+
metadata=metadata,
|
|
225
|
+
)
|
|
226
|
+
return _unwrap_experiment(self._http.run_experiment(body))
|
|
227
|
+
|
|
228
|
+
def run_by_id(self, experiment_id: str) -> Dict[str, Any]:
|
|
229
|
+
"""Execute a pending experiment created via ``create``."""
|
|
230
|
+
if not experiment_id:
|
|
231
|
+
raise ValueError("trodo: run_by_id requires an experiment id")
|
|
232
|
+
return _unwrap_experiment(self._http.run_experiment_by_id(experiment_id))
|
|
233
|
+
|
|
234
|
+
def get(self, experiment_id: str) -> Dict[str, Any]:
|
|
235
|
+
"""Fetch an experiment by id."""
|
|
236
|
+
if not experiment_id:
|
|
237
|
+
raise ValueError("trodo: get requires an experiment id")
|
|
238
|
+
res = self._http.get_experiment(experiment_id)
|
|
239
|
+
if isinstance(res, dict) and res.get("__error"):
|
|
240
|
+
raise RuntimeError(f"trodo: get failed (HTTP {res.get('status')})")
|
|
241
|
+
return _unwrap_experiment(res)
|
|
242
|
+
|
|
243
|
+
def wait(
|
|
244
|
+
self,
|
|
245
|
+
experiment_id: str,
|
|
246
|
+
*,
|
|
247
|
+
interval_ms: int = 2500,
|
|
248
|
+
timeout_ms: int = 600_000,
|
|
249
|
+
) -> Dict[str, Any]:
|
|
250
|
+
"""Poll until the experiment reaches a terminal status."""
|
|
251
|
+
deadline = time.monotonic() + (timeout_ms / 1000.0)
|
|
252
|
+
while True:
|
|
253
|
+
exp = self.get(experiment_id)
|
|
254
|
+
status = str(exp.get("status") or "").lower()
|
|
255
|
+
if status in _TERMINAL:
|
|
256
|
+
return exp
|
|
257
|
+
if time.monotonic() >= deadline:
|
|
258
|
+
raise TimeoutError(
|
|
259
|
+
f"trodo: wait timed out after {timeout_ms}ms (status: {status or 'unknown'})"
|
|
260
|
+
)
|
|
261
|
+
time.sleep(interval_ms / 1000.0)
|
|
262
|
+
|
|
263
|
+
def compare(
|
|
264
|
+
self, a_id: str, b_id: str, *, strict: bool = False
|
|
265
|
+
) -> Dict[str, Any]:
|
|
266
|
+
"""Compare two experiments side by side."""
|
|
267
|
+
if not a_id or not b_id:
|
|
268
|
+
raise ValueError("trodo: compare requires two experiment ids")
|
|
269
|
+
res = self._http.compare_experiments(a_id, b_id, strict=strict)
|
|
270
|
+
if isinstance(res, dict) and res.get("__error"):
|
|
271
|
+
raise RuntimeError(f"trodo: compare failed (HTTP {res.get('status')})")
|
|
272
|
+
return res if isinstance(res, dict) else {}
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _validate_run_params(
|
|
276
|
+
dataset: Optional[str],
|
|
277
|
+
dataset_id: Optional[str],
|
|
278
|
+
models: Optional[List[Dict[str, Any]]],
|
|
279
|
+
) -> None:
|
|
280
|
+
if not models:
|
|
281
|
+
raise ValueError("trodo: run requires a non-empty models list")
|
|
282
|
+
if not dataset and not dataset_id:
|
|
283
|
+
raise ValueError("trodo: run requires dataset or dataset_id")
|
|
284
|
+
for model in models:
|
|
285
|
+
if not model.get("credential_id") or not model.get("model"):
|
|
286
|
+
raise ValueError("trodo: each model needs credential_id and model")
|
|
@@ -3,8 +3,10 @@ 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
|
|
10
12
|
tests/test_prompt_trace.py
|
|
@@ -23,6 +25,8 @@ trodo/api/http_client.py
|
|
|
23
25
|
trodo/auto/__init__.py
|
|
24
26
|
trodo/auto/auto_event_manager.py
|
|
25
27
|
trodo/managers/__init__.py
|
|
28
|
+
trodo/managers/dataset_manager.py
|
|
29
|
+
trodo/managers/experiment_manager.py
|
|
26
30
|
trodo/managers/group_manager.py
|
|
27
31
|
trodo/managers/people_manager.py
|
|
28
32
|
trodo/managers/prompt_manager.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|