pytest-jev 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_jev/__init__.py +8 -0
- pytest_jev/judge.py +343 -0
- pytest_jev/plugin.py +155 -0
- pytest_jev/provider.py +56 -0
- pytest_jev/py.typed +0 -0
- pytest_jev/verdicts.py +259 -0
- pytest_jev-0.1.0.dist-info/METADATA +295 -0
- pytest_jev-0.1.0.dist-info/RECORD +11 -0
- pytest_jev-0.1.0.dist-info/WHEEL +4 -0
- pytest_jev-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_jev-0.1.0.dist-info/licenses/LICENSE +21 -0
pytest_jev/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Semantic assertions for pytest, judged by TypeSafe's Jev."""
|
|
2
|
+
|
|
3
|
+
from pytest_jev.judge import Jev
|
|
4
|
+
from pytest_jev.verdicts import ChoiceVerdict, Claim, Claims, ScoreVerdict
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = ["ChoiceVerdict", "Claim", "Claims", "Jev", "ScoreVerdict", "__version__"]
|
pytest_jev/judge.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""The `jev` fixture's object: builds Jev questions, caches answers and returns verdicts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from typesafe_sdk import RetryPolicy, TypeSafeAuthenticationError, TypeSafeClient, TypeSafeError
|
|
14
|
+
|
|
15
|
+
from pytest_jev.provider import ConfigError, resolve_provider
|
|
16
|
+
from pytest_jev.verdicts import ChoiceVerdict, Claim, Claims, ScoreVerdict
|
|
17
|
+
|
|
18
|
+
# Jev bills input tokens only: $0.042 per million (docs.typesafe.ai/models, checked 2026-09-21).
|
|
19
|
+
JEV_USD_PER_MTOK = 0.042
|
|
20
|
+
|
|
21
|
+
# Bump when the questions pytest-jev sends change meaning, so old cached answers are not reused.
|
|
22
|
+
CACHE_PREFIX = "jev/v1/"
|
|
23
|
+
|
|
24
|
+
MAX_CHOICE_OPTIONS = 255 # Jev's limit for one Choice question
|
|
25
|
+
|
|
26
|
+
# Exponential backoff on timeouts, rate limits and server errors, handled inside the SDK.
|
|
27
|
+
RETRY_POLICY = RetryPolicy(
|
|
28
|
+
max_retries=4,
|
|
29
|
+
backoff_initial=0.5,
|
|
30
|
+
backoff_max=8.0,
|
|
31
|
+
http_statuses={408, 429, *range(500, 600)},
|
|
32
|
+
timeout=60.0,
|
|
33
|
+
)
|
|
34
|
+
REQUEST_TIMEOUT = 30.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Client(Protocol):
|
|
38
|
+
"""Anything with the TypeSafe SDK's `system_one` signature, e.g. system-one-adapter's client."""
|
|
39
|
+
|
|
40
|
+
def system_one(self, *, state: Any, questions: Any, model: str) -> Any: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DefaultClient:
|
|
44
|
+
"""A TypeSafe client created on the first question, so a run without a key only skips the
|
|
45
|
+
tests that actually need Jev."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, provider: str = "auto") -> None:
|
|
48
|
+
self.provider = provider
|
|
49
|
+
self.name: str | None = None # the provider that answered, once known
|
|
50
|
+
self._client: TypeSafeClient | None = None
|
|
51
|
+
|
|
52
|
+
def system_one(self, *, state: Any, questions: Any, model: str) -> Any:
|
|
53
|
+
if self._client is None:
|
|
54
|
+
provider = resolve_provider(self.provider)
|
|
55
|
+
self._client = TypeSafeClient(
|
|
56
|
+
api_key=provider.api_key,
|
|
57
|
+
base_url=provider.base_url,
|
|
58
|
+
retry=RETRY_POLICY,
|
|
59
|
+
timeout=REQUEST_TIMEOUT,
|
|
60
|
+
)
|
|
61
|
+
self.name = provider.name
|
|
62
|
+
return self._client.system_one(state=state, questions=questions, model=model)
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
if self._client is not None:
|
|
66
|
+
self._client.close()
|
|
67
|
+
self._client = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Stats:
|
|
72
|
+
"""Totals for the run, printed in the terminal summary."""
|
|
73
|
+
|
|
74
|
+
questions: int = 0
|
|
75
|
+
requests: int = 0 # sent to Jev
|
|
76
|
+
cached: int = 0 # answered from .pytest_cache instead
|
|
77
|
+
input_tokens: int = 0
|
|
78
|
+
seconds: float = 0.0
|
|
79
|
+
models: set[str] = field(default_factory=set) # versioned model IDs that answered
|
|
80
|
+
providers: set[str] = field(default_factory=set)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def cost_usd(self) -> float:
|
|
84
|
+
return self.input_tokens * JEV_USD_PER_MTOK / 1_000_000
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class JevSession:
|
|
88
|
+
"""State shared by every `jev` fixture in a run: the answer cache and the usage totals."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, cache: Any, *, read_cache: bool = True, require_key: bool = False) -> None:
|
|
91
|
+
self.cache = cache # pytest's config.cache, or None with -p no:cacheprovider
|
|
92
|
+
self.read_cache = read_cache
|
|
93
|
+
self.require_key = require_key
|
|
94
|
+
self.stats = Stats()
|
|
95
|
+
|
|
96
|
+
def ask(
|
|
97
|
+
self, client: Client, model: str, state: dict[str, Any], questions: dict[str, Any]
|
|
98
|
+
) -> dict[str, dict[str, Any]]:
|
|
99
|
+
"""Answer every question about `state` in one request, or from the cache."""
|
|
100
|
+
__tracebackhide__ = True
|
|
101
|
+
key = cache_key(model, state, questions)
|
|
102
|
+
self.stats.questions += len(questions)
|
|
103
|
+
if self.read_cache and self.cache is not None:
|
|
104
|
+
hit = self.cache.get(key, None)
|
|
105
|
+
if isinstance(hit, dict) and set(questions) <= set(hit.get("answers", {})):
|
|
106
|
+
self.stats.cached += 1
|
|
107
|
+
self.stats.models.add(hit.get("model", model))
|
|
108
|
+
return hit["answers"]
|
|
109
|
+
|
|
110
|
+
started = time.perf_counter()
|
|
111
|
+
try:
|
|
112
|
+
response = client.system_one(state=state, questions=questions, model=model)
|
|
113
|
+
except ConfigError as exc:
|
|
114
|
+
if self.require_key:
|
|
115
|
+
pytest.fail(f"pytest-jev: {exc}", pytrace=False)
|
|
116
|
+
pytest.skip(f"pytest-jev: {exc}")
|
|
117
|
+
except TypeSafeAuthenticationError as exc:
|
|
118
|
+
pytest.fail(
|
|
119
|
+
f"pytest-jev: authentication failed, check your API key ({exc})", pytrace=False
|
|
120
|
+
)
|
|
121
|
+
except TypeSafeError as exc:
|
|
122
|
+
pytest.fail(f"pytest-jev: the Jev request failed: {exc}", pytrace=False)
|
|
123
|
+
elapsed = time.perf_counter() - started
|
|
124
|
+
|
|
125
|
+
missing = [name for name in questions if name not in response.answers]
|
|
126
|
+
if missing:
|
|
127
|
+
pytest.fail(f"pytest-jev: Jev did not answer {', '.join(missing)}", pytrace=False)
|
|
128
|
+
answers = {name: plain_answer(response.answers[name]) for name in questions}
|
|
129
|
+
|
|
130
|
+
stats = self.stats
|
|
131
|
+
stats.requests += 1
|
|
132
|
+
stats.seconds += elapsed
|
|
133
|
+
stats.input_tokens += getattr(response.usage, "input_tokens", None) or 0
|
|
134
|
+
stats.models.add(response.model)
|
|
135
|
+
if getattr(client, "name", None):
|
|
136
|
+
stats.providers.add(client.name)
|
|
137
|
+
if self.cache is not None:
|
|
138
|
+
self.cache.set(key, {"model": response.model, "answers": answers})
|
|
139
|
+
return answers
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cache_key(model: str, state: dict[str, Any], questions: dict[str, Any]) -> str:
|
|
143
|
+
payload = {"model": model, "state": state, "questions": questions}
|
|
144
|
+
try:
|
|
145
|
+
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
146
|
+
except (TypeError, ValueError) as exc:
|
|
147
|
+
raise TypeError(f"jev: the text and context must be JSON-serializable ({exc})") from None
|
|
148
|
+
return CACHE_PREFIX + hashlib.sha256(encoded.encode()).hexdigest()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def plain_answer(answer: Any) -> dict[str, Any]:
|
|
152
|
+
"""An SDK answer as a JSON-ready dict, the form the cache stores."""
|
|
153
|
+
if answer.type == "noul":
|
|
154
|
+
return {"type": "noul", "noul": float(answer.noul)}
|
|
155
|
+
probabilities = {str(key): float(p) for key, p in answer.probabilities.items()}
|
|
156
|
+
if answer.type == "choice":
|
|
157
|
+
return {
|
|
158
|
+
"type": "choice",
|
|
159
|
+
"choice": answer.choice,
|
|
160
|
+
"confidence": float(answer.confidence),
|
|
161
|
+
"probabilities": probabilities,
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
"type": "score",
|
|
165
|
+
"score": float(answer.score),
|
|
166
|
+
"confidence": float(answer.confidence),
|
|
167
|
+
"probabilities": probabilities,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def check_threshold(threshold: float) -> float:
|
|
172
|
+
# Below 0.5, a claim could pass both `holds` and `lacks` at once.
|
|
173
|
+
if not 0.5 <= threshold <= 1.0:
|
|
174
|
+
raise ValueError(f"jev threshold must be between 0.5 and 1, got {threshold}")
|
|
175
|
+
return float(threshold)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def claim_question(claim: str) -> str:
|
|
179
|
+
if not isinstance(claim, str) or not claim.strip():
|
|
180
|
+
raise ValueError("a jev claim must be a non-empty string")
|
|
181
|
+
return f"Does `text` satisfy: {claim.strip().rstrip('?.! ')}?"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_state(text: Any, context: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
185
|
+
state = {"text": text}
|
|
186
|
+
if context:
|
|
187
|
+
if "text" in context:
|
|
188
|
+
raise ValueError("jev context can't use the key 'text', it holds the text under test")
|
|
189
|
+
state.update(context)
|
|
190
|
+
return state
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def claim_list(claims: str | Iterable[str]) -> list[str]:
|
|
194
|
+
return [claims] if isinstance(claims, str) else list(claims)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_question(question: str) -> str:
|
|
198
|
+
if not isinstance(question, str) or not question.strip():
|
|
199
|
+
raise ValueError("a jev question must be a non-empty string")
|
|
200
|
+
return question.strip()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def option_criteria(options: Mapping[str, str | None] | Sequence[str]) -> dict[str, str | None]:
|
|
204
|
+
if isinstance(options, str):
|
|
205
|
+
raise ValueError("jev.choice takes a list of options or a dict of label -> description")
|
|
206
|
+
criteria = dict(options) if isinstance(options, Mapping) else dict.fromkeys(options)
|
|
207
|
+
if len(criteria) != len(options) or not 2 <= len(criteria) <= MAX_CHOICE_OPTIONS:
|
|
208
|
+
raise ValueError(f"jev.choice needs 2 to {MAX_CHOICE_OPTIONS} distinct options")
|
|
209
|
+
return criteria
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def score_levels(levels: Mapping[str, str] | Sequence[str]) -> tuple[tuple[str, ...], list[str]]:
|
|
213
|
+
"""Level labels for comparisons, and the descriptions Jev reads, lowest level first."""
|
|
214
|
+
if isinstance(levels, str):
|
|
215
|
+
raise ValueError("jev.score takes a list of levels or a dict of label -> description")
|
|
216
|
+
labels = tuple(levels)
|
|
217
|
+
descriptions = list(levels.values()) if isinstance(levels, Mapping) else list(levels)
|
|
218
|
+
if len(labels) < 2 or len(set(labels)) != len(labels):
|
|
219
|
+
raise ValueError("jev.score needs at least 2 distinct levels, lowest first")
|
|
220
|
+
return labels, descriptions
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Jev:
|
|
224
|
+
"""Semantic assertions about text, answered by TypeSafe's Jev. This is the `jev` fixture."""
|
|
225
|
+
|
|
226
|
+
def __init__(self, session: JevSession, client: Client, *, model: str, threshold: float):
|
|
227
|
+
self._session = session
|
|
228
|
+
self._client = client
|
|
229
|
+
self.model = model
|
|
230
|
+
self.threshold = check_threshold(threshold)
|
|
231
|
+
|
|
232
|
+
def __repr__(self) -> str:
|
|
233
|
+
return f"<jev model={self.model} threshold={self.threshold}>"
|
|
234
|
+
|
|
235
|
+
def holds(
|
|
236
|
+
self,
|
|
237
|
+
text: Any,
|
|
238
|
+
claim: str,
|
|
239
|
+
*,
|
|
240
|
+
context: Mapping[str, Any] | None = None,
|
|
241
|
+
threshold: float | None = None,
|
|
242
|
+
) -> Claim:
|
|
243
|
+
"""Is `claim` true of `text`? Truthy when Jev's probability is at least the threshold."""
|
|
244
|
+
__tracebackhide__ = True
|
|
245
|
+
return self._judge(text, [(claim, "holds")], context, threshold)[0]
|
|
246
|
+
|
|
247
|
+
def lacks(
|
|
248
|
+
self,
|
|
249
|
+
text: Any,
|
|
250
|
+
claim: str,
|
|
251
|
+
*,
|
|
252
|
+
context: Mapping[str, Any] | None = None,
|
|
253
|
+
threshold: float | None = None,
|
|
254
|
+
) -> Claim:
|
|
255
|
+
"""Is `claim` false of `text`? Truthy when Jev's probability is at most 1 - threshold."""
|
|
256
|
+
__tracebackhide__ = True
|
|
257
|
+
return self._judge(text, [(claim, "lacks")], context, threshold)[0]
|
|
258
|
+
|
|
259
|
+
def expect(
|
|
260
|
+
self,
|
|
261
|
+
text: Any,
|
|
262
|
+
*,
|
|
263
|
+
holds: str | Iterable[str] = (),
|
|
264
|
+
lacks: str | Iterable[str] = (),
|
|
265
|
+
context: Mapping[str, Any] | None = None,
|
|
266
|
+
threshold: float | None = None,
|
|
267
|
+
) -> Claims:
|
|
268
|
+
"""Check several claims in one request; fail with a report of every claim if any fails."""
|
|
269
|
+
__tracebackhide__ = True
|
|
270
|
+
pairs = [(claim, "holds") for claim in claim_list(holds)]
|
|
271
|
+
pairs += [(claim, "lacks") for claim in claim_list(lacks)]
|
|
272
|
+
if not pairs:
|
|
273
|
+
raise ValueError("jev.expect needs at least one claim in holds= or lacks=")
|
|
274
|
+
claims = self._judge(text, pairs, context, threshold)
|
|
275
|
+
if not claims.passed:
|
|
276
|
+
raise AssertionError(claims.report())
|
|
277
|
+
return claims
|
|
278
|
+
|
|
279
|
+
def choice(
|
|
280
|
+
self,
|
|
281
|
+
text: Any,
|
|
282
|
+
question: str,
|
|
283
|
+
options: Mapping[str, str | None] | Sequence[str],
|
|
284
|
+
*,
|
|
285
|
+
context: Mapping[str, Any] | None = None,
|
|
286
|
+
) -> ChoiceVerdict:
|
|
287
|
+
"""Which option fits `text`? Map labels to descriptions, or pass bare labels."""
|
|
288
|
+
__tracebackhide__ = True
|
|
289
|
+
question = check_question(question)
|
|
290
|
+
criteria = option_criteria(options)
|
|
291
|
+
asked = {"type": "choice", "instructions": question, "criteria": criteria}
|
|
292
|
+
answer = self._ask(text, context, {"choice": asked})["choice"]
|
|
293
|
+
probabilities = {label: answer["probabilities"].get(label, 0.0) for label in criteria}
|
|
294
|
+
return ChoiceVerdict(question, answer["choice"], probabilities, answer["confidence"])
|
|
295
|
+
|
|
296
|
+
def score(
|
|
297
|
+
self,
|
|
298
|
+
text: Any,
|
|
299
|
+
question: str,
|
|
300
|
+
levels: Mapping[str, str] | Sequence[str],
|
|
301
|
+
*,
|
|
302
|
+
context: Mapping[str, Any] | None = None,
|
|
303
|
+
threshold: float | None = None,
|
|
304
|
+
) -> ScoreVerdict:
|
|
305
|
+
"""Rate `text` on ordered levels, lowest first. Map short labels to descriptions, or pass
|
|
306
|
+
descriptions alone. Compare the result with a label: `assert tone >= "neutral"`."""
|
|
307
|
+
__tracebackhide__ = True
|
|
308
|
+
question = check_question(question)
|
|
309
|
+
threshold = self.threshold if threshold is None else check_threshold(threshold)
|
|
310
|
+
labels, descriptions = score_levels(levels)
|
|
311
|
+
asked = {"type": "score", "instructions": question, "criteria": descriptions}
|
|
312
|
+
answer = self._ask(text, context, {"score": asked})["score"]
|
|
313
|
+
spread = tuple(answer["probabilities"].get(str(i), 0.0) for i in range(len(labels)))
|
|
314
|
+
return ScoreVerdict(
|
|
315
|
+
question, labels, spread, answer["score"], answer["confidence"], threshold
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _judge(
|
|
319
|
+
self,
|
|
320
|
+
text: Any,
|
|
321
|
+
pairs: list[tuple[str, str]],
|
|
322
|
+
context: Mapping[str, Any] | None,
|
|
323
|
+
threshold: float | None,
|
|
324
|
+
) -> Claims:
|
|
325
|
+
__tracebackhide__ = True
|
|
326
|
+
threshold = self.threshold if threshold is None else check_threshold(threshold)
|
|
327
|
+
questions = {
|
|
328
|
+
f"claim_{i}": {"type": "noul", "instructions": claim_question(claim)}
|
|
329
|
+
for i, (claim, _) in enumerate(pairs)
|
|
330
|
+
}
|
|
331
|
+
answers = self._ask(text, context, questions)
|
|
332
|
+
claims = [
|
|
333
|
+
Claim(claim.strip(), expect, answers[f"claim_{i}"]["noul"], threshold)
|
|
334
|
+
for i, (claim, expect) in enumerate(pairs)
|
|
335
|
+
]
|
|
336
|
+
return Claims(claims, text)
|
|
337
|
+
|
|
338
|
+
def _ask(
|
|
339
|
+
self, text: Any, context: Mapping[str, Any] | None, questions: dict[str, Any]
|
|
340
|
+
) -> dict[str, dict[str, Any]]:
|
|
341
|
+
__tracebackhide__ = True
|
|
342
|
+
state = build_state(text, context)
|
|
343
|
+
return self._session.ask(self._client, self.model, state, questions)
|
pytest_jev/plugin.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""pytest hooks: options, the `jev` fixture, the automatic `jev` marker and the run summary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from pytest_jev.judge import Client, DefaultClient, Jev, JevSession, check_threshold
|
|
12
|
+
from pytest_jev.provider import PROVIDERS
|
|
13
|
+
from pytest_jev.verdicts import FLIPPED, ChoiceVerdict, ScoreVerdict
|
|
14
|
+
|
|
15
|
+
DEFAULT_MODEL = "jev-latest"
|
|
16
|
+
DEFAULT_THRESHOLD = 0.8
|
|
17
|
+
MARKER_OPTIONS = {"model", "threshold"}
|
|
18
|
+
|
|
19
|
+
SESSION = pytest.StashKey[JevSession]()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
23
|
+
group = parser.getgroup("jev", "semantic assertions judged by TypeSafe's Jev")
|
|
24
|
+
group.addoption(
|
|
25
|
+
"--jev-model",
|
|
26
|
+
help=f"Jev model (default {DEFAULT_MODEL}). Pin a version such as jev-1.13 "
|
|
27
|
+
"for reproducible runs.",
|
|
28
|
+
)
|
|
29
|
+
group.addoption(
|
|
30
|
+
"--jev-threshold",
|
|
31
|
+
type=float,
|
|
32
|
+
help=f"Probability a claim needs for jev.holds (default {DEFAULT_THRESHOLD}); "
|
|
33
|
+
"jev.lacks needs at most 1 minus this.",
|
|
34
|
+
)
|
|
35
|
+
group.addoption(
|
|
36
|
+
"--jev-provider",
|
|
37
|
+
choices=PROVIDERS,
|
|
38
|
+
help="Where Jev runs (default auto: OpenRouter if OPENROUTER_API_KEY is set, "
|
|
39
|
+
"else TypeSafe).",
|
|
40
|
+
)
|
|
41
|
+
group.addoption(
|
|
42
|
+
"--jev-no-cache",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="Ask Jev again instead of reusing answers cached in .pytest_cache.",
|
|
45
|
+
)
|
|
46
|
+
group.addoption(
|
|
47
|
+
"--jev-require",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="Fail tests that need Jev when no API key is set, instead of skipping them.",
|
|
50
|
+
)
|
|
51
|
+
parser.addini("jev_model", f"Jev model (default {DEFAULT_MODEL}).", default=DEFAULT_MODEL)
|
|
52
|
+
parser.addini("jev_threshold", "Default claim threshold (0.8).", default=str(DEFAULT_THRESHOLD))
|
|
53
|
+
parser.addini("jev_provider", "auto, openrouter or typesafe.", default="auto")
|
|
54
|
+
parser.addini("jev_require", "Fail instead of skip without an API key.", type="bool")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _setting(config: pytest.Config, name: str) -> Any:
|
|
58
|
+
value = config.getoption(f"--jev-{name}")
|
|
59
|
+
return config.getini(f"jev_{name}") if value is None else value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
63
|
+
config.addinivalue_line(
|
|
64
|
+
"markers",
|
|
65
|
+
"jev(threshold=None, model=None): a test that asks TypeSafe's Jev; "
|
|
66
|
+
"the keyword arguments override the run's defaults for that test.",
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
check_threshold(float(_setting(config, "threshold")))
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise pytest.UsageError(str(exc)) from None
|
|
72
|
+
if _setting(config, "provider") not in PROVIDERS:
|
|
73
|
+
raise pytest.UsageError(f"jev_provider must be one of: {', '.join(PROVIDERS)}")
|
|
74
|
+
config.stash[SESSION] = JevSession(
|
|
75
|
+
getattr(config, "cache", None),
|
|
76
|
+
read_cache=not config.getoption("--jev-no-cache"),
|
|
77
|
+
require_key=config.getoption("--jev-require") or config.getini("jev_require"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
|
|
82
|
+
# Mark every test that uses Jev, so `-m "not jev"` runs the rest offline.
|
|
83
|
+
for item in items:
|
|
84
|
+
if "jev" in getattr(item, "fixturenames", ()) and item.get_closest_marker("jev") is None:
|
|
85
|
+
item.add_marker("jev")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.fixture(scope="session")
|
|
89
|
+
def jev_client(pytestconfig: pytest.Config) -> Iterator[Client]:
|
|
90
|
+
"""Sends questions to Jev. Override it to use a fake or another backend."""
|
|
91
|
+
client = DefaultClient(_setting(pytestconfig, "provider"))
|
|
92
|
+
yield client
|
|
93
|
+
client.close()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture
|
|
97
|
+
def jev(request: pytest.FixtureRequest, jev_client: Client) -> Jev:
|
|
98
|
+
"""Semantic assertions judged by TypeSafe's Jev: holds, lacks, expect, choice and score."""
|
|
99
|
+
config = request.config
|
|
100
|
+
marker = request.node.get_closest_marker("jev")
|
|
101
|
+
overrides = dict(marker.kwargs) if marker else {}
|
|
102
|
+
unknown = set(overrides) - MARKER_OPTIONS
|
|
103
|
+
if unknown:
|
|
104
|
+
raise TypeError(f"@pytest.mark.jev got unknown options: {', '.join(sorted(unknown))}")
|
|
105
|
+
return Jev(
|
|
106
|
+
config.stash[SESSION],
|
|
107
|
+
jev_client,
|
|
108
|
+
model=overrides.get("model") or _setting(config, "model"),
|
|
109
|
+
threshold=float(overrides.get("threshold") or _setting(config, "threshold")),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def pytest_assertrepr_compare(op: str, left: object, right: object) -> list[str] | None:
|
|
114
|
+
if isinstance(left, (ChoiceVerdict, ScoreVerdict)):
|
|
115
|
+
return left.explain(op, right)
|
|
116
|
+
if isinstance(right, (ChoiceVerdict, ScoreVerdict)) and op in FLIPPED:
|
|
117
|
+
return right.explain(FLIPPED[op], left)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def pytest_terminal_summary(terminalreporter: Any, config: pytest.Config) -> None:
|
|
122
|
+
session = config.stash.get(SESSION, None)
|
|
123
|
+
if session is None or not session.stats.questions:
|
|
124
|
+
return
|
|
125
|
+
terminalreporter.write_sep("-", "jev")
|
|
126
|
+
terminalreporter.write_line(format_summary(session))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def format_summary(session: JevSession) -> str:
|
|
130
|
+
stats = session.stats
|
|
131
|
+
requests = f"{stats.requests} request{'' if stats.requests == 1 else 's'}"
|
|
132
|
+
if stats.cached:
|
|
133
|
+
requests += f" + {stats.cached} cached"
|
|
134
|
+
parts = [
|
|
135
|
+
f"{stats.questions} question{'' if stats.questions == 1 else 's'}",
|
|
136
|
+
requests,
|
|
137
|
+
f"{stats.input_tokens:,} input tokens",
|
|
138
|
+
format_usd(stats.cost_usd),
|
|
139
|
+
f"{stats.seconds:.2f} s in Jev",
|
|
140
|
+
]
|
|
141
|
+
if stats.models:
|
|
142
|
+
answered = ", ".join(sorted(stats.models))
|
|
143
|
+
if stats.providers:
|
|
144
|
+
answered += f" via {', '.join(sorted(stats.providers))}"
|
|
145
|
+
parts.append(answered)
|
|
146
|
+
return "jev: " + " · ".join(parts)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def format_usd(cost: float) -> str:
|
|
150
|
+
"""Two significant digits: a run of tests often costs a few millionths of a dollar."""
|
|
151
|
+
if cost <= 0:
|
|
152
|
+
return "$0"
|
|
153
|
+
if cost >= 0.01:
|
|
154
|
+
return f"${cost:.2f}"
|
|
155
|
+
return f"${cost:.{1 - math.floor(math.log10(cost))}f}"
|
pytest_jev/provider.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Choose where Jev requests go (OpenRouter or TypeSafe) and which key they use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
# The TypeSafe SDK appends /v1/systemone to this base URL.
|
|
10
|
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api"
|
|
11
|
+
|
|
12
|
+
OPENROUTER_KEY_ENV = "OPENROUTER_API_KEY"
|
|
13
|
+
TYPESAFE_KEY_ENV = "TYPESAFE_API_KEY"
|
|
14
|
+
|
|
15
|
+
PROVIDERS = ("auto", "openrouter", "typesafe")
|
|
16
|
+
|
|
17
|
+
NO_KEY_MESSAGE = f"no API key found, set {TYPESAFE_KEY_ENV} or {OPENROUTER_KEY_ENV}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigError(Exception):
|
|
21
|
+
"""A setup problem the user can fix, such as a missing key."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Provider:
|
|
26
|
+
name: str # "openrouter" or "typesafe"
|
|
27
|
+
api_key: str
|
|
28
|
+
base_url: str | None # None lets the SDK use TYPESAFE_BASE_URL or its default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def env_key(name: str, env: Mapping[str, str] | None = None) -> str | None:
|
|
32
|
+
value = (os.environ if env is None else env).get(name, "").strip()
|
|
33
|
+
return value or None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_provider(requested: str = "auto", env: Mapping[str, str] | None = None) -> Provider:
|
|
37
|
+
"""Pick the provider; `auto` prefers OpenRouter when both keys are set."""
|
|
38
|
+
openrouter_key = env_key(OPENROUTER_KEY_ENV, env)
|
|
39
|
+
typesafe_key = env_key(TYPESAFE_KEY_ENV, env)
|
|
40
|
+
if requested == "auto":
|
|
41
|
+
if openrouter_key:
|
|
42
|
+
requested = "openrouter"
|
|
43
|
+
elif typesafe_key:
|
|
44
|
+
requested = "typesafe"
|
|
45
|
+
else:
|
|
46
|
+
raise ConfigError(NO_KEY_MESSAGE)
|
|
47
|
+
|
|
48
|
+
if requested == "openrouter":
|
|
49
|
+
if not openrouter_key:
|
|
50
|
+
raise ConfigError(f"--jev-provider openrouter needs {OPENROUTER_KEY_ENV} to be set")
|
|
51
|
+
return Provider("openrouter", openrouter_key, OPENROUTER_BASE_URL)
|
|
52
|
+
if requested == "typesafe":
|
|
53
|
+
if not typesafe_key:
|
|
54
|
+
raise ConfigError(f"--jev-provider typesafe needs {TYPESAFE_KEY_ENV} to be set")
|
|
55
|
+
return Provider("typesafe", typesafe_key, None)
|
|
56
|
+
raise ConfigError(f"unknown provider {requested!r}; choose from {', '.join(PROVIDERS)}")
|
pytest_jev/py.typed
ADDED
|
File without changes
|
pytest_jev/verdicts.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""What the `jev` fixture returns, and the text pytest shows when an assertion on it fails."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, overload
|
|
8
|
+
|
|
9
|
+
# Probabilities are floats: 1 - 0.8 is 0.19999999999999996, and level probabilities sum to 0.9999.
|
|
10
|
+
EPSILON = 1e-9
|
|
11
|
+
BAR_WIDTH = 20
|
|
12
|
+
PREVIEW_CHARS = 120
|
|
13
|
+
|
|
14
|
+
OPERATORS = (">=", ">", "<=", "<", "==", "!=")
|
|
15
|
+
# The same comparison with the operands swapped: `"neutral" <= tone` means `tone >= "neutral"`.
|
|
16
|
+
FLIPPED = {">=": "<=", ">": "<", "<=": ">=", "<": ">", "==": "==", "!=": "!="}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def bar(p: float) -> str:
|
|
20
|
+
filled = round(max(0.0, min(1.0, p)) * BAR_WIDTH)
|
|
21
|
+
return "█" * filled + "░" * (BAR_WIDTH - filled)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def preview(text: Any) -> str:
|
|
25
|
+
shown = text if isinstance(text, str) else repr(text)
|
|
26
|
+
shown = " ".join(shown.split())
|
|
27
|
+
if len(shown) > PREVIEW_CHARS:
|
|
28
|
+
shown = shown[: PREVIEW_CHARS - 3] + "..."
|
|
29
|
+
return repr(shown) if isinstance(text, str) else shown
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Claim:
|
|
34
|
+
"""A yes/no claim about the text and Jev's probability that it is true. Truthy if it passed.
|
|
35
|
+
|
|
36
|
+
`holds` needs p >= threshold; `lacks` needs p <= 1 - threshold. A probability in between
|
|
37
|
+
fails both: Jev is unsure, and a test should not pass on a coin flip.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
claim: str
|
|
41
|
+
expect: str # "holds" or "lacks"
|
|
42
|
+
p: float # probability that the claim is true
|
|
43
|
+
threshold: float
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def passed(self) -> bool:
|
|
47
|
+
if self.expect == "holds":
|
|
48
|
+
return self.p >= self.threshold - EPSILON
|
|
49
|
+
return self.p <= 1 - self.threshold + EPSILON
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def needs(self) -> str:
|
|
53
|
+
if self.expect == "holds":
|
|
54
|
+
return f">= {self.threshold:.2f}"
|
|
55
|
+
return f"<= {1 - self.threshold:.2f}"
|
|
56
|
+
|
|
57
|
+
def __bool__(self) -> bool:
|
|
58
|
+
return self.passed
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return f"<jev {self.expect} {self.claim!r}: p={self.p:.2f}, needs {self.needs}>"
|
|
62
|
+
|
|
63
|
+
def report_line(self) -> str:
|
|
64
|
+
mark = "✓" if self.passed else "✗"
|
|
65
|
+
needs = "" if self.passed else f" (needs {self.needs})"
|
|
66
|
+
return f" {mark} {self.expect} p={self.p:.2f} {self.claim}{needs}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Claims(Sequence[Claim]):
|
|
70
|
+
"""The claims from one `jev.expect` call, all answered in one request."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, claims: Sequence[Claim], text: Any) -> None:
|
|
73
|
+
self._claims = tuple(claims)
|
|
74
|
+
self.text = text
|
|
75
|
+
|
|
76
|
+
@overload
|
|
77
|
+
def __getitem__(self, index: int) -> Claim: ...
|
|
78
|
+
@overload
|
|
79
|
+
def __getitem__(self, index: slice) -> Sequence[Claim]: ...
|
|
80
|
+
def __getitem__(self, index: int | slice) -> Claim | Sequence[Claim]:
|
|
81
|
+
return self._claims[index]
|
|
82
|
+
|
|
83
|
+
def __len__(self) -> int:
|
|
84
|
+
return len(self._claims)
|
|
85
|
+
|
|
86
|
+
def __iter__(self) -> Iterator[Claim]:
|
|
87
|
+
return iter(self._claims)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def failed(self) -> list[Claim]:
|
|
91
|
+
return [claim for claim in self._claims if not claim.passed]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def passed(self) -> bool:
|
|
95
|
+
return not self.failed
|
|
96
|
+
|
|
97
|
+
def __bool__(self) -> bool:
|
|
98
|
+
return self.passed
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f"<jev {len(self._claims) - len(self.failed)} of {len(self._claims)} claims passed>"
|
|
102
|
+
|
|
103
|
+
def report(self) -> str:
|
|
104
|
+
failed = len(self.failed)
|
|
105
|
+
noun = "claim" if len(self._claims) == 1 else "claims"
|
|
106
|
+
head = (
|
|
107
|
+
f"jev: {failed} of {len(self._claims)} {noun} failed"
|
|
108
|
+
if failed
|
|
109
|
+
else f"jev: all {len(self._claims)} {noun} passed"
|
|
110
|
+
)
|
|
111
|
+
lines = [head, f"text: {preview(self.text)}"]
|
|
112
|
+
lines += [claim.report_line() for claim in self._claims]
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, eq=False)
|
|
117
|
+
class ChoiceVerdict:
|
|
118
|
+
"""Jev's pick from a set of options. Compare it with an option label: `verdict == "billing"`."""
|
|
119
|
+
|
|
120
|
+
question: str
|
|
121
|
+
choice: str
|
|
122
|
+
probabilities: Mapping[str, float] # every option, in the order they were given
|
|
123
|
+
confidence: float
|
|
124
|
+
|
|
125
|
+
def label(self, label: object) -> str:
|
|
126
|
+
if not isinstance(label, str) or label not in self.probabilities:
|
|
127
|
+
options = ", ".join(map(repr, self.probabilities))
|
|
128
|
+
raise ValueError(f"{label!r} is not one of the options: {options}")
|
|
129
|
+
return label
|
|
130
|
+
|
|
131
|
+
def p(self, label: str) -> float:
|
|
132
|
+
"""The probability Jev gave `label`."""
|
|
133
|
+
return self.probabilities[self.label(label)]
|
|
134
|
+
|
|
135
|
+
def __eq__(self, other: object) -> bool:
|
|
136
|
+
if isinstance(other, str):
|
|
137
|
+
return self.choice == self.label(other)
|
|
138
|
+
return NotImplemented
|
|
139
|
+
|
|
140
|
+
__hash__ = None # type: ignore[assignment]
|
|
141
|
+
|
|
142
|
+
def ranked(self) -> list[tuple[str, float]]:
|
|
143
|
+
return sorted(self.probabilities.items(), key=lambda item: -item[1])
|
|
144
|
+
|
|
145
|
+
def __repr__(self) -> str:
|
|
146
|
+
ranked = ", ".join(f"{label} {p:.2f}" for label, p in self.ranked())
|
|
147
|
+
return f"<jev choice {self.choice!r} ({ranked})>"
|
|
148
|
+
|
|
149
|
+
def explain(self, op: str, other: object) -> list[str] | None:
|
|
150
|
+
if op not in ("==", "!=") or not isinstance(other, str):
|
|
151
|
+
return None
|
|
152
|
+
if op == "==":
|
|
153
|
+
head = f"jev chose {self.choice!r}, not {other!r}"
|
|
154
|
+
else:
|
|
155
|
+
head = f"jev chose {self.choice!r}"
|
|
156
|
+
width = max(len(label) for label in self.probabilities)
|
|
157
|
+
lines = [head, f"question: {self.question}"]
|
|
158
|
+
lines += [f" {label:<{width}} {p:.2f} {bar(p)}" for label, p in self.ranked()]
|
|
159
|
+
lines.append(f"confidence {self.confidence:.2f}")
|
|
160
|
+
return lines
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True, eq=False)
|
|
164
|
+
class ScoreVerdict:
|
|
165
|
+
"""Jev's rating on an ordered rubric. Compare it with a level label or index.
|
|
166
|
+
|
|
167
|
+
Comparisons are probabilistic: `tone >= "neutral"` passes when Jev puts at least `threshold`
|
|
168
|
+
of its probability on "neutral" or a higher level. `expected` is Jev's raw expected level.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
question: str
|
|
172
|
+
levels: tuple[str, ...] # labels, lowest level first
|
|
173
|
+
probabilities: tuple[float, ...] # one per level
|
|
174
|
+
expected: float
|
|
175
|
+
confidence: float
|
|
176
|
+
threshold: float
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def level(self) -> str:
|
|
180
|
+
"""The most likely level."""
|
|
181
|
+
best = max(range(len(self.levels)), key=lambda i: self.probabilities[i])
|
|
182
|
+
return self.levels[best]
|
|
183
|
+
|
|
184
|
+
def index(self, level: object) -> int:
|
|
185
|
+
if isinstance(level, str):
|
|
186
|
+
if level not in self.levels:
|
|
187
|
+
levels = ", ".join(map(repr, self.levels))
|
|
188
|
+
raise ValueError(f"{level!r} is not one of the levels: {levels}")
|
|
189
|
+
return self.levels.index(level)
|
|
190
|
+
if isinstance(level, int) and not isinstance(level, bool):
|
|
191
|
+
if not 0 <= level < len(self.levels):
|
|
192
|
+
raise ValueError(f"level {level} is out of range 0..{len(self.levels) - 1}")
|
|
193
|
+
return level
|
|
194
|
+
raise TypeError(f"compare a score with a level label or index, not {type(level).__name__}")
|
|
195
|
+
|
|
196
|
+
def mass(self, op: str, level: object) -> float:
|
|
197
|
+
"""The probability Jev puts on the levels that satisfy `score <op> level`."""
|
|
198
|
+
i, n = self.index(level), len(self.levels)
|
|
199
|
+
if op == "!=":
|
|
200
|
+
return 1 - self.probabilities[i]
|
|
201
|
+
first, last = {
|
|
202
|
+
">=": (i, n),
|
|
203
|
+
">": (i + 1, n),
|
|
204
|
+
"<=": (0, i + 1),
|
|
205
|
+
"<": (0, i),
|
|
206
|
+
"==": (i, i + 1),
|
|
207
|
+
}[op]
|
|
208
|
+
return sum(self.probabilities[first:last])
|
|
209
|
+
|
|
210
|
+
def _holds(self, op: str, level: object) -> bool:
|
|
211
|
+
return self.mass(op, level) >= self.threshold - EPSILON
|
|
212
|
+
|
|
213
|
+
def __ge__(self, level: object) -> bool:
|
|
214
|
+
return self._holds(">=", level)
|
|
215
|
+
|
|
216
|
+
def __gt__(self, level: object) -> bool:
|
|
217
|
+
return self._holds(">", level)
|
|
218
|
+
|
|
219
|
+
def __le__(self, level: object) -> bool:
|
|
220
|
+
return self._holds("<=", level)
|
|
221
|
+
|
|
222
|
+
def __lt__(self, level: object) -> bool:
|
|
223
|
+
return self._holds("<", level)
|
|
224
|
+
|
|
225
|
+
def __eq__(self, level: object) -> bool:
|
|
226
|
+
if isinstance(level, (int, str)):
|
|
227
|
+
return self._holds("==", level)
|
|
228
|
+
return NotImplemented
|
|
229
|
+
|
|
230
|
+
def __ne__(self, level: object) -> bool:
|
|
231
|
+
if isinstance(level, (int, str)):
|
|
232
|
+
return self._holds("!=", level)
|
|
233
|
+
return NotImplemented
|
|
234
|
+
|
|
235
|
+
__hash__ = None # type: ignore[assignment]
|
|
236
|
+
|
|
237
|
+
def __repr__(self) -> str:
|
|
238
|
+
spread = ", ".join(f"{p:.2f}" for p in self.probabilities)
|
|
239
|
+
return f"<jev score {self.level!r} (expected level {self.expected:.2f}; p=[{spread}])>"
|
|
240
|
+
|
|
241
|
+
def explain(self, op: str, other: object) -> list[str] | None:
|
|
242
|
+
if op not in OPERATORS or not isinstance(other, (int, str)) or isinstance(other, bool):
|
|
243
|
+
return None
|
|
244
|
+
try:
|
|
245
|
+
mass = self.mass(op, other)
|
|
246
|
+
except (ValueError, TypeError):
|
|
247
|
+
return None
|
|
248
|
+
target = self.levels[self.index(other)]
|
|
249
|
+
width = max(len(label) for label in self.levels)
|
|
250
|
+
lines = [
|
|
251
|
+
f"jev gave P(level {op} {target!r}) = {mass:.2f}, needs >= {self.threshold:.2f}",
|
|
252
|
+
f"question: {self.question}",
|
|
253
|
+
]
|
|
254
|
+
lines += [
|
|
255
|
+
f" {i} {label:<{width}} {p:.2f} {bar(p)}"
|
|
256
|
+
for i, (label, p) in enumerate(zip(self.levels, self.probabilities, strict=True))
|
|
257
|
+
]
|
|
258
|
+
lines.append(f"expected level {self.expected:.2f}, confidence {self.confidence:.2f}")
|
|
259
|
+
return lines
|
|
@@ -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,11 @@
|
|
|
1
|
+
pytest_jev/__init__.py,sha256=lR5ZRWlWK9VyxUd4uvJ9T1wvyxIyIx0JMVjdt_Wf33Y,282
|
|
2
|
+
pytest_jev/judge.py,sha256=mKKpSIlJoKXNc1EzPsYelJqeuvWzCmKmX3-zEaUwIoM,13581
|
|
3
|
+
pytest_jev/plugin.py,sha256=FKs8r7X4H4K_p07fJlBSOEU1C0h4DTuPSZocnLRqNao,5818
|
|
4
|
+
pytest_jev/provider.py,sha256=aqwR0LisIDGO4FcqN-9UwIG-C07xYFo63viVFVhj8ZI,2022
|
|
5
|
+
pytest_jev/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pytest_jev/verdicts.py,sha256=dbL63I5U92pLJmtGrGeSxmW5Kb8eIgURNyOmeiqKxwA,9296
|
|
7
|
+
pytest_jev-0.1.0.dist-info/METADATA,sha256=l-hiSUZf09cY7iuuuSFQd5NI-u5M_4Q_zD10s4CWtlw,11668
|
|
8
|
+
pytest_jev-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
pytest_jev-0.1.0.dist-info/entry_points.txt,sha256=qepy4DZCOZny6tbd53MfEiONz6kK21t1fmXoK2Pl4Yo,35
|
|
10
|
+
pytest_jev-0.1.0.dist-info/licenses/LICENSE,sha256=pd8fkxTemEajmHsyHVVp2IYDqQPmWGk-A9IqinkZETc,1064
|
|
11
|
+
pytest_jev-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|