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,169 @@
1
+ """``evalkeep compare``, ``runs`` and ``baseline`` -- reading two runs together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from evalkeep.commands.detect_cmd import default_reviewer
10
+ from evalkeep.comparison import ComparisonReport, compare_results
11
+ from evalkeep.config import Project
12
+ from evalkeep.errors import CommandError
13
+ from evalkeep.runs import BaselinePromotion, CaseResult, EvaluationRun, Outcome
14
+ from evalkeep.storage import TraceStore
15
+ from evalkeep.storage.runs import AmbiguousRun
16
+ from evalkeep.targets import BASELINE, CANDIDATE
17
+
18
+ SUITE_DRIFT_HINT = (
19
+ "The two runs covered different tests, so their pass rates are not "
20
+ "comparable. Re-run both against the current suite, or pass "
21
+ "--allow-suite-drift to compare only the tests they share."
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class RunSummary:
27
+ run: EvaluationRun
28
+ counts: dict[Outcome, int]
29
+ is_baseline: bool = False
30
+
31
+
32
+ def compare(
33
+ *,
34
+ project_root: Path = Path(),
35
+ baseline: str | None = None,
36
+ candidate: str | None = None,
37
+ allow_suite_drift: bool = False,
38
+ ) -> ComparisonReport:
39
+ """Compare two runs, refusing to compare incompatible suites."""
40
+ project = Project.load(project_root.expanduser().resolve())
41
+ with TraceStore.open(project.database_path) as store:
42
+ baseline_run = _resolve_run(store, baseline, default=BASELINE, role="baseline")
43
+ candidate_run = _resolve_run(store, candidate, default=CANDIDATE, role="candidate")
44
+
45
+ if baseline_run.run_id == candidate_run.run_id:
46
+ raise CommandError(
47
+ "The baseline and the candidate are the same run.",
48
+ hint="Pass --baseline and --candidate explicitly.",
49
+ )
50
+
51
+ report = compare_results(
52
+ baseline_run,
53
+ store.runs.results(baseline_run.run_id),
54
+ candidate_run,
55
+ store.runs.results(candidate_run.run_id),
56
+ )
57
+
58
+ if not report.suite_compatible and not allow_suite_drift:
59
+ raise CommandError(
60
+ f"These runs used different test suites "
61
+ f"({baseline_run.suite_hash} vs {candidate_run.suite_hash}).",
62
+ hint=SUITE_DRIFT_HINT,
63
+ )
64
+ return report
65
+
66
+
67
+ def list_runs(*, project_root: Path = Path(), limit: int = 20) -> list[RunSummary]:
68
+ project = Project.load(project_root.expanduser().resolve())
69
+ with TraceStore.open(project.database_path) as store:
70
+ promotion = store.runs.current_baseline()
71
+ return [
72
+ RunSummary(
73
+ run=run,
74
+ counts=store.runs.counts(run.run_id),
75
+ is_baseline=promotion is not None and promotion.run_id == run.run_id,
76
+ )
77
+ for run in store.runs.recent(limit=limit)
78
+ ]
79
+
80
+
81
+ def show_run(run_id: str, *, project_root: Path = Path()) -> tuple[EvaluationRun, list[CaseResult]]:
82
+ project = Project.load(project_root.expanduser().resolve())
83
+ with TraceStore.open(project.database_path) as store:
84
+ run = _lookup(store, run_id)
85
+ if run is None:
86
+ raise CommandError(
87
+ f"No run with ID {run_id.strip()!r}.",
88
+ hint="Run 'evalkeep runs list' to see what exists.",
89
+ )
90
+ return run, store.runs.results(run.run_id)
91
+
92
+
93
+ def promote_baseline(
94
+ run_id: str,
95
+ *,
96
+ project_root: Path = Path(),
97
+ reviewer: str | None = None,
98
+ reason: str | None = None,
99
+ ) -> BaselinePromotion:
100
+ """Make a run the reference point. Only ever an explicit, recorded decision."""
101
+ project = Project.load(project_root.expanduser().resolve())
102
+ with TraceStore.open(project.database_path) as store:
103
+ run = _lookup(store, run_id)
104
+ if run is None:
105
+ raise CommandError(
106
+ f"No run with ID {run_id.strip()!r}.",
107
+ hint="Run 'evalkeep runs list' to see what exists.",
108
+ )
109
+ errored = store.runs.counts(run.run_id).get(Outcome.ERROR, 0)
110
+ if errored:
111
+ raise CommandError(
112
+ f"That run had {errored} test(s) that never executed, so it is not "
113
+ "a sound reference point.",
114
+ hint="Fix the target and re-run before promoting.",
115
+ )
116
+ promotion = BaselinePromotion(
117
+ promotion_id=uuid.uuid4().hex,
118
+ run_id=run.run_id,
119
+ target_id=run.target_id,
120
+ reviewer=reviewer or default_reviewer(),
121
+ reason=reason,
122
+ )
123
+ store.runs.promote(promotion)
124
+ return promotion
125
+
126
+
127
+ def current_baseline(
128
+ *, project_root: Path = Path()
129
+ ) -> tuple[BaselinePromotion, EvaluationRun] | None:
130
+ project = Project.load(project_root.expanduser().resolve())
131
+ with TraceStore.open(project.database_path) as store:
132
+ promotion = store.runs.current_baseline()
133
+ if promotion is None:
134
+ return None
135
+ run = store.runs.get(promotion.run_id)
136
+ return (promotion, run) if run is not None else None
137
+
138
+
139
+ def _lookup(store: TraceStore, identifier: str) -> EvaluationRun | None:
140
+ try:
141
+ return store.runs.resolve(identifier)
142
+ except AmbiguousRun as exc:
143
+ raise CommandError(str(exc), hint="Use more characters of the run ID.") from exc
144
+
145
+
146
+ def _resolve_run(
147
+ store: TraceStore, identifier: str | None, *, default: str, role: str
148
+ ) -> EvaluationRun:
149
+ """Accept a run ID, a target name, or nothing at all.
150
+
151
+ With nothing given, the baseline is whichever run was explicitly promoted --
152
+ falling back to the newest run for the conventional target name only when no
153
+ promotion has ever been recorded.
154
+ """
155
+ if identifier is None and role == "baseline":
156
+ promotion = store.runs.current_baseline()
157
+ if promotion is not None:
158
+ run = store.runs.get(promotion.run_id)
159
+ if run is not None:
160
+ return run
161
+
162
+ name = (identifier or default).strip()
163
+ run = _lookup(store, name) or store.runs.latest(name)
164
+ if run is None:
165
+ raise CommandError(
166
+ f"No {role} run matching {name!r}.",
167
+ hint="Run 'evalkeep runs list', or 'evalkeep run --target ...' first.",
168
+ )
169
+ return run
@@ -0,0 +1,182 @@
1
+ """``evalkeep dataset`` -- generate and inspect regression-test drafts.
2
+
3
+ By default only cluster *representatives* get a test. That is the entire point
4
+ of clustering: a suite wants one good test per failure family, not forty copies
5
+ of the same bug. ``--all`` overrides it when you want coverage of every failure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from evalkeep.clusters import Cluster
14
+ from evalkeep.config import Project
15
+ from evalkeep.errors import CommandError
16
+ from evalkeep.failures import Failure, FailureStatus
17
+ from evalkeep.generation import GENERATOR_VERSION, build_test
18
+ from evalkeep.regression import RegressionTest, ReviewStatus
19
+ from evalkeep.storage import TraceStore
20
+
21
+
22
+ @dataclass
23
+ class BuildReport:
24
+ """What one ``dataset build`` did."""
25
+
26
+ considered: int = 0
27
+ created: int = 0
28
+ regenerated: int = 0
29
+ skipped: int = 0
30
+ reviewed_kept: int = 0
31
+ unanalyzed: int = 0
32
+ needs_expectation: int = 0
33
+ contradictions: int = 0
34
+ generator_version: int = GENERATOR_VERSION
35
+ warnings: list[tuple[str, str]] = field(default_factory=list)
36
+
37
+ @property
38
+ def changed(self) -> bool:
39
+ return bool(self.created or self.regenerated)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class DatasetListing:
44
+ tests: list[RegressionTest]
45
+ total: int
46
+ counts: dict[ReviewStatus, int]
47
+
48
+
49
+ def build_dataset(
50
+ *,
51
+ project_root: Path = Path(),
52
+ representatives_only: bool = True,
53
+ regenerate: bool = False,
54
+ limit: int | None = None,
55
+ ) -> BuildReport:
56
+ """Generate pending drafts for the failures worth covering."""
57
+ project = Project.load(project_root.expanduser().resolve())
58
+ report = BuildReport()
59
+
60
+ with TraceStore.open(project.database_path) as store:
61
+ clusters = store.clusters.list(include_dismissed=False)
62
+ if representatives_only and not clusters:
63
+ raise CommandError(
64
+ "No clusters to draw representatives from.",
65
+ hint="Run 'evalkeep discover' first, or pass --all to cover every failure.",
66
+ )
67
+
68
+ targets = _targets(store, clusters, representatives_only=representatives_only)
69
+ if not targets:
70
+ raise CommandError(
71
+ "Nothing to generate tests from.",
72
+ hint="Run 'evalkeep detect' and 'evalkeep analyze' first.",
73
+ )
74
+
75
+ for failure, cluster, roles in targets:
76
+ if limit is not None and report.created + report.regenerated >= limit:
77
+ break
78
+ report.considered += 1
79
+
80
+ existing = store.tests.get_by_failure(failure.failure_id)
81
+ if existing is not None and not regenerate:
82
+ report.skipped += 1
83
+ continue
84
+ if existing is not None and existing.reviewed:
85
+ # Regeneration rewrites a draft; it does not undo a review.
86
+ report.reviewed_kept += 1
87
+ continue
88
+
89
+ analysis = store.failures.get_analysis(failure.failure_id)
90
+ if analysis is None:
91
+ # Generated anyway: detection found evidence, and the trace
92
+ # still shows what the agent did. The draft says plainly that
93
+ # nobody has diagnosed it.
94
+ report.unanalyzed += 1
95
+ stored = store.get(failure.trace_id)
96
+ if stored is None: # pragma: no cover - the foreign key prevents this
97
+ continue
98
+
99
+ test = build_test(
100
+ stored.trace,
101
+ failure,
102
+ analysis,
103
+ cluster_id=cluster.cluster_id if cluster else None,
104
+ cluster_label=cluster.label if cluster else None,
105
+ representative_roles=roles,
106
+ )
107
+ if existing is not None:
108
+ test.created_at = existing.created_at
109
+ report.regenerated += 1
110
+ else:
111
+ report.created += 1
112
+
113
+ if not test.has_positive_expectation:
114
+ report.needs_expectation += 1
115
+ report.contradictions += len(test.contradictions)
116
+ report.warnings.extend((test.test_id, warning) for warning in test.warnings)
117
+
118
+ store.tests.save(test)
119
+
120
+ return report
121
+
122
+
123
+ def list_tests(
124
+ *,
125
+ project_root: Path = Path(),
126
+ status: ReviewStatus | None = None,
127
+ limit: int = 50,
128
+ offset: int = 0,
129
+ ) -> DatasetListing:
130
+ project = Project.load(project_root.expanduser().resolve())
131
+ with TraceStore.open(project.database_path) as store:
132
+ return DatasetListing(
133
+ tests=store.tests.list(status=status, limit=limit, offset=offset),
134
+ total=store.tests.count(status=status),
135
+ counts=store.tests.counts_by_status(),
136
+ )
137
+
138
+
139
+ def show_test(identifier: str, *, project_root: Path = Path()) -> RegressionTest:
140
+ """Look one up by test ID, failure ID or the trace it came from."""
141
+ project = Project.load(project_root.expanduser().resolve())
142
+ with TraceStore.open(project.database_path) as store:
143
+ cleaned = identifier.strip()
144
+ test = store.tests.get(cleaned) or store.tests.get_by_failure(cleaned)
145
+ if test is None:
146
+ failure = store.failures.get_by_trace(cleaned)
147
+ if failure is not None:
148
+ test = store.tests.get_by_failure(failure.failure_id)
149
+ if test is None:
150
+ raise CommandError(
151
+ f"No regression test matching {cleaned!r}.",
152
+ hint="Run 'evalkeep dataset list', or 'evalkeep dataset build' first.",
153
+ )
154
+ return test
155
+
156
+
157
+ def _targets(
158
+ store: TraceStore, clusters: list[Cluster], *, representatives_only: bool
159
+ ) -> list[tuple[Failure, Cluster | None, list[str]]]:
160
+ """Which failures get a test, and what cluster context to record with each."""
161
+ context: dict[str, tuple[Cluster, list[str]]] = {}
162
+ for cluster in clusters:
163
+ for member in cluster.members:
164
+ context[member.failure_id] = (
165
+ cluster,
166
+ [role.value for role in member.roles],
167
+ )
168
+
169
+ targets: list[tuple[Failure, Cluster | None, list[str]]] = []
170
+ for failure in store.failures.iter_all():
171
+ if failure.status is FailureStatus.DISMISSED:
172
+ continue
173
+ entry = context.get(failure.failure_id)
174
+ if representatives_only:
175
+ if entry is None or not entry[1]:
176
+ continue
177
+ targets.append((failure, entry[0], entry[1]))
178
+ elif entry is None:
179
+ targets.append((failure, None, []))
180
+ else:
181
+ targets.append((failure, entry[0], entry[1]))
182
+ return targets
@@ -0,0 +1,154 @@
1
+ """``evalkeep detect`` and ``evalkeep failures`` -- evidence and review."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from evalkeep.analysis import FailureAnalysis
10
+ from evalkeep.config import Project
11
+ from evalkeep.detection import DetectionReport, detect_failures
12
+ from evalkeep.errors import CommandError
13
+ from evalkeep.failures import Failure, FailureStatus, failure_id_for
14
+ from evalkeep.storage import FailureSummary, StoredTrace, TraceStore
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FailureListing:
19
+ summaries: list[FailureSummary]
20
+ total: int
21
+ counts: dict[FailureStatus, int]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class FailureDetail:
26
+ """A failure together with the trace it describes and how it was labelled."""
27
+
28
+ failure: Failure
29
+ trace: StoredTrace
30
+ analysis: FailureAnalysis | None = None
31
+
32
+
33
+ def default_reviewer() -> str:
34
+ """Who to record as the reviewer when the caller did not say."""
35
+ try:
36
+ return getpass.getuser()
37
+ except Exception: # pragma: no cover - getuser can fail on odd systems
38
+ return "unknown"
39
+
40
+
41
+ def run_detection(*, project_root: Path = Path()) -> DetectionReport:
42
+ project = Project.load(project_root.expanduser().resolve())
43
+ with TraceStore.open(project.database_path) as store:
44
+ if store.count() == 0:
45
+ raise CommandError(
46
+ "No traces have been ingested yet.",
47
+ hint="Run 'evalkeep ingest traces.jsonl' first.",
48
+ )
49
+ return detect_failures(store)
50
+
51
+
52
+ def list_failures(
53
+ *,
54
+ project_root: Path = Path(),
55
+ status: FailureStatus | None = None,
56
+ limit: int = 50,
57
+ offset: int = 0,
58
+ ) -> FailureListing:
59
+ project = Project.load(project_root.expanduser().resolve())
60
+ with TraceStore.open(project.database_path) as store:
61
+ return FailureListing(
62
+ summaries=store.failures.list(status=status, limit=limit, offset=offset),
63
+ total=store.failures.count(status=status),
64
+ counts=store.failures.counts_by_status(),
65
+ )
66
+
67
+
68
+ def show_failure(identifier: str, *, project_root: Path = Path()) -> FailureDetail:
69
+ """Look one up by failure ID or by the trace ID it belongs to."""
70
+ project = Project.load(project_root.expanduser().resolve())
71
+ with TraceStore.open(project.database_path) as store:
72
+ failure = resolve_failure(store, identifier, project=project)
73
+ stored = store.get(failure.trace_id)
74
+ if stored is None: # pragma: no cover - the foreign key prevents this
75
+ raise CommandError(f"Trace {failure.trace_id!r} is missing from the store.")
76
+ return FailureDetail(
77
+ failure=failure,
78
+ trace=stored,
79
+ analysis=store.failures.get_analysis(failure.failure_id),
80
+ )
81
+
82
+
83
+ def review_failure(
84
+ identifier: str,
85
+ status: FailureStatus,
86
+ *,
87
+ project_root: Path = Path(),
88
+ reviewer: str | None = None,
89
+ reason: str | None = None,
90
+ ) -> Failure:
91
+ """Record a human decision on an existing failure."""
92
+ project = Project.load(project_root.expanduser().resolve())
93
+ with TraceStore.open(project.database_path) as store:
94
+ failure = resolve_failure(store, identifier, project=project)
95
+ failure.review(status, reviewer=reviewer or default_reviewer(), reason=reason)
96
+ store.failures.save(failure)
97
+ return failure
98
+
99
+
100
+ def add_failure(
101
+ trace_id: str,
102
+ *,
103
+ project_root: Path = Path(),
104
+ reviewer: str | None = None,
105
+ reason: str | None = None,
106
+ ) -> Failure:
107
+ """Mark a trace as a failure by hand, with no detector evidence."""
108
+ project = Project.load(project_root.expanduser().resolve())
109
+ with TraceStore.open(project.database_path) as store:
110
+ stored_id = next((c for c in project.identify(trace_id) if store.get(c) is not None), None)
111
+ if stored_id is None:
112
+ raise CommandError(
113
+ f"No stored trace with ID {trace_id.strip()!r}.",
114
+ hint="Run 'evalkeep trace list' to see what has been ingested.",
115
+ )
116
+ existing = store.failures.get_by_trace(stored_id)
117
+ if existing is not None:
118
+ raise CommandError(
119
+ f"Trace {trace_id.strip()!r} already has failure {existing.failure_id} "
120
+ f"({existing.status.value}).",
121
+ hint=f"Use 'evalkeep failures confirm {existing.failure_id}' instead.",
122
+ )
123
+ failure = Failure.manual(stored_id, reviewer=reviewer or default_reviewer(), reason=reason)
124
+ store.failures.save(failure)
125
+ return failure
126
+
127
+
128
+ def resolve_failure(
129
+ store: TraceStore, identifier: str, *, project: Project | None = None
130
+ ) -> Failure:
131
+ """Accept a failure ID, or the trace ID it was derived from.
132
+
133
+ With pseudonymization on, the trace ID someone types may be the original
134
+ rather than the stored token, so every candidate spelling is tried.
135
+ """
136
+ cleaned = identifier.strip()
137
+ candidates = project.identify(cleaned) if project is not None else [cleaned]
138
+
139
+ failure = store.failures.get(cleaned)
140
+ for candidate in candidates:
141
+ if failure is not None:
142
+ break
143
+ failure = store.failures.get_by_trace(candidate)
144
+ for candidate in candidates:
145
+ if failure is not None:
146
+ break
147
+ if not candidate.startswith("fail-"):
148
+ failure = store.failures.get(failure_id_for(candidate))
149
+ if failure is None:
150
+ raise CommandError(
151
+ f"No failure matching {cleaned!r}.",
152
+ hint="Run 'evalkeep failures list', or 'evalkeep detect' if you have not detected yet.",
153
+ )
154
+ return failure