evalquill 0.1.0__tar.gz
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.
- evalquill-0.1.0/LICENSE +21 -0
- evalquill-0.1.0/PKG-INFO +256 -0
- evalquill-0.1.0/README.md +234 -0
- evalquill-0.1.0/pyproject.toml +45 -0
- evalquill-0.1.0/pyproject.toml.orig +45 -0
- evalquill-0.1.0/src/evalquill/__init__.py +166 -0
- evalquill-0.1.0/src/evalquill/compare.py +104 -0
- evalquill-0.1.0/src/evalquill/llm.py +10 -0
- evalquill-0.1.0/src/evalquill/metrics.py +10 -0
- evalquill-0.1.0/src/evalquill/providers.py +109 -0
- evalquill-0.1.0/src/evalquill/storage.py +101 -0
- evalquill-0.1.0/src/evalquill/validation.py +52 -0
evalquill-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Priteesh Karra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
evalquill-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: evalquill
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Compare two LLM evaluation runs case by case to see which cases a prompt change broke
|
|
5
|
+
Keywords: llm,evaluation,prompt-engineering,testing
|
|
6
|
+
Author: Priteesh Karra
|
|
7
|
+
Author-email: Priteesh Karra <WritetoPriteesh@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Testing
|
|
14
|
+
Requires-Dist: google-genai>=2.24.0 ; extra == 'gemini'
|
|
15
|
+
Requires-Dist: openai>=1.0 ; extra == 'openai'
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Project-URL: Homepage, https://github.com/priteesshhh/evalquill
|
|
18
|
+
Project-URL: Issues, https://github.com/priteesshhh/evalquill/issues
|
|
19
|
+
Provides-Extra: gemini
|
|
20
|
+
Provides-Extra: openai
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# evalquill
|
|
24
|
+
|
|
25
|
+
[](https://github.com/priteesshhh/evalquill/actions/workflows/ci.yml)
|
|
26
|
+
|
|
27
|
+
A small Python library for checking what changed when you edit an LLM prompt.
|
|
28
|
+
|
|
29
|
+
You change a prompt, rerun your evals, and the average score moves from 0.72 to 0.75. Did it get better? Maybe. It might also have fixed four cases and broken two that used to work. The average won't tell you that.
|
|
30
|
+
|
|
31
|
+
evalquill compares two runs case by case and tells you which specific cases got worse.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
Requires Python 3.10 or later. The core has no runtime dependencies.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install evalquill
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The OpenAI and Gemini adapters are optional extras:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install "evalquill[openai]"
|
|
45
|
+
pip install "evalquill[gemini]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
To work on it locally:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/priteesshhh/evalquill.git
|
|
52
|
+
cd evalquill
|
|
53
|
+
uv sync
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
A metric is a function `(response, expected) -> float` that returns a score from 0 to 1. An LLM is any function `(prompt) -> str`: a provider client, a wrapper around your own model, or a stub.
|
|
59
|
+
|
|
60
|
+
This example classifies support tickets. Two stub functions stand in for the same model under two versions of a prompt, so it runs without an API key.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from evalquill import evaluate_dataset
|
|
64
|
+
from evalquill.metrics import normalized_exact_match
|
|
65
|
+
from evalquill.compare import compare, diff_summary
|
|
66
|
+
|
|
67
|
+
dataset = [
|
|
68
|
+
{"id": "refund", "prompt": "I was charged twice this month.", "expected": "billing"},
|
|
69
|
+
{"id": "login", "prompt": "The login page shows a 500 error.", "expected": "account"},
|
|
70
|
+
{"id": "invite", "prompt": "My teammate never got the invite email.", "expected": "account"},
|
|
71
|
+
{"id": "webhook", "prompt": "Our webhook stopped receiving events.", "expected": "integration"},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
def llm_v1(prompt):
|
|
75
|
+
return {
|
|
76
|
+
"I was charged twice this month.": "billing",
|
|
77
|
+
"The login page shows a 500 error.": "bug",
|
|
78
|
+
"My teammate never got the invite email.": "account",
|
|
79
|
+
"Our webhook stopped receiving events.": "bug",
|
|
80
|
+
}[prompt]
|
|
81
|
+
|
|
82
|
+
def llm_v2(prompt):
|
|
83
|
+
return {
|
|
84
|
+
"I was charged twice this month.": "billing",
|
|
85
|
+
"The login page shows a 500 error.": "account",
|
|
86
|
+
"My teammate never got the invite email.": "integration",
|
|
87
|
+
"Our webhook stopped receiving events.": "integration",
|
|
88
|
+
}[prompt]
|
|
89
|
+
|
|
90
|
+
baseline = evaluate_dataset(dataset, llm_v1, [normalized_exact_match])
|
|
91
|
+
candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
|
|
92
|
+
|
|
93
|
+
summary = diff_summary(compare(baseline, candidate))
|
|
94
|
+
print(summary["normalized_exact_match"])
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
{'baseline_mean': 0.5, 'candidate_mean': 0.75, 'mean_delta': 0.25, 'improved': ['login', 'webhook'], 'regressed': ['invite'], 'newly_failing': ['invite']}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Accuracy rose from 0.50 to 0.75. Two cases improved, and `invite`, which v1 classified correctly, v2 now gets wrong. The average alone would not have shown that.
|
|
102
|
+
|
|
103
|
+
Each result row keeps the input, the response and its scores:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
{"id": "invite", "prompt": "My teammate never got the invite email.",
|
|
107
|
+
"expected": "account", "response": "integration",
|
|
108
|
+
"scores": {"normalized_exact_match": 0.0}}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`examples/prompt_change_demo.py` runs the same comparison through saved runs and prints the responses behind each regression.
|
|
112
|
+
|
|
113
|
+
## Failing a build
|
|
114
|
+
|
|
115
|
+
A regression check belongs in your test suite. Continuing from the quickstart, with `dataset` and `llm_v2` imported from wherever your project defines them:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from evalquill import evaluate_dataset, summary_scores, threshold_check
|
|
119
|
+
from evalquill.compare import compare, diff_summary
|
|
120
|
+
from evalquill.metrics import normalized_exact_match
|
|
121
|
+
from evalquill.storage import load_run
|
|
122
|
+
|
|
123
|
+
def test_accuracy_meets_threshold():
|
|
124
|
+
candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
|
|
125
|
+
checks = threshold_check(summary_scores(candidate), {"normalized_exact_match": 0.7})
|
|
126
|
+
assert checks == {"normalized_exact_match": "PASS"}
|
|
127
|
+
|
|
128
|
+
def test_no_case_stops_passing():
|
|
129
|
+
baseline = load_run("runs/baseline.json")["results"]
|
|
130
|
+
candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
|
|
131
|
+
summary = diff_summary(compare(baseline, candidate))["normalized_exact_match"]
|
|
132
|
+
assert summary["newly_failing"] == [], f"stopped passing: {summary['newly_failing']}"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The baseline is a saved run (see Saving runs below). With the quickstart's v2 prompt, the first test passes, since 0.75 clears 0.7, and the second fails:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
AssertionError: stopped passing: ['invite']
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The accuracy bar was met and a case still broke. A threshold alone lets that through.
|
|
142
|
+
|
|
143
|
+
`threshold_check` returns the strings `"PASS"`, `"FAIL"` and `"NOT RUN"`, so compare them explicitly. `assert all(checks.values())` would pass on `"FAIL"`, because every non-empty string is truthy.
|
|
144
|
+
|
|
145
|
+
## Saving runs
|
|
146
|
+
|
|
147
|
+
Save a run once and compare against it later, without paying for the same model calls again:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from evalquill.storage import save_run, load_run
|
|
151
|
+
|
|
152
|
+
save_run(baseline, "runs/baseline.json", metadata={"prompt_version": "v1"})
|
|
153
|
+
stored = load_run("runs/baseline.json")
|
|
154
|
+
compare(stored["results"], candidate)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Metadata is free-form. Record the model, its settings and the prompt version; that is what makes a saved run interpretable weeks later.
|
|
158
|
+
|
|
159
|
+
## Writing a metric
|
|
160
|
+
|
|
161
|
+
Any named function works. This one checks a field in a JSON response:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
import json
|
|
165
|
+
from evalquill import evaluate
|
|
166
|
+
|
|
167
|
+
def json_label(response: str, expected: str) -> float:
|
|
168
|
+
try:
|
|
169
|
+
return 1.0 if json.loads(response).get("label") == expected else 0.0
|
|
170
|
+
except (json.JSONDecodeError, AttributeError):
|
|
171
|
+
return 0.0
|
|
172
|
+
|
|
173
|
+
print(evaluate(expected="billing", response='{"label": "billing"}', metrics=[json_label]))
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
{'json_label': 1.0}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The rules: the function's name is the score's key and must be unique within a run. The return value must be a finite number from 0 to 1; booleans are rejected. If a metric raises, the run stops and the error names the case.
|
|
181
|
+
|
|
182
|
+
## Using a real model
|
|
183
|
+
|
|
184
|
+
Wrap your model in a function that takes a prompt and returns a string. For a self-hosted model, that is usually a few lines around your inference client.
|
|
185
|
+
|
|
186
|
+
For OpenAI and Gemini there are adapters. Install the matching extra; the key is read from `OPENAI_API_KEY` or `GEMINI_API_KEY`:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from evalquill.providers import openai_llm
|
|
190
|
+
|
|
191
|
+
llm, metadata = openai_llm(
|
|
192
|
+
"gpt-4o-mini",
|
|
193
|
+
system_prompt="Classify the support ticket as billing, account, integration, feature, bug or other. Reply with one word.",
|
|
194
|
+
temperature=0.0,
|
|
195
|
+
)
|
|
196
|
+
results = evaluate_dataset(dataset, llm, [normalized_exact_match])
|
|
197
|
+
save_run(results, "runs/gpt-4o-mini_v2.json", metadata=metadata)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`gemini_llm` works the same way. The returned `metadata` records the provider, model, temperature and system prompt, so a saved run says what produced it. The adapters add no retry logic of their own: the provider SDK's default retries apply, and any error that remains stops the run and names the case.
|
|
201
|
+
|
|
202
|
+
## Why case IDs matter
|
|
203
|
+
|
|
204
|
+
Comparisons join on case ID, not list position. If you reorder your dataset or insert a case, a positional comparison silently diffs unrelated rows and gives you a confident wrong answer.
|
|
205
|
+
|
|
206
|
+
IDs default to `case_0`, `case_1` and so on if you don't supply them. That's fine for a one-off run, but give real IDs to anything you'll compare across versions.
|
|
207
|
+
|
|
208
|
+
Two runs are rejected rather than compared if they cover different cases, use different metrics, or if any case's input or expected answer differs between them. Only the response and its scores may change.
|
|
209
|
+
|
|
210
|
+
## Case study
|
|
211
|
+
|
|
212
|
+
[case_study/](https://github.com/priteesshhh/evalquill/tree/v0.1.0/case_study) contains a worked example: 50 fictional support tickets, hand-labeled against a written guide, classified by gpt-4o-mini under two prompt versions. Results are reported on a frozen 20-ticket held-out set, with three repeats per version on that set.
|
|
213
|
+
|
|
214
|
+
Accuracy rose from 80% to 95% on the held-out set, and one previously correct ticket failed in all three observed repeats. The aggregate score does not surface that; the per-case comparison does.
|
|
215
|
+
|
|
216
|
+
All runs are saved. `case_study/rescore.py` recomputes every stored score from the saved responses, with no API key. See [case_study/README.md](https://github.com/priteesshhh/evalquill/blob/v0.1.0/case_study/README.md) for method, results and limitations.
|
|
217
|
+
|
|
218
|
+
## What it doesn't do
|
|
219
|
+
|
|
220
|
+
- No semantic or model-graded metrics. The built-ins are string comparisons, named for what they check: `normalized_exact_match` ignores case and surrounding whitespace, and `contains_substring` checks for a substring and nothing more. `contains_substring("19", "9")` returns 1.0, and a correct answer in a different form, such as "nine" for "9", scores 0.0.
|
|
221
|
+
- No RAG metrics. Metrics receive only the response and the expected value, so anything needing retrieved context doesn't fit the signature yet.
|
|
222
|
+
- No concurrency. Model calls are made one at a time.
|
|
223
|
+
- No repeat-run tooling in the library. The case study has scripts for its own repeats.
|
|
224
|
+
- No dashboard, no hosted anything.
|
|
225
|
+
|
|
226
|
+
This isn't the only tool in this space. promptfoo, DeepEval, LangSmith and Braintrust all overlap with it and most do more.
|
|
227
|
+
|
|
228
|
+
## Design notes
|
|
229
|
+
|
|
230
|
+
Scores are stored at full precision and rounded only for display. Rounding before a threshold check lets a true score of 0.7993 pass a 0.80 threshold, a silent false pass. There's a regression test for it.
|
|
231
|
+
|
|
232
|
+
Metrics must return a finite number between 0 and 1. Booleans are rejected explicitly: `True == 1.0` in Python, so a metric returning booleans would otherwise pass validation while suggesting its author didn't mean to return a score.
|
|
233
|
+
|
|
234
|
+
Duplicate metric names and duplicate case IDs are rejected. Both silently overwrite results otherwise. A missing score is rejected rather than averaged as zero.
|
|
235
|
+
|
|
236
|
+
A comparison requires each case's input and expected answer to be identical in both runs. Otherwise a changed reference answer would show up as a model improvement.
|
|
237
|
+
|
|
238
|
+
The whole dataset is validated before the first model call, so a bad row near the end doesn't cost every call before it. When a model call or a metric fails, the run stops and the error names the case. Recording 0.0 instead would make a broken metric look like a failing model.
|
|
239
|
+
|
|
240
|
+
Saves are atomic: a failed save leaves any previous file intact. Loading validates the whole file, not just its schema version.
|
|
241
|
+
|
|
242
|
+
## Tests
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
uv run pytest
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`tests/test_readme.py` runs every Python example in this README and checks the output shown, so the examples can't drift from the code. Live tests against real APIs are opt-in: `uv run pytest -m live`, with the provider keys set.
|
|
249
|
+
|
|
250
|
+
## Status
|
|
251
|
+
|
|
252
|
+
Early. 0.1.0 is the first release; the API may change in later 0.x releases.
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
MIT
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# evalquill
|
|
2
|
+
|
|
3
|
+
[](https://github.com/priteesshhh/evalquill/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A small Python library for checking what changed when you edit an LLM prompt.
|
|
6
|
+
|
|
7
|
+
You change a prompt, rerun your evals, and the average score moves from 0.72 to 0.75. Did it get better? Maybe. It might also have fixed four cases and broken two that used to work. The average won't tell you that.
|
|
8
|
+
|
|
9
|
+
evalquill compares two runs case by case and tells you which specific cases got worse.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Requires Python 3.10 or later. The core has no runtime dependencies.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install evalquill
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The OpenAI and Gemini adapters are optional extras:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install "evalquill[openai]"
|
|
23
|
+
pip install "evalquill[gemini]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
To work on it locally:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/priteesshhh/evalquill.git
|
|
30
|
+
cd evalquill
|
|
31
|
+
uv sync
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
A metric is a function `(response, expected) -> float` that returns a score from 0 to 1. An LLM is any function `(prompt) -> str`: a provider client, a wrapper around your own model, or a stub.
|
|
37
|
+
|
|
38
|
+
This example classifies support tickets. Two stub functions stand in for the same model under two versions of a prompt, so it runs without an API key.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from evalquill import evaluate_dataset
|
|
42
|
+
from evalquill.metrics import normalized_exact_match
|
|
43
|
+
from evalquill.compare import compare, diff_summary
|
|
44
|
+
|
|
45
|
+
dataset = [
|
|
46
|
+
{"id": "refund", "prompt": "I was charged twice this month.", "expected": "billing"},
|
|
47
|
+
{"id": "login", "prompt": "The login page shows a 500 error.", "expected": "account"},
|
|
48
|
+
{"id": "invite", "prompt": "My teammate never got the invite email.", "expected": "account"},
|
|
49
|
+
{"id": "webhook", "prompt": "Our webhook stopped receiving events.", "expected": "integration"},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
def llm_v1(prompt):
|
|
53
|
+
return {
|
|
54
|
+
"I was charged twice this month.": "billing",
|
|
55
|
+
"The login page shows a 500 error.": "bug",
|
|
56
|
+
"My teammate never got the invite email.": "account",
|
|
57
|
+
"Our webhook stopped receiving events.": "bug",
|
|
58
|
+
}[prompt]
|
|
59
|
+
|
|
60
|
+
def llm_v2(prompt):
|
|
61
|
+
return {
|
|
62
|
+
"I was charged twice this month.": "billing",
|
|
63
|
+
"The login page shows a 500 error.": "account",
|
|
64
|
+
"My teammate never got the invite email.": "integration",
|
|
65
|
+
"Our webhook stopped receiving events.": "integration",
|
|
66
|
+
}[prompt]
|
|
67
|
+
|
|
68
|
+
baseline = evaluate_dataset(dataset, llm_v1, [normalized_exact_match])
|
|
69
|
+
candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
|
|
70
|
+
|
|
71
|
+
summary = diff_summary(compare(baseline, candidate))
|
|
72
|
+
print(summary["normalized_exact_match"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
{'baseline_mean': 0.5, 'candidate_mean': 0.75, 'mean_delta': 0.25, 'improved': ['login', 'webhook'], 'regressed': ['invite'], 'newly_failing': ['invite']}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Accuracy rose from 0.50 to 0.75. Two cases improved, and `invite`, which v1 classified correctly, v2 now gets wrong. The average alone would not have shown that.
|
|
80
|
+
|
|
81
|
+
Each result row keeps the input, the response and its scores:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
{"id": "invite", "prompt": "My teammate never got the invite email.",
|
|
85
|
+
"expected": "account", "response": "integration",
|
|
86
|
+
"scores": {"normalized_exact_match": 0.0}}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`examples/prompt_change_demo.py` runs the same comparison through saved runs and prints the responses behind each regression.
|
|
90
|
+
|
|
91
|
+
## Failing a build
|
|
92
|
+
|
|
93
|
+
A regression check belongs in your test suite. Continuing from the quickstart, with `dataset` and `llm_v2` imported from wherever your project defines them:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from evalquill import evaluate_dataset, summary_scores, threshold_check
|
|
97
|
+
from evalquill.compare import compare, diff_summary
|
|
98
|
+
from evalquill.metrics import normalized_exact_match
|
|
99
|
+
from evalquill.storage import load_run
|
|
100
|
+
|
|
101
|
+
def test_accuracy_meets_threshold():
|
|
102
|
+
candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
|
|
103
|
+
checks = threshold_check(summary_scores(candidate), {"normalized_exact_match": 0.7})
|
|
104
|
+
assert checks == {"normalized_exact_match": "PASS"}
|
|
105
|
+
|
|
106
|
+
def test_no_case_stops_passing():
|
|
107
|
+
baseline = load_run("runs/baseline.json")["results"]
|
|
108
|
+
candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
|
|
109
|
+
summary = diff_summary(compare(baseline, candidate))["normalized_exact_match"]
|
|
110
|
+
assert summary["newly_failing"] == [], f"stopped passing: {summary['newly_failing']}"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The baseline is a saved run (see Saving runs below). With the quickstart's v2 prompt, the first test passes, since 0.75 clears 0.7, and the second fails:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
AssertionError: stopped passing: ['invite']
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The accuracy bar was met and a case still broke. A threshold alone lets that through.
|
|
120
|
+
|
|
121
|
+
`threshold_check` returns the strings `"PASS"`, `"FAIL"` and `"NOT RUN"`, so compare them explicitly. `assert all(checks.values())` would pass on `"FAIL"`, because every non-empty string is truthy.
|
|
122
|
+
|
|
123
|
+
## Saving runs
|
|
124
|
+
|
|
125
|
+
Save a run once and compare against it later, without paying for the same model calls again:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from evalquill.storage import save_run, load_run
|
|
129
|
+
|
|
130
|
+
save_run(baseline, "runs/baseline.json", metadata={"prompt_version": "v1"})
|
|
131
|
+
stored = load_run("runs/baseline.json")
|
|
132
|
+
compare(stored["results"], candidate)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Metadata is free-form. Record the model, its settings and the prompt version; that is what makes a saved run interpretable weeks later.
|
|
136
|
+
|
|
137
|
+
## Writing a metric
|
|
138
|
+
|
|
139
|
+
Any named function works. This one checks a field in a JSON response:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import json
|
|
143
|
+
from evalquill import evaluate
|
|
144
|
+
|
|
145
|
+
def json_label(response: str, expected: str) -> float:
|
|
146
|
+
try:
|
|
147
|
+
return 1.0 if json.loads(response).get("label") == expected else 0.0
|
|
148
|
+
except (json.JSONDecodeError, AttributeError):
|
|
149
|
+
return 0.0
|
|
150
|
+
|
|
151
|
+
print(evaluate(expected="billing", response='{"label": "billing"}', metrics=[json_label]))
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
{'json_label': 1.0}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The rules: the function's name is the score's key and must be unique within a run. The return value must be a finite number from 0 to 1; booleans are rejected. If a metric raises, the run stops and the error names the case.
|
|
159
|
+
|
|
160
|
+
## Using a real model
|
|
161
|
+
|
|
162
|
+
Wrap your model in a function that takes a prompt and returns a string. For a self-hosted model, that is usually a few lines around your inference client.
|
|
163
|
+
|
|
164
|
+
For OpenAI and Gemini there are adapters. Install the matching extra; the key is read from `OPENAI_API_KEY` or `GEMINI_API_KEY`:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from evalquill.providers import openai_llm
|
|
168
|
+
|
|
169
|
+
llm, metadata = openai_llm(
|
|
170
|
+
"gpt-4o-mini",
|
|
171
|
+
system_prompt="Classify the support ticket as billing, account, integration, feature, bug or other. Reply with one word.",
|
|
172
|
+
temperature=0.0,
|
|
173
|
+
)
|
|
174
|
+
results = evaluate_dataset(dataset, llm, [normalized_exact_match])
|
|
175
|
+
save_run(results, "runs/gpt-4o-mini_v2.json", metadata=metadata)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`gemini_llm` works the same way. The returned `metadata` records the provider, model, temperature and system prompt, so a saved run says what produced it. The adapters add no retry logic of their own: the provider SDK's default retries apply, and any error that remains stops the run and names the case.
|
|
179
|
+
|
|
180
|
+
## Why case IDs matter
|
|
181
|
+
|
|
182
|
+
Comparisons join on case ID, not list position. If you reorder your dataset or insert a case, a positional comparison silently diffs unrelated rows and gives you a confident wrong answer.
|
|
183
|
+
|
|
184
|
+
IDs default to `case_0`, `case_1` and so on if you don't supply them. That's fine for a one-off run, but give real IDs to anything you'll compare across versions.
|
|
185
|
+
|
|
186
|
+
Two runs are rejected rather than compared if they cover different cases, use different metrics, or if any case's input or expected answer differs between them. Only the response and its scores may change.
|
|
187
|
+
|
|
188
|
+
## Case study
|
|
189
|
+
|
|
190
|
+
[case_study/](https://github.com/priteesshhh/evalquill/tree/v0.1.0/case_study) contains a worked example: 50 fictional support tickets, hand-labeled against a written guide, classified by gpt-4o-mini under two prompt versions. Results are reported on a frozen 20-ticket held-out set, with three repeats per version on that set.
|
|
191
|
+
|
|
192
|
+
Accuracy rose from 80% to 95% on the held-out set, and one previously correct ticket failed in all three observed repeats. The aggregate score does not surface that; the per-case comparison does.
|
|
193
|
+
|
|
194
|
+
All runs are saved. `case_study/rescore.py` recomputes every stored score from the saved responses, with no API key. See [case_study/README.md](https://github.com/priteesshhh/evalquill/blob/v0.1.0/case_study/README.md) for method, results and limitations.
|
|
195
|
+
|
|
196
|
+
## What it doesn't do
|
|
197
|
+
|
|
198
|
+
- No semantic or model-graded metrics. The built-ins are string comparisons, named for what they check: `normalized_exact_match` ignores case and surrounding whitespace, and `contains_substring` checks for a substring and nothing more. `contains_substring("19", "9")` returns 1.0, and a correct answer in a different form, such as "nine" for "9", scores 0.0.
|
|
199
|
+
- No RAG metrics. Metrics receive only the response and the expected value, so anything needing retrieved context doesn't fit the signature yet.
|
|
200
|
+
- No concurrency. Model calls are made one at a time.
|
|
201
|
+
- No repeat-run tooling in the library. The case study has scripts for its own repeats.
|
|
202
|
+
- No dashboard, no hosted anything.
|
|
203
|
+
|
|
204
|
+
This isn't the only tool in this space. promptfoo, DeepEval, LangSmith and Braintrust all overlap with it and most do more.
|
|
205
|
+
|
|
206
|
+
## Design notes
|
|
207
|
+
|
|
208
|
+
Scores are stored at full precision and rounded only for display. Rounding before a threshold check lets a true score of 0.7993 pass a 0.80 threshold, a silent false pass. There's a regression test for it.
|
|
209
|
+
|
|
210
|
+
Metrics must return a finite number between 0 and 1. Booleans are rejected explicitly: `True == 1.0` in Python, so a metric returning booleans would otherwise pass validation while suggesting its author didn't mean to return a score.
|
|
211
|
+
|
|
212
|
+
Duplicate metric names and duplicate case IDs are rejected. Both silently overwrite results otherwise. A missing score is rejected rather than averaged as zero.
|
|
213
|
+
|
|
214
|
+
A comparison requires each case's input and expected answer to be identical in both runs. Otherwise a changed reference answer would show up as a model improvement.
|
|
215
|
+
|
|
216
|
+
The whole dataset is validated before the first model call, so a bad row near the end doesn't cost every call before it. When a model call or a metric fails, the run stops and the error names the case. Recording 0.0 instead would make a broken metric look like a failing model.
|
|
217
|
+
|
|
218
|
+
Saves are atomic: a failed save leaves any previous file intact. Loading validates the whole file, not just its schema version.
|
|
219
|
+
|
|
220
|
+
## Tests
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
uv run pytest
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`tests/test_readme.py` runs every Python example in this README and checks the output shown, so the examples can't drift from the code. Live tests against real APIs are opt-in: `uv run pytest -m live`, with the provider keys set.
|
|
227
|
+
|
|
228
|
+
## Status
|
|
229
|
+
|
|
230
|
+
Early. 0.1.0 is the first release; the API may change in later 0.x releases.
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "evalquill"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Compare two LLM evaluation runs case by case to see which cases a prompt change broke"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"llm",
|
|
11
|
+
"evaluation",
|
|
12
|
+
"prompt-engineering",
|
|
13
|
+
"testing",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[[project.authors]]
|
|
24
|
+
name = "Priteesh Karra"
|
|
25
|
+
email = "WritetoPriteesh@gmail.com"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
openai = ["openai>=1.0"]
|
|
29
|
+
gemini = ["google-genai>=2.24.0"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/priteesshhh/evalquill"
|
|
33
|
+
Issues = "https://github.com/priteesshhh/evalquill/issues"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["pytest>=8.0"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
markers = ["live: hits a real API, costs money, deselected by default"]
|
|
41
|
+
addopts = "-m 'not live'"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "evalquill"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Compare two LLM evaluation runs case by case to see which cases a prompt change broke"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Priteesh Karra", email = "WritetoPriteesh@gmail.com" }
|
|
11
|
+
]
|
|
12
|
+
keywords = ["llm", "evaluation", "prompt-engineering", "testing"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Software Development :: Testing",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
openai = [
|
|
23
|
+
"openai>=1.0",
|
|
24
|
+
]
|
|
25
|
+
gemini = [
|
|
26
|
+
"google-genai>=2.24.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/priteesshhh/evalquill"
|
|
31
|
+
Issues = "https://github.com/priteesshhh/evalquill/issues"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
markers = ["live: hits a real API, costs money, deselected by default"]
|
|
41
|
+
addopts = "-m 'not live'"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def evaluate(expected: str, response: str, metrics: list) -> dict:
|
|
5
|
+
_validate_metrics(metrics)
|
|
6
|
+
results = {}
|
|
7
|
+
for metric in metrics:
|
|
8
|
+
score = metric(response, expected)
|
|
9
|
+
_validate_score(score, metric.__name__)
|
|
10
|
+
results[metric.__name__] = score
|
|
11
|
+
return results
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _preflight(dataset, metrics) -> list:
|
|
15
|
+
"""Validate the whole dataset and metric list; return the case ids.
|
|
16
|
+
|
|
17
|
+
Runs before any model call, so a structural error anywhere in the
|
|
18
|
+
dataset costs nothing.
|
|
19
|
+
"""
|
|
20
|
+
_validate_metrics(metrics)
|
|
21
|
+
if not isinstance(dataset, list):
|
|
22
|
+
raise ValueError(f"dataset must be a list of cases, got {type(dataset).__name__}.")
|
|
23
|
+
|
|
24
|
+
ids = []
|
|
25
|
+
seen = set()
|
|
26
|
+
for i, item in enumerate(dataset):
|
|
27
|
+
if not isinstance(item, dict):
|
|
28
|
+
raise ValueError(f"Case {i} is not a dict, got {type(item).__name__}.")
|
|
29
|
+
missing = [f for f in ("prompt", "expected") if f not in item]
|
|
30
|
+
if missing:
|
|
31
|
+
raise ValueError(f"Case {i} is missing required field(s): {missing}.")
|
|
32
|
+
for field in ("prompt", "expected"):
|
|
33
|
+
if not isinstance(item[field], str):
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Case {i}: '{field}' must be a string, got {type(item[field]).__name__}."
|
|
36
|
+
)
|
|
37
|
+
case_id = item.get("id", f"case_{i}")
|
|
38
|
+
if case_id in seen:
|
|
39
|
+
raise ValueError(f"Duplicate case id: '{case_id}'. Each case must have a unique id.")
|
|
40
|
+
seen.add(case_id)
|
|
41
|
+
ids.append(case_id)
|
|
42
|
+
return ids
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def evaluate_dataset(dataset: list, llm, metrics: list) -> list:
|
|
46
|
+
"""Call llm on each case and score the response.
|
|
47
|
+
|
|
48
|
+
The dataset and metrics are validated in full before the first call.
|
|
49
|
+
LLM and metric failures are re-raised as RuntimeError naming the case;
|
|
50
|
+
the original exception is available as __cause__.
|
|
51
|
+
"""
|
|
52
|
+
ids = _preflight(dataset, metrics)
|
|
53
|
+
|
|
54
|
+
results = []
|
|
55
|
+
for case_id, item in zip(ids, dataset):
|
|
56
|
+
try:
|
|
57
|
+
response = llm(item["prompt"])
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise RuntimeError(f"LLM call failed on case '{case_id}' — {e}") from e
|
|
60
|
+
|
|
61
|
+
if not isinstance(response, str):
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"LLM returned {type(response).__name__} for case '{case_id}'; expected a string."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
result = evaluate(
|
|
68
|
+
expected=item["expected"],
|
|
69
|
+
response=response,
|
|
70
|
+
metrics=metrics
|
|
71
|
+
)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"Evaluation failed on case '{case_id}' "
|
|
75
|
+
f"(prompt: '{item['prompt']}') — {e}"
|
|
76
|
+
) from e
|
|
77
|
+
|
|
78
|
+
results.append({
|
|
79
|
+
"id": case_id,
|
|
80
|
+
"prompt": item["prompt"],
|
|
81
|
+
"expected": item["expected"],
|
|
82
|
+
"response": response,
|
|
83
|
+
"scores": result
|
|
84
|
+
})
|
|
85
|
+
return results
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def summary_scores(results: list) -> dict:
|
|
89
|
+
"""Mean of each metric across results, at full precision.
|
|
90
|
+
|
|
91
|
+
Every row must carry the same metric set with valid scores. A row missing
|
|
92
|
+
a metric is rejected rather than averaged as zero.
|
|
93
|
+
"""
|
|
94
|
+
if not results:
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
metric_set = None
|
|
98
|
+
totals = {}
|
|
99
|
+
for index, result in enumerate(results):
|
|
100
|
+
if not isinstance(result, dict) or not isinstance(result.get("scores"), dict):
|
|
101
|
+
raise ValueError(f"Result {index} has no scores dict.")
|
|
102
|
+
scores = result["scores"]
|
|
103
|
+
|
|
104
|
+
names = set(scores)
|
|
105
|
+
if metric_set is None:
|
|
106
|
+
metric_set = names
|
|
107
|
+
elif names != metric_set:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Inconsistent metric sets: result 0 has {sorted(metric_set)}, "
|
|
110
|
+
f"result {index} has {sorted(names)}. A missing score would "
|
|
111
|
+
"otherwise be averaged as zero."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
for name, score in scores.items():
|
|
115
|
+
_validate_score(score, name)
|
|
116
|
+
totals[name] = totals.get(name, 0.0) + score
|
|
117
|
+
|
|
118
|
+
return {name: total / len(results) for name, total in totals.items()}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def threshold_check(summary: dict, thresholds: dict) -> dict:
|
|
122
|
+
"""PASS, FAIL or NOT RUN per threshold.
|
|
123
|
+
|
|
124
|
+
NOT RUN means the metric was never measured. Invalid thresholds or
|
|
125
|
+
summary values are rejected rather than compared.
|
|
126
|
+
"""
|
|
127
|
+
for metric, minimum in thresholds.items():
|
|
128
|
+
try:
|
|
129
|
+
_validate_score(minimum, metric)
|
|
130
|
+
except ValueError as e:
|
|
131
|
+
raise ValueError(f"Invalid threshold for '{metric}': {e}") from e
|
|
132
|
+
|
|
133
|
+
results = {}
|
|
134
|
+
for metric, minimum in thresholds.items():
|
|
135
|
+
if metric not in summary:
|
|
136
|
+
results[metric] = "NOT RUN"
|
|
137
|
+
continue
|
|
138
|
+
_validate_score(summary[metric], metric)
|
|
139
|
+
results[metric] = "PASS" if summary[metric] >= minimum else "FAIL"
|
|
140
|
+
return results
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate_metrics(metrics) -> None:
|
|
144
|
+
if not isinstance(metrics, (list, tuple)) or not metrics:
|
|
145
|
+
raise ValueError("At least one metric is required; a run without metrics measures nothing.")
|
|
146
|
+
seen = set()
|
|
147
|
+
for metric in metrics:
|
|
148
|
+
if not callable(metric):
|
|
149
|
+
raise ValueError(f"Each metric must be callable, got {type(metric).__name__}.")
|
|
150
|
+
name = getattr(metric, "__name__", None)
|
|
151
|
+
if not isinstance(name, str):
|
|
152
|
+
raise ValueError("Each metric must have a __name__; use a named function.")
|
|
153
|
+
if name in seen:
|
|
154
|
+
raise ValueError(f"Duplicate metric name: '{name}'. Each metric must have a unique name.")
|
|
155
|
+
seen.add(name)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_score(value: float, name: str) -> None:
|
|
159
|
+
if isinstance(value, bool):
|
|
160
|
+
raise ValueError(f"Score '{name}' must be a float, not a boolean.")
|
|
161
|
+
if not isinstance(value, (int, float)):
|
|
162
|
+
raise ValueError(f"Score '{name}' must be a number, got {type(value).__name__}.")
|
|
163
|
+
if math.isnan(value) or math.isinf(value):
|
|
164
|
+
raise ValueError(f"Score '{name}' must be finite, got {value}.")
|
|
165
|
+
if not (0.0 <= value <= 1.0):
|
|
166
|
+
raise ValueError(f"Score '{name}' must be between 0 and 1, got {value}.")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Compare two evaluation runs case by case.
|
|
2
|
+
|
|
3
|
+
Comparison contract: for each case id, the input (prompt) and reference
|
|
4
|
+
answer (expected) must be identical in both runs. Only the response and its
|
|
5
|
+
scores may differ. The experimental variable - a system prompt, template or
|
|
6
|
+
model - lives in the llm callable and run metadata, never in these fields.
|
|
7
|
+
A changed input or reference answer is a different test case, so the runs
|
|
8
|
+
are rejected rather than compared.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import _validate_score
|
|
12
|
+
from .validation import validate_results
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def compare(baseline: list, candidate: list) -> dict:
|
|
16
|
+
base_idx, base_metrics = validate_results(baseline, "baseline")
|
|
17
|
+
cand_idx, cand_metrics = validate_results(candidate, "candidate")
|
|
18
|
+
|
|
19
|
+
if base_idx.keys() != cand_idx.keys():
|
|
20
|
+
only_base = sorted(base_idx.keys() - cand_idx.keys())
|
|
21
|
+
only_cand = sorted(cand_idx.keys() - base_idx.keys())
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"Runs cover different cases and cannot be compared. "
|
|
24
|
+
f"Only in baseline: {only_base}. Only in candidate: {only_cand}."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if base_metrics != cand_metrics:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"Runs used different metrics and cannot be compared. "
|
|
30
|
+
f"Baseline: {sorted(base_metrics)}. Candidate: {sorted(cand_metrics)}."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
changed_input = [i for i in base_idx if base_idx[i]["prompt"] != cand_idx[i]["prompt"]]
|
|
34
|
+
changed_expected = [i for i in base_idx if base_idx[i]["expected"] != cand_idx[i]["expected"]]
|
|
35
|
+
if changed_input or changed_expected:
|
|
36
|
+
problems = []
|
|
37
|
+
if changed_input:
|
|
38
|
+
problems.append(f"different input for cases {sorted(changed_input)}")
|
|
39
|
+
if changed_expected:
|
|
40
|
+
problems.append(f"different reference answer for cases {sorted(changed_expected)}")
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"Runs are not over the same dataset: " + "; ".join(problems) + ". "
|
|
43
|
+
"A changed input or reference answer would be reported as a model "
|
|
44
|
+
"improvement or regression. Compare only runs over identical cases."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
metrics = sorted(base_metrics)
|
|
48
|
+
cases = []
|
|
49
|
+
for case_id in base_idx:
|
|
50
|
+
base = base_idx[case_id]
|
|
51
|
+
cand = cand_idx[case_id]
|
|
52
|
+
cases.append({
|
|
53
|
+
"id": case_id,
|
|
54
|
+
"prompt": base["prompt"],
|
|
55
|
+
"expected": base["expected"],
|
|
56
|
+
"baseline_response": base["response"],
|
|
57
|
+
"candidate_response": cand["response"],
|
|
58
|
+
"response_changed": base["response"] != cand["response"],
|
|
59
|
+
"baseline_scores": dict(base["scores"]),
|
|
60
|
+
"candidate_scores": dict(cand["scores"]),
|
|
61
|
+
"deltas": {n: cand["scores"][n] - base["scores"][n] for n in metrics},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return {"cases": cases, "metrics": metrics}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def diff_summary(diff: dict, pass_threshold: float = 1.0) -> dict:
|
|
68
|
+
try:
|
|
69
|
+
_validate_score(pass_threshold, "pass_threshold")
|
|
70
|
+
except ValueError as e:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"pass_threshold must be a finite number between 0 and 1, got {pass_threshold!r}."
|
|
73
|
+
) from e
|
|
74
|
+
|
|
75
|
+
summary = {}
|
|
76
|
+
for name in diff["metrics"]:
|
|
77
|
+
improved, regressed, newly_failing = [], [], []
|
|
78
|
+
|
|
79
|
+
for case in diff["cases"]:
|
|
80
|
+
delta = case["deltas"][name]
|
|
81
|
+
if delta > 0:
|
|
82
|
+
improved.append(case["id"])
|
|
83
|
+
elif delta < 0:
|
|
84
|
+
regressed.append(case["id"])
|
|
85
|
+
|
|
86
|
+
was_passing = case["baseline_scores"][name] >= pass_threshold
|
|
87
|
+
now_passing = case["candidate_scores"][name] >= pass_threshold
|
|
88
|
+
if was_passing and not now_passing:
|
|
89
|
+
newly_failing.append(case["id"])
|
|
90
|
+
|
|
91
|
+
n = len(diff["cases"])
|
|
92
|
+
baseline_mean = sum(c["baseline_scores"][name] for c in diff["cases"]) / n
|
|
93
|
+
candidate_mean = sum(c["candidate_scores"][name] for c in diff["cases"]) / n
|
|
94
|
+
|
|
95
|
+
summary[name] = {
|
|
96
|
+
"baseline_mean": baseline_mean,
|
|
97
|
+
"candidate_mean": candidate_mean,
|
|
98
|
+
"mean_delta": candidate_mean - baseline_mean,
|
|
99
|
+
"improved": improved,
|
|
100
|
+
"regressed": regressed,
|
|
101
|
+
"newly_failing": newly_failing,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return summary
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
def mock_llm(prompt: str) -> str:
|
|
2
|
+
responses = {
|
|
3
|
+
"what is the capital of france?": "The capital of France is Paris.",
|
|
4
|
+
"what is 2 + 2?": "2 + 2 equals 4.",
|
|
5
|
+
"what is the capital of germany?": "The capital of Germany is Berlin.",
|
|
6
|
+
"what is the capital of india?": "The capital of India is Delhi.",
|
|
7
|
+
"what is 7 + 2?": "7 + 2 equals 9.",
|
|
8
|
+
"what is first president of united states of america?": "The first president of the United States of America is George Washington.",
|
|
9
|
+
}
|
|
10
|
+
return responses.get(prompt.strip().lower(), "I don't know.")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
def normalized_exact_match(response: str, expected: str) -> float:
|
|
2
|
+
if not expected.strip():
|
|
3
|
+
raise ValueError("expected string cannot be empty")
|
|
4
|
+
return 1.0 if response.strip().lower() == expected.strip().lower() else 0.0
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def contains_substring(response: str, expected: str) -> float:
|
|
8
|
+
if not expected.strip():
|
|
9
|
+
raise ValueError("expected string cannot be empty")
|
|
10
|
+
return 1.0 if expected.strip().lower() in response.strip().lower() else 0.0
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Adapters turning provider clients into plain llm(prompt) -> str callables."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def openai_llm(
|
|
5
|
+
model: str,
|
|
6
|
+
system_prompt: str | None = None,
|
|
7
|
+
temperature: float = 0.0,
|
|
8
|
+
client=None,
|
|
9
|
+
**kwargs,
|
|
10
|
+
):
|
|
11
|
+
"""Build an llm(prompt) -> str callable backed by OpenAI chat completions.
|
|
12
|
+
|
|
13
|
+
Returns (llm, metadata). Pass metadata to save_run so the run records
|
|
14
|
+
which model and settings produced it.
|
|
15
|
+
|
|
16
|
+
client is injectable for testing; when omitted, a real OpenAI client is
|
|
17
|
+
constructed and the openai package must be installed.
|
|
18
|
+
"""
|
|
19
|
+
if client is None:
|
|
20
|
+
try:
|
|
21
|
+
from openai import OpenAI
|
|
22
|
+
except ImportError as e:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"The openai package is required for openai_llm. "
|
|
25
|
+
"Install it with: pip install openai"
|
|
26
|
+
) from e
|
|
27
|
+
client = OpenAI()
|
|
28
|
+
|
|
29
|
+
def llm(prompt: str) -> str:
|
|
30
|
+
messages = []
|
|
31
|
+
if system_prompt is not None:
|
|
32
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
33
|
+
messages.append({"role": "user", "content": prompt})
|
|
34
|
+
|
|
35
|
+
response = client.chat.completions.create(
|
|
36
|
+
model=model,
|
|
37
|
+
messages=messages,
|
|
38
|
+
temperature=temperature,
|
|
39
|
+
**kwargs,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
content = response.choices[0].message.content
|
|
43
|
+
if content is None:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"Model '{model}' returned no content. "
|
|
46
|
+
"This can happen when the response was filtered or truncated."
|
|
47
|
+
)
|
|
48
|
+
return content
|
|
49
|
+
|
|
50
|
+
metadata = {
|
|
51
|
+
"provider": "openai",
|
|
52
|
+
"model": model,
|
|
53
|
+
"temperature": temperature,
|
|
54
|
+
"system_prompt": system_prompt,
|
|
55
|
+
**kwargs,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return llm, metadata
|
|
59
|
+
|
|
60
|
+
def gemini_llm(
|
|
61
|
+
model: str = "gemini-3.6-flash",
|
|
62
|
+
system_prompt: str | None = None,
|
|
63
|
+
temperature: float = 0.0,
|
|
64
|
+
client=None,
|
|
65
|
+
**kwargs,
|
|
66
|
+
):
|
|
67
|
+
"""Build an llm(prompt) -> str callable backed by the Gemini API.
|
|
68
|
+
|
|
69
|
+
Returns (llm, metadata). Requires GEMINI_API_KEY in the environment
|
|
70
|
+
when client is not injected.
|
|
71
|
+
"""
|
|
72
|
+
if client is None:
|
|
73
|
+
try:
|
|
74
|
+
from google import genai
|
|
75
|
+
except ImportError as e:
|
|
76
|
+
raise ImportError(
|
|
77
|
+
"The google-genai package is required for gemini_llm. "
|
|
78
|
+
"Install it with: pip install google-genai"
|
|
79
|
+
) from e
|
|
80
|
+
client = genai.Client()
|
|
81
|
+
|
|
82
|
+
def llm(prompt: str) -> str:
|
|
83
|
+
config = {"temperature": temperature, **kwargs}
|
|
84
|
+
if system_prompt is not None:
|
|
85
|
+
config["system_instruction"] = system_prompt
|
|
86
|
+
|
|
87
|
+
response = client.models.generate_content(
|
|
88
|
+
model=model,
|
|
89
|
+
contents=prompt,
|
|
90
|
+
config=config,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
text = response.text
|
|
94
|
+
if text is None:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"Model '{model}' returned no content. "
|
|
97
|
+
"This can happen when the response was filtered or truncated."
|
|
98
|
+
)
|
|
99
|
+
return text
|
|
100
|
+
|
|
101
|
+
metadata = {
|
|
102
|
+
"provider": "gemini",
|
|
103
|
+
"model": model,
|
|
104
|
+
"temperature": temperature,
|
|
105
|
+
"system_prompt": system_prompt,
|
|
106
|
+
**kwargs,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return llm, metadata
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Save and load evaluation runs as JSON."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .validation import validate_results
|
|
10
|
+
|
|
11
|
+
SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def save_run(results: list, path: str, metadata: dict | None = None) -> None:
|
|
15
|
+
"""Write a run to path atomically.
|
|
16
|
+
|
|
17
|
+
Rows are validated before anything is written. The payload is then
|
|
18
|
+
serialized in full, written to a temporary file in the same directory,
|
|
19
|
+
and moved onto path with os.replace. A failure at any stage leaves an
|
|
20
|
+
existing file at path unchanged and leaves no partial file behind. An
|
|
21
|
+
existing file at path is replaced on success.
|
|
22
|
+
|
|
23
|
+
Non-finite floats (NaN, infinity) are rejected, since they are not valid
|
|
24
|
+
JSON.
|
|
25
|
+
"""
|
|
26
|
+
if not results:
|
|
27
|
+
raise ValueError("Cannot save an empty run.")
|
|
28
|
+
if metadata is not None and not isinstance(metadata, dict):
|
|
29
|
+
raise ValueError("metadata must be a dict.")
|
|
30
|
+
validate_results(results, "run")
|
|
31
|
+
|
|
32
|
+
payload = {
|
|
33
|
+
"schema_version": SCHEMA_VERSION,
|
|
34
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
35
|
+
"metadata": metadata or {},
|
|
36
|
+
"results": results,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Serialize first: any TypeError or ValueError happens before disk I/O.
|
|
40
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False, allow_nan=False)
|
|
41
|
+
|
|
42
|
+
target = Path(path)
|
|
43
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
fd, tmp = tempfile.mkstemp(
|
|
46
|
+
dir=target.parent, prefix=f".{target.name}.", suffix=".tmp"
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
50
|
+
f.write(text)
|
|
51
|
+
f.flush()
|
|
52
|
+
os.fsync(f.fileno())
|
|
53
|
+
os.replace(tmp, target)
|
|
54
|
+
except BaseException:
|
|
55
|
+
try:
|
|
56
|
+
os.unlink(tmp)
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
pass
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_run(path: str) -> dict:
|
|
63
|
+
"""Load a run and validate its envelope and every row.
|
|
64
|
+
|
|
65
|
+
A matching schema_version is necessary but not sufficient: the file must
|
|
66
|
+
also contain valid metadata and a non-empty list of valid result rows.
|
|
67
|
+
"""
|
|
68
|
+
target = Path(path)
|
|
69
|
+
if not target.exists():
|
|
70
|
+
raise FileNotFoundError(f"No run found at '{path}'.")
|
|
71
|
+
|
|
72
|
+
def reject_constant(token):
|
|
73
|
+
raise ValueError(f"'{path}' contains non-standard JSON value {token}.")
|
|
74
|
+
|
|
75
|
+
with target.open(encoding="utf-8") as f:
|
|
76
|
+
try:
|
|
77
|
+
payload = json.load(f, parse_constant=reject_constant)
|
|
78
|
+
except json.JSONDecodeError as e:
|
|
79
|
+
raise ValueError(f"'{path}' is not valid JSON: {e}") from e
|
|
80
|
+
|
|
81
|
+
if not isinstance(payload, dict) or "schema_version" not in payload:
|
|
82
|
+
raise ValueError(f"'{path}' is not an evalquill run file.")
|
|
83
|
+
|
|
84
|
+
if payload["schema_version"] != SCHEMA_VERSION:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Run file '{path}' uses schema version {payload['schema_version']}, "
|
|
87
|
+
f"but this version of evalquill reads version {SCHEMA_VERSION}."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
for field in ("created_at", "metadata", "results"):
|
|
91
|
+
if field not in payload:
|
|
92
|
+
raise ValueError(f"'{path}' is missing required field '{field}' (results cannot be read).")
|
|
93
|
+
if not isinstance(payload["created_at"], str):
|
|
94
|
+
raise ValueError(f"'{path}': 'created_at' must be a string.")
|
|
95
|
+
if not isinstance(payload["metadata"], dict):
|
|
96
|
+
raise ValueError(f"'{path}': 'metadata' must be a dict.")
|
|
97
|
+
if not isinstance(payload["results"], list):
|
|
98
|
+
raise ValueError(f"'{path}': 'results' must be a list.")
|
|
99
|
+
|
|
100
|
+
validate_results(payload["results"], f"'{path}'")
|
|
101
|
+
return payload
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared validation for result rows and runs.
|
|
2
|
+
|
|
3
|
+
One definition of a valid run, used by comparison and by storage, so the two
|
|
4
|
+
boundaries cannot drift apart.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from . import _validate_score
|
|
8
|
+
|
|
9
|
+
REQUIRED_FIELDS = ("id", "prompt", "expected", "response", "scores")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_row(row, label: str, index: int) -> None:
|
|
13
|
+
if not isinstance(row, dict):
|
|
14
|
+
raise ValueError(f"{label} row {index} is not a dict.")
|
|
15
|
+
missing = [f for f in REQUIRED_FIELDS if f not in row]
|
|
16
|
+
if missing:
|
|
17
|
+
raise ValueError(f"{label} row {index} is missing required field(s): {missing}.")
|
|
18
|
+
if not isinstance(row["scores"], dict):
|
|
19
|
+
raise ValueError(f"{label} case '{row['id']}': scores must be a dict.")
|
|
20
|
+
for name, value in row["scores"].items():
|
|
21
|
+
try:
|
|
22
|
+
_validate_score(value, name)
|
|
23
|
+
except ValueError as e:
|
|
24
|
+
raise ValueError(f"{label} case '{row['id']}': {e}") from e
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_results(results, label: str):
|
|
28
|
+
"""Validate every row; return (rows indexed by id, the run's metric set)."""
|
|
29
|
+
if not isinstance(results, list):
|
|
30
|
+
raise ValueError(f"{label} results must be a list.")
|
|
31
|
+
if not results:
|
|
32
|
+
raise ValueError(f"{label} run must contain at least one case.")
|
|
33
|
+
|
|
34
|
+
indexed = {}
|
|
35
|
+
metric_set = None
|
|
36
|
+
first_id = None
|
|
37
|
+
for index, row in enumerate(results):
|
|
38
|
+
check_row(row, label, index)
|
|
39
|
+
case_id = row["id"]
|
|
40
|
+
if case_id in indexed:
|
|
41
|
+
raise ValueError(f"Duplicate case id '{case_id}' in {label} run.")
|
|
42
|
+
indexed[case_id] = row
|
|
43
|
+
|
|
44
|
+
names = set(row["scores"])
|
|
45
|
+
if metric_set is None:
|
|
46
|
+
metric_set, first_id = names, case_id
|
|
47
|
+
elif names != metric_set:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"{label} run has inconsistent metric sets: case '{first_id}' "
|
|
50
|
+
f"has {sorted(metric_set)}, case '{case_id}' has {sorted(names)}."
|
|
51
|
+
)
|
|
52
|
+
return indexed, metric_set
|