evalwise 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.
- evalwise/__init__.py +23 -0
- evalwise/__main__.py +6 -0
- evalwise/audio/__init__.py +5 -0
- evalwise/audio/assertions.py +335 -0
- evalwise/cli.py +251 -0
- evalwise/core/__init__.py +7 -0
- evalwise/core/context.py +47 -0
- evalwise/core/dataset.py +102 -0
- evalwise/core/result.py +142 -0
- evalwise/core/suite.py +243 -0
- evalwise/image/__init__.py +5 -0
- evalwise/image/assertions.py +382 -0
- evalwise/text/__init__.py +5 -0
- evalwise/text/assertions.py +854 -0
- evalwise-0.1.0.dist-info/METADATA +255 -0
- evalwise-0.1.0.dist-info/RECORD +18 -0
- evalwise-0.1.0.dist-info/WHEEL +4 -0
- evalwise-0.1.0.dist-info/entry_points.txt +2 -0
evalwise/core/dataset.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Dataset loading and management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Dataset:
|
|
11
|
+
"""
|
|
12
|
+
A collection of test inputs for evaluation.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
# From list
|
|
16
|
+
dataset = Dataset([
|
|
17
|
+
{"response": "Hello", "expected": "greeting"},
|
|
18
|
+
{"response": "Bye", "expected": "farewell"},
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
# From file
|
|
22
|
+
dataset = Dataset.from_file("./golden_set.json")
|
|
23
|
+
|
|
24
|
+
# From CSV
|
|
25
|
+
dataset = Dataset.from_csv("./test_cases.csv")
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, items: list[dict[str, Any]]):
|
|
29
|
+
self.items = items
|
|
30
|
+
|
|
31
|
+
def __len__(self) -> int:
|
|
32
|
+
return len(self.items)
|
|
33
|
+
|
|
34
|
+
def __iter__(self) -> Iterator[dict[str, Any]]:
|
|
35
|
+
return iter(self.items)
|
|
36
|
+
|
|
37
|
+
def __getitem__(self, idx: int) -> dict[str, Any]:
|
|
38
|
+
return self.items[idx]
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_file(cls, path: str | Path) -> Dataset:
|
|
42
|
+
"""Load dataset from JSON or YAML file."""
|
|
43
|
+
p = Path(path)
|
|
44
|
+
|
|
45
|
+
if p.suffix == ".json":
|
|
46
|
+
with open(p) as f:
|
|
47
|
+
data = json.load(f)
|
|
48
|
+
elif p.suffix in (".yaml", ".yml"):
|
|
49
|
+
try:
|
|
50
|
+
import yaml
|
|
51
|
+
with open(p) as f:
|
|
52
|
+
data = yaml.safe_load(f)
|
|
53
|
+
except ImportError:
|
|
54
|
+
raise ImportError("PyYAML required: pip install pyyaml")
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError(f"Unsupported format: {p.suffix}")
|
|
57
|
+
|
|
58
|
+
if isinstance(data, list):
|
|
59
|
+
return cls(data)
|
|
60
|
+
elif isinstance(data, dict) and "tests" in data:
|
|
61
|
+
return cls(data["tests"])
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError("File must contain a list or object with 'tests' key")
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_csv(cls, path: str | Path, **kwargs: Any) -> Dataset:
|
|
67
|
+
"""Load dataset from CSV file."""
|
|
68
|
+
import csv
|
|
69
|
+
|
|
70
|
+
items = []
|
|
71
|
+
with open(path, newline="") as f:
|
|
72
|
+
reader = csv.DictReader(f, **kwargs)
|
|
73
|
+
for row in reader:
|
|
74
|
+
items.append(dict(row))
|
|
75
|
+
|
|
76
|
+
return cls(items)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_jsonl(cls, path: str | Path) -> Dataset:
|
|
80
|
+
"""Load dataset from JSON Lines file."""
|
|
81
|
+
items = []
|
|
82
|
+
with open(path) as f:
|
|
83
|
+
for line in f:
|
|
84
|
+
if line.strip():
|
|
85
|
+
items.append(json.loads(line))
|
|
86
|
+
return cls(items)
|
|
87
|
+
|
|
88
|
+
def filter(self, fn: callable) -> Dataset:
|
|
89
|
+
"""Filter items by predicate function."""
|
|
90
|
+
return Dataset([item for item in self.items if fn(item)])
|
|
91
|
+
|
|
92
|
+
def sample(self, n: int, seed: int | None = None) -> Dataset:
|
|
93
|
+
"""Random sample of n items."""
|
|
94
|
+
import random
|
|
95
|
+
if seed is not None:
|
|
96
|
+
random.seed(seed)
|
|
97
|
+
sampled = random.sample(self.items, min(n, len(self.items)))
|
|
98
|
+
return Dataset(sampled)
|
|
99
|
+
|
|
100
|
+
def to_list(self) -> list[dict[str, Any]]:
|
|
101
|
+
"""Convert to list of dicts."""
|
|
102
|
+
return self.items.copy()
|
evalwise/core/result.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Result types for evaluations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Status(Enum):
|
|
10
|
+
PASS = "pass"
|
|
11
|
+
FAIL = "fail"
|
|
12
|
+
ERROR = "error"
|
|
13
|
+
SKIP = "skip"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AssertionResult:
|
|
18
|
+
"""Result of a single assertion."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
status: Status
|
|
22
|
+
message: str | None = None
|
|
23
|
+
expected: Any = None
|
|
24
|
+
actual: Any = None
|
|
25
|
+
duration_ms: float = 0.0
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def passed(self) -> bool:
|
|
29
|
+
return self.status == Status.PASS
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
icon = "✓" if self.passed else "✗"
|
|
33
|
+
return f"{icon} {self.name}: {self.status.value}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class TestResult:
|
|
38
|
+
"""Result of a single test case."""
|
|
39
|
+
|
|
40
|
+
test_name: str
|
|
41
|
+
input_id: str | None = None
|
|
42
|
+
assertions: list[AssertionResult] = field(default_factory=list)
|
|
43
|
+
duration_ms: float = 0.0
|
|
44
|
+
error: str | None = None
|
|
45
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def status(self) -> Status:
|
|
49
|
+
if self.error:
|
|
50
|
+
return Status.ERROR
|
|
51
|
+
if not self.assertions:
|
|
52
|
+
return Status.SKIP
|
|
53
|
+
if all(a.passed for a in self.assertions):
|
|
54
|
+
return Status.PASS
|
|
55
|
+
return Status.FAIL
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def passed(self) -> bool:
|
|
59
|
+
return self.status == Status.PASS
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def pass_count(self) -> int:
|
|
63
|
+
return sum(1 for a in self.assertions if a.passed)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def fail_count(self) -> int:
|
|
67
|
+
return sum(1 for a in self.assertions if not a.passed)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class SuiteResult:
|
|
72
|
+
"""Result of running a full test suite."""
|
|
73
|
+
|
|
74
|
+
suite_name: str
|
|
75
|
+
tests: list[TestResult] = field(default_factory=list)
|
|
76
|
+
started_at: datetime = field(default_factory=datetime.now)
|
|
77
|
+
completed_at: datetime | None = None
|
|
78
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def total_tests(self) -> int:
|
|
82
|
+
return len(self.tests)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def passed_tests(self) -> int:
|
|
86
|
+
return sum(1 for t in self.tests if t.passed)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def failed_tests(self) -> int:
|
|
90
|
+
return sum(1 for t in self.tests if t.status == Status.FAIL)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def error_tests(self) -> int:
|
|
94
|
+
return sum(1 for t in self.tests if t.status == Status.ERROR)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def pass_rate(self) -> float:
|
|
98
|
+
if not self.tests:
|
|
99
|
+
return 0.0
|
|
100
|
+
return self.passed_tests / self.total_tests
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def duration_ms(self) -> float:
|
|
104
|
+
return sum(t.duration_ms for t in self.tests)
|
|
105
|
+
|
|
106
|
+
def assert_pass_rate(self, threshold: float = 0.95) -> None:
|
|
107
|
+
"""Raise AssertionError if pass rate is below threshold."""
|
|
108
|
+
if self.pass_rate < threshold:
|
|
109
|
+
raise AssertionError(
|
|
110
|
+
f"Pass rate {self.pass_rate:.1%} is below threshold {threshold:.1%}. "
|
|
111
|
+
f"({self.passed_tests}/{self.total_tests} tests passed)"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict[str, Any]:
|
|
115
|
+
"""Convert to dictionary for JSON serialization."""
|
|
116
|
+
return {
|
|
117
|
+
"suite_name": self.suite_name,
|
|
118
|
+
"pass_rate": self.pass_rate,
|
|
119
|
+
"total_tests": self.total_tests,
|
|
120
|
+
"passed": self.passed_tests,
|
|
121
|
+
"failed": self.failed_tests,
|
|
122
|
+
"errors": self.error_tests,
|
|
123
|
+
"duration_ms": self.duration_ms,
|
|
124
|
+
"started_at": self.started_at.isoformat(),
|
|
125
|
+
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
|
126
|
+
"tests": [
|
|
127
|
+
{
|
|
128
|
+
"name": t.test_name,
|
|
129
|
+
"input_id": t.input_id,
|
|
130
|
+
"status": t.status.value,
|
|
131
|
+
"assertions": [
|
|
132
|
+
{
|
|
133
|
+
"name": a.name,
|
|
134
|
+
"status": a.status.value,
|
|
135
|
+
"message": a.message,
|
|
136
|
+
}
|
|
137
|
+
for a in t.assertions
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
for t in self.tests
|
|
141
|
+
],
|
|
142
|
+
}
|
evalwise/core/suite.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Test suite definition and runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from evalwise.core.result import AssertionResult, Status, SuiteResult, TestResult
|
|
12
|
+
from evalwise.core.context import EvalContext, get_context, set_context
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TestCase:
|
|
17
|
+
"""A single test case definition."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
fn: Callable[..., None]
|
|
21
|
+
tags: list[str] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Suite:
|
|
25
|
+
"""
|
|
26
|
+
A collection of evaluation tests.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
suite = Suite("summarization")
|
|
30
|
+
|
|
31
|
+
@suite.test
|
|
32
|
+
def test_format(response, context):
|
|
33
|
+
Assert.bullet_count(response, exactly=3)
|
|
34
|
+
Assert.word_count(response, max=200)
|
|
35
|
+
|
|
36
|
+
results = suite.run(dataset="./golden.json")
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, name: str):
|
|
40
|
+
self.name = name
|
|
41
|
+
self.tests: list[TestCase] = []
|
|
42
|
+
self._setup_fn: Callable[[], None] | None = None
|
|
43
|
+
self._teardown_fn: Callable[[], None] | None = None
|
|
44
|
+
|
|
45
|
+
def test(
|
|
46
|
+
self,
|
|
47
|
+
fn: Callable[..., None] | None = None,
|
|
48
|
+
*,
|
|
49
|
+
tags: list[str] | None = None,
|
|
50
|
+
) -> Callable[..., None]:
|
|
51
|
+
"""
|
|
52
|
+
Decorator to register a test function.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
@suite.test
|
|
56
|
+
def test_something(response):
|
|
57
|
+
Assert.contains(response, "hello")
|
|
58
|
+
|
|
59
|
+
@suite.test(tags=["slow"])
|
|
60
|
+
def test_expensive(response):
|
|
61
|
+
Assert.llm_judge(response, "Is helpful")
|
|
62
|
+
"""
|
|
63
|
+
def decorator(f: Callable[..., None]) -> Callable[..., None]:
|
|
64
|
+
self.tests.append(TestCase(
|
|
65
|
+
name=f.__name__,
|
|
66
|
+
fn=f,
|
|
67
|
+
tags=tags or [],
|
|
68
|
+
))
|
|
69
|
+
return f
|
|
70
|
+
|
|
71
|
+
if fn is not None:
|
|
72
|
+
return decorator(fn)
|
|
73
|
+
return decorator
|
|
74
|
+
|
|
75
|
+
def setup(self, fn: Callable[[], None]) -> Callable[[], None]:
|
|
76
|
+
"""Register a setup function to run before all tests."""
|
|
77
|
+
self._setup_fn = fn
|
|
78
|
+
return fn
|
|
79
|
+
|
|
80
|
+
def teardown(self, fn: Callable[[], None]) -> Callable[[], None]:
|
|
81
|
+
"""Register a teardown function to run after all tests."""
|
|
82
|
+
self._teardown_fn = fn
|
|
83
|
+
return fn
|
|
84
|
+
|
|
85
|
+
def run(
|
|
86
|
+
self,
|
|
87
|
+
dataset: list[dict[str, Any]] | str | None = None,
|
|
88
|
+
*,
|
|
89
|
+
tags: list[str] | None = None,
|
|
90
|
+
exclude_tags: list[str] | None = None,
|
|
91
|
+
fail_fast: bool = False,
|
|
92
|
+
) -> SuiteResult:
|
|
93
|
+
"""
|
|
94
|
+
Run all tests in the suite.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
dataset: List of test inputs, or path to JSON/YAML file
|
|
98
|
+
tags: Only run tests with these tags
|
|
99
|
+
exclude_tags: Skip tests with these tags
|
|
100
|
+
fail_fast: Stop on first failure
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
SuiteResult with all test outcomes
|
|
104
|
+
"""
|
|
105
|
+
result = SuiteResult(
|
|
106
|
+
suite_name=self.name,
|
|
107
|
+
started_at=datetime.now(),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Load dataset if path provided
|
|
111
|
+
if isinstance(dataset, str):
|
|
112
|
+
dataset = self._load_dataset(dataset)
|
|
113
|
+
|
|
114
|
+
# Default to single empty input if no dataset
|
|
115
|
+
if not dataset:
|
|
116
|
+
dataset = [{}]
|
|
117
|
+
|
|
118
|
+
# Filter tests by tags
|
|
119
|
+
tests_to_run = self._filter_tests(tags, exclude_tags)
|
|
120
|
+
|
|
121
|
+
# Run setup
|
|
122
|
+
if self._setup_fn:
|
|
123
|
+
self._setup_fn()
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
for test_case in tests_to_run:
|
|
127
|
+
for i, input_data in enumerate(dataset):
|
|
128
|
+
test_result = self._run_single_test(
|
|
129
|
+
test_case,
|
|
130
|
+
input_data,
|
|
131
|
+
input_id=str(i),
|
|
132
|
+
)
|
|
133
|
+
result.tests.append(test_result)
|
|
134
|
+
|
|
135
|
+
if fail_fast and not test_result.passed:
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
if fail_fast and result.failed_tests > 0:
|
|
139
|
+
break
|
|
140
|
+
finally:
|
|
141
|
+
if self._teardown_fn:
|
|
142
|
+
self._teardown_fn()
|
|
143
|
+
|
|
144
|
+
result.completed_at = datetime.now()
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
def _run_single_test(
|
|
148
|
+
self,
|
|
149
|
+
test_case: TestCase,
|
|
150
|
+
input_data: dict[str, Any],
|
|
151
|
+
input_id: str,
|
|
152
|
+
) -> TestResult:
|
|
153
|
+
"""Run a single test case with given input."""
|
|
154
|
+
import inspect
|
|
155
|
+
|
|
156
|
+
context = EvalContext()
|
|
157
|
+
set_context(context)
|
|
158
|
+
|
|
159
|
+
start_time = time.perf_counter()
|
|
160
|
+
error: str | None = None
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
# Get function signature to filter kwargs
|
|
164
|
+
sig = inspect.signature(test_case.fn)
|
|
165
|
+
params = sig.parameters
|
|
166
|
+
|
|
167
|
+
# Check if function accepts **kwargs
|
|
168
|
+
has_var_keyword = any(
|
|
169
|
+
p.kind == inspect.Parameter.VAR_KEYWORD
|
|
170
|
+
for p in params.values()
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if has_var_keyword:
|
|
174
|
+
# Function accepts any kwargs
|
|
175
|
+
filtered_data = input_data
|
|
176
|
+
else:
|
|
177
|
+
# Only pass kwargs that match function parameters
|
|
178
|
+
filtered_data = {
|
|
179
|
+
k: v for k, v in input_data.items()
|
|
180
|
+
if k in params
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Call test function with filtered kwargs
|
|
184
|
+
test_case.fn(**filtered_data)
|
|
185
|
+
except AssertionError as e:
|
|
186
|
+
# Assertion failures are expected
|
|
187
|
+
pass
|
|
188
|
+
except Exception as e:
|
|
189
|
+
error = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
|
|
190
|
+
|
|
191
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
192
|
+
|
|
193
|
+
return TestResult(
|
|
194
|
+
test_name=test_case.name,
|
|
195
|
+
input_id=input_id,
|
|
196
|
+
assertions=context.assertions,
|
|
197
|
+
duration_ms=duration_ms,
|
|
198
|
+
error=error,
|
|
199
|
+
metadata=input_data,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _filter_tests(
|
|
203
|
+
self,
|
|
204
|
+
tags: list[str] | None,
|
|
205
|
+
exclude_tags: list[str] | None,
|
|
206
|
+
) -> list[TestCase]:
|
|
207
|
+
"""Filter tests by tags."""
|
|
208
|
+
result = self.tests
|
|
209
|
+
|
|
210
|
+
if tags:
|
|
211
|
+
result = [t for t in result if any(tag in t.tags for tag in tags)]
|
|
212
|
+
|
|
213
|
+
if exclude_tags:
|
|
214
|
+
result = [t for t in result if not any(tag in t.tags for tag in exclude_tags)]
|
|
215
|
+
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
def _load_dataset(self, path: str) -> list[dict[str, Any]]:
|
|
219
|
+
"""Load dataset from file."""
|
|
220
|
+
import json
|
|
221
|
+
from pathlib import Path
|
|
222
|
+
|
|
223
|
+
p = Path(path)
|
|
224
|
+
|
|
225
|
+
if p.suffix == ".json":
|
|
226
|
+
with open(p) as f:
|
|
227
|
+
data = json.load(f)
|
|
228
|
+
elif p.suffix in (".yaml", ".yml"):
|
|
229
|
+
try:
|
|
230
|
+
import yaml
|
|
231
|
+
with open(p) as f:
|
|
232
|
+
data = yaml.safe_load(f)
|
|
233
|
+
except ImportError:
|
|
234
|
+
raise ImportError("PyYAML required for YAML datasets: pip install pyyaml")
|
|
235
|
+
else:
|
|
236
|
+
raise ValueError(f"Unsupported dataset format: {p.suffix}")
|
|
237
|
+
|
|
238
|
+
if isinstance(data, list):
|
|
239
|
+
return data
|
|
240
|
+
elif isinstance(data, dict) and "tests" in data:
|
|
241
|
+
return data["tests"]
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError("Dataset must be a list or object with 'tests' key")
|