provy-sdk 0.5.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.
provy/__init__.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ Provy SDK.
3
+
4
+ Importing `provy` is lightweight: it pulls in only the ingest client (REST + OTel
5
+ exporter), which needs `requests`. The optional pieces load on first use and tell
6
+ you which extra to install if it is missing:
7
+
8
+ - the LLM-as-judge → pip install "provy-sdk[judge]" (anthropic)
9
+ - the local eval/RCA engine → pip install "provy-sdk[engine]" (supabase; legacy
10
+ direct-DB path — prefer the ingest API)
11
+
12
+ So a tenant who just wants to send traces installs the base package and nothing else.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ from typing import Any
18
+
19
+ # ── Eager, lightweight (ingest client — requests only) ──────────────────────────
20
+ from provy.client import ProvyClient, ProvyExporter
21
+ from provy.session import TraceLogger
22
+ from provy.evals import write_eval
23
+
24
+ __version__ = "0.5.0"
25
+
26
+ # ── Lazy, heavy (loaded on first access; mapped to the extra that provides them) ──
27
+ # name -> (module, attribute, extra)
28
+ _LAZY: dict[str, tuple[str, str, str]] = {
29
+ # LLM-as-judge (anthropic)
30
+ "evaluate_session_outputs": ("provy.judge", "evaluate_session_outputs", "judge"),
31
+ # local eval + pattern + RCA engine (supabase, legacy direct-DB)
32
+ "EvalResult": ("provy.engine", "EvalResult", "engine"),
33
+ "Incident": ("provy.engine", "Incident", "engine"),
34
+ "run_evals_from_config": ("provy.engine", "run_evals_from_config", "engine"),
35
+ "run_all_detectors": ("provy.engine", "run_all_detectors", "engine"),
36
+ "run_quality_detectors": ("provy.engine", "run_quality_detectors", "engine"),
37
+ "run_evals_and_persist": ("provy.engine", "run_evals_and_persist", "engine"),
38
+ "run_detectors_and_persist": ("provy.engine", "run_detectors_and_persist", "engine"),
39
+ "compute_shadow_cb_fires": ("provy.engine", "compute_shadow_cb_fires", "engine"),
40
+ "build_annotated_call_stack":("provy.engine", "build_annotated_call_stack", "engine"),
41
+ "generate_fix_suggestion": ("provy.engine", "generate_fix_suggestion", "engine"),
42
+ "summarize_incident": ("provy.engine", "summarize_incident", "engine"),
43
+ "load_pipeline_config": ("provy.engine", "load_pipeline_config", "engine"),
44
+ "load_eval_configs": ("provy.engine", "load_eval_configs", "engine"),
45
+ "load_pipeline_agents": ("provy.engine", "load_pipeline_agents", "engine"),
46
+ "register_eval": ("provy.engine", "register_eval", "engine"),
47
+ "get_registry": ("provy.engine", "get_registry", "engine"),
48
+ }
49
+
50
+
51
+ def __getattr__(name: str) -> Any: # PEP 562 — module-level lazy attributes
52
+ target = _LAZY.get(name)
53
+ if target is None:
54
+ raise AttributeError(f"module 'provy' has no attribute {name!r}")
55
+ module, attr, extra = target
56
+ try:
57
+ mod = importlib.import_module(module)
58
+ except ImportError as exc:
59
+ raise ImportError(
60
+ f"{name!r} needs the '{extra}' extra. Install it with: "
61
+ f'pip install "provy-sdk[{extra}]"'
62
+ ) from exc
63
+ return getattr(mod, attr)
64
+
65
+
66
+ def __dir__() -> list[str]:
67
+ return sorted(__all__)
68
+
69
+
70
+ __all__ = [
71
+ # ingest client (base)
72
+ "ProvyClient",
73
+ "ProvyExporter",
74
+ "TraceLogger",
75
+ "write_eval",
76
+ # LLM-as-judge (extra: judge)
77
+ "evaluate_session_outputs",
78
+ # local engine (extra: engine)
79
+ "EvalResult",
80
+ "Incident",
81
+ "run_evals_from_config",
82
+ "run_all_detectors",
83
+ "run_quality_detectors",
84
+ "run_evals_and_persist",
85
+ "run_detectors_and_persist",
86
+ "compute_shadow_cb_fires",
87
+ "build_annotated_call_stack",
88
+ "generate_fix_suggestion",
89
+ "summarize_incident",
90
+ "load_pipeline_config",
91
+ "load_eval_configs",
92
+ "load_pipeline_agents",
93
+ "register_eval",
94
+ "get_registry",
95
+ ]
provy/client.py ADDED
@@ -0,0 +1,462 @@
1
+ """
2
+ Provy SDK — Python ingest client.
3
+
4
+ The canonical way to send data to Provy. Authenticates with an ingest key and
5
+ POSTs to the ingest API — no database credentials, works for any tenant. Two
6
+ shapes, same key:
7
+
8
+ PATH 1 — OTel exporter (LangChain, CrewAI, AutoGen, LlamaIndex, any OTel pipeline)
9
+ ----------------------------------------------------------------------------------
10
+ pip install "provy-sdk[otel]"
11
+
12
+ from opentelemetry.sdk.trace import TracerProvider
13
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
14
+ from provy import ProvyExporter
15
+
16
+ provider = TracerProvider()
17
+ provider.add_span_processor(BatchSpanProcessor(ProvyExporter(api_key="provy_...")))
18
+ # That's it — your OTel spans stream to Provy automatically.
19
+
20
+ PATH 2 — Direct ingest API (custom pipelines)
21
+ ---------------------------------------------
22
+ pip install provy-sdk
23
+
24
+ from provy import ProvyClient
25
+
26
+ client = ProvyClient(ingest_key="provy_...")
27
+ session_id = client.open_session("premarket")
28
+ client.trace(session_id=session_id, agent="research", step_type="agent_step", outcome="Done")
29
+ client.close_session(session_id, result_summary="Trade plan ready")
30
+
31
+ # OTel span IDs are populated automatically when opentelemetry-sdk is installed.
32
+ # Pass parent_trace_id manually if you manage span relationships yourself:
33
+ client.trace(..., parent_trace_id="<hex-span-id>")
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import os
39
+ import time
40
+ import uuid
41
+ import functools
42
+ import threading
43
+ import logging
44
+ import requests
45
+ from .transport import SpanBuffer, post_with_retry
46
+
47
+ PROVY_BASE_URL = os.environ.get("PROVY_URL") or os.environ.get("ARGUS_URL", "https://provy.ai")
48
+
49
+
50
+ def _emit_enabled(override: "bool | None" = None) -> bool:
51
+ """Whether the SDK may send telemetry to Provy.
52
+
53
+ Off by default, so a local or dev run holding production credentials does not
54
+ write into your production Provy. Turn it on in your production (or CI)
55
+ environment with PROVY_EMIT=1, or pass enabled=True to the client. An explicit
56
+ override always wins. When off, the client is a no-op: open_session returns a
57
+ local id and trace/close do nothing, so your code runs unchanged.
58
+ """
59
+ if override is not None:
60
+ return override
61
+ return os.environ.get("PROVY_EMIT", "").strip().lower() in ("1", "true", "yes", "on")
62
+
63
+ # Optional OTel — imported at runtime so the SDK works without it installed
64
+ try:
65
+ from opentelemetry import trace as otel_trace
66
+ from opentelemetry.sdk.trace import TracerProvider as OtelTracerProvider
67
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
68
+ from opentelemetry.trace import StatusCode
69
+ _OTEL_AVAILABLE = True
70
+ except ImportError:
71
+ _OTEL_AVAILABLE = False
72
+ SpanExporter = object # type: ignore[assignment,misc]
73
+ SpanExportResult = None # type: ignore[assignment]
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # ProvyExporter — OTel SpanExporter that streams spans to Provy OTLP gateway
78
+ # ---------------------------------------------------------------------------
79
+
80
+ log = logging.getLogger("provy.sdk")
81
+
82
+
83
+ class ProvyExporter(SpanExporter): # type: ignore[misc]
84
+ """
85
+ OTel SpanExporter. Attach to any TracerProvider; spans stream to Provy.
86
+
87
+ Usage:
88
+ from opentelemetry.sdk.trace import TracerProvider
89
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
90
+ from provy import ProvyExporter
91
+
92
+ provider = TracerProvider()
93
+ provider.add_span_processor(BatchSpanProcessor(ProvyExporter(api_key="provy_...")))
94
+ """
95
+
96
+ def __init__(self, api_key: str, endpoint: str | None = None, enabled: "bool | None" = None):
97
+ if not _OTEL_AVAILABLE:
98
+ raise ImportError("opentelemetry-sdk and opentelemetry-api are required for ProvyExporter")
99
+ self.api_key = api_key
100
+ self.endpoint = (endpoint or PROVY_BASE_URL).rstrip("/") + "/api/otlp/v1/traces"
101
+ self._headers = {"x-provy-key": api_key, "Content-Type": "application/json"}
102
+ self._enabled = enabled
103
+
104
+ def export(self, spans) -> "SpanExportResult": # type: ignore[override]
105
+ if not _emit_enabled(self._enabled):
106
+ return SpanExportResult.SUCCESS # type: ignore[attr-defined] # emission off: drop silently
107
+ otlp_spans = []
108
+ for span in spans:
109
+ ctx = span.get_span_context()
110
+ parent_ctx = span.parent
111
+
112
+ attrs = []
113
+ for k, v in (span.attributes or {}).items():
114
+ if isinstance(v, bool): attrs.append({"key": k, "value": {"boolValue": v}})
115
+ elif isinstance(v, int): attrs.append({"key": k, "value": {"intValue": v}})
116
+ elif isinstance(v, float):attrs.append({"key": k, "value": {"doubleValue": v}})
117
+ else: attrs.append({"key": k, "value": {"stringValue": str(v)}})
118
+
119
+ events = []
120
+ for ev in (span.events or []):
121
+ ev_attrs = []
122
+ for k, v in (ev.attributes or {}).items():
123
+ ev_attrs.append({"key": k, "value": {"stringValue": str(v)}})
124
+ events.append({"name": ev.name, "attributes": ev_attrs})
125
+
126
+ otlp_spans.append({
127
+ "spanId": format(ctx.span_id, "016x") if ctx else None,
128
+ "parentSpanId": format(parent_ctx.span_id, "016x") if parent_ctx else None,
129
+ "traceId": format(ctx.trace_id, "032x") if ctx else None,
130
+ "name": span.name,
131
+ "startTimeUnixNano": str(span.start_time),
132
+ "endTimeUnixNano": str(span.end_time),
133
+ "status": {"code": span.status.status_code.value if span.status else 0},
134
+ "attributes": attrs,
135
+ "events": events,
136
+ })
137
+
138
+ payload = {"resourceSpans": [{"scopeSpans": [{"spans": otlp_spans}]}]}
139
+ try:
140
+ r = requests.post(self.endpoint, json=payload, headers=self._headers, timeout=10)
141
+ r.raise_for_status()
142
+ return SpanExportResult.SUCCESS # type: ignore[attr-defined]
143
+ except Exception:
144
+ return SpanExportResult.FAILURE # type: ignore[attr-defined]
145
+
146
+ def shutdown(self) -> None:
147
+ pass
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # ProvyClient — direct ingest API (Path 2)
152
+ # ---------------------------------------------------------------------------
153
+
154
+ class ProvyClient:
155
+ """
156
+ Direct ingest API client for custom pipelines.
157
+
158
+ OTel span IDs (span_id, parent_span_id) are generated automatically when
159
+ opentelemetry-sdk is installed. Pass parent_trace_id manually to control
160
+ the call graph when you are not using OTel.
161
+ """
162
+
163
+ def __init__(self, ingest_key: str | None = None, base_url: str | None = None, enabled: "bool | None" = None, buffered: bool = True):
164
+ self.key = ingest_key or os.environ.get("PROVY_API_KEY") or os.environ.get("ARGUS_INGEST_KEY", "")
165
+ self.base = (base_url or PROVY_BASE_URL).rstrip("/")
166
+ self._enabled = enabled
167
+ self._headers = {
168
+ "x-provy-key": self.key,
169
+ "Content-Type": "application/json",
170
+ }
171
+ # OTel state (populated when opentelemetry-sdk is installed)
172
+ self._tracer = None
173
+ self._session_span = None
174
+ self._session_span_id: str | None = None
175
+ self._agent_spans: dict[str, object] = {}
176
+
177
+ if _OTEL_AVAILABLE:
178
+ _provider = OtelTracerProvider()
179
+ self._tracer = _provider.get_tracer("provy-sdk")
180
+
181
+ # Spans are buffered and flushed in batches (#484). Buffering is what turns a transient
182
+ # outage into a delay rather than data loss, and batching is free once they are queued: the
183
+ # ingest API takes an array, so one flush is one request instead of forty.
184
+ #
185
+ # Set buffered=False for a strictly synchronous client. Only do that if you genuinely need
186
+ # the write to have landed before the next line runs, and accept that a blip loses the span.
187
+ self._buffered = buffered
188
+ self._buffer = SpanBuffer(self._send_spans) if buffered else None
189
+
190
+ # ---- Session lifecycle ------------------------------------------------
191
+
192
+ def open_session(
193
+ self,
194
+ session_type: str,
195
+ external_id: str | None = None,
196
+ metadata: dict | None = None,
197
+ ) -> str:
198
+ if not _emit_enabled(self._enabled):
199
+ return str(uuid.uuid4()) # emission off: local id so caller code keeps working
200
+ # Synchronous on purpose: the caller needs the id back. Retried, because losing a session
201
+ # open loses every span that would have hung off it.
202
+ r = post_with_retry(
203
+ f"{self.base}/api/ingest/session/open",
204
+ {"session_type": session_type, "external_id": external_id, "metadata": metadata},
205
+ self._headers,
206
+ )
207
+ if r is None or r.status_code >= 400:
208
+ raise RuntimeError(
209
+ "provy: could not open session after retries. "
210
+ "Check PROVY_API_KEY and connectivity."
211
+ )
212
+ session_id = r.json()["session_id"]
213
+
214
+ if self._tracer and _OTEL_AVAILABLE:
215
+ self._session_span = self._tracer.start_span(f"session:{session_type}") # type: ignore[union-attr]
216
+ span_ctx = self._session_span.get_span_context() # type: ignore[union-attr]
217
+ self._session_span_id = format(span_ctx.span_id, "016x")
218
+
219
+ return session_id
220
+
221
+ def trace(
222
+ self,
223
+ session_id: str,
224
+ agent: str,
225
+ step_type: str,
226
+ outcome: str,
227
+ tool_name: str | None = None,
228
+ latency_ms: int | None = None,
229
+ tokens_in: int | None = None,
230
+ tokens_out: int | None = None,
231
+ cost_usd: float | None = None,
232
+ error: str | None = None,
233
+ output_json: dict | None = None,
234
+ parent_trace_id: str | None = None,
235
+ entity_id: str | None = None,
236
+ ) -> str:
237
+ """Log a trace step. Returns the span_id for this step (use as parent_trace_id for children).
238
+
239
+ Pass entity_id (the work-item key: trade/order id, ticket id) to join this trace to the
240
+ outcome you later report for the same item, so per-item quality and reconciliation link up.
241
+ """
242
+ if not _emit_enabled(self._enabled):
243
+ return "" # emission off: no-op
244
+
245
+ span_id = None
246
+ parent_span_id = parent_trace_id # caller override
247
+
248
+ if self._tracer and _OTEL_AVAILABLE:
249
+ # Determine OTel parent context
250
+ import opentelemetry.context as otel_ctx_api # type: ignore[import]
251
+ from opentelemetry.trace import NonRecordingSpan # type: ignore[import]
252
+
253
+ if agent in self._agent_spans:
254
+ parent_span = self._agent_spans[agent]
255
+ elif self._session_span:
256
+ parent_span = self._session_span
257
+ else:
258
+ parent_span = None
259
+
260
+ ctx = otel_trace.set_span_in_context(parent_span) if parent_span else otel_ctx_api.context.Context() # type: ignore[attr-defined]
261
+ span = self._tracer.start_span(f"{agent}:{step_type}", context=ctx) # type: ignore[union-attr]
262
+
263
+ sc = span.get_span_context()
264
+ span_id = format(sc.span_id, "016x")
265
+ parent_sc = parent_span.get_span_context() if parent_span else None # type: ignore[union-attr]
266
+ parent_span_id = parent_span_id or (format(parent_sc.span_id, "016x") if parent_sc else None)
267
+
268
+ # Store as the current agent span so nested tool calls can reference it
269
+ self._agent_spans[agent] = span
270
+ span.end()
271
+
272
+ body: dict = {
273
+ "session_id": session_id,
274
+ "agent": agent,
275
+ "step_type": step_type,
276
+ "outcome": outcome,
277
+ "tool_name": tool_name,
278
+ "latency_ms": latency_ms,
279
+ "tokens_input": tokens_in,
280
+ "tokens_output": tokens_out,
281
+ "cost_usd": cost_usd,
282
+ "error": error,
283
+ "output_json": output_json,
284
+ "entity_id": entity_id,
285
+ }
286
+ if span_id: body["span_id"] = span_id
287
+ if parent_span_id: body["parent_span_id"] = parent_span_id
288
+
289
+ # Buffered by default. Returns the locally generated span id, so the caller's call graph is
290
+ # correct whether or not the span has reached the server yet.
291
+ if self._buffer is not None:
292
+ self._buffer.add(body)
293
+ else:
294
+ post_with_retry(f"{self.base}/api/ingest/trace", body, self._headers)
295
+ return span_id or ""
296
+
297
+ # ---- transport ---------------------------------------------------------
298
+
299
+ def _send_spans(self, batch: list[dict]) -> bool:
300
+ """Deliver one batch of spans. Returns False when they are lost, so the buffer can count."""
301
+ r = post_with_retry(f"{self.base}/api/ingest/trace", batch, self._headers)
302
+ return r is not None and r.status_code < 400
303
+
304
+ def flush(self) -> None:
305
+ """Send anything still buffered, synchronously. Safe to call at any time."""
306
+ if self._buffer is not None:
307
+ self._buffer.flush()
308
+
309
+ @property
310
+ def buffer_stats(self) -> dict:
311
+ """Pending, dropped and failed span counts. Loss is visible, never silent."""
312
+ return self._buffer.stats if self._buffer is not None else {"pending": 0, "dropped": 0, "failed": 0}
313
+
314
+ def close_session(
315
+ self,
316
+ session_id: str,
317
+ status: str = "completed",
318
+ result_summary: str | None = None,
319
+ terminal_reason: str | None = None,
320
+ ) -> None:
321
+ if not _emit_enabled(self._enabled):
322
+ return # emission off: no-op
323
+ if self._session_span and _OTEL_AVAILABLE:
324
+ self._session_span.end() # type: ignore[union-attr]
325
+ self._session_span = None
326
+ self._session_span_id = None
327
+ self._agent_spans = {}
328
+
329
+ # ⛔ FLUSH BEFORE CLOSING. Buffered spans must land before the session closes, or the server
330
+ # computes a verdict over a run whose steps have not arrived. This ordering is what makes
331
+ # buffering safe rather than a race.
332
+ self.flush()
333
+ r = post_with_retry(
334
+ f"{self.base}/api/ingest/session/close",
335
+ {"session_id": session_id, "result_summary": result_summary, "terminal_reason": terminal_reason},
336
+ self._headers,
337
+ )
338
+ if r is None or r.status_code >= 400:
339
+ log.error("provy: could not close session %s after retries", session_id)
340
+
341
+ # ---- Quality checks ---------------------------------------------------
342
+
343
+ def eval(
344
+ self,
345
+ session_id: str,
346
+ eval_name: str,
347
+ agent: str,
348
+ score: float,
349
+ passed: bool,
350
+ layer: int = 4,
351
+ entity_id: str | None = None,
352
+ detail: dict | None = None,
353
+ threshold: float | None = None,
354
+ ) -> None:
355
+ """Write one eval result (a quality check) for a session.
356
+
357
+ Call once per criterion per session. score is 0..1; passed is score >= threshold.
358
+ layer defaults to 4 (LLM-as-judge / output quality). Pass entity_id to score a
359
+ specific work item when a session evaluates more than one. No-op when emission is off.
360
+ """
361
+ if not _emit_enabled(self._enabled):
362
+ return # emission off: no-op
363
+ body: dict = {
364
+ "session_id": session_id,
365
+ "eval_name": eval_name,
366
+ "agent": agent,
367
+ "score": score,
368
+ "passed": passed,
369
+ "layer": layer,
370
+ }
371
+ if entity_id is not None: body["entity_id"] = entity_id
372
+ if detail is not None: body["detail"] = detail
373
+ if threshold is not None: body["threshold"] = threshold
374
+
375
+ r = post_with_retry(f"{self.base}/api/ingest/eval", body, self._headers)
376
+ if r is None or r.status_code >= 400:
377
+ log.error("provy: eval ingest failed after retries")
378
+
379
+ # ---- Outcomes ---------------------------------------------------------
380
+
381
+ def report_outcome(
382
+ self,
383
+ entity_id: str,
384
+ label: str | None = None,
385
+ value: float | None = None,
386
+ signals: dict | None = None,
387
+ session_id: str | None = None,
388
+ source: str = "confirmed",
389
+ occurred_at: str | None = None,
390
+ ) -> None:
391
+ """Report a real business outcome for a work item and reconcile it against the prediction.
392
+
393
+ Keyed on entity_id (the same work-item key you tagged the traces/evals with). Send a
394
+ label ('success' | 'fail') or a numeric value (its sign reconciles the prediction) to
395
+ reconcile the overall prediction, and optionally a signals bag (name -> number | bool | str)
396
+ to grade the contract's conditions (Estimated vs Real). Numeric strings coerce to numbers;
397
+ non-numeric strings grade eq/in conditions. One call does both. Usually
398
+ posted later by a downstream job when the outcome lands. No-op when emission is off.
399
+ """
400
+ if not _emit_enabled(self._enabled):
401
+ return # emission off: no-op
402
+ body: dict = {
403
+ "entity_id": entity_id,
404
+ "source": source,
405
+ }
406
+ if label is not None: body["label"] = label
407
+ if value is not None: body["value"] = value
408
+ if signals is not None: body["signals"] = signals
409
+ if session_id is not None: body["session_id"] = session_id
410
+ if occurred_at is not None: body["occurred_at"] = occurred_at
411
+
412
+ r = post_with_retry(f"{self.base}/api/ingest/outcome", body, self._headers)
413
+ if r is None or r.status_code >= 400:
414
+ log.error("provy: outcome ingest failed after retries")
415
+
416
+ # ---- Decorator --------------------------------------------------------
417
+
418
+ def trace_fn(self, agent: str, step_type: str = "agent_step"):
419
+ """Decorator that auto-traces a function call."""
420
+ def decorator(fn):
421
+ @functools.wraps(fn)
422
+ def wrapper(*args, session_id: str | None = None, **kwargs):
423
+ start = time.time()
424
+ error = None
425
+ result = None
426
+ try:
427
+ result = fn(*args, **kwargs)
428
+ return result
429
+ except Exception as exc:
430
+ error = str(exc)
431
+ raise
432
+ finally:
433
+ if session_id:
434
+ latency = int((time.time() - start) * 1000)
435
+ try:
436
+ self.trace(
437
+ session_id = session_id,
438
+ agent = agent,
439
+ step_type = step_type,
440
+ outcome = str(result or error or ""),
441
+ latency_ms = latency,
442
+ error = error,
443
+ )
444
+ except Exception as exc: # noqa: BLE001
445
+ # ⛔ STILL DOES NOT RAISE, AND THAT IS DELIBERATE. This decorator wraps
446
+ # the caller's own function; breaking their agent because telemetry
447
+ # failed would be worse than the span being late.
448
+ #
449
+ # ⛔ BUT IT IS NO LONGER SILENT. This used to be `except Exception: pass`,
450
+ # so auto-instrumented spans vanished without trace on any failure. For a
451
+ # product built on the premise that an agent's own account of itself
452
+ # cannot be trusted, an SDK that quietly discarded the evidence was the
453
+ # wrong failure to have.
454
+ #
455
+ # In practice trace() now buffers, so this path is close to unreachable;
456
+ # it catches programming errors rather than network ones.
457
+ log.error(
458
+ "provy: could not record span for agent=%s step_type=%s: %s",
459
+ agent, step_type, exc,
460
+ )
461
+ return wrapper
462
+ return decorator
provy/db.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ try:
6
+ from supabase import create_client, Client
7
+ except ImportError as exc: # supabase ships in the 'engine' extra (legacy direct-DB path)
8
+ raise ImportError(
9
+ "Direct-DB features (the local engine, TraceLogger persistence) need the "
10
+ "'engine' extra. Install it with: pip install \"provy-sdk[engine]\". "
11
+ "For new pipelines, prefer the ingest API via ProvyClient (no DB credentials)."
12
+ ) from exc
13
+
14
+ _client: Client | None = None
15
+
16
+
17
+ def get_client() -> Client:
18
+ global _client
19
+ if _client is None:
20
+ _client = create_client(
21
+ os.environ["SUPABASE_URL"],
22
+ os.environ["SUPABASE_KEY"],
23
+ )
24
+ return _client
25
+
26
+
27
+ def reset_client() -> None:
28
+ """Force re-initialization on next get_client() call. Useful in tests."""
29
+ global _client
30
+ _client = None
@@ -0,0 +1,43 @@
1
+ from provy.engine.eval_engine import (
2
+ EvalResult,
3
+ run_evals_from_config,
4
+ run_and_persist as run_evals_and_persist,
5
+ register_eval,
6
+ get_registry,
7
+ )
8
+ from provy.engine.pattern_detector import (
9
+ Incident,
10
+ run_all_detectors,
11
+ run_quality_detectors,
12
+ compute_shadow_cb_fires,
13
+ run_and_persist as run_detectors_and_persist,
14
+ )
15
+ from provy.engine.rca_engine import (
16
+ build_annotated_call_stack,
17
+ generate_fix_suggestion,
18
+ summarize_incident,
19
+ )
20
+ from provy.engine.loader import load_pipeline_config, load_eval_configs, load_pipeline_agents
21
+
22
+ __all__ = [
23
+ # eval engine
24
+ "EvalResult",
25
+ "run_evals_from_config",
26
+ "run_evals_and_persist",
27
+ "register_eval",
28
+ "get_registry",
29
+ # pattern detector
30
+ "Incident",
31
+ "run_all_detectors",
32
+ "run_quality_detectors",
33
+ "compute_shadow_cb_fires",
34
+ "run_detectors_and_persist",
35
+ # rca engine
36
+ "build_annotated_call_stack",
37
+ "generate_fix_suggestion",
38
+ "summarize_incident",
39
+ # config loaders
40
+ "load_pipeline_config",
41
+ "load_eval_configs",
42
+ "load_pipeline_agents",
43
+ ]