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