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,141 @@
|
|
|
1
|
+
"""``evalkeep review`` and the non-interactive decision commands.
|
|
2
|
+
|
|
3
|
+
The interactive loop lives in the CLI; everything it can do is also reachable
|
|
4
|
+
one decision at a time, so a script or a CI job can record the same decisions
|
|
5
|
+
without a terminal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from evalkeep.analysis import FailureAnalysis
|
|
14
|
+
from evalkeep.commands.dataset_cmd import show_test
|
|
15
|
+
from evalkeep.commands.detect_cmd import default_reviewer
|
|
16
|
+
from evalkeep.config import Project
|
|
17
|
+
from evalkeep.errors import CommandError
|
|
18
|
+
from evalkeep.failures import Failure
|
|
19
|
+
from evalkeep.regression import RegressionTest, ReviewStatus
|
|
20
|
+
from evalkeep.review import ReviewError, apply_edits, approve, reject, render_editable
|
|
21
|
+
from evalkeep.storage import StoredTrace, TraceStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ReviewItem:
|
|
26
|
+
"""Everything a reviewer needs on screen to decide: guide 8H's first rule."""
|
|
27
|
+
|
|
28
|
+
test: RegressionTest
|
|
29
|
+
trace: StoredTrace
|
|
30
|
+
failure: Failure
|
|
31
|
+
analysis: FailureAnalysis | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pending_reviews(*, project_root: Path = Path(), limit: int | None = None) -> list[ReviewItem]:
|
|
35
|
+
"""Drafts awaiting a decision, with their source interaction and analysis."""
|
|
36
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
37
|
+
with TraceStore.open(project.database_path) as store:
|
|
38
|
+
drafts = store.tests.list(status=ReviewStatus.DRAFT, limit=limit or 1000)
|
|
39
|
+
return [item for test in drafts if (item := _assemble(store, test)) is not None]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def review_item(identifier: str, *, project_root: Path = Path()) -> ReviewItem:
|
|
43
|
+
"""One test, with its context, whatever its status."""
|
|
44
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
45
|
+
with TraceStore.open(project.database_path) as store:
|
|
46
|
+
test = _resolve(store, identifier)
|
|
47
|
+
item = _assemble(store, test)
|
|
48
|
+
if item is None: # pragma: no cover - foreign keys prevent this
|
|
49
|
+
raise CommandError(f"Test {test.test_id!r} has lost its source trace.")
|
|
50
|
+
return item
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def approve_test(
|
|
54
|
+
identifier: str,
|
|
55
|
+
*,
|
|
56
|
+
project_root: Path = Path(),
|
|
57
|
+
reviewer: str | None = None,
|
|
58
|
+
reason: str | None = None,
|
|
59
|
+
) -> RegressionTest:
|
|
60
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
61
|
+
with TraceStore.open(project.database_path) as store:
|
|
62
|
+
test = _resolve(store, identifier)
|
|
63
|
+
try:
|
|
64
|
+
approved = approve(test, reviewer=reviewer or default_reviewer(), reason=reason)
|
|
65
|
+
except ReviewError as exc:
|
|
66
|
+
raise CommandError(
|
|
67
|
+
str(exc),
|
|
68
|
+
hint="Fix it with 'evalkeep dataset edit <id>' and approve again.",
|
|
69
|
+
) from exc
|
|
70
|
+
store.tests.save(approved)
|
|
71
|
+
return approved
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def reject_test(
|
|
75
|
+
identifier: str,
|
|
76
|
+
*,
|
|
77
|
+
project_root: Path = Path(),
|
|
78
|
+
reviewer: str | None = None,
|
|
79
|
+
reason: str | None = None,
|
|
80
|
+
) -> RegressionTest:
|
|
81
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
82
|
+
with TraceStore.open(project.database_path) as store:
|
|
83
|
+
test = _resolve(store, identifier)
|
|
84
|
+
rejected = reject(test, reviewer=reviewer or default_reviewer(), reason=reason)
|
|
85
|
+
store.tests.save(rejected)
|
|
86
|
+
return rejected
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def editable_document(identifier: str, *, project_root: Path = Path()) -> str:
|
|
90
|
+
"""The YAML a reviewer edits for one test."""
|
|
91
|
+
return render_editable(show_test(identifier, project_root=project_root))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def edit_test(
|
|
95
|
+
identifier: str,
|
|
96
|
+
document: str,
|
|
97
|
+
*,
|
|
98
|
+
project_root: Path = Path(),
|
|
99
|
+
editor: str | None = None,
|
|
100
|
+
) -> RegressionTest:
|
|
101
|
+
"""Apply an edited document. Nothing is stored unless it is valid."""
|
|
102
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
103
|
+
with TraceStore.open(project.database_path) as store:
|
|
104
|
+
test = _resolve(store, identifier)
|
|
105
|
+
result = apply_edits(test, document, editor=editor or default_reviewer())
|
|
106
|
+
if result.test is None:
|
|
107
|
+
raise CommandError(
|
|
108
|
+
"The edit was not applied:\n - " + "\n - ".join(result.errors),
|
|
109
|
+
hint="The stored draft is unchanged.",
|
|
110
|
+
)
|
|
111
|
+
store.tests.save(result.test)
|
|
112
|
+
return result.test
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _assemble(store: TraceStore, test: RegressionTest) -> ReviewItem | None:
|
|
116
|
+
failure = store.failures.get(test.failure_id)
|
|
117
|
+
stored = store.get(test.provenance.trace_id)
|
|
118
|
+
if failure is None or stored is None: # pragma: no cover - foreign keys prevent this
|
|
119
|
+
return None
|
|
120
|
+
return ReviewItem(
|
|
121
|
+
test=test,
|
|
122
|
+
trace=stored,
|
|
123
|
+
failure=failure,
|
|
124
|
+
analysis=store.failures.get_analysis(test.failure_id),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve(store: TraceStore, identifier: str) -> RegressionTest:
|
|
129
|
+
"""Accept a test ID, a failure ID, or the trace the test came from."""
|
|
130
|
+
cleaned = identifier.strip()
|
|
131
|
+
test = store.tests.get(cleaned) or store.tests.get_by_failure(cleaned)
|
|
132
|
+
if test is None:
|
|
133
|
+
failure = store.failures.get_by_trace(cleaned)
|
|
134
|
+
if failure is not None:
|
|
135
|
+
test = store.tests.get_by_failure(failure.failure_id)
|
|
136
|
+
if test is None:
|
|
137
|
+
raise CommandError(
|
|
138
|
+
f"No regression test matching {cleaned!r}.",
|
|
139
|
+
hint="Run 'evalkeep dataset list' to see what exists.",
|
|
140
|
+
)
|
|
141
|
+
return test
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""``evalkeep export`` and ``evalkeep run`` -- hand the suite to the runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from evalkeep.config import Project
|
|
11
|
+
from evalkeep.errors import CommandError
|
|
12
|
+
from evalkeep.exporters import ExportFormat, build_config, replay_warnings, to_jsonl
|
|
13
|
+
from evalkeep.redaction import Redactor
|
|
14
|
+
from evalkeep.regression import RegressionTest, ReviewStatus
|
|
15
|
+
from evalkeep.runner import RunOutcome, execute
|
|
16
|
+
from evalkeep.storage import TraceStore
|
|
17
|
+
from evalkeep.targets import Target, get_target
|
|
18
|
+
|
|
19
|
+
NOTHING_APPROVED = "No approved tests to export."
|
|
20
|
+
NOTHING_APPROVED_HINT = "Only approved tests leave the database. Run 'evalkeep review' first."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ExportResult:
|
|
25
|
+
format: ExportFormat
|
|
26
|
+
path: Path
|
|
27
|
+
tests: int
|
|
28
|
+
target_id: str | None = None
|
|
29
|
+
warnings: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def approved_tests(*, project_root: Path = Path()) -> list[RegressionTest]:
|
|
33
|
+
"""The suite: every approved test, and nothing else."""
|
|
34
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
35
|
+
with TraceStore.open(project.database_path) as store:
|
|
36
|
+
return store.tests.list(status=ReviewStatus.APPROVED, limit=10_000)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def export_suite(
|
|
40
|
+
*,
|
|
41
|
+
project_root: Path = Path(),
|
|
42
|
+
export_format: ExportFormat = ExportFormat.PROMPTFOO,
|
|
43
|
+
target_id: str | None = None,
|
|
44
|
+
out: Path | None = None,
|
|
45
|
+
) -> ExportResult:
|
|
46
|
+
"""Write the approved suite in the requested format."""
|
|
47
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
48
|
+
tests = approved_tests(project_root=project_root)
|
|
49
|
+
if not tests:
|
|
50
|
+
raise CommandError(NOTHING_APPROVED, hint=NOTHING_APPROVED_HINT)
|
|
51
|
+
|
|
52
|
+
directory = (out or project.subdir("exports")).expanduser()
|
|
53
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
if export_format is ExportFormat.JSONL:
|
|
56
|
+
path = directory / "tests.jsonl"
|
|
57
|
+
path.write_text(to_jsonl(tests), encoding="utf-8")
|
|
58
|
+
return ExportResult(format=export_format, path=path, tests=len(tests))
|
|
59
|
+
|
|
60
|
+
target = _target(project, target_id)
|
|
61
|
+
path = directory / f"promptfooconfig.{target.target_id}.yaml"
|
|
62
|
+
path.write_text(
|
|
63
|
+
yaml.safe_dump(build_config(tests, target), sort_keys=False, default_flow_style=False),
|
|
64
|
+
encoding="utf-8",
|
|
65
|
+
)
|
|
66
|
+
return ExportResult(
|
|
67
|
+
format=export_format,
|
|
68
|
+
path=path,
|
|
69
|
+
tests=len(tests),
|
|
70
|
+
target_id=target.target_id,
|
|
71
|
+
warnings=tuple(replay_warnings(tests, target)),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_suite(
|
|
76
|
+
*,
|
|
77
|
+
project_root: Path = Path(),
|
|
78
|
+
target_id: str,
|
|
79
|
+
limit: int | None = None,
|
|
80
|
+
repetitions: int = 1,
|
|
81
|
+
) -> RunOutcome:
|
|
82
|
+
"""Delegate execution of the approved suite to the configured runner."""
|
|
83
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
84
|
+
target = get_target(project.root, target_id)
|
|
85
|
+
target.validate_shape()
|
|
86
|
+
|
|
87
|
+
tests = approved_tests(project_root=project_root)
|
|
88
|
+
if not tests:
|
|
89
|
+
raise CommandError(NOTHING_APPROVED, hint="Run 'evalkeep review' first.")
|
|
90
|
+
if limit is not None:
|
|
91
|
+
tests = tests[:limit]
|
|
92
|
+
|
|
93
|
+
warnings = replay_warnings(tests, target)
|
|
94
|
+
outcome = execute(
|
|
95
|
+
tests,
|
|
96
|
+
target,
|
|
97
|
+
directory=project.subdir("runs") / f"{target.target_id}-pending",
|
|
98
|
+
command=list(project.config.runner.command),
|
|
99
|
+
timeout_seconds=project.config.runner.timeout_seconds,
|
|
100
|
+
repetitions=repetitions,
|
|
101
|
+
# Relative paths in a target are relative to the project, not to
|
|
102
|
+
# wherever the command happened to be typed.
|
|
103
|
+
working_directory=project.root,
|
|
104
|
+
redactor=Redactor(project.config.redaction),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Name the directory after the run only once the run has an identity.
|
|
108
|
+
final = project.subdir("runs") / outcome.run.run_id
|
|
109
|
+
pending = Path(outcome.run.output_dir or "")
|
|
110
|
+
if pending.is_dir() and not final.exists():
|
|
111
|
+
pending.rename(final)
|
|
112
|
+
outcome.run.output_dir = str(final)
|
|
113
|
+
|
|
114
|
+
outcome.messages[:0] = warnings
|
|
115
|
+
with TraceStore.open(project.database_path) as store:
|
|
116
|
+
store.runs.save(outcome.run, outcome.results)
|
|
117
|
+
return outcome
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _target(project: Project, target_id: str | None) -> Target:
|
|
121
|
+
if target_id is not None:
|
|
122
|
+
return get_target(project.root, target_id)
|
|
123
|
+
from evalkeep.targets import load_targets
|
|
124
|
+
|
|
125
|
+
targets = load_targets(project.root)
|
|
126
|
+
if len(targets.targets) == 1:
|
|
127
|
+
return next(iter(targets.targets.values()))
|
|
128
|
+
raise CommandError(
|
|
129
|
+
"Which target should this be exported for?",
|
|
130
|
+
hint="Pass --target, or add one with 'evalkeep targets add'.",
|
|
131
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""``evalkeep targets`` -- configure the agents under test."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from evalkeep.config import Project
|
|
8
|
+
from evalkeep.errors import CommandError
|
|
9
|
+
from evalkeep.targets import (
|
|
10
|
+
Extraction,
|
|
11
|
+
Target,
|
|
12
|
+
TargetKind,
|
|
13
|
+
find_secrets,
|
|
14
|
+
load_targets,
|
|
15
|
+
referenced_environment,
|
|
16
|
+
save_targets,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
SECRET_HINT = (
|
|
20
|
+
"Reference secrets as ${ENV_VAR}; targets.yaml is committed, so a literal "
|
|
21
|
+
"credential here would be a leak."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def add_target(
|
|
26
|
+
target_id: str,
|
|
27
|
+
kind: TargetKind,
|
|
28
|
+
*,
|
|
29
|
+
project_root: Path = Path(),
|
|
30
|
+
description: str | None = None,
|
|
31
|
+
url: str | None = None,
|
|
32
|
+
method: str = "POST",
|
|
33
|
+
headers: dict[str, str] | None = None,
|
|
34
|
+
body: dict[str, object] | None = None,
|
|
35
|
+
path: str | None = None,
|
|
36
|
+
function: str | None = None,
|
|
37
|
+
provider: str | None = None,
|
|
38
|
+
output_path: str | None = None,
|
|
39
|
+
tool_calls_path: str | None = None,
|
|
40
|
+
replace: bool = False,
|
|
41
|
+
) -> Target:
|
|
42
|
+
"""Record a target, refusing anything that carries a literal credential."""
|
|
43
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
44
|
+
file = load_targets(project.root)
|
|
45
|
+
|
|
46
|
+
cleaned = target_id.strip()
|
|
47
|
+
if not cleaned:
|
|
48
|
+
raise CommandError("A target needs a name.")
|
|
49
|
+
if cleaned in file.targets and not replace:
|
|
50
|
+
raise CommandError(
|
|
51
|
+
f"A target named {cleaned!r} already exists.",
|
|
52
|
+
hint="Pass --replace to overwrite it.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
extract = Extraction()
|
|
56
|
+
if output_path is not None:
|
|
57
|
+
extract.output = output_path
|
|
58
|
+
if tool_calls_path is not None:
|
|
59
|
+
extract.tool_calls = tool_calls_path
|
|
60
|
+
|
|
61
|
+
target = Target(
|
|
62
|
+
target_id=cleaned,
|
|
63
|
+
kind=kind,
|
|
64
|
+
description=description,
|
|
65
|
+
url=url,
|
|
66
|
+
method=method,
|
|
67
|
+
headers=headers or {},
|
|
68
|
+
body=body or {},
|
|
69
|
+
extract=extract,
|
|
70
|
+
path=path,
|
|
71
|
+
function=function,
|
|
72
|
+
provider=provider,
|
|
73
|
+
)
|
|
74
|
+
target.validate_shape()
|
|
75
|
+
|
|
76
|
+
# The check that makes targets.yaml safe to commit.
|
|
77
|
+
problems = find_secrets(target)
|
|
78
|
+
if problems:
|
|
79
|
+
raise CommandError(
|
|
80
|
+
"This target contains what looks like a credential:\n - " + "\n - ".join(problems),
|
|
81
|
+
hint=SECRET_HINT,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
file.targets[cleaned] = target
|
|
85
|
+
save_targets(project.root, file)
|
|
86
|
+
return target
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def list_targets(*, project_root: Path = Path()) -> list[Target]:
|
|
90
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
91
|
+
return sorted(load_targets(project.root).targets.values(), key=lambda t: t.target_id)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def show_target(target_id: str, *, project_root: Path = Path()) -> tuple[Target, dict[str, bool]]:
|
|
95
|
+
"""A target and the environment variables it depends on."""
|
|
96
|
+
from evalkeep.targets import get_target
|
|
97
|
+
|
|
98
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
99
|
+
target = get_target(project.root, target_id)
|
|
100
|
+
return target, referenced_environment(target)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def remove_target(target_id: str, *, project_root: Path = Path()) -> None:
|
|
104
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
105
|
+
file = load_targets(project.root)
|
|
106
|
+
if target_id.strip() not in file.targets:
|
|
107
|
+
raise CommandError(f"No target named {target_id.strip()!r}.")
|
|
108
|
+
del file.targets[target_id.strip()]
|
|
109
|
+
save_targets(project.root, file)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""``evalkeep trace`` -- inspect stored traces.
|
|
2
|
+
|
|
3
|
+
Safe by construction: the database only ever holds redacted traces, so there is
|
|
4
|
+
no unredacted view to expose here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from evalkeep.config import Project
|
|
13
|
+
from evalkeep.errors import CommandError
|
|
14
|
+
from evalkeep.storage import StoredTrace, TraceStore, TraceSummary
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TraceListing:
|
|
19
|
+
summaries: list[TraceSummary]
|
|
20
|
+
total: int
|
|
21
|
+
offset: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def show_trace(trace_id: str, *, project_root: Path = Path()) -> StoredTrace:
|
|
25
|
+
"""Load one stored trace, or explain that it is not there."""
|
|
26
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
27
|
+
with TraceStore.open(project.database_path) as store:
|
|
28
|
+
stored = next(
|
|
29
|
+
(
|
|
30
|
+
found
|
|
31
|
+
for candidate in project.identify(trace_id)
|
|
32
|
+
if (found := store.get(candidate)) is not None
|
|
33
|
+
),
|
|
34
|
+
None,
|
|
35
|
+
)
|
|
36
|
+
if stored is None:
|
|
37
|
+
raise CommandError(
|
|
38
|
+
f"No stored trace with ID {trace_id.strip()!r}.",
|
|
39
|
+
hint="Run 'evalkeep trace list' to see what has been ingested.",
|
|
40
|
+
)
|
|
41
|
+
return stored
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def list_traces(
|
|
45
|
+
*,
|
|
46
|
+
project_root: Path = Path(),
|
|
47
|
+
limit: int = 50,
|
|
48
|
+
offset: int = 0,
|
|
49
|
+
status: str | None = None,
|
|
50
|
+
) -> TraceListing:
|
|
51
|
+
"""Page through stored traces, newest ingest last."""
|
|
52
|
+
project = Project.load(project_root.expanduser().resolve())
|
|
53
|
+
with TraceStore.open(project.database_path) as store:
|
|
54
|
+
return TraceListing(
|
|
55
|
+
summaries=store.list(limit=limit, offset=offset, status=status),
|
|
56
|
+
total=store.count(status=status),
|
|
57
|
+
offset=offset,
|
|
58
|
+
)
|