trodo-python 2.14.0__py3-none-any.whl → 2.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
trodo/__init__.py CHANGED
@@ -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.13.0"
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
 
trodo/api/endpoints.py CHANGED
@@ -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"
trodo/api/http_client.py CHANGED
@@ -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
 
trodo/client.py CHANGED
@@ -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(raw: Dict[str, Any], is_fallback: bool = False) -> ManagedPrompt:
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
 
trodo/otel/context.py CHANGED
@@ -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(
trodo/otel/processor.py CHANGED
@@ -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
trodo/otel/wrap_agent.py CHANGED
@@ -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 or None,
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 or None,
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)
trodo/prompts/types.py CHANGED
@@ -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
- return compile_prompt(self, values)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.14.0
3
+ Version: 2.16.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -1,36 +1,39 @@
1
- trodo/__init__.py,sha256=9VvBIbahXhuKln5593dCq29dan1IN7xFHU0voW4SDqs,21167
2
- trodo/client.py,sha256=z2q1HZKQNENycv9CRD0kOfwEhmz3Ti9_B-uPsqpvY3U,19454
1
+ trodo/__init__.py,sha256=V2vh8SxMkgOaBdowPlHzx_2hpX4T1lUlS2JmE6usz3M,23362
2
+ trodo/client.py,sha256=syz4zzWokdBaFNnO5QhYnUKVNHf8KaunofVFc1ODohI,20187
3
3
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
4
4
  trodo/user_context.py,sha256=9la6azzwEanVmdP4ps_xMoufbeWVeIGU-M8ychmgajg,7859
5
5
  trodo/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  trodo/api/async_client.py,sha256=rZN4aJ2QiKyrHBK260bApCUB9JaMWU6BQtzoSJZh7xk,3408
7
- trodo/api/endpoints.py,sha256=HKQ3d_Mxf0y4HwlHor0XkSAwUVj-4Xvv--rzE9njxjM,1027
8
- trodo/api/http_client.py,sha256=gVYVqn-M4Mjd0zTjx_Zsr246BL_xvIHOa2w8YlpVvD4,7638
7
+ trodo/api/endpoints.py,sha256=JVTn5l1U-7U2ibQoR9M_AjRgK6GMRgSx_DkV4opWouo,1171
8
+ trodo/api/http_client.py,sha256=rWeRLqVXQ-iXgAIzg6701WtK5eSlPZTTJgKZFerLZhs,8085
9
9
  trodo/auto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  trodo/auto/auto_event_manager.py,sha256=cztuRsRkNoJE5R4NfSfTrTJTGl4jx2Yb-Ncy0aVAPo8,4247
11
11
  trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ trodo/managers/dataset_manager.py,sha256=gx0S8ujG3cbw4IskBNMdceBjDbcEh_nebwKSzEvm7hE,2827
13
+ trodo/managers/experiment_manager.py,sha256=9GO1JUuV7FykaPfZXI8NVvEBDS2CZuyw9I3ywCrZBTI,3916
12
14
  trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
13
15
  trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
14
- trodo/managers/prompt_manager.py,sha256=kyQFdty7l7w7ngzsHw6zs1bgL4oa1Lj3ugMPxakrMqM,10719
16
+ trodo/managers/prompt_manager.py,sha256=jFHkdvDSxvvb9c53EQvBQygdDJ7X-ylNWEY_ag_SeC0,11227
15
17
  trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
16
- trodo/otel/auto_instrument.py,sha256=hJesQMOOp86U66PampcGNINmBVvwVuU_WHVrbUMauug,20895
17
- trodo/otel/context.py,sha256=iJ1rE42-SbO8VZHAxhIl2ZJXgNwLIVps5xLg8GKgfFc,1165
18
+ trodo/otel/auto_instrument.py,sha256=Iae9A9lvh2PImE6gqnyEMXeRPRpjTxZcxx1zW2KDLac,21467
19
+ trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
18
20
  trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
19
- trodo/otel/processor.py,sha256=aqcTmzTw9cESgIp829pu_XCa5_dG_2MaeJNsqJZeqQU,7495
21
+ trodo/otel/processor.py,sha256=ffhrzPDUBwEzeUBa9k5oKnl6vw7NV0urAoco2Z2aiVY,7703
22
+ trodo/otel/prompt_trace.py,sha256=BIrdLOpsR1_HoaCmWb_706GwZSs-UG3p76fjvX3CX3w,3391
20
23
  trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
21
24
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
22
- trodo/otel/wrap_agent.py,sha256=_nFDhxPyl0RlNKj29cBNeRc_zAY5FUiHvTiNUWxTt0M,39049
25
+ trodo/otel/wrap_agent.py,sha256=9lsrt6o25dZ8RGdHXERRF-PbZ8RqIoX1dw_cXF_dBdQ,39724
23
26
  trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
24
27
  trodo/prompts/compile.py,sha256=sEMl8EWdK0G9uOKGcgc39ujnA5RLCUtbk26TYVqK68Q,6345
25
28
  trodo/prompts/template.py,sha256=obQiivPCR6LEdz7q1cby7uL25tYHYui4aoEaiUNqWfs,9357
26
- trodo/prompts/types.py,sha256=RhvYSuXCrwnZHu5TftObIAs-TQa2E7v08alNmD62x4s,3327
29
+ trodo/prompts/types.py,sha256=3oyrEDb1jfnC4gF02mM3izhNHstJ-bu-8TxoF7Yk6mQ,4425
27
30
  trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
31
  trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
29
32
  trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
30
33
  trodo/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
34
  trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrTjs,2136
32
35
  trodo/session/session_manager.py,sha256=JrgH1VeicmtlxPR4dXEuJbxhi23OelkgwW3-9Slv80o,2525
33
- trodo_python-2.14.0.dist-info/METADATA,sha256=RCejgO9mpxqpdJhLGakkYLLVCzSB0ZJXwdFMQFrkXP8,23115
34
- trodo_python-2.14.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
35
- trodo_python-2.14.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
36
- trodo_python-2.14.0.dist-info/RECORD,,
36
+ trodo_python-2.16.0.dist-info/METADATA,sha256=i4J0WgohVDAcQi8OCVa_x6WpDhH9mWeYCEgX8BQ2lLs,23115
37
+ trodo_python-2.16.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
38
+ trodo_python-2.16.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
39
+ trodo_python-2.16.0.dist-info/RECORD,,