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 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
+ [![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,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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ jev = pytest_jev.plugin
@@ -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.