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.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: jev
3
+ Version: 0.1.0
4
+ Summary: Decorator that compiles Python function definitions into Jev (TypeSafe System One) queries
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: jinja2>=3.1.6
8
+ Requires-Dist: pydantic>=2.13.5
9
+ Requires-Dist: python-dotenv>=1.2.3
10
+ Requires-Dist: typesafe-sdk>=0.6.0
11
+
12
+ # jev
13
+
14
+ `@jev` turns a Python function definition into a query against [Jev](https://typesafe.ai), TypeSafe's System One model. You declare the function (parameters, docstring, return annotation) and the decorator compiles it into a `state` + typed `questions` request. Calling the function sends the request and returns a validated instance of the return annotation.
15
+
16
+ Jev generates no text. It answers typed questions about a state (yes/no probabilities, choices, scores) in one parallel call, with calibrated probabilities. That constraint drives the design: the return annotation must be a Pydantic model, and each of its fields maps onto one of Jev's three question types.
17
+
18
+ A function signature is already a complete specification of a decision. The name says what to decide, the parameters say what to decide it from, and the return annotation says what shape the answer takes; the docstring supplies the judgment. `@jev` treats that specification as sufficient and lets Jev fill in the body. The spec is made of things you already write: a signature, a docstring, a Pydantic model. There is no prompt string to maintain, no JSON schema to keep in sync, no parsing layer between the call and the answer.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ uv sync
24
+ ```
25
+
26
+ Python 3.14. Get an API key from [console.typesafe.ai](https://console.typesafe.ai) and put it in `.env`:
27
+
28
+ ```sh
29
+ TYPESAFE_API_KEY=...
30
+ ```
31
+
32
+ ## The pattern
33
+
34
+ ```python
35
+ from typing import Literal
36
+ from pydantic import BaseModel, Field
37
+ from jev import jev
38
+
39
+ class Triage(BaseModel):
40
+ department: Literal["billing", "technical", "sales"]
41
+ is_urgent: bool
42
+ frustration: int = Field(ge=0, le=2)
43
+
44
+ @jev
45
+ def triage(ticket: str) -> Triage:
46
+ """A customer support ticket:
47
+
48
+ {{ ticket }}
49
+ """
50
+ return triage.state()
51
+
52
+ triage("I was charged twice. Fix this NOW.")
53
+ # Triage(department='billing', is_urgent=True, frustration=2)
54
+ ```
55
+
56
+ What happens:
57
+
58
+ 1. At decoration time, the return annotation is checked (must be a `BaseModel` subclass; anything else raises `TypeError` at import rather than at first call) and each field is compiled into a question.
59
+ 2. At call time, the docstring is rendered as a Jinja2 template with the bound arguments and sent as the `state`. With no docstring, the arguments themselves are sent as a JSON state.
60
+ 3. The answers are coerced back and validated by pydantic. For the body-less form, write the body as `return triage.state()`; it type-checks like any other return and leaves the docstring as the whole state. A bare `...` or `raise NotImplementedError` also works.
61
+
62
+ For the body-less form the docstring does all the work, which may make it the only place in Python where documentation outranks implementation.
63
+
64
+ ## Field mapping
65
+
66
+ | Field type | Jev question | Coerced back as |
67
+ |---|---|---|
68
+ | `bool` | Noul | `p(yes) >= threshold` (default 0.5) |
69
+ | `Literal[...]` | Choice | the selected label |
70
+ | `Enum` | Choice | the selected member |
71
+ | `int` with `Field(ge=, le=)` | Score | `lo + round(expected_score)` |
72
+ | `float` with `Field(ge=, le=)` | Score | linear interpolation over the levels |
73
+
74
+ - `Field(description=...)` becomes the question's instructions; without one the field name is humanized (`is_urgent` → "is urgent"). Write descriptions; they are the questions.
75
+ - Score levels default to the numbers in range. Override them with `Field(..., json_schema_extra={"levels": ["cold", "warm", "hot"]})`.
76
+ - Limits: 255 options per choice, 256 levels per score. Exceeding either is a `TypeError` at decoration time.
77
+ - Anything else (`str`, nested models, lists, `Optional`) raises `TypeError` at decoration time, because Jev cannot produce those values.
78
+
79
+ ## Evaluated bodies
80
+
81
+ The body always runs, and there are three useful things it can do:
82
+
83
+ - **Return `fn.state()` with no value**: the body-less form; the rendered docstring is the whole state. (`...` or `raise NotImplementedError` work too.)
84
+ - **Build the state** with `return fn.state(value)`: loops, conditionals, f-strings, whatever Python you need; the returned value is the state, verbatim, and the docstring is documentation in this form.
85
+ - **Answer directly** with `return Model(...)`: skips the API call entirely, the mock seam for tests.
86
+
87
+ Building the state looks like this:
88
+
89
+ ```python
90
+ @jev
91
+ def triage_batch(tickets: list[str]) -> BatchTriage:
92
+ """Triage a batch of support tickets."""
93
+ numbered = [f"[{i}] {t}" for i, t in enumerate(tickets)]
94
+ return triage_batch.state(
95
+ "Triage this batch of support tickets.\n\n" + "\n".join(numbered)
96
+ )
97
+ ```
98
+
99
+ Framing like "Triage this batch" lives in the body now, in the open, rather than being lifted out of the docstring.
100
+
101
+ `fn.state(...)` is typed `value -> return-annotation`, so the body's `return` type-checks against the annotation.
102
+
103
+ Async functions work identically (`await` the call; the body may also `await`). Configure per function with `@jev(model="jev-latest", client=...)`; the default client reads `TYPESAFE_API_KEY` and calls `jev-latest`. The bool threshold defaults to 0.5; tune it per function with `@jev(bool_threshold=0.7)` or globally with the `JEV_BOOL_THRESHOLD` environment variable.
104
+
105
+ ## Batch with `.map`
106
+
107
+ `triage.map(tickets)` applies the function to each item in ONE call: the items become a JSON state array, the fields become one set of questions per item, and Jev answers all of them in parallel. The result is a `list[Triage]` in input order. Each item runs through the same body machinery as a direct call, so short-circuited items (`return Model(...)`) skip the API and slot into the results, and evaluated bodies run per item. Items must bind as the function's only positional argument. Batches are a single request: hundreds of questions per call have worked in TypeSafe's own cookbooks, but there is no documented limit, so chunk very large batches yourself.
108
+
109
+ ```python
110
+ triage.map(tickets) # sync: list[Triage]
111
+ await atriage.map(tickets) # async: list[Triage]
112
+ ```
113
+
114
+ ## Class form: `JevModel`
115
+
116
+ For the common case of one blob of state in, one struct out, there is a class interface (the same field machinery, no docstring involved):
117
+
118
+ ```python
119
+ from jev import JevModel
120
+
121
+ class Triage(JevModel):
122
+ department: Literal["billing", "technical", "sales"]
123
+ is_urgent: bool
124
+ frustration: int = Field(ge=0, le=2)
125
+
126
+ Triage.decide("I was charged twice. Fix this NOW.")
127
+ # Triage(department='billing', is_urgent=True, frustration=2)
128
+
129
+ await Triage.adecide("...") # async form
130
+ Triage(department="billing", is_urgent=False, frustration=0) # plain constructor: no API call
131
+ ```
132
+
133
+ Fields compile at class definition (unsupported types raise at import). Class attributes `__jev_model__` and `__jev_bool_threshold__` pin the model and threshold per class. Deciding is a classmethod rather than a constructor overload because pydantic's `dataclass_transform` synthesizes a field-only `__init__` for subclasses in both mypy and pyright; the classmethod keeps the call typed as `-> Self` in both.
134
+
135
+ ## Testing
136
+
137
+ ```python
138
+ from jev import builder, state_payload
139
+
140
+ marker = builder(triage_batch)(["a", "b"]) # runs the body, no API call
141
+ assert state_payload(marker) == "Triage this batch of support tickets.\n\n[0] a\n[1] b"
142
+ ```
143
+
144
+ Or return a model from the body to short-circuit the call in tests.
145
+
146
+ ## Type checking
147
+
148
+ The decorated function has type `JevFn[P, R]` (or `AsyncJevFn[P, R]` for async), so call sites see the original parameter signature and the declared return model, and `fn.state` returns the same model. Bare `@jev` rejects a non-`BaseModel` return annotation statically, before any code runs. The package passes `pyright --strict` and `mypy --strict` with no casts and no ignore comments.
149
+
150
+ See `example.py` for a runnable tour (`uv run python example.py`).
151
+
152
+ ## Limitations
153
+
154
+ - **Probabilities are discarded.** `bool` is thresholded (0.5 by default, tunable), Choice takes the argmax, Score returns the expected value. Jev's calibrated probabilities and confidence scores never reach you; if you need them (confidence-gated routing is the main reason to use Jev), use `typesafe_sdk` directly.
155
+ - **No streaming.** Jev samples in parallel in a single shot, so there is nothing to stream.
156
+ - **Legacy sentinel.** `raise NotImplementedError` (or `...`) still marks a body-less function, so a body that raises it incidentally is silently treated as body-less. Prefer `return fn.state()`, which carries no such ambiguity.
157
+ - **Clients live for the process lifetime.** The default sync client is created lazily and never closed; async clients are created per event loop (connection pools are loop-bound) and never closed.
158
+ - **The docstring is a template rather than documentation.** `help(fn)` shows Jinja. If that bothers you, keep the docstring minimal and do the work in an evaluated body.
@@ -0,0 +1,5 @@
1
+ jev.py,sha256=IUfCKwfhdRUHTDU4KwgYx4H7_ZIa6byBu4GPmqyQ7TI,33154
2
+ jev-0.1.0.dist-info/METADATA,sha256=jz1wr2PUH1kVuHm4s8oUjspTb0PM30Cye_efffKWBaU,9091
3
+ jev-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ jev-0.1.0.dist-info/top_level.txt,sha256=ORnGWwjfMbRQNUTRfKWddwrIMbypIGzTr2qOfyrSR7M,4
5
+ jev-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ jev
jev.py ADDED
@@ -0,0 +1,871 @@
1
+ """`@jev`: turn a Python function into a Jev (System One) structured-decision call.
2
+
3
+ The decorated function is never executed; it is the *specification* of a query:
4
+
5
+ - The docstring is a Jinja2 template. At call time it is rendered with the
6
+ bound arguments and sent as the System One ``state``.
7
+ - The return annotation must be a ``pydantic.BaseModel`` subclass. Each field
8
+ becomes a typed question:
9
+
10
+ ================================ ====== ==============================
11
+ Field type Jev Coerced back as
12
+ ================================ ====== ==============================
13
+ ``bool`` Noul ``probability_yes >= 0.5``
14
+ ``Literal[...]`` / ``Enum`` Choice the selected label / member
15
+ ``int`` with ``Field(ge, le)`` Score ``lo + round(expected_score)``
16
+ ``float`` with ``Field(ge, le)`` Score linear interpolation over levels
17
+ ================================ ====== ==============================
18
+
19
+ Field ``description`` becomes the question's instructions. Score level
20
+ labels default to the numbers in range; override them with
21
+ ``Field(..., json_schema_extra={"levels": [...]})``.
22
+ - The answers are validated and returned as an instance of the return model.
23
+
24
+ The body always runs, and there are three useful things it can do:
25
+
26
+ - ``return fn.state()``: the body-less form; the rendered docstring is the
27
+ whole state. A bare ``...`` or ``raise NotImplementedError`` also works.
28
+ - ``return fn.state(value)``: the body builds the state with full Python
29
+ (loops, conditionals, f-strings, even ``await``). The decorated function's
30
+ ``state`` method is typed ``value -> return-annotation``, so the body's
31
+ return type-checks; the wrapper unwraps the marker and sends ``value`` as
32
+ the state exactly as returned (the docstring is documentation in this
33
+ form and is not sent). ``builder(fn)`` is the pure state-builder and can
34
+ be unit-tested without any API call.
35
+ - ``return Model(...)``: construct the answer yourself and the API call is
36
+ skipped entirely (a mock seam for tests).
37
+
38
+ Works on sync and async functions, bare (``@jev``) or configured
39
+ (``@jev(model=..., client=...)``). The client defaults to the
40
+ ``TYPESAFE_API_KEY`` environment variable and ``jev-latest``.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import asyncio
46
+ import inspect
47
+ import json
48
+ import os
49
+ import typing
50
+ import weakref
51
+ from collections.abc import Awaitable, Callable, Coroutine, Sequence
52
+ from dataclasses import dataclass
53
+ from enum import Enum
54
+ from functools import wraps
55
+ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, ParamSpec, Protocol, Self, TypeIs, TypeVar, cast, get_args, get_origin, overload
56
+
57
+ import annotated_types
58
+ import jinja2
59
+ from pydantic import BaseModel
60
+
61
+ if TYPE_CHECKING:
62
+ from pydantic.fields import FieldInfo
63
+ from typesafe_sdk import ChoiceAnswer, JSONContent, NoulAnswer, ScoreAnswer, SystemOneResponse
64
+
65
+ from typesafe_sdk import AsyncTypeSafeClient, Choice, Noul, Score, TypeSafeClient
66
+
67
+ __all__ = ["jev", "JevFn", "AsyncJevFn", "JevModel", "state_payload", "builder"]
68
+
69
+ P = ParamSpec("P")
70
+ R = TypeVar("R", bound=BaseModel)
71
+ class JevFn(Protocol[P, R]):
72
+ """A sync ``@jev``-decorated function: call it to query Jev.
73
+
74
+ ``state`` builds the state marker a body returns via ``return fn.state(...)``.
75
+ ``map(items)`` applies the function to each item in a single batched call.
76
+ """
77
+
78
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ...
79
+ def state(self, value: JSONContent | None = None) -> R: ...
80
+ def map(self, items: Sequence[JSONContent]) -> list[R]: ...
81
+
82
+
83
+ class AsyncJevFn(Protocol[P, R]):
84
+ """An async ``@jev``-decorated function: await it to query Jev."""
85
+
86
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, R]: ...
87
+ def state(self, value: JSONContent | None = None) -> R: ...
88
+ def map(self, items: Sequence[JSONContent]) -> Coroutine[Any, Any, list[R]]: ...
89
+
90
+
91
+ class _JevDecorator(Protocol):
92
+ """The configured ``@jev(...)`` form: preserves params, swaps the return."""
93
+
94
+ @overload
95
+ def __call__(self, fn: Callable[P, R], /) -> JevFn[P, R]: ...
96
+ @overload
97
+ def __call__(self, fn: Callable[P, Awaitable[R]], /) -> AsyncJevFn[P, R]: ...
98
+
99
+ # Attribute on the marker instance that carries the body-built state value.
100
+ _STATE_ATTR = "__jev_state__"
101
+ # Distinguishes "not a marker" from "marker carrying None" (the body-less form).
102
+ _ABSENT: Any = object()
103
+
104
+
105
+ class _StateBuilder(Generic[R]):
106
+ """The ``state`` method on a ``@jev`` wrapper; builds the marker it unwraps."""
107
+
108
+ def __init__(self, model: type[R]) -> None:
109
+ self._model = model
110
+
111
+ def __call__(self, value: JSONContent | None = None) -> R:
112
+ # model_construct() is typed `-> Self`, so the marker is a genuine `R`
113
+ # as far as any type checker is concerned; the payload rides along in a
114
+ # dunder attribute and is unwrapped by the @jev wrapper before the
115
+ # instance can be observed. A missing payload (None is never a valid
116
+ # state) means "body-less": the rendered docstring is the state.
117
+ marker = self._model.model_construct()
118
+ object.__setattr__(marker, _STATE_ATTR, value)
119
+ return marker
120
+
121
+
122
+ def state_payload(marker: BaseModel) -> JSONContent | None:
123
+ """The value carried by a ``fn.state(...)`` marker.
124
+
125
+ Useful for unit-testing state builders without making an API call.
126
+ """
127
+ value = getattr(marker, _STATE_ATTR, _ABSENT)
128
+ if value is _ABSENT:
129
+ raise TypeError(
130
+ f"state_payload: {type(marker).__name__} is not a fn.state(...) marker"
131
+ )
132
+ return value
133
+
134
+
135
+ _builder_registry: weakref.WeakKeyDictionary[Callable[..., Any], Callable[..., Any]] = (
136
+ weakref.WeakKeyDictionary()
137
+ )
138
+
139
+
140
+ def builder(fn: Callable[P, Any]) -> Callable[P, Any]:
141
+ """The original function behind a ``@jev`` wrapper: the pure state-builder.
142
+
143
+ ``builder(fn)(*args, **kwargs)`` runs the body without touching the API,
144
+ so state construction can be unit-tested directly.
145
+ """
146
+ return _builder_registry.get(fn, fn)
147
+
148
+ # Jev's maximum choice cardinality.
149
+ _MAX_CHOICE_OPTIONS = 255
150
+ _MAX_SCORE_LEVELS = 256
151
+
152
+ # Noul -> bool coercion: p(yes) >= threshold. Overridable per function with
153
+ # @jev(bool_threshold=...) or globally with the env var.
154
+ _DEFAULT_BOOL_THRESHOLD = 0.5
155
+ _BOOL_THRESHOLD_ENV = "JEV_BOOL_THRESHOLD"
156
+
157
+ _jinja_env = jinja2.Environment(undefined=jinja2.StrictUndefined, autoescape=False)
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Public, fully-overloaded decorator
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ @overload
166
+ def jev(fn: Callable[P, R], /) -> JevFn[P, R]:
167
+ """Bare `@jev` on a sync function."""
168
+ ...
169
+
170
+
171
+ @overload
172
+ def jev(fn: Callable[P, Awaitable[R]], /) -> AsyncJevFn[P, R]:
173
+ """Bare `@jev` on an async function."""
174
+ ...
175
+
176
+
177
+ @overload
178
+ def jev(
179
+ fn: None = None,
180
+ /,
181
+ *,
182
+ model: str | None = None,
183
+ client: TypeSafeClient | AsyncTypeSafeClient | None = None,
184
+ bool_threshold: float | None = None,
185
+ ) -> _JevDecorator:
186
+ """Configured `@jev(...)`; preserves params, adds ``.state``."""
187
+ ...
188
+
189
+
190
+ def jev(
191
+ fn: Callable[..., Any] | None = None,
192
+ /,
193
+ *,
194
+ model: str | None = None,
195
+ client: TypeSafeClient | AsyncTypeSafeClient | None = None,
196
+ bool_threshold: float | None = None,
197
+ ) -> Any:
198
+ """Decorate `fn` so calling it queries Jev instead of running its body."""
199
+
200
+ def decorator(func: Callable[..., Any]) -> Any:
201
+ return _decorate(func, model=model, client=client, bool_threshold=bool_threshold)
202
+
203
+ if fn is not None:
204
+ return decorator(fn)
205
+ return decorator
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Decoration-time compilation (fail fast, before any API call)
210
+ # ---------------------------------------------------------------------------
211
+
212
+ @dataclass
213
+ class _AnswersView:
214
+ """The slice of a response the extractors read: a whole response, or a
215
+ per-item view over a batched (map) response."""
216
+
217
+ nouls: dict[str, NoulAnswer]
218
+ choices: dict[str, ChoiceAnswer]
219
+ scores: dict[str, ScoreAnswer]
220
+
221
+ @classmethod
222
+ def whole(cls, response: SystemOneResponse) -> Self:
223
+ return cls(response.nouls, response.choices, response.scores)
224
+
225
+ @classmethod
226
+ def for_item(cls, response: SystemOneResponse, index: int) -> Self:
227
+ prefix = f"{index}:"
228
+ return cls(
229
+ {k[len(prefix):]: a for k, a in response.nouls.items() if k.startswith(prefix)},
230
+ {k[len(prefix):]: a for k, a in response.choices.items() if k.startswith(prefix)},
231
+ {k[len(prefix):]: a for k, a in response.scores.items() if k.startswith(prefix)},
232
+ )
233
+
234
+
235
+ # A per-field extractor: answers -> the value for that model field.
236
+ _Extractor = Callable[[_AnswersView], Any]
237
+
238
+
239
+ def _decorate(
240
+ func: Callable[..., Any],
241
+ *,
242
+ model: str | None,
243
+ client: TypeSafeClient | AsyncTypeSafeClient | None,
244
+ bool_threshold: float | None,
245
+ ) -> Any:
246
+ return_model = _return_model_of(func)
247
+ signature = inspect.signature(func)
248
+ template = _compile_template(func)
249
+ questions, extractors = _compile_questions(return_model, bool_threshold)
250
+
251
+ if inspect.iscoroutinefunction(func):
252
+ if client is not None and not isinstance(client, AsyncTypeSafeClient):
253
+ raise TypeError(
254
+ f"@jev: {func.__qualname__} is async and needs an AsyncTypeSafeClient, "
255
+ f"got {type(client).__name__}"
256
+ )
257
+
258
+ @wraps(func)
259
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
260
+ body_result = await _acall_body(func, args, kwargs)
261
+ override, state = _resolve_body(
262
+ func, return_model, template, signature, args, kwargs, body_result
263
+ )
264
+ if override is not None:
265
+ return override
266
+ async_client = client if client is not None else _default_async_client()
267
+ response = await async_client.system_one(state=state, questions=questions, model=model)
268
+ return _materialize(return_model, extractors, response)
269
+
270
+ setattr(async_wrapper, "state", _StateBuilder(return_model))
271
+
272
+ async def amap_impl(items: Sequence[JSONContent]) -> Any:
273
+ overrides, states, batched, state_array = await _map_prepare_async(
274
+ func, return_model, template, signature, questions, items
275
+ )
276
+ if states:
277
+ async_client = client if client is not None else _default_async_client()
278
+ response = await async_client.system_one(
279
+ state=state_array, questions=batched, model=model
280
+ )
281
+ else:
282
+ response = None
283
+ return _map_finish(return_model, extractors, response, overrides, states, len(items))
284
+
285
+ setattr(async_wrapper, "map", amap_impl)
286
+ _builder_registry[async_wrapper] = func
287
+ return async_wrapper
288
+
289
+ if client is not None and not isinstance(client, TypeSafeClient):
290
+ raise TypeError(
291
+ f"@jev: {func.__qualname__} is sync and needs a TypeSafeClient, "
292
+ f"got {type(client).__name__}"
293
+ )
294
+
295
+ @wraps(func)
296
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
297
+ body_result = _call_body(func, args, kwargs)
298
+ override, state = _resolve_body(
299
+ func, return_model, template, signature, args, kwargs, body_result
300
+ )
301
+ if override is not None:
302
+ return override
303
+ sync_client = client if client is not None else _default_sync_client()
304
+ response = sync_client.system_one(state=state, questions=questions, model=model)
305
+ return _materialize(return_model, extractors, response)
306
+
307
+ setattr(sync_wrapper, "state", _StateBuilder(return_model))
308
+
309
+ def map_impl(items: Sequence[JSONContent]) -> Any:
310
+ overrides, states, batched, state_array = _map_prepare(
311
+ func, return_model, template, signature, questions, items
312
+ )
313
+ if states:
314
+ sync_client = client if client is not None else _default_sync_client()
315
+ response = sync_client.system_one(state=state_array, questions=batched, model=model)
316
+ else:
317
+ response = None
318
+ return _map_finish(return_model, extractors, response, overrides, states, len(items))
319
+
320
+ setattr(sync_wrapper, "map", map_impl)
321
+ _builder_registry[sync_wrapper] = func
322
+ return sync_wrapper
323
+
324
+
325
+ def _return_model_of(func: Callable[..., Any]) -> type[BaseModel]:
326
+ annotation = typing.get_type_hints(func).get("return")
327
+ if annotation is None:
328
+ raise TypeError(f"@jev: {func.__qualname__} must declare a return annotation")
329
+ if not (isinstance(annotation, type) and issubclass(annotation, BaseModel)):
330
+ raise TypeError(
331
+ f"@jev: {func.__qualname__} must return a pydantic BaseModel subclass, "
332
+ f"got {annotation!r}"
333
+ )
334
+ return annotation
335
+
336
+
337
+ def _compile_template(func: Callable[..., Any]) -> jinja2.Template | None:
338
+ doc = inspect.getdoc(func)
339
+ if doc is None:
340
+ return None
341
+ try:
342
+ return _jinja_env.from_string(doc)
343
+ except jinja2.TemplateSyntaxError as exc:
344
+ raise TypeError(
345
+ f"@jev: the docstring of {func.__qualname__} is not a valid Jinja2 template: {exc}"
346
+ ) from exc
347
+
348
+
349
+ def _compile_questions(
350
+ return_model: type[BaseModel],
351
+ bool_threshold: float | None,
352
+ ) -> tuple[dict[str, Noul | Choice | Score], dict[str, _Extractor]]:
353
+ questions: dict[str, Noul | Choice | Score] = {}
354
+ extractors: dict[str, _Extractor] = {}
355
+ for name, field in return_model.model_fields.items():
356
+ question, extractor = _compile_field(return_model, name, field, bool_threshold)
357
+ questions[name] = question
358
+ extractors[name] = extractor
359
+ return questions, extractors
360
+
361
+
362
+ def _compile_field(
363
+ return_model: type[BaseModel],
364
+ name: str,
365
+ field: FieldInfo,
366
+ bool_threshold: float | None,
367
+ ) -> tuple[Noul | Choice | Score, _Extractor]:
368
+ annotation = field.annotation
369
+ instructions = field.description or name.replace("_", " ")
370
+
371
+ # Order matters: `bool` is a subclass of `int`, and `Literal` checks must
372
+ # precede the generic type checks.
373
+ if annotation is bool:
374
+ return _compile_noul(name, instructions, bool_threshold)
375
+
376
+ literal_values = _literal_values(annotation)
377
+ if literal_values is not None:
378
+ return _compile_choice(name, instructions, _label_map(name, [(str(v), v) for v in literal_values]))
379
+
380
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
381
+ return _compile_choice(
382
+ name, instructions, _label_map(name, [(str(m.value), m) for m in annotation])
383
+ )
384
+
385
+ if annotation is int:
386
+ return _compile_score(return_model, name, field, is_integer=True, instructions=instructions)
387
+ if annotation is float:
388
+ return _compile_score(return_model, name, field, is_integer=False, instructions=instructions)
389
+
390
+ raise TypeError(
391
+ f"@jev: {return_model.__name__}.{name} has unsupported type {annotation!r}. "
392
+ "Jev cannot generate strings, so fields must be bool, Literal[...], Enum, "
393
+ "or int/float constrained with Field(ge=..., le=...)."
394
+ )
395
+
396
+
397
+ def _validate_bool_threshold(value: float, source: str) -> float:
398
+ if not 0.0 <= value <= 1.0:
399
+ raise ValueError(f"@jev: bool threshold from {source} must be in [0, 1], got {value}")
400
+ return value
401
+
402
+
403
+ def _bool_threshold(explicit: float | None) -> float:
404
+ """Resolve the effective threshold: decorator arg > env var > default."""
405
+ if explicit is not None:
406
+ return explicit
407
+ raw = os.environ.get(_BOOL_THRESHOLD_ENV)
408
+ if raw is None or not raw.strip():
409
+ return _DEFAULT_BOOL_THRESHOLD
410
+ try:
411
+ return _validate_bool_threshold(float(raw), _BOOL_THRESHOLD_ENV)
412
+ except ValueError:
413
+ raise ValueError(
414
+ f"@jev: {_BOOL_THRESHOLD_ENV} must be a float in [0, 1], got {raw!r}"
415
+ ) from None
416
+
417
+
418
+ def _compile_noul(
419
+ name: str, instructions: str, bool_threshold: float | None
420
+ ) -> tuple[Noul, _Extractor]:
421
+ # An explicit threshold is validated at decoration time; the env var is
422
+ # resolved per call so tests and workers can tune it without re-importing.
423
+ if bool_threshold is not None:
424
+ _validate_bool_threshold(bool_threshold, "@jev(bool_threshold=...)")
425
+
426
+ def extract(r: _AnswersView) -> bool:
427
+ return r.nouls[name].noul >= _bool_threshold(bool_threshold)
428
+
429
+ return Noul(instructions=instructions), extract
430
+
431
+
432
+ def _literal_values(annotation: Any) -> tuple[Any, ...] | None:
433
+ if get_origin(annotation) is Literal:
434
+ return get_args(annotation)
435
+ return None
436
+
437
+
438
+ def _is_list(value: Any) -> TypeIs[list[Any]]:
439
+ return isinstance(value, list)
440
+
441
+
442
+ def _is_str_dict(value: Any) -> TypeIs[dict[str, Any]]:
443
+ # pydantic's json_schema_extra is either a JsonDict or a callable, which
444
+ # this excludes; keys are checked so the TypeIs is honest. (The cast turns
445
+ # the isinstance narrowing's dict[Unknown, Unknown] into Any-typed keys.)
446
+ return isinstance(value, dict) and all(
447
+ isinstance(k, str) for k in cast("dict[Any, Any]", value)
448
+ )
449
+
450
+
451
+ def _label_map(name: str, pairs: list[tuple[str, Any]]) -> dict[str, Any]:
452
+ """Stringified label -> field value, rejecting collisions (e.g. Literal[1, "1"]).
453
+
454
+ The check must happen here, on the pairs: a dict comprehension would
455
+ silently dedupe colliding labels before anyone could count them.
456
+ """
457
+ label_to_value: dict[str, Any] = {}
458
+ for label, value in pairs:
459
+ if label in label_to_value:
460
+ raise TypeError(
461
+ f"@jev: field {name!r} has options that collide when stringified: {label!r}"
462
+ )
463
+ label_to_value[label] = value
464
+ return label_to_value
465
+
466
+
467
+ def _compile_choice(
468
+ name: str, instructions: str, label_to_value: dict[str, Any]
469
+ ) -> tuple[Choice, _Extractor]:
470
+ labels = list(label_to_value)
471
+ if len(labels) > _MAX_CHOICE_OPTIONS:
472
+ raise TypeError(
473
+ f"@jev: field {name!r} has {len(labels)} options; "
474
+ f"Jev supports at most {_MAX_CHOICE_OPTIONS} per choice"
475
+ )
476
+ question = Choice(instructions=instructions, criteria=dict.fromkeys(labels))
477
+ return question, lambda r: label_to_value[r.choices[name].choice]
478
+
479
+
480
+ def _compile_score(
481
+ return_model: type[BaseModel],
482
+ name: str,
483
+ field: FieldInfo,
484
+ *,
485
+ is_integer: bool,
486
+ instructions: str,
487
+ ) -> tuple[Score, _Extractor]:
488
+ lo: Any = None
489
+ hi: Any = None
490
+ for constraint in field.metadata:
491
+ if isinstance(constraint, annotated_types.Ge):
492
+ lo = constraint.ge
493
+ elif isinstance(constraint, annotated_types.Le):
494
+ hi = constraint.le
495
+ if lo is None or hi is None:
496
+ raise TypeError(
497
+ f"@jev: {return_model.__name__}.{name} is a score; "
498
+ "constrain it with Field(ge=..., le=...)"
499
+ )
500
+
501
+ extra: dict[str, Any] = field.json_schema_extra if _is_str_dict(field.json_schema_extra) else {}
502
+ raw_levels: Any = extra.get("levels")
503
+ if raw_levels is not None and not _is_list(raw_levels):
504
+ raise TypeError(
505
+ f"@jev: {return_model.__name__}.{name}: "
506
+ "json_schema_extra['levels'] must be a list of labels"
507
+ )
508
+ custom_levels: list[Any] | None = raw_levels
509
+
510
+ if is_integer:
511
+ lo_i, hi_i = int(lo), int(hi)
512
+ if custom_levels is not None:
513
+ levels = [str(x) for x in custom_levels]
514
+ else:
515
+ levels = [str(v) for v in range(lo_i, hi_i + 1)]
516
+ if len(levels) != hi_i - lo_i + 1:
517
+ raise TypeError(
518
+ f"@jev: {return_model.__name__}.{name}: custom levels must have exactly "
519
+ f"ge..le entries ({hi_i - lo_i + 1}), got {len(levels)}"
520
+ )
521
+
522
+ def extract_int(r: _AnswersView) -> int:
523
+ return lo_i + int(round(r.scores[name].score))
524
+
525
+ extractor: _Extractor = extract_int
526
+ else:
527
+ lo_f, hi_f = float(lo), float(hi)
528
+ if custom_levels is not None:
529
+ levels = [str(x) for x in custom_levels]
530
+ else:
531
+ levels = [str(lo_f), str(hi_f)]
532
+ if len(levels) < 2:
533
+ raise TypeError(
534
+ f"@jev: {return_model.__name__}.{name}: a float score needs at least 2 levels"
535
+ )
536
+ n_levels = len(levels)
537
+
538
+ def extract_float(r: _AnswersView) -> float:
539
+ expected = r.scores[name].score # in [0, n_levels - 1]
540
+ return lo_f + expected * (hi_f - lo_f) / (n_levels - 1)
541
+
542
+ extractor = extract_float
543
+
544
+ if len(levels) > _MAX_SCORE_LEVELS:
545
+ raise TypeError(
546
+ f"@jev: {return_model.__name__}.{name} has {len(levels)} levels; "
547
+ f"Jev supports at most {_MAX_SCORE_LEVELS} per score"
548
+ )
549
+ return Score(instructions=instructions, criteria=levels), extractor
550
+
551
+
552
+ # ---------------------------------------------------------------------------
553
+ # Call-time rendering and coercion
554
+ # ---------------------------------------------------------------------------
555
+
556
+
557
+ def _render_framing(
558
+ template: jinja2.Template | None,
559
+ signature: inspect.Signature,
560
+ args: tuple[Any, ...],
561
+ kwargs: dict[str, Any],
562
+ ) -> str | None:
563
+ if template is None:
564
+ return None
565
+ bound = signature.bind(*args, **kwargs)
566
+ bound.apply_defaults()
567
+ try:
568
+ return template.render(**bound.arguments)
569
+ except jinja2.UndefinedError as exc:
570
+ raise TypeError(f"@jev: docstring template references an unknown variable: {exc}") from exc
571
+
572
+
573
+ def _body_less_state(
574
+ template: jinja2.Template | None,
575
+ signature: inspect.Signature,
576
+ args: tuple[Any, ...],
577
+ kwargs: dict[str, Any],
578
+ ) -> Any:
579
+ """State for a body that did not build one: the rendered docstring, or the
580
+ arguments themselves as JSON when there is no docstring."""
581
+ framing = _render_framing(template, signature, args, kwargs)
582
+ if framing is not None:
583
+ return framing
584
+ bound = signature.bind(*args, **kwargs)
585
+ bound.apply_defaults()
586
+ # Round-trip through JSON so the state is exactly what the wire would
587
+ # carry: JSON-safe values only, anything exotic stringified.
588
+ return json.loads(json.dumps(bound.arguments, default=str))
589
+
590
+
591
+ def _resolve_body(
592
+ func: Callable[..., Any],
593
+ return_model: type[BaseModel],
594
+ template: jinja2.Template | None,
595
+ signature: inspect.Signature,
596
+ args: tuple[Any, ...],
597
+ kwargs: dict[str, Any],
598
+ body_result: Any,
599
+ ) -> tuple[BaseModel | None, Any]:
600
+ """Interpret the evaluated body's result.
601
+
602
+ Returns ``(override, None)`` when the body answered directly (skip the API
603
+ call), or ``(None, state)`` to query Jev with the resolved state.
604
+ """
605
+ value = getattr(body_result, _STATE_ATTR, _ABSENT)
606
+ if value is not _ABSENT:
607
+ if type(body_result) is not return_model:
608
+ raise TypeError(
609
+ f"@jev: {func.__qualname__} returned a state marker built for "
610
+ f"{type(body_result).__name__}, but its return annotation is "
611
+ f"{return_model.__name__}; use {func.__qualname__}.state(...)"
612
+ )
613
+ if value is None:
614
+ # fn.state() with no value: the body-less form.
615
+ return None, _body_less_state(template, signature, args, kwargs)
616
+ # fn.state(value): the value is the state, exactly as returned. The
617
+ # docstring is documentation in this form and is not sent.
618
+ return None, value
619
+
620
+ if isinstance(body_result, return_model):
621
+ # A real, fully-constructed model: the body answered directly.
622
+ return body_result, None
623
+
624
+ if body_result is not None:
625
+ raise TypeError(
626
+ f"@jev: {func.__qualname__}'s body must return "
627
+ f"{func.__qualname__}.state(...) or nothing, "
628
+ f"got {type(body_result).__name__}"
629
+ )
630
+
631
+ return None, _body_less_state(template, signature, args, kwargs)
632
+
633
+
634
+ def _call_body(
635
+ func: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]
636
+ ) -> Any:
637
+ """Run the body; a bare ``raise NotImplementedError`` means body-less."""
638
+ try:
639
+ return func(*args, **kwargs)
640
+ except NotImplementedError:
641
+ return None
642
+
643
+
644
+ async def _acall_body(
645
+ func: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]
646
+ ) -> Any:
647
+ try:
648
+ return await func(*args, **kwargs)
649
+ except NotImplementedError:
650
+ return None
651
+
652
+
653
+ def _rebuild_question(question: Noul | Choice | Score, index: int) -> Noul | Choice | Score:
654
+ """The same question, addressed to the item at ``index`` in a state array."""
655
+ instructions = f"For the item at index {index} in the state array: {question.instructions}"
656
+ if isinstance(question, Noul):
657
+ return Noul(instructions=instructions, criteria=question.criteria)
658
+ if isinstance(question, Choice):
659
+ return Choice(instructions=instructions, criteria=question.criteria)
660
+ return Score(instructions=instructions, criteria=question.criteria)
661
+
662
+
663
+ def _bind_single(
664
+ func: Callable[..., Any], signature: inspect.Signature, item: Any
665
+ ) -> tuple[tuple[Any, ...], dict[str, Any]]:
666
+ try:
667
+ bound = signature.bind(item)
668
+ except TypeError as exc:
669
+ raise TypeError(
670
+ f"@jev: {func.__qualname__}.map(items) needs each item to be the "
671
+ f"function's only positional argument: {exc}"
672
+ ) from exc
673
+ bound.apply_defaults()
674
+ return bound.args, bound.kwargs
675
+
676
+
677
+ # overrides by index, states by index, batched questions, and the state
678
+ # array (None in slots answered directly, so indices hold).
679
+ _MapPlan = tuple[
680
+ dict[int, BaseModel], dict[int, Any], dict[str, Noul | Choice | Score], list[Any]
681
+ ]
682
+
683
+
684
+ def _map_resolve(
685
+ func: Callable[..., Any],
686
+ return_model: type[BaseModel],
687
+ template: jinja2.Template | None,
688
+ signature: inspect.Signature,
689
+ questions: dict[str, Noul | Choice | Score],
690
+ bound: list[tuple[tuple[Any, ...], dict[str, Any]]],
691
+ body_results: list[Any],
692
+ ) -> _MapPlan:
693
+ """Interpret each item's body result through the same machinery as a
694
+ direct call, then batch the questions of the items that need Jev."""
695
+ overrides: dict[int, BaseModel] = {}
696
+ states: dict[int, Any] = {}
697
+ for i, ((args, kwargs), body_result) in enumerate(zip(bound, body_results, strict=True)):
698
+ override, state = _resolve_body(
699
+ func, return_model, template, signature, args, kwargs, body_result
700
+ )
701
+ if override is not None:
702
+ overrides[i] = override
703
+ else:
704
+ states[i] = state
705
+ batched: dict[str, Noul | Choice | Score] = {}
706
+ for i in states:
707
+ for name, question in questions.items():
708
+ batched[f"{i}:{name}"] = _rebuild_question(question, i)
709
+ return overrides, states, batched, [states.get(i) for i in range(len(bound))]
710
+
711
+
712
+ def _map_prepare(
713
+ func: Callable[..., Any],
714
+ return_model: type[BaseModel],
715
+ template: jinja2.Template | None,
716
+ signature: inspect.Signature,
717
+ questions: dict[str, Noul | Choice | Score],
718
+ items: Sequence[JSONContent],
719
+ ) -> _MapPlan:
720
+ """Bind and run each item through the body, then resolve the plan."""
721
+ bound = [_bind_single(func, signature, item) for item in items]
722
+ body_results = [_call_body(func, args, kwargs) for args, kwargs in bound]
723
+ return _map_resolve(
724
+ func, return_model, template, signature, questions, bound, body_results
725
+ )
726
+
727
+
728
+ async def _map_prepare_async(
729
+ func: Callable[..., Any],
730
+ return_model: type[BaseModel],
731
+ template: jinja2.Template | None,
732
+ signature: inspect.Signature,
733
+ questions: dict[str, Noul | Choice | Score],
734
+ items: Sequence[JSONContent],
735
+ ) -> _MapPlan:
736
+ bound = [_bind_single(func, signature, item) for item in items]
737
+ body_results = [await _acall_body(func, args, kwargs) for args, kwargs in bound]
738
+ return _map_resolve(
739
+ func, return_model, template, signature, questions, bound, body_results
740
+ )
741
+
742
+
743
+ def _map_finish(
744
+ return_model: type[BaseModel],
745
+ extractors: dict[str, _Extractor],
746
+ response: SystemOneResponse | None,
747
+ overrides: dict[int, BaseModel],
748
+ states: dict[int, Any],
749
+ n: int,
750
+ ) -> list[Any]:
751
+ results: list[Any] = [None] * n
752
+ for i, override in overrides.items():
753
+ results[i] = override
754
+ if states:
755
+ if response is None:
756
+ raise TypeError("@jev: internal error: batched answers missing")
757
+ for i in states:
758
+ results[i] = return_model(**_extract_values(extractors, _AnswersView.for_item(response, i)))
759
+ return results
760
+
761
+
762
+ def _extract_values(
763
+ extractors: dict[str, _Extractor],
764
+ response: _AnswersView,
765
+ ) -> dict[str, Any]:
766
+ return {name: extract(response) for name, extract in extractors.items()}
767
+
768
+
769
+ def _materialize(
770
+ return_model: type[BaseModel],
771
+ extractors: dict[str, _Extractor],
772
+ response: SystemOneResponse,
773
+ ) -> BaseModel:
774
+ return return_model(**_extract_values(extractors, _AnswersView.whole(response)))
775
+
776
+
777
+ # ---------------------------------------------------------------------------
778
+ # Lazily-created shared clients (TYPESAFE_API_KEY / SDK defaults)
779
+ # ---------------------------------------------------------------------------
780
+
781
+ _shared_sync_client: TypeSafeClient | None = None
782
+ # httpx connection pools are bound to the event loop that created them, so a
783
+ # client must never outlive its loop: keep one client per running loop.
784
+ _shared_async_clients: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, AsyncTypeSafeClient] = (
785
+ weakref.WeakKeyDictionary()
786
+ )
787
+
788
+
789
+ def _default_sync_client() -> TypeSafeClient:
790
+ global _shared_sync_client
791
+ if _shared_sync_client is None:
792
+ _shared_sync_client = TypeSafeClient()
793
+ return _shared_sync_client
794
+
795
+
796
+ def _default_async_client() -> AsyncTypeSafeClient:
797
+ loop = asyncio.get_running_loop()
798
+ client = _shared_async_clients.get(loop)
799
+ if client is None:
800
+ client = AsyncTypeSafeClient()
801
+ _shared_async_clients[loop] = client
802
+ return client
803
+
804
+
805
+ # ---------------------------------------------------------------------------
806
+ # JevModel: construct a model instance straight from a state
807
+ # ---------------------------------------------------------------------------
808
+
809
+
810
+ class JevModel(BaseModel):
811
+ """A pydantic model whose fields are Jev questions.
812
+
813
+ ``Model.decide(state)`` queries Jev and fills the fields from the answers;
814
+ the normal pydantic constructor validates locally and skips the API (the
815
+ mock seam). Fields follow the same rules as ``@jev`` return models: bool,
816
+ Literal[...], Enum, or int/float with Field(ge=..., le=...). Field
817
+ compilation happens at class definition, so an unsupported field type
818
+ raises TypeError at import::
819
+
820
+ class Triage(JevModel):
821
+ department: Literal["billing", "technical", "sales"]
822
+ is_urgent: bool
823
+
824
+ Triage.decide("I was charged twice!") # queries Jev
825
+ await Triage.adecide("I was charged twice!") # async form
826
+ Triage(department="billing", is_urgent=True) # no API call
827
+
828
+ Class attributes: ``__jev_model__`` pins the model name,
829
+ ``__jev_bool_threshold__`` overrides the Noul -> bool threshold.
830
+
831
+ (Deciding is a classmethod rather than a constructor overload because
832
+ pydantic's ``dataclass_transform`` synthesizes a field-only ``__init__``
833
+ for subclasses in both mypy and pyright; a classmethod keeps the call
834
+ typed as ``-> Self`` in both checkers.)
835
+ """
836
+
837
+ __jev_questions__: ClassVar[dict[str, Noul | Choice | Score]] = {}
838
+ __jev_extractors__: ClassVar[dict[str, _Extractor]] = {}
839
+ __jev_bool_threshold__: ClassVar[float | None] = None
840
+ __jev_model__: ClassVar[str | None] = None
841
+
842
+ @classmethod
843
+ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
844
+ super().__pydantic_init_subclass__(**kwargs)
845
+ if cls.__jev_bool_threshold__ is not None:
846
+ _validate_bool_threshold(
847
+ cls.__jev_bool_threshold__, f"{cls.__name__}.__jev_bool_threshold__"
848
+ )
849
+ questions, extractors = _compile_questions(cls, cls.__jev_bool_threshold__)
850
+ cls.__jev_questions__ = questions
851
+ cls.__jev_extractors__ = extractors
852
+
853
+ @classmethod
854
+ def decide(cls, state: JSONContent) -> Self:
855
+ """Decide the fields about a state by querying Jev."""
856
+ if not cls.__jev_questions__:
857
+ raise TypeError(f"{cls.__name__} declares no question fields")
858
+ response = _default_sync_client().system_one(
859
+ state=state, questions=cls.__jev_questions__, model=cls.__jev_model__
860
+ )
861
+ return cls(**_extract_values(cls.__jev_extractors__, _AnswersView.whole(response)))
862
+
863
+ @classmethod
864
+ async def adecide(cls, state: JSONContent) -> Self:
865
+ """The async form of ``decide``."""
866
+ if not cls.__jev_questions__:
867
+ raise TypeError(f"{cls.__name__} declares no question fields")
868
+ response = await _default_async_client().system_one(
869
+ state=state, questions=cls.__jev_questions__, model=cls.__jev_model__
870
+ )
871
+ return cls(**_extract_values(cls.__jev_extractors__, _AnswersView.whole(response)))