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
evalkeep/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Evalkeep: turn production agent failures into a small, reviewed regression suite."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__", "main"]
6
+
7
+
8
+ def main() -> None:
9
+ """Console-script entry point (delegates to the Typer app)."""
10
+ from evalkeep.cli import main as _main
11
+
12
+ _main()
evalkeep/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m evalkeep``."""
2
+
3
+ from evalkeep.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,45 @@
1
+ """Trace adapters and the registry the CLI resolves ``--format`` against."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from evalkeep.adapters.base import AdapterRecord, IssueKind, TraceAdapter, TraceIssue
6
+ from evalkeep.adapters.jsonl import JsonlAdapter
7
+ from evalkeep.adapters.langsmith import LangSmithAdapter
8
+ from evalkeep.adapters.otlp import OtlpAdapter
9
+ from evalkeep.errors import CommandError
10
+
11
+ DEFAULT_ADAPTER = "jsonl"
12
+
13
+ _ADAPTERS: dict[str, TraceAdapter] = {
14
+ JsonlAdapter.name: JsonlAdapter(),
15
+ LangSmithAdapter.name: LangSmithAdapter(),
16
+ OtlpAdapter.name: OtlpAdapter(),
17
+ }
18
+
19
+
20
+ def available_adapters() -> dict[str, TraceAdapter]:
21
+ return dict(_ADAPTERS)
22
+
23
+
24
+ def get_adapter(name: str) -> TraceAdapter:
25
+ try:
26
+ return _ADAPTERS[name]
27
+ except KeyError:
28
+ known = ", ".join(sorted(_ADAPTERS))
29
+ raise CommandError(
30
+ f"Unknown trace format {name!r}.", hint=f"Available formats: {known}."
31
+ ) from None
32
+
33
+
34
+ __all__ = [
35
+ "DEFAULT_ADAPTER",
36
+ "AdapterRecord",
37
+ "IssueKind",
38
+ "JsonlAdapter",
39
+ "LangSmithAdapter",
40
+ "OtlpAdapter",
41
+ "TraceAdapter",
42
+ "TraceIssue",
43
+ "available_adapters",
44
+ "get_adapter",
45
+ ]
@@ -0,0 +1,92 @@
1
+ """The adapter contract: every adapter normalizes or fails, never both.
2
+
3
+ An adapter turns one provider's records into :class:`AdapterRecord` values,
4
+ streaming them so that a 100k-trace file never has to fit in memory. A record
5
+ either carries a validated trace or carries the issues explaining why it could
6
+ not be validated -- an adapter never raises on bad input data. Exceptions are
7
+ reserved for the file itself being unusable, which the CLI reports as a command
8
+ error.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Iterator
14
+ from dataclasses import dataclass, field
15
+ from enum import StrEnum
16
+ from pathlib import Path
17
+ from typing import Any, ClassVar, Protocol, runtime_checkable
18
+
19
+ from evalkeep.trace import NormalizedTrace
20
+
21
+
22
+ class IssueKind(StrEnum):
23
+ """Why a record was rejected. Written verbatim into the error JSONL."""
24
+
25
+ ENCODING = "encoding"
26
+ JSON = "json"
27
+ SCHEMA = "schema"
28
+ DUPLICATE_ID = "duplicate_id"
29
+ #: The trace ID is already stored with different content.
30
+ ID_CONFLICT = "id_conflict"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TraceIssue:
35
+ """One reason one record is not usable, addressed to whoever must fix it."""
36
+
37
+ line: int
38
+ kind: IssueKind
39
+ message: str
40
+ trace_id: str | None = None
41
+ field: str | None = None
42
+ hint: str | None = None
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ """The structured error-JSONL record."""
46
+ record: dict[str, Any] = {
47
+ "line": self.line,
48
+ "kind": self.kind.value,
49
+ "message": self.message,
50
+ }
51
+ if self.trace_id is not None:
52
+ record["trace_id"] = self.trace_id
53
+ if self.field is not None:
54
+ record["field"] = self.field
55
+ if self.hint is not None:
56
+ record["hint"] = self.hint
57
+ return record
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class AdapterRecord:
62
+ """One source record: either a trace, or the issues that rejected it."""
63
+
64
+ line: int
65
+ trace: NormalizedTrace | None = None
66
+ issues: tuple[TraceIssue, ...] = field(default_factory=tuple)
67
+
68
+ @property
69
+ def ok(self) -> bool:
70
+ return self.trace is not None
71
+
72
+ @classmethod
73
+ def valid(cls, line: int, trace: NormalizedTrace) -> AdapterRecord:
74
+ return cls(line=line, trace=trace)
75
+
76
+ @classmethod
77
+ def rejected(cls, line: int, *issues: TraceIssue) -> AdapterRecord:
78
+ if not issues:
79
+ raise ValueError("a rejected record must carry at least one issue")
80
+ return cls(line=line, issues=tuple(issues))
81
+
82
+
83
+ @runtime_checkable
84
+ class TraceAdapter(Protocol):
85
+ """Converts a provider format into normalized traces."""
86
+
87
+ name: ClassVar[str]
88
+ description: ClassVar[str]
89
+
90
+ def read(self, path: Path) -> Iterator[AdapterRecord]:
91
+ """Stream records from ``path``, one per source record."""
92
+ ...
@@ -0,0 +1,164 @@
1
+ """The generic JSONL adapter: one JSON object per line, already normalized.
2
+
3
+ This is the format Evalkeep documents for users who export traces themselves.
4
+ Provider-specific adapters (Langfuse, Opik, OpenTelemetry) arrive in 0.2 and
5
+ map onto the same contract.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Iterable, Iterator
12
+ from pathlib import Path
13
+ from typing import Any, ClassVar
14
+
15
+ from pydantic import ValidationError
16
+
17
+ from evalkeep.adapters.base import AdapterRecord, IssueKind, TraceIssue
18
+ from evalkeep.trace import NormalizedTrace
19
+
20
+ EXTRA_FIELD_HINT = "Unknown fields belong under 'metadata.extra'."
21
+
22
+
23
+ class JsonlAdapter:
24
+ """Reads newline-delimited JSON, one normalized trace per line."""
25
+
26
+ name: ClassVar[str] = "jsonl"
27
+ description: ClassVar[str] = "Generic newline-delimited JSON (one trace object per line)"
28
+
29
+ def read(self, path: Path) -> Iterator[AdapterRecord]:
30
+ """Stream ``path`` without loading it into memory.
31
+
32
+ Opened in binary so that a single undecodable line becomes one rejected
33
+ record instead of aborting the whole file.
34
+ """
35
+ with path.open("rb") as handle:
36
+ yield from self.read_binary_lines(handle)
37
+
38
+ def read_binary_lines(self, lines: Iterable[bytes]) -> Iterator[AdapterRecord]:
39
+ for line_number, raw in enumerate(lines, start=1):
40
+ try:
41
+ text = raw.decode("utf-8")
42
+ except UnicodeDecodeError as exc:
43
+ yield AdapterRecord.rejected(
44
+ line_number,
45
+ TraceIssue(
46
+ line=line_number,
47
+ kind=IssueKind.ENCODING,
48
+ message=f"line is not valid UTF-8: {exc.reason}",
49
+ ),
50
+ )
51
+ continue
52
+ record = self._parse(line_number, text)
53
+ if record is not None:
54
+ yield record
55
+
56
+ def read_lines(self, lines: Iterable[str]) -> Iterator[AdapterRecord]:
57
+ """Same contract as :meth:`read`, over already-decoded lines."""
58
+ for line_number, text in enumerate(lines, start=1):
59
+ record = self._parse(line_number, text)
60
+ if record is not None:
61
+ yield record
62
+
63
+ def _parse(self, line_number: int, text: str) -> AdapterRecord | None:
64
+ """Return a record, or ``None`` for a blank line (not a record at all)."""
65
+ stripped = text.lstrip("\ufeff").strip()
66
+ if not stripped:
67
+ return None
68
+
69
+ try:
70
+ payload: Any = json.loads(stripped)
71
+ except json.JSONDecodeError as exc:
72
+ return AdapterRecord.rejected(
73
+ line_number,
74
+ TraceIssue(
75
+ line=line_number,
76
+ kind=IssueKind.JSON,
77
+ message=f"invalid JSON: {exc.msg} at column {exc.colno}",
78
+ ),
79
+ )
80
+
81
+ if not isinstance(payload, dict):
82
+ return AdapterRecord.rejected(
83
+ line_number,
84
+ TraceIssue(
85
+ line=line_number,
86
+ kind=IssueKind.JSON,
87
+ message=f"expected a JSON object, got {type(payload).__name__}",
88
+ ),
89
+ )
90
+
91
+ try:
92
+ trace = NormalizedTrace.model_validate(payload)
93
+ except ValidationError as exc:
94
+ return AdapterRecord.rejected(
95
+ line_number, *_issues_from_validation_error(line_number, payload, exc)
96
+ )
97
+ return AdapterRecord.valid(line_number, trace)
98
+
99
+
100
+ def _issues_from_validation_error(
101
+ line_number: int, payload: dict[str, Any], error: ValidationError
102
+ ) -> list[TraceIssue]:
103
+ """One issue per field error, so the error JSONL is directly actionable."""
104
+ trace_id = _best_effort_trace_id(payload)
105
+ issues: list[TraceIssue] = []
106
+ for detail in error.errors():
107
+ location = _format_location(detail["loc"], payload)
108
+ issues.append(
109
+ TraceIssue(
110
+ line=line_number,
111
+ kind=IssueKind.SCHEMA,
112
+ message=detail["msg"],
113
+ trace_id=trace_id,
114
+ field=location or None,
115
+ hint=EXTRA_FIELD_HINT if detail["type"] == "extra_forbidden" else None,
116
+ )
117
+ )
118
+ return issues
119
+
120
+
121
+ def _format_location(location: tuple[int | str, ...], payload: Any) -> str:
122
+ """Render a Pydantic error location as a path into the user's own JSON.
123
+
124
+ Pydantic inserts the union tag for a discriminated union, so a bad tool name
125
+ arrives as ``('events', 0, 'tool_call', 'tool')``. There is no ``tool_call``
126
+ key in the document, and telling someone to look at a path that does not
127
+ exist wastes their time. Any non-final segment that cannot be resolved
128
+ against the payload is dropped; the final segment is always kept, since a
129
+ missing required field is exactly the one that will not resolve.
130
+ """
131
+ parts: list[str] = []
132
+ current: Any = payload
133
+ last_index = len(location) - 1
134
+ for index, part in enumerate(location):
135
+ resolved = _descend(current, part)
136
+ if resolved is _UNRESOLVED and index != last_index:
137
+ continue
138
+ parts.append(str(part))
139
+ current = None if resolved is _UNRESOLVED else resolved
140
+ return ".".join(parts)
141
+
142
+
143
+ _UNRESOLVED = object()
144
+
145
+
146
+ def _descend(container: Any, part: int | str) -> Any:
147
+ """One step into ``container``, or ``_UNRESOLVED`` if the step is not there."""
148
+ if isinstance(container, dict) and part in container:
149
+ return container[part]
150
+ if (
151
+ isinstance(container, list)
152
+ and isinstance(part, int)
153
+ and -len(container) <= part < len(container)
154
+ ):
155
+ return container[part]
156
+ return _UNRESOLVED
157
+
158
+
159
+ def _best_effort_trace_id(payload: dict[str, Any]) -> str | None:
160
+ """Name the offending record even when the record failed validation."""
161
+ candidate = payload.get("trace_id")
162
+ if isinstance(candidate, str) and candidate.strip():
163
+ return candidate.strip()
164
+ return None