obzerve 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.
obzerve/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Obzerve Python SDK: trace AI-agent workflows with OpenTelemetry."""
2
+
3
+ from obzerve.attributes import SpanKind
4
+ from obzerve.delivery import DeliveryStats
5
+ from obzerve.observer import Observer, ObzerveSpan, __version__
6
+
7
+ __all__ = ["DeliveryStats", "Observer", "ObzerveSpan", "SpanKind", "__version__"]
obzerve/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Obzerve SDK version (single source of truth for the package metadata)."""
2
+
3
+ __version__ = "0.1.0"
obzerve/attributes.py ADDED
@@ -0,0 +1,61 @@
1
+ """Span attribute keys shared by the SDK and the Obzerve server.
2
+
3
+ AI-specific keys follow the OpenTelemetry GenAI semantic conventions where one
4
+ exists; Obzerve-specific keys live under the ``obzerve.`` namespace.
5
+ """
6
+
7
+ # Obzerve
8
+ SPAN_KIND = "obzerve.span.kind"
9
+ RETRY_COUNT = "obzerve.retry.count"
10
+ RETRY_ATTEMPT = "obzerve.retry.attempt"
11
+ INPUT = "obzerve.input"
12
+ OUTPUT = "obzerve.output"
13
+ INPUT_TRUNCATED = "obzerve.input.truncated"
14
+ OUTPUT_TRUNCATED = "obzerve.output.truncated"
15
+ INPUT_REDACTED = "obzerve.input.redacted"
16
+ SDK_VERSION = "obzerve.sdk.version"
17
+
18
+ # Replay: recording (set on replayable operations)
19
+ REPLAY_KEY = "obzerve.replay.key" # "<name>#<occurrence>" within the trace
20
+ REPLAY_INPUT_HASH = "obzerve.replay.input_hash"
21
+ REPLAY_COMPLETE = "obzerve.replay.complete" # the recorded output round-trips exactly
22
+ REPLAY_INCOMPLETE_REASON = "obzerve.replay.incomplete_reason"
23
+ REPLAY_SIDE_EFFECT = "obzerve.replay.side_effect"
24
+ REPLAY_ERROR_CLASS = "obzerve.replay.error_class" # "module:QualName" of a raised exception
25
+ # Replay: replaying
26
+ REPLAY_SOURCE = "obzerve.replay.source" # "live" or "recorded"
27
+ REPLAY_INPUT_MATCHED = "obzerve.replay.input_matched"
28
+ REPLAY_OF = "obzerve.replay.of" # on the root span: the trace being replayed
29
+ REPLAY_MODE = "obzerve.replay.mode"
30
+ REPLAY_LABEL = "obzerve.replay.label"
31
+
32
+ # Versioned configuration and evaluation (root span)
33
+ PROMPT_NAME = "obzerve.prompt.name"
34
+ PROMPT_VERSION = "obzerve.prompt.version"
35
+ EVAL_RUN_ID = "obzerve.eval.run_id"
36
+ EVAL_CASE_ID = "obzerve.eval.case_id"
37
+
38
+ # OpenTelemetry GenAI semantic conventions
39
+ GEN_AI_PROVIDER = "gen_ai.provider.name"
40
+ GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
41
+ GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"
42
+ GEN_AI_INPUT_TOKENS = "gen_ai.usage.input_tokens"
43
+ GEN_AI_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
44
+ # Input tokens served from the provider's prompt cache (a subset of input tokens).
45
+ GEN_AI_CACHED_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
46
+
47
+ # OpenTelemetry general conventions
48
+ ERROR_TYPE = "error.type"
49
+ SERVICE_NAME = "service.name"
50
+
51
+
52
+ class SpanKind:
53
+ """Logical operation types understood by the dashboard."""
54
+
55
+ AGENT = "agent"
56
+ LLM = "llm"
57
+ TOOL = "tool"
58
+ RETRIEVAL = "retrieval"
59
+ CHAIN = "chain"
60
+ HANDOFF = "handoff"
61
+ FUNCTION = "function"
obzerve/cli.py ADDED
@@ -0,0 +1,188 @@
1
+ """``obzerve`` command line: turn traces into regression cases, compare eval runs,
2
+ and gate releases in CI (exit code 1 when the gate fails).
3
+
4
+ Reads ``OBZERVE_ENDPOINT`` and ``OBZERVE_API_KEY`` from the environment.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sys
13
+
14
+ from obzerve.client import ObzerveAPIError, ObzerveClient
15
+
16
+
17
+ def _client(args: argparse.Namespace) -> ObzerveClient:
18
+ return ObzerveClient(args.endpoint, args.api_key)
19
+
20
+
21
+ def _pct(value: float | None) -> str:
22
+ return "–" if value is None else f"{value:+.1f}%"
23
+
24
+
25
+ def _money(value: float | None) -> str:
26
+ return "–" if value is None else f"${value:.6f}"
27
+
28
+
29
+ def cmd_case_add(args: argparse.Namespace) -> int:
30
+ assertions: list[dict] = []
31
+ if args.no_error:
32
+ assertions.append({"type": "no_error"})
33
+ if args.contains_all:
34
+ assertions.append({"type": "contains_all", "values": args.contains_all})
35
+ if args.contains_any:
36
+ assertions.append({"type": "contains_any", "values": args.contains_any})
37
+ if args.not_contains:
38
+ assertions.append({"type": "not_contains", "values": args.not_contains})
39
+ if args.regex:
40
+ assertions.append({"type": "regex", "pattern": args.regex})
41
+ if args.max_latency_ms:
42
+ assertions.append({"type": "max_latency_ms", "value": args.max_latency_ms})
43
+ for raw in args.assertion or []:
44
+ assertions.append(json.loads(raw))
45
+ if not assertions:
46
+ print("add at least one assertion (e.g. --no-error)", file=sys.stderr)
47
+ return 2
48
+ case = _client(args).create_case(
49
+ args.dataset, args.trace_id, args.name, assertions, args.notes, args.mode
50
+ )
51
+ print(
52
+ f"case {case['id']} added to {args.dataset!r} ({case['replayability']}, "
53
+ f"{case['recordings']} recorded operations, replayed {case['replay_policy']})"
54
+ )
55
+ for reason in case["replay_reasons"]:
56
+ print(f" note: {reason}")
57
+ return 0
58
+
59
+
60
+ def _print_comparison(cmp: dict) -> None:
61
+ b, c = cmp["baseline"], cmp["candidate"]
62
+ rows = [
63
+ ("label", b["label"], c["label"]),
64
+ ("prompt", _prompt(b), _prompt(c)),
65
+ (
66
+ "pass rate",
67
+ f"{b['pass_rate']:.0%} ({b['passed']}/{b['cases']})",
68
+ f"{c['pass_rate']:.0%} ({c['passed']}/{c['cases']})",
69
+ ),
70
+ (
71
+ "est. cost / case",
72
+ _money(b["estimated_cost_per_case_usd"]),
73
+ _money(c["estimated_cost_per_case_usd"]),
74
+ ),
75
+ (
76
+ "output tokens / case",
77
+ _num(b["output_tokens_per_case"]),
78
+ _num(c["output_tokens_per_case"]),
79
+ ),
80
+ ("p95 latency", _ms(b["latency_p95_ms"]), _ms(c["latency_p95_ms"])),
81
+ ]
82
+ width = max(len(r[1]) for r in rows) + 2
83
+ print(f"{'':22}{'baseline':{width}}candidate")
84
+ for name, x, y in rows:
85
+ print(f"{name:22}{x:{width}}{y}")
86
+ print(
87
+ f"\nregressions: {cmp['regressions']} fixes: {cmp['fixes']} "
88
+ f"cost/case {_pct(cmp['cost_per_case_delta_pct'])} "
89
+ f"p95 {_pct(cmp['latency_p95_delta_pct'])}"
90
+ )
91
+ if not cmp["comparable"]:
92
+ print("NOT COMPARABLE: " + "; ".join(cmp["reasons"]))
93
+ for case in cmp["cases"]:
94
+ if case["change"] in ("regression", "fix", "still_failing"):
95
+ why = f" — {'; '.join(case['failed_assertions'])}" if case["failed_assertions"] else ""
96
+ print(f" {case['change']:13} {case['case_name']}{why}")
97
+
98
+
99
+ def _prompt(run: dict) -> str:
100
+ p = run.get("prompt")
101
+ return f"{p['name']} v{p['version']}" if p else "–"
102
+
103
+
104
+ def _num(v: float | None) -> str:
105
+ return "–" if v is None else f"{v:.0f}"
106
+
107
+
108
+ def _ms(v: float | None) -> str:
109
+ return "–" if v is None else f"{v:.0f} ms"
110
+
111
+
112
+ def cmd_compare(args: argparse.Namespace) -> int:
113
+ _print_comparison(_client(args).compare_runs(args.baseline, args.candidate))
114
+ return 0
115
+
116
+
117
+ def cmd_gate(args: argparse.Namespace) -> int:
118
+ result = _client(args).gate(
119
+ args.baseline,
120
+ args.candidate,
121
+ max_regressions=args.max_regressions,
122
+ min_pass_rate=args.min_pass_rate,
123
+ max_pass_rate_drop=args.max_pass_rate_drop,
124
+ max_cost_increase_pct=args.max_cost_increase_pct,
125
+ max_p95_latency_increase_pct=args.max_p95_latency_increase_pct,
126
+ )
127
+ _print_comparison(result["comparison"])
128
+ print()
129
+ for check in result["checks"]:
130
+ print(f" [{'PASS' if check['passed'] else 'FAIL'}] {check['name']}: {check['detail']}")
131
+ print("\nGATE " + ("PASSED" if result["passed"] else "FAILED"))
132
+ return 0 if result["passed"] else 1
133
+
134
+
135
+ def main(argv: list[str] | None = None) -> int:
136
+ parser = argparse.ArgumentParser(prog="obzerve", description=__doc__.split("\n\n")[0])
137
+ parser.add_argument(
138
+ "--endpoint", default=os.environ.get("OBZERVE_ENDPOINT", "http://localhost:8000")
139
+ )
140
+ parser.add_argument("--api-key", default=os.environ.get("OBZERVE_API_KEY"))
141
+ sub = parser.add_subparsers(dest="command", required=True)
142
+
143
+ case = sub.add_parser("case", help="Regression cases").add_subparsers(dest="sub", required=True)
144
+ add = case.add_parser("add", help="Turn a trace into a regression case")
145
+ add.add_argument("trace_id")
146
+ add.add_argument("--dataset", required=True)
147
+ add.add_argument("--name", required=True)
148
+ add.add_argument("--notes")
149
+ add.add_argument(
150
+ "--mode",
151
+ choices=["recorded", "llm-live", "live"],
152
+ default="llm-live",
153
+ help="recorded: re-run the recorded failure exactly; llm-live: model live (default)",
154
+ )
155
+ add.add_argument("--no-error", action="store_true", help="the run must not raise")
156
+ add.add_argument("--contains-all", nargs="+", metavar="TERM")
157
+ add.add_argument("--contains-any", nargs="+", metavar="TERM")
158
+ add.add_argument("--not-contains", nargs="+", metavar="TERM")
159
+ add.add_argument("--regex", help="output must match (case-insensitive)")
160
+ add.add_argument("--max-latency-ms", type=float)
161
+ add.add_argument("--assertion", action="append", metavar="JSON", help="raw assertion spec")
162
+ add.set_defaults(fn=cmd_case_add)
163
+
164
+ ev = sub.add_parser("eval", help="Eval runs").add_subparsers(dest="sub", required=True)
165
+ compare = ev.add_parser("compare", help="Compare a candidate run with a baseline run")
166
+ compare.add_argument("baseline")
167
+ compare.add_argument("candidate")
168
+ compare.set_defaults(fn=cmd_compare)
169
+ gate = ev.add_parser("gate", help="Fail (exit 1) if the candidate does not meet the criteria")
170
+ gate.add_argument("baseline")
171
+ gate.add_argument("candidate")
172
+ gate.add_argument("--max-regressions", type=int, default=0)
173
+ gate.add_argument("--min-pass-rate", type=float)
174
+ gate.add_argument("--max-pass-rate-drop", type=float)
175
+ gate.add_argument("--max-cost-increase-pct", type=float)
176
+ gate.add_argument("--max-p95-latency-increase-pct", type=float)
177
+ gate.set_defaults(fn=cmd_gate)
178
+
179
+ args = parser.parse_args(argv)
180
+ try:
181
+ return args.fn(args)
182
+ except ObzerveAPIError as exc:
183
+ print(f"error: {exc}", file=sys.stderr)
184
+ return 2
185
+
186
+
187
+ if __name__ == "__main__":
188
+ sys.exit(main())
obzerve/client.py ADDED
@@ -0,0 +1,98 @@
1
+ """HTTP client for Obzerve's API: replay bundles, prompt versions, eval datasets and runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ import requests
9
+
10
+ from obzerve.replay import ReplayBundle
11
+
12
+
13
+ class ObzerveAPIError(Exception):
14
+ def __init__(self, status: int, detail: Any):
15
+ super().__init__(f"HTTP {status}: {detail}")
16
+ self.status = status
17
+ self.detail = detail
18
+
19
+
20
+ class ObzerveClient:
21
+ def __init__(
22
+ self,
23
+ endpoint: str = "http://localhost:8000",
24
+ api_key: str | None = None,
25
+ timeout_s: float = 30.0,
26
+ ):
27
+ self.endpoint = endpoint.rstrip("/")
28
+ self.timeout_s = timeout_s
29
+ self._session = requests.Session()
30
+ if api_key:
31
+ self._session.headers["Authorization"] = f"Bearer {api_key}"
32
+
33
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
34
+ resp = self._session.request(
35
+ method, f"{self.endpoint}{path}", timeout=self.timeout_s, **kwargs
36
+ )
37
+ if resp.status_code >= 400:
38
+ try:
39
+ detail = resp.json().get("detail", resp.text)
40
+ except ValueError:
41
+ detail = resp.text
42
+ raise ObzerveAPIError(resp.status_code, detail)
43
+ return resp.json() if resp.content else None
44
+
45
+ # Replay
46
+ def replay_bundle(self, trace_id: str) -> ReplayBundle:
47
+ return ReplayBundle.from_json(self._request("GET", f"/api/v1/traces/{trace_id}/replay"))
48
+
49
+ def compare_traces(self, a: str, b: str) -> dict[str, Any]:
50
+ return self._request("GET", "/api/v1/trace-comparison", params={"a": a, "b": b})
51
+
52
+ # Prompt versions
53
+ def register_prompt(self, name: str, config: dict[str, Any]) -> dict[str, Any]:
54
+ """Register a prompt/model configuration; identical content returns the same version."""
55
+ return self._request(
56
+ "POST", f"/api/v1/prompts/{quote(name, safe='')}/versions", json={"config": config}
57
+ )
58
+
59
+ # Datasets and cases
60
+ def dataset(
61
+ self, name: str, *, version: int | None = None, include_bundles: bool = False
62
+ ) -> dict[str, Any]:
63
+ params: dict[str, Any] = {"include_bundles": str(include_bundles).lower()}
64
+ if version is not None:
65
+ params["version"] = version
66
+ return self._request("GET", f"/api/v1/datasets/{quote(name, safe='')}", params=params)
67
+
68
+ def create_case(
69
+ self,
70
+ dataset: str,
71
+ trace_id: str,
72
+ name: str,
73
+ assertions: list[dict[str, Any]],
74
+ notes: str | None = None,
75
+ replay_policy: str = "llm-live",
76
+ ) -> dict[str, Any]:
77
+ return self._request(
78
+ "POST",
79
+ f"/api/v1/datasets/{quote(dataset, safe='')}/cases",
80
+ json={
81
+ "trace_id": trace_id,
82
+ "name": name,
83
+ "assertions": assertions,
84
+ "notes": notes,
85
+ "replay_policy": replay_policy,
86
+ },
87
+ )
88
+
89
+ # Eval runs
90
+ def submit_run(self, run: dict[str, Any]) -> dict[str, Any]:
91
+ return self._request("POST", "/api/v1/eval-runs", json=run)
92
+
93
+ def compare_runs(self, baseline: str, candidate: str) -> dict[str, Any]:
94
+ return self._request("GET", f"/api/v1/eval-runs/compare/{baseline}/{candidate}")
95
+
96
+ def gate(self, baseline: str, candidate: str, **criteria: Any) -> dict[str, Any]:
97
+ params = {k: v for k, v in criteria.items() if v is not None}
98
+ return self._request("GET", f"/api/v1/eval-runs/gate/{baseline}/{candidate}", params=params)
obzerve/delivery.py ADDED
@@ -0,0 +1,139 @@
1
+ """Bounded, accounted span delivery.
2
+
3
+ Wraps OpenTelemetry's ``BatchSpanProcessor`` and exporter so that every ended
4
+ span is counted as exactly one of: exported, failed (the backend rejected it
5
+ or was unreachable after the exporter's retries), dropped (the buffer was
6
+ full), or still pending. The buffer bound is enforced here, before the batch
7
+ processor's own queue, so drops are counted precisely.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import threading
14
+ import time
15
+ from collections.abc import Sequence
16
+ from dataclasses import dataclass
17
+
18
+ from opentelemetry.context import Context
19
+ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
20
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult
21
+
22
+ logger = logging.getLogger("obzerve")
23
+
24
+ _WARN_INTERVAL_S = 60.0
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DeliveryStats:
29
+ """Telemetry delivery counters since the Observer was created."""
30
+
31
+ spans_ended: int
32
+ spans_exported: int
33
+ spans_failed: int
34
+ spans_dropped: int
35
+ spans_pending: int
36
+
37
+
38
+ class _Counters:
39
+ def __init__(self) -> None:
40
+ self.lock = threading.Lock()
41
+ self.ended = 0
42
+ self.exported = 0
43
+ self.failed = 0
44
+ self.dropped = 0
45
+ self.pending = 0
46
+ self._last_warning = 0.0
47
+
48
+ def warn(self, message: str, *args: object) -> None:
49
+ """Log at most once per interval, so a dead backend cannot flood the agent's logs."""
50
+ now = time.monotonic()
51
+ if now - self._last_warning >= _WARN_INTERVAL_S:
52
+ self._last_warning = now
53
+ logger.warning(message, *args)
54
+
55
+
56
+ class AccountingExporter(SpanExporter):
57
+ def __init__(self, inner: SpanExporter, counters: _Counters):
58
+ self._inner = inner
59
+ self._counters = counters
60
+
61
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
62
+ try:
63
+ result = self._inner.export(spans)
64
+ except Exception: # an exporter bug must not kill the export thread
65
+ logger.exception("Obzerve exporter raised")
66
+ result = SpanExportResult.FAILURE
67
+ c = self._counters
68
+ with c.lock:
69
+ c.pending -= len(spans)
70
+ if result is SpanExportResult.SUCCESS:
71
+ c.exported += len(spans)
72
+ else:
73
+ c.failed += len(spans)
74
+ failed = c.failed
75
+ if result is not SpanExportResult.SUCCESS:
76
+ c.warn("Obzerve could not deliver %d spans (%d failed in total)", len(spans), failed)
77
+ return result
78
+
79
+ def shutdown(self) -> None:
80
+ self._inner.shutdown()
81
+
82
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
83
+ return self._inner.force_flush(timeout_millis)
84
+
85
+
86
+ class AccountingProcessor(SpanProcessor):
87
+ """Admits a span to the batch processor only while fewer than
88
+ ``max_pending`` spans are buffered or being exported."""
89
+
90
+ def __init__(
91
+ self,
92
+ exporter: SpanExporter,
93
+ *,
94
+ max_queue_size: int,
95
+ max_export_batch_size: int,
96
+ schedule_delay_ms: int,
97
+ export_timeout_s: float,
98
+ ):
99
+ self.counters = _Counters()
100
+ self._max_pending = max_queue_size
101
+ # The inner queue holds at most ``max_pending`` spans, so it never drops on its own.
102
+ self._batch = BatchSpanProcessor(
103
+ AccountingExporter(exporter, self.counters),
104
+ max_queue_size=max_queue_size,
105
+ max_export_batch_size=min(max_export_batch_size, max_queue_size),
106
+ schedule_delay_millis=schedule_delay_ms,
107
+ export_timeout_millis=export_timeout_s * 1000,
108
+ )
109
+
110
+ def on_start(self, span: Span, parent_context: Context | None = None) -> None:
111
+ self._batch.on_start(span, parent_context)
112
+
113
+ def on_end(self, span: ReadableSpan) -> None:
114
+ if not span.context.trace_flags.sampled:
115
+ return
116
+ c = self.counters
117
+ with c.lock:
118
+ c.ended += 1
119
+ admitted = c.pending < self._max_pending
120
+ if admitted:
121
+ c.pending += 1
122
+ else:
123
+ c.dropped += 1
124
+ dropped = c.dropped
125
+ if admitted:
126
+ self._batch.on_end(span)
127
+ else:
128
+ c.warn("Obzerve telemetry buffer full; dropping spans (%d dropped in total)", dropped)
129
+
130
+ def stats(self) -> DeliveryStats:
131
+ c = self.counters
132
+ with c.lock:
133
+ return DeliveryStats(c.ended, c.exported, c.failed, c.dropped, c.pending)
134
+
135
+ def shutdown(self) -> None:
136
+ self._batch.shutdown()
137
+
138
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
139
+ return self._batch.force_flush(timeout_millis)
obzerve/evals.py ADDED
@@ -0,0 +1,156 @@
1
+ """Evaluation runs: replay every case of a dataset against a candidate and
2
+ check its assertions.
3
+
4
+ Each case carries the recorded input and dependencies of the trace it was
5
+ made from, and its own replay policy: ``recorded`` to re-run a recorded failure
6
+ exactly, or ``llm-live`` to test the model/prompt against recorded tools and
7
+ retrieval. ``run_eval`` replays each case, applies its deterministic
8
+ assertions, and submits the results with everything needed to judge whether
9
+ two runs are comparable: dataset version, evaluator version, prompt version,
10
+ replay policy, and configuration.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import time
18
+ import uuid
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timezone
22
+ from typing import Any
23
+
24
+ from obzerve import attributes as attrs
25
+ from obzerve.client import ObzerveClient
26
+ from obzerve.observer import Observer
27
+ from obzerve.replay import POLICIES, ReplayBundle, ReplayPolicy
28
+
29
+ # Bump whenever an assertion's semantics change: runs with different evaluator
30
+ # versions are not compared.
31
+ EVALUATOR_VERSION = "obzerve-assertions/1"
32
+
33
+
34
+ @dataclass
35
+ class Outcome:
36
+ output: str | None
37
+ error: str | None
38
+ latency_ms: float
39
+
40
+
41
+ def output_text(value: Any) -> str | None:
42
+ if value is None:
43
+ return None
44
+ if isinstance(value, str):
45
+ return value
46
+ try:
47
+ return json.dumps(value, ensure_ascii=False, default=repr)
48
+ except (TypeError, ValueError):
49
+ return repr(value)
50
+
51
+
52
+ def evaluate(assertion: dict[str, Any], outcome: Outcome) -> tuple[bool, str]:
53
+ """Apply one assertion; returns ``(passed, explanation)``."""
54
+ kind = assertion["type"]
55
+ text = (outcome.output or "").lower()
56
+ if kind == "no_error":
57
+ return outcome.error is None, "completed without error" if outcome.error is None else (
58
+ f"raised {outcome.error}"
59
+ )
60
+ if kind == "max_latency_ms":
61
+ limit = assertion["value"]
62
+ return outcome.latency_ms <= limit, f"took {outcome.latency_ms:.0f} ms (limit {limit:g})"
63
+ if outcome.error is not None:
64
+ return False, f"no output: raised {outcome.error}"
65
+ if kind == "contains_all":
66
+ missing = [v for v in assertion["values"] if v.lower() not in text]
67
+ return not missing, "contains all terms" if not missing else f"missing {missing}"
68
+ if kind == "contains_any":
69
+ found = [v for v in assertion["values"] if v.lower() in text]
70
+ need = assertion.get("min_matches", 1)
71
+ return len(found) >= need, f"found {len(found)} of required {need}: {found}"
72
+ if kind == "not_contains":
73
+ present = [v for v in assertion["values"] if v.lower() in text]
74
+ return not present, "none present" if not present else f"contains {present}"
75
+ if kind == "regex":
76
+ matched = re.search(assertion["pattern"], outcome.output or "", re.IGNORECASE) is not None
77
+ want = assertion.get("must_match", True)
78
+ return matched == want, f"pattern {'matched' if matched else 'did not match'}"
79
+ if kind == "max_output_chars":
80
+ n, limit = len(outcome.output or ""), assertion["value"]
81
+ return n <= limit, f"{n} chars (limit {limit})"
82
+ return False, f"unknown assertion type {kind!r} for {EVALUATOR_VERSION}"
83
+
84
+
85
+ def run_eval(
86
+ observer: Observer,
87
+ client: ObzerveClient,
88
+ dataset: str,
89
+ target: Callable[[Any], Any],
90
+ *,
91
+ label: str,
92
+ policy: ReplayPolicy | None = None,
93
+ prompt_version: dict[str, Any] | None = None,
94
+ config: dict[str, Any] | None = None,
95
+ on_case: Callable[[dict[str, Any], dict[str, Any]], None] | None = None,
96
+ ) -> dict[str, Any]:
97
+ """Run every active case of ``dataset`` through ``target(root_input)`` and
98
+ submit the results. ``policy`` overrides every case's own replay policy
99
+ (default: per case). ``prompt_version`` is the dict returned by
100
+ ``client.register_prompt``. Returns the stored run (with its summary)."""
101
+ data = client.dataset(dataset, include_bundles=True)
102
+ if not data["cases"]:
103
+ raise ValueError(f"dataset {dataset!r} has no active cases")
104
+ started = datetime.now(timezone.utc)
105
+ run_id = str(uuid.uuid4())
106
+ replay_attrs: dict[str, Any] = {attrs.EVAL_RUN_ID: run_id}
107
+ if prompt_version:
108
+ replay_attrs[attrs.PROMPT_NAME] = prompt_version["name"]
109
+ replay_attrs[attrs.PROMPT_VERSION] = str(prompt_version["version"])
110
+
111
+ results = []
112
+ for case in data["cases"]:
113
+ bundle = ReplayBundle.from_json(case["bundle"])
114
+ case_attrs = {**replay_attrs, attrs.EVAL_CASE_ID: case["id"]}
115
+ t0 = time.perf_counter()
116
+ output = error = None
117
+ case_policy = policy or POLICIES[case.get("replay_policy", "llm-live")]
118
+ with observer.replay(bundle, case_policy, label=label, attributes=case_attrs) as session:
119
+ try:
120
+ output = output_text(target(bundle.root_input))
121
+ except Exception as exc: # a failing case is a result, not a crash
122
+ error = f"{type(exc).__name__}: {exc}"
123
+ outcome = Outcome(output, error, (time.perf_counter() - t0) * 1000)
124
+ verdicts = []
125
+ for assertion in case["assertions"]:
126
+ passed, detail = evaluate(assertion, outcome)
127
+ verdicts.append({"type": assertion["type"], "passed": passed, "detail": detail})
128
+ result = {
129
+ "case_id": case["id"],
130
+ "trace_id": session.root_trace_id,
131
+ "passed": all(v["passed"] for v in verdicts),
132
+ "assertions": verdicts,
133
+ "output": (output or "")[:200_000] or None,
134
+ "error": error,
135
+ "latency_ms": outcome.latency_ms,
136
+ }
137
+ results.append(result)
138
+ if on_case:
139
+ on_case(case, result)
140
+
141
+ observer.flush()
142
+ return client.submit_run(
143
+ {
144
+ "id": run_id,
145
+ "dataset": dataset,
146
+ "dataset_version": data["version"],
147
+ "evaluator_version": EVALUATOR_VERSION,
148
+ "label": label,
149
+ "replay_policy": policy.name if policy else "per-case",
150
+ "prompt_version_id": prompt_version["id"] if prompt_version else None,
151
+ "config": config or {},
152
+ "started_at": started.isoformat(),
153
+ "finished_at": datetime.now(timezone.utc).isoformat(),
154
+ "results": results,
155
+ }
156
+ )