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.
Files changed (75) hide show
  1. evalkeep/__init__.py +12 -0
  2. evalkeep/__main__.py +6 -0
  3. evalkeep/adapters/__init__.py +45 -0
  4. evalkeep/adapters/base.py +92 -0
  5. evalkeep/adapters/jsonl.py +164 -0
  6. evalkeep/adapters/langsmith.py +436 -0
  7. evalkeep/adapters/otlp.py +442 -0
  8. evalkeep/adapters/semconv.py +208 -0
  9. evalkeep/analysis.py +174 -0
  10. evalkeep/analysis_run.py +160 -0
  11. evalkeep/analyzers/__init__.py +52 -0
  12. evalkeep/analyzers/anthropic.py +145 -0
  13. evalkeep/analyzers/stub.py +34 -0
  14. evalkeep/cache.py +122 -0
  15. evalkeep/cli.py +1933 -0
  16. evalkeep/clustering.py +383 -0
  17. evalkeep/clusters.py +101 -0
  18. evalkeep/commands/__init__.py +1 -0
  19. evalkeep/commands/analyze_cmd.py +100 -0
  20. evalkeep/commands/compare_cmd.py +169 -0
  21. evalkeep/commands/dataset_cmd.py +182 -0
  22. evalkeep/commands/detect_cmd.py +154 -0
  23. evalkeep/commands/discover_cmd.py +274 -0
  24. evalkeep/commands/ingest_cmd.py +50 -0
  25. evalkeep/commands/init_cmd.py +151 -0
  26. evalkeep/commands/pipeline_cmd.py +156 -0
  27. evalkeep/commands/review_cmd.py +141 -0
  28. evalkeep/commands/run_cmd.py +131 -0
  29. evalkeep/commands/target_cmd.py +109 -0
  30. evalkeep/commands/trace_cmd.py +58 -0
  31. evalkeep/comparison.py +432 -0
  32. evalkeep/config.py +209 -0
  33. evalkeep/detection.py +94 -0
  34. evalkeep/detectors.py +182 -0
  35. evalkeep/discovery.py +208 -0
  36. evalkeep/embeddings/__init__.py +31 -0
  37. evalkeep/embeddings/base.py +32 -0
  38. evalkeep/embeddings/hashing.py +98 -0
  39. evalkeep/errors.py +42 -0
  40. evalkeep/examples/__init__.py +37 -0
  41. evalkeep/examples/langsmith/runs.jsonl +18 -0
  42. evalkeep/examples/opentelemetry/spans.json +898 -0
  43. evalkeep/examples/refund-agent/agents/baseline.py +66 -0
  44. evalkeep/examples/refund-agent/agents/candidate.py +66 -0
  45. evalkeep/examples/refund-agent/traces.jsonl +5 -0
  46. evalkeep/examples/tau-bench/prepare.py +230 -0
  47. evalkeep/exporters/__init__.py +45 -0
  48. evalkeep/exporters/generic.py +31 -0
  49. evalkeep/exporters/promptfoo.py +219 -0
  50. evalkeep/failures.py +95 -0
  51. evalkeep/generation.py +303 -0
  52. evalkeep/hashing.py +56 -0
  53. evalkeep/ingest.py +257 -0
  54. evalkeep/prompts.py +127 -0
  55. evalkeep/pseudonyms.py +82 -0
  56. evalkeep/py.typed +0 -0
  57. evalkeep/redaction.py +333 -0
  58. evalkeep/regression.py +409 -0
  59. evalkeep/review.py +309 -0
  60. evalkeep/runner.py +302 -0
  61. evalkeep/runs.py +185 -0
  62. evalkeep/storage/__init__.py +37 -0
  63. evalkeep/storage/clusters.py +163 -0
  64. evalkeep/storage/failures.py +254 -0
  65. evalkeep/storage/migrations.py +370 -0
  66. evalkeep/storage/regression.py +136 -0
  67. evalkeep/storage/runs.py +223 -0
  68. evalkeep/storage/store.py +429 -0
  69. evalkeep/targets.py +205 -0
  70. evalkeep/trace.py +238 -0
  71. evalkeep-0.1.0.dist-info/METADATA +221 -0
  72. evalkeep-0.1.0.dist-info/RECORD +75 -0
  73. evalkeep-0.1.0.dist-info/WHEEL +4 -0
  74. evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
  75. 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()