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 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,6 @@
1
+ """Allow running as `python -m evalwise`."""
2
+
3
+ from evalwise.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,5 @@
1
+ """Audio assertion module - deterministic checks for generated audio."""
2
+
3
+ from evalwise.audio.assertions import AudioAssert
4
+
5
+ __all__ = ["AudioAssert"]
@@ -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"]
@@ -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)