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
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,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
|