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/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EvalWise - Deterministic-first AI evaluation for text, image, audio, and video.
|
|
3
|
+
|
|
4
|
+
The eval SDK that doesn't default to "ask another AI if this is good."
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from evalwise.core.suite import Suite
|
|
8
|
+
from evalwise.core.result import TestResult, SuiteResult
|
|
9
|
+
from evalwise.core.dataset import Dataset
|
|
10
|
+
from evalwise.text import Assert as TextAssert
|
|
11
|
+
|
|
12
|
+
# Convenience alias
|
|
13
|
+
Assert = TextAssert
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Suite",
|
|
18
|
+
"TestResult",
|
|
19
|
+
"SuiteResult",
|
|
20
|
+
"Dataset",
|
|
21
|
+
"Assert",
|
|
22
|
+
"TextAssert",
|
|
23
|
+
]
|
evalwise/__main__.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audio assertions - deterministic checks for generated audio.
|
|
3
|
+
|
|
4
|
+
Uses Whisper for transcription, librosa for analysis.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from evalwise.core.context import get_context
|
|
16
|
+
from evalwise.core.result import AssertionResult, Status
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_FFMPEG_INSTALL_HELP = """\
|
|
20
|
+
ffmpeg is required by Whisper but was not found on your system.
|
|
21
|
+
|
|
22
|
+
Install it for your platform:
|
|
23
|
+
|
|
24
|
+
macOS: brew install ffmpeg
|
|
25
|
+
Ubuntu: sudo apt update && sudo apt install ffmpeg
|
|
26
|
+
Windows: winget install Gyan.FFmpeg
|
|
27
|
+
or download from https://ffmpeg.org/download.html
|
|
28
|
+
|
|
29
|
+
After installing, verify it is on your PATH:
|
|
30
|
+
|
|
31
|
+
ffmpeg -version
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_SSL_DOWNLOAD_HELP = """\
|
|
35
|
+
Failed to download the Whisper model because of an SSL certificate verification error.
|
|
36
|
+
|
|
37
|
+
This is usually a local environment issue, not a problem with EvalWise or Whisper.
|
|
38
|
+
|
|
39
|
+
Fix options:
|
|
40
|
+
|
|
41
|
+
1. Update certificates:
|
|
42
|
+
pip install --upgrade certifi
|
|
43
|
+
export SSL_CERT_FILE=$(python -m certifi)
|
|
44
|
+
|
|
45
|
+
2. Pre-download the model so no network call is needed:
|
|
46
|
+
Download the .pt file from https://github.com/openai/whisper/blob/main/model-card.md
|
|
47
|
+
and place it in ~/.cache/whisper/
|
|
48
|
+
|
|
49
|
+
3. (Local dev only) Disable verification:
|
|
50
|
+
export PYTHONHTTPSVERIFY=0
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@lru_cache(maxsize=None)
|
|
55
|
+
def _load_whisper_model(model_name: str):
|
|
56
|
+
"""Load a Whisper model, caching it to avoid repeated downloads."""
|
|
57
|
+
try:
|
|
58
|
+
import whisper
|
|
59
|
+
except ImportError:
|
|
60
|
+
raise ImportError("openai-whisper required: pip install openai-whisper")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
return whisper.load_model(model_name)
|
|
64
|
+
except urllib.error.URLError as exc:
|
|
65
|
+
error_msg = str(exc).lower()
|
|
66
|
+
if "certificate" in error_msg or "ssl" in error_msg:
|
|
67
|
+
raise RuntimeError(_SSL_DOWNLOAD_HELP) from exc
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _handle_whisper_call(exc: Exception) -> None:
|
|
72
|
+
"""Convert common Whisper runtime errors into actionable messages."""
|
|
73
|
+
if isinstance(exc, FileNotFoundError) and "ffmpeg" in str(exc):
|
|
74
|
+
raise RuntimeError(_FFMPEG_INSTALL_HELP) from exc
|
|
75
|
+
raise exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AudioAssert:
|
|
79
|
+
"""
|
|
80
|
+
Deterministic assertions for generated audio.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
from evalwise.audio import AudioAssert
|
|
84
|
+
|
|
85
|
+
AudioAssert.transcription_contains(audio, "hello world")
|
|
86
|
+
AudioAssert.duration_between(audio, min_sec=5, max_sec=30)
|
|
87
|
+
AudioAssert.language_is(audio, "en")
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# ==================== Transcription ====================
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def transcription_equals(
|
|
94
|
+
audio: str | Path,
|
|
95
|
+
expected: str,
|
|
96
|
+
*,
|
|
97
|
+
model: str = "base",
|
|
98
|
+
case_sensitive: bool = False,
|
|
99
|
+
) -> bool:
|
|
100
|
+
"""Assert audio transcription equals expected text."""
|
|
101
|
+
start = time.perf_counter()
|
|
102
|
+
|
|
103
|
+
model_obj = _load_whisper_model(model)
|
|
104
|
+
try:
|
|
105
|
+
result_whisper = model_obj.transcribe(str(audio))
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
_handle_whisper_call(exc)
|
|
108
|
+
transcript = result_whisper["text"].strip()
|
|
109
|
+
|
|
110
|
+
if case_sensitive:
|
|
111
|
+
passed = transcript == expected
|
|
112
|
+
else:
|
|
113
|
+
passed = transcript.lower() == expected.lower()
|
|
114
|
+
|
|
115
|
+
result = AssertionResult(
|
|
116
|
+
name="transcription_equals",
|
|
117
|
+
status=Status.PASS if passed else Status.FAIL,
|
|
118
|
+
message=f"Transcription mismatch" if not passed else None,
|
|
119
|
+
expected=expected,
|
|
120
|
+
actual=transcript,
|
|
121
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
122
|
+
)
|
|
123
|
+
get_context().add_assertion(result)
|
|
124
|
+
return passed
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def transcription_contains(
|
|
128
|
+
audio: str | Path,
|
|
129
|
+
substring: str,
|
|
130
|
+
*,
|
|
131
|
+
model: str = "base",
|
|
132
|
+
case_sensitive: bool = False,
|
|
133
|
+
) -> bool:
|
|
134
|
+
"""Assert audio transcription contains substring."""
|
|
135
|
+
start = time.perf_counter()
|
|
136
|
+
|
|
137
|
+
model_obj = _load_whisper_model(model)
|
|
138
|
+
try:
|
|
139
|
+
result_whisper = model_obj.transcribe(str(audio))
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
_handle_whisper_call(exc)
|
|
142
|
+
transcript = result_whisper["text"].strip()
|
|
143
|
+
|
|
144
|
+
if case_sensitive:
|
|
145
|
+
passed = substring in transcript
|
|
146
|
+
else:
|
|
147
|
+
passed = substring.lower() in transcript.lower()
|
|
148
|
+
|
|
149
|
+
result = AssertionResult(
|
|
150
|
+
name="transcription_contains",
|
|
151
|
+
status=Status.PASS if passed else Status.FAIL,
|
|
152
|
+
message=f"'{substring}' not in transcription" if not passed else None,
|
|
153
|
+
expected=f"contains '{substring}'",
|
|
154
|
+
actual=transcript[:200] + "..." if len(transcript) > 200 else transcript,
|
|
155
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
156
|
+
)
|
|
157
|
+
get_context().add_assertion(result)
|
|
158
|
+
return passed
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def language_is(audio: str | Path, expected_lang: str, *, model: str = "base") -> bool:
|
|
162
|
+
"""Assert detected language matches expected."""
|
|
163
|
+
start = time.perf_counter()
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
import whisper
|
|
167
|
+
except ImportError:
|
|
168
|
+
raise ImportError("openai-whisper required: pip install openai-whisper")
|
|
169
|
+
|
|
170
|
+
model_obj = _load_whisper_model(model)
|
|
171
|
+
|
|
172
|
+
# Load audio and detect language
|
|
173
|
+
try:
|
|
174
|
+
audio_data = whisper.load_audio(str(audio))
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
_handle_whisper_call(exc)
|
|
177
|
+
audio_data = whisper.pad_or_trim(audio_data)
|
|
178
|
+
mel = whisper.log_mel_spectrogram(audio_data).to(model_obj.device)
|
|
179
|
+
|
|
180
|
+
_, probs = model_obj.detect_language(mel)
|
|
181
|
+
detected = max(probs, key=probs.get)
|
|
182
|
+
|
|
183
|
+
passed = detected == expected_lang
|
|
184
|
+
|
|
185
|
+
result = AssertionResult(
|
|
186
|
+
name="language_is",
|
|
187
|
+
status=Status.PASS if passed else Status.FAIL,
|
|
188
|
+
message=f"Detected '{detected}', expected '{expected_lang}'" if not passed else None,
|
|
189
|
+
expected=expected_lang,
|
|
190
|
+
actual=detected,
|
|
191
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
192
|
+
)
|
|
193
|
+
get_context().add_assertion(result)
|
|
194
|
+
return passed
|
|
195
|
+
|
|
196
|
+
# ==================== Duration ====================
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _get_duration(audio: str | Path) -> float:
|
|
200
|
+
"""Get audio duration using available library."""
|
|
201
|
+
audio_path = str(audio)
|
|
202
|
+
|
|
203
|
+
# Try scipy first (more commonly available)
|
|
204
|
+
try:
|
|
205
|
+
from scipy.io import wavfile
|
|
206
|
+
if audio_path.endswith('.wav'):
|
|
207
|
+
sr, data = wavfile.read(audio_path)
|
|
208
|
+
return len(data) / sr
|
|
209
|
+
except ImportError:
|
|
210
|
+
pass
|
|
211
|
+
except Exception:
|
|
212
|
+
pass # Not a WAV or failed, try librosa
|
|
213
|
+
|
|
214
|
+
# Fall back to librosa
|
|
215
|
+
try:
|
|
216
|
+
import librosa
|
|
217
|
+
return librosa.get_duration(path=audio_path)
|
|
218
|
+
except ImportError:
|
|
219
|
+
raise ImportError("librosa or scipy required: pip install librosa (or scipy for WAV)")
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def duration_between(
|
|
223
|
+
audio: str | Path,
|
|
224
|
+
*,
|
|
225
|
+
min_sec: float | None = None,
|
|
226
|
+
max_sec: float | None = None,
|
|
227
|
+
) -> bool:
|
|
228
|
+
"""Assert audio duration is within bounds."""
|
|
229
|
+
start = time.perf_counter()
|
|
230
|
+
|
|
231
|
+
duration = AudioAssert._get_duration(audio)
|
|
232
|
+
|
|
233
|
+
if min_sec is not None and max_sec is not None:
|
|
234
|
+
passed = min_sec <= duration <= max_sec
|
|
235
|
+
expected = f"{min_sec}-{max_sec}s"
|
|
236
|
+
elif min_sec is not None:
|
|
237
|
+
passed = duration >= min_sec
|
|
238
|
+
expected = f">= {min_sec}s"
|
|
239
|
+
elif max_sec is not None:
|
|
240
|
+
passed = duration <= max_sec
|
|
241
|
+
expected = f"<= {max_sec}s"
|
|
242
|
+
else:
|
|
243
|
+
passed = True
|
|
244
|
+
expected = "any"
|
|
245
|
+
|
|
246
|
+
result = AssertionResult(
|
|
247
|
+
name="duration_between",
|
|
248
|
+
status=Status.PASS if passed else Status.FAIL,
|
|
249
|
+
message=f"Duration {duration:.1f}s not in range {expected}" if not passed else None,
|
|
250
|
+
expected=expected,
|
|
251
|
+
actual=f"{duration:.1f}s",
|
|
252
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
253
|
+
)
|
|
254
|
+
get_context().add_assertion(result)
|
|
255
|
+
return passed
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def duration_is(audio: str | Path, *, seconds: float, tolerance: float = 0.5) -> bool:
|
|
259
|
+
"""Assert audio duration matches expected."""
|
|
260
|
+
start = time.perf_counter()
|
|
261
|
+
|
|
262
|
+
duration = AudioAssert._get_duration(audio)
|
|
263
|
+
passed = abs(duration - seconds) <= tolerance
|
|
264
|
+
|
|
265
|
+
result = AssertionResult(
|
|
266
|
+
name="duration_is",
|
|
267
|
+
status=Status.PASS if passed else Status.FAIL,
|
|
268
|
+
message=f"Duration {duration:.1f}s != {seconds}s (±{tolerance})" if not passed else None,
|
|
269
|
+
expected=f"{seconds}s (±{tolerance})",
|
|
270
|
+
actual=f"{duration:.1f}s",
|
|
271
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
272
|
+
)
|
|
273
|
+
get_context().add_assertion(result)
|
|
274
|
+
return passed
|
|
275
|
+
|
|
276
|
+
# ==================== Quality ====================
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def sample_rate_is(audio: str | Path, *, hz: int) -> bool:
|
|
280
|
+
"""Assert audio sample rate."""
|
|
281
|
+
start = time.perf_counter()
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
import librosa
|
|
285
|
+
except ImportError:
|
|
286
|
+
raise ImportError("librosa required: pip install librosa")
|
|
287
|
+
|
|
288
|
+
_, sr = librosa.load(str(audio), sr=None)
|
|
289
|
+
passed = sr == hz
|
|
290
|
+
|
|
291
|
+
result = AssertionResult(
|
|
292
|
+
name="sample_rate_is",
|
|
293
|
+
status=Status.PASS if passed else Status.FAIL,
|
|
294
|
+
message=f"Sample rate {sr}Hz != {hz}Hz" if not passed else None,
|
|
295
|
+
expected=f"{hz}Hz",
|
|
296
|
+
actual=f"{sr}Hz",
|
|
297
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
298
|
+
)
|
|
299
|
+
get_context().add_assertion(result)
|
|
300
|
+
return passed
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def no_silence(audio: str | Path, *, max_silence_sec: float = 1.0) -> bool:
|
|
304
|
+
"""Assert no long silence gaps in audio."""
|
|
305
|
+
start = time.perf_counter()
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
import librosa
|
|
309
|
+
import numpy as np
|
|
310
|
+
except ImportError:
|
|
311
|
+
raise ImportError("librosa required: pip install librosa")
|
|
312
|
+
|
|
313
|
+
y, sr = librosa.load(str(audio))
|
|
314
|
+
|
|
315
|
+
# Find non-silent intervals
|
|
316
|
+
intervals = librosa.effects.split(y, top_db=30)
|
|
317
|
+
|
|
318
|
+
# Check gaps between intervals
|
|
319
|
+
max_gap = 0.0
|
|
320
|
+
for i in range(1, len(intervals)):
|
|
321
|
+
gap = (intervals[i][0] - intervals[i-1][1]) / sr
|
|
322
|
+
max_gap = max(max_gap, gap)
|
|
323
|
+
|
|
324
|
+
passed = max_gap <= max_silence_sec
|
|
325
|
+
|
|
326
|
+
result = AssertionResult(
|
|
327
|
+
name="no_silence",
|
|
328
|
+
status=Status.PASS if passed else Status.FAIL,
|
|
329
|
+
message=f"Max silence gap {max_gap:.1f}s > {max_silence_sec}s" if not passed else None,
|
|
330
|
+
expected=f"<= {max_silence_sec}s silence",
|
|
331
|
+
actual=f"{max_gap:.1f}s max gap",
|
|
332
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
333
|
+
)
|
|
334
|
+
get_context().add_assertion(result)
|
|
335
|
+
return passed
|
evalwise/cli.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Command-line interface for evalwise."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
@click.version_option()
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""EvalWise - Deterministic-first AI evaluation."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@main.command()
|
|
25
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
26
|
+
@click.option("--dataset", "-d", type=click.Path(exists=True), help="Dataset file (JSON/YAML)")
|
|
27
|
+
@click.option("--threshold", "-t", type=float, default=0.95, help="Pass rate threshold")
|
|
28
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file for results (JSON)")
|
|
29
|
+
@click.option("--fail-fast", is_flag=True, help="Stop on first failure")
|
|
30
|
+
@click.option("--ci", is_flag=True, help="CI mode: exit code 1 on failure")
|
|
31
|
+
@click.option("--tags", help="Only run tests with these tags (comma-separated)")
|
|
32
|
+
@click.option("--exclude-tags", help="Skip tests with these tags (comma-separated)")
|
|
33
|
+
def run(
|
|
34
|
+
path: str,
|
|
35
|
+
dataset: str | None,
|
|
36
|
+
threshold: float,
|
|
37
|
+
output: str | None,
|
|
38
|
+
fail_fast: bool,
|
|
39
|
+
ci: bool,
|
|
40
|
+
tags: str | None,
|
|
41
|
+
exclude_tags: str | None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Run evaluation suite.
|
|
45
|
+
|
|
46
|
+
PATH can be a Python file containing a Suite, or a directory of test files.
|
|
47
|
+
"""
|
|
48
|
+
from evalwise.core.suite import Suite
|
|
49
|
+
|
|
50
|
+
path_obj = Path(path)
|
|
51
|
+
|
|
52
|
+
# Find and load suites
|
|
53
|
+
suites: list[Suite] = []
|
|
54
|
+
|
|
55
|
+
if path_obj.is_file():
|
|
56
|
+
suites.extend(_load_suites_from_file(path_obj))
|
|
57
|
+
elif path_obj.is_dir():
|
|
58
|
+
for f in path_obj.glob("**/*.py"):
|
|
59
|
+
if f.name.startswith("test_") or f.name.endswith("_test.py"):
|
|
60
|
+
suites.extend(_load_suites_from_file(f))
|
|
61
|
+
|
|
62
|
+
if not suites:
|
|
63
|
+
console.print("[red]No test suites found[/red]")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
# Load dataset if provided
|
|
67
|
+
dataset_items = None
|
|
68
|
+
if dataset:
|
|
69
|
+
dataset_items = _load_dataset(dataset)
|
|
70
|
+
|
|
71
|
+
# Parse tags
|
|
72
|
+
tags_list = [t.strip() for t in tags.split(",")] if tags else None
|
|
73
|
+
exclude_tags_list = [t.strip() for t in exclude_tags.split(",")] if exclude_tags else None
|
|
74
|
+
|
|
75
|
+
# Run all suites
|
|
76
|
+
all_passed = True
|
|
77
|
+
all_results = []
|
|
78
|
+
|
|
79
|
+
for suite in suites:
|
|
80
|
+
console.print(f"\n[bold]Running suite: {suite.name}[/bold]")
|
|
81
|
+
|
|
82
|
+
result = suite.run(
|
|
83
|
+
dataset=dataset_items,
|
|
84
|
+
fail_fast=fail_fast,
|
|
85
|
+
tags=tags_list,
|
|
86
|
+
exclude_tags=exclude_tags_list,
|
|
87
|
+
)
|
|
88
|
+
all_results.append(result)
|
|
89
|
+
|
|
90
|
+
# Print results
|
|
91
|
+
_print_results(result)
|
|
92
|
+
|
|
93
|
+
if result.pass_rate < threshold:
|
|
94
|
+
all_passed = False
|
|
95
|
+
console.print(
|
|
96
|
+
f"[red]✗ Pass rate {result.pass_rate:.1%} below threshold {threshold:.1%}[/red]"
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
console.print(
|
|
100
|
+
f"[green]✓ Pass rate {result.pass_rate:.1%} meets threshold {threshold:.1%}[/green]"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Save output if requested
|
|
104
|
+
if output:
|
|
105
|
+
output_data = [r.to_dict() for r in all_results]
|
|
106
|
+
with open(output, "w") as f:
|
|
107
|
+
json.dump(output_data, f, indent=2)
|
|
108
|
+
console.print(f"\nResults saved to {output}")
|
|
109
|
+
|
|
110
|
+
# Exit code for CI
|
|
111
|
+
if ci and not all_passed:
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _load_suites_from_file(path: Path) -> list:
|
|
116
|
+
"""Load Suite objects from a Python file."""
|
|
117
|
+
from evalwise.core.suite import Suite
|
|
118
|
+
|
|
119
|
+
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
120
|
+
if spec is None or spec.loader is None:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
module = importlib.util.module_from_spec(spec)
|
|
124
|
+
sys.modules[path.stem] = module
|
|
125
|
+
spec.loader.exec_module(module)
|
|
126
|
+
|
|
127
|
+
suites = []
|
|
128
|
+
for name in dir(module):
|
|
129
|
+
obj = getattr(module, name)
|
|
130
|
+
if isinstance(obj, Suite):
|
|
131
|
+
suites.append(obj)
|
|
132
|
+
|
|
133
|
+
return suites
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _load_dataset(path: str) -> list:
|
|
137
|
+
"""Load dataset from file."""
|
|
138
|
+
p = Path(path)
|
|
139
|
+
|
|
140
|
+
if p.suffix == ".json":
|
|
141
|
+
with open(p) as f:
|
|
142
|
+
data = json.load(f)
|
|
143
|
+
elif p.suffix in (".yaml", ".yml"):
|
|
144
|
+
import yaml
|
|
145
|
+
with open(p) as f:
|
|
146
|
+
data = yaml.safe_load(f)
|
|
147
|
+
elif p.suffix == ".jsonl":
|
|
148
|
+
data = []
|
|
149
|
+
with open(p) as f:
|
|
150
|
+
for line in f:
|
|
151
|
+
if line.strip():
|
|
152
|
+
data.append(json.loads(line))
|
|
153
|
+
else:
|
|
154
|
+
raise ValueError(f"Unsupported format: {p.suffix}")
|
|
155
|
+
|
|
156
|
+
if isinstance(data, list):
|
|
157
|
+
return data
|
|
158
|
+
elif isinstance(data, dict) and "tests" in data:
|
|
159
|
+
return data["tests"]
|
|
160
|
+
else:
|
|
161
|
+
raise ValueError("Dataset must be a list or object with 'tests' key")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _print_results(result) -> None:
|
|
165
|
+
"""Print suite results as a table."""
|
|
166
|
+
from evalwise.core.result import Status
|
|
167
|
+
|
|
168
|
+
# Group by test name
|
|
169
|
+
test_groups: dict[str, list] = {}
|
|
170
|
+
for t in result.tests:
|
|
171
|
+
if t.test_name not in test_groups:
|
|
172
|
+
test_groups[t.test_name] = []
|
|
173
|
+
test_groups[t.test_name].append(t)
|
|
174
|
+
|
|
175
|
+
for test_name, tests in test_groups.items():
|
|
176
|
+
passed = sum(1 for t in tests if t.passed)
|
|
177
|
+
total = len(tests)
|
|
178
|
+
|
|
179
|
+
# Check for errors (exceptions during test)
|
|
180
|
+
errors = [t for t in tests if t.error]
|
|
181
|
+
if errors:
|
|
182
|
+
console.print(f"\n[red]✗ {test_name}: {len(errors)}/{total} had errors[/red]")
|
|
183
|
+
# Show first error as example
|
|
184
|
+
console.print(f"[dim] Example error: {errors[0].error.split(chr(10))[0]}[/dim]")
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# Collect assertion stats
|
|
188
|
+
assertion_stats: dict[str, tuple[int, int]] = {}
|
|
189
|
+
for t in tests:
|
|
190
|
+
for a in t.assertions:
|
|
191
|
+
if a.name not in assertion_stats:
|
|
192
|
+
assertion_stats[a.name] = (0, 0)
|
|
193
|
+
p, tot = assertion_stats[a.name]
|
|
194
|
+
assertion_stats[a.name] = (p + (1 if a.passed else 0), tot + 1)
|
|
195
|
+
|
|
196
|
+
if not assertion_stats:
|
|
197
|
+
console.print(f"\n[yellow]⚠ {test_name}: No assertions recorded[/yellow]")
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
table = Table(title=f"{test_name} ({passed}/{total} passed)")
|
|
201
|
+
table.add_column("Assertion")
|
|
202
|
+
table.add_column("Passed", justify="right")
|
|
203
|
+
table.add_column("Total", justify="right")
|
|
204
|
+
table.add_column("Rate", justify="right")
|
|
205
|
+
|
|
206
|
+
for name, (p, tot) in assertion_stats.items():
|
|
207
|
+
rate = p / tot if tot > 0 else 0
|
|
208
|
+
color = "green" if rate >= 0.95 else "yellow" if rate >= 0.8 else "red"
|
|
209
|
+
table.add_row(name, str(p), str(tot), f"[{color}]{rate:.1%}[/{color}]")
|
|
210
|
+
|
|
211
|
+
console.print(table)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@main.command()
|
|
215
|
+
def init() -> None:
|
|
216
|
+
"""Create a sample eval file."""
|
|
217
|
+
sample = '''"""Sample evaluation suite."""
|
|
218
|
+
|
|
219
|
+
from evalwise import Suite, Assert
|
|
220
|
+
|
|
221
|
+
suite = Suite("my_evals")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@suite.test
|
|
225
|
+
def test_format(response: str):
|
|
226
|
+
"""Test response format."""
|
|
227
|
+
Assert.word_count(response, min=10, max=500)
|
|
228
|
+
Assert.json_valid(response)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@suite.test
|
|
232
|
+
def test_content(response: str, expected: str):
|
|
233
|
+
"""Test response content."""
|
|
234
|
+
Assert.contains(response, expected)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Run with: evalwise run test_eval.py --dataset golden.json
|
|
238
|
+
'''
|
|
239
|
+
|
|
240
|
+
with open("test_eval.py", "w") as f:
|
|
241
|
+
f.write(sample)
|
|
242
|
+
|
|
243
|
+
console.print("[green]Created test_eval.py[/green]")
|
|
244
|
+
console.print("\nNext steps:")
|
|
245
|
+
console.print(" 1. Edit test_eval.py to add your assertions")
|
|
246
|
+
console.print(" 2. Create a dataset file (golden.json)")
|
|
247
|
+
console.print(" 3. Run: evalwise run test_eval.py --dataset golden.json")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Core evaluation framework."""
|
|
2
|
+
|
|
3
|
+
from evalwise.core.suite import Suite
|
|
4
|
+
from evalwise.core.result import TestResult, SuiteResult, AssertionResult
|
|
5
|
+
from evalwise.core.dataset import Dataset
|
|
6
|
+
|
|
7
|
+
__all__ = ["Suite", "TestResult", "SuiteResult", "AssertionResult", "Dataset"]
|
evalwise/core/context.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Evaluation context for collecting assertion results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from evalwise.core.result import AssertionResult
|
|
9
|
+
|
|
10
|
+
# Context variable to hold the current eval context
|
|
11
|
+
_current_context: contextvars.ContextVar[EvalContext | None] = contextvars.ContextVar(
|
|
12
|
+
"eval_context", default=None
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class EvalContext:
|
|
18
|
+
"""
|
|
19
|
+
Context object that collects assertion results during a test run.
|
|
20
|
+
|
|
21
|
+
Assertions register their results here so the test runner can collect them.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
assertions: list[AssertionResult] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
def add_assertion(self, result: AssertionResult) -> None:
|
|
27
|
+
"""Add an assertion result to this context."""
|
|
28
|
+
self.assertions.append(result)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_context() -> EvalContext:
|
|
32
|
+
"""Get the current evaluation context, creating one if needed."""
|
|
33
|
+
ctx = _current_context.get()
|
|
34
|
+
if ctx is None:
|
|
35
|
+
ctx = EvalContext()
|
|
36
|
+
_current_context.set(ctx)
|
|
37
|
+
return ctx
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def set_context(ctx: EvalContext) -> None:
|
|
41
|
+
"""Set the current evaluation context."""
|
|
42
|
+
_current_context.set(ctx)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def clear_context() -> None:
|
|
46
|
+
"""Clear the current evaluation context."""
|
|
47
|
+
_current_context.set(None)
|