evalkeep 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evalkeep/__init__.py +12 -0
- evalkeep/__main__.py +6 -0
- evalkeep/adapters/__init__.py +45 -0
- evalkeep/adapters/base.py +92 -0
- evalkeep/adapters/jsonl.py +164 -0
- evalkeep/adapters/langsmith.py +436 -0
- evalkeep/adapters/otlp.py +442 -0
- evalkeep/adapters/semconv.py +208 -0
- evalkeep/analysis.py +174 -0
- evalkeep/analysis_run.py +160 -0
- evalkeep/analyzers/__init__.py +52 -0
- evalkeep/analyzers/anthropic.py +145 -0
- evalkeep/analyzers/stub.py +34 -0
- evalkeep/cache.py +122 -0
- evalkeep/cli.py +1933 -0
- evalkeep/clustering.py +383 -0
- evalkeep/clusters.py +101 -0
- evalkeep/commands/__init__.py +1 -0
- evalkeep/commands/analyze_cmd.py +100 -0
- evalkeep/commands/compare_cmd.py +169 -0
- evalkeep/commands/dataset_cmd.py +182 -0
- evalkeep/commands/detect_cmd.py +154 -0
- evalkeep/commands/discover_cmd.py +274 -0
- evalkeep/commands/ingest_cmd.py +50 -0
- evalkeep/commands/init_cmd.py +151 -0
- evalkeep/commands/pipeline_cmd.py +156 -0
- evalkeep/commands/review_cmd.py +141 -0
- evalkeep/commands/run_cmd.py +131 -0
- evalkeep/commands/target_cmd.py +109 -0
- evalkeep/commands/trace_cmd.py +58 -0
- evalkeep/comparison.py +432 -0
- evalkeep/config.py +209 -0
- evalkeep/detection.py +94 -0
- evalkeep/detectors.py +182 -0
- evalkeep/discovery.py +208 -0
- evalkeep/embeddings/__init__.py +31 -0
- evalkeep/embeddings/base.py +32 -0
- evalkeep/embeddings/hashing.py +98 -0
- evalkeep/errors.py +42 -0
- evalkeep/examples/__init__.py +37 -0
- evalkeep/examples/langsmith/runs.jsonl +18 -0
- evalkeep/examples/opentelemetry/spans.json +898 -0
- evalkeep/examples/refund-agent/agents/baseline.py +66 -0
- evalkeep/examples/refund-agent/agents/candidate.py +66 -0
- evalkeep/examples/refund-agent/traces.jsonl +5 -0
- evalkeep/examples/tau-bench/prepare.py +230 -0
- evalkeep/exporters/__init__.py +45 -0
- evalkeep/exporters/generic.py +31 -0
- evalkeep/exporters/promptfoo.py +219 -0
- evalkeep/failures.py +95 -0
- evalkeep/generation.py +303 -0
- evalkeep/hashing.py +56 -0
- evalkeep/ingest.py +257 -0
- evalkeep/prompts.py +127 -0
- evalkeep/pseudonyms.py +82 -0
- evalkeep/py.typed +0 -0
- evalkeep/redaction.py +333 -0
- evalkeep/regression.py +409 -0
- evalkeep/review.py +309 -0
- evalkeep/runner.py +302 -0
- evalkeep/runs.py +185 -0
- evalkeep/storage/__init__.py +37 -0
- evalkeep/storage/clusters.py +163 -0
- evalkeep/storage/failures.py +254 -0
- evalkeep/storage/migrations.py +370 -0
- evalkeep/storage/regression.py +136 -0
- evalkeep/storage/runs.py +223 -0
- evalkeep/storage/store.py +429 -0
- evalkeep/targets.py +205 -0
- evalkeep/trace.py +238 -0
- evalkeep-0.1.0.dist-info/METADATA +221 -0
- evalkeep-0.1.0.dist-info/RECORD +75 -0
- evalkeep-0.1.0.dist-info/WHEEL +4 -0
- evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
- evalkeep-0.1.0.dist-info/licenses/LICENSE +202 -0
evalkeep/runs.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Evaluation runs and their per-test results.
|
|
2
|
+
|
|
3
|
+
The distinction that matters here is between a test that *failed* and a test
|
|
4
|
+
that never got to run. An assertion failure is information about the agent; a
|
|
5
|
+
timeout or a crashed provider is information about the harness. Conflating them
|
|
6
|
+
would let an outage look like a regression, which is exactly the mistake the
|
|
7
|
+
comparison stage exists to avoid.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Outcome(StrEnum):
|
|
20
|
+
PASS = "pass"
|
|
21
|
+
FAIL = "fail"
|
|
22
|
+
#: Never evaluated: the harness or the target failed, not the agent's answer.
|
|
23
|
+
ERROR = "error"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ErrorKind(StrEnum):
|
|
27
|
+
TIMEOUT = "timeout"
|
|
28
|
+
EXECUTION_ERROR = "execution_error"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RunStatus(StrEnum):
|
|
32
|
+
COMPLETED = "completed"
|
|
33
|
+
FAILED = "failed"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Verdict(StrEnum):
|
|
37
|
+
"""What a whole set of repetitions says about one case.
|
|
38
|
+
|
|
39
|
+
The third value is the point of repeating at all: a case that sometimes
|
|
40
|
+
passes has told you something a single execution cannot.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
PASS = "pass"
|
|
44
|
+
FAIL = "fail"
|
|
45
|
+
#: Passed some repetitions and failed others.
|
|
46
|
+
FLAKY = "flaky"
|
|
47
|
+
#: Never actually evaluated.
|
|
48
|
+
ERROR = "error"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class CaseResult:
|
|
53
|
+
test_id: str
|
|
54
|
+
outcome: Outcome
|
|
55
|
+
error_kind: ErrorKind | None = None
|
|
56
|
+
error: str | None = None
|
|
57
|
+
latency_ms: int | None = None
|
|
58
|
+
#: What the agent actually produced, redacted before storage.
|
|
59
|
+
observation: str | None = None
|
|
60
|
+
failed_assertions: list[str] = field(default_factory=list)
|
|
61
|
+
#: Which execution of this case this was, zero-based.
|
|
62
|
+
repetition: int = 0
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def comparable(self) -> bool:
|
|
66
|
+
"""Errors are reported separately, never counted as pass or fail."""
|
|
67
|
+
return self.outcome is not Outcome.ERROR
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class CaseSummary:
|
|
72
|
+
"""Every execution of one case in one run, taken together."""
|
|
73
|
+
|
|
74
|
+
test_id: str
|
|
75
|
+
passed: int = 0
|
|
76
|
+
failed: int = 0
|
|
77
|
+
errored: int = 0
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def repetitions(self) -> int:
|
|
81
|
+
return self.passed + self.failed + self.errored
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def evaluated(self) -> int:
|
|
85
|
+
"""Repetitions that actually ran. Errors are not evidence either way."""
|
|
86
|
+
return self.passed + self.failed
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def pass_rate(self) -> float | None:
|
|
90
|
+
return self.passed / self.evaluated if self.evaluated else None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def verdict(self) -> Verdict:
|
|
94
|
+
if not self.evaluated:
|
|
95
|
+
return Verdict.ERROR
|
|
96
|
+
if self.passed == self.evaluated:
|
|
97
|
+
return Verdict.PASS
|
|
98
|
+
if self.passed == 0:
|
|
99
|
+
return Verdict.FAIL
|
|
100
|
+
return Verdict.FLAKY
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def flaky(self) -> bool:
|
|
104
|
+
return self.verdict is Verdict.FLAKY
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def confidence(self) -> tuple[float, float] | None:
|
|
108
|
+
"""A Wilson interval on the pass rate, or ``None`` with nothing to go on.
|
|
109
|
+
|
|
110
|
+
Wilson rather than the normal approximation because these counts are
|
|
111
|
+
small and one-sided -- 20 passes out of 20 would otherwise produce an
|
|
112
|
+
interval of zero width, which claims certainty no sample can give.
|
|
113
|
+
"""
|
|
114
|
+
return wilson_interval(self.passed, self.evaluated)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class EvaluationRun:
|
|
119
|
+
run_id: str
|
|
120
|
+
target_id: str
|
|
121
|
+
#: Identifies the exact set of approved tests that ran, so two runs are only
|
|
122
|
+
#: compared when they answered the same questions.
|
|
123
|
+
suite_hash: str
|
|
124
|
+
tests: int = 0
|
|
125
|
+
#: How many times each test was executed.
|
|
126
|
+
repetitions: int = 1
|
|
127
|
+
status: RunStatus = RunStatus.COMPLETED
|
|
128
|
+
runner: str | None = None
|
|
129
|
+
environment: dict[str, Any] = field(default_factory=dict)
|
|
130
|
+
started_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
131
|
+
finished_at: datetime | None = None
|
|
132
|
+
output_dir: str | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class BaselinePromotion:
|
|
137
|
+
"""A recorded decision that one run is now the reference point."""
|
|
138
|
+
|
|
139
|
+
promotion_id: str
|
|
140
|
+
run_id: str
|
|
141
|
+
target_id: str
|
|
142
|
+
reviewer: str
|
|
143
|
+
reason: str | None = None
|
|
144
|
+
promoted_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def suite_hash(test_ids: list[str]) -> str:
|
|
148
|
+
"""A fingerprint of which tests a run covered."""
|
|
149
|
+
material = "\n".join(sorted(test_ids))
|
|
150
|
+
return "sha256:" + hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
#: 95% two-sided normal quantile, for the Wilson interval.
|
|
154
|
+
_Z = 1.959963984540054
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def wilson_interval(successes: int, trials: int) -> tuple[float, float] | None:
|
|
158
|
+
"""A Wilson score interval for a proportion.
|
|
159
|
+
|
|
160
|
+
Chosen over the Wald interval because it behaves at the edges, where these
|
|
161
|
+
measurements live: an all-pass or all-fail case still gets an interval that
|
|
162
|
+
reflects how few times it was tried.
|
|
163
|
+
"""
|
|
164
|
+
if trials <= 0:
|
|
165
|
+
return None
|
|
166
|
+
rate = successes / trials
|
|
167
|
+
denominator = 1 + _Z**2 / trials
|
|
168
|
+
centre = (rate + _Z**2 / (2 * trials)) / denominator
|
|
169
|
+
spread = _Z * ((rate * (1 - rate) / trials + _Z**2 / (4 * trials**2)) ** 0.5) / denominator
|
|
170
|
+
return max(0.0, centre - spread), min(1.0, centre + spread)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def summarize(results: list[CaseResult]) -> dict[str, CaseSummary]:
|
|
174
|
+
"""Group repeated executions by case."""
|
|
175
|
+
summaries: dict[str, CaseSummary] = {}
|
|
176
|
+
for result in results:
|
|
177
|
+
summary = summaries.setdefault(result.test_id, CaseSummary(test_id=result.test_id))
|
|
178
|
+
match result.outcome:
|
|
179
|
+
case Outcome.PASS:
|
|
180
|
+
summary.passed += 1
|
|
181
|
+
case Outcome.FAIL:
|
|
182
|
+
summary.failed += 1
|
|
183
|
+
case Outcome.ERROR:
|
|
184
|
+
summary.errored += 1
|
|
185
|
+
return summaries
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Redacted trace storage: SQLite schema, migrations and the trace store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from evalkeep.storage.clusters import ClusterStore
|
|
6
|
+
from evalkeep.storage.failures import FailureStore, FailureSummary
|
|
7
|
+
from evalkeep.storage.migrations import LATEST_VERSION, MIGRATIONS, Migration, apply_migrations
|
|
8
|
+
from evalkeep.storage.regression import RegressionStore
|
|
9
|
+
from evalkeep.storage.runs import RunStore
|
|
10
|
+
from evalkeep.storage.store import (
|
|
11
|
+
OccurrenceStats,
|
|
12
|
+
StoredTrace,
|
|
13
|
+
StoreOutcome,
|
|
14
|
+
StoreResult,
|
|
15
|
+
TraceOccurrence,
|
|
16
|
+
TraceStore,
|
|
17
|
+
TraceSummary,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"LATEST_VERSION",
|
|
22
|
+
"MIGRATIONS",
|
|
23
|
+
"ClusterStore",
|
|
24
|
+
"FailureStore",
|
|
25
|
+
"FailureSummary",
|
|
26
|
+
"Migration",
|
|
27
|
+
"OccurrenceStats",
|
|
28
|
+
"RegressionStore",
|
|
29
|
+
"RunStore",
|
|
30
|
+
"StoreOutcome",
|
|
31
|
+
"StoreResult",
|
|
32
|
+
"StoredTrace",
|
|
33
|
+
"TraceOccurrence",
|
|
34
|
+
"TraceStore",
|
|
35
|
+
"TraceSummary",
|
|
36
|
+
"apply_migrations",
|
|
37
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Persistence for clusterings, clusters and their members."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from evalkeep.clusters import Cluster, ClusteringRun, ClusterMember, MemberRole
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClusterStore:
|
|
14
|
+
"""Read/write access to the clustering tables.
|
|
15
|
+
|
|
16
|
+
Only one clustering is current at a time: ``discover`` writes a new run and
|
|
17
|
+
the previous one is replaced. Keeping several would mean every downstream
|
|
18
|
+
command had to ask which grouping it meant.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
22
|
+
self._connection = connection
|
|
23
|
+
|
|
24
|
+
def replace_run(self, run: ClusteringRun, clusters: list[Cluster]) -> None:
|
|
25
|
+
"""Install a clustering, atomically, discarding any previous one."""
|
|
26
|
+
with self._connection:
|
|
27
|
+
self._connection.execute("DELETE FROM clustering_runs")
|
|
28
|
+
self._connection.execute(
|
|
29
|
+
"""
|
|
30
|
+
INSERT INTO clustering_runs (
|
|
31
|
+
run_id, created_at, embedder, dimensions, parameters, failures
|
|
32
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
33
|
+
""",
|
|
34
|
+
(
|
|
35
|
+
run.run_id,
|
|
36
|
+
run.created_at.isoformat(),
|
|
37
|
+
run.embedder,
|
|
38
|
+
run.dimensions,
|
|
39
|
+
json.dumps(run.parameters, sort_keys=True),
|
|
40
|
+
run.failures,
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
for cluster in clusters:
|
|
44
|
+
self._insert_cluster(cluster, run.run_id)
|
|
45
|
+
|
|
46
|
+
def current_run(self) -> ClusteringRun | None:
|
|
47
|
+
row = self._connection.execute(
|
|
48
|
+
"SELECT * FROM clustering_runs ORDER BY created_at DESC LIMIT 1"
|
|
49
|
+
).fetchone()
|
|
50
|
+
if row is None:
|
|
51
|
+
return None
|
|
52
|
+
return ClusteringRun(
|
|
53
|
+
run_id=row["run_id"],
|
|
54
|
+
embedder=row["embedder"],
|
|
55
|
+
dimensions=row["dimensions"],
|
|
56
|
+
parameters=json.loads(row["parameters"]),
|
|
57
|
+
failures=row["failures"],
|
|
58
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def save(self, cluster: Cluster) -> None:
|
|
62
|
+
"""Update one cluster in place, replacing its membership."""
|
|
63
|
+
run = self.current_run()
|
|
64
|
+
if run is None: # pragma: no cover - callers check first
|
|
65
|
+
raise ValueError("no clustering to update")
|
|
66
|
+
with self._connection:
|
|
67
|
+
self._connection.execute(
|
|
68
|
+
"DELETE FROM clusters WHERE cluster_id = ?", (cluster.cluster_id,)
|
|
69
|
+
)
|
|
70
|
+
self._insert_cluster(cluster, run.run_id)
|
|
71
|
+
|
|
72
|
+
def delete(self, cluster_id: str) -> None:
|
|
73
|
+
with self._connection:
|
|
74
|
+
self._connection.execute("DELETE FROM clusters WHERE cluster_id = ?", (cluster_id,))
|
|
75
|
+
|
|
76
|
+
def get(self, cluster_id: str) -> Cluster | None:
|
|
77
|
+
row = self._connection.execute(
|
|
78
|
+
"SELECT * FROM clusters WHERE cluster_id = ?", (cluster_id.strip(),)
|
|
79
|
+
).fetchone()
|
|
80
|
+
return self._build(row) if row is not None else None
|
|
81
|
+
|
|
82
|
+
def list(self, *, include_dismissed: bool = True) -> list[Cluster]:
|
|
83
|
+
query = "SELECT * FROM clusters"
|
|
84
|
+
if not include_dismissed:
|
|
85
|
+
query += " WHERE dismissed = 0"
|
|
86
|
+
rows = self._connection.execute(query).fetchall()
|
|
87
|
+
clusters = [self._build(row) for row in rows]
|
|
88
|
+
clusters.sort(key=lambda cluster: (-cluster.size, cluster.cluster_id))
|
|
89
|
+
return clusters
|
|
90
|
+
|
|
91
|
+
def count(self) -> int:
|
|
92
|
+
return int(self._connection.execute("SELECT COUNT(*) AS n FROM clusters").fetchone()["n"])
|
|
93
|
+
|
|
94
|
+
def find_by_failure(self, failure_id: str) -> Cluster | None:
|
|
95
|
+
row = self._connection.execute(
|
|
96
|
+
"""
|
|
97
|
+
SELECT c.* FROM clusters c
|
|
98
|
+
JOIN cluster_members m ON m.cluster_id = c.cluster_id
|
|
99
|
+
WHERE m.failure_id = ?
|
|
100
|
+
""",
|
|
101
|
+
(failure_id,),
|
|
102
|
+
).fetchone()
|
|
103
|
+
return self._build(row) if row is not None else None
|
|
104
|
+
|
|
105
|
+
def _insert_cluster(self, cluster: Cluster, run_id: str) -> None:
|
|
106
|
+
self._connection.execute(
|
|
107
|
+
"""
|
|
108
|
+
INSERT INTO clusters (
|
|
109
|
+
cluster_id, run_id, label, labelled_by, dismissed, created_at
|
|
110
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
111
|
+
""",
|
|
112
|
+
(
|
|
113
|
+
cluster.cluster_id,
|
|
114
|
+
run_id,
|
|
115
|
+
cluster.label,
|
|
116
|
+
cluster.labelled_by,
|
|
117
|
+
int(cluster.dismissed),
|
|
118
|
+
cluster.created_at.isoformat(),
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
self._connection.executemany(
|
|
122
|
+
"""
|
|
123
|
+
INSERT INTO cluster_members (cluster_id, failure_id, distance, roles)
|
|
124
|
+
VALUES (?, ?, ?, ?)
|
|
125
|
+
""",
|
|
126
|
+
[
|
|
127
|
+
(
|
|
128
|
+
cluster.cluster_id,
|
|
129
|
+
member.failure_id,
|
|
130
|
+
member.distance,
|
|
131
|
+
json.dumps([role.value for role in member.roles]),
|
|
132
|
+
)
|
|
133
|
+
for member in cluster.members
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _build(self, row: sqlite3.Row) -> Cluster:
|
|
138
|
+
return Cluster(
|
|
139
|
+
cluster_id=row["cluster_id"],
|
|
140
|
+
label=row["label"],
|
|
141
|
+
members=_load_members(self._connection, row["cluster_id"]),
|
|
142
|
+
labelled_by=row["labelled_by"],
|
|
143
|
+
dismissed=bool(row["dismissed"]),
|
|
144
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _load_members(connection: sqlite3.Connection, cluster_id: str) -> list[ClusterMember]:
|
|
149
|
+
rows = connection.execute(
|
|
150
|
+
"SELECT * FROM cluster_members WHERE cluster_id = ? ORDER BY distance, failure_id",
|
|
151
|
+
(cluster_id,),
|
|
152
|
+
).fetchall()
|
|
153
|
+
members: list[ClusterMember] = []
|
|
154
|
+
for row in rows:
|
|
155
|
+
raw: Any = json.loads(row["roles"])
|
|
156
|
+
members.append(
|
|
157
|
+
ClusterMember(
|
|
158
|
+
failure_id=row["failure_id"],
|
|
159
|
+
distance=row["distance"],
|
|
160
|
+
roles=[MemberRole(value) for value in raw],
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
return members
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Persistence for failure candidates and their signals.
|
|
2
|
+
|
|
3
|
+
Shares the trace store's connection, so a failure and its signals are always
|
|
4
|
+
written in the same transaction as each other and under the same foreign keys
|
|
5
|
+
as the trace they describe.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from evalkeep.analysis import FailureAnalysis
|
|
18
|
+
from evalkeep.detectors import Signal
|
|
19
|
+
from evalkeep.failures import Failure, FailureOrigin, FailureStatus
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class FailureSummary:
|
|
24
|
+
"""One row of ``evalkeep failures list``."""
|
|
25
|
+
|
|
26
|
+
failure_id: str
|
|
27
|
+
trace_id: str
|
|
28
|
+
status: FailureStatus
|
|
29
|
+
origin: FailureOrigin
|
|
30
|
+
signals: int
|
|
31
|
+
kinds: list[str]
|
|
32
|
+
reviewer: str | None
|
|
33
|
+
failure_type: str | None = None
|
|
34
|
+
severity: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FailureStore:
|
|
38
|
+
"""Read/write access to the failure tables."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
41
|
+
self._connection = connection
|
|
42
|
+
|
|
43
|
+
def save(self, failure: Failure) -> None:
|
|
44
|
+
"""Write a failure and replace its signals, in one transaction."""
|
|
45
|
+
with self._connection:
|
|
46
|
+
self._connection.execute(
|
|
47
|
+
"""
|
|
48
|
+
INSERT INTO failures (
|
|
49
|
+
failure_id, trace_id, status, origin, detected_at,
|
|
50
|
+
updated_at, reviewer, reason
|
|
51
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
52
|
+
ON CONFLICT(failure_id) DO UPDATE SET
|
|
53
|
+
status = excluded.status,
|
|
54
|
+
origin = excluded.origin,
|
|
55
|
+
updated_at = excluded.updated_at,
|
|
56
|
+
reviewer = excluded.reviewer,
|
|
57
|
+
reason = excluded.reason
|
|
58
|
+
""",
|
|
59
|
+
(
|
|
60
|
+
failure.failure_id,
|
|
61
|
+
failure.trace_id,
|
|
62
|
+
failure.status.value,
|
|
63
|
+
failure.origin.value,
|
|
64
|
+
failure.detected_at.isoformat(),
|
|
65
|
+
failure.updated_at.isoformat(),
|
|
66
|
+
failure.reviewer,
|
|
67
|
+
failure.reason,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
self._connection.execute(
|
|
71
|
+
"DELETE FROM failure_signals WHERE failure_id = ?", (failure.failure_id,)
|
|
72
|
+
)
|
|
73
|
+
self._connection.executemany(
|
|
74
|
+
"""
|
|
75
|
+
INSERT INTO failure_signals (
|
|
76
|
+
failure_id, position, detector, kind, source, summary, evidence
|
|
77
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
78
|
+
""",
|
|
79
|
+
[
|
|
80
|
+
(
|
|
81
|
+
failure.failure_id,
|
|
82
|
+
position,
|
|
83
|
+
signal.detector,
|
|
84
|
+
signal.kind.value,
|
|
85
|
+
signal.source,
|
|
86
|
+
signal.summary,
|
|
87
|
+
json.dumps(signal.evidence, sort_keys=True),
|
|
88
|
+
)
|
|
89
|
+
for position, signal in enumerate(failure.signals)
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def save_analysis(self, failure_id: str, analysis: FailureAnalysis) -> None:
|
|
94
|
+
"""Store the latest analysis for a failure, replacing any previous one."""
|
|
95
|
+
with self._connection:
|
|
96
|
+
self._connection.execute(
|
|
97
|
+
"""
|
|
98
|
+
INSERT INTO failure_analyses (
|
|
99
|
+
failure_id, failure_type, component, severity, summary,
|
|
100
|
+
analyzer, prompt_version, analyzed_at, labeler, raw_response
|
|
101
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
102
|
+
ON CONFLICT(failure_id) DO UPDATE SET
|
|
103
|
+
failure_type = excluded.failure_type,
|
|
104
|
+
component = excluded.component,
|
|
105
|
+
severity = excluded.severity,
|
|
106
|
+
summary = excluded.summary,
|
|
107
|
+
analyzer = excluded.analyzer,
|
|
108
|
+
prompt_version = excluded.prompt_version,
|
|
109
|
+
analyzed_at = excluded.analyzed_at,
|
|
110
|
+
labeler = excluded.labeler,
|
|
111
|
+
raw_response = excluded.raw_response
|
|
112
|
+
""",
|
|
113
|
+
(
|
|
114
|
+
failure_id,
|
|
115
|
+
analysis.failure_type.value,
|
|
116
|
+
analysis.component.value,
|
|
117
|
+
analysis.severity.value,
|
|
118
|
+
analysis.summary,
|
|
119
|
+
analysis.analyzer,
|
|
120
|
+
analysis.prompt_version,
|
|
121
|
+
analysis.analyzed_at.isoformat(),
|
|
122
|
+
analysis.labeler,
|
|
123
|
+
analysis.raw_response,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def get_analysis(self, failure_id: str) -> FailureAnalysis | None:
|
|
128
|
+
row = self._connection.execute(
|
|
129
|
+
"SELECT * FROM failure_analyses WHERE failure_id = ?", (failure_id,)
|
|
130
|
+
).fetchone()
|
|
131
|
+
if row is None:
|
|
132
|
+
return None
|
|
133
|
+
return FailureAnalysis.from_dict(
|
|
134
|
+
{
|
|
135
|
+
"failure_type": row["failure_type"],
|
|
136
|
+
"component": row["component"],
|
|
137
|
+
"severity": row["severity"],
|
|
138
|
+
"summary": row["summary"],
|
|
139
|
+
"analyzer": row["analyzer"],
|
|
140
|
+
"prompt_version": row["prompt_version"],
|
|
141
|
+
"analyzed_at": row["analyzed_at"],
|
|
142
|
+
"labeler": row["labeler"],
|
|
143
|
+
"raw_response": row["raw_response"],
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def counts_by_type(self) -> dict[str, int]:
|
|
148
|
+
rows = self._connection.execute(
|
|
149
|
+
"SELECT failure_type, COUNT(*) AS n FROM failure_analyses GROUP BY failure_type"
|
|
150
|
+
).fetchall()
|
|
151
|
+
return {row["failure_type"]: int(row["n"]) for row in rows}
|
|
152
|
+
|
|
153
|
+
def delete(self, failure_id: str) -> None:
|
|
154
|
+
with self._connection:
|
|
155
|
+
self._connection.execute("DELETE FROM failures WHERE failure_id = ?", (failure_id,))
|
|
156
|
+
|
|
157
|
+
def get(self, failure_id: str) -> Failure | None:
|
|
158
|
+
row = self._connection.execute(
|
|
159
|
+
"SELECT * FROM failures WHERE failure_id = ?", (failure_id.strip(),)
|
|
160
|
+
).fetchone()
|
|
161
|
+
return self._build(row) if row is not None else None
|
|
162
|
+
|
|
163
|
+
def get_by_trace(self, trace_id: str) -> Failure | None:
|
|
164
|
+
row = self._connection.execute(
|
|
165
|
+
"SELECT * FROM failures WHERE trace_id = ?", (trace_id.strip(),)
|
|
166
|
+
).fetchone()
|
|
167
|
+
return self._build(row) if row is not None else None
|
|
168
|
+
|
|
169
|
+
def list(
|
|
170
|
+
self, *, status: FailureStatus | None = None, limit: int = 50, offset: int = 0
|
|
171
|
+
) -> list[FailureSummary]:
|
|
172
|
+
query = """
|
|
173
|
+
SELECT f.*, a.failure_type AS analysis_type, a.severity AS analysis_severity
|
|
174
|
+
FROM failures f
|
|
175
|
+
LEFT JOIN failure_analyses a ON a.failure_id = f.failure_id
|
|
176
|
+
"""
|
|
177
|
+
parameters: list[Any] = []
|
|
178
|
+
if status is not None:
|
|
179
|
+
query += " WHERE f.status = ?"
|
|
180
|
+
parameters.append(status.value)
|
|
181
|
+
query += " ORDER BY f.detected_at, f.failure_id LIMIT ? OFFSET ?"
|
|
182
|
+
parameters += [limit, offset]
|
|
183
|
+
|
|
184
|
+
summaries: list[FailureSummary] = []
|
|
185
|
+
for row in self._connection.execute(query, parameters).fetchall():
|
|
186
|
+
signals = _load_signals(self._connection, row["failure_id"])
|
|
187
|
+
kinds: dict[str, None] = {}
|
|
188
|
+
for signal in signals:
|
|
189
|
+
kinds.setdefault(signal.kind.value, None)
|
|
190
|
+
summaries.append(
|
|
191
|
+
FailureSummary(
|
|
192
|
+
failure_id=row["failure_id"],
|
|
193
|
+
trace_id=row["trace_id"],
|
|
194
|
+
status=FailureStatus(row["status"]),
|
|
195
|
+
origin=FailureOrigin(row["origin"]),
|
|
196
|
+
signals=len(signals),
|
|
197
|
+
kinds=list(kinds),
|
|
198
|
+
reviewer=row["reviewer"],
|
|
199
|
+
failure_type=row["analysis_type"],
|
|
200
|
+
severity=row["analysis_severity"],
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return summaries
|
|
204
|
+
|
|
205
|
+
def count(self, *, status: FailureStatus | None = None) -> int:
|
|
206
|
+
if status is None:
|
|
207
|
+
row = self._connection.execute("SELECT COUNT(*) AS n FROM failures").fetchone()
|
|
208
|
+
else:
|
|
209
|
+
row = self._connection.execute(
|
|
210
|
+
"SELECT COUNT(*) AS n FROM failures WHERE status = ?", (status.value,)
|
|
211
|
+
).fetchone()
|
|
212
|
+
return int(row["n"])
|
|
213
|
+
|
|
214
|
+
def counts_by_status(self) -> dict[FailureStatus, int]:
|
|
215
|
+
rows = self._connection.execute(
|
|
216
|
+
"SELECT status, COUNT(*) AS n FROM failures GROUP BY status"
|
|
217
|
+
).fetchall()
|
|
218
|
+
return {FailureStatus(row["status"]): int(row["n"]) for row in rows}
|
|
219
|
+
|
|
220
|
+
def iter_all(self) -> Iterator[Failure]:
|
|
221
|
+
for row in self._connection.execute("SELECT * FROM failures ORDER BY failure_id"):
|
|
222
|
+
yield self._build(row)
|
|
223
|
+
|
|
224
|
+
def _build(self, row: sqlite3.Row) -> Failure:
|
|
225
|
+
return Failure(
|
|
226
|
+
failure_id=row["failure_id"],
|
|
227
|
+
trace_id=row["trace_id"],
|
|
228
|
+
status=FailureStatus(row["status"]),
|
|
229
|
+
origin=FailureOrigin(row["origin"]),
|
|
230
|
+
signals=_load_signals(self._connection, row["failure_id"]),
|
|
231
|
+
detected_at=datetime.fromisoformat(row["detected_at"]),
|
|
232
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
233
|
+
reviewer=row["reviewer"],
|
|
234
|
+
reason=row["reason"],
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _load_signals(connection: sqlite3.Connection, failure_id: str) -> list[Signal]:
|
|
239
|
+
rows = connection.execute(
|
|
240
|
+
"SELECT * FROM failure_signals WHERE failure_id = ? ORDER BY position",
|
|
241
|
+
(failure_id,),
|
|
242
|
+
).fetchall()
|
|
243
|
+
return [
|
|
244
|
+
Signal.from_dict(
|
|
245
|
+
{
|
|
246
|
+
"detector": row["detector"],
|
|
247
|
+
"kind": row["kind"],
|
|
248
|
+
"source": row["source"],
|
|
249
|
+
"summary": row["summary"],
|
|
250
|
+
"evidence": json.loads(row["evidence"]),
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
for row in rows
|
|
254
|
+
]
|