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/storage/runs.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Persistence for evaluation runs and their results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from evalkeep.runs import (
|
|
10
|
+
BaselinePromotion,
|
|
11
|
+
CaseResult,
|
|
12
|
+
CaseSummary,
|
|
13
|
+
ErrorKind,
|
|
14
|
+
EvaluationRun,
|
|
15
|
+
Outcome,
|
|
16
|
+
RunStatus,
|
|
17
|
+
summarize,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AmbiguousRun(Exception):
|
|
22
|
+
"""A run prefix matched more than one run."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RunStore:
|
|
26
|
+
"""Read/write access to the run tables."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
29
|
+
self._connection = connection
|
|
30
|
+
|
|
31
|
+
def save(self, run: EvaluationRun, results: list[CaseResult]) -> None:
|
|
32
|
+
"""Write a run and its results together, so neither exists alone."""
|
|
33
|
+
with self._connection:
|
|
34
|
+
self._connection.execute(
|
|
35
|
+
"""
|
|
36
|
+
INSERT INTO evaluation_runs (
|
|
37
|
+
run_id, target_id, suite_hash, tests, repetitions, status,
|
|
38
|
+
runner, environment, started_at, finished_at, output_dir
|
|
39
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
40
|
+
ON CONFLICT(run_id) DO UPDATE SET
|
|
41
|
+
status = excluded.status,
|
|
42
|
+
tests = excluded.tests,
|
|
43
|
+
finished_at = excluded.finished_at,
|
|
44
|
+
output_dir = excluded.output_dir
|
|
45
|
+
""",
|
|
46
|
+
(
|
|
47
|
+
run.run_id,
|
|
48
|
+
run.target_id,
|
|
49
|
+
run.suite_hash,
|
|
50
|
+
run.tests,
|
|
51
|
+
run.repetitions,
|
|
52
|
+
run.status.value,
|
|
53
|
+
run.runner,
|
|
54
|
+
json.dumps(run.environment, sort_keys=True),
|
|
55
|
+
run.started_at.isoformat(),
|
|
56
|
+
run.finished_at.isoformat() if run.finished_at else None,
|
|
57
|
+
run.output_dir,
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
self._connection.execute("DELETE FROM test_results WHERE run_id = ?", (run.run_id,))
|
|
61
|
+
self._connection.executemany(
|
|
62
|
+
"""
|
|
63
|
+
INSERT INTO test_results (
|
|
64
|
+
run_id, test_id, repetition, outcome, error_kind, error,
|
|
65
|
+
latency_ms, observation, failed_assertions
|
|
66
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
67
|
+
""",
|
|
68
|
+
[
|
|
69
|
+
(
|
|
70
|
+
run.run_id,
|
|
71
|
+
result.test_id,
|
|
72
|
+
result.repetition,
|
|
73
|
+
result.outcome.value,
|
|
74
|
+
result.error_kind.value if result.error_kind else None,
|
|
75
|
+
result.error,
|
|
76
|
+
result.latency_ms,
|
|
77
|
+
result.observation,
|
|
78
|
+
json.dumps(result.failed_assertions),
|
|
79
|
+
)
|
|
80
|
+
for result in results
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def get(self, run_id: str) -> EvaluationRun | None:
|
|
85
|
+
row = self._connection.execute(
|
|
86
|
+
"SELECT * FROM evaluation_runs WHERE run_id = ?", (run_id.strip(),)
|
|
87
|
+
).fetchone()
|
|
88
|
+
return _build_run(row) if row is not None else None
|
|
89
|
+
|
|
90
|
+
def resolve(self, identifier: str) -> EvaluationRun | None:
|
|
91
|
+
"""Find a run by its full ID or an unambiguous prefix.
|
|
92
|
+
|
|
93
|
+
Run IDs are 32 hex characters, which nobody types. Listings show a
|
|
94
|
+
prefix, so a prefix has to be usable -- an identifier a tool prints and
|
|
95
|
+
will not accept back is a bug, not a nicety.
|
|
96
|
+
"""
|
|
97
|
+
cleaned = identifier.strip()
|
|
98
|
+
if not cleaned:
|
|
99
|
+
return None
|
|
100
|
+
exact = self.get(cleaned)
|
|
101
|
+
if exact is not None:
|
|
102
|
+
return exact
|
|
103
|
+
|
|
104
|
+
rows = self._connection.execute(
|
|
105
|
+
"SELECT * FROM evaluation_runs WHERE run_id LIKE ? || '%' ORDER BY run_id",
|
|
106
|
+
(cleaned,),
|
|
107
|
+
).fetchall()
|
|
108
|
+
if len(rows) > 1:
|
|
109
|
+
matches = ", ".join(row["run_id"][:12] for row in rows)
|
|
110
|
+
raise AmbiguousRun(f"{cleaned!r} matches several runs: {matches}.")
|
|
111
|
+
return _build_run(rows[0]) if rows else None
|
|
112
|
+
|
|
113
|
+
def latest(self, target_id: str) -> EvaluationRun | None:
|
|
114
|
+
row = self._connection.execute(
|
|
115
|
+
"""
|
|
116
|
+
SELECT * FROM evaluation_runs WHERE target_id = ?
|
|
117
|
+
ORDER BY started_at DESC LIMIT 1
|
|
118
|
+
""",
|
|
119
|
+
(target_id.strip(),),
|
|
120
|
+
).fetchone()
|
|
121
|
+
return _build_run(row) if row is not None else None
|
|
122
|
+
|
|
123
|
+
def recent(self, *, limit: int = 20) -> list[EvaluationRun]:
|
|
124
|
+
return [
|
|
125
|
+
_build_run(row)
|
|
126
|
+
for row in self._connection.execute(
|
|
127
|
+
"SELECT * FROM evaluation_runs ORDER BY started_at DESC LIMIT ?", (limit,)
|
|
128
|
+
)
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
def results(self, run_id: str) -> list[CaseResult]:
|
|
132
|
+
return [
|
|
133
|
+
_build_result(row)
|
|
134
|
+
for row in self._connection.execute(
|
|
135
|
+
"SELECT * FROM test_results WHERE run_id = ? ORDER BY test_id, repetition",
|
|
136
|
+
(run_id,),
|
|
137
|
+
)
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
def promote(self, promotion: BaselinePromotion) -> None:
|
|
141
|
+
"""Record that a run is now the baseline. Never inferred, always decided."""
|
|
142
|
+
with self._connection:
|
|
143
|
+
self._connection.execute(
|
|
144
|
+
"""
|
|
145
|
+
INSERT INTO baseline_promotions (
|
|
146
|
+
promotion_id, run_id, target_id, promoted_at, reviewer, reason
|
|
147
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
148
|
+
""",
|
|
149
|
+
(
|
|
150
|
+
promotion.promotion_id,
|
|
151
|
+
promotion.run_id,
|
|
152
|
+
promotion.target_id,
|
|
153
|
+
promotion.promoted_at.isoformat(),
|
|
154
|
+
promotion.reviewer,
|
|
155
|
+
promotion.reason,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def current_baseline(self) -> BaselinePromotion | None:
|
|
160
|
+
row = self._connection.execute(
|
|
161
|
+
"SELECT * FROM baseline_promotions ORDER BY promoted_at DESC LIMIT 1"
|
|
162
|
+
).fetchone()
|
|
163
|
+
return _build_promotion(row) if row is not None else None
|
|
164
|
+
|
|
165
|
+
def promotions(self, *, limit: int = 20) -> list[BaselinePromotion]:
|
|
166
|
+
return [
|
|
167
|
+
_build_promotion(row)
|
|
168
|
+
for row in self._connection.execute(
|
|
169
|
+
"SELECT * FROM baseline_promotions ORDER BY promoted_at DESC LIMIT ?",
|
|
170
|
+
(limit,),
|
|
171
|
+
)
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def summaries(self, run_id: str) -> dict[str, CaseSummary]:
|
|
175
|
+
"""Every case in a run, with its repetitions taken together."""
|
|
176
|
+
return summarize(self.results(run_id))
|
|
177
|
+
|
|
178
|
+
def counts(self, run_id: str) -> dict[Outcome, int]:
|
|
179
|
+
rows = self._connection.execute(
|
|
180
|
+
"SELECT outcome, COUNT(*) AS n FROM test_results WHERE run_id = ? GROUP BY outcome",
|
|
181
|
+
(run_id,),
|
|
182
|
+
).fetchall()
|
|
183
|
+
return {Outcome(row["outcome"]): int(row["n"]) for row in rows}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _build_promotion(row: sqlite3.Row) -> BaselinePromotion:
|
|
187
|
+
return BaselinePromotion(
|
|
188
|
+
promotion_id=row["promotion_id"],
|
|
189
|
+
run_id=row["run_id"],
|
|
190
|
+
target_id=row["target_id"],
|
|
191
|
+
reviewer=row["reviewer"],
|
|
192
|
+
reason=row["reason"],
|
|
193
|
+
promoted_at=datetime.fromisoformat(row["promoted_at"]),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _build_run(row: sqlite3.Row) -> EvaluationRun:
|
|
198
|
+
return EvaluationRun(
|
|
199
|
+
run_id=row["run_id"],
|
|
200
|
+
target_id=row["target_id"],
|
|
201
|
+
suite_hash=row["suite_hash"],
|
|
202
|
+
tests=row["tests"],
|
|
203
|
+
repetitions=row["repetitions"],
|
|
204
|
+
status=RunStatus(row["status"]),
|
|
205
|
+
runner=row["runner"],
|
|
206
|
+
environment=json.loads(row["environment"]),
|
|
207
|
+
started_at=datetime.fromisoformat(row["started_at"]),
|
|
208
|
+
finished_at=(datetime.fromisoformat(row["finished_at"]) if row["finished_at"] else None),
|
|
209
|
+
output_dir=row["output_dir"],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _build_result(row: sqlite3.Row) -> CaseResult:
|
|
214
|
+
return CaseResult(
|
|
215
|
+
test_id=row["test_id"],
|
|
216
|
+
outcome=Outcome(row["outcome"]),
|
|
217
|
+
error_kind=ErrorKind(row["error_kind"]) if row["error_kind"] else None,
|
|
218
|
+
error=row["error"],
|
|
219
|
+
latency_ms=row["latency_ms"],
|
|
220
|
+
observation=row["observation"],
|
|
221
|
+
failed_assertions=list(json.loads(row["failed_assertions"])),
|
|
222
|
+
repetition=row["repetition"],
|
|
223
|
+
)
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""The trace store: redacted traces in SQLite, never silently overwritten.
|
|
2
|
+
|
|
3
|
+
The full redacted trace is stored as JSON on ``traces`` and is the source of
|
|
4
|
+
truth. Events are also written as rows, as a derived index so that detection can
|
|
5
|
+
ask "which traces called ``refund_order``" without deserializing every trace;
|
|
6
|
+
both are written in the same transaction, so the index cannot drift.
|
|
7
|
+
|
|
8
|
+
Storing a trace ID that already exists is never an overwrite. It is either a
|
|
9
|
+
no-op (the stored trace has the same content, so re-ingesting a file is safe) or
|
|
10
|
+
a conflict the caller must resolve (same ID, different content).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from evalkeep.errors import CommandError
|
|
27
|
+
from evalkeep.hashing import content_hash
|
|
28
|
+
from evalkeep.redaction import RedactionSummary
|
|
29
|
+
from evalkeep.storage.clusters import ClusterStore
|
|
30
|
+
from evalkeep.storage.failures import FailureStore
|
|
31
|
+
from evalkeep.storage.migrations import apply_migrations
|
|
32
|
+
from evalkeep.storage.regression import RegressionStore
|
|
33
|
+
from evalkeep.storage.runs import RunStore
|
|
34
|
+
from evalkeep.trace import NormalizedTrace, ToolCallEvent, ToolResultEvent
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StoreResult(StrEnum):
|
|
38
|
+
"""What happened -- or, for a dry run, what would have happened."""
|
|
39
|
+
|
|
40
|
+
STORED = "stored"
|
|
41
|
+
#: Same trace_id, same content: already ingested, nothing to do.
|
|
42
|
+
ALREADY_STORED = "already_stored"
|
|
43
|
+
#: Same trace_id, different content: refused, the caller must decide.
|
|
44
|
+
ID_CONFLICT = "id_conflict"
|
|
45
|
+
#: Different trace_id, same content: the interaction is already covered.
|
|
46
|
+
CONTENT_DUPLICATE = "content_duplicate"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class StoreOutcome:
|
|
51
|
+
result: StoreResult
|
|
52
|
+
trace_id: str
|
|
53
|
+
content_hash: str
|
|
54
|
+
#: For a content duplicate, the trace already holding this content.
|
|
55
|
+
existing_trace_id: str | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def written(self) -> bool:
|
|
59
|
+
return self.result is StoreResult.STORED
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class TraceOccurrence:
|
|
64
|
+
"""One sighting of an interaction.
|
|
65
|
+
|
|
66
|
+
Distinct from a trace: `traces` holds one row per interaction, and this
|
|
67
|
+
holds one row per time that interaction was seen. The same bug hitting two
|
|
68
|
+
hundred users is one trace and two hundred occurrences.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
occurrence_id: str
|
|
72
|
+
canonical_trace_id: str
|
|
73
|
+
content_hash: str
|
|
74
|
+
#: The ID this sighting carried, which may differ from the canonical one.
|
|
75
|
+
trace_id: str
|
|
76
|
+
source: str | None = None
|
|
77
|
+
agent: str | None = None
|
|
78
|
+
model: str | None = None
|
|
79
|
+
recorded_at: str | None = None
|
|
80
|
+
ingested_at: str = ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class OccurrenceStats:
|
|
85
|
+
"""How often an interaction was seen, and across what."""
|
|
86
|
+
|
|
87
|
+
count: int = 0
|
|
88
|
+
first_seen: str | None = None
|
|
89
|
+
last_seen: str | None = None
|
|
90
|
+
#: Distinct agent versions this interaction was observed on.
|
|
91
|
+
agents: tuple[str, ...] = ()
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def recurring(self) -> bool:
|
|
95
|
+
return self.count > 1
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class StoredTrace:
|
|
100
|
+
"""A trace as it came back out of the database."""
|
|
101
|
+
|
|
102
|
+
trace: NormalizedTrace
|
|
103
|
+
content_hash: str
|
|
104
|
+
ingested_at: str
|
|
105
|
+
redactions: int
|
|
106
|
+
redaction_summary: dict[str, int]
|
|
107
|
+
occurrences: OccurrenceStats = field(default_factory=OccurrenceStats)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class TraceSummary:
|
|
112
|
+
"""One row of ``evalkeep trace list``."""
|
|
113
|
+
|
|
114
|
+
trace_id: str
|
|
115
|
+
status: str
|
|
116
|
+
source: str | None
|
|
117
|
+
recorded_at: str | None
|
|
118
|
+
events: int
|
|
119
|
+
redactions: int
|
|
120
|
+
occurrences: int = 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TraceStore:
|
|
124
|
+
"""Read/write access to one project's trace database."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
127
|
+
self._connection = connection
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
@contextmanager
|
|
131
|
+
def open(cls, path: Path) -> Iterator[TraceStore]:
|
|
132
|
+
"""Open (creating and migrating as needed) and always close cleanly."""
|
|
133
|
+
try:
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
connection = sqlite3.connect(path)
|
|
136
|
+
except (OSError, sqlite3.Error) as exc:
|
|
137
|
+
raise CommandError(f"Could not open the database {path}: {exc}") from exc
|
|
138
|
+
|
|
139
|
+
connection.row_factory = sqlite3.Row
|
|
140
|
+
try:
|
|
141
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
142
|
+
apply_migrations(connection)
|
|
143
|
+
yield cls(connection)
|
|
144
|
+
finally:
|
|
145
|
+
connection.close()
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def clusters(self) -> ClusterStore:
|
|
149
|
+
"""Clusterings, sharing this store's connection."""
|
|
150
|
+
return ClusterStore(self._connection)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def runs(self) -> RunStore:
|
|
154
|
+
"""Evaluation runs, sharing this store's connection."""
|
|
155
|
+
return RunStore(self._connection)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def tests(self) -> RegressionStore:
|
|
159
|
+
"""Regression-test drafts, sharing this store's connection."""
|
|
160
|
+
return RegressionStore(self._connection)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def failures(self) -> FailureStore:
|
|
164
|
+
"""Failure candidates, sharing this store's connection."""
|
|
165
|
+
return FailureStore(self._connection)
|
|
166
|
+
|
|
167
|
+
# -- writing ---------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def add(
|
|
170
|
+
self, trace: NormalizedTrace, *, redaction: RedactionSummary | None = None
|
|
171
|
+
) -> StoreOutcome:
|
|
172
|
+
"""Store a redacted trace, or explain why it was not stored."""
|
|
173
|
+
outcome = self.classify(trace)
|
|
174
|
+
if not outcome.written:
|
|
175
|
+
return outcome
|
|
176
|
+
|
|
177
|
+
summary = redaction or RedactionSummary()
|
|
178
|
+
payload = trace.model_dump(mode="json")
|
|
179
|
+
now = datetime.now(UTC).isoformat()
|
|
180
|
+
try:
|
|
181
|
+
with self._connection: # one transaction: trace and its events
|
|
182
|
+
self._connection.execute(
|
|
183
|
+
"""
|
|
184
|
+
INSERT INTO traces (
|
|
185
|
+
trace_id, content_hash, schema_version, status, source,
|
|
186
|
+
recorded_at, ingested_at, redactions, redaction_summary, payload
|
|
187
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
188
|
+
""",
|
|
189
|
+
(
|
|
190
|
+
trace.trace_id,
|
|
191
|
+
outcome.content_hash,
|
|
192
|
+
trace.schema_version,
|
|
193
|
+
trace.outcome.status.value,
|
|
194
|
+
trace.metadata.source,
|
|
195
|
+
_isoformat(trace.metadata.recorded_at),
|
|
196
|
+
now,
|
|
197
|
+
summary.total,
|
|
198
|
+
json.dumps(summary.to_dict(), sort_keys=True),
|
|
199
|
+
json.dumps(payload, sort_keys=True),
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
self._connection.executemany(
|
|
203
|
+
"""
|
|
204
|
+
INSERT INTO events (
|
|
205
|
+
trace_id, position, event_id, type, tool, call_id, timestamp, payload
|
|
206
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
207
|
+
""",
|
|
208
|
+
list(_event_rows(trace, payload)),
|
|
209
|
+
)
|
|
210
|
+
except sqlite3.Error as exc: # pragma: no cover - defensive
|
|
211
|
+
raise CommandError(f"Could not store trace {trace.trace_id!r}: {exc}") from exc
|
|
212
|
+
return outcome
|
|
213
|
+
|
|
214
|
+
def record_occurrence(
|
|
215
|
+
self, trace: NormalizedTrace, *, canonical_trace_id: str, digest: str
|
|
216
|
+
) -> bool:
|
|
217
|
+
"""Note that this interaction was seen. Returns True if it was new.
|
|
218
|
+
|
|
219
|
+
The occurrence ID is derived from the sighting itself, so re-ingesting a
|
|
220
|
+
file is a no-op rather than a way to inflate a frequency count.
|
|
221
|
+
"""
|
|
222
|
+
occurrence_id = occurrence_id_for(digest, trace)
|
|
223
|
+
with self._connection:
|
|
224
|
+
cursor = self._connection.execute(
|
|
225
|
+
"""
|
|
226
|
+
INSERT OR IGNORE INTO trace_occurrences (
|
|
227
|
+
occurrence_id, canonical_trace_id, content_hash, trace_id,
|
|
228
|
+
source, agent, model, recorded_at, ingested_at
|
|
229
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
230
|
+
""",
|
|
231
|
+
(
|
|
232
|
+
occurrence_id,
|
|
233
|
+
canonical_trace_id,
|
|
234
|
+
digest,
|
|
235
|
+
trace.trace_id,
|
|
236
|
+
trace.metadata.source,
|
|
237
|
+
trace.metadata.agent,
|
|
238
|
+
trace.metadata.model,
|
|
239
|
+
_isoformat(trace.metadata.recorded_at),
|
|
240
|
+
datetime.now(UTC).isoformat(),
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
return cursor.rowcount > 0
|
|
244
|
+
|
|
245
|
+
def occurrences(self, trace_id: str) -> OccurrenceStats:
|
|
246
|
+
"""How often this interaction was seen, and on which agent versions."""
|
|
247
|
+
row = self._connection.execute(
|
|
248
|
+
"""
|
|
249
|
+
SELECT COUNT(*) AS n,
|
|
250
|
+
MIN(COALESCE(recorded_at, ingested_at)) AS first_seen,
|
|
251
|
+
MAX(COALESCE(recorded_at, ingested_at)) AS last_seen
|
|
252
|
+
FROM trace_occurrences WHERE canonical_trace_id = ?
|
|
253
|
+
""",
|
|
254
|
+
(trace_id.strip(),),
|
|
255
|
+
).fetchone()
|
|
256
|
+
agents = [
|
|
257
|
+
r["agent"]
|
|
258
|
+
for r in self._connection.execute(
|
|
259
|
+
"""
|
|
260
|
+
SELECT DISTINCT agent FROM trace_occurrences
|
|
261
|
+
WHERE canonical_trace_id = ? AND agent IS NOT NULL
|
|
262
|
+
ORDER BY agent
|
|
263
|
+
""",
|
|
264
|
+
(trace_id.strip(),),
|
|
265
|
+
)
|
|
266
|
+
]
|
|
267
|
+
return OccurrenceStats(
|
|
268
|
+
count=int(row["n"]),
|
|
269
|
+
first_seen=row["first_seen"],
|
|
270
|
+
last_seen=row["last_seen"],
|
|
271
|
+
agents=tuple(agents),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def occurrence_list(self, trace_id: str) -> list[TraceOccurrence]:
|
|
275
|
+
return [
|
|
276
|
+
TraceOccurrence(
|
|
277
|
+
occurrence_id=row["occurrence_id"],
|
|
278
|
+
canonical_trace_id=row["canonical_trace_id"],
|
|
279
|
+
content_hash=row["content_hash"],
|
|
280
|
+
trace_id=row["trace_id"],
|
|
281
|
+
source=row["source"],
|
|
282
|
+
agent=row["agent"],
|
|
283
|
+
model=row["model"],
|
|
284
|
+
recorded_at=row["recorded_at"],
|
|
285
|
+
ingested_at=row["ingested_at"],
|
|
286
|
+
)
|
|
287
|
+
for row in self._connection.execute(
|
|
288
|
+
"""
|
|
289
|
+
SELECT * FROM trace_occurrences WHERE canonical_trace_id = ?
|
|
290
|
+
ORDER BY COALESCE(recorded_at, ingested_at), occurrence_id
|
|
291
|
+
""",
|
|
292
|
+
(trace_id.strip(),),
|
|
293
|
+
)
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
def classify(self, trace: NormalizedTrace) -> StoreOutcome:
|
|
297
|
+
"""Decide what storing ``trace`` would do, without writing anything."""
|
|
298
|
+
digest = content_hash(trace)
|
|
299
|
+
|
|
300
|
+
existing = self._connection.execute(
|
|
301
|
+
"SELECT content_hash FROM traces WHERE trace_id = ?", (trace.trace_id,)
|
|
302
|
+
).fetchone()
|
|
303
|
+
if existing is not None:
|
|
304
|
+
result = (
|
|
305
|
+
StoreResult.ALREADY_STORED
|
|
306
|
+
if existing["content_hash"] == digest
|
|
307
|
+
else StoreResult.ID_CONFLICT
|
|
308
|
+
)
|
|
309
|
+
return StoreOutcome(result, trace.trace_id, digest)
|
|
310
|
+
|
|
311
|
+
same_content = self._connection.execute(
|
|
312
|
+
"SELECT trace_id FROM traces WHERE content_hash = ? ORDER BY trace_id LIMIT 1",
|
|
313
|
+
(digest,),
|
|
314
|
+
).fetchone()
|
|
315
|
+
if same_content is not None:
|
|
316
|
+
return StoreOutcome(
|
|
317
|
+
StoreResult.CONTENT_DUPLICATE,
|
|
318
|
+
trace.trace_id,
|
|
319
|
+
digest,
|
|
320
|
+
existing_trace_id=same_content["trace_id"],
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return StoreOutcome(StoreResult.STORED, trace.trace_id, digest)
|
|
324
|
+
|
|
325
|
+
# -- reading ---------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def get(self, trace_id: str) -> StoredTrace | None:
|
|
328
|
+
row = self._connection.execute(
|
|
329
|
+
"SELECT * FROM traces WHERE trace_id = ?", (trace_id.strip(),)
|
|
330
|
+
).fetchone()
|
|
331
|
+
if row is None:
|
|
332
|
+
return None
|
|
333
|
+
return StoredTrace(
|
|
334
|
+
trace=NormalizedTrace.model_validate_json(row["payload"]),
|
|
335
|
+
content_hash=row["content_hash"],
|
|
336
|
+
ingested_at=row["ingested_at"],
|
|
337
|
+
redactions=row["redactions"],
|
|
338
|
+
redaction_summary=json.loads(row["redaction_summary"]),
|
|
339
|
+
occurrences=self.occurrences(row["trace_id"]),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def list(
|
|
343
|
+
self, *, limit: int = 50, offset: int = 0, status: str | None = None
|
|
344
|
+
) -> list[TraceSummary]:
|
|
345
|
+
query = """
|
|
346
|
+
SELECT t.trace_id, t.status, t.source, t.recorded_at, t.redactions,
|
|
347
|
+
(SELECT COUNT(*) FROM events e WHERE e.trace_id = t.trace_id) AS events,
|
|
348
|
+
(SELECT COUNT(*) FROM trace_occurrences o
|
|
349
|
+
WHERE o.canonical_trace_id = t.trace_id) AS occurrences
|
|
350
|
+
FROM traces t
|
|
351
|
+
"""
|
|
352
|
+
parameters: list[Any] = []
|
|
353
|
+
if status is not None:
|
|
354
|
+
query += " WHERE t.status = ?"
|
|
355
|
+
parameters.append(status)
|
|
356
|
+
query += " ORDER BY t.ingested_at, t.trace_id LIMIT ? OFFSET ?"
|
|
357
|
+
parameters += [limit, offset]
|
|
358
|
+
|
|
359
|
+
return [
|
|
360
|
+
TraceSummary(
|
|
361
|
+
trace_id=row["trace_id"],
|
|
362
|
+
status=row["status"],
|
|
363
|
+
source=row["source"],
|
|
364
|
+
recorded_at=row["recorded_at"],
|
|
365
|
+
events=row["events"],
|
|
366
|
+
redactions=row["redactions"],
|
|
367
|
+
occurrences=row["occurrences"],
|
|
368
|
+
)
|
|
369
|
+
for row in self._connection.execute(query, parameters)
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
def iter_traces(self) -> Iterator[NormalizedTrace]:
|
|
373
|
+
"""Stream every stored trace in ingest order, without loading them all."""
|
|
374
|
+
cursor = self._connection.execute(
|
|
375
|
+
"SELECT payload FROM traces ORDER BY ingested_at, trace_id"
|
|
376
|
+
)
|
|
377
|
+
for row in cursor:
|
|
378
|
+
yield NormalizedTrace.model_validate_json(row["payload"])
|
|
379
|
+
|
|
380
|
+
def count(self, *, status: str | None = None) -> int:
|
|
381
|
+
if status is None:
|
|
382
|
+
row = self._connection.execute("SELECT COUNT(*) AS n FROM traces").fetchone()
|
|
383
|
+
else:
|
|
384
|
+
row = self._connection.execute(
|
|
385
|
+
"SELECT COUNT(*) AS n FROM traces WHERE status = ?", (status,)
|
|
386
|
+
).fetchone()
|
|
387
|
+
return int(row["n"])
|
|
388
|
+
|
|
389
|
+
def event_count(self) -> int:
|
|
390
|
+
return int(self._connection.execute("SELECT COUNT(*) AS n FROM events").fetchone()["n"])
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def occurrence_id_for(digest: str, trace: NormalizedTrace) -> str:
|
|
394
|
+
"""Identify a sighting by what distinguishes it from other sightings.
|
|
395
|
+
|
|
396
|
+
Two records of the same interaction, carrying the same ID and the same
|
|
397
|
+
recorded time, are the same sighting seen twice -- most often because a file
|
|
398
|
+
was ingested again -- and must not count twice.
|
|
399
|
+
"""
|
|
400
|
+
material = "\n".join(
|
|
401
|
+
[
|
|
402
|
+
digest,
|
|
403
|
+
trace.trace_id,
|
|
404
|
+
_isoformat(trace.metadata.recorded_at) or "",
|
|
405
|
+
trace.metadata.source or "",
|
|
406
|
+
]
|
|
407
|
+
)
|
|
408
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:24]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _event_rows(trace: NormalizedTrace, payload: dict[str, Any]) -> Iterator[tuple[Any, ...]]:
|
|
412
|
+
"""Flatten events into index rows, keeping their recorded order."""
|
|
413
|
+
for position, (event, dumped) in enumerate(zip(trace.events, payload["events"], strict=True)):
|
|
414
|
+
tool = event.tool if isinstance(event, ToolCallEvent | ToolResultEvent) else None
|
|
415
|
+
call_id = event.call_id if isinstance(event, ToolCallEvent | ToolResultEvent) else None
|
|
416
|
+
yield (
|
|
417
|
+
trace.trace_id,
|
|
418
|
+
position,
|
|
419
|
+
event.event_id,
|
|
420
|
+
event.type,
|
|
421
|
+
tool,
|
|
422
|
+
call_id,
|
|
423
|
+
_isoformat(event.timestamp),
|
|
424
|
+
json.dumps(dumped, sort_keys=True),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _isoformat(value: datetime | None) -> str | None:
|
|
429
|
+
return value.isoformat() if value is not None else None
|