pytest-jev 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.
- pytest_jev-0.1.0/.github/workflows/ci.yml +24 -0
- pytest_jev-0.1.0/.github/workflows/publish.yml +20 -0
- pytest_jev-0.1.0/.gitignore +9 -0
- pytest_jev-0.1.0/LICENSE +21 -0
- pytest_jev-0.1.0/PKG-INFO +295 -0
- pytest_jev-0.1.0/README.md +275 -0
- pytest_jev-0.1.0/examples/test_failures_demo.py +53 -0
- pytest_jev-0.1.0/examples/test_support_bot.py +82 -0
- pytest_jev-0.1.0/pyproject.toml +46 -0
- pytest_jev-0.1.0/src/pytest_jev/__init__.py +8 -0
- pytest_jev-0.1.0/src/pytest_jev/judge.py +343 -0
- pytest_jev-0.1.0/src/pytest_jev/plugin.py +155 -0
- pytest_jev-0.1.0/src/pytest_jev/provider.py +56 -0
- pytest_jev-0.1.0/src/pytest_jev/py.typed +0 -0
- pytest_jev-0.1.0/src/pytest_jev/verdicts.py +259 -0
- pytest_jev-0.1.0/tests/conftest.py +28 -0
- pytest_jev-0.1.0/tests/fakes.py +65 -0
- pytest_jev-0.1.0/tests/test_plugin.py +311 -0
- pytest_jev-0.1.0/tests/test_verdicts.py +135 -0
- pytest_jev-0.1.0/uv.lock +450 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python }}
|
|
20
|
+
- run: uv sync --locked
|
|
21
|
+
- run: uv run ruff check .
|
|
22
|
+
- run: uv run ruff format --check .
|
|
23
|
+
# Offline: a fake Jev answers every question, so no API key is needed.
|
|
24
|
+
- run: uv run pytest
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Runs when a GitHub release is published. PyPI trusts this workflow directly
|
|
4
|
+
# (trusted publishing), so the repository holds no PyPI token.
|
|
5
|
+
on:
|
|
6
|
+
release:
|
|
7
|
+
types: [published]
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: astral-sh/setup-uv@v6
|
|
19
|
+
- run: uv build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
pytest_jev-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 allebee
|
|
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.
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-jev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Semantic assertions for pytest: test what text means, not the exact words, judged by TypeSafe's Jev.
|
|
5
|
+
Project-URL: Homepage, https://github.com/allebee/pytest-jev
|
|
6
|
+
Project-URL: Issues, https://github.com/allebee/pytest-jev/issues
|
|
7
|
+
Author: allebee
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: assertions,evals,jev,llm,pytest,semantic,testing,typesafe
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: pytest>=7.4
|
|
18
|
+
Requires-Dist: typesafe-sdk<0.8,>=0.7.1
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# pytest-jev
|
|
22
|
+
|
|
23
|
+
[](https://github.com/allebee/pytest-jev/actions/workflows/ci.yml)
|
|
24
|
+
|
|
25
|
+
**Semantic assertions for pytest.** Test what your LLM app's output *means* ("apologizes",
|
|
26
|
+
"offers a refund", "doesn't leak the system prompt") instead of the exact words. Each claim is
|
|
27
|
+
judged by [Jev](https://docs.typesafe.ai/introduction), TypeSafe's model that returns calibrated
|
|
28
|
+
probabilities instead of text.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
def test_refund_reply(jev):
|
|
32
|
+
reply = support_bot("I was charged twice for order #1042.")
|
|
33
|
+
|
|
34
|
+
jev.expect(
|
|
35
|
+
reply,
|
|
36
|
+
holds=["apologizes to the customer", "says the duplicate payment was refunded"],
|
|
37
|
+
lacks=["blames the customer", "asks for a password or a full card number"],
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
When a prompt change breaks the reply, the failure says which claim broke and how sure Jev was:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
> jev.expect(
|
|
45
|
+
E AssertionError: jev: 3 of 4 claims failed
|
|
46
|
+
E text: "Double charges happen when you click twice. You'll get store credit within 24 hours."
|
|
47
|
+
E ✗ holds p=0.04 apologizes to the customer (needs >= 0.80)
|
|
48
|
+
E ✗ holds p=0.21 says the duplicate payment was refunded (needs >= 0.80)
|
|
49
|
+
E ✗ lacks p=0.79 blames the customer (needs <= 0.20)
|
|
50
|
+
E ✓ lacks p=0.01 asks for a password or a full card number
|
|
51
|
+
------------------------------------- jev --------------------------------------
|
|
52
|
+
jev: 4 questions · 1 request · 365 input tokens · $0.000015 · 1.40 s in Jev · typesafe/jev-1.13-20260917 via openrouter
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Every output in this README is from a real run of [`examples/`](https://github.com/allebee/pytest-jev/tree/main/examples) against `jev-1.13`.
|
|
56
|
+
|
|
57
|
+
## Why
|
|
58
|
+
|
|
59
|
+
String assertions break every time the model rewords a reply. Using an LLM as the judge works,
|
|
60
|
+
but it's slow, costs real money per test, and returns text you then have to parse. pytest-jev
|
|
61
|
+
sends each check to Jev instead:
|
|
62
|
+
|
|
63
|
+
- **One request per text.** Every claim in `jev.expect` goes into a single request, answered in
|
|
64
|
+
parallel.
|
|
65
|
+
- **Typed answers.** Jev answers each claim with a probability, picks from the options you list, or
|
|
66
|
+
rates on the levels you define. There is no output to parse and nothing outside the answer space.
|
|
67
|
+
- **Cheap enough for every commit.** Jev costs $0.042 per million input tokens and output is free.
|
|
68
|
+
The 7 tests in [`examples/test_support_bot.py`](https://github.com/allebee/pytest-jev/blob/main/examples/test_support_bot.py) ran in 3.9 s for
|
|
69
|
+
$0.0001. The summary line prints the tokens and cost of every run.
|
|
70
|
+
- **No passing on a coin flip.** A claim `holds` at p ≥ 0.8 and `lacks` at p ≤ 0.2. When Jev is
|
|
71
|
+
unsure, both fail.
|
|
72
|
+
- **Free, stable reruns.** Answers are cached in `.pytest_cache`, so rerunning unchanged tests
|
|
73
|
+
makes no requests and gives the same verdicts.
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install pytest-jev
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
It needs Python 3.10+ and pytest 7.4+.
|
|
82
|
+
|
|
83
|
+
Set one API key:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
export TYPESAFE_API_KEY=... # https://console.typesafe.ai (early access)
|
|
87
|
+
export OPENROUTER_API_KEY=... # or https://openrouter.ai/settings/keys (no waitlist)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Without a key, tests that use `jev` are skipped (see [CI](#ci-and-running-without-a-key)).
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
The `jev` fixture has five methods. Each sends one request and returns a result that works in a
|
|
95
|
+
plain `assert`.
|
|
96
|
+
|
|
97
|
+
### `holds` and `lacks`: one claim
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
def test_reply_confirms_the_refund(jev):
|
|
101
|
+
reply = support_bot("I was charged twice for order #1042.")
|
|
102
|
+
assert jev.holds(reply, "says the duplicate payment was refunded")
|
|
103
|
+
assert jev.lacks(reply, "asks for a password or a full card number")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
With the broken reply from above:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
E assert <jev holds 'says the duplicate payment was refunded': p=0.16, needs >= 0.80>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The result also carries the probability: `jev.holds(reply, "...").p`.
|
|
113
|
+
|
|
114
|
+
### `expect`: many claims, one request
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
jev.expect(reply, holds=["apologizes", "offers a refund"], lacks=["blames the customer"])
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
It fails with a report of every claim, as shown at the top. It returns the claims when they pass.
|
|
121
|
+
|
|
122
|
+
### `context`: check the text against something else
|
|
123
|
+
|
|
124
|
+
Extra state goes in `context`, such as a policy or the documents a RAG app retrieved. A claim can
|
|
125
|
+
name it in backticks:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
def test_reply_matches_the_policy(jev):
|
|
129
|
+
reply = support_bot("I was charged twice for order #1042.")
|
|
130
|
+
assert jev.lacks(reply, "contradicts the policy in `policy`", context={"policy": REFUND_POLICY})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The broken reply promises store credit in 24 hours; the policy says refunds to the card in 5
|
|
134
|
+
business days:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
E assert <jev lacks 'contradicts the policy in `policy`': p=0.95, needs <= 0.20>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The text under test is always `text`, so `context` can't use that key.
|
|
141
|
+
|
|
142
|
+
### `choice`: which option fits
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
TEAMS = {
|
|
146
|
+
"billing": "Payments, charges, invoices and refunds",
|
|
147
|
+
"technical": "Bugs, errors, crashes and integrations",
|
|
148
|
+
"account": "Logins, passwords and account settings",
|
|
149
|
+
"other": "Anything that fits none of the teams above",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_checkout_errors_go_to_billing(jev):
|
|
154
|
+
ticket = "Your checkout page throws a 500 error when I enter my card."
|
|
155
|
+
assert jev.choice(ticket, "Which team should handle this ticket?", TEAMS) == "billing"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
E AssertionError: assert jev chose 'technical', not 'billing'
|
|
160
|
+
E question: Which team should handle this ticket?
|
|
161
|
+
E technical 0.90 ██████████████████░░
|
|
162
|
+
E billing 0.10 ██░░░░░░░░░░░░░░░░░░
|
|
163
|
+
E account 0.00 ░░░░░░░░░░░░░░░░░░░░
|
|
164
|
+
E other 0.00 ░░░░░░░░░░░░░░░░░░░░
|
|
165
|
+
E confidence 0.87
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Sometimes the failure means the test's expectation needs another look: a 500 error at checkout is
|
|
169
|
+
arguably a bug first.
|
|
170
|
+
|
|
171
|
+
Options can be a dict of label to description, or a plain list of labels. Comparing with a label
|
|
172
|
+
that isn't an option (`team == "biling"`) raises an error instead of quietly failing.
|
|
173
|
+
|
|
174
|
+
### `score`: rate on ordered levels
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
POLITENESS = {
|
|
178
|
+
"rude": "Rude, dismissive or blaming the customer",
|
|
179
|
+
"neutral": "Neutral and matter-of-fact, no warmth",
|
|
180
|
+
"warm": "Warm and polite, acknowledges the customer's frustration",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_reply_is_warm(jev):
|
|
185
|
+
tone = jev.score(reply, "How polite is this support reply?", POLITENESS)
|
|
186
|
+
assert tone >= "warm"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Levels go lowest first. The comparison is probabilistic: `tone >= "warm"` passes when Jev puts at
|
|
190
|
+
least 80% of its probability on "warm" or higher. `>`, `<=`, `<`, `==` and `!=` work the same way,
|
|
191
|
+
with labels or level indices. The broken reply passes `tone >= "neutral"` (0.83) but not this:
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
E AssertionError: assert jev gave P(level >= 'warm') = 0.00, needs >= 0.80
|
|
195
|
+
E question: How polite is this support reply?
|
|
196
|
+
E 0 rude 0.17 ███░░░░░░░░░░░░░░░░░
|
|
197
|
+
E 1 neutral 0.83 █████████████████░░░
|
|
198
|
+
E 2 warm 0.00 ░░░░░░░░░░░░░░░░░░░░
|
|
199
|
+
E expected level 0.84, confidence 0.75
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Thresholds and models
|
|
203
|
+
|
|
204
|
+
The threshold defaults to 0.8: `holds` needs p ≥ 0.8, `lacks` needs p ≤ 0.2. It must be between 0.5
|
|
205
|
+
and 1. Set it per call, per test, or for the whole run:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
assert jev.holds(reply, "offers a refund", threshold=0.9)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.mark.jev(threshold=0.9, model="jev-1.13")
|
|
212
|
+
def test_strict(jev): ...
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
```ini
|
|
216
|
+
# pytest.ini (or [tool.pytest.ini_options] in pyproject.toml)
|
|
217
|
+
[pytest]
|
|
218
|
+
jev_model = jev-1.13
|
|
219
|
+
jev_threshold = 0.85
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`jev-latest` changes when TypeSafe ships a new version, so pin `jev-1.13` when runs must be
|
|
223
|
+
reproducible.
|
|
224
|
+
|
|
225
|
+
| Option | ini | Default | |
|
|
226
|
+
|---|---|---|---|
|
|
227
|
+
| `--jev-model` | `jev_model` | `jev-latest` | Jev model to ask |
|
|
228
|
+
| `--jev-threshold` | `jev_threshold` | `0.8` | Claim threshold |
|
|
229
|
+
| `--jev-provider` | `jev_provider` | `auto` | `typesafe`, `openrouter`, or `auto` (OpenRouter if its key is set) |
|
|
230
|
+
| `--jev-no-cache` | | off | Ask again instead of reusing cached answers |
|
|
231
|
+
| `--jev-require` | `jev_require` | off | Fail instead of skip when no key is set |
|
|
232
|
+
|
|
233
|
+
## CI and running without a key
|
|
234
|
+
|
|
235
|
+
- Tests that use `jev` get the `jev` marker automatically. `pytest -m "not jev"` runs everything
|
|
236
|
+
else offline.
|
|
237
|
+
- Without a key, `jev` tests are **skipped** and say why. In CI, pass `--jev-require` (or set
|
|
238
|
+
`jev_require = true`) so a missing secret fails the build instead.
|
|
239
|
+
- Answers are cached by model, text, context and question. Pass `--jev-no-cache` to ask again.
|
|
240
|
+
|
|
241
|
+
## Use another backend
|
|
242
|
+
|
|
243
|
+
Requests go through the session-scoped `jev_client` fixture. Override it in `conftest.py` with
|
|
244
|
+
anything that has the TypeSafe SDK's `system_one(state=, questions=, model=)` method, such as a fake
|
|
245
|
+
for offline unit tests, or [system-one-adapter](https://github.com/typesafe-ai/system-one-adapter-python)
|
|
246
|
+
to run the same assertions through an LLM and compare:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
@pytest.fixture(scope="session")
|
|
250
|
+
def jev_client():
|
|
251
|
+
return MyFakeJev()
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Writing claims that work
|
|
255
|
+
|
|
256
|
+
Jev reads claims literally ([Jev 1.13 known limits](https://docs.typesafe.ai/model-jaggedness/jev-1.13)):
|
|
257
|
+
|
|
258
|
+
- **One condition per claim.** Write "apologizes" and "offers a refund" as two claims, not one
|
|
259
|
+
joined with "and".
|
|
260
|
+
- **Say exactly what you mean.** "Says the duplicate payment was refunded" works better than
|
|
261
|
+
"handles the refund correctly".
|
|
262
|
+
- **Keep numbers, counts and dates in code.** `assert "5 business days" in reply` is exact; Jev is
|
|
263
|
+
not a calculator.
|
|
264
|
+
- **Name the context.** "contradicts `docs`" points Jev at the right part of the state.
|
|
265
|
+
|
|
266
|
+
## How it works
|
|
267
|
+
|
|
268
|
+
Each call sends one request to Jev's `/v1/systemone` endpoint with `state = {"text": text,
|
|
269
|
+
**context}`. Every claim becomes a Noul question, ``Does `text` satisfy: <claim>?``, which returns
|
|
270
|
+
the probability it is true. `choice` sends a Choice question and `score` sends a Score question.
|
|
271
|
+
The thresholds and comparisons are ordinary Python in this plugin.
|
|
272
|
+
|
|
273
|
+
## Limitations
|
|
274
|
+
|
|
275
|
+
- Jev can be wrong. Treat a threshold as a policy you tune on your own cases, and read the failure
|
|
276
|
+
report before trusting a pass or fail.
|
|
277
|
+
- Jev's probabilities move a little between calls. In five calls while this README was written,
|
|
278
|
+
"says the duplicate payment was refunded" scored between 0.16 and 0.23 on the same reply. The unsure band between 0.2 and 0.8 absorbs
|
|
279
|
+
this, and the cache keeps reruns identical.
|
|
280
|
+
- Text only: no images or audio.
|
|
281
|
+
- The text and context you assert on are sent to TypeSafe or OpenRouter. Keep secrets and personal
|
|
282
|
+
data out of test fixtures.
|
|
283
|
+
- Not affiliated with or endorsed by TypeSafe AI.
|
|
284
|
+
|
|
285
|
+
## Development
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
uv sync
|
|
289
|
+
uv run pytest # offline: a fake Jev answers every question
|
|
290
|
+
uv run ruff check .
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
MIT
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# pytest-jev
|
|
2
|
+
|
|
3
|
+
[](https://github.com/allebee/pytest-jev/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**Semantic assertions for pytest.** Test what your LLM app's output *means* ("apologizes",
|
|
6
|
+
"offers a refund", "doesn't leak the system prompt") instead of the exact words. Each claim is
|
|
7
|
+
judged by [Jev](https://docs.typesafe.ai/introduction), TypeSafe's model that returns calibrated
|
|
8
|
+
probabilities instead of text.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
def test_refund_reply(jev):
|
|
12
|
+
reply = support_bot("I was charged twice for order #1042.")
|
|
13
|
+
|
|
14
|
+
jev.expect(
|
|
15
|
+
reply,
|
|
16
|
+
holds=["apologizes to the customer", "says the duplicate payment was refunded"],
|
|
17
|
+
lacks=["blames the customer", "asks for a password or a full card number"],
|
|
18
|
+
)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
When a prompt change breaks the reply, the failure says which claim broke and how sure Jev was:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
> jev.expect(
|
|
25
|
+
E AssertionError: jev: 3 of 4 claims failed
|
|
26
|
+
E text: "Double charges happen when you click twice. You'll get store credit within 24 hours."
|
|
27
|
+
E ✗ holds p=0.04 apologizes to the customer (needs >= 0.80)
|
|
28
|
+
E ✗ holds p=0.21 says the duplicate payment was refunded (needs >= 0.80)
|
|
29
|
+
E ✗ lacks p=0.79 blames the customer (needs <= 0.20)
|
|
30
|
+
E ✓ lacks p=0.01 asks for a password or a full card number
|
|
31
|
+
------------------------------------- jev --------------------------------------
|
|
32
|
+
jev: 4 questions · 1 request · 365 input tokens · $0.000015 · 1.40 s in Jev · typesafe/jev-1.13-20260917 via openrouter
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Every output in this README is from a real run of [`examples/`](https://github.com/allebee/pytest-jev/tree/main/examples) against `jev-1.13`.
|
|
36
|
+
|
|
37
|
+
## Why
|
|
38
|
+
|
|
39
|
+
String assertions break every time the model rewords a reply. Using an LLM as the judge works,
|
|
40
|
+
but it's slow, costs real money per test, and returns text you then have to parse. pytest-jev
|
|
41
|
+
sends each check to Jev instead:
|
|
42
|
+
|
|
43
|
+
- **One request per text.** Every claim in `jev.expect` goes into a single request, answered in
|
|
44
|
+
parallel.
|
|
45
|
+
- **Typed answers.** Jev answers each claim with a probability, picks from the options you list, or
|
|
46
|
+
rates on the levels you define. There is no output to parse and nothing outside the answer space.
|
|
47
|
+
- **Cheap enough for every commit.** Jev costs $0.042 per million input tokens and output is free.
|
|
48
|
+
The 7 tests in [`examples/test_support_bot.py`](https://github.com/allebee/pytest-jev/blob/main/examples/test_support_bot.py) ran in 3.9 s for
|
|
49
|
+
$0.0001. The summary line prints the tokens and cost of every run.
|
|
50
|
+
- **No passing on a coin flip.** A claim `holds` at p ≥ 0.8 and `lacks` at p ≤ 0.2. When Jev is
|
|
51
|
+
unsure, both fail.
|
|
52
|
+
- **Free, stable reruns.** Answers are cached in `.pytest_cache`, so rerunning unchanged tests
|
|
53
|
+
makes no requests and gives the same verdicts.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install pytest-jev
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
It needs Python 3.10+ and pytest 7.4+.
|
|
62
|
+
|
|
63
|
+
Set one API key:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
export TYPESAFE_API_KEY=... # https://console.typesafe.ai (early access)
|
|
67
|
+
export OPENROUTER_API_KEY=... # or https://openrouter.ai/settings/keys (no waitlist)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Without a key, tests that use `jev` are skipped (see [CI](#ci-and-running-without-a-key)).
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
The `jev` fixture has five methods. Each sends one request and returns a result that works in a
|
|
75
|
+
plain `assert`.
|
|
76
|
+
|
|
77
|
+
### `holds` and `lacks`: one claim
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
def test_reply_confirms_the_refund(jev):
|
|
81
|
+
reply = support_bot("I was charged twice for order #1042.")
|
|
82
|
+
assert jev.holds(reply, "says the duplicate payment was refunded")
|
|
83
|
+
assert jev.lacks(reply, "asks for a password or a full card number")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
With the broken reply from above:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
E assert <jev holds 'says the duplicate payment was refunded': p=0.16, needs >= 0.80>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The result also carries the probability: `jev.holds(reply, "...").p`.
|
|
93
|
+
|
|
94
|
+
### `expect`: many claims, one request
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
jev.expect(reply, holds=["apologizes", "offers a refund"], lacks=["blames the customer"])
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
It fails with a report of every claim, as shown at the top. It returns the claims when they pass.
|
|
101
|
+
|
|
102
|
+
### `context`: check the text against something else
|
|
103
|
+
|
|
104
|
+
Extra state goes in `context`, such as a policy or the documents a RAG app retrieved. A claim can
|
|
105
|
+
name it in backticks:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
def test_reply_matches_the_policy(jev):
|
|
109
|
+
reply = support_bot("I was charged twice for order #1042.")
|
|
110
|
+
assert jev.lacks(reply, "contradicts the policy in `policy`", context={"policy": REFUND_POLICY})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The broken reply promises store credit in 24 hours; the policy says refunds to the card in 5
|
|
114
|
+
business days:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
E assert <jev lacks 'contradicts the policy in `policy`': p=0.95, needs <= 0.20>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The text under test is always `text`, so `context` can't use that key.
|
|
121
|
+
|
|
122
|
+
### `choice`: which option fits
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
TEAMS = {
|
|
126
|
+
"billing": "Payments, charges, invoices and refunds",
|
|
127
|
+
"technical": "Bugs, errors, crashes and integrations",
|
|
128
|
+
"account": "Logins, passwords and account settings",
|
|
129
|
+
"other": "Anything that fits none of the teams above",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_checkout_errors_go_to_billing(jev):
|
|
134
|
+
ticket = "Your checkout page throws a 500 error when I enter my card."
|
|
135
|
+
assert jev.choice(ticket, "Which team should handle this ticket?", TEAMS) == "billing"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
E AssertionError: assert jev chose 'technical', not 'billing'
|
|
140
|
+
E question: Which team should handle this ticket?
|
|
141
|
+
E technical 0.90 ██████████████████░░
|
|
142
|
+
E billing 0.10 ██░░░░░░░░░░░░░░░░░░
|
|
143
|
+
E account 0.00 ░░░░░░░░░░░░░░░░░░░░
|
|
144
|
+
E other 0.00 ░░░░░░░░░░░░░░░░░░░░
|
|
145
|
+
E confidence 0.87
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Sometimes the failure means the test's expectation needs another look: a 500 error at checkout is
|
|
149
|
+
arguably a bug first.
|
|
150
|
+
|
|
151
|
+
Options can be a dict of label to description, or a plain list of labels. Comparing with a label
|
|
152
|
+
that isn't an option (`team == "biling"`) raises an error instead of quietly failing.
|
|
153
|
+
|
|
154
|
+
### `score`: rate on ordered levels
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
POLITENESS = {
|
|
158
|
+
"rude": "Rude, dismissive or blaming the customer",
|
|
159
|
+
"neutral": "Neutral and matter-of-fact, no warmth",
|
|
160
|
+
"warm": "Warm and polite, acknowledges the customer's frustration",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_reply_is_warm(jev):
|
|
165
|
+
tone = jev.score(reply, "How polite is this support reply?", POLITENESS)
|
|
166
|
+
assert tone >= "warm"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Levels go lowest first. The comparison is probabilistic: `tone >= "warm"` passes when Jev puts at
|
|
170
|
+
least 80% of its probability on "warm" or higher. `>`, `<=`, `<`, `==` and `!=` work the same way,
|
|
171
|
+
with labels or level indices. The broken reply passes `tone >= "neutral"` (0.83) but not this:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
E AssertionError: assert jev gave P(level >= 'warm') = 0.00, needs >= 0.80
|
|
175
|
+
E question: How polite is this support reply?
|
|
176
|
+
E 0 rude 0.17 ███░░░░░░░░░░░░░░░░░
|
|
177
|
+
E 1 neutral 0.83 █████████████████░░░
|
|
178
|
+
E 2 warm 0.00 ░░░░░░░░░░░░░░░░░░░░
|
|
179
|
+
E expected level 0.84, confidence 0.75
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Thresholds and models
|
|
183
|
+
|
|
184
|
+
The threshold defaults to 0.8: `holds` needs p ≥ 0.8, `lacks` needs p ≤ 0.2. It must be between 0.5
|
|
185
|
+
and 1. Set it per call, per test, or for the whole run:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
assert jev.holds(reply, "offers a refund", threshold=0.9)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.jev(threshold=0.9, model="jev-1.13")
|
|
192
|
+
def test_strict(jev): ...
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```ini
|
|
196
|
+
# pytest.ini (or [tool.pytest.ini_options] in pyproject.toml)
|
|
197
|
+
[pytest]
|
|
198
|
+
jev_model = jev-1.13
|
|
199
|
+
jev_threshold = 0.85
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`jev-latest` changes when TypeSafe ships a new version, so pin `jev-1.13` when runs must be
|
|
203
|
+
reproducible.
|
|
204
|
+
|
|
205
|
+
| Option | ini | Default | |
|
|
206
|
+
|---|---|---|---|
|
|
207
|
+
| `--jev-model` | `jev_model` | `jev-latest` | Jev model to ask |
|
|
208
|
+
| `--jev-threshold` | `jev_threshold` | `0.8` | Claim threshold |
|
|
209
|
+
| `--jev-provider` | `jev_provider` | `auto` | `typesafe`, `openrouter`, or `auto` (OpenRouter if its key is set) |
|
|
210
|
+
| `--jev-no-cache` | | off | Ask again instead of reusing cached answers |
|
|
211
|
+
| `--jev-require` | `jev_require` | off | Fail instead of skip when no key is set |
|
|
212
|
+
|
|
213
|
+
## CI and running without a key
|
|
214
|
+
|
|
215
|
+
- Tests that use `jev` get the `jev` marker automatically. `pytest -m "not jev"` runs everything
|
|
216
|
+
else offline.
|
|
217
|
+
- Without a key, `jev` tests are **skipped** and say why. In CI, pass `--jev-require` (or set
|
|
218
|
+
`jev_require = true`) so a missing secret fails the build instead.
|
|
219
|
+
- Answers are cached by model, text, context and question. Pass `--jev-no-cache` to ask again.
|
|
220
|
+
|
|
221
|
+
## Use another backend
|
|
222
|
+
|
|
223
|
+
Requests go through the session-scoped `jev_client` fixture. Override it in `conftest.py` with
|
|
224
|
+
anything that has the TypeSafe SDK's `system_one(state=, questions=, model=)` method, such as a fake
|
|
225
|
+
for offline unit tests, or [system-one-adapter](https://github.com/typesafe-ai/system-one-adapter-python)
|
|
226
|
+
to run the same assertions through an LLM and compare:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
@pytest.fixture(scope="session")
|
|
230
|
+
def jev_client():
|
|
231
|
+
return MyFakeJev()
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Writing claims that work
|
|
235
|
+
|
|
236
|
+
Jev reads claims literally ([Jev 1.13 known limits](https://docs.typesafe.ai/model-jaggedness/jev-1.13)):
|
|
237
|
+
|
|
238
|
+
- **One condition per claim.** Write "apologizes" and "offers a refund" as two claims, not one
|
|
239
|
+
joined with "and".
|
|
240
|
+
- **Say exactly what you mean.** "Says the duplicate payment was refunded" works better than
|
|
241
|
+
"handles the refund correctly".
|
|
242
|
+
- **Keep numbers, counts and dates in code.** `assert "5 business days" in reply` is exact; Jev is
|
|
243
|
+
not a calculator.
|
|
244
|
+
- **Name the context.** "contradicts `docs`" points Jev at the right part of the state.
|
|
245
|
+
|
|
246
|
+
## How it works
|
|
247
|
+
|
|
248
|
+
Each call sends one request to Jev's `/v1/systemone` endpoint with `state = {"text": text,
|
|
249
|
+
**context}`. Every claim becomes a Noul question, ``Does `text` satisfy: <claim>?``, which returns
|
|
250
|
+
the probability it is true. `choice` sends a Choice question and `score` sends a Score question.
|
|
251
|
+
The thresholds and comparisons are ordinary Python in this plugin.
|
|
252
|
+
|
|
253
|
+
## Limitations
|
|
254
|
+
|
|
255
|
+
- Jev can be wrong. Treat a threshold as a policy you tune on your own cases, and read the failure
|
|
256
|
+
report before trusting a pass or fail.
|
|
257
|
+
- Jev's probabilities move a little between calls. In five calls while this README was written,
|
|
258
|
+
"says the duplicate payment was refunded" scored between 0.16 and 0.23 on the same reply. The unsure band between 0.2 and 0.8 absorbs
|
|
259
|
+
this, and the cache keeps reruns identical.
|
|
260
|
+
- Text only: no images or audio.
|
|
261
|
+
- The text and context you assert on are sent to TypeSafe or OpenRouter. Keep secrets and personal
|
|
262
|
+
data out of test fixtures.
|
|
263
|
+
- Not affiliated with or endorsed by TypeSafe AI.
|
|
264
|
+
|
|
265
|
+
## Development
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
uv sync
|
|
269
|
+
uv run pytest # offline: a fake Jev answers every question
|
|
270
|
+
uv run ruff check .
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT
|