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
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()