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,274 @@
|
|
|
1
|
+
"""``evalkeep discover`` and ``evalkeep clusters`` -- group failures and edit groups.
|
|
2
|
+
|
|
3
|
+
Editing a clustering is a first-class operation, not an escape hatch. A distance
|
|
4
|
+
metric over short summaries will always split a family that a person can see is
|
|
5
|
+
one, and merge two that a person can see are not; merge, split, rename and
|
|
6
|
+
dismiss are how that judgement gets recorded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from evalkeep.analysis import FailureAnalysis
|
|
15
|
+
from evalkeep.cache import EmbeddingCache
|
|
16
|
+
from evalkeep.clustering import ClusterInput, assign_roles, derive_label
|
|
17
|
+
from evalkeep.clusters import Cluster, ClusterMember
|
|
18
|
+
from evalkeep.commands.analyze_cmd import run_analysis
|
|
19
|
+
from evalkeep.commands.detect_cmd import default_reviewer
|
|
20
|
+
from evalkeep.config import Project
|
|
21
|
+
from evalkeep.discovery import ClusterEditsWouldBeLost, DiscoveryReport, discover
|
|
22
|
+
from evalkeep.embeddings import get_embedder
|
|
23
|
+
from evalkeep.errors import CommandError
|
|
24
|
+
from evalkeep.storage import TraceStore
|
|
25
|
+
|
|
26
|
+
EDITS_WOULD_BE_LOST_HINT = "Re-run with --force to discard them, or leave the clustering as it is."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ClusterDetail:
|
|
31
|
+
"""A cluster together with what each member is."""
|
|
32
|
+
|
|
33
|
+
cluster: Cluster
|
|
34
|
+
analyses: dict[str, FailureAnalysis]
|
|
35
|
+
trace_ids: dict[str, str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_discovery(
|
|
39
|
+
*,
|
|
40
|
+
project_root: Path = Path(),
|
|
41
|
+
analyze: bool = True,
|
|
42
|
+
force: bool = False,
|
|
43
|
+
use_cache: bool = True,
|
|
44
|
+
group_undescribed: bool = False,
|
|
45
|
+
) -> DiscoveryReport:
|
|
46
|
+
"""Analyze (when a provider is configured), embed, cluster and select."""
|
|
47
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
48
|
+
|
|
49
|
+
if analyze and project.config.analyzer.provider != "manual":
|
|
50
|
+
run_analysis(project_root=project_root, use_cache=use_cache)
|
|
51
|
+
|
|
52
|
+
embedder = get_embedder(project.config.clustering)
|
|
53
|
+
cache = EmbeddingCache(project.subdir("cache"), enabled=use_cache)
|
|
54
|
+
|
|
55
|
+
with TraceStore.open(project.database_path) as store:
|
|
56
|
+
if store.failures.count() == 0:
|
|
57
|
+
raise CommandError(
|
|
58
|
+
"No failure candidates to group.", hint="Run 'evalkeep detect' first."
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
report = discover(
|
|
62
|
+
store,
|
|
63
|
+
embedder,
|
|
64
|
+
cache,
|
|
65
|
+
project.config.clustering,
|
|
66
|
+
force=force,
|
|
67
|
+
group_undescribed=group_undescribed,
|
|
68
|
+
)
|
|
69
|
+
except ClusterEditsWouldBeLost as exc:
|
|
70
|
+
names = ", ".join(cluster.label for cluster in exc.clusters)
|
|
71
|
+
raise CommandError(
|
|
72
|
+
f"Re-clustering would discard reviewer edits on {len(exc.clusters)} "
|
|
73
|
+
f"cluster(s): {names}.",
|
|
74
|
+
hint=EDITS_WOULD_BE_LOST_HINT,
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
if report.clusters == 0:
|
|
78
|
+
raise CommandError(
|
|
79
|
+
"No analyzed failures to group.",
|
|
80
|
+
hint="Run 'evalkeep analyze', or label failures by hand with "
|
|
81
|
+
"'evalkeep failures label'.",
|
|
82
|
+
)
|
|
83
|
+
return report
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def list_clusters(*, project_root: Path = Path(), include_dismissed: bool = True) -> list[Cluster]:
|
|
87
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
88
|
+
with TraceStore.open(project.database_path) as store:
|
|
89
|
+
return store.clusters.list(include_dismissed=include_dismissed)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def show_cluster(cluster_id: str, *, project_root: Path = Path()) -> ClusterDetail:
|
|
93
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
94
|
+
with TraceStore.open(project.database_path) as store:
|
|
95
|
+
cluster = _resolve(store, cluster_id)
|
|
96
|
+
return _detail(store, cluster)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def rename_cluster(
|
|
100
|
+
cluster_id: str, label: str, *, project_root: Path = Path(), reviewer: str | None = None
|
|
101
|
+
) -> Cluster:
|
|
102
|
+
cleaned = label.strip()
|
|
103
|
+
if not cleaned:
|
|
104
|
+
raise CommandError("A cluster label cannot be empty.")
|
|
105
|
+
|
|
106
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
107
|
+
with TraceStore.open(project.database_path) as store:
|
|
108
|
+
cluster = _resolve(store, cluster_id)
|
|
109
|
+
cluster.label = cleaned
|
|
110
|
+
cluster.labelled_by = reviewer or default_reviewer()
|
|
111
|
+
store.clusters.save(cluster)
|
|
112
|
+
return cluster
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def dismiss_cluster(
|
|
116
|
+
cluster_id: str, *, project_root: Path = Path(), reviewer: str | None = None
|
|
117
|
+
) -> Cluster:
|
|
118
|
+
"""Mark a family as not worth regression coverage. Kept, not deleted."""
|
|
119
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
120
|
+
with TraceStore.open(project.database_path) as store:
|
|
121
|
+
cluster = _resolve(store, cluster_id)
|
|
122
|
+
cluster.dismissed = True
|
|
123
|
+
cluster.labelled_by = cluster.labelled_by or reviewer or default_reviewer()
|
|
124
|
+
store.clusters.save(cluster)
|
|
125
|
+
return cluster
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def restore_cluster(cluster_id: str, *, project_root: Path = Path()) -> Cluster:
|
|
129
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
130
|
+
with TraceStore.open(project.database_path) as store:
|
|
131
|
+
cluster = _resolve(store, cluster_id)
|
|
132
|
+
cluster.dismissed = False
|
|
133
|
+
store.clusters.save(cluster)
|
|
134
|
+
return cluster
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def merge_clusters(
|
|
138
|
+
cluster_ids: list[str], *, project_root: Path = Path(), reviewer: str | None = None
|
|
139
|
+
) -> Cluster:
|
|
140
|
+
"""Combine several families into one the reviewer says is really one."""
|
|
141
|
+
if len(cluster_ids) < 2:
|
|
142
|
+
raise CommandError("Merging needs at least two clusters.")
|
|
143
|
+
|
|
144
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
145
|
+
with TraceStore.open(project.database_path) as store:
|
|
146
|
+
clusters = [_resolve(store, cluster_id) for cluster_id in cluster_ids]
|
|
147
|
+
if len({cluster.cluster_id for cluster in clusters}) != len(clusters):
|
|
148
|
+
raise CommandError("Each cluster can only be merged once.")
|
|
149
|
+
|
|
150
|
+
members = [member for cluster in clusters for member in cluster.members]
|
|
151
|
+
named = next((cluster for cluster in clusters if cluster.labelled_by), None)
|
|
152
|
+
merged = Cluster.build(
|
|
153
|
+
label=named.label if named else _combined_label(store, members),
|
|
154
|
+
members=_rerank(store, members),
|
|
155
|
+
)
|
|
156
|
+
# A merge is a judgement, so the result counts as reviewer-edited and
|
|
157
|
+
# will not be silently rebuilt away by the next `discover`.
|
|
158
|
+
merged.labelled_by = named.labelled_by if named else (reviewer or default_reviewer())
|
|
159
|
+
|
|
160
|
+
for cluster in clusters:
|
|
161
|
+
store.clusters.delete(cluster.cluster_id)
|
|
162
|
+
store.clusters.save(merged)
|
|
163
|
+
return merged
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def split_cluster(
|
|
167
|
+
cluster_id: str,
|
|
168
|
+
failure_ids: list[str],
|
|
169
|
+
*,
|
|
170
|
+
project_root: Path = Path(),
|
|
171
|
+
reviewer: str | None = None,
|
|
172
|
+
) -> tuple[Cluster, Cluster]:
|
|
173
|
+
"""Move some members out of a family into a new one of their own."""
|
|
174
|
+
if not failure_ids:
|
|
175
|
+
raise CommandError("Splitting needs at least one failure to move out.")
|
|
176
|
+
|
|
177
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
178
|
+
who = reviewer or default_reviewer()
|
|
179
|
+
with TraceStore.open(project.database_path) as store:
|
|
180
|
+
cluster = _resolve(store, cluster_id)
|
|
181
|
+
wanted = {identifier.strip() for identifier in failure_ids}
|
|
182
|
+
moving = [member for member in cluster.members if member.failure_id in wanted]
|
|
183
|
+
staying = [member for member in cluster.members if member.failure_id not in wanted]
|
|
184
|
+
|
|
185
|
+
missing = wanted - {member.failure_id for member in moving}
|
|
186
|
+
if missing:
|
|
187
|
+
raise CommandError(
|
|
188
|
+
f"Not in cluster {cluster.cluster_id}: {', '.join(sorted(missing))}.",
|
|
189
|
+
hint="Run 'evalkeep clusters show <id>' to see its members.",
|
|
190
|
+
)
|
|
191
|
+
if not staying:
|
|
192
|
+
raise CommandError(
|
|
193
|
+
"Splitting out every member would leave the cluster empty.",
|
|
194
|
+
hint="Nothing to do: the cluster already contains exactly these failures.",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
store.clusters.delete(cluster.cluster_id)
|
|
198
|
+
remainder = Cluster.build(
|
|
199
|
+
label=_combined_label(store, staying), members=_rerank(store, staying)
|
|
200
|
+
)
|
|
201
|
+
remainder.labelled_by = who
|
|
202
|
+
extracted = Cluster.build(
|
|
203
|
+
label=_combined_label(store, moving), members=_rerank(store, moving)
|
|
204
|
+
)
|
|
205
|
+
extracted.labelled_by = who
|
|
206
|
+
store.clusters.save(remainder)
|
|
207
|
+
store.clusters.save(extracted)
|
|
208
|
+
return remainder, extracted
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _rerank(store: TraceStore, members: list[ClusterMember]) -> list[ClusterMember]:
|
|
212
|
+
"""Re-mark representatives after an edit changed the membership.
|
|
213
|
+
|
|
214
|
+
Distances to the old centroid are kept: recomputing one would need the
|
|
215
|
+
vectors, and the ordering they induce is what the roles depend on. The roles
|
|
216
|
+
themselves are re-derived through the same function the algorithm uses, so
|
|
217
|
+
an edited cluster carries no stale marks and no missing ones.
|
|
218
|
+
"""
|
|
219
|
+
ranked = sorted(members, key=lambda member: (member.distance, member.failure_id))
|
|
220
|
+
fresh = [
|
|
221
|
+
ClusterMember(failure_id=member.failure_id, distance=member.distance) for member in ranked
|
|
222
|
+
]
|
|
223
|
+
severities = {}
|
|
224
|
+
for member in fresh:
|
|
225
|
+
analysis = store.failures.get_analysis(member.failure_id)
|
|
226
|
+
if analysis is not None:
|
|
227
|
+
severities[member.failure_id] = analysis.severity
|
|
228
|
+
assign_roles(fresh, severities)
|
|
229
|
+
return fresh
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _combined_label(store: TraceStore, members: list[ClusterMember]) -> str:
|
|
233
|
+
inputs: list[ClusterInput] = []
|
|
234
|
+
for member in members:
|
|
235
|
+
analysis = store.failures.get_analysis(member.failure_id)
|
|
236
|
+
if analysis is not None:
|
|
237
|
+
inputs.append(ClusterInput.from_analysis(member.failure_id, analysis))
|
|
238
|
+
return derive_label(inputs) if inputs else "unlabelled"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _detail(store: TraceStore, cluster: Cluster) -> ClusterDetail:
|
|
242
|
+
analyses: dict[str, FailureAnalysis] = {}
|
|
243
|
+
trace_ids: dict[str, str] = {}
|
|
244
|
+
for member in cluster.members:
|
|
245
|
+
analysis = store.failures.get_analysis(member.failure_id)
|
|
246
|
+
if analysis is not None:
|
|
247
|
+
analyses[member.failure_id] = analysis
|
|
248
|
+
failure = store.failures.get(member.failure_id)
|
|
249
|
+
if failure is not None:
|
|
250
|
+
trace_ids[member.failure_id] = failure.trace_id
|
|
251
|
+
return ClusterDetail(cluster=cluster, analyses=analyses, trace_ids=trace_ids)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _resolve(store: TraceStore, identifier: str) -> Cluster:
|
|
255
|
+
"""Accept a cluster ID, or the ID of any failure or trace inside it."""
|
|
256
|
+
cleaned = identifier.strip()
|
|
257
|
+
cluster = store.clusters.get(cleaned)
|
|
258
|
+
if cluster is not None:
|
|
259
|
+
return cluster
|
|
260
|
+
|
|
261
|
+
cluster = store.clusters.find_by_failure(cleaned)
|
|
262
|
+
if cluster is not None:
|
|
263
|
+
return cluster
|
|
264
|
+
|
|
265
|
+
failure = store.failures.get_by_trace(cleaned)
|
|
266
|
+
if failure is not None:
|
|
267
|
+
cluster = store.clusters.find_by_failure(failure.failure_id)
|
|
268
|
+
if cluster is not None:
|
|
269
|
+
return cluster
|
|
270
|
+
|
|
271
|
+
raise CommandError(
|
|
272
|
+
f"No cluster matching {cleaned!r}.",
|
|
273
|
+
hint="Run 'evalkeep clusters list', or 'evalkeep discover' first.",
|
|
274
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""``evalkeep ingest`` -- validate, redact, deduplicate and store traces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from evalkeep.adapters import DEFAULT_ADAPTER, get_adapter
|
|
8
|
+
from evalkeep.config import Project
|
|
9
|
+
from evalkeep.errors import CommandError
|
|
10
|
+
from evalkeep.ingest import DEFAULT_SAMPLE_LIMIT, IngestReport, ingest_file
|
|
11
|
+
from evalkeep.redaction import Redactor
|
|
12
|
+
from evalkeep.storage import TraceStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ingest_traces(
|
|
16
|
+
path: Path,
|
|
17
|
+
*,
|
|
18
|
+
project_root: Path = Path(),
|
|
19
|
+
adapter_name: str = DEFAULT_ADAPTER,
|
|
20
|
+
validate_only: bool = False,
|
|
21
|
+
dry_run: bool = False,
|
|
22
|
+
error_path: Path | None = None,
|
|
23
|
+
sample_limit: int = DEFAULT_SAMPLE_LIMIT,
|
|
24
|
+
) -> IngestReport:
|
|
25
|
+
"""Run the ingest pipeline over ``path``."""
|
|
26
|
+
adapter = get_adapter(adapter_name)
|
|
27
|
+
if validate_only and dry_run:
|
|
28
|
+
raise CommandError(
|
|
29
|
+
"--validate-only and --dry-run cannot be combined.",
|
|
30
|
+
hint="--validate-only checks the file alone; --dry-run also checks it "
|
|
31
|
+
"against the stored traces.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
path = path.expanduser()
|
|
35
|
+
resolved_errors = error_path.expanduser() if error_path is not None else None
|
|
36
|
+
|
|
37
|
+
if validate_only:
|
|
38
|
+
return ingest_file(path, adapter, error_path=resolved_errors, sample_limit=sample_limit)
|
|
39
|
+
|
|
40
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
41
|
+
with TraceStore.open(project.database_path) as store:
|
|
42
|
+
return ingest_file(
|
|
43
|
+
path,
|
|
44
|
+
adapter,
|
|
45
|
+
store=store,
|
|
46
|
+
redactor=Redactor(project.config.redaction, pseudonymizer=project.pseudonymizer()),
|
|
47
|
+
dry_run=dry_run,
|
|
48
|
+
error_path=resolved_errors,
|
|
49
|
+
sample_limit=sample_limit,
|
|
50
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""``evalkeep init`` -- create a safe, idempotent local project structure.
|
|
2
|
+
|
|
3
|
+
Initialization never destroys existing work. Re-running it fills in whatever is
|
|
4
|
+
missing and leaves everything else untouched, so it is safe to run in a
|
|
5
|
+
half-configured project or as part of a setup script. ``--force`` is the only
|
|
6
|
+
way to rewrite an existing configuration file, and even then nothing under the
|
|
7
|
+
state directory is deleted.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from evalkeep.config import (
|
|
18
|
+
CONFIG_FILENAME,
|
|
19
|
+
GITIGNORE_ENTRIES,
|
|
20
|
+
GITIGNORE_HEADER,
|
|
21
|
+
STATE_SUBDIRS,
|
|
22
|
+
Project,
|
|
23
|
+
ProjectConfig,
|
|
24
|
+
)
|
|
25
|
+
from evalkeep.errors import CommandError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Action(StrEnum):
|
|
29
|
+
"""What initialization did to one path."""
|
|
30
|
+
|
|
31
|
+
CREATED = "created"
|
|
32
|
+
EXISTS = "exists"
|
|
33
|
+
UPDATED = "updated"
|
|
34
|
+
OVERWRITTEN = "overwritten"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Step:
|
|
39
|
+
action: Action
|
|
40
|
+
path: Path
|
|
41
|
+
detail: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class InitReport:
|
|
46
|
+
project: Project
|
|
47
|
+
steps: list[Step] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def changed(self) -> bool:
|
|
51
|
+
return any(step.action is not Action.EXISTS for step in self.steps)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def initialize_project(
|
|
55
|
+
root: Path,
|
|
56
|
+
*,
|
|
57
|
+
project_name: str | None = None,
|
|
58
|
+
force: bool = False,
|
|
59
|
+
) -> InitReport:
|
|
60
|
+
"""Create or complete an Evalkeep project rooted at ``root``."""
|
|
61
|
+
root = root.expanduser().resolve()
|
|
62
|
+
_check_writable_directory(root)
|
|
63
|
+
|
|
64
|
+
config_path = root / CONFIG_FILENAME
|
|
65
|
+
steps: list[Step] = []
|
|
66
|
+
|
|
67
|
+
if config_path.is_file() and not force:
|
|
68
|
+
config = ProjectConfig.from_yaml(
|
|
69
|
+
config_path.read_text(encoding="utf-8"), source=config_path
|
|
70
|
+
)
|
|
71
|
+
if project_name is not None and project_name != config.project_name:
|
|
72
|
+
raise CommandError(
|
|
73
|
+
f"{config_path} already names this project "
|
|
74
|
+
f"{config.project_name!r}, not {project_name!r}.",
|
|
75
|
+
hint="Edit the file directly, or re-run with --force.",
|
|
76
|
+
)
|
|
77
|
+
steps.append(Step(Action.EXISTS, config_path, "left unchanged"))
|
|
78
|
+
else:
|
|
79
|
+
overwriting = config_path.is_file()
|
|
80
|
+
config = ProjectConfig(project_name=project_name or root.name)
|
|
81
|
+
_write_text(config_path, config.to_yaml())
|
|
82
|
+
steps.append(Step(Action.OVERWRITTEN if overwriting else Action.CREATED, config_path))
|
|
83
|
+
|
|
84
|
+
project = Project(root, config)
|
|
85
|
+
steps.extend(_ensure_state_dirs(project))
|
|
86
|
+
steps.append(_ensure_gitignore(root))
|
|
87
|
+
return InitReport(project=project, steps=steps)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ensure_state_dirs(project: Project) -> list[Step]:
|
|
91
|
+
steps: list[Step] = []
|
|
92
|
+
state_dir = project.state_dir
|
|
93
|
+
steps.append(_ensure_dir(state_dir))
|
|
94
|
+
for name, purpose in STATE_SUBDIRS.items():
|
|
95
|
+
steps.append(_ensure_dir(project.subdir(name), detail=purpose))
|
|
96
|
+
# Git does not track empty directories; a placeholder keeps the layout
|
|
97
|
+
# intact for anyone who clones the repository.
|
|
98
|
+
keep = project.subdir("exports") / ".gitkeep"
|
|
99
|
+
if not keep.exists():
|
|
100
|
+
_write_text(keep, "")
|
|
101
|
+
return steps
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ensure_dir(path: Path, *, detail: str = "") -> Step:
|
|
105
|
+
if path.is_dir():
|
|
106
|
+
return Step(Action.EXISTS, path, detail)
|
|
107
|
+
if path.exists():
|
|
108
|
+
raise CommandError(f"{path} exists but is not a directory.")
|
|
109
|
+
try:
|
|
110
|
+
path.mkdir(parents=True)
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
raise CommandError(f"Could not create {path}: {exc}") from exc
|
|
113
|
+
return Step(Action.CREATED, path, detail)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ensure_gitignore(root: Path) -> Step:
|
|
117
|
+
"""Add only the entries that are missing, preserving the existing file."""
|
|
118
|
+
path = root / ".gitignore"
|
|
119
|
+
existing_lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
|
|
120
|
+
present = {line.strip() for line in existing_lines}
|
|
121
|
+
missing = [entry for entry in GITIGNORE_ENTRIES if entry not in present]
|
|
122
|
+
|
|
123
|
+
if not missing:
|
|
124
|
+
return Step(Action.EXISTS, path, "all entries present")
|
|
125
|
+
|
|
126
|
+
block = [GITIGNORE_HEADER, *missing]
|
|
127
|
+
if existing_lines:
|
|
128
|
+
prefix = existing_lines + ([""] if existing_lines[-1].strip() else [])
|
|
129
|
+
action = Action.UPDATED
|
|
130
|
+
else:
|
|
131
|
+
prefix = []
|
|
132
|
+
action = Action.CREATED
|
|
133
|
+
_write_text(path, "\n".join([*prefix, *block]) + "\n")
|
|
134
|
+
return Step(action, path, f"{len(missing)} entr{'y' if len(missing) == 1 else 'ies'} added")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _check_writable_directory(root: Path) -> None:
|
|
138
|
+
if not root.exists():
|
|
139
|
+
raise CommandError(f"{root} does not exist.")
|
|
140
|
+
if not root.is_dir():
|
|
141
|
+
raise CommandError(f"{root} is not a directory.")
|
|
142
|
+
if not os.access(root, os.W_OK):
|
|
143
|
+
raise CommandError(f"{root} is not writable.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _write_text(path: Path, text: str) -> None:
|
|
147
|
+
try:
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
path.write_text(text, encoding="utf-8")
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
raise CommandError(f"Could not write {path}: {exc}") from exc
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""``evalkeep from-traces`` -- the whole pipeline up to the review gate.
|
|
2
|
+
|
|
3
|
+
The stages exist because each is a real decision with its own evidence, and
|
|
4
|
+
anyone tuning a suite will end up running them one at a time. But five commands
|
|
5
|
+
and their options is a lot to understand before seeing whether the tool is worth
|
|
6
|
+
anything, so this runs them in order and reports what came out.
|
|
7
|
+
|
|
8
|
+
It deliberately stops at review. Everything before it is derived and can be
|
|
9
|
+
re-run; approving a test is a judgement, and a command that quietly approved
|
|
10
|
+
things on your behalf would defeat the point of the gate.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from evalkeep.adapters import DEFAULT_ADAPTER
|
|
19
|
+
from evalkeep.commands.analyze_cmd import run_analysis
|
|
20
|
+
from evalkeep.commands.dataset_cmd import build_dataset
|
|
21
|
+
from evalkeep.commands.detect_cmd import run_detection
|
|
22
|
+
from evalkeep.commands.discover_cmd import run_discovery
|
|
23
|
+
from evalkeep.commands.ingest_cmd import ingest_traces
|
|
24
|
+
from evalkeep.config import Project
|
|
25
|
+
from evalkeep.discovery import CLUSTERABLE
|
|
26
|
+
from evalkeep.errors import CommandError
|
|
27
|
+
from evalkeep.regression import ReviewStatus
|
|
28
|
+
from evalkeep.storage import TraceStore
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PipelineReport:
|
|
33
|
+
"""What one end-to-end pass produced, in the order it happened."""
|
|
34
|
+
|
|
35
|
+
traces: int = 0
|
|
36
|
+
already_known: int = 0
|
|
37
|
+
invalid: int = 0
|
|
38
|
+
failures: int = 0
|
|
39
|
+
evidence: dict[str, int] = field(default_factory=dict)
|
|
40
|
+
analyzed: int = 0
|
|
41
|
+
described: bool = False
|
|
42
|
+
families: int = 0
|
|
43
|
+
representatives: int = 0
|
|
44
|
+
drafts: int = 0
|
|
45
|
+
ready: int = 0
|
|
46
|
+
needs_expectation: int = 0
|
|
47
|
+
pending_review: int = 0
|
|
48
|
+
notes: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def found_nothing(self) -> bool:
|
|
52
|
+
return self.failures == 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def from_traces(
|
|
56
|
+
path: Path,
|
|
57
|
+
*,
|
|
58
|
+
project_root: Path = Path(),
|
|
59
|
+
adapter_name: str = DEFAULT_ADAPTER,
|
|
60
|
+
limit: int | None = None,
|
|
61
|
+
) -> PipelineReport:
|
|
62
|
+
"""Ingest a trace file and carry it as far as the review queue."""
|
|
63
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
64
|
+
report = PipelineReport()
|
|
65
|
+
|
|
66
|
+
ingested = ingest_traces(path, project_root=project_root, adapter_name=adapter_name)
|
|
67
|
+
report.traces = ingested.stored
|
|
68
|
+
report.already_known = ingested.already_stored + ingested.content_duplicates
|
|
69
|
+
report.invalid = ingested.invalid
|
|
70
|
+
if ingested.identifier_risks:
|
|
71
|
+
report.notes.append(
|
|
72
|
+
f"{ingested.identifier_risks} trace(s) have identifiers that look like "
|
|
73
|
+
"they carry personal data; see redaction.pseudonymize_identifiers."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if report.traces == 0 and report.already_known == 0:
|
|
77
|
+
raise CommandError(
|
|
78
|
+
"No traces were stored, so there is nothing to work with.",
|
|
79
|
+
hint="Check the file, or pass --format if it is not Evalkeep's own JSONL.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
detected = run_detection(project_root=project_root)
|
|
83
|
+
report.failures = detected.failures
|
|
84
|
+
report.evidence = {kind.value: count for kind, count in detected.by_kind.items()}
|
|
85
|
+
if report.found_nothing:
|
|
86
|
+
return report
|
|
87
|
+
|
|
88
|
+
# Describing failures is what lets them be grouped by *what they are*. With
|
|
89
|
+
# no provider configured that is a person's job, so the run continues on
|
|
90
|
+
# observed behaviour and says so rather than stopping.
|
|
91
|
+
if project.config.analyzer.provider != "manual":
|
|
92
|
+
analysis = run_analysis(project_root=project_root)
|
|
93
|
+
report.analyzed = analysis.analyzed + analysis.from_cache + analysis.skipped
|
|
94
|
+
if analysis.failed:
|
|
95
|
+
# Configured is not the same as working. Saying which provider
|
|
96
|
+
# refused, and why, beats a later error about nothing to group.
|
|
97
|
+
report.notes.append(
|
|
98
|
+
f"{analysis.failed} failure(s) could not be described by "
|
|
99
|
+
f"{analysis.analyzer}: {analysis.errors[0][1] if analysis.errors else 'unknown'}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Ask the store, not the analyzer: descriptions also arrive from
|
|
103
|
+
# 'evalkeep failures label', and a run after that should not be told it has
|
|
104
|
+
# none. Anything still undescribed is grouped on observed behaviour rather
|
|
105
|
+
# than dropped, which is the only way a first run reaches the review queue.
|
|
106
|
+
undescribed = _undescribed(project)
|
|
107
|
+
report.described = undescribed == 0
|
|
108
|
+
if undescribed:
|
|
109
|
+
report.notes.append(
|
|
110
|
+
f"{undescribed} failure(s) were grouped by what was observed, not by what "
|
|
111
|
+
"they are. Describe them with 'evalkeep failures label', or set a working "
|
|
112
|
+
"analyzer.provider in evalkeep.yaml, and re-run for tighter families."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
discovered = run_discovery(
|
|
116
|
+
project_root=project_root,
|
|
117
|
+
analyze=False,
|
|
118
|
+
force=True,
|
|
119
|
+
group_undescribed=undescribed > 0,
|
|
120
|
+
)
|
|
121
|
+
report.families = discovered.clusters
|
|
122
|
+
report.representatives = discovered.representatives
|
|
123
|
+
|
|
124
|
+
# Regenerate: everything up to the review gate is derived, so a second run
|
|
125
|
+
# after describing a failure should reflect it rather than leave the first
|
|
126
|
+
# run's draft in place. Reviewed tests are kept regardless -- rebuilding a
|
|
127
|
+
# draft is not the same as undoing a decision.
|
|
128
|
+
built = build_dataset(project_root=project_root, limit=limit, regenerate=True)
|
|
129
|
+
report.drafts = built.created + built.regenerated + built.skipped
|
|
130
|
+
|
|
131
|
+
with TraceStore.open(project.database_path) as store:
|
|
132
|
+
drafts = store.tests.list(status=ReviewStatus.DRAFT, limit=10_000)
|
|
133
|
+
report.pending_review = len(drafts)
|
|
134
|
+
# Enough evidence means the draft carries a check that fails when the
|
|
135
|
+
# bug returns -- which is what a regression test is for, and what
|
|
136
|
+
# drives the compare numbers even when all it does is forbid the
|
|
137
|
+
# action that was observed. `needs_expectation` then qualifies that
|
|
138
|
+
# same set rather than naming a separate one: those drafts cannot yet
|
|
139
|
+
# confirm the agent did the right thing instead, only that it avoided
|
|
140
|
+
# the one wrong thing. Reported as a remainder, it read as six
|
|
141
|
+
# findings from three failures.
|
|
142
|
+
report.ready = sum(1 for test in drafts if test.deterministic_expectations)
|
|
143
|
+
report.needs_expectation = sum(1 for test in drafts if not test.has_positive_expectation)
|
|
144
|
+
|
|
145
|
+
return report
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _undescribed(project: Project) -> int:
|
|
149
|
+
"""How many clusterable failures nobody has said anything about yet."""
|
|
150
|
+
with TraceStore.open(project.database_path) as store:
|
|
151
|
+
return sum(
|
|
152
|
+
1
|
|
153
|
+
for failure in store.failures.iter_all()
|
|
154
|
+
if failure.status in CLUSTERABLE
|
|
155
|
+
and store.failures.get_analysis(failure.failure_id) is None
|
|
156
|
+
)
|