trodo-python 2.11.0__py3-none-any.whl → 2.12.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 +96 -1
- trodo/api/http_client.py +53 -0
- trodo/client.py +9 -0
- trodo/managers/prompt_manager.py +286 -0
- trodo/prompts/__init__.py +37 -0
- trodo/prompts/compile.py +181 -0
- trodo/prompts/template.py +280 -0
- trodo/prompts/types.py +87 -0
- {trodo_python-2.11.0.dist-info → trodo_python-2.12.0.dist-info}/METADATA +39 -1
- {trodo_python-2.11.0.dist-info → trodo_python-2.12.0.dist-info}/RECORD +12 -7
- {trodo_python-2.11.0.dist-info → trodo_python-2.12.0.dist-info}/WHEEL +0 -0
- {trodo_python-2.11.0.dist-info → trodo_python-2.12.0.dist-info}/top_level.txt +0 -0
trodo/__init__.py
CHANGED
|
@@ -41,13 +41,21 @@ 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.12.0"
|
|
45
45
|
|
|
46
46
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
47
47
|
|
|
48
48
|
from .client import TrodoClient
|
|
49
49
|
from .user_context import UserContext
|
|
50
50
|
from .managers.group_manager import GroupProfile
|
|
51
|
+
from .managers.prompt_manager import ManagedPrompt, PromptSummary
|
|
52
|
+
from .prompts import (
|
|
53
|
+
CompiledPrompt,
|
|
54
|
+
CompileError,
|
|
55
|
+
PromptVariable,
|
|
56
|
+
TemplateError,
|
|
57
|
+
render as _render_template,
|
|
58
|
+
)
|
|
51
59
|
from .otel.wrap_agent import (
|
|
52
60
|
wrap_agent as _wrap_agent_ctx,
|
|
53
61
|
span as _span_ctx,
|
|
@@ -100,6 +108,17 @@ __all__ = [
|
|
|
100
108
|
"propagation_headers",
|
|
101
109
|
"current_run_id",
|
|
102
110
|
"current_span_id",
|
|
111
|
+
# Prompt management
|
|
112
|
+
"get_prompt",
|
|
113
|
+
"list_prompts",
|
|
114
|
+
"compile_prompt",
|
|
115
|
+
"render_template",
|
|
116
|
+
"CompiledPrompt",
|
|
117
|
+
"PromptVariable",
|
|
118
|
+
"CompileError",
|
|
119
|
+
"TemplateError",
|
|
120
|
+
"ManagedPrompt",
|
|
121
|
+
"PromptSummary",
|
|
103
122
|
]
|
|
104
123
|
|
|
105
124
|
# ============================================================================
|
|
@@ -249,6 +268,82 @@ def reset(distinct_id: str) -> ResetResult:
|
|
|
249
268
|
return _get_client().reset(distinct_id)
|
|
250
269
|
|
|
251
270
|
|
|
271
|
+
# ----------------------------------------------------------------------------
|
|
272
|
+
# Prompt management
|
|
273
|
+
# ----------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def get_prompt(
|
|
276
|
+
name: str,
|
|
277
|
+
label: Optional[str] = None,
|
|
278
|
+
version: Optional[int] = None,
|
|
279
|
+
cache_ttl_seconds: Optional[float] = None,
|
|
280
|
+
fallback: Optional[Dict[str, Any]] = None,
|
|
281
|
+
max_retries: int = 2,
|
|
282
|
+
) -> ManagedPrompt:
|
|
283
|
+
"""Fetch a managed prompt by name.
|
|
284
|
+
|
|
285
|
+
Follows the ``production`` label by default; pass ``label`` for a different
|
|
286
|
+
one or ``version`` to pin exactly.
|
|
287
|
+
|
|
288
|
+
Cached for 60s with stale-while-revalidate, so a Trodo outage degrades
|
|
289
|
+
instead of taking your app down. Pass ``fallback`` to cover cold start::
|
|
290
|
+
|
|
291
|
+
p = trodo.get_prompt("support-triage", fallback={
|
|
292
|
+
"messages": [{"role": "user", "content": [{"type": "text", "text": "Help with {{q}}"}]}],
|
|
293
|
+
"variables": [{"name": "q", "type": "string", "required": True}],
|
|
294
|
+
})
|
|
295
|
+
compiled = p.compile(q="where is my order")
|
|
296
|
+
# compiled.messages / compiled.model / compiled.tools
|
|
297
|
+
|
|
298
|
+
:raises LookupError: if the prompt cannot be fetched and nothing is cached
|
|
299
|
+
and no ``fallback`` was given
|
|
300
|
+
"""
|
|
301
|
+
return _get_client().prompts.get(
|
|
302
|
+
name,
|
|
303
|
+
label=label,
|
|
304
|
+
version=version,
|
|
305
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
306
|
+
fallback=fallback,
|
|
307
|
+
max_retries=max_retries,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def list_prompts() -> List[PromptSummary]:
|
|
312
|
+
"""List prompts available to this site's team (names + labels, no body)."""
|
|
313
|
+
return _get_client().prompts.list()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def compile_prompt(
|
|
317
|
+
prompt: ManagedPrompt,
|
|
318
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
319
|
+
**kwargs: Any,
|
|
320
|
+
) -> CompiledPrompt:
|
|
321
|
+
"""Compile a managed prompt with variable values.
|
|
322
|
+
|
|
323
|
+
Returns ``CompiledPrompt(messages, model, tools, response_format)`` — hand it
|
|
324
|
+
straight to your provider client.
|
|
325
|
+
"""
|
|
326
|
+
merged = dict(variables or {})
|
|
327
|
+
merged.update(kwargs)
|
|
328
|
+
return prompt.compile(merged)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def render_template(
|
|
332
|
+
template: str,
|
|
333
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
334
|
+
strict: bool = True,
|
|
335
|
+
**kwargs: Any,
|
|
336
|
+
) -> str:
|
|
337
|
+
"""Render a template string with ``{{variables}}`` (no client required).
|
|
338
|
+
|
|
339
|
+
Strict by default: an unknown variable raises rather than silently leaving a
|
|
340
|
+
literal ``{{typo}}`` in the text you send to a model.
|
|
341
|
+
"""
|
|
342
|
+
merged = dict(variables or {})
|
|
343
|
+
merged.update(kwargs)
|
|
344
|
+
return _render_template(template, merged, strict=strict)
|
|
345
|
+
|
|
346
|
+
|
|
252
347
|
def enable_auto_events() -> None:
|
|
253
348
|
_get_client().enable_auto_events()
|
|
254
349
|
|
trodo/api/http_client.py
CHANGED
|
@@ -80,6 +80,59 @@ class HttpClient:
|
|
|
80
80
|
self.on_error(exc)
|
|
81
81
|
return {}
|
|
82
82
|
|
|
83
|
+
def _get(
|
|
84
|
+
self, path: str, params: Optional[Dict[str, Any]] = None, attempt: int = 0
|
|
85
|
+
) -> ApiResult:
|
|
86
|
+
"""GET helper for read endpoints (prompt management). Surfaces a non-2xx
|
|
87
|
+
as {"__error": True, "status": <code>, ...} so callers can tell
|
|
88
|
+
"not found" apart from a successful empty body."""
|
|
89
|
+
url = f"{self.api_base}{path}"
|
|
90
|
+
self._log(f"GET {url}")
|
|
91
|
+
try:
|
|
92
|
+
resp = self._session.get(url, params=params or {}, timeout=self.timeout)
|
|
93
|
+
if resp.status_code >= 500 and attempt < self.retries:
|
|
94
|
+
delay = 2 ** attempt
|
|
95
|
+
self._log(f"Retry {attempt + 1} after {delay}s (status {resp.status_code})")
|
|
96
|
+
time.sleep(delay)
|
|
97
|
+
return self._get(path, params, attempt + 1)
|
|
98
|
+
try:
|
|
99
|
+
data = resp.json()
|
|
100
|
+
except Exception:
|
|
101
|
+
data = {}
|
|
102
|
+
if resp.status_code >= 400:
|
|
103
|
+
base = {"__error": True, "status": resp.status_code}
|
|
104
|
+
if isinstance(data, dict):
|
|
105
|
+
base.update(data)
|
|
106
|
+
return base
|
|
107
|
+
return data
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
if attempt < self.retries:
|
|
110
|
+
delay = 2 ** attempt
|
|
111
|
+
self._log(f"Retry {attempt + 1} after {delay}s (network error)")
|
|
112
|
+
time.sleep(delay)
|
|
113
|
+
return self._get(path, params, attempt + 1)
|
|
114
|
+
self._log(f"Error: {exc}")
|
|
115
|
+
if self.on_error:
|
|
116
|
+
self.on_error(exc)
|
|
117
|
+
return {"__error": True}
|
|
118
|
+
|
|
119
|
+
def get_prompt(
|
|
120
|
+
self,
|
|
121
|
+
name: str,
|
|
122
|
+
label: Optional[str] = None,
|
|
123
|
+
version: Optional[Any] = None,
|
|
124
|
+
) -> ApiResult:
|
|
125
|
+
from urllib.parse import quote
|
|
126
|
+
params: Dict[str, Any] = {}
|
|
127
|
+
if label:
|
|
128
|
+
params["label"] = label
|
|
129
|
+
if version is not None and version != "":
|
|
130
|
+
params["version"] = version
|
|
131
|
+
return self._get(f"/api/sdk/prompts/{quote(str(name), safe='')}", params)
|
|
132
|
+
|
|
133
|
+
def list_prompts(self) -> ApiResult:
|
|
134
|
+
return self._get("/api/sdk/prompts")
|
|
135
|
+
|
|
83
136
|
def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
|
|
84
137
|
return self._request("/api/sdk/track", {"sessionData": session_data})
|
|
85
138
|
|
trodo/client.py
CHANGED
|
@@ -76,6 +76,7 @@ class TrodoClient:
|
|
|
76
76
|
)
|
|
77
77
|
|
|
78
78
|
self._session_manager = SessionManager()
|
|
79
|
+
self._prompts = None # lazily-built PromptManager
|
|
79
80
|
|
|
80
81
|
if batch_enabled:
|
|
81
82
|
self._event_queue: Optional[EventQueue] = EventQueue(batch_size)
|
|
@@ -116,6 +117,14 @@ class TrodoClient:
|
|
|
116
117
|
the public surface; subject to change between minors."""
|
|
117
118
|
return self._span_processor
|
|
118
119
|
|
|
120
|
+
@property
|
|
121
|
+
def prompts(self):
|
|
122
|
+
"""Read-only access to the team's managed prompt registry."""
|
|
123
|
+
if self._prompts is None:
|
|
124
|
+
from .managers.prompt_manager import PromptManager
|
|
125
|
+
self._prompts = PromptManager(self._http)
|
|
126
|
+
return self._prompts
|
|
127
|
+
|
|
119
128
|
# --------------------------------------------------------------------------
|
|
120
129
|
# Primary pattern: for_user()
|
|
121
130
|
# --------------------------------------------------------------------------
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Prompt management — read managed prompts from the Trodo registry.
|
|
2
|
+
|
|
3
|
+
Prompts are authored and versioned in the Trodo dashboard; the SDK fetches them
|
|
4
|
+
at runtime by name + optional label/version so application code never hard-codes
|
|
5
|
+
prompt text. A deploy label (e.g. ``production``) points at one version, so you
|
|
6
|
+
ship a new prompt by moving the label rather than redeploying.
|
|
7
|
+
|
|
8
|
+
``compile()`` returns a structured :class:`CompiledPrompt` — ``{messages, model,
|
|
9
|
+
tools, response_format}``, not a string. A string has nowhere to put model
|
|
10
|
+
config or tools, which is why every product that returns one cannot carry them.
|
|
11
|
+
|
|
12
|
+
Mirrors ``sdks/trodo-node-sdk/src/managers/PromptManager.ts`` test-for-test.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Set
|
|
20
|
+
|
|
21
|
+
from ..prompts.compile import CompileError, compile_prompt
|
|
22
|
+
from ..prompts.template import TemplateError, render
|
|
23
|
+
from ..prompts.types import CompiledPrompt, ManagedPrompt, PromptSummary, PromptVariable
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"PromptManager",
|
|
27
|
+
"ManagedPrompt",
|
|
28
|
+
"PromptSummary",
|
|
29
|
+
"PromptVariable",
|
|
30
|
+
"CompiledPrompt",
|
|
31
|
+
"CompileError",
|
|
32
|
+
"TemplateError",
|
|
33
|
+
"compile_prompt",
|
|
34
|
+
"render",
|
|
35
|
+
"DEFAULT_TTL_SECONDS",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
DEFAULT_TTL_SECONDS = 60.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cache_key(name: str, version: Optional[int], label: Optional[str]) -> str:
|
|
42
|
+
# Resolution happens server-side on every fetch; the client only caches
|
|
43
|
+
# under whatever selector was asked for. So a label flip propagates within
|
|
44
|
+
# one TTL without the client knowing anything about labels.
|
|
45
|
+
if version is not None:
|
|
46
|
+
return f"{name}::version:{version}"
|
|
47
|
+
if label:
|
|
48
|
+
return f"{name}::label:{label}"
|
|
49
|
+
return f"{name}::label:production"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _to_variables(raw: Any) -> List[PromptVariable]:
|
|
53
|
+
out: List[PromptVariable] = []
|
|
54
|
+
for v in raw or []:
|
|
55
|
+
if isinstance(v, PromptVariable):
|
|
56
|
+
out.append(v)
|
|
57
|
+
elif isinstance(v, dict) and isinstance(v.get("name"), str):
|
|
58
|
+
out.append(
|
|
59
|
+
PromptVariable(
|
|
60
|
+
name=v["name"],
|
|
61
|
+
type=v.get("type") or "string",
|
|
62
|
+
required=bool(v.get("required")),
|
|
63
|
+
default=v.get("default"),
|
|
64
|
+
description=v.get("description"),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _to_prompt(raw: Dict[str, Any], is_fallback: bool = False) -> ManagedPrompt:
|
|
71
|
+
return ManagedPrompt(
|
|
72
|
+
name=str(raw.get("name") or ""),
|
|
73
|
+
description=raw.get("description"),
|
|
74
|
+
tags=list(raw.get("tags") or []),
|
|
75
|
+
version=int(raw.get("version") or 0),
|
|
76
|
+
labels=list(raw.get("labels") or []),
|
|
77
|
+
messages=list(raw.get("messages") or []),
|
|
78
|
+
model_config=dict(raw.get("model_config") or {}),
|
|
79
|
+
tools=list(raw.get("tools") or []),
|
|
80
|
+
response_format=raw.get("response_format"),
|
|
81
|
+
variables=_to_variables(raw.get("variables")),
|
|
82
|
+
updated_at=raw.get("updated_at") or raw.get("updatedAt"),
|
|
83
|
+
is_fallback=is_fallback,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _Cache:
|
|
88
|
+
"""Prompt cache with stale-while-revalidate.
|
|
89
|
+
|
|
90
|
+
An LLM app must never hard-fail because the prompt service blinked. The
|
|
91
|
+
ladder, in order:
|
|
92
|
+
|
|
93
|
+
fresh cache -> stale cache -> caller's fallback -> raise
|
|
94
|
+
|
|
95
|
+
A prompt fetch sits on the hot path of every request an app makes. Without
|
|
96
|
+
this, adding Trodo prompt management would make an app strictly *less*
|
|
97
|
+
reliable than hardcoding the string — which is a good reason not to adopt it.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self._store: Dict[str, Any] = {}
|
|
102
|
+
self._expires: Dict[str, float] = {}
|
|
103
|
+
self._refreshing: Set[str] = set()
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
def get_fresh(self, key: str) -> Optional[Any]:
|
|
107
|
+
with self._lock:
|
|
108
|
+
if key in self._store and self._expires.get(key, 0.0) > time.time():
|
|
109
|
+
return self._store[key]
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def get_stale(self, key: str) -> Optional[Any]:
|
|
113
|
+
with self._lock:
|
|
114
|
+
return self._store.get(key)
|
|
115
|
+
|
|
116
|
+
def set(self, key: str, value: Any, ttl: float) -> None:
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._store[key] = value
|
|
119
|
+
self._expires[key] = time.time() + ttl
|
|
120
|
+
|
|
121
|
+
def revalidate(self, key: str, fetcher: Callable[[], Any], ttl: float) -> None:
|
|
122
|
+
with self._lock:
|
|
123
|
+
if key in self._refreshing:
|
|
124
|
+
return
|
|
125
|
+
self._refreshing.add(key)
|
|
126
|
+
|
|
127
|
+
def _run() -> None:
|
|
128
|
+
try:
|
|
129
|
+
self.set(key, fetcher(), ttl)
|
|
130
|
+
except Exception: # noqa: BLE001
|
|
131
|
+
# Deliberately swallowed. The caller already has a usable stale
|
|
132
|
+
# value; a background refresh failing is not their problem and
|
|
133
|
+
# must never surface. It retries on the next request.
|
|
134
|
+
pass
|
|
135
|
+
finally:
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._refreshing.discard(key)
|
|
138
|
+
|
|
139
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
140
|
+
|
|
141
|
+
def clear(self) -> None:
|
|
142
|
+
with self._lock:
|
|
143
|
+
self._store.clear()
|
|
144
|
+
self._expires.clear()
|
|
145
|
+
self._refreshing.clear()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _with_retry(fn: Callable[[], Any], max_retries: int) -> Any:
|
|
149
|
+
"""Retry with exponential backoff, capped. Mirrors the Node SDK."""
|
|
150
|
+
attempts = max(0, min(max_retries, 4))
|
|
151
|
+
last: Optional[BaseException] = None
|
|
152
|
+
for i in range(attempts + 1):
|
|
153
|
+
try:
|
|
154
|
+
return fn()
|
|
155
|
+
except Exception as e: # noqa: BLE001
|
|
156
|
+
last = e
|
|
157
|
+
if i == attempts:
|
|
158
|
+
break
|
|
159
|
+
time.sleep(min(2**i * 0.2, 10.0))
|
|
160
|
+
assert last is not None
|
|
161
|
+
raise last
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class PromptManager:
|
|
165
|
+
"""Read-only access to the team's managed prompt registry."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, http_client: Any) -> None:
|
|
168
|
+
self._http = http_client
|
|
169
|
+
self._cache = _Cache()
|
|
170
|
+
|
|
171
|
+
def get(
|
|
172
|
+
self,
|
|
173
|
+
name: str,
|
|
174
|
+
label: Optional[str] = None,
|
|
175
|
+
version: Optional[int] = None,
|
|
176
|
+
cache_ttl_seconds: Optional[float] = None,
|
|
177
|
+
fallback: Optional[Dict[str, Any]] = None,
|
|
178
|
+
max_retries: int = 2,
|
|
179
|
+
) -> ManagedPrompt:
|
|
180
|
+
"""Fetch a managed prompt by name.
|
|
181
|
+
|
|
182
|
+
Without arguments you get the version labelled ``production``; pass
|
|
183
|
+
``label`` to follow a different deploy label, or ``version`` to pin
|
|
184
|
+
exactly.
|
|
185
|
+
|
|
186
|
+
Availability ladder — fresh cache -> stale cache -> ``fallback`` ->
|
|
187
|
+
raise. A prompt fetch is on your hot path, so a Trodo outage degrades
|
|
188
|
+
rather than takes your app down. Check ``prompt.is_fallback`` to detect
|
|
189
|
+
the last rung. Pass ``cache_ttl_seconds=0`` to disable caching (handy in
|
|
190
|
+
development).
|
|
191
|
+
|
|
192
|
+
:raises ValueError: if *name* is empty, or both ``label`` and ``version``
|
|
193
|
+
:raises LookupError: if the prompt cannot be fetched and nothing is
|
|
194
|
+
cached and no ``fallback`` was given
|
|
195
|
+
"""
|
|
196
|
+
if not name:
|
|
197
|
+
raise ValueError("trodo: get_prompt(name) requires a name")
|
|
198
|
+
if version is not None and label:
|
|
199
|
+
raise ValueError("trodo: pass either version or label, not both")
|
|
200
|
+
|
|
201
|
+
key = _cache_key(name, version, label)
|
|
202
|
+
ttl = DEFAULT_TTL_SECONDS if cache_ttl_seconds is None else float(cache_ttl_seconds)
|
|
203
|
+
|
|
204
|
+
def fetcher() -> Dict[str, Any]:
|
|
205
|
+
res = _with_retry(
|
|
206
|
+
lambda: self._http.get_prompt(name, label=label, version=version),
|
|
207
|
+
max_retries,
|
|
208
|
+
)
|
|
209
|
+
if not res or res.get("__error") or not res.get("prompt"):
|
|
210
|
+
status = res.get("status") if isinstance(res, dict) else None
|
|
211
|
+
detail = res.get("error") if isinstance(res, dict) else None
|
|
212
|
+
raise LookupError(
|
|
213
|
+
f"trodo: could not fetch prompt {name!r}"
|
|
214
|
+
+ (f" (HTTP {status})" if status else "")
|
|
215
|
+
+ (f": {detail}" if detail else "")
|
|
216
|
+
)
|
|
217
|
+
return res["prompt"]
|
|
218
|
+
|
|
219
|
+
if ttl > 0:
|
|
220
|
+
fresh = self._cache.get_fresh(key)
|
|
221
|
+
if fresh is not None:
|
|
222
|
+
return _to_prompt(fresh)
|
|
223
|
+
|
|
224
|
+
stale = self._cache.get_stale(key)
|
|
225
|
+
if stale is not None:
|
|
226
|
+
# Serve immediately, refresh behind the caller's back. A slow or
|
|
227
|
+
# dead API costs latency on nobody's request.
|
|
228
|
+
self._cache.revalidate(key, fetcher, ttl)
|
|
229
|
+
return _to_prompt(stale)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
raw = fetcher()
|
|
233
|
+
if ttl > 0:
|
|
234
|
+
self._cache.set(key, raw, ttl)
|
|
235
|
+
return _to_prompt(raw)
|
|
236
|
+
except Exception:
|
|
237
|
+
stale = self._cache.get_stale(key)
|
|
238
|
+
if stale is not None:
|
|
239
|
+
return _to_prompt(stale)
|
|
240
|
+
if fallback:
|
|
241
|
+
return _to_prompt(
|
|
242
|
+
{
|
|
243
|
+
"name": name,
|
|
244
|
+
"version": 0,
|
|
245
|
+
"labels": [],
|
|
246
|
+
"tags": [],
|
|
247
|
+
"messages": fallback.get("messages") or [],
|
|
248
|
+
"model_config": fallback.get("model_config") or {},
|
|
249
|
+
"tools": [],
|
|
250
|
+
"response_format": None,
|
|
251
|
+
"variables": fallback.get("variables") or [],
|
|
252
|
+
},
|
|
253
|
+
is_fallback=True,
|
|
254
|
+
)
|
|
255
|
+
raise
|
|
256
|
+
|
|
257
|
+
def list(self) -> List[PromptSummary]:
|
|
258
|
+
"""List the prompts available to this site's team (names + labels)."""
|
|
259
|
+
res = self._http.list_prompts()
|
|
260
|
+
if not res or res.get("__error") or not isinstance(res.get("prompts"), list):
|
|
261
|
+
return []
|
|
262
|
+
return [
|
|
263
|
+
PromptSummary(
|
|
264
|
+
name=str(p.get("name") or ""),
|
|
265
|
+
description=p.get("description"),
|
|
266
|
+
version=int(p.get("version") or 0),
|
|
267
|
+
labels=list(p.get("labels") or []),
|
|
268
|
+
updated_at=p.get("updated_at") or p.get("updatedAt"),
|
|
269
|
+
)
|
|
270
|
+
for p in res["prompts"]
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
def compile(
|
|
274
|
+
self,
|
|
275
|
+
prompt: ManagedPrompt,
|
|
276
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
277
|
+
**kwargs: Any,
|
|
278
|
+
) -> CompiledPrompt:
|
|
279
|
+
"""Compile a managed prompt with variable values."""
|
|
280
|
+
values = dict(variables or {})
|
|
281
|
+
values.update(kwargs)
|
|
282
|
+
return prompt.compile(values)
|
|
283
|
+
|
|
284
|
+
def clear_cache(self) -> None:
|
|
285
|
+
"""Drop everything cached. Mostly useful in tests."""
|
|
286
|
+
self._cache.clear()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Managed prompt support: template rendering, compilation, and types."""
|
|
2
|
+
|
|
3
|
+
from .compile import CompileError, build_scope, compile_prompt
|
|
4
|
+
from .template import TemplateError, extract_names, parse, render, render_with_report
|
|
5
|
+
from .types import (
|
|
6
|
+
CompiledPrompt,
|
|
7
|
+
ContentBlock,
|
|
8
|
+
ManagedPrompt,
|
|
9
|
+
ModelConfig,
|
|
10
|
+
PromptMessage,
|
|
11
|
+
PromptNode,
|
|
12
|
+
PromptSummary,
|
|
13
|
+
PromptTool,
|
|
14
|
+
PromptVariable,
|
|
15
|
+
ResponseFormat,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CompileError",
|
|
20
|
+
"CompiledPrompt",
|
|
21
|
+
"ContentBlock",
|
|
22
|
+
"ManagedPrompt",
|
|
23
|
+
"ModelConfig",
|
|
24
|
+
"PromptMessage",
|
|
25
|
+
"PromptNode",
|
|
26
|
+
"PromptSummary",
|
|
27
|
+
"PromptTool",
|
|
28
|
+
"PromptVariable",
|
|
29
|
+
"ResponseFormat",
|
|
30
|
+
"TemplateError",
|
|
31
|
+
"build_scope",
|
|
32
|
+
"compile_prompt",
|
|
33
|
+
"extract_names",
|
|
34
|
+
"parse",
|
|
35
|
+
"render",
|
|
36
|
+
"render_with_report",
|
|
37
|
+
]
|
trodo/prompts/compile.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Compile a managed prompt into a ready-to-send payload.
|
|
2
|
+
|
|
3
|
+
Mirrors ``backend/services/prompts/compile.js`` and the Node SDK's
|
|
4
|
+
``src/prompts/compile.ts`` exactly. The backend compiles for the playground and
|
|
5
|
+
the hosted ``/run`` path; this compiles in the caller's process. They must agree,
|
|
6
|
+
or the prompt you tested is not the prompt you shipped.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from .template import render
|
|
14
|
+
from .types import CompiledPrompt, PromptVariable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CompileError(Exception):
|
|
18
|
+
"""Raised when a prompt cannot be compiled with the supplied values.
|
|
19
|
+
|
|
20
|
+
Carries ``details`` — every problem found, not just the first, so a caller
|
|
21
|
+
can surface them all at once.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, details: Optional[List[str]] = None) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.details: List[str] = details or []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
|
|
30
|
+
"""Coerce a caller value to the declared type.
|
|
31
|
+
|
|
32
|
+
Values arrive over HTTP and from application code, so ``"5"`` for a number
|
|
33
|
+
variable is normal, not an error — but ``"abc"`` is.
|
|
34
|
+
"""
|
|
35
|
+
if value is None:
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
if type_ == "number":
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
errors.append(f"variable '{name}': expected a number, got {value!r}")
|
|
41
|
+
return value
|
|
42
|
+
if isinstance(value, (int, float)):
|
|
43
|
+
return value
|
|
44
|
+
try:
|
|
45
|
+
return float(value) if "." in str(value) else int(value)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
errors.append(f"variable '{name}': expected a number, got {value!r}")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
if type_ == "boolean":
|
|
51
|
+
if isinstance(value, bool):
|
|
52
|
+
return value
|
|
53
|
+
if value == "true":
|
|
54
|
+
return True
|
|
55
|
+
if value == "false":
|
|
56
|
+
return False
|
|
57
|
+
errors.append(f"variable '{name}': expected a boolean, got {value!r}")
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
if type_ == "string":
|
|
61
|
+
return value if isinstance(value, str) else str(value)
|
|
62
|
+
|
|
63
|
+
if type_ == "messages":
|
|
64
|
+
if not isinstance(value, list):
|
|
65
|
+
errors.append(f"variable '{name}': expected an array of messages")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_scope(
|
|
72
|
+
variables: Optional[List[PromptVariable]],
|
|
73
|
+
values: Optional[Dict[str, Any]],
|
|
74
|
+
) -> Dict[str, Any]:
|
|
75
|
+
"""Build the render scope from declarations + caller values.
|
|
76
|
+
|
|
77
|
+
Resolution order: caller value → declared default → required-and-absent
|
|
78
|
+
raises → optional-and-absent renders empty.
|
|
79
|
+
"""
|
|
80
|
+
errors: List[str] = []
|
|
81
|
+
scope: Dict[str, Any] = {}
|
|
82
|
+
declared = set()
|
|
83
|
+
values = values or {}
|
|
84
|
+
|
|
85
|
+
for v in variables or []:
|
|
86
|
+
name = getattr(v, "name", None) if not isinstance(v, dict) else v.get("name")
|
|
87
|
+
if not isinstance(name, str):
|
|
88
|
+
continue
|
|
89
|
+
declared.add(name)
|
|
90
|
+
|
|
91
|
+
if isinstance(v, dict):
|
|
92
|
+
type_ = v.get("type") or "string"
|
|
93
|
+
has_default = "default" in v and v.get("default") is not None
|
|
94
|
+
default = v.get("default")
|
|
95
|
+
else:
|
|
96
|
+
type_ = getattr(v, "type", None) or "string"
|
|
97
|
+
default = getattr(v, "default", None)
|
|
98
|
+
has_default = default is not None
|
|
99
|
+
|
|
100
|
+
has = name in values
|
|
101
|
+
value = values.get(name) if has else None
|
|
102
|
+
|
|
103
|
+
# Missing value -> the declared default, or empty. Nothing is
|
|
104
|
+
# "required": compile always succeeds with whatever the caller provides.
|
|
105
|
+
if not has or value is None:
|
|
106
|
+
value = default if has_default else ([] if type_ == "messages" else "")
|
|
107
|
+
|
|
108
|
+
scope[name] = _coerce(value, type_, name, errors)
|
|
109
|
+
|
|
110
|
+
# Passing something the prompt doesn't declare is nearly always a rename
|
|
111
|
+
# that didn't propagate. Fail loudly rather than render a prompt nobody
|
|
112
|
+
# intended.
|
|
113
|
+
for k in values:
|
|
114
|
+
if k not in declared:
|
|
115
|
+
errors.append(f"variable '{k}' is not declared on this prompt")
|
|
116
|
+
|
|
117
|
+
if errors:
|
|
118
|
+
raise CompileError("Cannot compile prompt: " + "; ".join(errors), errors)
|
|
119
|
+
return scope
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _render_block(block: Dict[str, Any], scope: Dict[str, Any]) -> Dict[str, Any]:
|
|
123
|
+
if block.get("type") == "text":
|
|
124
|
+
out = dict(block)
|
|
125
|
+
out["text"] = render(block.get("text"), scope, strict=True)
|
|
126
|
+
return out
|
|
127
|
+
return block
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _resolve_placeholder(
|
|
131
|
+
node: Dict[str, Any], scope: Dict[str, Any], errors: List[str]
|
|
132
|
+
) -> List[Dict[str, Any]]:
|
|
133
|
+
name = node.get("name")
|
|
134
|
+
value = scope.get(name)
|
|
135
|
+
if value is None:
|
|
136
|
+
return []
|
|
137
|
+
if not isinstance(value, list):
|
|
138
|
+
# Langfuse coerces a non-list into {"role": "NOT_GIVEN", ...} plus a
|
|
139
|
+
# warning, and that sentinel role then reaches the provider. Refuse.
|
|
140
|
+
errors.append(
|
|
141
|
+
f"placeholder '{name}': expected an array of messages, got {type(value).__name__}"
|
|
142
|
+
)
|
|
143
|
+
return []
|
|
144
|
+
for i, m in enumerate(value):
|
|
145
|
+
if not isinstance(m, dict) or not isinstance(m.get("role"), str):
|
|
146
|
+
errors.append(f"placeholder '{name}'[{i}]: each item must be a message with a role")
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def compile_prompt(prompt: Any, values: Optional[Dict[str, Any]] = None) -> CompiledPrompt:
|
|
151
|
+
"""Compile *prompt* with *values*.
|
|
152
|
+
|
|
153
|
+
Returns a :class:`CompiledPrompt` — ``{messages, model, tools,
|
|
154
|
+
response_format}``, not a string. A string has nowhere to put model config
|
|
155
|
+
or tools, which is why every product that returns one cannot carry them.
|
|
156
|
+
"""
|
|
157
|
+
get = (lambda k, d=None: prompt.get(k, d)) if isinstance(prompt, dict) else (
|
|
158
|
+
lambda k, d=None: getattr(prompt, k, d)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
scope = build_scope(get("variables") or [], values or {})
|
|
162
|
+
errors: List[str] = []
|
|
163
|
+
out: List[Dict[str, Any]] = []
|
|
164
|
+
|
|
165
|
+
for node in get("messages") or []:
|
|
166
|
+
if isinstance(node, dict) and node.get("type") == "placeholder":
|
|
167
|
+
out.extend(_resolve_placeholder(node, scope, errors))
|
|
168
|
+
continue
|
|
169
|
+
msg = dict(node)
|
|
170
|
+
msg["content"] = [_render_block(b, scope) for b in (node.get("content") or [])]
|
|
171
|
+
out.append(msg)
|
|
172
|
+
|
|
173
|
+
if errors:
|
|
174
|
+
raise CompileError("Cannot compile prompt: " + "; ".join(errors), errors)
|
|
175
|
+
|
|
176
|
+
return CompiledPrompt(
|
|
177
|
+
messages=out,
|
|
178
|
+
model=get("model_config") or {},
|
|
179
|
+
tools=get("tools") or [],
|
|
180
|
+
response_format=get("response_format"),
|
|
181
|
+
)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Trodo prompt template engine — Python SDK port.
|
|
2
|
+
|
|
3
|
+
MUST stay byte-identical to:
|
|
4
|
+
backend/services/prompts/template.js (source of truth)
|
|
5
|
+
sdks/trodo-node-sdk/src/prompts/template.ts
|
|
6
|
+
|
|
7
|
+
All three are pinned by ``backend/services/prompts/template.vectors.json``,
|
|
8
|
+
which this package's tests load directly. If you change behaviour here without
|
|
9
|
+
adding a vector and updating the other two, the suites will disagree — which is
|
|
10
|
+
the point. Divergence between what the playground renders and what the SDK
|
|
11
|
+
renders is the single worst bug this feature can have.
|
|
12
|
+
|
|
13
|
+
Zero dependencies, deliberately: this SDK ships with only ``requests`` and that
|
|
14
|
+
is a feature for something embedded in customer apps. Notably we do NOT use
|
|
15
|
+
jinja2 here — it has no faithful JavaScript implementation, and cross-language
|
|
16
|
+
fidelity matters more than expressiveness. See docs/prompt-management-v2-plan.md §5.
|
|
17
|
+
|
|
18
|
+
Supported:
|
|
19
|
+
{{name}} interpolate (raw — we emit prompts, not HTML)
|
|
20
|
+
{{a.b.c}} dot-path resolution
|
|
21
|
+
{{#name}}...{{/name}} section: truthy renders once, list renders per item
|
|
22
|
+
{{^name}}...{{/name}} inverted section
|
|
23
|
+
{{.}} current item inside a list section
|
|
24
|
+
{{! comment }} comment
|
|
25
|
+
|
|
26
|
+
Not supported (raises, never silently ignored): partials, lambdas, set-delimiters.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
34
|
+
|
|
35
|
+
_OPEN = "{{"
|
|
36
|
+
_CLOSE = "}}"
|
|
37
|
+
|
|
38
|
+
_NAME_RE = re.compile(r"^(\.|[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*)$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TemplateError(Exception):
|
|
42
|
+
"""Raised for malformed templates and (in strict mode) unknown variables."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Tokens are dicts to mirror the JS ports exactly:
|
|
46
|
+
# {"t": "text", "v": str}
|
|
47
|
+
# {"t": "var", "v": str}
|
|
48
|
+
# {"t": "section", "v": str, "inverted": bool, "children": [...]}
|
|
49
|
+
Token = Dict[str, Any]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _assert_name(name: str, pos: int) -> None:
|
|
53
|
+
if not _NAME_RE.match(name):
|
|
54
|
+
raise TemplateError(f"Invalid variable name '{name}' at position {pos}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse(template: Optional[str]) -> List[Token]:
|
|
58
|
+
"""Parse a template into a token tree."""
|
|
59
|
+
src = "" if template is None else str(template)
|
|
60
|
+
root: List[Token] = []
|
|
61
|
+
stack: List[Tuple[Optional[str], List[Token]]] = [(None, root)]
|
|
62
|
+
i = 0
|
|
63
|
+
|
|
64
|
+
def push(tok: Token) -> None:
|
|
65
|
+
stack[-1][1].append(tok)
|
|
66
|
+
|
|
67
|
+
while i < len(src):
|
|
68
|
+
open_at = src.find(_OPEN, i)
|
|
69
|
+
if open_at == -1:
|
|
70
|
+
if i < len(src):
|
|
71
|
+
push({"t": "text", "v": src[i:]})
|
|
72
|
+
break
|
|
73
|
+
if open_at > i:
|
|
74
|
+
push({"t": "text", "v": src[i:open_at]})
|
|
75
|
+
|
|
76
|
+
close_at = src.find(_CLOSE, open_at + len(_OPEN))
|
|
77
|
+
if close_at == -1:
|
|
78
|
+
raise TemplateError(f"Unclosed '{{{{' at position {open_at}")
|
|
79
|
+
|
|
80
|
+
body = src[open_at + len(_OPEN) : close_at].strip()
|
|
81
|
+
i = close_at + len(_CLOSE)
|
|
82
|
+
|
|
83
|
+
if not body:
|
|
84
|
+
raise TemplateError(f"Empty tag '{{{{}}}}' at position {open_at}")
|
|
85
|
+
|
|
86
|
+
sigil = body[0]
|
|
87
|
+
rest = body[1:].strip()
|
|
88
|
+
|
|
89
|
+
if sigil == "!":
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if sigil in (">", "<"):
|
|
93
|
+
raise TemplateError(
|
|
94
|
+
f"Partials ({{{{>{rest}}}}}) are not supported. Inline the content instead."
|
|
95
|
+
)
|
|
96
|
+
if sigil == "=":
|
|
97
|
+
raise TemplateError("Set-delimiter tags ({{=...=}}) are not supported.")
|
|
98
|
+
if sigil == "&":
|
|
99
|
+
raise TemplateError(
|
|
100
|
+
f"Unescaped tags ({{{{&{rest}}}}}) are not supported: "
|
|
101
|
+
f"{{{{{rest}}}}} is already raw."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if sigil in ("#", "^"):
|
|
105
|
+
if not rest:
|
|
106
|
+
raise TemplateError(f"Section tag missing a name at position {open_at}")
|
|
107
|
+
_assert_name(rest, open_at)
|
|
108
|
+
tok: Token = {"t": "section", "v": rest, "inverted": sigil == "^", "children": []}
|
|
109
|
+
push(tok)
|
|
110
|
+
stack.append((rest, tok["children"]))
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if sigil == "/":
|
|
114
|
+
top_name, _ = stack[-1]
|
|
115
|
+
if top_name is None:
|
|
116
|
+
raise TemplateError(f"Unexpected closing tag {{{{/{rest}}}}} — no open section")
|
|
117
|
+
if top_name != rest:
|
|
118
|
+
raise TemplateError(
|
|
119
|
+
f"Mismatched closing tag: expected {{{{/{top_name}}}}} "
|
|
120
|
+
f"but found {{{{/{rest}}}}}"
|
|
121
|
+
)
|
|
122
|
+
stack.pop()
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
_assert_name(body, open_at)
|
|
126
|
+
push({"t": "var", "v": body})
|
|
127
|
+
|
|
128
|
+
if len(stack) > 1:
|
|
129
|
+
raise TemplateError(f"Unclosed section {{{{#{stack[-1][0]}}}}}")
|
|
130
|
+
return root
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _lookup(path: str, scopes: Sequence[Any]) -> Tuple[bool, Any]:
|
|
134
|
+
"""Resolve a dot-path against a scope chain (innermost first).
|
|
135
|
+
|
|
136
|
+
This is the piece every competitor gets wrong: Langfuse's parser lexes
|
|
137
|
+
``{{user.name}}`` as a name but then does a flat key lookup, so the dot-path
|
|
138
|
+
only resolves if you literally pass a ``"user.name"`` key. We walk it.
|
|
139
|
+
"""
|
|
140
|
+
if path == ".":
|
|
141
|
+
return (True, scopes[0]) if scopes else (False, None)
|
|
142
|
+
|
|
143
|
+
parts = path.split(".")
|
|
144
|
+
for scope in scopes:
|
|
145
|
+
if not isinstance(scope, dict):
|
|
146
|
+
continue
|
|
147
|
+
if parts[0] not in scope:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
cur: Any = scope
|
|
151
|
+
ok = True
|
|
152
|
+
for part in parts:
|
|
153
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
154
|
+
ok = False
|
|
155
|
+
break
|
|
156
|
+
cur = cur[part]
|
|
157
|
+
if ok:
|
|
158
|
+
return True, cur
|
|
159
|
+
return False, None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _stringify(value: Any) -> str:
|
|
163
|
+
if value is None:
|
|
164
|
+
return ""
|
|
165
|
+
if isinstance(value, str):
|
|
166
|
+
return value
|
|
167
|
+
if isinstance(value, bool):
|
|
168
|
+
# Must match JS: `true` / `false`, not Python's `True` / `False`.
|
|
169
|
+
return "true" if value else "false"
|
|
170
|
+
if isinstance(value, (int, float)):
|
|
171
|
+
# Match JS number formatting: 5.0 renders as "5", not "5.0".
|
|
172
|
+
if isinstance(value, float) and value.is_integer():
|
|
173
|
+
return str(int(value))
|
|
174
|
+
return str(value)
|
|
175
|
+
try:
|
|
176
|
+
return json.dumps(value, separators=(",", ":"))
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
return str(value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_falsy(value: Any) -> bool:
|
|
182
|
+
if value is None or value is False or value == "":
|
|
183
|
+
return True
|
|
184
|
+
if isinstance(value, (list, tuple)) and len(value) == 0:
|
|
185
|
+
return True
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _render_tokens(tokens: List[Token], scopes: List[Any], strict: bool, missing: Set[str]) -> str:
|
|
190
|
+
out: List[str] = []
|
|
191
|
+
for tok in tokens:
|
|
192
|
+
kind = tok["t"]
|
|
193
|
+
|
|
194
|
+
if kind == "text":
|
|
195
|
+
out.append(tok["v"])
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
if kind == "var":
|
|
199
|
+
found, value = _lookup(tok["v"], scopes)
|
|
200
|
+
if not found:
|
|
201
|
+
if strict:
|
|
202
|
+
raise TemplateError(
|
|
203
|
+
f"Unknown variable '{{{{{tok['v']}}}}}'. "
|
|
204
|
+
"Declare it on the prompt or pass a value."
|
|
205
|
+
)
|
|
206
|
+
missing.add(tok["v"])
|
|
207
|
+
continue
|
|
208
|
+
out.append(_stringify(value))
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# section
|
|
212
|
+
found, value = _lookup(tok["v"], scopes)
|
|
213
|
+
if not found and strict and not tok["inverted"]:
|
|
214
|
+
raise TemplateError(
|
|
215
|
+
f"Unknown section '{{{{#{tok['v']}}}}}'. "
|
|
216
|
+
"Declare it on the prompt or pass a value."
|
|
217
|
+
)
|
|
218
|
+
if not found:
|
|
219
|
+
missing.add(tok["v"])
|
|
220
|
+
|
|
221
|
+
falsy = _is_falsy(value)
|
|
222
|
+
if tok["inverted"]:
|
|
223
|
+
if falsy:
|
|
224
|
+
out.append(_render_tokens(tok["children"], scopes, strict, missing))
|
|
225
|
+
continue
|
|
226
|
+
if falsy:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if isinstance(value, (list, tuple)):
|
|
230
|
+
for item in value:
|
|
231
|
+
out.append(_render_tokens(tok["children"], [item, *scopes], strict, missing))
|
|
232
|
+
elif isinstance(value, dict):
|
|
233
|
+
out.append(_render_tokens(tok["children"], [value, *scopes], strict, missing))
|
|
234
|
+
else:
|
|
235
|
+
out.append(_render_tokens(tok["children"], scopes, strict, missing))
|
|
236
|
+
|
|
237
|
+
return "".join(out)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def render(
|
|
241
|
+
template: Optional[str],
|
|
242
|
+
values: Optional[Dict[str, Any]] = None,
|
|
243
|
+
strict: bool = True,
|
|
244
|
+
) -> str:
|
|
245
|
+
"""Render *template* with *values*.
|
|
246
|
+
|
|
247
|
+
Strict by default: an unknown variable raises rather than leaving a literal
|
|
248
|
+
``{{typo}}`` in the text you send to a model.
|
|
249
|
+
"""
|
|
250
|
+
return _render_tokens(parse(template), [values or {}], strict, set())
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def render_with_report(
|
|
254
|
+
template: Optional[str],
|
|
255
|
+
values: Optional[Dict[str, Any]] = None,
|
|
256
|
+
strict: bool = False,
|
|
257
|
+
) -> Tuple[str, List[str]]:
|
|
258
|
+
"""Render, also reporting which names were missing. Returns (text, missing)."""
|
|
259
|
+
missing: Set[str] = set()
|
|
260
|
+
text = _render_tokens(parse(template), [values or {}], strict, missing)
|
|
261
|
+
return text, list(missing)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def extract_names(template: Optional[str]) -> List[str]:
|
|
265
|
+
"""Names referenced by a template, first-appearance order, de-duplicated."""
|
|
266
|
+
seen: List[str] = []
|
|
267
|
+
|
|
268
|
+
def walk(tokens: List[Token]) -> None:
|
|
269
|
+
for tok in tokens:
|
|
270
|
+
if tok["t"] == "text":
|
|
271
|
+
continue
|
|
272
|
+
if tok["v"] != ".":
|
|
273
|
+
root = tok["v"].split(".")[0]
|
|
274
|
+
if root not in seen:
|
|
275
|
+
seen.append(root)
|
|
276
|
+
if tok["t"] == "section":
|
|
277
|
+
walk(tok["children"])
|
|
278
|
+
|
|
279
|
+
walk(parse(template))
|
|
280
|
+
return seen
|
trodo/prompts/types.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Public types for managed prompts.
|
|
2
|
+
|
|
3
|
+
Mirrors the wire contract in ``backend/controllers/sdkPromptController.js`` and
|
|
4
|
+
the Node SDK's ``src/prompts/types.ts``. All three move together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
# A message is a plain dict on the wire:
|
|
13
|
+
# {"role": "system", "content": [{"type": "text", "text": "..."}]}
|
|
14
|
+
# A placeholder — a slot where the caller injects a list of messages:
|
|
15
|
+
# {"type": "placeholder", "name": "chat_history"}
|
|
16
|
+
PromptMessage = Dict[str, Any]
|
|
17
|
+
PromptNode = Dict[str, Any]
|
|
18
|
+
ContentBlock = Dict[str, Any]
|
|
19
|
+
ModelConfig = Dict[str, Any]
|
|
20
|
+
PromptTool = Dict[str, Any]
|
|
21
|
+
ResponseFormat = Dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PromptVariable:
|
|
26
|
+
"""A declared variable.
|
|
27
|
+
|
|
28
|
+
Every competitor leaves variables undeclared and untyped — discovered by
|
|
29
|
+
regex, with no defaults and no required-ness. The declaration is what lets
|
|
30
|
+
``compile()`` fail before the model call rather than shipping a literal
|
|
31
|
+
``{{typo}}`` and finding out from the bill.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
type: str = "string"
|
|
36
|
+
required: bool = False
|
|
37
|
+
default: Any = None
|
|
38
|
+
description: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CompiledPrompt:
|
|
43
|
+
"""The result of compiling a prompt — ready to hand to a provider client."""
|
|
44
|
+
|
|
45
|
+
messages: List[PromptMessage]
|
|
46
|
+
model: ModelConfig = field(default_factory=dict)
|
|
47
|
+
tools: List[PromptTool] = field(default_factory=list)
|
|
48
|
+
response_format: Optional[ResponseFormat] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ManagedPrompt:
|
|
53
|
+
"""A managed prompt resolved from the Trodo registry."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
messages: List[PromptNode] = field(default_factory=list)
|
|
57
|
+
model_config: ModelConfig = field(default_factory=dict)
|
|
58
|
+
tools: List[PromptTool] = field(default_factory=list)
|
|
59
|
+
response_format: Optional[ResponseFormat] = None
|
|
60
|
+
variables: List[PromptVariable] = field(default_factory=list)
|
|
61
|
+
version: int = 0
|
|
62
|
+
labels: List[str] = field(default_factory=list)
|
|
63
|
+
tags: List[str] = field(default_factory=list)
|
|
64
|
+
description: Optional[str] = None
|
|
65
|
+
updated_at: Optional[str] = None
|
|
66
|
+
#: True when this came from the ``fallback`` argument because the API was
|
|
67
|
+
#: unreachable and nothing was cached.
|
|
68
|
+
is_fallback: bool = False
|
|
69
|
+
|
|
70
|
+
def compile(self, variables: Optional[Dict[str, Any]] = None, **kwargs: Any) -> CompiledPrompt:
|
|
71
|
+
"""Compile with variable values, as a dict or as keyword arguments."""
|
|
72
|
+
from .compile import compile_prompt # local import avoids a cycle
|
|
73
|
+
|
|
74
|
+
values = dict(variables or {})
|
|
75
|
+
values.update(kwargs)
|
|
76
|
+
return compile_prompt(self, values)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class PromptSummary:
|
|
81
|
+
"""Lightweight prompt entry returned by ``list_prompts()``."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
version: int = 0
|
|
85
|
+
labels: List[str] = field(default_factory=list)
|
|
86
|
+
description: Optional[str] = None
|
|
87
|
+
updated_at: Optional[str] = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trodo-python
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.12.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
|
|
@@ -438,6 +438,44 @@ Runnable scenarios that double as integration tests live in
|
|
|
438
438
|
|
|
439
439
|
---
|
|
440
440
|
|
|
441
|
+
## Prompt Management (v2.9.0+)
|
|
442
|
+
|
|
443
|
+
Author and version prompts in the Trodo dashboard, then fetch them at runtime so
|
|
444
|
+
your application never hard-codes prompt text. A deploy **label** (e.g.
|
|
445
|
+
`production`) points at one version; ship a new prompt by moving the label — no
|
|
446
|
+
redeploy.
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
import trodo
|
|
450
|
+
trodo.init(site_id="your-site-id")
|
|
451
|
+
|
|
452
|
+
# Latest version (default), a label, or a pinned version number:
|
|
453
|
+
prompt = trodo.get_prompt("refund-agent", label="production")
|
|
454
|
+
|
|
455
|
+
# Fill {{variables}} — unknown tokens are left intact so a missing value shows.
|
|
456
|
+
system = prompt.compile(company="Acme", customer_name="Ada")
|
|
457
|
+
|
|
458
|
+
resp = openai.chat.completions.create(
|
|
459
|
+
model=prompt.config.get("model", "gpt-4o-mini"),
|
|
460
|
+
temperature=prompt.config.get("temperature", 0.2),
|
|
461
|
+
messages=[{"role": "system", "content": system},
|
|
462
|
+
{"role": "user", "content": query}],
|
|
463
|
+
)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
`get_prompt()` returns a `ManagedPrompt` with `name`, `version`, `labels`,
|
|
467
|
+
`template`, `config`, `variables`, and a `.compile(**vars)` method. It raises
|
|
468
|
+
`LookupError` if the prompt can't be found (you can't run without it).
|
|
469
|
+
|
|
470
|
+
```python
|
|
471
|
+
trodo.get_prompt("refund-agent") # latest version
|
|
472
|
+
trodo.get_prompt("refund-agent", version=3) # pinned version
|
|
473
|
+
trodo.list_prompts() # [PromptSummary(name=..., labels=...), ...]
|
|
474
|
+
trodo.compile_prompt(template_or_prompt, {...}) # standalone {{var}} substitution
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
441
479
|
## Agent Analytics (legacy event-based API)
|
|
442
480
|
|
|
443
481
|
The older per-event API below is still supported but superseded by
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
trodo/__init__.py,sha256=
|
|
2
|
-
trodo/client.py,sha256=
|
|
1
|
+
trodo/__init__.py,sha256=F5asTaDmKcWtk6NJ_DVr4wo1S4HoYMWFiiyqHHDNSvE,21167
|
|
2
|
+
trodo/client.py,sha256=z2q1HZKQNENycv9CRD0kOfwEhmz3Ti9_B-uPsqpvY3U,19454
|
|
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
7
|
trodo/api/endpoints.py,sha256=HKQ3d_Mxf0y4HwlHor0XkSAwUVj-4Xvv--rzE9njxjM,1027
|
|
8
|
-
trodo/api/http_client.py,sha256=
|
|
8
|
+
trodo/api/http_client.py,sha256=gVYVqn-M4Mjd0zTjx_Zsr246BL_xvIHOa2w8YlpVvD4,7638
|
|
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
12
|
trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
|
|
13
13
|
trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
|
|
14
|
+
trodo/managers/prompt_manager.py,sha256=Ub7rlg3dVVllr6qBf6sXLGvunnP_7FLeGEOal6sYFPY,10448
|
|
14
15
|
trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
|
|
15
16
|
trodo/otel/auto_instrument.py,sha256=hJesQMOOp86U66PampcGNINmBVvwVuU_WHVrbUMauug,20895
|
|
16
17
|
trodo/otel/context.py,sha256=iJ1rE42-SbO8VZHAxhIl2ZJXgNwLIVps5xLg8GKgfFc,1165
|
|
@@ -19,13 +20,17 @@ trodo/otel/processor.py,sha256=aqcTmzTw9cESgIp829pu_XCa5_dG_2MaeJNsqJZeqQU,7495
|
|
|
19
20
|
trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
|
|
20
21
|
trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
|
|
21
22
|
trodo/otel/wrap_agent.py,sha256=_nFDhxPyl0RlNKj29cBNeRc_zAY5FUiHvTiNUWxTt0M,39049
|
|
23
|
+
trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
|
|
24
|
+
trodo/prompts/compile.py,sha256=Jqy1yaSE1j931KIHP1opMpEEmBKHv-I4BUq-1xaNVWA,6352
|
|
25
|
+
trodo/prompts/template.py,sha256=obQiivPCR6LEdz7q1cby7uL25tYHYui4aoEaiUNqWfs,9357
|
|
26
|
+
trodo/prompts/types.py,sha256=iCNoJk6_kk0JvEblGsDm6c9lEdQW6ixOOv6aCG6IP-k,2924
|
|
22
27
|
trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
28
|
trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
|
|
24
29
|
trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
|
|
25
30
|
trodo/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
31
|
trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrTjs,2136
|
|
27
32
|
trodo/session/session_manager.py,sha256=JrgH1VeicmtlxPR4dXEuJbxhi23OelkgwW3-9Slv80o,2525
|
|
28
|
-
trodo_python-2.
|
|
29
|
-
trodo_python-2.
|
|
30
|
-
trodo_python-2.
|
|
31
|
-
trodo_python-2.
|
|
33
|
+
trodo_python-2.12.0.dist-info/METADATA,sha256=pvC-YSCsk3GkCPdxnNGUkbgT-ZLBWIgO-MWLxqzzcrA,23115
|
|
34
|
+
trodo_python-2.12.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
35
|
+
trodo_python-2.12.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
|
|
36
|
+
trodo_python-2.12.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|