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 +95 -0
- provy/client.py +462 -0
- provy/db.py +30 -0
- provy/engine/__init__.py +43 -0
- provy/engine/eval_engine.py +459 -0
- provy/engine/loader.py +60 -0
- provy/engine/pattern_detector.py +800 -0
- provy/engine/rca_engine.py +158 -0
- provy/evals.py +53 -0
- provy/judge.py +241 -0
- provy/session.py +415 -0
- provy/transport.py +220 -0
- provy_sdk-0.5.0.dist-info/METADATA +218 -0
- provy_sdk-0.5.0.dist-info/RECORD +16 -0
- provy_sdk-0.5.0.dist-info/WHEEL +5 -0
- provy_sdk-0.5.0.dist-info/top_level.txt +1 -0
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
|
provy/engine/__init__.py
ADDED
|
@@ -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
|
+
]
|