trodo-python 2.16.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.16.0 → trodo_python-2.17.0}/PKG-INFO +1 -1
- {trodo_python-2.16.0 → trodo_python-2.17.0}/pyproject.toml +1 -1
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_experiments.py +56 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/__init__.py +54 -1
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/api/endpoints.py +5 -1
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/api/http_client.py +22 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/client.py +1 -1
- trodo_python-2.17.0/trodo/managers/experiment_manager.py +286 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo_python.egg-info/PKG-INFO +1 -1
- trodo_python-2.16.0/trodo/managers/experiment_manager.py +0 -105
- {trodo_python-2.16.0 → trodo_python-2.17.0}/README.md +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/setup.cfg +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_datasets.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_error_enrichment.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_prompt_trace.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_prompts.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/managers/dataset_manager.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/managers/prompt_manager.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/otel/processor.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/otel/prompt_trace.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/otel/wrap_agent.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/prompts/__init__.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/prompts/compile.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/prompts/template.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/prompts/types.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/types.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo/user_context.py +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo_python.egg-info/SOURCES.txt +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.16.0 → trodo_python-2.17.0}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -118,3 +118,59 @@ def test_ingest_requires_item_position_and_output():
|
|
|
118
118
|
ExperimentManager(FakeHttp()).ingest("ds", [{"output": "o"}])
|
|
119
119
|
with pytest.raises(ValueError, match="requires an 'output'"):
|
|
120
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"
|
|
@@ -119,9 +119,14 @@ __all__ = [
|
|
|
119
119
|
"TemplateError",
|
|
120
120
|
"ManagedPrompt",
|
|
121
121
|
"PromptSummary",
|
|
122
|
-
# Datasets & experiments
|
|
122
|
+
# Datasets & experiments
|
|
123
123
|
"append_dataset",
|
|
124
124
|
"ingest_experiment",
|
|
125
|
+
"create_experiment",
|
|
126
|
+
"run_experiment",
|
|
127
|
+
"get_experiment",
|
|
128
|
+
"wait_experiment",
|
|
129
|
+
"compare_experiments",
|
|
125
130
|
]
|
|
126
131
|
|
|
127
132
|
# ============================================================================
|
|
@@ -405,6 +410,54 @@ def ingest_experiment(
|
|
|
405
410
|
)
|
|
406
411
|
|
|
407
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
|
+
|
|
408
461
|
def enable_auto_events() -> None:
|
|
409
462
|
_get_client().enable_auto_events()
|
|
410
463
|
|
|
@@ -23,6 +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
|
|
26
|
+
# Datasets & experiments
|
|
27
27
|
DATASETS_BASE = "/api/sdk/datasets" # /datasets/{ref}/items
|
|
28
|
+
EXPERIMENTS = "/api/sdk/experiments"
|
|
28
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"
|
|
@@ -143,6 +143,28 @@ class HttpClient:
|
|
|
143
143
|
def ingest_experiment(self, body: Dict[str, Any]) -> ApiResult:
|
|
144
144
|
return self._request("/api/sdk/experiments/ingest", body)
|
|
145
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
|
+
|
|
146
168
|
def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
|
|
147
169
|
return self._request("/api/sdk/track", {"sessionData": session_data})
|
|
148
170
|
|
|
@@ -137,7 +137,7 @@ class TrodoClient:
|
|
|
137
137
|
|
|
138
138
|
@property
|
|
139
139
|
def experiments(self):
|
|
140
|
-
"""
|
|
140
|
+
"""Managed experiment runs + external ingest for server-side grading."""
|
|
141
141
|
if self._experiments is None:
|
|
142
142
|
from .managers.experiment_manager import ExperimentManager
|
|
143
143
|
self._experiments = ExperimentManager(self._http)
|
|
@@ -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")
|
|
@@ -1,105 +0,0 @@
|
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|