skillproof 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.
- skillproof/__init__.py +3 -0
- skillproof/__main__.py +3 -0
- skillproof/assertions/__init__.py +62 -0
- skillproof/assertions/base.py +44 -0
- skillproof/assertions/builtin.py +176 -0
- skillproof/assertions/llm_judge.py +24 -0
- skillproof/cli.py +313 -0
- skillproof/config.py +188 -0
- skillproof/engine.py +268 -0
- skillproof/errors.py +72 -0
- skillproof/estimate.py +83 -0
- skillproof/judges/__init__.py +43 -0
- skillproof/judges/anthropic_judge.py +59 -0
- skillproof/judges/base.py +69 -0
- skillproof/judges/mock.py +21 -0
- skillproof/judges/openai_judge.py +58 -0
- skillproof/parsers/__init__.py +6 -0
- skillproof/parsers/claude_code.py +144 -0
- skillproof/parsers/codex.py +112 -0
- skillproof/report/__init__.py +34 -0
- skillproof/report/badge.py +60 -0
- skillproof/report/format.py +75 -0
- skillproof/report/html_report.py +296 -0
- skillproof/report/json_report.py +9 -0
- skillproof/report/markdown.py +78 -0
- skillproof/report/summary.py +216 -0
- skillproof/report/terminal.py +89 -0
- skillproof/results.py +88 -0
- skillproof/runners/__init__.py +33 -0
- skillproof/runners/base.py +102 -0
- skillproof/runners/claude_code.py +117 -0
- skillproof/runners/codex.py +133 -0
- skillproof/runners/mock.py +91 -0
- skillproof/runners/openai_compat.py +238 -0
- skillproof/sandbox.py +85 -0
- skillproof/scaffold.py +153 -0
- skillproof/skill.py +97 -0
- skillproof/stats.py +228 -0
- skillproof-0.1.0.dist-info/METADATA +262 -0
- skillproof-0.1.0.dist-info/RECORD +43 -0
- skillproof-0.1.0.dist-info/WHEEL +4 -0
- skillproof-0.1.0.dist-info/entry_points.txt +2 -0
- skillproof-0.1.0.dist-info/licenses/LICENSE +21 -0
skillproof/__init__.py
ADDED
skillproof/__main__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Assertion types usable under `assert:` in evals.yaml.
|
|
2
|
+
|
|
3
|
+
To add an assertion type: write a `BaseAssertion` subclass with a unique
|
|
4
|
+
`type: Literal["..."]` field and an async `check()`, then add it to
|
|
5
|
+
`Assertion` below. Validation and error messages come from pydantic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, get_args
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from skillproof.assertions.base import AssertionResult, BaseAssertion, CheckContext
|
|
15
|
+
from skillproof.assertions.builtin import (
|
|
16
|
+
Command,
|
|
17
|
+
Contains,
|
|
18
|
+
FileContains,
|
|
19
|
+
FileExists,
|
|
20
|
+
MaxTokens,
|
|
21
|
+
NotContains,
|
|
22
|
+
Regex,
|
|
23
|
+
)
|
|
24
|
+
from skillproof.assertions.llm_judge import LLMJudge
|
|
25
|
+
from skillproof.judges.base import JudgeUnavailable
|
|
26
|
+
|
|
27
|
+
Assertion = Annotated[
|
|
28
|
+
Contains | NotContains | Regex | FileExists | FileContains | Command | MaxTokens | LLMJudge,
|
|
29
|
+
Field(discriminator="type"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# ["contains", "not_contains", ...], read from each class's `type` Literal.
|
|
33
|
+
ASSERTION_TYPES: list[str] = [
|
|
34
|
+
get_args(cls.model_fields["type"].annotation)[0] for cls in get_args(get_args(Assertion)[0])
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def check_all(assertions: list[BaseAssertion], ctx: CheckContext) -> list[AssertionResult]:
|
|
39
|
+
"""Run assertions in order. A crashing assertion counts as a failure, not a crash.
|
|
40
|
+
|
|
41
|
+
JudgeUnavailable is re-raised: the judge broke, not the agent, so the
|
|
42
|
+
engine marks the whole run as errored instead.
|
|
43
|
+
"""
|
|
44
|
+
results = []
|
|
45
|
+
for assertion in assertions:
|
|
46
|
+
try:
|
|
47
|
+
results.append(await assertion.check(ctx))
|
|
48
|
+
except JudgeUnavailable:
|
|
49
|
+
raise
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
results.append(assertion.result(False, f"assertion error: {exc}"))
|
|
52
|
+
return results
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"ASSERTION_TYPES",
|
|
57
|
+
"Assertion",
|
|
58
|
+
"AssertionResult",
|
|
59
|
+
"BaseAssertion",
|
|
60
|
+
"CheckContext",
|
|
61
|
+
"check_all",
|
|
62
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Base class for assertions.
|
|
2
|
+
|
|
3
|
+
An assertion is a pydantic model (so evals.yaml is validated for free) with an
|
|
4
|
+
async `check()` that looks at one finished run and returns an AssertionResult.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict
|
|
13
|
+
|
|
14
|
+
from skillproof.judges.base import Judge
|
|
15
|
+
from skillproof.runners.base import AgentRun
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CheckContext:
|
|
20
|
+
run: AgentRun
|
|
21
|
+
workdir: Path # the sandbox the agent worked in
|
|
22
|
+
judge: Judge | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AssertionResult(BaseModel):
|
|
26
|
+
type: str
|
|
27
|
+
passed: bool
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BaseAssertion(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra="forbid")
|
|
33
|
+
|
|
34
|
+
type: str
|
|
35
|
+
|
|
36
|
+
def describe(self) -> str:
|
|
37
|
+
"""Short human label, e.g. `contains "NullPointerException"`."""
|
|
38
|
+
return self.type
|
|
39
|
+
|
|
40
|
+
async def check(self, ctx: CheckContext) -> AssertionResult:
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def result(self, passed: bool, message: str) -> AssertionResult:
|
|
44
|
+
return AssertionResult(type=self.type, passed=passed, message=message)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Deterministic assertions. These are cheap and reproducible: prefer them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import Field, field_validator
|
|
10
|
+
|
|
11
|
+
from skillproof.assertions.base import AssertionResult, BaseAssertion, CheckContext
|
|
12
|
+
from skillproof.sandbox import run_shell
|
|
13
|
+
|
|
14
|
+
MAX_OUTPUT_IN_MESSAGE = 500
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sandbox_relative(path: str) -> str:
|
|
18
|
+
p = PurePosixPath(path)
|
|
19
|
+
if p.is_absolute() or ".." in p.parts:
|
|
20
|
+
raise ValueError("must be a relative path inside the sandbox (no '/' prefix, no '..')")
|
|
21
|
+
return path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _snippet(text: str, limit: int = 80) -> str:
|
|
25
|
+
text = " ".join(text.split())
|
|
26
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Contains(BaseAssertion):
|
|
30
|
+
"""The agent's final answer contains `value`."""
|
|
31
|
+
|
|
32
|
+
type: Literal["contains"]
|
|
33
|
+
value: str
|
|
34
|
+
case_sensitive: bool = False
|
|
35
|
+
|
|
36
|
+
def describe(self) -> str:
|
|
37
|
+
return f'contains "{self.value}"'
|
|
38
|
+
|
|
39
|
+
async def check(self, ctx: CheckContext) -> AssertionResult:
|
|
40
|
+
haystack, needle = ctx.run.final_text, self.value
|
|
41
|
+
if not self.case_sensitive:
|
|
42
|
+
haystack, needle = haystack.lower(), needle.lower()
|
|
43
|
+
if needle in haystack:
|
|
44
|
+
return self.result(True, f'answer contains "{self.value}"')
|
|
45
|
+
return self.result(False, f'"{self.value}" not in answer: "{_snippet(ctx.run.final_text)}"')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NotContains(BaseAssertion):
|
|
49
|
+
"""The agent's final answer does NOT contain `value`."""
|
|
50
|
+
|
|
51
|
+
type: Literal["not_contains"]
|
|
52
|
+
value: str
|
|
53
|
+
case_sensitive: bool = False
|
|
54
|
+
|
|
55
|
+
def describe(self) -> str:
|
|
56
|
+
return f'not_contains "{self.value}"'
|
|
57
|
+
|
|
58
|
+
async def check(self, ctx: CheckContext) -> AssertionResult:
|
|
59
|
+
haystack, needle = ctx.run.final_text, self.value
|
|
60
|
+
if not self.case_sensitive:
|
|
61
|
+
haystack, needle = haystack.lower(), needle.lower()
|
|
62
|
+
if needle in haystack:
|
|
63
|
+
return self.result(False, f'answer contains forbidden "{self.value}"')
|
|
64
|
+
return self.result(True, f'answer does not contain "{self.value}"')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Regex(BaseAssertion):
|
|
68
|
+
"""The agent's final answer matches a Python regular expression."""
|
|
69
|
+
|
|
70
|
+
type: Literal["regex"]
|
|
71
|
+
pattern: str
|
|
72
|
+
|
|
73
|
+
@field_validator("pattern")
|
|
74
|
+
@classmethod
|
|
75
|
+
def _compiles(cls, pattern: str) -> str:
|
|
76
|
+
try:
|
|
77
|
+
re.compile(pattern)
|
|
78
|
+
except re.error as exc:
|
|
79
|
+
raise ValueError(f"invalid regular expression: {exc}") from exc
|
|
80
|
+
return pattern
|
|
81
|
+
|
|
82
|
+
def describe(self) -> str:
|
|
83
|
+
return f"regex /{self.pattern}/"
|
|
84
|
+
|
|
85
|
+
async def check(self, ctx: CheckContext) -> AssertionResult:
|
|
86
|
+
if re.search(self.pattern, ctx.run.final_text, flags=re.MULTILINE):
|
|
87
|
+
return self.result(True, f"answer matches /{self.pattern}/")
|
|
88
|
+
return self.result(False, f"answer does not match /{self.pattern}/")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FileExists(BaseAssertion):
|
|
92
|
+
"""A file exists in the sandbox after the run."""
|
|
93
|
+
|
|
94
|
+
type: Literal["file_exists"]
|
|
95
|
+
path: str
|
|
96
|
+
|
|
97
|
+
@field_validator("path")
|
|
98
|
+
@classmethod
|
|
99
|
+
def _inside_sandbox(cls, path: str) -> str:
|
|
100
|
+
return _sandbox_relative(path)
|
|
101
|
+
|
|
102
|
+
def describe(self) -> str:
|
|
103
|
+
return f"file_exists {self.path}"
|
|
104
|
+
|
|
105
|
+
async def check(self, ctx: CheckContext) -> AssertionResult:
|
|
106
|
+
if (ctx.workdir / self.path).exists():
|
|
107
|
+
return self.result(True, f"{self.path} exists")
|
|
108
|
+
return self.result(False, f"{self.path} was not created")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class FileContains(BaseAssertion):
|
|
112
|
+
"""A file in the sandbox contains `value`."""
|
|
113
|
+
|
|
114
|
+
type: Literal["file_contains"]
|
|
115
|
+
path: str
|
|
116
|
+
value: str
|
|
117
|
+
case_sensitive: bool = False
|
|
118
|
+
|
|
119
|
+
@field_validator("path")
|
|
120
|
+
@classmethod
|
|
121
|
+
def _inside_sandbox(cls, path: str) -> str:
|
|
122
|
+
return _sandbox_relative(path)
|
|
123
|
+
|
|
124
|
+
def describe(self) -> str:
|
|
125
|
+
return f'file_contains {self.path} "{self.value}"'
|
|
126
|
+
|
|
127
|
+
async def check(self, ctx: CheckContext) -> AssertionResult:
|
|
128
|
+
target: Path = ctx.workdir / self.path
|
|
129
|
+
if not target.is_file():
|
|
130
|
+
return self.result(False, f"{self.path} was not created")
|
|
131
|
+
content = target.read_text(encoding="utf-8", errors="replace")
|
|
132
|
+
needle = self.value
|
|
133
|
+
if not self.case_sensitive:
|
|
134
|
+
content, needle = content.lower(), needle.lower()
|
|
135
|
+
if needle in content:
|
|
136
|
+
return self.result(True, f'{self.path} contains "{self.value}"')
|
|
137
|
+
return self.result(False, f'{self.path} does not contain "{self.value}"')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Command(BaseAssertion):
|
|
141
|
+
"""Run a shell command in the sandbox; passes if it exits 0.
|
|
142
|
+
|
|
143
|
+
API keys and tokens are removed from the command's environment.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
type: Literal["command"]
|
|
147
|
+
run: str
|
|
148
|
+
timeout: float = Field(default=120, gt=0)
|
|
149
|
+
|
|
150
|
+
def describe(self) -> str:
|
|
151
|
+
return f"command `{self.run}`"
|
|
152
|
+
|
|
153
|
+
async def check(self, ctx: CheckContext) -> AssertionResult:
|
|
154
|
+
code, out = await run_shell(self.run, ctx.workdir, self.timeout)
|
|
155
|
+
if code is None:
|
|
156
|
+
return self.result(False, f"`{self.run}` timed out after {self.timeout:g}s")
|
|
157
|
+
if code == 0:
|
|
158
|
+
return self.result(True, f"`{self.run}` exited 0")
|
|
159
|
+
output = out.strip()[-MAX_OUTPUT_IN_MESSAGE:]
|
|
160
|
+
return self.result(False, f"`{self.run}` exited {code}: {output}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class MaxTokens(BaseAssertion):
|
|
164
|
+
"""The run used at most `value` tokens (input + output + cache)."""
|
|
165
|
+
|
|
166
|
+
type: Literal["max_tokens"]
|
|
167
|
+
value: int = Field(gt=0)
|
|
168
|
+
|
|
169
|
+
def describe(self) -> str:
|
|
170
|
+
return f"max_tokens {self.value}"
|
|
171
|
+
|
|
172
|
+
async def check(self, ctx: CheckContext) -> AssertionResult:
|
|
173
|
+
used = ctx.run.usage.total_tokens
|
|
174
|
+
if used <= self.value:
|
|
175
|
+
return self.result(True, f"used {used} tokens (limit {self.value})")
|
|
176
|
+
return self.result(False, f"used {used} tokens, over the limit of {self.value}")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The optional LLM-graded assertion. Non-deterministic: use deterministic ones first."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from skillproof.assertions.base import AssertionResult, BaseAssertion, CheckContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LLMJudge(BaseAssertion):
|
|
11
|
+
"""A judge model decides whether the final answer satisfies `rubric`."""
|
|
12
|
+
|
|
13
|
+
type: Literal["llm_judge"]
|
|
14
|
+
rubric: str
|
|
15
|
+
|
|
16
|
+
def describe(self) -> str:
|
|
17
|
+
rubric = self.rubric if len(self.rubric) <= 40 else self.rubric[:39] + "…"
|
|
18
|
+
return f'llm_judge "{rubric}"'
|
|
19
|
+
|
|
20
|
+
async def check(self, ctx: CheckContext) -> AssertionResult:
|
|
21
|
+
if ctx.judge is None:
|
|
22
|
+
return self.result(False, "no judge configured")
|
|
23
|
+
verdict = await ctx.judge.grade(self.rubric, ctx.run.final_text)
|
|
24
|
+
return self.result(verdict.passed, f"judge ({ctx.judge.name}): {verdict.reason}")
|
skillproof/cli.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""The `skillproof` command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
|
|
14
|
+
|
|
15
|
+
from skillproof import __version__
|
|
16
|
+
from skillproof.config import EvalConfig, load_config
|
|
17
|
+
from skillproof.engine import Engine, RunOptions
|
|
18
|
+
from skillproof.errors import RunAborted, SkillproofError
|
|
19
|
+
from skillproof.estimate import CONFIRM_ABOVE_USD, Estimate, estimate
|
|
20
|
+
from skillproof.report import FORMATS, Summary, build_summary, render_text, terminal, write_reports
|
|
21
|
+
from skillproof.report import badge as badges
|
|
22
|
+
from skillproof.results import SuiteResult, find_latest_run, load_suite
|
|
23
|
+
from skillproof.scaffold import init_skill
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="skillproof",
|
|
27
|
+
help="pytest for agent skills: prove your skill actually works.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
add_completion=False,
|
|
30
|
+
pretty_exceptions_enable=False,
|
|
31
|
+
)
|
|
32
|
+
console = Console()
|
|
33
|
+
err_console = Console(stderr=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fail(exc: SkillproofError) -> typer.Exit:
|
|
37
|
+
err_console.print(f"[bold red]error:[/] {exc.message}", highlight=False)
|
|
38
|
+
if exc.hint:
|
|
39
|
+
err_console.print(f"[bold]fix:[/] {exc.hint}", highlight=False)
|
|
40
|
+
return typer.Exit(code=2)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def version_callback(value: bool) -> None:
|
|
44
|
+
if value:
|
|
45
|
+
console.print(f"skillproof {__version__}")
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.callback()
|
|
50
|
+
def root(
|
|
51
|
+
version: Annotated[
|
|
52
|
+
bool,
|
|
53
|
+
typer.Option("--version", callback=version_callback, is_eager=True, help="Show version."),
|
|
54
|
+
] = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def init(
|
|
61
|
+
path: Annotated[Path, typer.Argument(help="Skill folder (contains SKILL.md).")] = Path("."),
|
|
62
|
+
runner: Annotated[str, typer.Option(help="Agent to test with.")] = "claude-code",
|
|
63
|
+
force: Annotated[
|
|
64
|
+
bool, typer.Option("--force", help="Overwrite an existing evals.yaml.")
|
|
65
|
+
] = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Scaffold an evals.yaml with example cases."""
|
|
68
|
+
try:
|
|
69
|
+
result = init_skill(path, runner=runner, force=force)
|
|
70
|
+
except SkillproofError as exc:
|
|
71
|
+
raise fail(exc) from exc
|
|
72
|
+
for created in result.created:
|
|
73
|
+
console.print(f"[green]created[/] {created}")
|
|
74
|
+
for warning in result.warnings:
|
|
75
|
+
console.print(f"[yellow]warning:[/] {warning}")
|
|
76
|
+
console.print(f"\nNext: edit {path / 'evals.yaml'}, then run [bold]skillproof run {path}[/]")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def run(
|
|
81
|
+
path: Annotated[Path, typer.Argument(help="Skill folder or evals.yaml file.")] = Path("."),
|
|
82
|
+
trials: Annotated[
|
|
83
|
+
int | None, typer.Option("--trials", "-n", min=1, help="Override trials per case.")
|
|
84
|
+
] = None,
|
|
85
|
+
case: Annotated[
|
|
86
|
+
list[str] | None, typer.Option("--case", "-k", help="Only run this case (repeatable).")
|
|
87
|
+
] = None,
|
|
88
|
+
parallel: Annotated[int, typer.Option("--parallel", "-p", min=1, help="Concurrent runs.")] = 4,
|
|
89
|
+
report_format: Annotated[
|
|
90
|
+
str, typer.Option("--format", "-f", help="terminal, md, html or json.")
|
|
91
|
+
] = "terminal",
|
|
92
|
+
output: Annotated[
|
|
93
|
+
Path | None, typer.Option("--output", "-o", help="Write the md/html/json report here.")
|
|
94
|
+
] = None,
|
|
95
|
+
out: Annotated[Path, typer.Option(help="Where transcripts and results are saved.")] = Path(
|
|
96
|
+
".skillproof"
|
|
97
|
+
),
|
|
98
|
+
keep_sandboxes: Annotated[
|
|
99
|
+
bool, typer.Option("--keep-sandboxes", help="Save each run's final working dir.")
|
|
100
|
+
] = False,
|
|
101
|
+
yes: Annotated[
|
|
102
|
+
bool, typer.Option("--yes", "-y", help="Don't ask before an expensive run.")
|
|
103
|
+
] = False,
|
|
104
|
+
confirm_above: Annotated[
|
|
105
|
+
float, typer.Option(help="Ask for confirmation above this estimated cost (USD).")
|
|
106
|
+
] = CONFIRM_ABOVE_USD,
|
|
107
|
+
dry_run: Annotated[
|
|
108
|
+
bool, typer.Option("--dry-run", help="Show the plan and cost estimate, run nothing.")
|
|
109
|
+
] = False,
|
|
110
|
+
min_lift: Annotated[
|
|
111
|
+
float | None,
|
|
112
|
+
typer.Option(help="Exit 1 if the lift is below this many percentage points (CI gate)."),
|
|
113
|
+
] = None,
|
|
114
|
+
min_trigger_rate: Annotated[
|
|
115
|
+
float | None,
|
|
116
|
+
typer.Option(help="Exit 1 if the trigger rate is below this percentage (CI gate)."),
|
|
117
|
+
] = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Run the evals with and without the skill, and print a report."""
|
|
120
|
+
check_format(report_format)
|
|
121
|
+
options = RunOptions(
|
|
122
|
+
parallel=parallel,
|
|
123
|
+
out_dir=out,
|
|
124
|
+
keep_sandboxes=keep_sandboxes,
|
|
125
|
+
trials=trials,
|
|
126
|
+
only_cases=case,
|
|
127
|
+
)
|
|
128
|
+
# Machine-readable output goes to stdout, so everything else goes to stderr.
|
|
129
|
+
info = console if report_format == "terminal" else err_console
|
|
130
|
+
try:
|
|
131
|
+
config = load_config(path)
|
|
132
|
+
check_placeholders(config)
|
|
133
|
+
engine = Engine(config, options)
|
|
134
|
+
plan = estimate(engine, out)
|
|
135
|
+
info.print(f"[bold]plan:[/] {plan.describe()}", highlight=False)
|
|
136
|
+
if dry_run:
|
|
137
|
+
return
|
|
138
|
+
confirm_cost(plan, confirm_above, yes)
|
|
139
|
+
with Progress(
|
|
140
|
+
TextColumn("[bold]running[/]"),
|
|
141
|
+
BarColumn(),
|
|
142
|
+
MofNCompleteColumn(),
|
|
143
|
+
TimeElapsedColumn(),
|
|
144
|
+
console=info,
|
|
145
|
+
transient=True,
|
|
146
|
+
) as progress:
|
|
147
|
+
task = progress.add_task("runs", total=len(engine.jobs()))
|
|
148
|
+
engine.on_trial = lambda _trial: progress.advance(task)
|
|
149
|
+
suite = asyncio.run(engine.run())
|
|
150
|
+
summary = write_reports(suite)
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
saved = save_partial_reports(out)
|
|
153
|
+
err_console.print(f"\n[yellow]interrupted.[/] Partial results: {saved}", highlight=False)
|
|
154
|
+
raise typer.Exit(code=130) from None
|
|
155
|
+
except RunAborted as exc:
|
|
156
|
+
save_partial_reports(out)
|
|
157
|
+
raise fail(exc) from exc
|
|
158
|
+
except SkillproofError as exc:
|
|
159
|
+
raise fail(exc) from exc
|
|
160
|
+
emit(report_format, summary, suite, output)
|
|
161
|
+
gate(summary, min_lift, min_trigger_rate)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def check_placeholders(config: EvalConfig) -> None:
|
|
165
|
+
"""Refuse to pay for running the TODO examples that `skillproof init` writes."""
|
|
166
|
+
for case in config.cases:
|
|
167
|
+
texts = [case.prompt, *(str(a.model_dump()) for a in case.assertions)]
|
|
168
|
+
if any("TODO" in text for text in texts):
|
|
169
|
+
raise SkillproofError(
|
|
170
|
+
f"Case {case.name!r} still has TODO placeholders from `skillproof init`.",
|
|
171
|
+
hint=f"Write a real prompt and assertions in {config.path}, then run again.",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def confirm_cost(plan: Estimate, confirm_above: float, yes: bool) -> None:
|
|
176
|
+
total = plan.total_usd
|
|
177
|
+
if yes or total is None or total <= confirm_above:
|
|
178
|
+
return
|
|
179
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
180
|
+
if typer.confirm(f"This may cost about ${total:.2f}. Continue?", default=False):
|
|
181
|
+
return
|
|
182
|
+
raise typer.Exit(code=1)
|
|
183
|
+
raise fail(
|
|
184
|
+
SkillproofError(
|
|
185
|
+
f"Estimated cost ~${total:.2f} is above ${confirm_above:.2f}.",
|
|
186
|
+
hint="Pass --yes to run anyway, lower --trials, or raise --confirm-above.",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def save_partial_reports(out: Path) -> str:
|
|
192
|
+
"""After an interruption, render reports for whatever finished."""
|
|
193
|
+
try:
|
|
194
|
+
suite = load_suite(find_latest_run(out))
|
|
195
|
+
write_reports(suite)
|
|
196
|
+
return f"{suite.run_dir}/report.html"
|
|
197
|
+
except (SkillproofError, OSError, ValueError):
|
|
198
|
+
return str(out / "runs")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def gate(summary: Summary, min_lift: float | None, min_trigger_rate: float | None) -> None:
|
|
202
|
+
"""CI gates: exit 1 when the skill doesn't meet the bar."""
|
|
203
|
+
failures = []
|
|
204
|
+
if min_lift is not None:
|
|
205
|
+
if summary.lift is None:
|
|
206
|
+
failures.append("lift could not be measured (no graded runs)")
|
|
207
|
+
elif summary.lift * 100 < min_lift:
|
|
208
|
+
failures.append(f"lift {summary.lift * 100:+.0f}% is below --min-lift {min_lift:+g}%")
|
|
209
|
+
if min_trigger_rate is not None:
|
|
210
|
+
rate = summary.trigger_rate
|
|
211
|
+
if rate is None:
|
|
212
|
+
failures.append("trigger rate could not be measured with this runner")
|
|
213
|
+
elif rate * 100 < min_trigger_rate:
|
|
214
|
+
failures.append(
|
|
215
|
+
f"trigger rate {rate * 100:.0f}% is below --min-trigger-rate {min_trigger_rate:g}%"
|
|
216
|
+
)
|
|
217
|
+
for failure in failures:
|
|
218
|
+
err_console.print(f"[bold red]gate failed:[/] {failure}", highlight=False)
|
|
219
|
+
if failures:
|
|
220
|
+
raise typer.Exit(code=1)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command()
|
|
224
|
+
def report(
|
|
225
|
+
path: Annotated[
|
|
226
|
+
Path | None,
|
|
227
|
+
typer.Argument(help="A run directory or results.json. Default: the latest run."),
|
|
228
|
+
] = None,
|
|
229
|
+
report_format: Annotated[
|
|
230
|
+
str, typer.Option("--format", "-f", help="terminal, md, html or json.")
|
|
231
|
+
] = "terminal",
|
|
232
|
+
output: Annotated[
|
|
233
|
+
Path | None, typer.Option("--output", "-o", help="Write the md/html/json report here.")
|
|
234
|
+
] = None,
|
|
235
|
+
out: Annotated[Path, typer.Option(help="Where runs are saved.")] = Path(".skillproof"),
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Re-render the report for a saved run, without running anything."""
|
|
238
|
+
check_format(report_format)
|
|
239
|
+
try:
|
|
240
|
+
suite = load_suite(path if path is not None else find_latest_run(out))
|
|
241
|
+
except SkillproofError as exc:
|
|
242
|
+
raise fail(exc) from exc
|
|
243
|
+
summary = write_reports(suite) # refresh the saved reports with this version
|
|
244
|
+
emit(report_format, summary, suite, output)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command()
|
|
248
|
+
def badge(
|
|
249
|
+
path: Annotated[Path, typer.Argument(help="Skill folder (or its evals.yaml).")] = Path("."),
|
|
250
|
+
endpoint: Annotated[
|
|
251
|
+
bool, typer.Option("--json", help="Print shields.io endpoint JSON instead of markdown.")
|
|
252
|
+
] = False,
|
|
253
|
+
out: Annotated[Path, typer.Option(help="Where runs are saved.")] = Path(".skillproof"),
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Print a shields.io badge for the skill's latest run."""
|
|
256
|
+
try:
|
|
257
|
+
skill_path = load_config(path).skill
|
|
258
|
+
except SkillproofError:
|
|
259
|
+
skill_path = path.resolve()
|
|
260
|
+
try:
|
|
261
|
+
summary = build_summary(load_suite(find_latest_run(out, skill_path)))
|
|
262
|
+
except SkillproofError as exc:
|
|
263
|
+
raise fail(exc) from exc
|
|
264
|
+
print(badges.endpoint_json(summary) if endpoint else badges.markdown(summary))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def check_format(report_format: str) -> None:
|
|
268
|
+
if report_format not in FORMATS:
|
|
269
|
+
raise fail(
|
|
270
|
+
SkillproofError(
|
|
271
|
+
f"Unknown report format {report_format!r}.",
|
|
272
|
+
hint=f"Use one of: {', '.join(FORMATS)}.",
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def emit(report_format: str, summary: Summary, suite: SuiteResult, output: Path | None) -> None:
|
|
278
|
+
"""Show or write the report in the requested format."""
|
|
279
|
+
if report_format == "terminal":
|
|
280
|
+
terminal.render(summary, console)
|
|
281
|
+
return
|
|
282
|
+
text = render_text(report_format, summary, suite)
|
|
283
|
+
if output is None and report_format == "html":
|
|
284
|
+
output = Path(suite.run_dir) / "report.html"
|
|
285
|
+
if output is None:
|
|
286
|
+
sys.stdout.write(text)
|
|
287
|
+
return
|
|
288
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
output.write_text(text, encoding="utf-8")
|
|
290
|
+
err_console.print(f"wrote {output}", highlight=False)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
ISSUES_URL = "https://github.com/akhilgarg07/skillproof/issues"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def main() -> None:
|
|
297
|
+
"""Console entry point: user errors get a fix-it message, bugs get a bug report link."""
|
|
298
|
+
try:
|
|
299
|
+
app()
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
if os.environ.get("SKILLPROOF_DEBUG"):
|
|
302
|
+
raise
|
|
303
|
+
err_console.print(
|
|
304
|
+
f"[bold red]skillproof crashed:[/] {type(exc).__name__}: {exc}\n"
|
|
305
|
+
f"This is a bug in skillproof, not in your skill. Re-run with SKILLPROOF_DEBUG=1 for "
|
|
306
|
+
f"the traceback and please report it at {ISSUES_URL}",
|
|
307
|
+
highlight=False,
|
|
308
|
+
)
|
|
309
|
+
sys.exit(70)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
main()
|