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.
- levanto-0.1.0/.gitignore +14 -0
- levanto-0.1.0/LICENSE +21 -0
- levanto-0.1.0/PKG-INFO +295 -0
- levanto-0.1.0/README.md +267 -0
- levanto-0.1.0/examples/README.md +34 -0
- levanto-0.1.0/pyproject.toml +45 -0
- levanto-0.1.0/src/levanto/__init__.py +64 -0
- levanto-0.1.0/src/levanto/_http.py +390 -0
- levanto-0.1.0/src/levanto/client.py +428 -0
- levanto-0.1.0/src/levanto/errors.py +53 -0
- levanto-0.1.0/src/levanto/py.typed +0 -0
- levanto-0.1.0/src/levanto/questions.py +251 -0
- levanto-0.1.0/src/levanto/types.py +172 -0
levanto-0.1.0/.gitignore
ADDED
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
|
levanto-0.1.0/README.md
ADDED
|
@@ -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.
|