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.
@@ -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
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .env
@@ -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
+ [![CI](https://github.com/allebee/pytest-jev/actions/workflows/ci.yml/badge.svg)](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
+ [![CI](https://github.com/allebee/pytest-jev/actions/workflows/ci.yml/badge.svg)](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