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