spanlight 0.1.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.
- spanlight/__init__.py +59 -0
- spanlight/_client.py +459 -0
- spanlight/_config.py +138 -0
- spanlight/_context.py +79 -0
- spanlight/_decorator.py +345 -0
- spanlight/_experiments.py +256 -0
- spanlight/_experiments_api.py +182 -0
- spanlight/_experiments_parse.py +68 -0
- spanlight/_experiments_report.py +100 -0
- spanlight/_experiments_runs.py +122 -0
- spanlight/_experiments_setup.py +85 -0
- spanlight/_exporter.py +462 -0
- spanlight/_globals.py +198 -0
- spanlight/_ids.py +45 -0
- spanlight/_prompt_client.py +241 -0
- spanlight/_prompts.py +247 -0
- spanlight/_request_hash.py +58 -0
- spanlight/_scores.py +57 -0
- spanlight/_serialize.py +126 -0
- spanlight/_span.py +337 -0
- spanlight/_templates.py +238 -0
- spanlight/_version.py +3 -0
- spanlight/integrations/__init__.py +5 -0
- spanlight/integrations/_common.py +422 -0
- spanlight/integrations/anthropic.py +349 -0
- spanlight/integrations/openai.py +356 -0
- spanlight/py.typed +0 -0
- spanlight-0.1.0.dist-info/METADATA +447 -0
- spanlight-0.1.0.dist-info/RECORD +31 -0
- spanlight-0.1.0.dist-info/WHEEL +4 -0
- spanlight-0.1.0.dist-info/licenses/LICENSE +21 -0
spanlight/_context.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Propagation of the active span through :mod:`contextvars`.
|
|
2
|
+
|
|
3
|
+
Using a :class:`~contextvars.ContextVar` means the current span follows the
|
|
4
|
+
natural flow of the program: nested function calls see their caller's span,
|
|
5
|
+
and :mod:`asyncio` tasks inherit the span that was active when they were
|
|
6
|
+
created (each task runs in a copy of its creator's context). Threads do not
|
|
7
|
+
inherit context automatically; use :func:`contextvars.copy_context` and
|
|
8
|
+
``ctx.run(...)`` when handing work to a thread pool.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from contextvars import ContextVar, Token
|
|
16
|
+
|
|
17
|
+
from ._span import Span
|
|
18
|
+
|
|
19
|
+
_current_span: ContextVar[Span | None] = ContextVar("spanlight_current_span", default=None)
|
|
20
|
+
_current_prompt_version_id: ContextVar[str | None] = ContextVar(
|
|
21
|
+
"spanlight_current_prompt_version_id", default=None
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_current_span() -> Span | None:
|
|
26
|
+
"""Return the span active in the current context, if any."""
|
|
27
|
+
return _current_span.get()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_current_trace_id() -> str | None:
|
|
31
|
+
"""Return the trace id of the active span, if any."""
|
|
32
|
+
span = _current_span.get()
|
|
33
|
+
return span.trace_id if span is not None else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextlib.contextmanager
|
|
37
|
+
def activate(span: Span) -> Iterator[Span]:
|
|
38
|
+
"""Make ``span`` the current span for the duration of the ``with`` block."""
|
|
39
|
+
token = _current_span.set(span)
|
|
40
|
+
try:
|
|
41
|
+
yield span
|
|
42
|
+
finally:
|
|
43
|
+
_current_span.reset(token)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def set_current_span(span: Span | None) -> Token[Span | None]:
|
|
47
|
+
"""Set the current span and return a token for :func:`restore_current_span`."""
|
|
48
|
+
return _current_span.set(span)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def restore_current_span(token: Token[Span | None], previous: Span | None) -> None:
|
|
52
|
+
"""Undo :func:`set_current_span`.
|
|
53
|
+
|
|
54
|
+
``ContextVar.reset`` refuses tokens created in a different context (for
|
|
55
|
+
example a ``with`` block entered in one task and exited in another). In
|
|
56
|
+
that case the previous span is restored by value instead of raising.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
_current_span.reset(token)
|
|
60
|
+
except ValueError:
|
|
61
|
+
_current_span.set(previous)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_current_prompt_version_id() -> str | None:
|
|
65
|
+
"""Return the prompt version id spans started now are linked to, if any."""
|
|
66
|
+
return _current_prompt_version_id.get()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_current_prompt_version_id(version_id: str | None) -> Token[str | None]:
|
|
70
|
+
"""Link spans started from now on to ``version_id``; returns a token to undo it."""
|
|
71
|
+
return _current_prompt_version_id.set(version_id)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def restore_current_prompt_version_id(token: Token[str | None], previous: str | None) -> None:
|
|
75
|
+
"""Undo :func:`set_current_prompt_version_id`, by value when the token is foreign."""
|
|
76
|
+
try:
|
|
77
|
+
_current_prompt_version_id.reset(token)
|
|
78
|
+
except ValueError:
|
|
79
|
+
_current_prompt_version_id.set(previous)
|
spanlight/_decorator.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Implementation of the ``@observe`` decorator.
|
|
2
|
+
|
|
3
|
+
The decorator supports four kinds of callables, each needing slightly
|
|
4
|
+
different treatment so that the span covers the *real* duration of the work:
|
|
5
|
+
|
|
6
|
+
* plain functions: the span covers the call;
|
|
7
|
+
* coroutine functions: the span covers the awaited coroutine;
|
|
8
|
+
* generator functions: the span stays open until the generator is exhausted,
|
|
9
|
+
closed or fails, and the current-span context is active only while the
|
|
10
|
+
generator body runs (never leaking into the consumer between items);
|
|
11
|
+
* async generator functions: same as generators, for ``async for``.
|
|
12
|
+
|
|
13
|
+
In every case user exceptions propagate unchanged, and failures inside the
|
|
14
|
+
SDK itself are swallowed so they cannot break the decorated function.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import inspect
|
|
21
|
+
import logging
|
|
22
|
+
from collections.abc import AsyncGenerator, Callable, Generator
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast
|
|
25
|
+
|
|
26
|
+
from ._context import get_current_span, restore_current_span, set_current_span
|
|
27
|
+
from ._serialize import MAX_ITEMS
|
|
28
|
+
from ._span import Span, SpanKind
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ._client import Spanlight
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("spanlight")
|
|
34
|
+
|
|
35
|
+
P = ParamSpec("P")
|
|
36
|
+
R = TypeVar("R")
|
|
37
|
+
|
|
38
|
+
_IMPLICIT_RECEIVERS = frozenset({"self", "cls"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class _ObserveOptions:
|
|
43
|
+
name: str
|
|
44
|
+
kind: SpanKind
|
|
45
|
+
capture_input: bool
|
|
46
|
+
capture_output: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_observe(
|
|
50
|
+
get_client: Callable[[], Spanlight],
|
|
51
|
+
name: Callable[P, R] | str | None,
|
|
52
|
+
*,
|
|
53
|
+
kind: SpanKind,
|
|
54
|
+
capture_input: bool,
|
|
55
|
+
capture_output: bool,
|
|
56
|
+
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
|
57
|
+
"""Shared implementation behind both ``observe`` entry points.
|
|
58
|
+
|
|
59
|
+
``name`` is either the span name (``@observe(name="answer")`` or
|
|
60
|
+
``@observe("answer")``), ``None`` (``@observe()``), or, for the bare
|
|
61
|
+
``@observe`` form, the decorated function itself.
|
|
62
|
+
"""
|
|
63
|
+
span_name = name if isinstance(name, str) else None
|
|
64
|
+
|
|
65
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
66
|
+
options = _ObserveOptions(
|
|
67
|
+
name=span_name or str(getattr(func, "__name__", "function")),
|
|
68
|
+
kind=kind,
|
|
69
|
+
capture_input=capture_input,
|
|
70
|
+
capture_output=capture_output,
|
|
71
|
+
)
|
|
72
|
+
return _decorate(func, get_client, options)
|
|
73
|
+
|
|
74
|
+
if callable(name):
|
|
75
|
+
return decorator(name)
|
|
76
|
+
return decorator
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _decorate(
|
|
80
|
+
func: Callable[P, R],
|
|
81
|
+
get_client: Callable[[], Spanlight],
|
|
82
|
+
options: _ObserveOptions,
|
|
83
|
+
) -> Callable[P, R]:
|
|
84
|
+
signature = _safe_signature(func)
|
|
85
|
+
|
|
86
|
+
def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Span | None:
|
|
87
|
+
"""Start the span for one call, or return ``None`` to run untraced."""
|
|
88
|
+
try:
|
|
89
|
+
client = get_client()
|
|
90
|
+
if not client.enabled:
|
|
91
|
+
return None
|
|
92
|
+
span = client.start_span(options.name, kind=options.kind)
|
|
93
|
+
if options.capture_input:
|
|
94
|
+
span.set_input(_bind_arguments(signature, args, kwargs))
|
|
95
|
+
return span
|
|
96
|
+
except Exception:
|
|
97
|
+
logger.debug("observe: failed to start span %r", options.name, exc_info=True)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
if inspect.isasyncgenfunction(func):
|
|
101
|
+
return cast("Callable[P, R]", _wrap_async_generator(func, start, options))
|
|
102
|
+
if inspect.isgeneratorfunction(func):
|
|
103
|
+
return cast("Callable[P, R]", _wrap_generator(func, start, options))
|
|
104
|
+
if inspect.iscoroutinefunction(func):
|
|
105
|
+
return cast("Callable[P, R]", _wrap_coroutine(func, start, options))
|
|
106
|
+
return _wrap_function(func, start, options)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_Starter = Callable[[tuple[Any, ...], dict[str, Any]], Span | None]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _wrap_function(
|
|
113
|
+
func: Callable[P, R],
|
|
114
|
+
start: _Starter,
|
|
115
|
+
options: _ObserveOptions,
|
|
116
|
+
) -> Callable[P, R]:
|
|
117
|
+
@functools.wraps(func)
|
|
118
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
119
|
+
span = start(args, kwargs)
|
|
120
|
+
if span is None:
|
|
121
|
+
return func(*args, **kwargs)
|
|
122
|
+
|
|
123
|
+
previous = get_current_span()
|
|
124
|
+
token = set_current_span(span)
|
|
125
|
+
try:
|
|
126
|
+
result = func(*args, **kwargs)
|
|
127
|
+
except BaseException as exc:
|
|
128
|
+
_finish(span, error=exc)
|
|
129
|
+
raise
|
|
130
|
+
finally:
|
|
131
|
+
restore_current_span(token, previous)
|
|
132
|
+
_finish(span, output=result, capture_output=options.capture_output)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
return wrapper
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _wrap_coroutine(
|
|
139
|
+
func: Callable[..., Any],
|
|
140
|
+
start: _Starter,
|
|
141
|
+
options: _ObserveOptions,
|
|
142
|
+
) -> Callable[..., Any]:
|
|
143
|
+
@functools.wraps(func)
|
|
144
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
145
|
+
span = start(args, kwargs)
|
|
146
|
+
if span is None:
|
|
147
|
+
return await func(*args, **kwargs)
|
|
148
|
+
|
|
149
|
+
previous = get_current_span()
|
|
150
|
+
token = set_current_span(span)
|
|
151
|
+
try:
|
|
152
|
+
result = await func(*args, **kwargs)
|
|
153
|
+
except BaseException as exc:
|
|
154
|
+
_finish(span, error=exc)
|
|
155
|
+
raise
|
|
156
|
+
finally:
|
|
157
|
+
restore_current_span(token, previous)
|
|
158
|
+
_finish(span, output=result, capture_output=options.capture_output)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
return wrapper
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _wrap_generator(
|
|
165
|
+
func: Callable[..., Generator[Any, Any, Any]],
|
|
166
|
+
start: _Starter,
|
|
167
|
+
options: _ObserveOptions,
|
|
168
|
+
) -> Callable[..., Generator[Any, Any, Any]]:
|
|
169
|
+
@functools.wraps(func)
|
|
170
|
+
def wrapper(*args: Any, **kwargs: Any) -> Generator[Any, Any, Any]:
|
|
171
|
+
span = start(args, kwargs)
|
|
172
|
+
generator = func(*args, **kwargs)
|
|
173
|
+
if span is None:
|
|
174
|
+
return generator
|
|
175
|
+
return _traced_generator(generator, span, options)
|
|
176
|
+
|
|
177
|
+
return wrapper
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _traced_generator(
|
|
181
|
+
generator: Generator[Any, Any, Any],
|
|
182
|
+
span: Span,
|
|
183
|
+
options: _ObserveOptions,
|
|
184
|
+
) -> Generator[Any, Any, Any]:
|
|
185
|
+
"""Drive ``generator`` step by step with ``span`` active only during each step.
|
|
186
|
+
|
|
187
|
+
``send()``, ``throw()`` and ``close()`` from the consumer are forwarded
|
|
188
|
+
to the inner generator, so the wrapper is transparent.
|
|
189
|
+
"""
|
|
190
|
+
collected = _OutputCollector(enabled=options.capture_output)
|
|
191
|
+
step: Callable[[], Any] = functools.partial(generator.send, None)
|
|
192
|
+
while True:
|
|
193
|
+
previous = get_current_span()
|
|
194
|
+
token = set_current_span(span)
|
|
195
|
+
try:
|
|
196
|
+
item = step()
|
|
197
|
+
except StopIteration as stop:
|
|
198
|
+
_finish(
|
|
199
|
+
span, output=collected.result(stop.value), capture_output=options.capture_output
|
|
200
|
+
)
|
|
201
|
+
return stop.value
|
|
202
|
+
except BaseException as exc:
|
|
203
|
+
_finish(span, error=exc)
|
|
204
|
+
raise
|
|
205
|
+
finally:
|
|
206
|
+
restore_current_span(token, previous)
|
|
207
|
+
|
|
208
|
+
collected.add(item)
|
|
209
|
+
try:
|
|
210
|
+
sent = yield item
|
|
211
|
+
except GeneratorExit:
|
|
212
|
+
try:
|
|
213
|
+
generator.close()
|
|
214
|
+
finally:
|
|
215
|
+
_finish(span, output=collected.result(None), capture_output=options.capture_output)
|
|
216
|
+
raise
|
|
217
|
+
except BaseException as exc:
|
|
218
|
+
step = functools.partial(generator.throw, exc)
|
|
219
|
+
else:
|
|
220
|
+
step = functools.partial(generator.send, sent)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _wrap_async_generator(
|
|
224
|
+
func: Callable[..., AsyncGenerator[Any, Any]],
|
|
225
|
+
start: _Starter,
|
|
226
|
+
options: _ObserveOptions,
|
|
227
|
+
) -> Callable[..., AsyncGenerator[Any, Any]]:
|
|
228
|
+
@functools.wraps(func)
|
|
229
|
+
def wrapper(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any]:
|
|
230
|
+
span = start(args, kwargs)
|
|
231
|
+
generator = func(*args, **kwargs)
|
|
232
|
+
if span is None:
|
|
233
|
+
return generator
|
|
234
|
+
return _traced_async_generator(generator, span, options)
|
|
235
|
+
|
|
236
|
+
return wrapper
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def _traced_async_generator(
|
|
240
|
+
generator: AsyncGenerator[Any, Any],
|
|
241
|
+
span: Span,
|
|
242
|
+
options: _ObserveOptions,
|
|
243
|
+
) -> AsyncGenerator[Any, Any]:
|
|
244
|
+
"""Async counterpart of :func:`_traced_generator`."""
|
|
245
|
+
collected = _OutputCollector(enabled=options.capture_output)
|
|
246
|
+
step: Callable[[], Any] = functools.partial(generator.asend, None)
|
|
247
|
+
while True:
|
|
248
|
+
previous = get_current_span()
|
|
249
|
+
token = set_current_span(span)
|
|
250
|
+
try:
|
|
251
|
+
item = await step()
|
|
252
|
+
except StopAsyncIteration:
|
|
253
|
+
_finish(span, output=collected.result(None), capture_output=options.capture_output)
|
|
254
|
+
return
|
|
255
|
+
except BaseException as exc:
|
|
256
|
+
_finish(span, error=exc)
|
|
257
|
+
raise
|
|
258
|
+
finally:
|
|
259
|
+
restore_current_span(token, previous)
|
|
260
|
+
|
|
261
|
+
collected.add(item)
|
|
262
|
+
try:
|
|
263
|
+
sent = yield item
|
|
264
|
+
except GeneratorExit:
|
|
265
|
+
try:
|
|
266
|
+
await generator.aclose()
|
|
267
|
+
finally:
|
|
268
|
+
_finish(span, output=collected.result(None), capture_output=options.capture_output)
|
|
269
|
+
raise
|
|
270
|
+
except BaseException as exc:
|
|
271
|
+
step = functools.partial(generator.athrow, exc)
|
|
272
|
+
else:
|
|
273
|
+
step = functools.partial(generator.asend, sent)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class _OutputCollector:
|
|
277
|
+
"""Accumulates yielded items (bounded) to use as a generator span's output."""
|
|
278
|
+
|
|
279
|
+
def __init__(self, *, enabled: bool) -> None:
|
|
280
|
+
self._enabled = enabled
|
|
281
|
+
self._items: list[Any] = []
|
|
282
|
+
self.truncated = False
|
|
283
|
+
|
|
284
|
+
def add(self, item: Any) -> None:
|
|
285
|
+
if not self._enabled:
|
|
286
|
+
return
|
|
287
|
+
if len(self._items) < MAX_ITEMS:
|
|
288
|
+
self._items.append(item)
|
|
289
|
+
else:
|
|
290
|
+
self.truncated = True
|
|
291
|
+
|
|
292
|
+
def result(self, return_value: Any) -> Any:
|
|
293
|
+
"""Return the items, joined into one string when they are all strings."""
|
|
294
|
+
if not self._enabled:
|
|
295
|
+
return None
|
|
296
|
+
if self._items and all(isinstance(item, str) for item in self._items):
|
|
297
|
+
return "".join(self._items)
|
|
298
|
+
if not self._items and return_value is not None:
|
|
299
|
+
return return_value
|
|
300
|
+
return list(self._items)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _finish(
|
|
304
|
+
span: Span,
|
|
305
|
+
*,
|
|
306
|
+
output: Any = None,
|
|
307
|
+
capture_output: bool = False,
|
|
308
|
+
error: BaseException | None = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""End ``span`` recording either its output or the error. Never raises."""
|
|
311
|
+
try:
|
|
312
|
+
if error is not None:
|
|
313
|
+
if not isinstance(error, GeneratorExit):
|
|
314
|
+
span.record_error(error)
|
|
315
|
+
elif capture_output:
|
|
316
|
+
span.set_output(output)
|
|
317
|
+
span.end()
|
|
318
|
+
except Exception:
|
|
319
|
+
logger.debug("observe: failed to finish span", exc_info=True)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _safe_signature(func: Callable[..., Any]) -> inspect.Signature | None:
|
|
323
|
+
try:
|
|
324
|
+
return inspect.signature(func)
|
|
325
|
+
except (TypeError, ValueError):
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _bind_arguments(
|
|
330
|
+
signature: inspect.Signature | None,
|
|
331
|
+
args: tuple[Any, ...],
|
|
332
|
+
kwargs: dict[str, Any],
|
|
333
|
+
) -> dict[str, Any]:
|
|
334
|
+
"""Map call arguments to parameter names, omitting ``self``/``cls``."""
|
|
335
|
+
if signature is None:
|
|
336
|
+
return {"args": list(args), "kwargs": kwargs}
|
|
337
|
+
try:
|
|
338
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
339
|
+
except TypeError:
|
|
340
|
+
return {"args": list(args), "kwargs": kwargs}
|
|
341
|
+
arguments = dict(bound.arguments)
|
|
342
|
+
parameters = list(signature.parameters)
|
|
343
|
+
if parameters and parameters[0] in _IMPLICIT_RECEIVERS:
|
|
344
|
+
arguments.pop(parameters[0], None)
|
|
345
|
+
return arguments
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Run a prompt over a dataset on the server and gate on the scores.
|
|
2
|
+
|
|
3
|
+
Use it as ``python -m spanlight._experiments``.
|
|
4
|
+
|
|
5
|
+
The runner talks to the dashboard API (``/api/v1``) with a personal access token. It finds the
|
|
6
|
+
dataset, prompt version and evaluators by name, reuses or creates the experiment for that
|
|
7
|
+
combination, starts a run and polls it until it ends. Exit codes: 0 every threshold held, 1 a
|
|
8
|
+
score fell below its ``--fail-below`` threshold, 2 the run could not be started, failed or timed
|
|
9
|
+
out. The ``eval-gate`` GitHub Action wraps this module.
|
|
10
|
+
|
|
11
|
+
The token is read from ``SPANLIGHT_TOKEN`` and never printed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
21
|
+
from decimal import Decimal, InvalidOperation
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from ._experiments_api import (
|
|
27
|
+
Api,
|
|
28
|
+
ExperimentError,
|
|
29
|
+
ResolvedPrompt,
|
|
30
|
+
resolve_dataset,
|
|
31
|
+
resolve_evaluators,
|
|
32
|
+
resolve_prompt,
|
|
33
|
+
)
|
|
34
|
+
from ._experiments_parse import PromptRef, parse_fail_below, parse_prompt_ref
|
|
35
|
+
from ._experiments_report import render_markdown
|
|
36
|
+
from ._experiments_runs import (
|
|
37
|
+
ExperimentOutcome,
|
|
38
|
+
org_of,
|
|
39
|
+
outcome_of,
|
|
40
|
+
start_run,
|
|
41
|
+
wait,
|
|
42
|
+
wait_idle,
|
|
43
|
+
)
|
|
44
|
+
from ._experiments_setup import experiment_for, normalize_credential_id
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"ExperimentError",
|
|
48
|
+
"ExperimentOutcome",
|
|
49
|
+
"PromptRef",
|
|
50
|
+
"main",
|
|
51
|
+
"parse_fail_below",
|
|
52
|
+
"parse_prompt_ref",
|
|
53
|
+
"run_experiment",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
EXIT_PASSED = 0
|
|
57
|
+
EXIT_THRESHOLD = 1
|
|
58
|
+
EXIT_FAILED = 2
|
|
59
|
+
_MAX_COST_CEILING = Decimal(1000)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _prepare(
|
|
63
|
+
api: Api,
|
|
64
|
+
dataset: str,
|
|
65
|
+
prompt: str,
|
|
66
|
+
evaluators: list[str],
|
|
67
|
+
credential_id: str,
|
|
68
|
+
model: str,
|
|
69
|
+
max_cost_usd: Decimal | None,
|
|
70
|
+
) -> tuple[dict[str, Any], ResolvedPrompt]:
|
|
71
|
+
"""Resolve the names to ids and find or create the experiment."""
|
|
72
|
+
dataset_row = resolve_dataset(api, dataset)
|
|
73
|
+
version = resolve_prompt(api, parse_prompt_ref(prompt))
|
|
74
|
+
experiment = experiment_for(
|
|
75
|
+
api,
|
|
76
|
+
dataset_row,
|
|
77
|
+
version,
|
|
78
|
+
evaluators,
|
|
79
|
+
resolve_evaluators(api, evaluators),
|
|
80
|
+
credential_id=credential_id,
|
|
81
|
+
model=model,
|
|
82
|
+
max_cost_usd=max_cost_usd,
|
|
83
|
+
)
|
|
84
|
+
return experiment, version
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_experiment(
|
|
88
|
+
*,
|
|
89
|
+
host: str,
|
|
90
|
+
token: str,
|
|
91
|
+
project_id: str,
|
|
92
|
+
dataset: str,
|
|
93
|
+
prompt: str,
|
|
94
|
+
evaluators: list[str],
|
|
95
|
+
fail_below: dict[str, Decimal],
|
|
96
|
+
credential_id: str,
|
|
97
|
+
model: str,
|
|
98
|
+
max_cost_usd: Decimal | None = None,
|
|
99
|
+
timeout_s: int = 1800,
|
|
100
|
+
poll_s: float = 5,
|
|
101
|
+
transport: httpx.BaseTransport | None = None,
|
|
102
|
+
environ: Mapping[str, str] | None = None,
|
|
103
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
104
|
+
clock: Callable[[], float] = time.monotonic,
|
|
105
|
+
) -> ExperimentOutcome:
|
|
106
|
+
"""Run ``prompt`` (``name``, ``name@label`` or ``name:version``) over ``dataset`` and score it.
|
|
107
|
+
|
|
108
|
+
``credential_id`` and ``model`` choose the provider the prompt runs on and, with the
|
|
109
|
+
evaluators, which experiment is used (a different combination is a different experiment).
|
|
110
|
+
``max_cost_usd`` caps a run and applies only when the experiment is created. The whole call,
|
|
111
|
+
including waiting for an earlier run of the same experiment, is bounded by ``timeout_s``.
|
|
112
|
+
Raises :class:`ExperimentError` when the run cannot be set up; a run that fails or times out
|
|
113
|
+
is returned with that ``status``.
|
|
114
|
+
"""
|
|
115
|
+
env = os.environ if environ is None else environ
|
|
116
|
+
credential_id = normalize_credential_id(credential_id)
|
|
117
|
+
api = Api(host=host, token=token, project_id=project_id, transport=transport)
|
|
118
|
+
try:
|
|
119
|
+
deadline = clock() + timeout_s
|
|
120
|
+
experiment, version = _prepare(
|
|
121
|
+
api, dataset, prompt, evaluators, credential_id, model, max_cost_usd
|
|
122
|
+
)
|
|
123
|
+
url = f"{host.rstrip('/')}/{org_of(api)}/{project_id}/experiments/{experiment['id']}"
|
|
124
|
+
wait_idle(api, experiment["id"], deadline, poll_s, sleep, clock)
|
|
125
|
+
run = start_run(api, experiment["id"], version.version_id, env)
|
|
126
|
+
try:
|
|
127
|
+
final = wait(api, experiment["id"], run["id"], deadline, poll_s, sleep, clock)
|
|
128
|
+
except ExperimentError as error:
|
|
129
|
+
# The run exists: report it with its id rather than lose it.
|
|
130
|
+
final = {"status": "failed", "error": str(error), "totals": None}
|
|
131
|
+
finally:
|
|
132
|
+
api.close()
|
|
133
|
+
title = f"{dataset} / {version.name} v{version.version}"
|
|
134
|
+
return outcome_of(final, run["id"], url, title, timeout_s, fail_below)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _poll_seconds(raw: str) -> float:
|
|
138
|
+
"""``--poll``: at least one second, so the gate cannot hammer the server."""
|
|
139
|
+
value = float(raw)
|
|
140
|
+
if value < 1:
|
|
141
|
+
raise argparse.ArgumentTypeError("must be at least 1 second")
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _max_cost(raw: str) -> Decimal:
|
|
146
|
+
"""``--max-cost-usd``: a positive amount of at most the server's ceiling of 1000."""
|
|
147
|
+
try:
|
|
148
|
+
value = Decimal(raw)
|
|
149
|
+
except InvalidOperation:
|
|
150
|
+
raise argparse.ArgumentTypeError("must be a number") from None
|
|
151
|
+
if not value.is_finite() or not 0 < value <= _MAX_COST_CEILING:
|
|
152
|
+
raise argparse.ArgumentTypeError("must be above 0 and at most 1000")
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parser() -> argparse.ArgumentParser:
|
|
157
|
+
parser = argparse.ArgumentParser(
|
|
158
|
+
prog="python -m spanlight._experiments",
|
|
159
|
+
description="Run a prompt over a dataset and fail when a score is below its threshold. "
|
|
160
|
+
"The access token is read from SPANLIGHT_TOKEN.",
|
|
161
|
+
)
|
|
162
|
+
env = os.environ.get
|
|
163
|
+
parser.add_argument("--host", default=env("SPANLIGHT_HOST"), help="Spanlight URL (https)")
|
|
164
|
+
parser.add_argument("--project-id", default=env("SPANLIGHT_PROJECT_ID"))
|
|
165
|
+
parser.add_argument("--dataset", required=True, help="dataset name")
|
|
166
|
+
parser.add_argument("--prompt", required=True, help="name, name@label or name:version")
|
|
167
|
+
parser.add_argument("--evaluators", default="", help="comma-separated evaluator names")
|
|
168
|
+
parser.add_argument("--fail-below", default="", help="e.g. relevance=0.8,format=1")
|
|
169
|
+
parser.add_argument("--credential-id", default=None, help="provider credential to run on")
|
|
170
|
+
parser.add_argument("--model", default=None, help="model to run the prompt on")
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--max-cost-usd", type=_max_cost, default=None, help="run cost cap (new experiments)"
|
|
173
|
+
)
|
|
174
|
+
parser.add_argument("--timeout", type=int, default=1800, help="seconds to wait in total")
|
|
175
|
+
parser.add_argument("--poll", type=_poll_seconds, default=5.0, help="seconds between polls")
|
|
176
|
+
return parser
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _publish(outcome: ExperimentOutcome, markdown: str) -> None:
|
|
180
|
+
"""Print the report; inside GitHub Actions also fill the step summary and step outputs."""
|
|
181
|
+
print(markdown)
|
|
182
|
+
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
183
|
+
if summary:
|
|
184
|
+
with open(summary, "a", encoding="utf-8") as handle:
|
|
185
|
+
handle.write(markdown + "\n")
|
|
186
|
+
outputs = os.environ.get("GITHUB_OUTPUT")
|
|
187
|
+
if outputs:
|
|
188
|
+
with open(outputs, "a", encoding="utf-8") as handle:
|
|
189
|
+
handle.write(f"run-id={outcome.run_id}\nrun-url={outcome.url}\n")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _missing_settings(args: argparse.Namespace, token: str | None) -> list[str]:
|
|
193
|
+
"""What the caller left out, as messages."""
|
|
194
|
+
checks = (
|
|
195
|
+
("--host or SPANLIGHT_HOST is required", args.host),
|
|
196
|
+
("--project-id or SPANLIGHT_PROJECT_ID is required", args.project_id),
|
|
197
|
+
("--credential-id is required", args.credential_id),
|
|
198
|
+
("--model is required", args.model),
|
|
199
|
+
("SPANLIGHT_TOKEN is required", token),
|
|
200
|
+
)
|
|
201
|
+
return [message for message, value in checks if not value]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _gate(args: argparse.Namespace, token: str) -> int:
|
|
205
|
+
"""Run the experiment, publish the report and return the exit code."""
|
|
206
|
+
fail_below = parse_fail_below(args.fail_below)
|
|
207
|
+
outcome = run_experiment(
|
|
208
|
+
host=args.host,
|
|
209
|
+
token=token,
|
|
210
|
+
project_id=args.project_id,
|
|
211
|
+
dataset=args.dataset,
|
|
212
|
+
prompt=args.prompt,
|
|
213
|
+
evaluators=[name.strip() for name in args.evaluators.split(",") if name.strip()],
|
|
214
|
+
fail_below=fail_below,
|
|
215
|
+
credential_id=args.credential_id,
|
|
216
|
+
model=args.model,
|
|
217
|
+
max_cost_usd=args.max_cost_usd,
|
|
218
|
+
timeout_s=args.timeout,
|
|
219
|
+
poll_s=args.poll,
|
|
220
|
+
)
|
|
221
|
+
report = render_markdown(
|
|
222
|
+
title=outcome.title,
|
|
223
|
+
status=outcome.status,
|
|
224
|
+
error=outcome.error,
|
|
225
|
+
url=outcome.url,
|
|
226
|
+
totals=outcome.totals,
|
|
227
|
+
fail_below=fail_below,
|
|
228
|
+
failures=outcome.failures,
|
|
229
|
+
)
|
|
230
|
+
_publish(outcome, report)
|
|
231
|
+
if outcome.status != "done":
|
|
232
|
+
return EXIT_FAILED
|
|
233
|
+
return EXIT_PASSED if outcome.passed else EXIT_THRESHOLD
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
237
|
+
"""Run the gate. Returns the exit code: 0 passed, 1 a threshold missed, 2 anything else."""
|
|
238
|
+
args = _parser().parse_args(argv)
|
|
239
|
+
token = os.environ.get("SPANLIGHT_TOKEN")
|
|
240
|
+
problems = _missing_settings(args, token)
|
|
241
|
+
if problems:
|
|
242
|
+
print("error: " + "; ".join(problems), file=sys.stderr)
|
|
243
|
+
return EXIT_FAILED
|
|
244
|
+
try:
|
|
245
|
+
return _gate(args, token or "")
|
|
246
|
+
except (ExperimentError, ValueError) as error:
|
|
247
|
+
print(f"error: {error}", file=sys.stderr)
|
|
248
|
+
except Exception as error:
|
|
249
|
+
# Exit 1 means "a score fell below its threshold", so a bug or an answer of an
|
|
250
|
+
# unexpected shape must not look like one. The type only: a repr could carry a header.
|
|
251
|
+
print(f"error: unexpected {type(error).__name__}", file=sys.stderr)
|
|
252
|
+
return EXIT_FAILED
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
sys.exit(main())
|