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