evalkeep 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.
- evalkeep/__init__.py +12 -0
- evalkeep/__main__.py +6 -0
- evalkeep/adapters/__init__.py +45 -0
- evalkeep/adapters/base.py +92 -0
- evalkeep/adapters/jsonl.py +164 -0
- evalkeep/adapters/langsmith.py +436 -0
- evalkeep/adapters/otlp.py +442 -0
- evalkeep/adapters/semconv.py +208 -0
- evalkeep/analysis.py +174 -0
- evalkeep/analysis_run.py +160 -0
- evalkeep/analyzers/__init__.py +52 -0
- evalkeep/analyzers/anthropic.py +145 -0
- evalkeep/analyzers/stub.py +34 -0
- evalkeep/cache.py +122 -0
- evalkeep/cli.py +1933 -0
- evalkeep/clustering.py +383 -0
- evalkeep/clusters.py +101 -0
- evalkeep/commands/__init__.py +1 -0
- evalkeep/commands/analyze_cmd.py +100 -0
- evalkeep/commands/compare_cmd.py +169 -0
- evalkeep/commands/dataset_cmd.py +182 -0
- evalkeep/commands/detect_cmd.py +154 -0
- evalkeep/commands/discover_cmd.py +274 -0
- evalkeep/commands/ingest_cmd.py +50 -0
- evalkeep/commands/init_cmd.py +151 -0
- evalkeep/commands/pipeline_cmd.py +156 -0
- evalkeep/commands/review_cmd.py +141 -0
- evalkeep/commands/run_cmd.py +131 -0
- evalkeep/commands/target_cmd.py +109 -0
- evalkeep/commands/trace_cmd.py +58 -0
- evalkeep/comparison.py +432 -0
- evalkeep/config.py +209 -0
- evalkeep/detection.py +94 -0
- evalkeep/detectors.py +182 -0
- evalkeep/discovery.py +208 -0
- evalkeep/embeddings/__init__.py +31 -0
- evalkeep/embeddings/base.py +32 -0
- evalkeep/embeddings/hashing.py +98 -0
- evalkeep/errors.py +42 -0
- evalkeep/examples/__init__.py +37 -0
- evalkeep/examples/langsmith/runs.jsonl +18 -0
- evalkeep/examples/opentelemetry/spans.json +898 -0
- evalkeep/examples/refund-agent/agents/baseline.py +66 -0
- evalkeep/examples/refund-agent/agents/candidate.py +66 -0
- evalkeep/examples/refund-agent/traces.jsonl +5 -0
- evalkeep/examples/tau-bench/prepare.py +230 -0
- evalkeep/exporters/__init__.py +45 -0
- evalkeep/exporters/generic.py +31 -0
- evalkeep/exporters/promptfoo.py +219 -0
- evalkeep/failures.py +95 -0
- evalkeep/generation.py +303 -0
- evalkeep/hashing.py +56 -0
- evalkeep/ingest.py +257 -0
- evalkeep/prompts.py +127 -0
- evalkeep/pseudonyms.py +82 -0
- evalkeep/py.typed +0 -0
- evalkeep/redaction.py +333 -0
- evalkeep/regression.py +409 -0
- evalkeep/review.py +309 -0
- evalkeep/runner.py +302 -0
- evalkeep/runs.py +185 -0
- evalkeep/storage/__init__.py +37 -0
- evalkeep/storage/clusters.py +163 -0
- evalkeep/storage/failures.py +254 -0
- evalkeep/storage/migrations.py +370 -0
- evalkeep/storage/regression.py +136 -0
- evalkeep/storage/runs.py +223 -0
- evalkeep/storage/store.py +429 -0
- evalkeep/targets.py +205 -0
- evalkeep/trace.py +238 -0
- evalkeep-0.1.0.dist-info/METADATA +221 -0
- evalkeep-0.1.0.dist-info/RECORD +75 -0
- evalkeep-0.1.0.dist-info/WHEEL +4 -0
- evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
- evalkeep-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Reading LangSmith runs exported to JSON.
|
|
2
|
+
|
|
3
|
+
LangSmith is the one widely-used platform that does not speak OTLP on the way
|
|
4
|
+
out: its traces are trees of `Run` objects in its own shape. That is precisely
|
|
5
|
+
why it earns an adapter -- the OTLP adapter covers the platforms that ingest
|
|
6
|
+
OpenTelemetry, and this covers the biggest one that does not.
|
|
7
|
+
|
|
8
|
+
**It reads a file, never the API.** LangSmith's bulk export is a paid tier, so an
|
|
9
|
+
API-based adapter would lock out everyone below it. A file works from any export
|
|
10
|
+
route -- the UI download, a `list_runs` script, or bulk export -- and keeps
|
|
11
|
+
credentials out of Evalkeep entirely.
|
|
12
|
+
|
|
13
|
+
Export runs however you like, as JSONL (one run per line) or a JSON array::
|
|
14
|
+
|
|
15
|
+
from langsmith import Client
|
|
16
|
+
with open("runs.jsonl", "w") as handle:
|
|
17
|
+
for run in Client().list_runs(project_name="my-project"):
|
|
18
|
+
handle.write(run.json() + "\\n")
|
|
19
|
+
|
|
20
|
+
**One LangSmith trace becomes one Evalkeep trace**, assembled from every run
|
|
21
|
+
sharing a `trace_id`, so runs are grouped rather than emitted one by one.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from collections import Counter
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, ClassVar
|
|
33
|
+
|
|
34
|
+
from pydantic import ValidationError
|
|
35
|
+
|
|
36
|
+
from evalkeep.adapters.base import AdapterRecord, IssueKind, TraceIssue
|
|
37
|
+
from evalkeep.trace import NormalizedTrace
|
|
38
|
+
|
|
39
|
+
#: Where a prompt usually lives in a LangChain run's inputs, most specific
|
|
40
|
+
#: first. Tried in order rather than guessed at, and documented so a project
|
|
41
|
+
#: with a different shape knows what to rename.
|
|
42
|
+
INPUT_KEYS = ("input", "question", "query", "prompt", "text", "content")
|
|
43
|
+
OUTPUT_KEYS = ("output", "answer", "result", "text", "content", "generations")
|
|
44
|
+
|
|
45
|
+
TOOL_RUN = "tool"
|
|
46
|
+
LLM_RUN = "llm"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Run:
|
|
51
|
+
"""One LangSmith run, decoded far enough to be useful."""
|
|
52
|
+
|
|
53
|
+
run_id: str
|
|
54
|
+
trace_id: str
|
|
55
|
+
parent_run_id: str | None
|
|
56
|
+
name: str
|
|
57
|
+
run_type: str
|
|
58
|
+
inputs: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
outputs: dict[str, Any] = field(default_factory=dict)
|
|
60
|
+
error: str | None = None
|
|
61
|
+
status: str | None = None
|
|
62
|
+
start_time: str | None = None
|
|
63
|
+
end_time: str | None = None
|
|
64
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
tags: list[str] = field(default_factory=list)
|
|
66
|
+
feedback: list[dict[str, Any]] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def failed(self) -> bool:
|
|
70
|
+
return bool(self.error) or (self.status or "").lower() == "error"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_root(self) -> bool:
|
|
74
|
+
return not self.parent_run_id
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LangSmithAdapter:
|
|
78
|
+
"""Reads exported LangSmith runs, as JSONL or a JSON array."""
|
|
79
|
+
|
|
80
|
+
name: ClassVar[str] = "langsmith"
|
|
81
|
+
description: ClassVar[str] = "LangSmith runs exported as JSONL or a JSON array"
|
|
82
|
+
|
|
83
|
+
def read(self, path: Path) -> Iterator[AdapterRecord]:
|
|
84
|
+
runs: list[Run] = []
|
|
85
|
+
try:
|
|
86
|
+
raw = path.read_text(encoding="utf-8")
|
|
87
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
88
|
+
yield AdapterRecord.rejected(
|
|
89
|
+
1,
|
|
90
|
+
TraceIssue(
|
|
91
|
+
line=1, kind=IssueKind.ENCODING, message=f"could not read {path}: {exc}"
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
for line, payload in _documents(raw):
|
|
97
|
+
if isinstance(payload, Exception):
|
|
98
|
+
yield AdapterRecord.rejected(
|
|
99
|
+
line,
|
|
100
|
+
TraceIssue(line=line, kind=IssueKind.JSON, message=f"invalid JSON: {payload}"),
|
|
101
|
+
)
|
|
102
|
+
continue
|
|
103
|
+
run = _run(payload)
|
|
104
|
+
if run is not None:
|
|
105
|
+
runs.append(run)
|
|
106
|
+
|
|
107
|
+
yield from self._group(runs)
|
|
108
|
+
|
|
109
|
+
def _group(self, runs: list[Run]) -> Iterator[AdapterRecord]:
|
|
110
|
+
grouped: dict[str, list[Run]] = {}
|
|
111
|
+
for run in runs:
|
|
112
|
+
grouped.setdefault(run.trace_id, []).append(run)
|
|
113
|
+
|
|
114
|
+
for line, (trace_id, group) in enumerate(grouped.items(), start=1):
|
|
115
|
+
group.sort(key=lambda run: (run.start_time or "", run.run_id))
|
|
116
|
+
try:
|
|
117
|
+
yield AdapterRecord.valid(line, _build(trace_id, group))
|
|
118
|
+
except ValidationError as exc:
|
|
119
|
+
yield AdapterRecord.rejected(
|
|
120
|
+
line,
|
|
121
|
+
*[
|
|
122
|
+
TraceIssue(
|
|
123
|
+
line=line,
|
|
124
|
+
kind=IssueKind.SCHEMA,
|
|
125
|
+
message=detail["msg"],
|
|
126
|
+
trace_id=trace_id,
|
|
127
|
+
field=".".join(str(part) for part in detail["loc"]) or None,
|
|
128
|
+
)
|
|
129
|
+
for detail in exc.errors()
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _documents(raw: str) -> Iterator[tuple[int, Any]]:
|
|
135
|
+
"""Each run in the export, whether the file is JSONL or a JSON array."""
|
|
136
|
+
stripped = raw.strip()
|
|
137
|
+
if not stripped:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if stripped.startswith("["):
|
|
141
|
+
try:
|
|
142
|
+
parsed = json.loads(stripped)
|
|
143
|
+
except json.JSONDecodeError as exc:
|
|
144
|
+
yield 1, exc
|
|
145
|
+
return
|
|
146
|
+
yield from enumerate(parsed, start=1)
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
for line_number, line in enumerate(raw.splitlines(), start=1):
|
|
150
|
+
if not line.strip():
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
yield line_number, json.loads(line)
|
|
154
|
+
except json.JSONDecodeError as exc:
|
|
155
|
+
yield line_number, exc
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _run(payload: Any) -> Run | None:
|
|
159
|
+
if not isinstance(payload, dict):
|
|
160
|
+
return None
|
|
161
|
+
run_id = payload.get("id") or payload.get("run_id")
|
|
162
|
+
if not isinstance(run_id, str) or not run_id:
|
|
163
|
+
return None
|
|
164
|
+
# A run without a trace_id is its own trace; LangSmith omits it on roots in
|
|
165
|
+
# some export shapes.
|
|
166
|
+
trace_id = payload.get("trace_id") or run_id
|
|
167
|
+
return Run(
|
|
168
|
+
run_id=run_id,
|
|
169
|
+
trace_id=str(trace_id),
|
|
170
|
+
parent_run_id=payload.get("parent_run_id") or None,
|
|
171
|
+
name=str(payload.get("name") or ""),
|
|
172
|
+
run_type=str(payload.get("run_type") or "").lower(),
|
|
173
|
+
inputs=_mapping(payload.get("inputs")),
|
|
174
|
+
outputs=_mapping(payload.get("outputs")),
|
|
175
|
+
error=payload.get("error") or None,
|
|
176
|
+
status=payload.get("status"),
|
|
177
|
+
start_time=payload.get("start_time"),
|
|
178
|
+
end_time=payload.get("end_time"),
|
|
179
|
+
extra=_mapping(payload.get("extra")),
|
|
180
|
+
tags=[str(tag) for tag in payload.get("tags") or []],
|
|
181
|
+
feedback=[f for f in payload.get("feedback") or [] if isinstance(f, dict)],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _mapping(value: Any) -> dict[str, Any]:
|
|
186
|
+
"""A dictionary, or an empty one. Export shapes vary; nothing here raises."""
|
|
187
|
+
return value if isinstance(value, dict) else {}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build(trace_id: str, runs: list[Run]) -> NormalizedTrace:
|
|
191
|
+
root = next((run for run in runs if run.is_root), runs[0])
|
|
192
|
+
payload: dict[str, Any] = {
|
|
193
|
+
"trace_id": trace_id,
|
|
194
|
+
"input": _input(root, runs),
|
|
195
|
+
"events": _events(runs),
|
|
196
|
+
"outcome": _outcome(runs),
|
|
197
|
+
"metadata": _metadata(root, runs),
|
|
198
|
+
}
|
|
199
|
+
output = _output(root)
|
|
200
|
+
if output is not None:
|
|
201
|
+
payload["output"] = output
|
|
202
|
+
return NormalizedTrace.model_validate(payload)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _input(root: Run, runs: list[Run]) -> dict[str, Any]:
|
|
206
|
+
for run in [root, *runs]:
|
|
207
|
+
conversation = _messages(run.inputs)
|
|
208
|
+
if conversation:
|
|
209
|
+
return {"messages": conversation}
|
|
210
|
+
value = _first_text(run.inputs, INPUT_KEYS)
|
|
211
|
+
if value:
|
|
212
|
+
return {"text": value}
|
|
213
|
+
return {"text": root.name or "(no recorded input)"}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _output(root: Run) -> dict[str, Any] | None:
|
|
217
|
+
conversation = _messages(root.outputs)
|
|
218
|
+
if conversation:
|
|
219
|
+
return {"messages": conversation}
|
|
220
|
+
value = _first_text(root.outputs, OUTPUT_KEYS)
|
|
221
|
+
return {"text": value} if value else None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _messages(payload: dict[str, Any]) -> list[dict[str, str]]:
|
|
225
|
+
"""Role/content pairs from a LangChain messages list, if there is one."""
|
|
226
|
+
raw = payload.get("messages")
|
|
227
|
+
if not isinstance(raw, list):
|
|
228
|
+
return []
|
|
229
|
+
found: list[dict[str, str]] = []
|
|
230
|
+
for item in raw:
|
|
231
|
+
# LangChain nests one level deeper for chat model runs.
|
|
232
|
+
entry = item[0] if isinstance(item, list) and item else item
|
|
233
|
+
if not isinstance(entry, dict):
|
|
234
|
+
continue
|
|
235
|
+
role = entry.get("role") or entry.get("type")
|
|
236
|
+
content = entry.get("content")
|
|
237
|
+
if isinstance(role, str) and isinstance(content, str) and content:
|
|
238
|
+
found.append({"role": _role(role), "content": content})
|
|
239
|
+
return found
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _role(value: str) -> str:
|
|
243
|
+
"""LangChain says 'human' and 'ai' where the trace schema says user/assistant."""
|
|
244
|
+
return {"human": "user", "ai": "assistant", "chat": "assistant"}.get(
|
|
245
|
+
value.lower(), value.lower()
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _first_text(payload: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
|
250
|
+
for key in keys:
|
|
251
|
+
value = payload.get(key)
|
|
252
|
+
if isinstance(value, str) and value.strip():
|
|
253
|
+
return value
|
|
254
|
+
if isinstance(value, dict | list) and value:
|
|
255
|
+
return json.dumps(value, default=str)
|
|
256
|
+
if payload:
|
|
257
|
+
return json.dumps(payload, default=str)
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _events(runs: list[Run]) -> list[dict[str, Any]]:
|
|
262
|
+
"""Tool runs become a call and its result; unexecuted intents become calls.
|
|
263
|
+
|
|
264
|
+
As with OpenTelemetry, a call is usually recorded twice -- once as the
|
|
265
|
+
model's declared `tool_calls` and once as the child tool run that executed
|
|
266
|
+
it -- so an intent a tool run accounts for is dropped, one for one.
|
|
267
|
+
"""
|
|
268
|
+
executed = Counter(
|
|
269
|
+
_call_key(_safe_tool_name(run.name), run.inputs) for run in runs if run.run_type == TOOL_RUN
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
events: list[dict[str, Any]] = []
|
|
273
|
+
for run in runs:
|
|
274
|
+
if run.run_type == TOOL_RUN:
|
|
275
|
+
tool = _safe_tool_name(run.name)
|
|
276
|
+
events.append(
|
|
277
|
+
{
|
|
278
|
+
"event_id": f"{run.run_id}-call",
|
|
279
|
+
"type": "tool_call",
|
|
280
|
+
"tool": tool,
|
|
281
|
+
"call_id": run.run_id,
|
|
282
|
+
"arguments": run.inputs,
|
|
283
|
+
"timestamp": _timestamp(run.start_time),
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
events.append(
|
|
287
|
+
{
|
|
288
|
+
"event_id": f"{run.run_id}-result",
|
|
289
|
+
"type": "tool_result",
|
|
290
|
+
"tool": tool,
|
|
291
|
+
"call_id": run.run_id,
|
|
292
|
+
"result": run.outputs or None,
|
|
293
|
+
"error": run.error,
|
|
294
|
+
"timestamp": _timestamp(run.end_time or run.start_time),
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
for index, (tool, arguments) in enumerate(_declared_tool_calls(run)):
|
|
300
|
+
key = _call_key(_safe_tool_name(tool), arguments)
|
|
301
|
+
if executed.get(key, 0) > 0:
|
|
302
|
+
executed[key] -= 1
|
|
303
|
+
continue
|
|
304
|
+
events.append(
|
|
305
|
+
{
|
|
306
|
+
"event_id": f"{run.run_id}-tool-{index}",
|
|
307
|
+
"type": "tool_call",
|
|
308
|
+
"tool": _safe_tool_name(tool),
|
|
309
|
+
"arguments": arguments,
|
|
310
|
+
"timestamp": _timestamp(run.start_time),
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
return events
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _declared_tool_calls(run: Run) -> list[tuple[str, dict[str, Any]]]:
|
|
317
|
+
"""Tool calls a model asked for, wherever LangChain put them this time."""
|
|
318
|
+
calls: list[tuple[str, dict[str, Any]]] = []
|
|
319
|
+
for message in _iter_messages(run.outputs):
|
|
320
|
+
for call in message.get("tool_calls") or []:
|
|
321
|
+
if not isinstance(call, dict):
|
|
322
|
+
continue
|
|
323
|
+
name = call.get("name") or (call.get("function") or {}).get("name")
|
|
324
|
+
arguments = call.get("args")
|
|
325
|
+
if arguments is None:
|
|
326
|
+
arguments = (call.get("function") or {}).get("arguments")
|
|
327
|
+
if isinstance(arguments, str):
|
|
328
|
+
try:
|
|
329
|
+
arguments = json.loads(arguments)
|
|
330
|
+
except json.JSONDecodeError:
|
|
331
|
+
arguments = {"arguments": arguments}
|
|
332
|
+
if isinstance(name, str) and name:
|
|
333
|
+
calls.append((name, arguments if isinstance(arguments, dict) else {}))
|
|
334
|
+
return calls
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _iter_messages(payload: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
|
338
|
+
"""Walk the generations/messages nesting LangChain outputs come in."""
|
|
339
|
+
for generation_list in payload.get("generations") or []:
|
|
340
|
+
entries = generation_list if isinstance(generation_list, list) else [generation_list]
|
|
341
|
+
for entry in entries:
|
|
342
|
+
if not isinstance(entry, dict):
|
|
343
|
+
continue
|
|
344
|
+
message = entry.get("message")
|
|
345
|
+
if isinstance(message, dict):
|
|
346
|
+
yield {**message, **(message.get("kwargs") or {})}
|
|
347
|
+
for entry in payload.get("messages") or []:
|
|
348
|
+
item = entry[0] if isinstance(entry, list) and entry else entry
|
|
349
|
+
if isinstance(item, dict):
|
|
350
|
+
yield item
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _outcome(runs: list[Run]) -> dict[str, Any]:
|
|
354
|
+
"""Errors and explicit feedback are evidence; silence is not success."""
|
|
355
|
+
failed = [run for run in runs if run.failed]
|
|
356
|
+
negative = [item for run in runs for item in run.feedback if _is_negative(item)]
|
|
357
|
+
|
|
358
|
+
if not failed and not negative:
|
|
359
|
+
return {"status": "unknown"}
|
|
360
|
+
|
|
361
|
+
outcome: dict[str, Any] = {"status": "error" if failed else "failure"}
|
|
362
|
+
if failed:
|
|
363
|
+
outcome["evaluations"] = [
|
|
364
|
+
{
|
|
365
|
+
"name": run.name or run.run_type or "run",
|
|
366
|
+
"passed": False,
|
|
367
|
+
"reason": run.error or "the run reported an error status",
|
|
368
|
+
}
|
|
369
|
+
for run in failed
|
|
370
|
+
]
|
|
371
|
+
if negative:
|
|
372
|
+
comment = next((str(item.get("comment")) for item in negative if item.get("comment")), None)
|
|
373
|
+
outcome["feedback"] = {"rating": "negative", "comment": comment}
|
|
374
|
+
return outcome
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _is_negative(item: dict[str, Any]) -> bool:
|
|
378
|
+
"""Only an unambiguous negative counts.
|
|
379
|
+
|
|
380
|
+
A score is meaningless without its scale, so only a numeric zero -- which
|
|
381
|
+
LangSmith's thumbs-down records -- or an explicitly negative value is read
|
|
382
|
+
as evidence. Anything else is left for a person to judge.
|
|
383
|
+
"""
|
|
384
|
+
score = item.get("score")
|
|
385
|
+
if isinstance(score, bool):
|
|
386
|
+
return score is False
|
|
387
|
+
if isinstance(score, int | float):
|
|
388
|
+
return float(score) <= 0.0
|
|
389
|
+
value = item.get("value")
|
|
390
|
+
return isinstance(value, str) and value.lower() in {"negative", "thumbs_down", "bad"}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _metadata(root: Run, runs: list[Run]) -> dict[str, Any]:
|
|
394
|
+
metadata = root.extra.get("metadata") if isinstance(root.extra, dict) else {}
|
|
395
|
+
metadata = metadata if isinstance(metadata, dict) else {}
|
|
396
|
+
model = metadata.get("ls_model_name") or metadata.get("model")
|
|
397
|
+
if not model:
|
|
398
|
+
for run in runs:
|
|
399
|
+
extra = run.extra.get("metadata") if isinstance(run.extra, dict) else {}
|
|
400
|
+
if isinstance(extra, dict) and extra.get("ls_model_name"):
|
|
401
|
+
model = extra["ls_model_name"]
|
|
402
|
+
break
|
|
403
|
+
return {
|
|
404
|
+
"source": "langsmith",
|
|
405
|
+
"agent": str(metadata.get("ls_project_name") or root.name or "") or None,
|
|
406
|
+
"model": str(model) if model else None,
|
|
407
|
+
"recorded_at": _timestamp(root.start_time),
|
|
408
|
+
"tags": root.tags,
|
|
409
|
+
"extra": {"runs": len(runs), "run_type": root.run_type},
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _safe_tool_name(name: str) -> str:
|
|
414
|
+
"""Coerce a run name into something the trace schema accepts as a tool."""
|
|
415
|
+
cleaned = "".join(character if character.isalnum() else "_" for character in name.strip())
|
|
416
|
+
cleaned = cleaned.strip("_") or "tool"
|
|
417
|
+
if not (cleaned[0].isalpha() or cleaned[0] == "_"):
|
|
418
|
+
cleaned = f"_{cleaned}"
|
|
419
|
+
return cleaned[:128]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _call_key(tool: str, arguments: dict[str, Any]) -> str:
|
|
423
|
+
return f"{tool}:{json.dumps(arguments, sort_keys=True, default=str)}"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _timestamp(value: str | None) -> str | None:
|
|
427
|
+
"""LangSmith timestamps are ISO 8601, sometimes without a zone."""
|
|
428
|
+
if not value:
|
|
429
|
+
return None
|
|
430
|
+
try:
|
|
431
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
432
|
+
except ValueError:
|
|
433
|
+
return None
|
|
434
|
+
if parsed.tzinfo is None:
|
|
435
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
436
|
+
return parsed.isoformat()
|