prismtrace-sdk 0.3.2__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.
- prismtrace/__init__.py +34 -0
- prismtrace/claude_tracer.py +363 -0
- prismtrace/client.py +265 -0
- prismtrace/elevenlabs_voice.py +223 -0
- prismtrace/google_adk.py +789 -0
- prismtrace/langchain_handler.py +411 -0
- prismtrace/langgraph_helper.py +120 -0
- prismtrace/litellm_callback.py +257 -0
- prismtrace/openai_agents.py +291 -0
- prismtrace/otel.py +145 -0
- prismtrace/verify.py +168 -0
- prismtrace_sdk-0.3.2.dist-info/METADATA +11 -0
- prismtrace_sdk-0.3.2.dist-info/RECORD +15 -0
- prismtrace_sdk-0.3.2.dist-info/WHEEL +5 -0
- prismtrace_sdk-0.3.2.dist-info/top_level.txt +1 -0
prismtrace/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .client import PRISMtrace
|
|
2
|
+
from .langchain_handler import PRISMtraceCallbackHandler
|
|
3
|
+
from .claude_tracer import ClaudeAgentTracer
|
|
4
|
+
from .langgraph_helper import PRISMtraceLangGraphHandler, wrap_graph as wrap_langgraph
|
|
5
|
+
from .google_adk import PRISMtraceADKAdapter
|
|
6
|
+
from .litellm_callback import install as install_litellm
|
|
7
|
+
from .openai_agents import PRISMtraceTracingProcessor, install as install_openai_agents
|
|
8
|
+
from .elevenlabs_voice import (
|
|
9
|
+
PRISMtraceVoiceTracer,
|
|
10
|
+
install as install_elevenlabs_voice,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = "0.3.2"
|
|
14
|
+
__all__ = [
|
|
15
|
+
# Core
|
|
16
|
+
"PRISMtrace",
|
|
17
|
+
# LangChain (Supported)
|
|
18
|
+
"PRISMtraceCallbackHandler",
|
|
19
|
+
# Anthropic direct (Supported)
|
|
20
|
+
"ClaudeAgentTracer",
|
|
21
|
+
# LangGraph (Supported)
|
|
22
|
+
"PRISMtraceLangGraphHandler",
|
|
23
|
+
"wrap_langgraph",
|
|
24
|
+
# Google ADK (Beta)
|
|
25
|
+
"PRISMtraceADKAdapter",
|
|
26
|
+
# LiteLLM (Universal Gateway, Supported)
|
|
27
|
+
"install_litellm",
|
|
28
|
+
# OpenAI Agents SDK (Preview)
|
|
29
|
+
"PRISMtraceTracingProcessor",
|
|
30
|
+
"install_openai_agents",
|
|
31
|
+
# ElevenLabs Agents — voice (Supported)
|
|
32
|
+
"PRISMtraceVoiceTracer",
|
|
33
|
+
"install_elevenlabs_voice",
|
|
34
|
+
]
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Claude agent tracer — wraps Anthropic client to auto-trace agentic tool use loops.
|
|
2
|
+
|
|
3
|
+
Emits both spans (for the trace detail view) AND a trajectory (for PRISM
|
|
4
|
+
evaluation + trajectory analytics) after each agentic run.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Callable, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClaudeAgentTracer:
|
|
17
|
+
"""Wraps an Anthropic client to trace agentic tool use loops automatically."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
anthropic_client: Any,
|
|
22
|
+
api_key: str,
|
|
23
|
+
project_id: str,
|
|
24
|
+
endpoint: str = "https://prismtrace-production.up.railway.app",
|
|
25
|
+
agent_name: str = "claude-agent",
|
|
26
|
+
emit_trajectory: bool = True,
|
|
27
|
+
):
|
|
28
|
+
self.client = anthropic_client
|
|
29
|
+
self.api_key = api_key
|
|
30
|
+
self.project_id = project_id
|
|
31
|
+
self.endpoint = endpoint.rstrip("/")
|
|
32
|
+
self.agent_name = agent_name
|
|
33
|
+
self.emit_trajectory = emit_trajectory
|
|
34
|
+
self._http = httpx.Client(
|
|
35
|
+
headers={
|
|
36
|
+
"x-prismtrace-key": api_key,
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
},
|
|
39
|
+
timeout=15,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _now_iso(self) -> str:
|
|
43
|
+
return datetime.now(timezone.utc).isoformat()
|
|
44
|
+
|
|
45
|
+
def run(
|
|
46
|
+
self,
|
|
47
|
+
messages: list[dict],
|
|
48
|
+
tools: list[dict],
|
|
49
|
+
system: str = "",
|
|
50
|
+
model: str = "claude-sonnet-4-5-20250514",
|
|
51
|
+
max_iterations: int = 10,
|
|
52
|
+
tool_executor: Optional[Callable[[str, dict], str]] = None,
|
|
53
|
+
session_id: Optional[str] = None,
|
|
54
|
+
) -> dict:
|
|
55
|
+
"""Run the full agentic loop, tracing every step.
|
|
56
|
+
|
|
57
|
+
Emits spans to /api/spans/ingest AND a trajectory to /api/trajectories.
|
|
58
|
+
"""
|
|
59
|
+
trace_id = str(uuid.uuid4())
|
|
60
|
+
conversation_id = session_id or str(uuid.uuid4())
|
|
61
|
+
root_span_id = str(uuid.uuid4())
|
|
62
|
+
spans: list[dict] = []
|
|
63
|
+
trajectory_steps: list[dict] = []
|
|
64
|
+
current_messages = list(messages)
|
|
65
|
+
iteration = 0
|
|
66
|
+
total_duration_ms = 0
|
|
67
|
+
|
|
68
|
+
loop_start = self._now_iso()
|
|
69
|
+
|
|
70
|
+
while iteration < max_iterations:
|
|
71
|
+
iteration += 1
|
|
72
|
+
step_span_id = str(uuid.uuid4())
|
|
73
|
+
|
|
74
|
+
# --- LLM call ---
|
|
75
|
+
llm_span_id = str(uuid.uuid4())
|
|
76
|
+
llm_start = self._now_iso()
|
|
77
|
+
t0 = time.perf_counter()
|
|
78
|
+
|
|
79
|
+
kwargs: dict[str, Any] = {
|
|
80
|
+
"model": model,
|
|
81
|
+
"max_tokens": 4096,
|
|
82
|
+
"messages": current_messages,
|
|
83
|
+
"tools": tools,
|
|
84
|
+
}
|
|
85
|
+
if system:
|
|
86
|
+
kwargs["system"] = system
|
|
87
|
+
|
|
88
|
+
response = self.client.messages.create(**kwargs)
|
|
89
|
+
t1 = time.perf_counter()
|
|
90
|
+
|
|
91
|
+
llm_end = self._now_iso()
|
|
92
|
+
llm_duration = (t1 - t0) * 1000
|
|
93
|
+
total_duration_ms += int(llm_duration)
|
|
94
|
+
|
|
95
|
+
usage = getattr(response, "usage", None)
|
|
96
|
+
input_tokens = getattr(usage, "input_tokens", 0) if usage else 0
|
|
97
|
+
output_tokens = getattr(usage, "output_tokens", 0) if usage else 0
|
|
98
|
+
|
|
99
|
+
resp_text_parts = []
|
|
100
|
+
tool_uses = []
|
|
101
|
+
for block in response.content:
|
|
102
|
+
if block.type == "text":
|
|
103
|
+
resp_text_parts.append(block.text)
|
|
104
|
+
elif block.type == "tool_use":
|
|
105
|
+
tool_uses.append(block)
|
|
106
|
+
|
|
107
|
+
spans.append({
|
|
108
|
+
"span_id": llm_span_id,
|
|
109
|
+
"parent_span_id": root_span_id,
|
|
110
|
+
"name": f"llm:{model}",
|
|
111
|
+
"span_type": "llm",
|
|
112
|
+
"input_text": json.dumps(current_messages[-3:]),
|
|
113
|
+
"output_text": "\n".join(resp_text_parts) or None,
|
|
114
|
+
"metadata": {"iteration": iteration},
|
|
115
|
+
"start_time": llm_start,
|
|
116
|
+
"end_time": llm_end,
|
|
117
|
+
"duration_ms": llm_duration,
|
|
118
|
+
"status": "ok",
|
|
119
|
+
"token_count_input": input_tokens,
|
|
120
|
+
"token_count_output": output_tokens,
|
|
121
|
+
"model": model,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
# Trajectory: record the LLM reasoning step
|
|
125
|
+
step_type = "reasoning" if tool_uses else "final_answer"
|
|
126
|
+
trajectory_steps.append({
|
|
127
|
+
"step_type": step_type,
|
|
128
|
+
"label": f"LLM call (iteration {iteration})",
|
|
129
|
+
"input_summary": (current_messages[-1].get("content", "") or "")[:200]
|
|
130
|
+
if isinstance(current_messages[-1].get("content"), str)
|
|
131
|
+
else f"[{len(current_messages)} messages]",
|
|
132
|
+
"output_summary": (" ".join(resp_text_parts))[:200] or None,
|
|
133
|
+
"duration_ms": int(llm_duration),
|
|
134
|
+
"token_count": input_tokens + output_tokens,
|
|
135
|
+
"status": "success",
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
if response.stop_reason != "tool_use" or not tool_uses:
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
# --- Tool calls ---
|
|
142
|
+
assistant_content = []
|
|
143
|
+
for block in response.content:
|
|
144
|
+
if block.type == "text":
|
|
145
|
+
assistant_content.append({"type": "text", "text": block.text})
|
|
146
|
+
elif block.type == "tool_use":
|
|
147
|
+
assistant_content.append({
|
|
148
|
+
"type": "tool_use",
|
|
149
|
+
"id": block.id,
|
|
150
|
+
"name": block.name,
|
|
151
|
+
"input": block.input,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
current_messages.append({"role": "assistant", "content": assistant_content})
|
|
155
|
+
|
|
156
|
+
tool_results = []
|
|
157
|
+
for tu in tool_uses:
|
|
158
|
+
tool_span_id = str(uuid.uuid4())
|
|
159
|
+
tool_start = self._now_iso()
|
|
160
|
+
t_tool_0 = time.perf_counter()
|
|
161
|
+
|
|
162
|
+
result_text = ""
|
|
163
|
+
status = "ok"
|
|
164
|
+
error_msg = None
|
|
165
|
+
if tool_executor:
|
|
166
|
+
try:
|
|
167
|
+
result_text = tool_executor(tu.name, tu.input)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
result_text = str(e)
|
|
170
|
+
status = "error"
|
|
171
|
+
error_msg = str(e)
|
|
172
|
+
else:
|
|
173
|
+
result_text = json.dumps({"info": "no tool_executor provided"})
|
|
174
|
+
|
|
175
|
+
t_tool_1 = time.perf_counter()
|
|
176
|
+
tool_end = self._now_iso()
|
|
177
|
+
tool_dur = (t_tool_1 - t_tool_0) * 1000
|
|
178
|
+
total_duration_ms += int(tool_dur)
|
|
179
|
+
|
|
180
|
+
spans.append({
|
|
181
|
+
"span_id": tool_span_id,
|
|
182
|
+
"parent_span_id": root_span_id,
|
|
183
|
+
"name": tu.name,
|
|
184
|
+
"span_type": "tool",
|
|
185
|
+
"input_text": json.dumps(tu.input),
|
|
186
|
+
"output_text": result_text[:10000] if result_text else None,
|
|
187
|
+
"metadata": {"tool_id": tu.id},
|
|
188
|
+
"start_time": tool_start,
|
|
189
|
+
"end_time": tool_end,
|
|
190
|
+
"duration_ms": tool_dur,
|
|
191
|
+
"status": status,
|
|
192
|
+
"error_message": error_msg,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
# Trajectory: record each tool call
|
|
196
|
+
trajectory_steps.append({
|
|
197
|
+
"step_type": "tool_call",
|
|
198
|
+
"label": tu.name,
|
|
199
|
+
"tool_name": tu.name,
|
|
200
|
+
"input_summary": json.dumps(tu.input)[:200],
|
|
201
|
+
"output_summary": (result_text or "")[:200],
|
|
202
|
+
"duration_ms": int(tool_dur),
|
|
203
|
+
"token_count": 0,
|
|
204
|
+
"status": "success" if status == "ok" else "error",
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
tool_results.append({
|
|
208
|
+
"type": "tool_result",
|
|
209
|
+
"tool_use_id": tu.id,
|
|
210
|
+
"content": result_text,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
current_messages.append({"role": "user", "content": tool_results})
|
|
214
|
+
|
|
215
|
+
spans.append({
|
|
216
|
+
"span_id": step_span_id,
|
|
217
|
+
"parent_span_id": root_span_id,
|
|
218
|
+
"name": f"agent_step_{iteration}",
|
|
219
|
+
"span_type": "agent",
|
|
220
|
+
"input_text": json.dumps([tu.name for tu in tool_uses]),
|
|
221
|
+
"output_text": None,
|
|
222
|
+
"metadata": {"iteration": iteration, "tool_count": len(tool_uses)},
|
|
223
|
+
"start_time": llm_start,
|
|
224
|
+
"end_time": self._now_iso(),
|
|
225
|
+
"duration_ms": (time.perf_counter() - t0) * 1000,
|
|
226
|
+
"status": "ok",
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
# Root span
|
|
230
|
+
loop_end = self._now_iso()
|
|
231
|
+
spans.insert(0, {
|
|
232
|
+
"span_id": root_span_id,
|
|
233
|
+
"parent_span_id": None,
|
|
234
|
+
"name": "claude_agent_run",
|
|
235
|
+
"span_type": "chain",
|
|
236
|
+
"input_text": json.dumps(messages[:2]),
|
|
237
|
+
"output_text": "\n".join(resp_text_parts) if resp_text_parts else None,
|
|
238
|
+
"metadata": {"total_iterations": iteration, "model": model},
|
|
239
|
+
"start_time": loop_start,
|
|
240
|
+
"end_time": loop_end,
|
|
241
|
+
"duration_ms": None,
|
|
242
|
+
"status": "ok",
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
# Flush spans
|
|
246
|
+
try:
|
|
247
|
+
self._http.post(
|
|
248
|
+
f"{self.endpoint}/api/spans/ingest",
|
|
249
|
+
json={
|
|
250
|
+
"trace_id": trace_id,
|
|
251
|
+
"project_id": self.project_id,
|
|
252
|
+
"spans": spans,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
print(f"PRISMtrace flush error (spans): {e}")
|
|
257
|
+
|
|
258
|
+
# Flush trajectory for PRISM evaluation
|
|
259
|
+
trajectory_id = None
|
|
260
|
+
if self.emit_trajectory and trajectory_steps:
|
|
261
|
+
has_errors = any(s.get("status") == "error" for s in trajectory_steps)
|
|
262
|
+
try:
|
|
263
|
+
resp = self._http.post(
|
|
264
|
+
f"{self.endpoint}/api/trajectories",
|
|
265
|
+
json={
|
|
266
|
+
"project_id": self.project_id,
|
|
267
|
+
"conversation_id": conversation_id,
|
|
268
|
+
"request_id": trace_id,
|
|
269
|
+
"agent_id": self.agent_name,
|
|
270
|
+
"agent_name": self.agent_name,
|
|
271
|
+
"steps": trajectory_steps,
|
|
272
|
+
"total_duration_ms": total_duration_ms,
|
|
273
|
+
"final_status": "error" if has_errors else "success",
|
|
274
|
+
"model": model,
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
if resp.status_code in (200, 201):
|
|
278
|
+
trajectory_id = resp.json().get("id")
|
|
279
|
+
except Exception as e:
|
|
280
|
+
print(f"PRISMtrace flush error (trajectory): {e}")
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"response": response,
|
|
284
|
+
"trace_id": trace_id,
|
|
285
|
+
"trajectory_id": trajectory_id,
|
|
286
|
+
"iterations": iteration,
|
|
287
|
+
"spans_count": len(spans),
|
|
288
|
+
"trajectory_steps": len(trajectory_steps),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def instrument_client(self) -> None:
|
|
292
|
+
"""Wraps the Anthropic client's messages.create so every call is auto-traced."""
|
|
293
|
+
original_create = self.client.messages.create
|
|
294
|
+
|
|
295
|
+
tracer = self
|
|
296
|
+
|
|
297
|
+
def traced_create(*args: Any, **kwargs: Any) -> Any:
|
|
298
|
+
trace_id = str(uuid.uuid4())
|
|
299
|
+
span_id = str(uuid.uuid4())
|
|
300
|
+
start = tracer._now_iso()
|
|
301
|
+
t0 = time.perf_counter()
|
|
302
|
+
|
|
303
|
+
response = original_create(*args, **kwargs)
|
|
304
|
+
|
|
305
|
+
t1 = time.perf_counter()
|
|
306
|
+
end = tracer._now_iso()
|
|
307
|
+
dur_ms = (t1 - t0) * 1000
|
|
308
|
+
|
|
309
|
+
usage = getattr(response, "usage", None)
|
|
310
|
+
model = kwargs.get("model", "unknown")
|
|
311
|
+
|
|
312
|
+
tool_spans: list[dict] = []
|
|
313
|
+
if hasattr(response, "content"):
|
|
314
|
+
for block in response.content:
|
|
315
|
+
if hasattr(block, "type") and block.type == "tool_use":
|
|
316
|
+
tool_spans.append({
|
|
317
|
+
"span_id": str(uuid.uuid4()),
|
|
318
|
+
"parent_span_id": span_id,
|
|
319
|
+
"name": block.name,
|
|
320
|
+
"span_type": "tool",
|
|
321
|
+
"input_text": json.dumps(block.input),
|
|
322
|
+
"metadata": {"tool_id": block.id},
|
|
323
|
+
"start_time": start,
|
|
324
|
+
"end_time": end,
|
|
325
|
+
"duration_ms": dur_ms,
|
|
326
|
+
"status": "ok",
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
spans = [
|
|
330
|
+
{
|
|
331
|
+
"span_id": span_id,
|
|
332
|
+
"parent_span_id": None,
|
|
333
|
+
"name": f"llm:{model}",
|
|
334
|
+
"span_type": "llm",
|
|
335
|
+
"input_text": json.dumps(kwargs.get("messages", [])[-3:]),
|
|
336
|
+
"output_text": None,
|
|
337
|
+
"metadata": {},
|
|
338
|
+
"start_time": start,
|
|
339
|
+
"end_time": end,
|
|
340
|
+
"duration_ms": dur_ms,
|
|
341
|
+
"status": "ok",
|
|
342
|
+
"token_count_input": getattr(usage, "input_tokens", None) if usage else None,
|
|
343
|
+
"token_count_output": getattr(usage, "output_tokens", None) if usage else None,
|
|
344
|
+
"model": model,
|
|
345
|
+
},
|
|
346
|
+
*tool_spans,
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
tracer._http.post(
|
|
351
|
+
f"{tracer.endpoint}/api/spans/ingest",
|
|
352
|
+
json={
|
|
353
|
+
"trace_id": trace_id,
|
|
354
|
+
"project_id": tracer.project_id,
|
|
355
|
+
"spans": spans,
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
print(f"PRISMtrace auto-trace error: {e}")
|
|
360
|
+
|
|
361
|
+
return response
|
|
362
|
+
|
|
363
|
+
self.client.messages.create = traced_create
|
prismtrace/client.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""PRISMtrace client for AI observability."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PRISMtrace:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
api_key: str,
|
|
17
|
+
host: str,
|
|
18
|
+
project_id: str,
|
|
19
|
+
timeout: int = 10,
|
|
20
|
+
):
|
|
21
|
+
self.api_key = api_key
|
|
22
|
+
self.host = host.rstrip("/")
|
|
23
|
+
self.project_id = project_id
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
self._client = httpx.Client(
|
|
26
|
+
base_url=self.host,
|
|
27
|
+
headers={
|
|
28
|
+
"x-prismtrace-key": api_key,
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
},
|
|
31
|
+
timeout=self.timeout,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
# Traces (existing)
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def trace_llm(
|
|
39
|
+
self,
|
|
40
|
+
model: str,
|
|
41
|
+
input_messages: list,
|
|
42
|
+
output: str,
|
|
43
|
+
latency_ms: int,
|
|
44
|
+
token_count_input: int = 0,
|
|
45
|
+
token_count_output: int = 0,
|
|
46
|
+
trace_id: Optional[str] = None,
|
|
47
|
+
metadata: Optional[dict] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
payload = {
|
|
50
|
+
"project_id": self.project_id,
|
|
51
|
+
"model": model,
|
|
52
|
+
"input_messages": input_messages,
|
|
53
|
+
"output_message": output,
|
|
54
|
+
"latency_ms": latency_ms,
|
|
55
|
+
"token_count_input": token_count_input,
|
|
56
|
+
"token_count_output": token_count_output,
|
|
57
|
+
}
|
|
58
|
+
if trace_id is not None:
|
|
59
|
+
payload["trace_id"] = trace_id
|
|
60
|
+
if metadata is not None:
|
|
61
|
+
payload["metadata"] = metadata
|
|
62
|
+
threading.Thread(target=self._post, args=("/api/traces", payload), daemon=True).start()
|
|
63
|
+
|
|
64
|
+
def _post(self, path: str, payload: dict) -> Optional[dict]:
|
|
65
|
+
try:
|
|
66
|
+
resp = self._client.post(path, json=payload)
|
|
67
|
+
if resp.status_code in (200, 201):
|
|
68
|
+
return resp.json()
|
|
69
|
+
print(
|
|
70
|
+
f"PRISMtrace warning: {path} returned {resp.status_code}: {resp.text[:200]}",
|
|
71
|
+
file=sys.stderr,
|
|
72
|
+
)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
print(f"PRISMtrace warning: {path} failed: {e}", file=sys.stderr)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _post_sync(self, path: str, payload: dict) -> Optional[dict]:
|
|
78
|
+
try:
|
|
79
|
+
resp = self._client.post(path, json=payload)
|
|
80
|
+
if resp.status_code in (200, 201):
|
|
81
|
+
return resp.json()
|
|
82
|
+
print(
|
|
83
|
+
f"PRISMtrace warning: {path} returned {resp.status_code}: {resp.text[:200]}",
|
|
84
|
+
file=sys.stderr,
|
|
85
|
+
)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"PRISMtrace warning: {path} failed: {e}", file=sys.stderr)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def _get_sync(self, path: str) -> Optional[dict]:
|
|
91
|
+
try:
|
|
92
|
+
resp = self._client.get(path)
|
|
93
|
+
if resp.status_code == 200:
|
|
94
|
+
return resp.json()
|
|
95
|
+
print(
|
|
96
|
+
f"PRISMtrace warning: GET {path} returned {resp.status_code}: {resp.text[:200]}",
|
|
97
|
+
file=sys.stderr,
|
|
98
|
+
)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f"PRISMtrace warning: GET {path} failed: {e}", file=sys.stderr)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def trace(self, name: Optional[str] = None) -> Callable:
|
|
104
|
+
def decorator(fn: Callable) -> Callable:
|
|
105
|
+
@wraps(fn)
|
|
106
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
107
|
+
import time
|
|
108
|
+
start = time.perf_counter()
|
|
109
|
+
result = fn(*args, **kwargs)
|
|
110
|
+
elapsed_ms = int((time.perf_counter() - start) * 1000)
|
|
111
|
+
self.trace_llm(
|
|
112
|
+
model=name or fn.__name__,
|
|
113
|
+
input_messages=[{"role": "user", "content": str(args)}],
|
|
114
|
+
output=str(result),
|
|
115
|
+
latency_ms=elapsed_ms,
|
|
116
|
+
)
|
|
117
|
+
return result
|
|
118
|
+
return wrapper
|
|
119
|
+
return decorator
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Trajectories
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def submit_trajectory(
|
|
126
|
+
self,
|
|
127
|
+
steps: list[dict],
|
|
128
|
+
*,
|
|
129
|
+
agent_name: str = "default-agent",
|
|
130
|
+
agent_id: Optional[str] = None,
|
|
131
|
+
conversation_id: Optional[str] = None,
|
|
132
|
+
request_id: Optional[str] = None,
|
|
133
|
+
model: Optional[str] = None,
|
|
134
|
+
final_status: str = "success",
|
|
135
|
+
async_send: bool = False,
|
|
136
|
+
) -> Optional[dict]:
|
|
137
|
+
"""Submit a trajectory (ordered step list) for PRISM evaluation.
|
|
138
|
+
|
|
139
|
+
Each step dict should contain:
|
|
140
|
+
step_type: "reasoning" | "tool_call" | "final_answer" | ...
|
|
141
|
+
label: short description
|
|
142
|
+
output_summary: what this step produced (optional but recommended)
|
|
143
|
+
tool_name: tool identifier (required when step_type is tool_call)
|
|
144
|
+
input_summary: what went in (optional)
|
|
145
|
+
duration_ms: step latency (optional)
|
|
146
|
+
token_count: tokens used (optional)
|
|
147
|
+
status: "success" | "error" (default "success")
|
|
148
|
+
|
|
149
|
+
Returns the API response dict (trajectory id, step_count, created_at)
|
|
150
|
+
or None on failure.
|
|
151
|
+
"""
|
|
152
|
+
payload = {
|
|
153
|
+
"project_id": self.project_id,
|
|
154
|
+
"conversation_id": conversation_id or str(uuid.uuid4()),
|
|
155
|
+
"request_id": request_id or str(uuid.uuid4()),
|
|
156
|
+
"agent_id": agent_id or agent_name,
|
|
157
|
+
"agent_name": agent_name,
|
|
158
|
+
"steps": steps,
|
|
159
|
+
"total_duration_ms": sum(s.get("duration_ms") or 0 for s in steps),
|
|
160
|
+
"final_status": final_status,
|
|
161
|
+
"model": model,
|
|
162
|
+
}
|
|
163
|
+
if async_send:
|
|
164
|
+
threading.Thread(
|
|
165
|
+
target=self._post, args=("/api/trajectories", payload), daemon=True,
|
|
166
|
+
).start()
|
|
167
|
+
return None
|
|
168
|
+
return self._post_sync("/api/trajectories", payload)
|
|
169
|
+
|
|
170
|
+
def get_trajectory(self, trajectory_id: str) -> Optional[dict]:
|
|
171
|
+
"""Fetch a trajectory and its steps."""
|
|
172
|
+
return self._get_sync(f"/api/trajectories/{trajectory_id}")
|
|
173
|
+
|
|
174
|
+
def get_trajectory_evaluation(self, trajectory_id: str) -> Optional[dict]:
|
|
175
|
+
"""Fetch PRISM evaluation results for a trajectory."""
|
|
176
|
+
return self._get_sync(f"/api/trajectories/{trajectory_id}/evaluation")
|
|
177
|
+
|
|
178
|
+
def retrigger_evaluation(self, trajectory_id: str) -> Optional[dict]:
|
|
179
|
+
"""Re-run PRISM evaluation for an existing trajectory."""
|
|
180
|
+
return self._post_sync(
|
|
181
|
+
f"/api/trajectories/{trajectory_id}/evaluate?project_id={self.project_id}",
|
|
182
|
+
{},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# Knowledge Base
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def kb_upload(
|
|
190
|
+
self,
|
|
191
|
+
filename: str,
|
|
192
|
+
content: bytes | str,
|
|
193
|
+
*,
|
|
194
|
+
agent_id: Optional[str] = None,
|
|
195
|
+
description: Optional[str] = None,
|
|
196
|
+
content_type: str = "text/plain",
|
|
197
|
+
) -> Optional[dict]:
|
|
198
|
+
"""Upload a document to the project knowledge base.
|
|
199
|
+
|
|
200
|
+
Returns {"id", "name", "chunk_count", ...} or None on failure.
|
|
201
|
+
"""
|
|
202
|
+
if isinstance(content, str):
|
|
203
|
+
content = content.encode("utf-8")
|
|
204
|
+
|
|
205
|
+
files = {"file": (filename, content, content_type)}
|
|
206
|
+
data: dict[str, str] = {"project_id": self.project_id}
|
|
207
|
+
if agent_id:
|
|
208
|
+
data["agent_id"] = agent_id
|
|
209
|
+
if description:
|
|
210
|
+
data["description"] = description
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
resp = httpx.post(
|
|
214
|
+
f"{self.host}/api/knowledge-base/documents",
|
|
215
|
+
files=files,
|
|
216
|
+
data=data,
|
|
217
|
+
headers={"x-prismtrace-key": self.api_key},
|
|
218
|
+
timeout=self.timeout,
|
|
219
|
+
)
|
|
220
|
+
if resp.status_code in (200, 201):
|
|
221
|
+
return resp.json()
|
|
222
|
+
print(f"PRISMtrace warning: kb_upload returned {resp.status_code}")
|
|
223
|
+
except Exception as e:
|
|
224
|
+
print(f"PRISMtrace warning: kb_upload failed: {e}")
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def kb_search(
|
|
228
|
+
self,
|
|
229
|
+
query: str,
|
|
230
|
+
*,
|
|
231
|
+
agent_id: Optional[str] = None,
|
|
232
|
+
limit: int = 5,
|
|
233
|
+
) -> list[dict]:
|
|
234
|
+
"""Search the project knowledge base. Returns list of chunk results."""
|
|
235
|
+
payload: dict[str, Any] = {
|
|
236
|
+
"query": query,
|
|
237
|
+
"project_id": self.project_id,
|
|
238
|
+
"limit": limit,
|
|
239
|
+
}
|
|
240
|
+
if agent_id:
|
|
241
|
+
payload["agent_id"] = agent_id
|
|
242
|
+
result = self._post_sync("/api/knowledge-base/search", payload)
|
|
243
|
+
if result:
|
|
244
|
+
return result.get("results", [])
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
def kb_list_documents(self, agent_id: Optional[str] = None) -> list[dict]:
|
|
248
|
+
"""List documents in the project knowledge base."""
|
|
249
|
+
path = f"/api/knowledge-base/documents?project_id={self.project_id}"
|
|
250
|
+
if agent_id:
|
|
251
|
+
path += f"&agent_id={agent_id}"
|
|
252
|
+
result = self._get_sync(path)
|
|
253
|
+
if result:
|
|
254
|
+
return result.get("documents", [])
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
def kb_delete_document(self, doc_id: str) -> bool:
|
|
258
|
+
"""Delete a document from the knowledge base."""
|
|
259
|
+
try:
|
|
260
|
+
resp = self._client.delete(
|
|
261
|
+
f"/api/knowledge-base/documents/{doc_id}?project_id={self.project_id}",
|
|
262
|
+
)
|
|
263
|
+
return resp.status_code == 200
|
|
264
|
+
except Exception:
|
|
265
|
+
return False
|