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