levanto 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ .venv/
2
+ venv/
3
+ dist/
4
+ build/
5
+ __pycache__/
6
+ *.egg-info/
7
+ *.egg
8
+ *.pyc
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+ .python-version
levanto-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Levanto Labs
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.
levanto-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,295 @@
1
+ Metadata-Version: 2.4
2
+ Name: levanto
3
+ Version: 0.1.0
4
+ Summary: Typed Python client for the Levanto Sage decision API.
5
+ Project-URL: Documentation, https://docs.levanto.ai
6
+ Project-URL: Homepage, https://github.com/levantolabs/levanto-py
7
+ Project-URL: Source, https://github.com/levantolabs/levanto-py
8
+ Project-URL: Issues, https://github.com/levantolabs/levanto-py/issues
9
+ Author-email: Levanto Labs <team@levanto.ai>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,classification,decision,levanto,llm,sage
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: httpx
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest; extra == 'test'
26
+ Requires-Dist: pytest-asyncio; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # levanto (Python)
30
+
31
+ Thin, fully typed Python client for the Levanto Sage decision API. One client object (sync `LevantoClient` and async `AsyncLevantoClient`), five decision kinds (Yes/No, Choice, Scale, Sort, Tags), built on `httpx`.
32
+
33
+ **Docs:** [docs.levanto.ai](https://docs.levanto.ai) · Have an issue or want to leave feedback? Let us know at [team@levanto.ai](mailto:team@levanto.ai).
34
+
35
+ This README is a complete reference: every exported function and type, every default, and the behaviors that are not obvious from the signatures.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install levanto
41
+ ```
42
+
43
+ Requires Python 3.9+. Depends on `httpx`.
44
+
45
+ ## Quick start
46
+
47
+ ```python
48
+ from levanto import LevantoClient, YesNo, Scale
49
+
50
+ client = LevantoClient(api_key="lv_live_...")
51
+
52
+ doc = 'Marketing email: "Risk-free, guaranteed 40% returns for accredited investors."'
53
+
54
+ # One decision -> full envelope
55
+ env = client.decide(doc, YesNo("Needs compliance review?"))
56
+ print(env["result"]["answer"]) # 'yes' | 'no'
57
+
58
+ # Shortcut -> just the result payload
59
+ r = client.yesno(doc, "Needs compliance review?")
60
+ print(r["probability"], r["confidence"], r["answer"])
61
+
62
+ # Batch: same document, many questions -> aligned list.
63
+ # Scale takes exactly 5 levels (0..4); a list of 5 strings maps to those levels by index.
64
+ severity = ["none", "low", "moderate", "high", "severe"]
65
+ items = client.decide(doc, [YesNo("Urgent?"), Scale("How severe?", severity)])
66
+ # Each item reads like a single decide, plus "ok":
67
+ if items[0]["ok"]:
68
+ print(items[0]["result"]) # {"probability": ..., "confidence": ..., "answer": ...}
69
+ ```
70
+
71
+ Async is identical, with `await` and `AsyncLevantoClient`:
72
+
73
+ ```python
74
+ from levanto import AsyncLevantoClient, YesNo
75
+
76
+ async with AsyncLevantoClient(api_key="lv_live_...") as client:
77
+ env = await client.decide("...", YesNo("Needs review?"))
78
+ ```
79
+
80
+ ## Exports
81
+
82
+ From `levanto`: `LevantoClient`, `AsyncLevantoClient`; the batch-grouping type `Group` and result `GroupResult`; question types `YesNo`, `Choice`, `Scale`, `Sort`, `Tags`, `Question`; sub-objects `ChoiceOption`, `ScaleLevel`, `TagSpec`, `Grounding`; errors `LevantoError`, `AuthError`, `ValidationError`, `ServiceUnavailableError`, `LevantoAPIError`; and `__version__`.
83
+ The result/envelope typed structures live in `levanto.types`: `YesNoResult`, `ChoiceProbability`, `ChoiceResult`, `ScaleResult`, `SortResult`, `TagResult`, `TagsResult`, `Result`, `Meta`, `Envelope`, `BatchItem`, `GroupResult`, `BatchResponse`, `Content`. These are `TypedDict`s: at runtime the values are plain `dict`s, so you access fields with `["key"]`.
84
+
85
+ ## LevantoClient / AsyncLevantoClient
86
+
87
+ ```python
88
+ LevantoClient(api_key: str, *, base_url: str = "https://sage.levanto.ai",
89
+ timeout: float = 60.0, max_retries: int = 3, transport=None)
90
+
91
+ AsyncLevantoClient(api_key: str, *, base_url: str = "https://sage.levanto.ai",
92
+ timeout: float = 60.0, max_retries: int = 3, transport=None)
93
+ ```
94
+
95
+ | Param | Type | Default | Notes |
96
+ |---|---|---|---|
97
+ | `api_key` | `str` | required | Sent as `Authorization: Bearer <api_key>`. |
98
+ | `base_url` | `str` | `"https://sage.levanto.ai"` | Trailing slashes are stripped. |
99
+ | `timeout` | `float` | `60.0` | Seconds, passed to `httpx`. |
100
+ | `max_retries` | `int` | `3` | Retries on transient failures (see Behaviors). |
101
+ | `transport` | `httpx.BaseTransport` / `AsyncBaseTransport` | `None` | Transport override, mainly for tests (`httpx.MockTransport`). |
102
+
103
+ The two clients share one surface. On `AsyncLevantoClient`, every request method is a coroutine (`await`).
104
+
105
+ Lifecycle: `LevantoClient` supports `with client:` and `client.close()`; `AsyncLevantoClient` supports `async with client:` and `await client.aclose()`.
106
+
107
+ ### decide
108
+
109
+ ```python
110
+ decide(document, question: Question) -> Envelope
111
+ decide(document, question: Sequence[Question]) -> list[BatchItem]
112
+ ```
113
+
114
+ A single question calls `POST /decide` and returns the full envelope. A list/tuple of questions calls `POST /decide/batch` as a single group (the shared document is sent once, not repeated per question) and returns a `list[BatchItem]` aligned to input order.
115
+
116
+ ### decide_groups
117
+
118
+ ```python
119
+ decide_groups(groups: Sequence[Group]) -> list[GroupResult]
120
+ ```
121
+
122
+ Score several documents in one round-trip, each with its own questions. `Group` is a dataclass `Group(document, questions)`. Returns one `GroupResult` (`{"items": list[BatchItem]}`) per input group, in order; each group's `items` are aligned to that group's questions (same flattened shape as `decide`).
123
+
124
+ ```python
125
+ from levanto import Group, YesNo, Scale
126
+
127
+ results = client.decide_groups([
128
+ Group(post, [YesNo("Violates policy?"), Scale("How harmful?", severity)]),
129
+ Group(comment, [YesNo("Is this spam?")]),
130
+ ])
131
+ results[0]["items"][0]["result"] # yes/no payload for the post
132
+ ```
133
+
134
+ ### Shortcuts
135
+
136
+ Each shortcut builds the matching question, makes one single (non-batch) decision, and returns just the `result` payload.
137
+
138
+ ```python
139
+ yesno (document, instructions: str, *, id=None, grounding=None) -> YesNoResult
140
+ choice(document, instructions: str, options, *, id=None, grounding=None) -> ChoiceResult
141
+ scale (document, instructions: str, levels, *, id=None, grounding=None) -> ScaleResult
142
+ sort (items, instructions: str, *, id=None) -> SortResult
143
+ tags (document, tags, *, id=None, grounding=None, instructions=None) -> TagsResult
144
+ ```
145
+
146
+ `sort` takes the list to order as its first argument. `id` and `grounding` are keyword-only.
147
+
148
+ ### ready
149
+
150
+ ```python
151
+ ready() -> bool
152
+ ```
153
+
154
+ Calls `GET /ready` with no `Authorization` header. Returns `True` on HTTP 200, `False` otherwise (503 means still loading). Never retried.
155
+
156
+ ## Question types
157
+
158
+ One dataclass per kind. `id` and `grounding` are keyword-only on every constructor. The lowercase client methods (`client.yesno`) never collide with the constructors (`YesNo`).
159
+
160
+ ```python
161
+ YesNo (instructions: str, *, id: str | None = None, grounding: Grounding | None = None) # kind: "yesno"
162
+ Choice(instructions: str, options, *, id=None, grounding=None) # kind: "choice"
163
+ Scale (instructions: str, levels, *, id=None, grounding=None) # kind: "scale"
164
+ Sort (instructions: str, *, id: str | None = None) # kind: "sort" (no grounding)
165
+ Tags (tags, *, instructions=None, id=None, grounding=None) # kind: "tags" (instructions optional)
166
+ ```
167
+
168
+ Input types for the collection args:
169
+
170
+ ```python
171
+ OptionInput = Union[str, ChoiceOption, dict] # Choice.options
172
+ LevelInput = Union[str, ScaleLevel, dict] # Scale.levels
173
+ TagInput = Union[str, TagSpec, dict] # Tags.tags
174
+ ```
175
+
176
+ Shorthands applied at construction:
177
+ - `Choice(instr, ["approve", "revise"])`: each bare string becomes `ChoiceOption(option=...)`.
178
+ - `Scale(instr, ["worst", "bad", "ok", "good", "best"])`: the 5 strings map to levels `0..4` by index.
179
+ - `Tags(["pii", "toxicity"])`: each bare string becomes `TagSpec(id=...)`.
180
+
181
+ Explicit dataclass instances and plain `dict`s are always accepted and passed through unchanged. Passing `grounding=` to `Sort` raises `TypeError`.
182
+
183
+ Per-kind fields:
184
+
185
+ | Field | yesno | choice | scale | sort | tags |
186
+ |---|:--:|:--:|:--:|:--:|:--:|
187
+ | `instructions` | required | required | required | required | optional |
188
+ | `options` / `levels` / `tags` | - | `options` req | `levels` req | - | `tags` req |
189
+ | `id` | opt | opt | opt | opt | opt |
190
+ | `grounding` | opt | opt | opt | no | opt |
191
+
192
+ ## Sub-objects
193
+
194
+ ```python
195
+ @dataclass
196
+ class ChoiceOption: option: str; description: Optional[str] = None
197
+
198
+ @dataclass
199
+ class ScaleLevel: level: int; description: Optional[str] = None # exactly 5 rungs, level in 0..4; description optional
200
+
201
+ @dataclass
202
+ class TagSpec: id: str; name: Optional[str] = None; threshold: Optional[float] = None # name optional; threshold 0..1, when set the result carries `applies`
203
+
204
+ @dataclass
205
+ class Grounding:
206
+ trigger: Optional[str] = None # serialized as-is
207
+ confidence_floor: Optional[float] = None
208
+ extra: Optional[dict] = None # merged verbatim into the wire object
209
+ ```
210
+
211
+ ## Documents
212
+
213
+ `document` accepts `Content = Union[str, dict, list]`. Normalization: a bare `str` is sent as-is (the API treats it as text); a `list` is wrapped as `{"kind": "list", "value": items}`; an explicit `dict` (e.g. `{"kind": "text", "value": ...}` or `{"kind": "list", "value": [...]}`) passes through. Sort takes a list of `{"id", "content"}` items; the other kinds take text.
214
+
215
+ ## Grounding
216
+
217
+ Attach a `Grounding` to any non-`sort` question (or pass it to a shortcut) to let the server augment low-confidence decisions with web search. On the wire, grounding is lifted from the question to a top-level `grounding` sibling of `content`/`question`.
218
+
219
+ Grounding is thin: only the fields you set are sent, and the server applies its defaults for anything omitted. `trigger` and `confidence_floor` are first-class fields; the remaining server knobs go through `extra`, merged verbatim.
220
+
221
+ Full grounding config (wire field, SDK access, default, range):
222
+
223
+ | Wire field | SDK | Default | Range |
224
+ |---|---|---|---|
225
+ | `trigger` | `trigger` | `"low_confidence"` | `"never"` \| `"low_confidence"` \| `"always"` |
226
+ | `confidence_floor` | `confidence_floor` | `0.80` | `0.0 .. 1.0` |
227
+ | `max_results` | `extra={"max_results": ...}` | `10` | `1 .. 20` |
228
+ | `max_context_tokens` | `extra={"max_context_tokens": ...}` | `2000` | `1 .. 8000` |
229
+ | `return_sources` | `extra={"return_sources": ...}` | `True` | bool |
230
+
231
+ ```python
232
+ YesNo(
233
+ "Is Acme currently bankrupt?",
234
+ grounding=Grounding(trigger="low_confidence", confidence_floor=0.85,
235
+ extra={"max_results": 8, "return_sources": True}),
236
+ )
237
+ ```
238
+
239
+ The floor is a confidence threshold, interpreted per kind: for yesno/scale/choice, search fires when the decision `confidence` is below it (yesno confidence is decisiveness `2·|p−0.5|`); for tags it is weakest-link (search fires if any tag's confidence is below the floor). This is distinct from `TagSpec.threshold`, which controls a tag's `applies` flag. When grounding runs, the envelope includes a `grounding_meta` block (`triggered`, `trigger_reason?`, `queries?`, `sources?`, `added_context_tokens?`, `search_ms?`).
240
+
241
+ ## Return types (`levanto.types`)
242
+
243
+ ```python
244
+ class YesNoResult(TypedDict): probability: float; confidence: float; answer: Literal["yes", "no"]
245
+ class ChoiceProbability(TypedDict): option: str; probability: float
246
+ class ChoiceResult(TypedDict): chosen: str; confidence: float; probabilities: list[ChoiceProbability]
247
+ class ScaleResult(TypedDict): expectation: float; confidence: float
248
+ class SortResult(TypedDict): sorted: list[str]; confidence: Optional[float] # nullable per the API; never assume a number
249
+ class TagResult(TypedDict): id: str; probability: float; confidence: float; applies: Optional[bool] # (applies optional; bool when the tag had a threshold, else None)
250
+ class TagsResult(TypedDict): tags: list[TagResult]
251
+
252
+ class Meta(TypedDict): model: str; latency_ms: float
253
+ class Envelope(TypedDict): id: str; kind: str; result: Result; meta: Meta; grounding_meta: dict # (grounding_meta optional)
254
+ class BatchItem(TypedDict): id: str; kind: str; ok: bool; result: Result; meta: Meta; grounding_meta: dict; error: str # (result/meta/grounding_meta present on ok; error on failure)
255
+ class GroupResult(TypedDict): items: list[BatchItem] # one per decide_groups() input group, in order; items aligned to that group's questions
256
+
257
+ Result = Union[YesNoResult, ChoiceResult, ScaleResult, SortResult, TagsResult]
258
+ ```
259
+
260
+ `answer` is the raw API string `"yes"` / `"no"`, not a bool. `SortResult["confidence"]` is typed nullable by the API, so never assume it is a number. `TagResult["applies"]` is a bool when that tag was created with a `threshold`, otherwise `None`.
261
+
262
+ ## Errors
263
+
264
+ ```python
265
+ class LevantoError(Exception):
266
+ def __init__(self, message: str, *, status: int | None = None, detail: str | None = None): ...
267
+ # attributes: .status, .detail
268
+
269
+ class AuthError(LevantoError): ... # 401, 402
270
+ class ValidationError(LevantoError): ... # 400, 422
271
+ class ServiceUnavailableError(LevantoError): ... # 503
272
+ class LevantoAPIError(LevantoError): ... # any other non-2xx
273
+ ```
274
+
275
+ Every API error carries the HTTP `status` and the server `detail` string.
276
+
277
+ ## Behaviors
278
+
279
+ - **id defaulting**: if a question has no `id`, the SDK fills one. A single call defaults to the kind string (e.g. `"yesno"`); a batch assigns `q0, q1, ...` by index. A supplied `id` is kept.
280
+ - **Batch**: `decide(doc, questions)` sends one group (shared content once); `decide_groups` sends several. `BatchItem`s come back in question order, with `id`/`kind` from each request's question. On the wire the server nests a full single-decide envelope under each answer's `result`; the client flattens it so a successful item reads exactly like a single decide (`item["result"]` is the bare payload, with `item["meta"]` and, when grounding ran, `item["grounding_meta"]` alongside it). A failed item carries `error` instead.
281
+ - **Batch error modes**: a schema-invalid question (e.g. wrong scale level count) rejects the *whole* batch with a `ValidationError`. A question that is well-formed but incompatible with its group's shared content (e.g. a `sort` question over text content) is isolated: that item comes back `ok=False` with an `error`, while the others succeed.
282
+ - **Retries**: transport errors and HTTP `429, 500, 502, 503, 504` are retried up to `max_retries`, with exponential backoff plus full jitter (base 0.5s, capped at 8s). `ready()` is never retried.
283
+ - **Sync and async parity**: both clients share the same request-building, serialization, parsing, error-mapping, and retry policy; only the transport call and the sleep differ.
284
+
285
+ ## Limits (enforced server-side, surfaced as `ValidationError`)
286
+
287
+ - choice: 2..120 options
288
+ - scale: exactly 5 levels, values `0..4`
289
+ - sort: 2..120 items
290
+ - tags: 1..120 tags; `threshold` in `0..1`
291
+ - The full rendered request must fit within roughly 32K tokens.
292
+
293
+ ## License
294
+
295
+ MIT
@@ -0,0 +1,267 @@
1
+ # levanto (Python)
2
+
3
+ Thin, fully typed Python client for the Levanto Sage decision API. One client object (sync `LevantoClient` and async `AsyncLevantoClient`), five decision kinds (Yes/No, Choice, Scale, Sort, Tags), built on `httpx`.
4
+
5
+ **Docs:** [docs.levanto.ai](https://docs.levanto.ai) · Have an issue or want to leave feedback? Let us know at [team@levanto.ai](mailto:team@levanto.ai).
6
+
7
+ This README is a complete reference: every exported function and type, every default, and the behaviors that are not obvious from the signatures.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install levanto
13
+ ```
14
+
15
+ Requires Python 3.9+. Depends on `httpx`.
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ from levanto import LevantoClient, YesNo, Scale
21
+
22
+ client = LevantoClient(api_key="lv_live_...")
23
+
24
+ doc = 'Marketing email: "Risk-free, guaranteed 40% returns for accredited investors."'
25
+
26
+ # One decision -> full envelope
27
+ env = client.decide(doc, YesNo("Needs compliance review?"))
28
+ print(env["result"]["answer"]) # 'yes' | 'no'
29
+
30
+ # Shortcut -> just the result payload
31
+ r = client.yesno(doc, "Needs compliance review?")
32
+ print(r["probability"], r["confidence"], r["answer"])
33
+
34
+ # Batch: same document, many questions -> aligned list.
35
+ # Scale takes exactly 5 levels (0..4); a list of 5 strings maps to those levels by index.
36
+ severity = ["none", "low", "moderate", "high", "severe"]
37
+ items = client.decide(doc, [YesNo("Urgent?"), Scale("How severe?", severity)])
38
+ # Each item reads like a single decide, plus "ok":
39
+ if items[0]["ok"]:
40
+ print(items[0]["result"]) # {"probability": ..., "confidence": ..., "answer": ...}
41
+ ```
42
+
43
+ Async is identical, with `await` and `AsyncLevantoClient`:
44
+
45
+ ```python
46
+ from levanto import AsyncLevantoClient, YesNo
47
+
48
+ async with AsyncLevantoClient(api_key="lv_live_...") as client:
49
+ env = await client.decide("...", YesNo("Needs review?"))
50
+ ```
51
+
52
+ ## Exports
53
+
54
+ From `levanto`: `LevantoClient`, `AsyncLevantoClient`; the batch-grouping type `Group` and result `GroupResult`; question types `YesNo`, `Choice`, `Scale`, `Sort`, `Tags`, `Question`; sub-objects `ChoiceOption`, `ScaleLevel`, `TagSpec`, `Grounding`; errors `LevantoError`, `AuthError`, `ValidationError`, `ServiceUnavailableError`, `LevantoAPIError`; and `__version__`.
55
+ The result/envelope typed structures live in `levanto.types`: `YesNoResult`, `ChoiceProbability`, `ChoiceResult`, `ScaleResult`, `SortResult`, `TagResult`, `TagsResult`, `Result`, `Meta`, `Envelope`, `BatchItem`, `GroupResult`, `BatchResponse`, `Content`. These are `TypedDict`s: at runtime the values are plain `dict`s, so you access fields with `["key"]`.
56
+
57
+ ## LevantoClient / AsyncLevantoClient
58
+
59
+ ```python
60
+ LevantoClient(api_key: str, *, base_url: str = "https://sage.levanto.ai",
61
+ timeout: float = 60.0, max_retries: int = 3, transport=None)
62
+
63
+ AsyncLevantoClient(api_key: str, *, base_url: str = "https://sage.levanto.ai",
64
+ timeout: float = 60.0, max_retries: int = 3, transport=None)
65
+ ```
66
+
67
+ | Param | Type | Default | Notes |
68
+ |---|---|---|---|
69
+ | `api_key` | `str` | required | Sent as `Authorization: Bearer <api_key>`. |
70
+ | `base_url` | `str` | `"https://sage.levanto.ai"` | Trailing slashes are stripped. |
71
+ | `timeout` | `float` | `60.0` | Seconds, passed to `httpx`. |
72
+ | `max_retries` | `int` | `3` | Retries on transient failures (see Behaviors). |
73
+ | `transport` | `httpx.BaseTransport` / `AsyncBaseTransport` | `None` | Transport override, mainly for tests (`httpx.MockTransport`). |
74
+
75
+ The two clients share one surface. On `AsyncLevantoClient`, every request method is a coroutine (`await`).
76
+
77
+ Lifecycle: `LevantoClient` supports `with client:` and `client.close()`; `AsyncLevantoClient` supports `async with client:` and `await client.aclose()`.
78
+
79
+ ### decide
80
+
81
+ ```python
82
+ decide(document, question: Question) -> Envelope
83
+ decide(document, question: Sequence[Question]) -> list[BatchItem]
84
+ ```
85
+
86
+ A single question calls `POST /decide` and returns the full envelope. A list/tuple of questions calls `POST /decide/batch` as a single group (the shared document is sent once, not repeated per question) and returns a `list[BatchItem]` aligned to input order.
87
+
88
+ ### decide_groups
89
+
90
+ ```python
91
+ decide_groups(groups: Sequence[Group]) -> list[GroupResult]
92
+ ```
93
+
94
+ Score several documents in one round-trip, each with its own questions. `Group` is a dataclass `Group(document, questions)`. Returns one `GroupResult` (`{"items": list[BatchItem]}`) per input group, in order; each group's `items` are aligned to that group's questions (same flattened shape as `decide`).
95
+
96
+ ```python
97
+ from levanto import Group, YesNo, Scale
98
+
99
+ results = client.decide_groups([
100
+ Group(post, [YesNo("Violates policy?"), Scale("How harmful?", severity)]),
101
+ Group(comment, [YesNo("Is this spam?")]),
102
+ ])
103
+ results[0]["items"][0]["result"] # yes/no payload for the post
104
+ ```
105
+
106
+ ### Shortcuts
107
+
108
+ Each shortcut builds the matching question, makes one single (non-batch) decision, and returns just the `result` payload.
109
+
110
+ ```python
111
+ yesno (document, instructions: str, *, id=None, grounding=None) -> YesNoResult
112
+ choice(document, instructions: str, options, *, id=None, grounding=None) -> ChoiceResult
113
+ scale (document, instructions: str, levels, *, id=None, grounding=None) -> ScaleResult
114
+ sort (items, instructions: str, *, id=None) -> SortResult
115
+ tags (document, tags, *, id=None, grounding=None, instructions=None) -> TagsResult
116
+ ```
117
+
118
+ `sort` takes the list to order as its first argument. `id` and `grounding` are keyword-only.
119
+
120
+ ### ready
121
+
122
+ ```python
123
+ ready() -> bool
124
+ ```
125
+
126
+ Calls `GET /ready` with no `Authorization` header. Returns `True` on HTTP 200, `False` otherwise (503 means still loading). Never retried.
127
+
128
+ ## Question types
129
+
130
+ One dataclass per kind. `id` and `grounding` are keyword-only on every constructor. The lowercase client methods (`client.yesno`) never collide with the constructors (`YesNo`).
131
+
132
+ ```python
133
+ YesNo (instructions: str, *, id: str | None = None, grounding: Grounding | None = None) # kind: "yesno"
134
+ Choice(instructions: str, options, *, id=None, grounding=None) # kind: "choice"
135
+ Scale (instructions: str, levels, *, id=None, grounding=None) # kind: "scale"
136
+ Sort (instructions: str, *, id: str | None = None) # kind: "sort" (no grounding)
137
+ Tags (tags, *, instructions=None, id=None, grounding=None) # kind: "tags" (instructions optional)
138
+ ```
139
+
140
+ Input types for the collection args:
141
+
142
+ ```python
143
+ OptionInput = Union[str, ChoiceOption, dict] # Choice.options
144
+ LevelInput = Union[str, ScaleLevel, dict] # Scale.levels
145
+ TagInput = Union[str, TagSpec, dict] # Tags.tags
146
+ ```
147
+
148
+ Shorthands applied at construction:
149
+ - `Choice(instr, ["approve", "revise"])`: each bare string becomes `ChoiceOption(option=...)`.
150
+ - `Scale(instr, ["worst", "bad", "ok", "good", "best"])`: the 5 strings map to levels `0..4` by index.
151
+ - `Tags(["pii", "toxicity"])`: each bare string becomes `TagSpec(id=...)`.
152
+
153
+ Explicit dataclass instances and plain `dict`s are always accepted and passed through unchanged. Passing `grounding=` to `Sort` raises `TypeError`.
154
+
155
+ Per-kind fields:
156
+
157
+ | Field | yesno | choice | scale | sort | tags |
158
+ |---|:--:|:--:|:--:|:--:|:--:|
159
+ | `instructions` | required | required | required | required | optional |
160
+ | `options` / `levels` / `tags` | - | `options` req | `levels` req | - | `tags` req |
161
+ | `id` | opt | opt | opt | opt | opt |
162
+ | `grounding` | opt | opt | opt | no | opt |
163
+
164
+ ## Sub-objects
165
+
166
+ ```python
167
+ @dataclass
168
+ class ChoiceOption: option: str; description: Optional[str] = None
169
+
170
+ @dataclass
171
+ class ScaleLevel: level: int; description: Optional[str] = None # exactly 5 rungs, level in 0..4; description optional
172
+
173
+ @dataclass
174
+ class TagSpec: id: str; name: Optional[str] = None; threshold: Optional[float] = None # name optional; threshold 0..1, when set the result carries `applies`
175
+
176
+ @dataclass
177
+ class Grounding:
178
+ trigger: Optional[str] = None # serialized as-is
179
+ confidence_floor: Optional[float] = None
180
+ extra: Optional[dict] = None # merged verbatim into the wire object
181
+ ```
182
+
183
+ ## Documents
184
+
185
+ `document` accepts `Content = Union[str, dict, list]`. Normalization: a bare `str` is sent as-is (the API treats it as text); a `list` is wrapped as `{"kind": "list", "value": items}`; an explicit `dict` (e.g. `{"kind": "text", "value": ...}` or `{"kind": "list", "value": [...]}`) passes through. Sort takes a list of `{"id", "content"}` items; the other kinds take text.
186
+
187
+ ## Grounding
188
+
189
+ Attach a `Grounding` to any non-`sort` question (or pass it to a shortcut) to let the server augment low-confidence decisions with web search. On the wire, grounding is lifted from the question to a top-level `grounding` sibling of `content`/`question`.
190
+
191
+ Grounding is thin: only the fields you set are sent, and the server applies its defaults for anything omitted. `trigger` and `confidence_floor` are first-class fields; the remaining server knobs go through `extra`, merged verbatim.
192
+
193
+ Full grounding config (wire field, SDK access, default, range):
194
+
195
+ | Wire field | SDK | Default | Range |
196
+ |---|---|---|---|
197
+ | `trigger` | `trigger` | `"low_confidence"` | `"never"` \| `"low_confidence"` \| `"always"` |
198
+ | `confidence_floor` | `confidence_floor` | `0.80` | `0.0 .. 1.0` |
199
+ | `max_results` | `extra={"max_results": ...}` | `10` | `1 .. 20` |
200
+ | `max_context_tokens` | `extra={"max_context_tokens": ...}` | `2000` | `1 .. 8000` |
201
+ | `return_sources` | `extra={"return_sources": ...}` | `True` | bool |
202
+
203
+ ```python
204
+ YesNo(
205
+ "Is Acme currently bankrupt?",
206
+ grounding=Grounding(trigger="low_confidence", confidence_floor=0.85,
207
+ extra={"max_results": 8, "return_sources": True}),
208
+ )
209
+ ```
210
+
211
+ The floor is a confidence threshold, interpreted per kind: for yesno/scale/choice, search fires when the decision `confidence` is below it (yesno confidence is decisiveness `2·|p−0.5|`); for tags it is weakest-link (search fires if any tag's confidence is below the floor). This is distinct from `TagSpec.threshold`, which controls a tag's `applies` flag. When grounding runs, the envelope includes a `grounding_meta` block (`triggered`, `trigger_reason?`, `queries?`, `sources?`, `added_context_tokens?`, `search_ms?`).
212
+
213
+ ## Return types (`levanto.types`)
214
+
215
+ ```python
216
+ class YesNoResult(TypedDict): probability: float; confidence: float; answer: Literal["yes", "no"]
217
+ class ChoiceProbability(TypedDict): option: str; probability: float
218
+ class ChoiceResult(TypedDict): chosen: str; confidence: float; probabilities: list[ChoiceProbability]
219
+ class ScaleResult(TypedDict): expectation: float; confidence: float
220
+ class SortResult(TypedDict): sorted: list[str]; confidence: Optional[float] # nullable per the API; never assume a number
221
+ class TagResult(TypedDict): id: str; probability: float; confidence: float; applies: Optional[bool] # (applies optional; bool when the tag had a threshold, else None)
222
+ class TagsResult(TypedDict): tags: list[TagResult]
223
+
224
+ class Meta(TypedDict): model: str; latency_ms: float
225
+ class Envelope(TypedDict): id: str; kind: str; result: Result; meta: Meta; grounding_meta: dict # (grounding_meta optional)
226
+ class BatchItem(TypedDict): id: str; kind: str; ok: bool; result: Result; meta: Meta; grounding_meta: dict; error: str # (result/meta/grounding_meta present on ok; error on failure)
227
+ class GroupResult(TypedDict): items: list[BatchItem] # one per decide_groups() input group, in order; items aligned to that group's questions
228
+
229
+ Result = Union[YesNoResult, ChoiceResult, ScaleResult, SortResult, TagsResult]
230
+ ```
231
+
232
+ `answer` is the raw API string `"yes"` / `"no"`, not a bool. `SortResult["confidence"]` is typed nullable by the API, so never assume it is a number. `TagResult["applies"]` is a bool when that tag was created with a `threshold`, otherwise `None`.
233
+
234
+ ## Errors
235
+
236
+ ```python
237
+ class LevantoError(Exception):
238
+ def __init__(self, message: str, *, status: int | None = None, detail: str | None = None): ...
239
+ # attributes: .status, .detail
240
+
241
+ class AuthError(LevantoError): ... # 401, 402
242
+ class ValidationError(LevantoError): ... # 400, 422
243
+ class ServiceUnavailableError(LevantoError): ... # 503
244
+ class LevantoAPIError(LevantoError): ... # any other non-2xx
245
+ ```
246
+
247
+ Every API error carries the HTTP `status` and the server `detail` string.
248
+
249
+ ## Behaviors
250
+
251
+ - **id defaulting**: if a question has no `id`, the SDK fills one. A single call defaults to the kind string (e.g. `"yesno"`); a batch assigns `q0, q1, ...` by index. A supplied `id` is kept.
252
+ - **Batch**: `decide(doc, questions)` sends one group (shared content once); `decide_groups` sends several. `BatchItem`s come back in question order, with `id`/`kind` from each request's question. On the wire the server nests a full single-decide envelope under each answer's `result`; the client flattens it so a successful item reads exactly like a single decide (`item["result"]` is the bare payload, with `item["meta"]` and, when grounding ran, `item["grounding_meta"]` alongside it). A failed item carries `error` instead.
253
+ - **Batch error modes**: a schema-invalid question (e.g. wrong scale level count) rejects the *whole* batch with a `ValidationError`. A question that is well-formed but incompatible with its group's shared content (e.g. a `sort` question over text content) is isolated: that item comes back `ok=False` with an `error`, while the others succeed.
254
+ - **Retries**: transport errors and HTTP `429, 500, 502, 503, 504` are retried up to `max_retries`, with exponential backoff plus full jitter (base 0.5s, capped at 8s). `ready()` is never retried.
255
+ - **Sync and async parity**: both clients share the same request-building, serialization, parsing, error-mapping, and retry policy; only the transport call and the sleep differ.
256
+
257
+ ## Limits (enforced server-side, surfaced as `ValidationError`)
258
+
259
+ - choice: 2..120 options
260
+ - scale: exactly 5 levels, values `0..4`
261
+ - sort: 2..120 items
262
+ - tags: 1..120 tags; `threshold` in `0..1`
263
+ - The full rendered request must fit within roughly 32K tokens.
264
+
265
+ ## License
266
+
267
+ MIT
@@ -0,0 +1,34 @@
1
+ # Examples
2
+
3
+ Runnable examples for the `levanto` client. Each script is standalone and reads
4
+ `LEVANTO_API_KEY` from the environment.
5
+
6
+ ## Setup
7
+
8
+ ```bash
9
+ pip install -e . # or: pip install levanto
10
+ export LEVANTO_API_KEY=lv_live_...
11
+ ```
12
+
13
+ ## Run
14
+
15
+ ```bash
16
+ python examples/01_quickstart.py
17
+ python examples/02_all_kinds.py
18
+ python examples/03_batch.py
19
+ python examples/04_grounding.py
20
+ python examples/05_error_handling.py
21
+ python examples/06_custom_transport.py
22
+ ```
23
+
24
+ | Script | Shows |
25
+ |---|---|
26
+ | `01_quickstart` | Client setup, `decide()` envelope, a shortcut (plus the async client) |
27
+ | `02_all_kinds` | yesno / choice / scale / sort / tags |
28
+ | `03_batch` | One document, many questions, `list[BatchItem]` |
29
+ | `04_grounding` | Web-search grounding and `grounding_meta` |
30
+ | `05_error_handling` | `ready()` and typed errors |
31
+ | `06_custom_transport` | Injecting your own httpx transport |
32
+
33
+ The Sage endpoint is scale-to-zero, so the first call after an idle period may
34
+ take ~90s to warm up.