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/cli.py
ADDED
|
@@ -0,0 +1,1933 @@
|
|
|
1
|
+
"""The Evalkeep command line.
|
|
2
|
+
|
|
3
|
+
The CLI coordinates commands and renders output; the business logic lives in
|
|
4
|
+
:mod:`evalkeep.commands` so it can be tested without a terminal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from evalkeep import __version__
|
|
20
|
+
from evalkeep.adapters import DEFAULT_ADAPTER, available_adapters
|
|
21
|
+
from evalkeep.analysis import Component, FailureType, Severity
|
|
22
|
+
from evalkeep.analysis_run import AnalysisReport
|
|
23
|
+
from evalkeep.commands.analyze_cmd import label_failure, run_analysis
|
|
24
|
+
from evalkeep.commands.compare_cmd import (
|
|
25
|
+
compare,
|
|
26
|
+
current_baseline,
|
|
27
|
+
list_runs,
|
|
28
|
+
promote_baseline,
|
|
29
|
+
show_run,
|
|
30
|
+
)
|
|
31
|
+
from evalkeep.commands.dataset_cmd import BuildReport, build_dataset, list_tests, show_test
|
|
32
|
+
from evalkeep.commands.detect_cmd import (
|
|
33
|
+
FailureDetail,
|
|
34
|
+
add_failure,
|
|
35
|
+
list_failures,
|
|
36
|
+
review_failure,
|
|
37
|
+
run_detection,
|
|
38
|
+
show_failure,
|
|
39
|
+
)
|
|
40
|
+
from evalkeep.commands.discover_cmd import (
|
|
41
|
+
ClusterDetail,
|
|
42
|
+
dismiss_cluster,
|
|
43
|
+
list_clusters,
|
|
44
|
+
merge_clusters,
|
|
45
|
+
rename_cluster,
|
|
46
|
+
restore_cluster,
|
|
47
|
+
run_discovery,
|
|
48
|
+
show_cluster,
|
|
49
|
+
split_cluster,
|
|
50
|
+
)
|
|
51
|
+
from evalkeep.commands.ingest_cmd import ingest_traces
|
|
52
|
+
from evalkeep.commands.init_cmd import Action, initialize_project
|
|
53
|
+
from evalkeep.commands.pipeline_cmd import PipelineReport, from_traces
|
|
54
|
+
from evalkeep.commands.review_cmd import (
|
|
55
|
+
ReviewItem,
|
|
56
|
+
approve_test,
|
|
57
|
+
edit_test,
|
|
58
|
+
editable_document,
|
|
59
|
+
pending_reviews,
|
|
60
|
+
reject_test,
|
|
61
|
+
review_item,
|
|
62
|
+
)
|
|
63
|
+
from evalkeep.commands.run_cmd import ExportResult, export_suite, run_suite
|
|
64
|
+
from evalkeep.commands.target_cmd import add_target, list_targets, remove_target, show_target
|
|
65
|
+
from evalkeep.commands.trace_cmd import list_traces, show_trace
|
|
66
|
+
from evalkeep.comparison import Classification, ComparisonReport
|
|
67
|
+
from evalkeep.detection import DetectionReport
|
|
68
|
+
from evalkeep.discovery import DiscoveryReport
|
|
69
|
+
from evalkeep.errors import CommandError, EvalkeepError, ExitCode
|
|
70
|
+
from evalkeep.exporters import parse_format
|
|
71
|
+
from evalkeep.failures import FailureStatus
|
|
72
|
+
from evalkeep.ingest import DEFAULT_SAMPLE_LIMIT, IngestMode, IngestReport
|
|
73
|
+
from evalkeep.regression import RegressionTest, ReviewStatus
|
|
74
|
+
from evalkeep.review import ReviewDecision, ReviewOutcome
|
|
75
|
+
from evalkeep.runner import RunOutcome
|
|
76
|
+
from evalkeep.runs import CaseResult, EvaluationRun, Outcome
|
|
77
|
+
from evalkeep.storage import StoredTrace
|
|
78
|
+
from evalkeep.targets import TargetKind
|
|
79
|
+
|
|
80
|
+
T = TypeVar("T")
|
|
81
|
+
|
|
82
|
+
app = typer.Typer(
|
|
83
|
+
name="evalkeep",
|
|
84
|
+
help="Turn production agent failures into a small, reviewed regression suite.",
|
|
85
|
+
no_args_is_help=True,
|
|
86
|
+
add_completion=False,
|
|
87
|
+
)
|
|
88
|
+
trace_app = typer.Typer(name="trace", help="Inspect stored traces.", no_args_is_help=True)
|
|
89
|
+
app.add_typer(trace_app)
|
|
90
|
+
failures_app = typer.Typer(
|
|
91
|
+
name="failures", help="Inspect and review failure candidates.", no_args_is_help=True
|
|
92
|
+
)
|
|
93
|
+
app.add_typer(failures_app)
|
|
94
|
+
clusters_app = typer.Typer(
|
|
95
|
+
name="clusters", help="Inspect and edit failure families.", no_args_is_help=True
|
|
96
|
+
)
|
|
97
|
+
app.add_typer(clusters_app)
|
|
98
|
+
dataset_app = typer.Typer(
|
|
99
|
+
name="dataset", help="Generate and inspect regression tests.", no_args_is_help=True
|
|
100
|
+
)
|
|
101
|
+
app.add_typer(dataset_app)
|
|
102
|
+
targets_app = typer.Typer(
|
|
103
|
+
name="targets", help="Configure the agents under test.", no_args_is_help=True
|
|
104
|
+
)
|
|
105
|
+
app.add_typer(targets_app)
|
|
106
|
+
runs_app = typer.Typer(name="runs", help="Inspect evaluation runs.", no_args_is_help=True)
|
|
107
|
+
app.add_typer(runs_app)
|
|
108
|
+
baseline_app = typer.Typer(
|
|
109
|
+
name="baseline", help="The run everything is compared against.", no_args_is_help=True
|
|
110
|
+
)
|
|
111
|
+
app.add_typer(baseline_app)
|
|
112
|
+
|
|
113
|
+
console = Console()
|
|
114
|
+
err_console = Console(stderr=True)
|
|
115
|
+
|
|
116
|
+
ACTION_STYLES: dict[Action, str] = {
|
|
117
|
+
Action.CREATED: "green",
|
|
118
|
+
Action.UPDATED: "green",
|
|
119
|
+
Action.OVERWRITTEN: "yellow",
|
|
120
|
+
Action.EXISTS: "dim",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
PROJECT_OPTION = typer.Option(Path(), "--project", "-C", metavar="DIR", help="Project directory.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _version_callback(value: bool) -> None:
|
|
127
|
+
if value:
|
|
128
|
+
console.print(__version__)
|
|
129
|
+
raise typer.Exit(ExitCode.OK)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.callback()
|
|
133
|
+
def cli(
|
|
134
|
+
_version: bool = typer.Option(
|
|
135
|
+
False,
|
|
136
|
+
"--version",
|
|
137
|
+
"-V",
|
|
138
|
+
callback=_version_callback,
|
|
139
|
+
is_eager=True,
|
|
140
|
+
help="Show the Evalkeep version and exit.",
|
|
141
|
+
),
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Evalkeep turns recorded failures into a trustworthy regression suite."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command("from-traces")
|
|
147
|
+
def from_traces_command(
|
|
148
|
+
path: Path = typer.Argument(..., help="Trace file to read."),
|
|
149
|
+
project: Path = PROJECT_OPTION,
|
|
150
|
+
trace_format: str = typer.Option(
|
|
151
|
+
DEFAULT_ADAPTER,
|
|
152
|
+
"--format",
|
|
153
|
+
"-f",
|
|
154
|
+
help=f"Trace format. One of: {', '.join(sorted(available_adapters()))}.",
|
|
155
|
+
),
|
|
156
|
+
limit: int | None = typer.Option(None, "--limit", min=1, help="Draft at most N tests."),
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Ingest, detect, group and draft tests in one pass, ready for review."""
|
|
159
|
+
report = _run(
|
|
160
|
+
lambda: from_traces(path, project_root=project, adapter_name=trace_format, limit=limit)
|
|
161
|
+
)
|
|
162
|
+
_render_pipeline(report)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _render_pipeline(report: PipelineReport) -> None:
|
|
166
|
+
console.print(f"[bold]{report.traces}[/] trace(s) ingested")
|
|
167
|
+
if report.already_known:
|
|
168
|
+
console.print(f"[dim]{report.already_known} were already stored, and were skipped[/]")
|
|
169
|
+
|
|
170
|
+
if report.found_nothing:
|
|
171
|
+
console.print("\n[bold]No failures found.[/]")
|
|
172
|
+
console.print(
|
|
173
|
+
"[dim]Evalkeep only reports evidence: an explicit failure status, "
|
|
174
|
+
"negative feedback, or a failed evaluator. If your traces record "
|
|
175
|
+
"none of those, mark them by hand with 'evalkeep failures add'.[/]"
|
|
176
|
+
)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
evidence = ", ".join(f"{kind} x{count}" for kind, count in sorted(report.evidence.items()))
|
|
180
|
+
console.print(f"[bold red]{report.failures}[/] failure(s) found [dim]{evidence}[/]")
|
|
181
|
+
console.print(f"[bold]{report.families}[/] failure famil(ies)")
|
|
182
|
+
colour = "green" if report.ready else "yellow"
|
|
183
|
+
console.print(f"[bold {colour}]{report.ready}[/] with enough evidence for a regression test")
|
|
184
|
+
if report.needs_expectation:
|
|
185
|
+
console.print(
|
|
186
|
+
f"[dim]{report.needs_expectation} of them only forbid the mistake that was "
|
|
187
|
+
"observed; say what should have happened at review.[/]"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
for note in report.notes:
|
|
191
|
+
console.print(f"\n[yellow]note:[/] {note}")
|
|
192
|
+
|
|
193
|
+
console.print(f"\nReview them: [bold]evalkeep review[/] ({report.pending_review} pending)")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.command()
|
|
197
|
+
def demo(
|
|
198
|
+
directory: Path = typer.Argument(Path("evalkeep-demo"), help="Where to write them."),
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Write the bundled example traces and agents into a directory.
|
|
201
|
+
|
|
202
|
+
The examples ship inside the package, so this works from a PyPI install
|
|
203
|
+
with no clone and no network.
|
|
204
|
+
"""
|
|
205
|
+
from evalkeep import examples
|
|
206
|
+
|
|
207
|
+
target = directory.expanduser()
|
|
208
|
+
written = _run(lambda: _write_examples(target))
|
|
209
|
+
console.print(f"[bold green]wrote[/] {len(written)} files to {target}")
|
|
210
|
+
console.print(
|
|
211
|
+
f"\nNext: [bold]evalkeep init && evalkeep ingest {target}/refund-agent/traces.jsonl[/]"
|
|
212
|
+
)
|
|
213
|
+
console.print(f"[dim]formats available: {', '.join(examples.available())}[/]")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _write_examples(target: Path) -> list[Path]:
|
|
217
|
+
from evalkeep import examples
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
return examples.copy_to(target)
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
raise CommandError(f"Could not write examples to {target}: {exc}") from exc
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command()
|
|
227
|
+
def version() -> None:
|
|
228
|
+
"""Print the Evalkeep version."""
|
|
229
|
+
console.print(__version__)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.command()
|
|
233
|
+
def init(
|
|
234
|
+
directory: Path = typer.Argument(Path(), help="Project directory to initialize."),
|
|
235
|
+
name: str | None = typer.Option(
|
|
236
|
+
None, "--name", help="Project name to record in the configuration."
|
|
237
|
+
),
|
|
238
|
+
force: bool = typer.Option(False, "--force", help="Rewrite an existing configuration file."),
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Create a safe local project structure. Safe to re-run."""
|
|
241
|
+
report = _run(lambda: initialize_project(directory, project_name=name, force=force))
|
|
242
|
+
|
|
243
|
+
table = Table(box=None, pad_edge=False)
|
|
244
|
+
table.add_column("action")
|
|
245
|
+
table.add_column("path")
|
|
246
|
+
table.add_column("", style="dim")
|
|
247
|
+
root = report.project.root
|
|
248
|
+
for step in report.steps:
|
|
249
|
+
try:
|
|
250
|
+
shown = step.path.relative_to(root)
|
|
251
|
+
except ValueError:
|
|
252
|
+
shown = step.path
|
|
253
|
+
table.add_row(
|
|
254
|
+
f"[{ACTION_STYLES[step.action]}]{step.action.value}[/]", str(shown), step.detail
|
|
255
|
+
)
|
|
256
|
+
console.print(table)
|
|
257
|
+
|
|
258
|
+
if report.changed:
|
|
259
|
+
name_shown = report.project.config.project_name
|
|
260
|
+
console.print(f"\n[bold green]Initialized[/] {name_shown} in {root}")
|
|
261
|
+
else:
|
|
262
|
+
console.print(f"\n[dim]Already initialized:[/] {root}")
|
|
263
|
+
console.print("Next: [bold]evalkeep ingest traces.jsonl[/]")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@app.command()
|
|
267
|
+
def ingest(
|
|
268
|
+
path: Path = typer.Argument(..., help="Trace file to read."),
|
|
269
|
+
validate_only: bool = typer.Option(
|
|
270
|
+
False,
|
|
271
|
+
"--validate-only",
|
|
272
|
+
help="Check the file alone. Needs no project, stores nothing.",
|
|
273
|
+
),
|
|
274
|
+
dry_run: bool = typer.Option(
|
|
275
|
+
False,
|
|
276
|
+
"--dry-run",
|
|
277
|
+
help="Redact and check against stored traces without writing anything.",
|
|
278
|
+
),
|
|
279
|
+
trace_format: str = typer.Option(
|
|
280
|
+
DEFAULT_ADAPTER,
|
|
281
|
+
"--format",
|
|
282
|
+
"-f",
|
|
283
|
+
help=f"Trace format. One of: {', '.join(sorted(available_adapters()))}.",
|
|
284
|
+
),
|
|
285
|
+
project: Path = PROJECT_OPTION,
|
|
286
|
+
errors: Path | None = typer.Option(
|
|
287
|
+
None, "--errors", metavar="PATH", help="Write one JSON object per issue to this file."
|
|
288
|
+
),
|
|
289
|
+
sample_limit: int = typer.Option(
|
|
290
|
+
DEFAULT_SAMPLE_LIMIT, "--show", min=0, help="How many issues to print."
|
|
291
|
+
),
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Validate, redact, deduplicate and store traces."""
|
|
294
|
+
report = _run(
|
|
295
|
+
lambda: ingest_traces(
|
|
296
|
+
path,
|
|
297
|
+
project_root=project,
|
|
298
|
+
adapter_name=trace_format,
|
|
299
|
+
validate_only=validate_only,
|
|
300
|
+
dry_run=dry_run,
|
|
301
|
+
error_path=errors,
|
|
302
|
+
sample_limit=sample_limit,
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
_render_ingest(report)
|
|
306
|
+
raise typer.Exit(report.exit_code)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@trace_app.command("list")
|
|
310
|
+
def trace_list(
|
|
311
|
+
project: Path = PROJECT_OPTION,
|
|
312
|
+
limit: int = typer.Option(50, "--limit", min=1, help="Rows to show."),
|
|
313
|
+
offset: int = typer.Option(0, "--offset", min=0, help="Rows to skip."),
|
|
314
|
+
status: str | None = typer.Option(None, "--status", help="Filter by outcome status."),
|
|
315
|
+
) -> None:
|
|
316
|
+
"""List stored traces."""
|
|
317
|
+
listing = _run(
|
|
318
|
+
lambda: list_traces(project_root=project, limit=limit, offset=offset, status=status)
|
|
319
|
+
)
|
|
320
|
+
if not listing.summaries:
|
|
321
|
+
console.print("[dim]No stored traces.[/]")
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
table = Table(box=None, pad_edge=False)
|
|
325
|
+
table.add_column("trace_id", style="cyan")
|
|
326
|
+
table.add_column("status")
|
|
327
|
+
table.add_column("events", justify="right")
|
|
328
|
+
table.add_column("seen", justify="right")
|
|
329
|
+
table.add_column("redactions", justify="right")
|
|
330
|
+
table.add_column("source", style="dim")
|
|
331
|
+
table.add_column("recorded", style="dim")
|
|
332
|
+
for summary in listing.summaries:
|
|
333
|
+
table.add_row(
|
|
334
|
+
summary.trace_id,
|
|
335
|
+
_status_markup(summary.status),
|
|
336
|
+
str(summary.events),
|
|
337
|
+
f"[yellow]{summary.occurrences}[/]"
|
|
338
|
+
if summary.occurrences > 1
|
|
339
|
+
else str(summary.occurrences),
|
|
340
|
+
str(summary.redactions),
|
|
341
|
+
summary.source or "",
|
|
342
|
+
summary.recorded_at or "",
|
|
343
|
+
)
|
|
344
|
+
console.print(table)
|
|
345
|
+
|
|
346
|
+
shown = len(listing.summaries)
|
|
347
|
+
console.print(f"\n[dim]{shown} of {listing.total} traces[/]")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@trace_app.command("show")
|
|
351
|
+
def trace_show(
|
|
352
|
+
trace_id: str = typer.Argument(..., help="Trace to inspect."),
|
|
353
|
+
project: Path = PROJECT_OPTION,
|
|
354
|
+
as_json: bool = typer.Option(False, "--json", help="Print the stored trace as JSON."),
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Inspect one stored trace. Stored traces are always redacted."""
|
|
357
|
+
stored = _run(lambda: show_trace(trace_id, project_root=project))
|
|
358
|
+
if as_json:
|
|
359
|
+
console.print_json(stored.trace.model_dump_json())
|
|
360
|
+
return
|
|
361
|
+
_render_trace(stored)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _render_trace(stored: StoredTrace) -> None:
|
|
365
|
+
trace = stored.trace
|
|
366
|
+
header = Table(box=None, pad_edge=False, show_header=False)
|
|
367
|
+
header.add_column(style="bold")
|
|
368
|
+
header.add_column()
|
|
369
|
+
header.add_row("trace_id", trace.trace_id)
|
|
370
|
+
header.add_row("status", _status_markup(trace.outcome.status.value))
|
|
371
|
+
header.add_row("content", stored.content_hash)
|
|
372
|
+
header.add_row("ingested", stored.ingested_at)
|
|
373
|
+
seen = stored.occurrences
|
|
374
|
+
if seen.count:
|
|
375
|
+
detail = f"{seen.count} time{'s' if seen.recurring else ''}"
|
|
376
|
+
if seen.recurring and seen.first_seen and seen.last_seen:
|
|
377
|
+
detail += f" ({seen.first_seen[:10]} to {seen.last_seen[:10]})"
|
|
378
|
+
if seen.agents:
|
|
379
|
+
detail += f", on {', '.join(seen.agents)}"
|
|
380
|
+
style = "yellow" if seen.recurring else "dim"
|
|
381
|
+
header.add_row("seen", f"[{style}]{detail}[/]")
|
|
382
|
+
if stored.redactions:
|
|
383
|
+
detail = ", ".join(f"{rule} x{count}" for rule, count in stored.redaction_summary.items())
|
|
384
|
+
header.add_row("redacted", f"{stored.redactions} values ({detail})")
|
|
385
|
+
console.print(header)
|
|
386
|
+
|
|
387
|
+
if trace.input.text:
|
|
388
|
+
console.print(f"\n[bold]input[/]\n{trace.input.text}")
|
|
389
|
+
for message in trace.input.messages:
|
|
390
|
+
console.print(f"\n[bold]input:{message.role.value}[/]\n{message.content}")
|
|
391
|
+
if trace.output is not None and trace.output.text:
|
|
392
|
+
console.print(f"\n[bold]output[/]\n{trace.output.text}")
|
|
393
|
+
|
|
394
|
+
if trace.events:
|
|
395
|
+
console.print("\n[bold]events[/]")
|
|
396
|
+
events = Table(box=None, pad_edge=False)
|
|
397
|
+
events.add_column("#", justify="right", style="dim")
|
|
398
|
+
events.add_column("type")
|
|
399
|
+
events.add_column("detail")
|
|
400
|
+
for position, event in enumerate(trace.events):
|
|
401
|
+
events.add_row(str(position), event.type, _event_detail(event))
|
|
402
|
+
console.print(events)
|
|
403
|
+
|
|
404
|
+
if trace.outcome.feedback is not None:
|
|
405
|
+
feedback = trace.outcome.feedback
|
|
406
|
+
console.print(
|
|
407
|
+
f"\n[bold]feedback[/] {feedback.rating or ''} {feedback.comment or ''}".rstrip()
|
|
408
|
+
)
|
|
409
|
+
for evaluation in trace.outcome.evaluations:
|
|
410
|
+
verdict = {True: "[green]pass[/]", False: "[red]fail[/]", None: "[dim]-[/]"}[
|
|
411
|
+
evaluation.passed
|
|
412
|
+
]
|
|
413
|
+
console.print(
|
|
414
|
+
f"[bold]eval[/] {evaluation.name} {verdict} {evaluation.reason or ''}".rstrip()
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _event_detail(event: object) -> str:
|
|
419
|
+
tool = getattr(event, "tool", None)
|
|
420
|
+
if tool is not None:
|
|
421
|
+
arguments = getattr(event, "arguments", None)
|
|
422
|
+
if arguments is not None:
|
|
423
|
+
return f"{tool}({json.dumps(arguments, sort_keys=True)})"
|
|
424
|
+
result = getattr(event, "result", None)
|
|
425
|
+
return f"{tool} -> {json.dumps(result, sort_keys=True, default=str)}"
|
|
426
|
+
content = getattr(event, "content", None)
|
|
427
|
+
if content is not None:
|
|
428
|
+
return f"{getattr(event, 'role', '')}: {content}"
|
|
429
|
+
return getattr(event, "name", "")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _status_markup(status: str) -> str:
|
|
433
|
+
styles = {"failure": "red", "success": "green", "error": "yellow"}
|
|
434
|
+
style = styles.get(status)
|
|
435
|
+
return f"[{style}]{status}[/]" if style else f"[dim]{status}[/]"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@app.command()
|
|
439
|
+
def detect(project: Path = PROJECT_OPTION) -> None:
|
|
440
|
+
"""Create evidence-backed failure candidates."""
|
|
441
|
+
report = _run(lambda: run_detection(project_root=project))
|
|
442
|
+
_render_detection(report)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@failures_app.command("list")
|
|
446
|
+
def failures_list(
|
|
447
|
+
project: Path = PROJECT_OPTION,
|
|
448
|
+
status: FailureStatus | None = typer.Option(None, "--status", help="Filter by status."),
|
|
449
|
+
limit: int = typer.Option(50, "--limit", min=1, help="Rows to show."),
|
|
450
|
+
offset: int = typer.Option(0, "--offset", min=0, help="Rows to skip."),
|
|
451
|
+
) -> None:
|
|
452
|
+
"""List failure candidates."""
|
|
453
|
+
listing = _run(
|
|
454
|
+
lambda: list_failures(project_root=project, status=status, limit=limit, offset=offset)
|
|
455
|
+
)
|
|
456
|
+
if not listing.summaries:
|
|
457
|
+
console.print("[dim]No failure candidates.[/]")
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
table = Table(box=None, pad_edge=False)
|
|
461
|
+
table.add_column("failure_id", style="cyan")
|
|
462
|
+
table.add_column("trace_id")
|
|
463
|
+
table.add_column("status")
|
|
464
|
+
# A count, not the kinds: seven columns of prose does not fit a terminal,
|
|
465
|
+
# and 'failures show' is where the evidence itself belongs.
|
|
466
|
+
table.add_column("evidence", justify="right")
|
|
467
|
+
table.add_column("type")
|
|
468
|
+
table.add_column("severity")
|
|
469
|
+
table.add_column("reviewer", style="dim")
|
|
470
|
+
for summary in listing.summaries:
|
|
471
|
+
table.add_row(
|
|
472
|
+
summary.failure_id,
|
|
473
|
+
summary.trace_id,
|
|
474
|
+
_failure_status_markup(summary.status),
|
|
475
|
+
str(summary.signals) if summary.signals else "[dim]manual[/]",
|
|
476
|
+
summary.failure_type or "[dim]-[/]",
|
|
477
|
+
_severity_markup(summary.severity) if summary.severity else "[dim]-[/]",
|
|
478
|
+
summary.reviewer or "",
|
|
479
|
+
)
|
|
480
|
+
console.print(table)
|
|
481
|
+
|
|
482
|
+
breakdown = ", ".join(
|
|
483
|
+
f"{count} {status.value}" for status, count in sorted(listing.counts.items())
|
|
484
|
+
)
|
|
485
|
+
console.print(f"\n[dim]{len(listing.summaries)} of {listing.total} ({breakdown})[/]")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@failures_app.command("show")
|
|
489
|
+
def failures_show(
|
|
490
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Failure ID or trace ID."),
|
|
491
|
+
project: Path = PROJECT_OPTION,
|
|
492
|
+
) -> None:
|
|
493
|
+
"""Inspect the evidence behind one failure."""
|
|
494
|
+
detail = _run(lambda: show_failure(identifier, project_root=project))
|
|
495
|
+
_render_failure(detail)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@failures_app.command("confirm")
|
|
499
|
+
def failures_confirm(
|
|
500
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Failure ID or trace ID."),
|
|
501
|
+
project: Path = PROJECT_OPTION,
|
|
502
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is deciding."),
|
|
503
|
+
reason: str | None = typer.Option(None, "--reason", help="Why."),
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Confirm a failure candidate is a real failure."""
|
|
506
|
+
failure = _run(
|
|
507
|
+
lambda: review_failure(
|
|
508
|
+
identifier,
|
|
509
|
+
FailureStatus.CONFIRMED,
|
|
510
|
+
project_root=project,
|
|
511
|
+
reviewer=reviewer,
|
|
512
|
+
reason=reason,
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
console.print(
|
|
516
|
+
f"[bold green]confirmed[/] {failure.failure_id} ({failure.trace_id}) by {failure.reviewer}"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@failures_app.command("dismiss")
|
|
521
|
+
def failures_dismiss(
|
|
522
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Failure ID or trace ID."),
|
|
523
|
+
project: Path = PROJECT_OPTION,
|
|
524
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is deciding."),
|
|
525
|
+
reason: str | None = typer.Option(None, "--reason", help="Why."),
|
|
526
|
+
) -> None:
|
|
527
|
+
"""Dismiss a failure candidate. The record is kept for audit."""
|
|
528
|
+
failure = _run(
|
|
529
|
+
lambda: review_failure(
|
|
530
|
+
identifier,
|
|
531
|
+
FailureStatus.DISMISSED,
|
|
532
|
+
project_root=project,
|
|
533
|
+
reviewer=reviewer,
|
|
534
|
+
reason=reason,
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
console.print(
|
|
538
|
+
f"[bold yellow]dismissed[/] {failure.failure_id} ({failure.trace_id}) by {failure.reviewer}"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@failures_app.command("add")
|
|
543
|
+
def failures_add(
|
|
544
|
+
trace_id: str = typer.Argument(..., help="Trace to mark as a failure."),
|
|
545
|
+
project: Path = PROJECT_OPTION,
|
|
546
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is deciding."),
|
|
547
|
+
reason: str | None = typer.Option(None, "--reason", help="Why."),
|
|
548
|
+
) -> None:
|
|
549
|
+
"""Mark a trace as a failure by hand, with no detector evidence."""
|
|
550
|
+
failure = _run(
|
|
551
|
+
lambda: add_failure(trace_id, project_root=project, reviewer=reviewer, reason=reason)
|
|
552
|
+
)
|
|
553
|
+
console.print(
|
|
554
|
+
f"[bold green]added[/] {failure.failure_id} for {failure.trace_id} by {failure.reviewer}"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@app.command()
|
|
559
|
+
def analyze(
|
|
560
|
+
project: Path = PROJECT_OPTION,
|
|
561
|
+
reanalyze: bool = typer.Option(
|
|
562
|
+
False, "--reanalyze", help="Re-run over existing machine analyses."
|
|
563
|
+
),
|
|
564
|
+
overwrite_manual: bool = typer.Option(
|
|
565
|
+
False,
|
|
566
|
+
"--overwrite-manual",
|
|
567
|
+
help="Also replace hand-written labels. Off by default.",
|
|
568
|
+
),
|
|
569
|
+
limit: int | None = typer.Option(None, "--limit", min=1, help="Stop after N failures."),
|
|
570
|
+
no_cache: bool = typer.Option(False, "--no-cache", help="Ignore the analyzer cache."),
|
|
571
|
+
) -> None:
|
|
572
|
+
"""Describe failures with the configured analyzer provider."""
|
|
573
|
+
report = _run(
|
|
574
|
+
lambda: run_analysis(
|
|
575
|
+
project_root=project,
|
|
576
|
+
reanalyze=reanalyze,
|
|
577
|
+
overwrite_manual=overwrite_manual,
|
|
578
|
+
limit=limit,
|
|
579
|
+
use_cache=not no_cache,
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
_render_analysis(report)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@failures_app.command("label")
|
|
586
|
+
def failures_label(
|
|
587
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Failure ID or trace ID."),
|
|
588
|
+
failure_type: FailureType = typer.Option(..., "--type", help="What went wrong."),
|
|
589
|
+
component: Component = typer.Option(..., "--component", help="Where it went wrong."),
|
|
590
|
+
severity: Severity = typer.Option(..., "--severity", help="How much it matters."),
|
|
591
|
+
summary: str = typer.Option(..., "--summary", help="One sentence naming the mistake."),
|
|
592
|
+
project: Path = PROJECT_OPTION,
|
|
593
|
+
labeler: str | None = typer.Option(None, "--labeler", help="Who is labelling."),
|
|
594
|
+
) -> None:
|
|
595
|
+
"""Describe a failure by hand. Works with no analyzer provider configured."""
|
|
596
|
+
analysis = _run(
|
|
597
|
+
lambda: label_failure(
|
|
598
|
+
identifier,
|
|
599
|
+
failure_type=failure_type,
|
|
600
|
+
component=component,
|
|
601
|
+
severity=severity,
|
|
602
|
+
summary=summary,
|
|
603
|
+
project_root=project,
|
|
604
|
+
labeler=labeler,
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
console.print(
|
|
608
|
+
f"[bold green]labelled[/] {analysis.failure_type.value} / "
|
|
609
|
+
f"{analysis.component.value} / {analysis.severity.value} by {analysis.labeler}"
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _render_analysis(report: AnalysisReport) -> None:
|
|
614
|
+
summary = Table(box=None, pad_edge=False, show_header=False)
|
|
615
|
+
summary.add_column(style="bold")
|
|
616
|
+
summary.add_column(justify="right")
|
|
617
|
+
summary.add_row("analyzer", report.analyzer)
|
|
618
|
+
summary.add_row("prompt version", str(report.prompt_version))
|
|
619
|
+
summary.add_row("considered", str(report.considered))
|
|
620
|
+
summary.add_row("analyzed", f"[green]{report.analyzed}[/]")
|
|
621
|
+
if report.from_cache:
|
|
622
|
+
summary.add_row("from cache", str(report.from_cache))
|
|
623
|
+
if report.skipped:
|
|
624
|
+
summary.add_row("already analyzed", str(report.skipped))
|
|
625
|
+
if report.manual_kept:
|
|
626
|
+
summary.add_row("hand labels kept", str(report.manual_kept))
|
|
627
|
+
if report.failed:
|
|
628
|
+
summary.add_row("failed", f"[red]{report.failed}[/]")
|
|
629
|
+
if report.redactions:
|
|
630
|
+
summary.add_row("redacted values", str(report.redactions))
|
|
631
|
+
console.print(summary)
|
|
632
|
+
|
|
633
|
+
if report.by_type:
|
|
634
|
+
detail = ", ".join(f"{name} x{count}" for name, count in sorted(report.by_type.items()))
|
|
635
|
+
console.print(f"[dim]{detail}[/]")
|
|
636
|
+
|
|
637
|
+
for failure_id, message in report.errors:
|
|
638
|
+
err_console.print(f"[yellow]could not analyze[/] {failure_id}: {message}")
|
|
639
|
+
|
|
640
|
+
console.print("\nNext: [bold]evalkeep failures list[/]")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _failure_status_markup(status: FailureStatus) -> str:
|
|
644
|
+
styles = {
|
|
645
|
+
FailureStatus.CONFIRMED: "red",
|
|
646
|
+
FailureStatus.CANDIDATE: "yellow",
|
|
647
|
+
FailureStatus.DISMISSED: "dim",
|
|
648
|
+
}
|
|
649
|
+
return f"[{styles[status]}]{status.value}[/]"
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@app.command()
|
|
653
|
+
def discover(
|
|
654
|
+
project: Path = PROJECT_OPTION,
|
|
655
|
+
skip_analysis: bool = typer.Option(
|
|
656
|
+
False, "--skip-analysis", help="Group what is already analyzed, analyzing nothing."
|
|
657
|
+
),
|
|
658
|
+
force: bool = typer.Option(
|
|
659
|
+
False, "--force", help="Re-cluster even if it discards reviewer edits."
|
|
660
|
+
),
|
|
661
|
+
no_cache: bool = typer.Option(False, "--no-cache", help="Ignore the embedding cache."),
|
|
662
|
+
group_undescribed: bool = typer.Option(
|
|
663
|
+
False,
|
|
664
|
+
"--group-undescribed",
|
|
665
|
+
help="Also group failures nobody has described, by observed behaviour.",
|
|
666
|
+
),
|
|
667
|
+
) -> None:
|
|
668
|
+
"""Analyze, embed, cluster and select representatives."""
|
|
669
|
+
report = _run(
|
|
670
|
+
lambda: run_discovery(
|
|
671
|
+
project_root=project,
|
|
672
|
+
analyze=not skip_analysis,
|
|
673
|
+
force=force,
|
|
674
|
+
use_cache=not no_cache,
|
|
675
|
+
group_undescribed=group_undescribed,
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
_render_discovery(report)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@clusters_app.command("list")
|
|
682
|
+
def clusters_list(
|
|
683
|
+
project: Path = PROJECT_OPTION,
|
|
684
|
+
include_dismissed: bool = typer.Option(
|
|
685
|
+
True, "--dismissed/--no-dismissed", help="Include dismissed families."
|
|
686
|
+
),
|
|
687
|
+
) -> None:
|
|
688
|
+
"""List failure families."""
|
|
689
|
+
clusters = _run(
|
|
690
|
+
lambda: list_clusters(project_root=project, include_dismissed=include_dismissed)
|
|
691
|
+
)
|
|
692
|
+
if not clusters:
|
|
693
|
+
console.print("[dim]No clusters. Run 'evalkeep discover'.[/]")
|
|
694
|
+
return
|
|
695
|
+
|
|
696
|
+
table = Table(box=None, pad_edge=False)
|
|
697
|
+
table.add_column("cluster_id", style="cyan")
|
|
698
|
+
table.add_column("size", justify="right")
|
|
699
|
+
table.add_column("reps", justify="right")
|
|
700
|
+
table.add_column("label")
|
|
701
|
+
table.add_column("state", style="dim")
|
|
702
|
+
for cluster in clusters:
|
|
703
|
+
state = "dismissed" if cluster.dismissed else ("renamed" if cluster.labelled_by else "")
|
|
704
|
+
table.add_row(
|
|
705
|
+
cluster.cluster_id,
|
|
706
|
+
str(cluster.size),
|
|
707
|
+
str(len(cluster.representatives)),
|
|
708
|
+
f"[dim]{cluster.label}[/]" if cluster.dismissed else cluster.label,
|
|
709
|
+
state,
|
|
710
|
+
)
|
|
711
|
+
console.print(table)
|
|
712
|
+
|
|
713
|
+
total = sum(cluster.size for cluster in clusters)
|
|
714
|
+
console.print(f"\n[dim]{len(clusters)} clusters covering {total} failures[/]")
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@clusters_app.command("show")
|
|
718
|
+
def clusters_show(
|
|
719
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Cluster, failure or trace ID."),
|
|
720
|
+
project: Path = PROJECT_OPTION,
|
|
721
|
+
) -> None:
|
|
722
|
+
"""Inspect one family and its representatives."""
|
|
723
|
+
detail = _run(lambda: show_cluster(identifier, project_root=project))
|
|
724
|
+
_render_cluster(detail)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
@clusters_app.command("rename")
|
|
728
|
+
def clusters_rename(
|
|
729
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Cluster, failure or trace ID."),
|
|
730
|
+
label: str = typer.Argument(..., help="New label."),
|
|
731
|
+
project: Path = PROJECT_OPTION,
|
|
732
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is renaming."),
|
|
733
|
+
) -> None:
|
|
734
|
+
"""Rename a family."""
|
|
735
|
+
cluster = _run(
|
|
736
|
+
lambda: rename_cluster(identifier, label, project_root=project, reviewer=reviewer)
|
|
737
|
+
)
|
|
738
|
+
console.print(f"[bold green]renamed[/] {cluster.cluster_id} to {cluster.label!r}")
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
@clusters_app.command("dismiss")
|
|
742
|
+
def clusters_dismiss(
|
|
743
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Cluster, failure or trace ID."),
|
|
744
|
+
project: Path = PROJECT_OPTION,
|
|
745
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is deciding."),
|
|
746
|
+
) -> None:
|
|
747
|
+
"""Mark a family as not worth regression coverage. It is kept, not deleted."""
|
|
748
|
+
cluster = _run(lambda: dismiss_cluster(identifier, project_root=project, reviewer=reviewer))
|
|
749
|
+
console.print(f"[bold yellow]dismissed[/] {cluster.cluster_id} ({cluster.label})")
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@clusters_app.command("restore")
|
|
753
|
+
def clusters_restore(
|
|
754
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Cluster, failure or trace ID."),
|
|
755
|
+
project: Path = PROJECT_OPTION,
|
|
756
|
+
) -> None:
|
|
757
|
+
"""Undo a dismissal."""
|
|
758
|
+
cluster = _run(lambda: restore_cluster(identifier, project_root=project))
|
|
759
|
+
console.print(f"[bold green]restored[/] {cluster.cluster_id} ({cluster.label})")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@clusters_app.command("merge")
|
|
763
|
+
def clusters_merge(
|
|
764
|
+
identifiers: list[str] = typer.Argument(..., metavar="ID...", help="Clusters to merge."),
|
|
765
|
+
project: Path = PROJECT_OPTION,
|
|
766
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is deciding."),
|
|
767
|
+
) -> None:
|
|
768
|
+
"""Combine several families into one."""
|
|
769
|
+
cluster = _run(lambda: merge_clusters(identifiers, project_root=project, reviewer=reviewer))
|
|
770
|
+
console.print(
|
|
771
|
+
f"[bold green]merged[/] into {cluster.cluster_id} "
|
|
772
|
+
f"({cluster.size} failures, {cluster.label!r})"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
@clusters_app.command("split")
|
|
777
|
+
def clusters_split(
|
|
778
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Cluster to split."),
|
|
779
|
+
failure: list[str] = typer.Option(..., "--failure", help="Failure to move out. Repeatable."),
|
|
780
|
+
project: Path = PROJECT_OPTION,
|
|
781
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is deciding."),
|
|
782
|
+
) -> None:
|
|
783
|
+
"""Move some members of a family into a new family of their own."""
|
|
784
|
+
remainder, extracted = _run(
|
|
785
|
+
lambda: split_cluster(identifier, failure, project_root=project, reviewer=reviewer)
|
|
786
|
+
)
|
|
787
|
+
console.print(
|
|
788
|
+
f"[bold green]split[/] {extracted.cluster_id} ({extracted.size}) "
|
|
789
|
+
f"out of {remainder.cluster_id} ({remainder.size})"
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _render_discovery(report: DiscoveryReport) -> None:
|
|
794
|
+
summary = Table(box=None, pad_edge=False, show_header=False)
|
|
795
|
+
summary.add_column(style="bold")
|
|
796
|
+
summary.add_column(justify="right")
|
|
797
|
+
summary.add_row("embedder", report.embedder)
|
|
798
|
+
summary.add_row("failures", str(report.considered))
|
|
799
|
+
if report.unanalyzed:
|
|
800
|
+
summary.add_row("not analyzed", f"[yellow]{report.unanalyzed}[/]")
|
|
801
|
+
summary.add_row("clusters", f"[green]{report.clusters}[/]")
|
|
802
|
+
summary.add_row("largest", str(report.largest))
|
|
803
|
+
summary.add_row("singletons", str(report.singletons))
|
|
804
|
+
summary.add_row("representatives", str(report.representatives))
|
|
805
|
+
summary.add_row("embedded", str(report.embedded))
|
|
806
|
+
if report.from_cache:
|
|
807
|
+
summary.add_row("from cache", str(report.from_cache))
|
|
808
|
+
if report.kept_labels:
|
|
809
|
+
summary.add_row("labels kept", str(report.kept_labels))
|
|
810
|
+
if report.discarded_edits:
|
|
811
|
+
summary.add_row("edits discarded", f"[red]{report.discarded_edits}[/]")
|
|
812
|
+
console.print(summary)
|
|
813
|
+
|
|
814
|
+
parameters = ", ".join(f"{key}={value}" for key, value in sorted(report.parameters.items()))
|
|
815
|
+
console.print(f"[dim]{parameters}[/]")
|
|
816
|
+
console.print("\nNext: [bold]evalkeep clusters list[/]")
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _render_cluster(detail: ClusterDetail) -> None:
|
|
820
|
+
cluster = detail.cluster
|
|
821
|
+
header = Table(box=None, pad_edge=False, show_header=False)
|
|
822
|
+
header.add_column(style="bold")
|
|
823
|
+
header.add_column()
|
|
824
|
+
header.add_row("cluster_id", cluster.cluster_id)
|
|
825
|
+
header.add_row("label", cluster.label)
|
|
826
|
+
header.add_row("size", str(cluster.size))
|
|
827
|
+
if cluster.labelled_by:
|
|
828
|
+
header.add_row("edited by", cluster.labelled_by)
|
|
829
|
+
if cluster.dismissed:
|
|
830
|
+
header.add_row("state", "[yellow]dismissed[/]")
|
|
831
|
+
console.print(header)
|
|
832
|
+
|
|
833
|
+
console.print("\n[bold]members[/]")
|
|
834
|
+
table = Table(box=None, pad_edge=False)
|
|
835
|
+
table.add_column("failure_id", style="cyan")
|
|
836
|
+
table.add_column("trace_id")
|
|
837
|
+
table.add_column("dist", justify="right")
|
|
838
|
+
table.add_column("role")
|
|
839
|
+
table.add_column("severity")
|
|
840
|
+
table.add_column("summary")
|
|
841
|
+
for member in cluster.members:
|
|
842
|
+
analysis = detail.analyses.get(member.failure_id)
|
|
843
|
+
table.add_row(
|
|
844
|
+
member.failure_id,
|
|
845
|
+
detail.trace_ids.get(member.failure_id, ""),
|
|
846
|
+
f"{member.distance:.2f}",
|
|
847
|
+
", ".join(role.value for role in member.roles) or "[dim]-[/]",
|
|
848
|
+
_severity_markup(analysis.severity.value) if analysis else "",
|
|
849
|
+
analysis.summary if analysis else "",
|
|
850
|
+
)
|
|
851
|
+
console.print(table)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
@dataset_app.command("build")
|
|
855
|
+
def dataset_build(
|
|
856
|
+
project: Path = PROJECT_OPTION,
|
|
857
|
+
all_failures: bool = typer.Option(
|
|
858
|
+
False, "--all", help="Cover every failure, not just cluster representatives."
|
|
859
|
+
),
|
|
860
|
+
regenerate: bool = typer.Option(
|
|
861
|
+
False, "--regenerate", help="Rewrite existing drafts. Never touches reviewed tests."
|
|
862
|
+
),
|
|
863
|
+
limit: int | None = typer.Option(None, "--limit", min=1, help="Stop after N tests."),
|
|
864
|
+
) -> None:
|
|
865
|
+
"""Generate pending regression-test drafts."""
|
|
866
|
+
report = _run(
|
|
867
|
+
lambda: build_dataset(
|
|
868
|
+
project_root=project,
|
|
869
|
+
representatives_only=not all_failures,
|
|
870
|
+
regenerate=regenerate,
|
|
871
|
+
limit=limit,
|
|
872
|
+
)
|
|
873
|
+
)
|
|
874
|
+
_render_build(report)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
@dataset_app.command("list")
|
|
878
|
+
def dataset_list(
|
|
879
|
+
project: Path = PROJECT_OPTION,
|
|
880
|
+
status: ReviewStatus | None = typer.Option(None, "--status", help="Filter by status."),
|
|
881
|
+
limit: int = typer.Option(50, "--limit", min=1, help="Rows to show."),
|
|
882
|
+
offset: int = typer.Option(0, "--offset", min=0, help="Rows to skip."),
|
|
883
|
+
) -> None:
|
|
884
|
+
"""List regression tests."""
|
|
885
|
+
listing = _run(
|
|
886
|
+
lambda: list_tests(project_root=project, status=status, limit=limit, offset=offset)
|
|
887
|
+
)
|
|
888
|
+
if not listing.tests:
|
|
889
|
+
console.print("[dim]No regression tests. Run 'evalkeep dataset build'.[/]")
|
|
890
|
+
return
|
|
891
|
+
|
|
892
|
+
table = Table(box=None, pad_edge=False)
|
|
893
|
+
table.add_column("test_id", style="cyan", overflow="fold")
|
|
894
|
+
table.add_column("status")
|
|
895
|
+
table.add_column("checks", justify="right")
|
|
896
|
+
table.add_column("reviewer", style="dim")
|
|
897
|
+
table.add_column("needs", style="yellow")
|
|
898
|
+
for test in listing.tests:
|
|
899
|
+
needs = []
|
|
900
|
+
if not test.has_positive_expectation:
|
|
901
|
+
needs.append("expectation")
|
|
902
|
+
if test.contradictions:
|
|
903
|
+
needs.append("conflict")
|
|
904
|
+
table.add_row(
|
|
905
|
+
test.test_id,
|
|
906
|
+
_test_status_markup(test.status),
|
|
907
|
+
str(len(test.deterministic_expectations)),
|
|
908
|
+
f"{test.reviewer} (edited)" if test.edited else (test.reviewer or ""),
|
|
909
|
+
", ".join(needs),
|
|
910
|
+
)
|
|
911
|
+
console.print(table)
|
|
912
|
+
|
|
913
|
+
breakdown = ", ".join(
|
|
914
|
+
f"{count} {status.value}" for status, count in sorted(listing.counts.items())
|
|
915
|
+
)
|
|
916
|
+
console.print(f"\n[dim]{len(listing.tests)} of {listing.total} ({breakdown})[/]")
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
@dataset_app.command("show")
|
|
920
|
+
def dataset_show(
|
|
921
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Test, failure or trace ID."),
|
|
922
|
+
project: Path = PROJECT_OPTION,
|
|
923
|
+
) -> None:
|
|
924
|
+
"""Inspect one regression test and its provenance."""
|
|
925
|
+
test = _run(lambda: show_test(identifier, project_root=project))
|
|
926
|
+
_render_test(test)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@app.command()
|
|
930
|
+
def review(
|
|
931
|
+
identifier: str | None = typer.Argument(
|
|
932
|
+
None, metavar="[ID]", help="Review one test instead of every draft."
|
|
933
|
+
),
|
|
934
|
+
project: Path = PROJECT_OPTION,
|
|
935
|
+
limit: int | None = typer.Option(None, "--limit", min=1, help="Review at most N drafts."),
|
|
936
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is reviewing."),
|
|
937
|
+
) -> None:
|
|
938
|
+
"""Approve, edit, reject or skip drafts."""
|
|
939
|
+
if identifier is not None:
|
|
940
|
+
items = [_run(lambda: review_item(identifier, project_root=project))]
|
|
941
|
+
else:
|
|
942
|
+
items = _run(lambda: pending_reviews(project_root=project, limit=limit))
|
|
943
|
+
|
|
944
|
+
if not items:
|
|
945
|
+
console.print("[dim]Nothing to review. Run 'evalkeep dataset build'.[/]")
|
|
946
|
+
return
|
|
947
|
+
|
|
948
|
+
who = reviewer or _default_reviewer()
|
|
949
|
+
outcome = ReviewOutcome(remaining=len(items))
|
|
950
|
+
for position, item in enumerate(items, start=1):
|
|
951
|
+
console.rule(f"[bold]{position} of {len(items)}[/] {item.test.test_id}")
|
|
952
|
+
if not _review_one(item, project=project, reviewer=who, outcome=outcome):
|
|
953
|
+
break
|
|
954
|
+
outcome.remaining -= 1
|
|
955
|
+
|
|
956
|
+
_render_review_outcome(outcome)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _review_one(item: ReviewItem, *, project: Path, reviewer: str, outcome: ReviewOutcome) -> bool:
|
|
960
|
+
"""Show one draft and act on the decision. Returns False to stop the session."""
|
|
961
|
+
current = item
|
|
962
|
+
while True:
|
|
963
|
+
_render_review_item(current)
|
|
964
|
+
decision = _ask_decision()
|
|
965
|
+
|
|
966
|
+
match decision:
|
|
967
|
+
case ReviewDecision.APPROVE:
|
|
968
|
+
try:
|
|
969
|
+
approve_test(
|
|
970
|
+
current.test.test_id,
|
|
971
|
+
project_root=project,
|
|
972
|
+
reviewer=reviewer,
|
|
973
|
+
reason=_ask_reason("Why approve? (optional)"),
|
|
974
|
+
)
|
|
975
|
+
except EvalkeepError as exc:
|
|
976
|
+
err_console.print(f"[bold red]error:[/] {exc.message}")
|
|
977
|
+
continue
|
|
978
|
+
outcome.reviewed += 1
|
|
979
|
+
outcome.approved += 1
|
|
980
|
+
if current.test.edited:
|
|
981
|
+
outcome.edited += 1
|
|
982
|
+
console.print("[bold green]approved[/]")
|
|
983
|
+
return True
|
|
984
|
+
|
|
985
|
+
case ReviewDecision.REJECT:
|
|
986
|
+
reject_test(
|
|
987
|
+
current.test.test_id,
|
|
988
|
+
project_root=project,
|
|
989
|
+
reviewer=reviewer,
|
|
990
|
+
reason=_ask_reason("Why reject?"),
|
|
991
|
+
)
|
|
992
|
+
outcome.reviewed += 1
|
|
993
|
+
outcome.rejected += 1
|
|
994
|
+
console.print("[bold yellow]rejected[/] [dim](kept for audit)[/]")
|
|
995
|
+
return True
|
|
996
|
+
|
|
997
|
+
case ReviewDecision.EDIT:
|
|
998
|
+
if not _edit_in_place(current.test.test_id, project=project, reviewer=reviewer):
|
|
999
|
+
continue
|
|
1000
|
+
current = review_item(current.test.test_id, project_root=project)
|
|
1001
|
+
continue
|
|
1002
|
+
|
|
1003
|
+
case _:
|
|
1004
|
+
outcome.skipped += 1
|
|
1005
|
+
console.print("[dim]skipped[/]")
|
|
1006
|
+
return True
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _edit_in_place(test_id: str, *, project: Path, reviewer: str) -> bool:
|
|
1010
|
+
"""Open the document in $EDITOR and store it if it is valid."""
|
|
1011
|
+
document = editable_document(test_id, project_root=project)
|
|
1012
|
+
try:
|
|
1013
|
+
edited = click.edit(document, extension=".yaml")
|
|
1014
|
+
except Exception as exc: # no editor configured, or it failed to launch
|
|
1015
|
+
err_console.print(f"[bold red]error:[/] could not open an editor: {exc}")
|
|
1016
|
+
err_console.print("[dim]hint:[/] set $EDITOR, or use 'evalkeep dataset edit'.")
|
|
1017
|
+
return False
|
|
1018
|
+
|
|
1019
|
+
if edited is None or edited.strip() == document.strip():
|
|
1020
|
+
console.print("[dim]no changes[/]")
|
|
1021
|
+
return False
|
|
1022
|
+
|
|
1023
|
+
try:
|
|
1024
|
+
edit_test(test_id, edited, project_root=project, editor=reviewer)
|
|
1025
|
+
except EvalkeepError as exc:
|
|
1026
|
+
err_console.print(f"[bold red]error:[/] {exc.message}")
|
|
1027
|
+
if exc.hint:
|
|
1028
|
+
err_console.print(f"[dim]hint:[/] {exc.hint}")
|
|
1029
|
+
return False
|
|
1030
|
+
|
|
1031
|
+
console.print("[bold green]edited[/]")
|
|
1032
|
+
return True
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _ask_decision() -> ReviewDecision:
|
|
1036
|
+
answer = typer.prompt("approve / edit / reject / skip", default="skip", show_default=True)
|
|
1037
|
+
letter = answer.strip().lower()[:1]
|
|
1038
|
+
return {
|
|
1039
|
+
"a": ReviewDecision.APPROVE,
|
|
1040
|
+
"e": ReviewDecision.EDIT,
|
|
1041
|
+
"r": ReviewDecision.REJECT,
|
|
1042
|
+
}.get(letter, ReviewDecision.SKIP)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _ask_reason(prompt: str) -> str | None:
|
|
1046
|
+
answer = typer.prompt(prompt, default="", show_default=False)
|
|
1047
|
+
return answer.strip() or None
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _default_reviewer() -> str:
|
|
1051
|
+
from evalkeep.commands.detect_cmd import default_reviewer
|
|
1052
|
+
|
|
1053
|
+
return default_reviewer()
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _render_review_item(item: ReviewItem) -> None:
|
|
1057
|
+
"""Guide 8H: the source interaction, the analysis, then the proposed test."""
|
|
1058
|
+
console.print("\n[bold]the interaction[/]")
|
|
1059
|
+
_render_trace(item.trace)
|
|
1060
|
+
|
|
1061
|
+
if item.analysis is not None:
|
|
1062
|
+
analysis = item.analysis
|
|
1063
|
+
console.print(
|
|
1064
|
+
f"\n[bold]analysis[/] {analysis.failure_type.value} / "
|
|
1065
|
+
f"{analysis.component.value} / {_severity_markup(analysis.severity.value)}"
|
|
1066
|
+
)
|
|
1067
|
+
console.print(analysis.summary)
|
|
1068
|
+
console.print(f"[dim]by {analysis.analyzer}[/]")
|
|
1069
|
+
|
|
1070
|
+
console.print("\n[bold]evidence[/]")
|
|
1071
|
+
for signal in item.failure.signals:
|
|
1072
|
+
console.print(f" [dim]({signal.kind.value})[/] {signal.summary}")
|
|
1073
|
+
|
|
1074
|
+
console.print("\n[bold]proposed test[/]")
|
|
1075
|
+
_render_test(item.test)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _render_review_outcome(outcome: ReviewOutcome) -> None:
|
|
1079
|
+
console.rule()
|
|
1080
|
+
summary = Table(box=None, pad_edge=False, show_header=False)
|
|
1081
|
+
summary.add_column(style="bold")
|
|
1082
|
+
summary.add_column(justify="right")
|
|
1083
|
+
summary.add_row("approved", f"[green]{outcome.approved}[/]")
|
|
1084
|
+
if outcome.edited:
|
|
1085
|
+
summary.add_row("of those, edited", str(outcome.edited))
|
|
1086
|
+
summary.add_row("rejected", str(outcome.rejected))
|
|
1087
|
+
summary.add_row("skipped", str(outcome.skipped))
|
|
1088
|
+
if outcome.remaining:
|
|
1089
|
+
summary.add_row("left to review", str(outcome.remaining))
|
|
1090
|
+
console.print(summary)
|
|
1091
|
+
console.print("\n[dim]Only approved tests are exported.[/]")
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
@dataset_app.command("approve")
|
|
1095
|
+
def dataset_approve(
|
|
1096
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Test, failure or trace ID."),
|
|
1097
|
+
project: Path = PROJECT_OPTION,
|
|
1098
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is deciding."),
|
|
1099
|
+
reason: str | None = typer.Option(None, "--reason", help="Why."),
|
|
1100
|
+
) -> None:
|
|
1101
|
+
"""Approve a test without the interactive loop."""
|
|
1102
|
+
test = _run(
|
|
1103
|
+
lambda: approve_test(identifier, project_root=project, reviewer=reviewer, reason=reason)
|
|
1104
|
+
)
|
|
1105
|
+
console.print(f"[bold green]approved[/] {test.test_id} by {test.reviewer}")
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
@dataset_app.command("reject")
|
|
1109
|
+
def dataset_reject(
|
|
1110
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Test, failure or trace ID."),
|
|
1111
|
+
project: Path = PROJECT_OPTION,
|
|
1112
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is deciding."),
|
|
1113
|
+
reason: str | None = typer.Option(None, "--reason", help="Why."),
|
|
1114
|
+
) -> None:
|
|
1115
|
+
"""Reject a test. The record is kept for audit."""
|
|
1116
|
+
test = _run(
|
|
1117
|
+
lambda: reject_test(identifier, project_root=project, reviewer=reviewer, reason=reason)
|
|
1118
|
+
)
|
|
1119
|
+
console.print(f"[bold yellow]rejected[/] {test.test_id} by {test.reviewer}")
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
@dataset_app.command("edit")
|
|
1123
|
+
def dataset_edit(
|
|
1124
|
+
identifier: str = typer.Argument(..., metavar="ID", help="Test, failure or trace ID."),
|
|
1125
|
+
project: Path = PROJECT_OPTION,
|
|
1126
|
+
from_file: Path | None = typer.Option(
|
|
1127
|
+
None, "--file", help="Read the edited document from a file instead of $EDITOR."
|
|
1128
|
+
),
|
|
1129
|
+
show: bool = typer.Option(False, "--show", help="Print the editable document and exit."),
|
|
1130
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is editing."),
|
|
1131
|
+
) -> None:
|
|
1132
|
+
"""Edit a test's input and expectations."""
|
|
1133
|
+
if show:
|
|
1134
|
+
console.print(_run(lambda: editable_document(identifier, project_root=project)))
|
|
1135
|
+
return
|
|
1136
|
+
|
|
1137
|
+
if from_file is not None:
|
|
1138
|
+
document = _run(lambda: _read_document(from_file))
|
|
1139
|
+
else:
|
|
1140
|
+
original = _run(lambda: editable_document(identifier, project_root=project))
|
|
1141
|
+
edited = click.edit(original, extension=".yaml")
|
|
1142
|
+
if edited is None or edited.strip() == original.strip():
|
|
1143
|
+
console.print("[dim]no changes[/]")
|
|
1144
|
+
return
|
|
1145
|
+
document = edited
|
|
1146
|
+
|
|
1147
|
+
test = _run(lambda: edit_test(identifier, document, project_root=project, editor=reviewer))
|
|
1148
|
+
console.print(
|
|
1149
|
+
f"[bold green]edited[/] {test.test_id} "
|
|
1150
|
+
f"({len(test.expectations)} expectations, {len(test.warnings)} warnings)"
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _read_document(path: Path) -> str:
|
|
1155
|
+
try:
|
|
1156
|
+
return path.expanduser().read_text(encoding="utf-8")
|
|
1157
|
+
except OSError as exc:
|
|
1158
|
+
raise CommandError(f"Could not read {path}: {exc}") from exc
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
@targets_app.command("add")
|
|
1162
|
+
def targets_add(
|
|
1163
|
+
target_id: str = typer.Argument(..., metavar="NAME", help="baseline, candidate, ..."),
|
|
1164
|
+
kind: TargetKind = typer.Option(..., "--type", help="How the agent is reached."),
|
|
1165
|
+
project: Path = PROJECT_OPTION,
|
|
1166
|
+
description: str | None = typer.Option(None, "--description"),
|
|
1167
|
+
url: str | None = typer.Option(None, "--url", help="http: the endpoint."),
|
|
1168
|
+
method: str = typer.Option("POST", "--method", help="http: the HTTP method."),
|
|
1169
|
+
header: list[str] = typer.Option(
|
|
1170
|
+
[], "--header", metavar="NAME=VALUE", help="http: repeatable. Use ${ENV} for secrets."
|
|
1171
|
+
),
|
|
1172
|
+
body: str | None = typer.Option(
|
|
1173
|
+
None, "--body", help="http: request body as JSON. Use {{input}} for the test input."
|
|
1174
|
+
),
|
|
1175
|
+
path: str | None = typer.Option(None, "--path", help="python/javascript: the script."),
|
|
1176
|
+
function: str | None = typer.Option(None, "--function", help="python: the entry point."),
|
|
1177
|
+
provider: str | None = typer.Option(None, "--provider", help="model: the provider id."),
|
|
1178
|
+
output_path: str | None = typer.Option(
|
|
1179
|
+
None, "--output-path", help="http: where the answer is, e.g. json.reply."
|
|
1180
|
+
),
|
|
1181
|
+
tool_calls_path: str | None = typer.Option(
|
|
1182
|
+
None, "--tool-calls-path", help="http: where the tool calls are."
|
|
1183
|
+
),
|
|
1184
|
+
replace: bool = typer.Option(False, "--replace", help="Overwrite an existing target."),
|
|
1185
|
+
) -> None:
|
|
1186
|
+
"""Record how to reach an agent. Secrets must be ${ENV_VAR} references."""
|
|
1187
|
+
target = _run(
|
|
1188
|
+
lambda: add_target(
|
|
1189
|
+
target_id,
|
|
1190
|
+
kind,
|
|
1191
|
+
project_root=project,
|
|
1192
|
+
description=description,
|
|
1193
|
+
url=url,
|
|
1194
|
+
method=method,
|
|
1195
|
+
headers=_parse_pairs(header),
|
|
1196
|
+
body=_parse_json(body, "--body"),
|
|
1197
|
+
path=path,
|
|
1198
|
+
function=function,
|
|
1199
|
+
provider=provider,
|
|
1200
|
+
output_path=output_path,
|
|
1201
|
+
tool_calls_path=tool_calls_path,
|
|
1202
|
+
replace=replace,
|
|
1203
|
+
)
|
|
1204
|
+
)
|
|
1205
|
+
console.print(f"[bold green]added[/] target {target.target_id} ({target.kind.value})")
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@targets_app.command("list")
|
|
1209
|
+
def targets_list(project: Path = PROJECT_OPTION) -> None:
|
|
1210
|
+
"""List configured targets."""
|
|
1211
|
+
targets = _run(lambda: list_targets(project_root=project))
|
|
1212
|
+
if not targets:
|
|
1213
|
+
console.print("[dim]No targets. Add one with 'evalkeep targets add'.[/]")
|
|
1214
|
+
return
|
|
1215
|
+
table = Table(box=None, pad_edge=False)
|
|
1216
|
+
table.add_column("target", style="cyan")
|
|
1217
|
+
table.add_column("type")
|
|
1218
|
+
table.add_column("where", overflow="fold")
|
|
1219
|
+
table.add_column("description", style="dim")
|
|
1220
|
+
for target in targets:
|
|
1221
|
+
table.add_row(
|
|
1222
|
+
target.target_id,
|
|
1223
|
+
target.kind.value,
|
|
1224
|
+
target.url or target.path or target.provider or "",
|
|
1225
|
+
target.description or "",
|
|
1226
|
+
)
|
|
1227
|
+
console.print(table)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
@targets_app.command("show")
|
|
1231
|
+
def targets_show(
|
|
1232
|
+
target_id: str = typer.Argument(..., metavar="NAME"),
|
|
1233
|
+
project: Path = PROJECT_OPTION,
|
|
1234
|
+
) -> None:
|
|
1235
|
+
"""Inspect a target and the environment it needs."""
|
|
1236
|
+
target, environment = _run(lambda: show_target(target_id, project_root=project))
|
|
1237
|
+
console.print_json(target.model_dump_json(exclude_none=True))
|
|
1238
|
+
if environment:
|
|
1239
|
+
console.print("\n[bold]environment[/]")
|
|
1240
|
+
for name, present in sorted(environment.items()):
|
|
1241
|
+
mark = "[green]set[/]" if present else "[red]missing[/]"
|
|
1242
|
+
console.print(f" {name} {mark}")
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
@targets_app.command("remove")
|
|
1246
|
+
def targets_remove(
|
|
1247
|
+
target_id: str = typer.Argument(..., metavar="NAME"),
|
|
1248
|
+
project: Path = PROJECT_OPTION,
|
|
1249
|
+
) -> None:
|
|
1250
|
+
"""Forget a target."""
|
|
1251
|
+
_run(lambda: remove_target(target_id, project_root=project))
|
|
1252
|
+
console.print(f"[bold yellow]removed[/] target {target_id}")
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
@app.command()
|
|
1256
|
+
def export(
|
|
1257
|
+
project: Path = PROJECT_OPTION,
|
|
1258
|
+
export_format: str = typer.Option("promptfoo", "--format", "-f", help="promptfoo or jsonl."),
|
|
1259
|
+
target: str | None = typer.Option(None, "--target", help="Which target to export for."),
|
|
1260
|
+
out: Path | None = typer.Option(None, "--out", help="Directory to write into."),
|
|
1261
|
+
) -> None:
|
|
1262
|
+
"""Create runner-compatible files from the approved suite."""
|
|
1263
|
+
result = _run(
|
|
1264
|
+
lambda: export_suite(
|
|
1265
|
+
project_root=project,
|
|
1266
|
+
export_format=parse_format(export_format),
|
|
1267
|
+
target_id=target,
|
|
1268
|
+
out=out,
|
|
1269
|
+
)
|
|
1270
|
+
)
|
|
1271
|
+
_render_export(result)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
@app.command()
|
|
1275
|
+
def run(
|
|
1276
|
+
project: Path = PROJECT_OPTION,
|
|
1277
|
+
target: str = typer.Option(..., "--target", help="Which target to run against."),
|
|
1278
|
+
limit: int | None = typer.Option(None, "--limit", min=1, help="Run at most N tests."),
|
|
1279
|
+
repetitions: int = typer.Option(
|
|
1280
|
+
1,
|
|
1281
|
+
"--repetitions",
|
|
1282
|
+
"-r",
|
|
1283
|
+
min=1,
|
|
1284
|
+
help="Execute each test N times. Agents are stochastic; one pass is not a fix.",
|
|
1285
|
+
),
|
|
1286
|
+
) -> None:
|
|
1287
|
+
"""Delegate execution of the approved suite to Promptfoo."""
|
|
1288
|
+
outcome = _run(
|
|
1289
|
+
lambda: run_suite(
|
|
1290
|
+
project_root=project,
|
|
1291
|
+
target_id=target,
|
|
1292
|
+
limit=limit,
|
|
1293
|
+
repetitions=repetitions,
|
|
1294
|
+
)
|
|
1295
|
+
)
|
|
1296
|
+
_render_run(outcome)
|
|
1297
|
+
raise typer.Exit(ExitCode.OK)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _parse_pairs(pairs: list[str]) -> dict[str, str]:
|
|
1301
|
+
parsed: dict[str, str] = {}
|
|
1302
|
+
for pair in pairs:
|
|
1303
|
+
name, separator, value = pair.partition("=")
|
|
1304
|
+
if not separator:
|
|
1305
|
+
raise CommandError(f"Expected NAME=VALUE, got {pair!r}.")
|
|
1306
|
+
parsed[name.strip()] = value.strip()
|
|
1307
|
+
return parsed
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _parse_json(raw: str | None, option: str) -> dict[str, object] | None:
|
|
1311
|
+
if raw is None:
|
|
1312
|
+
return None
|
|
1313
|
+
try:
|
|
1314
|
+
parsed = json.loads(raw)
|
|
1315
|
+
except json.JSONDecodeError as exc:
|
|
1316
|
+
raise CommandError(f"{option} must be valid JSON: {exc}") from exc
|
|
1317
|
+
if not isinstance(parsed, dict):
|
|
1318
|
+
raise CommandError(f"{option} must be a JSON object.")
|
|
1319
|
+
return parsed
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _render_export(result: ExportResult) -> None:
|
|
1323
|
+
summary = Table(box=None, pad_edge=False, show_header=False)
|
|
1324
|
+
summary.add_column(style="bold")
|
|
1325
|
+
summary.add_column()
|
|
1326
|
+
summary.add_row("format", result.format.value)
|
|
1327
|
+
if result.target_id:
|
|
1328
|
+
summary.add_row("target", result.target_id)
|
|
1329
|
+
summary.add_row("tests", str(result.tests))
|
|
1330
|
+
summary.add_row("written", str(result.path))
|
|
1331
|
+
console.print(summary)
|
|
1332
|
+
for warning in result.warnings:
|
|
1333
|
+
err_console.print(f"[yellow]warning:[/] {warning}")
|
|
1334
|
+
console.print("\n[dim]Approved tests only.[/]")
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _render_run(outcome: RunOutcome) -> None:
|
|
1338
|
+
counts = outcome.counts
|
|
1339
|
+
passed = counts.get(Outcome.PASS, 0)
|
|
1340
|
+
failed = counts.get(Outcome.FAIL, 0)
|
|
1341
|
+
errored = counts.get(Outcome.ERROR, 0)
|
|
1342
|
+
|
|
1343
|
+
summary = Table(box=None, pad_edge=False, show_header=False)
|
|
1344
|
+
summary.add_column(style="bold")
|
|
1345
|
+
summary.add_column(justify="right")
|
|
1346
|
+
summary.add_row("run", outcome.run.run_id)
|
|
1347
|
+
summary.add_row("target", outcome.run.target_id)
|
|
1348
|
+
summary.add_row("runner", outcome.run.runner or "unknown")
|
|
1349
|
+
summary.add_row("tests", str(outcome.run.tests))
|
|
1350
|
+
if outcome.run.repetitions > 1:
|
|
1351
|
+
summary.add_row("repetitions", str(outcome.run.repetitions))
|
|
1352
|
+
summary.add_row("passed", f"[green]{passed}[/]")
|
|
1353
|
+
summary.add_row("failed", f"[red]{failed}[/]" if failed else "0")
|
|
1354
|
+
summary.add_row("errors", f"[yellow]{errored}[/]" if errored else "0")
|
|
1355
|
+
console.print(summary)
|
|
1356
|
+
|
|
1357
|
+
if outcome.run.repetitions > 1:
|
|
1358
|
+
verdicts = Table(box=None, pad_edge=False)
|
|
1359
|
+
verdicts.add_column("test_id", style="cyan", overflow="fold")
|
|
1360
|
+
verdicts.add_column("verdict")
|
|
1361
|
+
verdicts.add_column("passed", justify="right")
|
|
1362
|
+
verdicts.add_column("95% interval", justify="right")
|
|
1363
|
+
styles = {"pass": "green", "fail": "red", "flaky": "yellow", "error": "dim"}
|
|
1364
|
+
for case in outcome.summaries.values():
|
|
1365
|
+
interval = case.confidence
|
|
1366
|
+
verdicts.add_row(
|
|
1367
|
+
case.test_id,
|
|
1368
|
+
f"[{styles[case.verdict.value]}]{case.verdict.value}[/]",
|
|
1369
|
+
f"{case.passed}/{case.evaluated}" if case.evaluated else "-",
|
|
1370
|
+
f"{interval[0]:.0%} to {interval[1]:.0%}" if interval else "-",
|
|
1371
|
+
)
|
|
1372
|
+
console.print("\n")
|
|
1373
|
+
console.print(verdicts)
|
|
1374
|
+
if outcome.flaky:
|
|
1375
|
+
console.print(
|
|
1376
|
+
f"\n[yellow]{len(outcome.flaky)} case(s) are flaky[/] [dim]— they "
|
|
1377
|
+
"passed some repetitions and failed others, which a single "
|
|
1378
|
+
"execution would have reported as a plain pass or fail.[/]"
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
for result in outcome.results:
|
|
1382
|
+
if outcome.run.repetitions > 1:
|
|
1383
|
+
break
|
|
1384
|
+
if result.outcome is Outcome.ERROR:
|
|
1385
|
+
console.print(
|
|
1386
|
+
f"[yellow]error[/] {result.test_id} "
|
|
1387
|
+
f"[dim]({result.error_kind.value if result.error_kind else 'unknown'})[/]"
|
|
1388
|
+
)
|
|
1389
|
+
elif result.outcome is Outcome.FAIL:
|
|
1390
|
+
reason = result.failed_assertions[0] if result.failed_assertions else ""
|
|
1391
|
+
console.print(f"[red]fail[/] {result.test_id} [dim]{reason.splitlines()[0:1]}[/]")
|
|
1392
|
+
|
|
1393
|
+
for message in outcome.messages:
|
|
1394
|
+
err_console.print(f"[dim]{message}[/]")
|
|
1395
|
+
|
|
1396
|
+
if errored:
|
|
1397
|
+
console.print(
|
|
1398
|
+
"\n[dim]Errors are reported separately: a test that never ran is not "
|
|
1399
|
+
"a test that failed.[/]"
|
|
1400
|
+
)
|
|
1401
|
+
console.print(f"\n[dim]Results stored under {outcome.run.output_dir}[/]")
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
@app.command("compare")
|
|
1405
|
+
def compare_runs(
|
|
1406
|
+
project: Path = PROJECT_OPTION,
|
|
1407
|
+
baseline: str | None = typer.Option(
|
|
1408
|
+
None, "--baseline", help="Run ID or target name. Defaults to the promoted baseline."
|
|
1409
|
+
),
|
|
1410
|
+
candidate: str | None = typer.Option(
|
|
1411
|
+
None, "--candidate", help="Run ID or target name. Defaults to the newest candidate run."
|
|
1412
|
+
),
|
|
1413
|
+
allow_suite_drift: bool = typer.Option(
|
|
1414
|
+
False, "--allow-suite-drift", help="Compare only the tests both runs share."
|
|
1415
|
+
),
|
|
1416
|
+
fail_on_regression: bool = typer.Option(
|
|
1417
|
+
False, "--fail-on-regression", help="Exit non-zero when any test regressed."
|
|
1418
|
+
),
|
|
1419
|
+
) -> None:
|
|
1420
|
+
"""Compare baseline and candidate result sets."""
|
|
1421
|
+
report = _run(
|
|
1422
|
+
lambda: compare(
|
|
1423
|
+
project_root=project,
|
|
1424
|
+
baseline=baseline,
|
|
1425
|
+
candidate=candidate,
|
|
1426
|
+
allow_suite_drift=allow_suite_drift,
|
|
1427
|
+
)
|
|
1428
|
+
)
|
|
1429
|
+
_render_comparison(report)
|
|
1430
|
+
if fail_on_regression and report.regressions:
|
|
1431
|
+
raise typer.Exit(ExitCode.RECORD_ERRORS)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
@runs_app.command("list")
|
|
1435
|
+
def runs_list(
|
|
1436
|
+
project: Path = PROJECT_OPTION,
|
|
1437
|
+
limit: int = typer.Option(20, "--limit", min=1, help="Rows to show."),
|
|
1438
|
+
) -> None:
|
|
1439
|
+
"""List evaluation runs, newest first."""
|
|
1440
|
+
summaries = _run(lambda: list_runs(project_root=project, limit=limit))
|
|
1441
|
+
if not summaries:
|
|
1442
|
+
console.print("[dim]No runs. Run 'evalkeep run --target baseline'.[/]")
|
|
1443
|
+
return
|
|
1444
|
+
|
|
1445
|
+
table = Table(box=None, pad_edge=False)
|
|
1446
|
+
table.add_column("run", style="cyan", overflow="fold")
|
|
1447
|
+
table.add_column("target")
|
|
1448
|
+
table.add_column("pass", justify="right")
|
|
1449
|
+
table.add_column("fail", justify="right")
|
|
1450
|
+
table.add_column("error", justify="right")
|
|
1451
|
+
table.add_column("suite", style="dim")
|
|
1452
|
+
table.add_column("", style="dim")
|
|
1453
|
+
for summary in summaries:
|
|
1454
|
+
table.add_row(
|
|
1455
|
+
summary.run.run_id[:12]
|
|
1456
|
+
+ (f" x{summary.run.repetitions}" if summary.run.repetitions > 1 else ""),
|
|
1457
|
+
summary.run.target_id,
|
|
1458
|
+
str(summary.counts.get(Outcome.PASS, 0)),
|
|
1459
|
+
str(summary.counts.get(Outcome.FAIL, 0)),
|
|
1460
|
+
str(summary.counts.get(Outcome.ERROR, 0)),
|
|
1461
|
+
summary.run.suite_hash.removeprefix("sha256:")[:8],
|
|
1462
|
+
"baseline" if summary.is_baseline else "",
|
|
1463
|
+
)
|
|
1464
|
+
console.print(table)
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
@runs_app.command("show")
|
|
1468
|
+
def runs_show(
|
|
1469
|
+
run_id: str = typer.Argument(..., metavar="RUN_ID"),
|
|
1470
|
+
project: Path = PROJECT_OPTION,
|
|
1471
|
+
) -> None:
|
|
1472
|
+
"""Inspect one run and its per-test results."""
|
|
1473
|
+
run, results = _run(lambda: show_run(run_id, project_root=project))
|
|
1474
|
+
_render_run_detail(run, results)
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
@baseline_app.command("promote")
|
|
1478
|
+
def baseline_promote(
|
|
1479
|
+
run_id: str = typer.Argument(..., metavar="RUN_ID"),
|
|
1480
|
+
project: Path = PROJECT_OPTION,
|
|
1481
|
+
reviewer: str | None = typer.Option(None, "--reviewer", help="Who is deciding."),
|
|
1482
|
+
reason: str | None = typer.Option(None, "--reason", help="Why."),
|
|
1483
|
+
) -> None:
|
|
1484
|
+
"""Make a run the reference point. Never automatic."""
|
|
1485
|
+
promotion = _run(
|
|
1486
|
+
lambda: promote_baseline(run_id, project_root=project, reviewer=reviewer, reason=reason)
|
|
1487
|
+
)
|
|
1488
|
+
console.print(
|
|
1489
|
+
f"[bold green]promoted[/] {promotion.run_id[:12]} "
|
|
1490
|
+
f"({promotion.target_id}) by {promotion.reviewer}"
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
@baseline_app.command("show")
|
|
1495
|
+
def baseline_show(project: Path = PROJECT_OPTION) -> None:
|
|
1496
|
+
"""Show which run is currently the baseline."""
|
|
1497
|
+
current = _run(lambda: current_baseline(project_root=project))
|
|
1498
|
+
if current is None:
|
|
1499
|
+
console.print(
|
|
1500
|
+
"[dim]No baseline has been promoted. Comparisons fall back to the "
|
|
1501
|
+
"newest run for the 'baseline' target.[/]"
|
|
1502
|
+
)
|
|
1503
|
+
return
|
|
1504
|
+
promotion, run = current
|
|
1505
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
1506
|
+
table.add_column(style="bold")
|
|
1507
|
+
table.add_column()
|
|
1508
|
+
table.add_row("run", run.run_id)
|
|
1509
|
+
table.add_row("target", run.target_id)
|
|
1510
|
+
table.add_row("suite", run.suite_hash)
|
|
1511
|
+
table.add_row("promoted", promotion.promoted_at.isoformat())
|
|
1512
|
+
table.add_row("by", promotion.reviewer)
|
|
1513
|
+
if promotion.reason:
|
|
1514
|
+
table.add_row("reason", promotion.reason)
|
|
1515
|
+
console.print(table)
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
_CLASSIFICATION_STYLES: dict[Classification, str] = {
|
|
1519
|
+
Classification.UNCHANGED_PASS: "green",
|
|
1520
|
+
Classification.FIXED: "bold green",
|
|
1521
|
+
Classification.LIKELY_FIXED: "green",
|
|
1522
|
+
Classification.REGRESSION: "bold red",
|
|
1523
|
+
Classification.UNCHANGED_FAILURE: "red",
|
|
1524
|
+
Classification.NOT_COMPARABLE: "yellow",
|
|
1525
|
+
Classification.MISSING: "yellow",
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _render_run_detail(run: EvaluationRun, results: list[CaseResult]) -> None:
|
|
1530
|
+
header = Table(box=None, pad_edge=False, show_header=False)
|
|
1531
|
+
header.add_column(style="bold")
|
|
1532
|
+
header.add_column()
|
|
1533
|
+
header.add_row("run", run.run_id)
|
|
1534
|
+
header.add_row("target", run.target_id)
|
|
1535
|
+
header.add_row("suite", run.suite_hash)
|
|
1536
|
+
header.add_row("runner", run.runner or "unknown")
|
|
1537
|
+
header.add_row("started", run.started_at.isoformat())
|
|
1538
|
+
console.print(header)
|
|
1539
|
+
|
|
1540
|
+
table = Table(box=None, pad_edge=False)
|
|
1541
|
+
table.add_column("test_id", style="cyan", overflow="fold")
|
|
1542
|
+
table.add_column("outcome")
|
|
1543
|
+
table.add_column("detail", style="dim")
|
|
1544
|
+
for result in results:
|
|
1545
|
+
style = {"pass": "green", "fail": "red", "error": "yellow"}[result.outcome.value]
|
|
1546
|
+
detail = ""
|
|
1547
|
+
if result.outcome is Outcome.ERROR:
|
|
1548
|
+
detail = result.error_kind.value if result.error_kind else "error"
|
|
1549
|
+
elif result.failed_assertions:
|
|
1550
|
+
detail = result.failed_assertions[0].splitlines()[0]
|
|
1551
|
+
table.add_row(result.test_id, f"[{style}]{result.outcome.value}[/]", detail)
|
|
1552
|
+
console.print("\n")
|
|
1553
|
+
console.print(table)
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def _render_comparison(report: ComparisonReport) -> None:
|
|
1557
|
+
counts = report.counts
|
|
1558
|
+
header = Table(box=None, pad_edge=False, show_header=False)
|
|
1559
|
+
header.add_column(style="bold")
|
|
1560
|
+
header.add_column()
|
|
1561
|
+
header.add_row(
|
|
1562
|
+
"baseline", f"{report.baseline_run.run_id[:12]} ({report.baseline_run.target_id})"
|
|
1563
|
+
)
|
|
1564
|
+
header.add_row(
|
|
1565
|
+
"candidate", f"{report.candidate_run.run_id[:12]} ({report.candidate_run.target_id})"
|
|
1566
|
+
)
|
|
1567
|
+
if not report.suite_compatible:
|
|
1568
|
+
header.add_row("suite", "[yellow]differs; comparing shared tests only[/]")
|
|
1569
|
+
console.print(header)
|
|
1570
|
+
|
|
1571
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
1572
|
+
table.add_column(style="bold")
|
|
1573
|
+
table.add_column(justify="right")
|
|
1574
|
+
for classification in Classification:
|
|
1575
|
+
count = counts.get(classification, 0)
|
|
1576
|
+
if not count and classification in (
|
|
1577
|
+
Classification.NOT_COMPARABLE,
|
|
1578
|
+
Classification.MISSING,
|
|
1579
|
+
):
|
|
1580
|
+
continue
|
|
1581
|
+
label = classification.value.replace("_", " ")
|
|
1582
|
+
style = _CLASSIFICATION_STYLES[classification]
|
|
1583
|
+
table.add_row(label, f"[{style}]{count}[/]" if count else "0")
|
|
1584
|
+
console.print("\n")
|
|
1585
|
+
console.print(table)
|
|
1586
|
+
|
|
1587
|
+
for comparison in report.regressions:
|
|
1588
|
+
rates = f" [dim]({comparison.rates})[/]" if comparison.rates else ""
|
|
1589
|
+
console.print(f"[bold red]regression[/] {comparison.test_id}{rates}")
|
|
1590
|
+
for comparison in report.fixes:
|
|
1591
|
+
rates = f" [dim]({comparison.rates})[/]" if comparison.rates else ""
|
|
1592
|
+
console.print(f"[bold green]fixed[/] {comparison.test_id}{rates}")
|
|
1593
|
+
for comparison in report.comparisons:
|
|
1594
|
+
if comparison.classification is Classification.LIKELY_FIXED:
|
|
1595
|
+
interval = comparison.confidence
|
|
1596
|
+
bound = (
|
|
1597
|
+
f" [dim](95% interval {interval[0]:.0%} to {interval[1]:.0%})[/]"
|
|
1598
|
+
if interval
|
|
1599
|
+
else ""
|
|
1600
|
+
)
|
|
1601
|
+
console.print(
|
|
1602
|
+
f"[green]likely fixed[/] {comparison.test_id} [dim]({comparison.rates})[/]{bound}"
|
|
1603
|
+
)
|
|
1604
|
+
for comparison in report.excluded:
|
|
1605
|
+
console.print(f"[yellow]excluded[/] {comparison.test_id} [dim]({comparison.reason})[/]")
|
|
1606
|
+
|
|
1607
|
+
_render_statistics(report)
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def _render_statistics(report: ComparisonReport) -> None:
|
|
1611
|
+
baseline_rate = report.baseline_pass_rate
|
|
1612
|
+
candidate_rate = report.candidate_pass_rate
|
|
1613
|
+
if baseline_rate is None or candidate_rate is None:
|
|
1614
|
+
console.print("\n[yellow]No comparable tests.[/] Nothing can be concluded.")
|
|
1615
|
+
return
|
|
1616
|
+
|
|
1617
|
+
statistics = report.statistics
|
|
1618
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
1619
|
+
table.add_column(style="bold")
|
|
1620
|
+
table.add_column(justify="right")
|
|
1621
|
+
table.add_row("compared", str(len(report.comparable)))
|
|
1622
|
+
table.add_row("baseline pass rate", f"{baseline_rate:.1%}")
|
|
1623
|
+
table.add_row("candidate pass rate", f"{candidate_rate:.1%}")
|
|
1624
|
+
if statistics is not None:
|
|
1625
|
+
table.add_row("difference", f"{statistics.difference:+.1%}")
|
|
1626
|
+
table.add_row("p-value", f"{statistics.p_value:.4f}")
|
|
1627
|
+
if statistics.interval is not None:
|
|
1628
|
+
low, high = statistics.interval
|
|
1629
|
+
table.add_row("95% interval", f"{low:+.1%} to {high:+.1%}")
|
|
1630
|
+
console.print("\n")
|
|
1631
|
+
console.print(table)
|
|
1632
|
+
|
|
1633
|
+
if statistics is None:
|
|
1634
|
+
return
|
|
1635
|
+
if statistics.note:
|
|
1636
|
+
console.print(f"[dim]{statistics.note}[/]")
|
|
1637
|
+
elif statistics.significant:
|
|
1638
|
+
console.print("[dim]McNemar's exact test: the change is unlikely to be chance.[/]")
|
|
1639
|
+
else:
|
|
1640
|
+
console.print(
|
|
1641
|
+
"[dim]McNemar's exact test: this difference is within what chance "
|
|
1642
|
+
"would produce. Not evidence of no change -- evidence of not enough "
|
|
1643
|
+
"evidence.[/]"
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
if report.flaky:
|
|
1647
|
+
console.print(
|
|
1648
|
+
f"\n[yellow]{len(report.flaky)} case(s) are flaky[/] and are not "
|
|
1649
|
+
"counted as passing: a case that only sometimes passes has not been "
|
|
1650
|
+
"fixed."
|
|
1651
|
+
)
|
|
1652
|
+
elif report.repeated:
|
|
1653
|
+
console.print("\n[dim]No case was flaky across its repetitions.[/]")
|
|
1654
|
+
|
|
1655
|
+
if report.excluded:
|
|
1656
|
+
console.print(
|
|
1657
|
+
f"\n[yellow]{len(report.excluded)} test(s) excluded[/] and not counted "
|
|
1658
|
+
"in any rate above."
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def _test_status_markup(status: ReviewStatus) -> str:
|
|
1663
|
+
styles = {
|
|
1664
|
+
ReviewStatus.DRAFT: "yellow",
|
|
1665
|
+
ReviewStatus.APPROVED: "green",
|
|
1666
|
+
ReviewStatus.REJECTED: "dim",
|
|
1667
|
+
}
|
|
1668
|
+
return f"[{styles[status]}]{status.value}[/]"
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _render_build(report: BuildReport) -> None:
|
|
1672
|
+
summary = Table(box=None, pad_edge=False, show_header=False)
|
|
1673
|
+
summary.add_column(style="bold")
|
|
1674
|
+
summary.add_column(justify="right")
|
|
1675
|
+
summary.add_row("considered", str(report.considered))
|
|
1676
|
+
summary.add_row("drafts created", f"[green]{report.created}[/]")
|
|
1677
|
+
if report.regenerated:
|
|
1678
|
+
summary.add_row("regenerated", str(report.regenerated))
|
|
1679
|
+
if report.skipped:
|
|
1680
|
+
summary.add_row("already drafted", str(report.skipped))
|
|
1681
|
+
if report.reviewed_kept:
|
|
1682
|
+
summary.add_row("reviewed, kept", str(report.reviewed_kept))
|
|
1683
|
+
if report.unanalyzed:
|
|
1684
|
+
summary.add_row("not analyzed", f"[yellow]{report.unanalyzed}[/]")
|
|
1685
|
+
if report.needs_expectation:
|
|
1686
|
+
summary.add_row("need an expectation", f"[yellow]{report.needs_expectation}[/]")
|
|
1687
|
+
if report.contradictions:
|
|
1688
|
+
summary.add_row("contradictions", f"[red]{report.contradictions}[/]")
|
|
1689
|
+
console.print(summary)
|
|
1690
|
+
|
|
1691
|
+
for test_id, warning in report.warnings[:10]:
|
|
1692
|
+
console.print(f"[yellow]{test_id}[/] [dim]{warning}[/]")
|
|
1693
|
+
if len(report.warnings) > 10:
|
|
1694
|
+
console.print(f"[dim]... and {len(report.warnings) - 10} more warnings[/]")
|
|
1695
|
+
|
|
1696
|
+
console.print("\n[dim]Drafts are pending: nothing is exported until it is approved.[/]")
|
|
1697
|
+
console.print("Next: [bold]evalkeep dataset list[/]")
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def _render_test(test: RegressionTest) -> None:
|
|
1701
|
+
header = Table(box=None, pad_edge=False, show_header=False)
|
|
1702
|
+
header.add_column(style="bold")
|
|
1703
|
+
header.add_column()
|
|
1704
|
+
header.add_row("test_id", test.test_id)
|
|
1705
|
+
header.add_row("status", _test_status_markup(test.status))
|
|
1706
|
+
header.add_row("trace_id", test.provenance.trace_id)
|
|
1707
|
+
header.add_row("failure_id", test.failure_id)
|
|
1708
|
+
if test.provenance.cluster_label:
|
|
1709
|
+
header.add_row("cluster", test.provenance.cluster_label)
|
|
1710
|
+
if test.provenance.representative_roles:
|
|
1711
|
+
header.add_row("selected as", ", ".join(test.provenance.representative_roles))
|
|
1712
|
+
if test.provenance.failure_type:
|
|
1713
|
+
header.add_row("failure type", test.provenance.failure_type)
|
|
1714
|
+
if test.provenance.severity:
|
|
1715
|
+
header.add_row("severity", _severity_markup(test.provenance.severity))
|
|
1716
|
+
header.add_row("analyzer", test.provenance.analyzer or "")
|
|
1717
|
+
header.add_row("generator", f"v{test.provenance.generator_version}")
|
|
1718
|
+
if test.reviewer:
|
|
1719
|
+
header.add_row("reviewer", test.reviewer)
|
|
1720
|
+
if test.reviewed_at:
|
|
1721
|
+
header.add_row("reviewed", test.reviewed_at.isoformat())
|
|
1722
|
+
if test.review_reason:
|
|
1723
|
+
header.add_row("reason", test.review_reason)
|
|
1724
|
+
if test.edited:
|
|
1725
|
+
header.add_row("edited by", test.edited_by or "")
|
|
1726
|
+
console.print(header)
|
|
1727
|
+
|
|
1728
|
+
if test.input.text:
|
|
1729
|
+
console.print(f"\n[bold]input[/]\n{test.input.text}")
|
|
1730
|
+
for message in test.input.messages:
|
|
1731
|
+
console.print(f"\n[bold]input:{message['role']}[/]\n{message['content']}")
|
|
1732
|
+
|
|
1733
|
+
console.print("\n[bold]expectations[/]")
|
|
1734
|
+
table = Table(box=None, pad_edge=False)
|
|
1735
|
+
table.add_column("type")
|
|
1736
|
+
table.add_column("check")
|
|
1737
|
+
table.add_column("kind", style="dim")
|
|
1738
|
+
for expectation in test.expectations:
|
|
1739
|
+
table.add_row(
|
|
1740
|
+
expectation.type.value,
|
|
1741
|
+
expectation.describe(),
|
|
1742
|
+
"deterministic" if expectation.deterministic else "needs a judge",
|
|
1743
|
+
)
|
|
1744
|
+
console.print(table)
|
|
1745
|
+
|
|
1746
|
+
if test.fixtures:
|
|
1747
|
+
console.print("\n[bold]fixtures[/] [dim](tool results the original agent saw)[/]")
|
|
1748
|
+
fixtures = Table(box=None, pad_edge=False)
|
|
1749
|
+
fixtures.add_column("tool")
|
|
1750
|
+
fixtures.add_column("arguments")
|
|
1751
|
+
fixtures.add_column("result")
|
|
1752
|
+
for fixture in test.fixtures:
|
|
1753
|
+
fixtures.add_row(
|
|
1754
|
+
fixture.tool,
|
|
1755
|
+
json.dumps(fixture.arguments, sort_keys=True),
|
|
1756
|
+
json.dumps(fixture.result, sort_keys=True, default=str),
|
|
1757
|
+
)
|
|
1758
|
+
console.print(fixtures)
|
|
1759
|
+
|
|
1760
|
+
for warning in test.warnings:
|
|
1761
|
+
console.print(f"\n[yellow]needs review:[/] {warning}")
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _severity_markup(severity: str) -> str:
|
|
1765
|
+
styles = {"critical": "bold red", "high": "red", "medium": "yellow", "low": "dim"}
|
|
1766
|
+
style = styles.get(severity)
|
|
1767
|
+
return f"[{style}]{severity}[/]" if style else severity
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def _render_detection(report: DetectionReport) -> None:
|
|
1771
|
+
summary = Table(box=None, pad_edge=False, show_header=False)
|
|
1772
|
+
summary.add_column(style="bold")
|
|
1773
|
+
summary.add_column(justify="right")
|
|
1774
|
+
summary.add_row("traces examined", str(report.traces))
|
|
1775
|
+
summary.add_row("failures", f"[red]{report.failures}[/]" if report.failures else "0")
|
|
1776
|
+
summary.add_row("new", f"[green]{report.created}[/]")
|
|
1777
|
+
if report.updated:
|
|
1778
|
+
summary.add_row("updated", str(report.updated))
|
|
1779
|
+
if report.unchanged:
|
|
1780
|
+
summary.add_row("unchanged", str(report.unchanged))
|
|
1781
|
+
if report.withdrawn:
|
|
1782
|
+
summary.add_row("withdrawn", str(report.withdrawn))
|
|
1783
|
+
if report.preserved_reviews:
|
|
1784
|
+
summary.add_row("reviews kept", str(report.preserved_reviews))
|
|
1785
|
+
summary.add_row("signals", str(report.signals))
|
|
1786
|
+
console.print(summary)
|
|
1787
|
+
|
|
1788
|
+
if report.by_kind:
|
|
1789
|
+
detail = ", ".join(
|
|
1790
|
+
f"{kind.value} x{count}" for kind, count in sorted(report.by_kind.items())
|
|
1791
|
+
)
|
|
1792
|
+
console.print(f"[dim]{detail}[/]")
|
|
1793
|
+
console.print("\nNext: [bold]evalkeep failures list[/]")
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def _render_failure(detail: FailureDetail) -> None:
|
|
1797
|
+
failure = detail.failure
|
|
1798
|
+
header = Table(box=None, pad_edge=False, show_header=False)
|
|
1799
|
+
header.add_column(style="bold")
|
|
1800
|
+
header.add_column()
|
|
1801
|
+
header.add_row("failure_id", failure.failure_id)
|
|
1802
|
+
header.add_row("trace_id", failure.trace_id)
|
|
1803
|
+
header.add_row("status", _failure_status_markup(failure.status))
|
|
1804
|
+
header.add_row("origin", failure.origin.value)
|
|
1805
|
+
header.add_row("detected", failure.detected_at.isoformat())
|
|
1806
|
+
if failure.reviewer:
|
|
1807
|
+
header.add_row("reviewer", failure.reviewer)
|
|
1808
|
+
if failure.reason:
|
|
1809
|
+
header.add_row("reason", failure.reason)
|
|
1810
|
+
console.print(header)
|
|
1811
|
+
|
|
1812
|
+
if failure.signals:
|
|
1813
|
+
console.print("\n[bold]evidence[/]")
|
|
1814
|
+
signals = Table(box=None, pad_edge=False)
|
|
1815
|
+
signals.add_column("kind")
|
|
1816
|
+
signals.add_column("source", style="magenta")
|
|
1817
|
+
signals.add_column("detail")
|
|
1818
|
+
for signal in failure.signals:
|
|
1819
|
+
signals.add_row(signal.kind.value, signal.source, signal.summary)
|
|
1820
|
+
console.print(signals)
|
|
1821
|
+
else:
|
|
1822
|
+
console.print("\n[dim]No detector evidence; this failure was added by hand.[/]")
|
|
1823
|
+
|
|
1824
|
+
if detail.analysis is not None:
|
|
1825
|
+
analysis = detail.analysis
|
|
1826
|
+
console.print("\n[bold]analysis[/]")
|
|
1827
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
1828
|
+
table.add_column(style="bold")
|
|
1829
|
+
table.add_column()
|
|
1830
|
+
table.add_row("type", analysis.failure_type.value)
|
|
1831
|
+
table.add_row("component", analysis.component.value)
|
|
1832
|
+
table.add_row("severity", _severity_markup(analysis.severity.value))
|
|
1833
|
+
table.add_row("summary", analysis.summary)
|
|
1834
|
+
table.add_row("analyzer", analysis.analyzer)
|
|
1835
|
+
if not analysis.manual:
|
|
1836
|
+
table.add_row("prompt version", str(analysis.prompt_version))
|
|
1837
|
+
table.add_row("analyzed", analysis.analyzed_at.isoformat())
|
|
1838
|
+
console.print(table)
|
|
1839
|
+
else:
|
|
1840
|
+
console.print("\n[dim]Not analyzed yet. Run 'evalkeep analyze', or label by hand.[/]")
|
|
1841
|
+
|
|
1842
|
+
console.print("\n[bold]trace[/]")
|
|
1843
|
+
_render_trace(detail.trace)
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def _render_ingest(report: IngestReport) -> None:
|
|
1847
|
+
if report.sample:
|
|
1848
|
+
issues = Table(box=None, pad_edge=False)
|
|
1849
|
+
issues.add_column("line", justify="right", style="cyan")
|
|
1850
|
+
issues.add_column("kind")
|
|
1851
|
+
issues.add_column("field", style="magenta")
|
|
1852
|
+
issues.add_column("problem")
|
|
1853
|
+
for issue in report.sample:
|
|
1854
|
+
problem = (
|
|
1855
|
+
issue.message if issue.hint is None else f"{issue.message} [dim]({issue.hint})[/]"
|
|
1856
|
+
)
|
|
1857
|
+
issues.add_row(str(issue.line), issue.kind.value, issue.field or "", problem)
|
|
1858
|
+
console.print(issues)
|
|
1859
|
+
if report.truncated:
|
|
1860
|
+
hint = (
|
|
1861
|
+
"re-run with --errors PATH to capture them all"
|
|
1862
|
+
if report.error_path is None
|
|
1863
|
+
else f"see {report.error_path}"
|
|
1864
|
+
)
|
|
1865
|
+
console.print(f"[dim]... and {report.truncated} more ({hint})[/]")
|
|
1866
|
+
console.print()
|
|
1867
|
+
|
|
1868
|
+
summary = Table(box=None, pad_edge=False, show_header=False)
|
|
1869
|
+
summary.add_column(style="bold")
|
|
1870
|
+
summary.add_column(justify="right")
|
|
1871
|
+
summary.add_row("records", str(report.records))
|
|
1872
|
+
summary.add_row("valid", f"[green]{report.valid}[/]")
|
|
1873
|
+
summary.add_row("invalid", f"[red]{report.invalid}[/]" if report.invalid else "0")
|
|
1874
|
+
if report.duplicate_ids:
|
|
1875
|
+
summary.add_row("duplicate ids", f"[red]{report.duplicate_ids}[/]")
|
|
1876
|
+
if report.mode is not IngestMode.VALIDATE:
|
|
1877
|
+
label = "would store" if report.mode is IngestMode.DRY_RUN else "stored"
|
|
1878
|
+
summary.add_row(label, f"[green]{report.stored}[/]")
|
|
1879
|
+
if report.already_stored:
|
|
1880
|
+
summary.add_row("already stored", str(report.already_stored))
|
|
1881
|
+
if report.content_duplicates:
|
|
1882
|
+
summary.add_row("duplicate content", str(report.content_duplicates))
|
|
1883
|
+
if report.occurrences:
|
|
1884
|
+
summary.add_row("sightings recorded", str(report.occurrences))
|
|
1885
|
+
if report.id_conflicts:
|
|
1886
|
+
summary.add_row("id conflicts", f"[red]{report.id_conflicts}[/]")
|
|
1887
|
+
summary.add_row("redacted values", str(report.redactions))
|
|
1888
|
+
console.print(summary)
|
|
1889
|
+
|
|
1890
|
+
if report.redactions:
|
|
1891
|
+
detail = ", ".join(
|
|
1892
|
+
f"{rule} x{count}" for rule, count in report.redaction_summary.to_dict().items()
|
|
1893
|
+
)
|
|
1894
|
+
console.print(f"[dim]{detail}[/]")
|
|
1895
|
+
if report.identifier_risks:
|
|
1896
|
+
err_console.print(
|
|
1897
|
+
f"\n[yellow]warning:[/] {report.identifier_risks} trace(s) have "
|
|
1898
|
+
"identifiers that look like they carry personal data, and identifiers "
|
|
1899
|
+
"are stored as-is:"
|
|
1900
|
+
)
|
|
1901
|
+
for notice in report.notices:
|
|
1902
|
+
err_console.print(f" [dim]{notice}[/]")
|
|
1903
|
+
err_console.print(
|
|
1904
|
+
" [dim]Set redaction.pseudonymize_identifiers in evalkeep.yaml to "
|
|
1905
|
+
"replace them with per-project tokens.[/]"
|
|
1906
|
+
)
|
|
1907
|
+
|
|
1908
|
+
if report.error_path is not None and report.issue_count:
|
|
1909
|
+
console.print(f"\n[dim]{report.issue_count} issues written to {report.error_path}[/]")
|
|
1910
|
+
|
|
1911
|
+
if report.mode is IngestMode.DRY_RUN:
|
|
1912
|
+
console.print("\n[bold yellow]Dry run[/] - nothing was written.")
|
|
1913
|
+
elif not report.ok:
|
|
1914
|
+
console.print(f"\n[bold red]Invalid[/] {report.path}")
|
|
1915
|
+
elif report.mode is IngestMode.VALIDATE:
|
|
1916
|
+
console.print(f"\n[bold green]Valid[/] {report.path}")
|
|
1917
|
+
else:
|
|
1918
|
+
console.print(f"\n[bold green]Ingested[/] {report.stored} traces from {report.path}")
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
def _run(action: Callable[[], T]) -> T:
|
|
1922
|
+
"""Run a command function, turning EvalkeepError into a clean exit."""
|
|
1923
|
+
try:
|
|
1924
|
+
return action()
|
|
1925
|
+
except EvalkeepError as exc:
|
|
1926
|
+
err_console.print(f"[bold red]error:[/] {exc.message}")
|
|
1927
|
+
if exc.hint:
|
|
1928
|
+
err_console.print(f"[dim]hint:[/] {exc.hint}")
|
|
1929
|
+
raise typer.Exit(exc.exit_code) from exc
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
def main() -> None:
|
|
1933
|
+
app()
|