levanto 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.
- levanto/__init__.py +64 -0
- levanto/_http.py +390 -0
- levanto/client.py +428 -0
- levanto/errors.py +53 -0
- levanto/py.typed +0 -0
- levanto/questions.py +251 -0
- levanto/types.py +172 -0
- levanto-0.1.0.dist-info/METADATA +295 -0
- levanto-0.1.0.dist-info/RECORD +11 -0
- levanto-0.1.0.dist-info/WHEEL +4 -0
- levanto-0.1.0.dist-info/licenses/LICENSE +21 -0
levanto/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Levanto: a thin, typed Python client for the Levanto Sage decision API.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
* :class:`LevantoClient` / :class:`AsyncLevantoClient` -- the clients.
|
|
6
|
+
* Question types: :class:`YesNo`, :class:`Choice`, :class:`Scale`,
|
|
7
|
+
:class:`Sort`, :class:`Tags`.
|
|
8
|
+
* Sub-objects: :class:`ChoiceOption`, :class:`ScaleLevel`, :class:`TagSpec`,
|
|
9
|
+
:class:`Grounding`.
|
|
10
|
+
* Errors: :class:`LevantoError` and its subclasses.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ._http import __version__
|
|
16
|
+
from .client import AsyncLevantoClient, Group, LevantoClient
|
|
17
|
+
from .errors import (
|
|
18
|
+
AuthError,
|
|
19
|
+
LevantoAPIError,
|
|
20
|
+
LevantoError,
|
|
21
|
+
ServiceUnavailableError,
|
|
22
|
+
ValidationError,
|
|
23
|
+
)
|
|
24
|
+
from .types import GroupResult
|
|
25
|
+
from .questions import (
|
|
26
|
+
Choice,
|
|
27
|
+
ChoiceOption,
|
|
28
|
+
Grounding,
|
|
29
|
+
Question,
|
|
30
|
+
Scale,
|
|
31
|
+
ScaleLevel,
|
|
32
|
+
Sort,
|
|
33
|
+
Tags,
|
|
34
|
+
TagSpec,
|
|
35
|
+
YesNo,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"__version__",
|
|
40
|
+
# clients
|
|
41
|
+
"LevantoClient",
|
|
42
|
+
"AsyncLevantoClient",
|
|
43
|
+
# batch grouping
|
|
44
|
+
"Group",
|
|
45
|
+
"GroupResult",
|
|
46
|
+
# questions
|
|
47
|
+
"YesNo",
|
|
48
|
+
"Choice",
|
|
49
|
+
"Scale",
|
|
50
|
+
"Sort",
|
|
51
|
+
"Tags",
|
|
52
|
+
"Question",
|
|
53
|
+
# sub-objects
|
|
54
|
+
"ChoiceOption",
|
|
55
|
+
"ScaleLevel",
|
|
56
|
+
"TagSpec",
|
|
57
|
+
"Grounding",
|
|
58
|
+
# errors
|
|
59
|
+
"LevantoError",
|
|
60
|
+
"AuthError",
|
|
61
|
+
"ValidationError",
|
|
62
|
+
"ServiceUnavailableError",
|
|
63
|
+
"LevantoAPIError",
|
|
64
|
+
]
|
levanto/_http.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Shared, transport-agnostic core for both clients.
|
|
2
|
+
|
|
3
|
+
Everything that must behave identically across the sync and async clients
|
|
4
|
+
lives here so the two code paths cannot drift:
|
|
5
|
+
|
|
6
|
+
* request-body construction (content normalization, ``id`` defaulting,
|
|
7
|
+
grounding lifting) and question serialization,
|
|
8
|
+
* response parsing (single envelope + batch alignment),
|
|
9
|
+
* HTTP status -> exception mapping,
|
|
10
|
+
* the retry/backoff policy.
|
|
11
|
+
|
|
12
|
+
Only the actual transport call (``httpx.Client.request`` vs
|
|
13
|
+
``httpx.AsyncClient.request``) differs, and each client implements its own
|
|
14
|
+
thin loop around :func:`should_retry` / :func:`backoff_delay`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import random
|
|
20
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from .errors import (
|
|
25
|
+
AuthError,
|
|
26
|
+
LevantoAPIError,
|
|
27
|
+
LevantoError,
|
|
28
|
+
ServiceUnavailableError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
from .questions import (
|
|
32
|
+
ChoiceOption,
|
|
33
|
+
Grounding,
|
|
34
|
+
ScaleLevel,
|
|
35
|
+
TagSpec,
|
|
36
|
+
Question,
|
|
37
|
+
)
|
|
38
|
+
from .types import BatchItem, Content, Envelope
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
USER_AGENT = f"levanto-python/{__version__}"
|
|
42
|
+
|
|
43
|
+
#: Statuses the client retries (the endpoint is scale-to-zero).
|
|
44
|
+
RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------- #
|
|
48
|
+
# Content normalization
|
|
49
|
+
# --------------------------------------------------------------------------- #
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_content(document: Content) -> Any:
|
|
53
|
+
"""Turn a user ``document`` into a wire ``content`` value.
|
|
54
|
+
|
|
55
|
+
* ``str`` is sent as-is (the API accepts a bare string as text).
|
|
56
|
+
* ``dict`` (e.g. ``{"kind": "text" | "list", ...}``) passes through.
|
|
57
|
+
* ``list`` is wrapped as ``{"kind": "list", "value": items}``.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
if isinstance(document, str):
|
|
61
|
+
return document
|
|
62
|
+
if isinstance(document, dict):
|
|
63
|
+
return document
|
|
64
|
+
if isinstance(document, (list, tuple)):
|
|
65
|
+
return {"kind": "list", "value": list(document)}
|
|
66
|
+
raise TypeError(
|
|
67
|
+
f"document must be str, dict, or list, got {type(document).__name__}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------- #
|
|
72
|
+
# Question / sub-object serialization
|
|
73
|
+
# --------------------------------------------------------------------------- #
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _serialize_option(option: Any) -> Dict[str, Any]:
|
|
77
|
+
if isinstance(option, ChoiceOption):
|
|
78
|
+
wire: Dict[str, Any] = {"option": option.option}
|
|
79
|
+
if option.description is not None:
|
|
80
|
+
wire["description"] = option.description
|
|
81
|
+
return wire
|
|
82
|
+
if isinstance(option, dict):
|
|
83
|
+
return option
|
|
84
|
+
if isinstance(option, str):
|
|
85
|
+
return {"option": option}
|
|
86
|
+
raise TypeError(f"invalid choice option: {option!r}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _serialize_level(level: Any) -> Dict[str, Any]:
|
|
90
|
+
if isinstance(level, ScaleLevel):
|
|
91
|
+
wire: Dict[str, Any] = {"level": level.level}
|
|
92
|
+
if level.description is not None:
|
|
93
|
+
wire["description"] = level.description
|
|
94
|
+
return wire
|
|
95
|
+
if isinstance(level, dict):
|
|
96
|
+
return level
|
|
97
|
+
raise TypeError(f"invalid scale level: {level!r}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _serialize_tag(tag: Any) -> Dict[str, Any]:
|
|
101
|
+
if isinstance(tag, TagSpec):
|
|
102
|
+
wire: Dict[str, Any] = {"id": tag.id}
|
|
103
|
+
if tag.name is not None:
|
|
104
|
+
wire["name"] = tag.name
|
|
105
|
+
if tag.threshold is not None:
|
|
106
|
+
wire["threshold"] = tag.threshold
|
|
107
|
+
return wire
|
|
108
|
+
if isinstance(tag, dict):
|
|
109
|
+
return tag
|
|
110
|
+
if isinstance(tag, str):
|
|
111
|
+
return {"id": tag}
|
|
112
|
+
raise TypeError(f"invalid tag spec: {tag!r}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _serialize_grounding(grounding: Grounding) -> Dict[str, Any]:
|
|
116
|
+
# Thin: send only the fields the caller set; the server fills defaults for
|
|
117
|
+
# the rest. Keeps grounding serialization identical to the JS SDK.
|
|
118
|
+
wire: Dict[str, Any] = {}
|
|
119
|
+
if grounding.trigger is not None:
|
|
120
|
+
wire["trigger"] = grounding.trigger
|
|
121
|
+
if grounding.confidence_floor is not None:
|
|
122
|
+
wire["confidence_floor"] = grounding.confidence_floor
|
|
123
|
+
if grounding.extra:
|
|
124
|
+
wire.update(grounding.extra)
|
|
125
|
+
return wire
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _serialize_question(question: Question, *, default_id: str) -> Dict[str, Any]:
|
|
129
|
+
"""Build the wire ``question`` object (excluding grounding).
|
|
130
|
+
|
|
131
|
+
``id`` is filled with ``default_id`` when the question does not carry one;
|
|
132
|
+
a user-supplied id is preserved.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
kind = question.kind
|
|
136
|
+
wire: Dict[str, Any] = {
|
|
137
|
+
"id": question.id if question.id is not None else default_id,
|
|
138
|
+
"kind": kind,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if kind == "yesno":
|
|
142
|
+
wire["instructions"] = question.instructions
|
|
143
|
+
elif kind == "choice":
|
|
144
|
+
wire["instructions"] = question.instructions
|
|
145
|
+
wire["options"] = [_serialize_option(o) for o in question.options]
|
|
146
|
+
elif kind == "scale":
|
|
147
|
+
wire["instructions"] = question.instructions
|
|
148
|
+
wire["levels"] = [_serialize_level(level) for level in question.levels]
|
|
149
|
+
elif kind == "sort":
|
|
150
|
+
wire["instructions"] = question.instructions
|
|
151
|
+
elif kind == "tags":
|
|
152
|
+
# `instructions` is optional for tags; only emit it when set.
|
|
153
|
+
if getattr(question, "instructions", None) is not None:
|
|
154
|
+
wire["instructions"] = question.instructions
|
|
155
|
+
wire["tags"] = [_serialize_tag(t) for t in question.tags]
|
|
156
|
+
else: # pragma: no cover - defensive
|
|
157
|
+
raise ValueError(f"unknown question kind: {kind!r}")
|
|
158
|
+
|
|
159
|
+
return wire
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _build_request_entry(
|
|
163
|
+
content: Any, question: Question, *, default_id: str
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""One ``{content, question, grounding?}`` entry.
|
|
166
|
+
|
|
167
|
+
Grounding is a top-level sibling of ``content``/``question`` on the wire,
|
|
168
|
+
even though users attach it to the question for ergonomics. ``Sort`` never
|
|
169
|
+
carries grounding.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
entry: Dict[str, Any] = {
|
|
173
|
+
"content": content,
|
|
174
|
+
"question": _serialize_question(question, default_id=default_id),
|
|
175
|
+
}
|
|
176
|
+
grounding = getattr(question, "grounding", None)
|
|
177
|
+
if grounding is not None:
|
|
178
|
+
entry["grounding"] = _serialize_grounding(grounding)
|
|
179
|
+
return entry
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_single_body(document: Content, question: Question) -> Dict[str, Any]:
|
|
183
|
+
"""Body for ``POST /decide``. Single-call ids default to the kind string."""
|
|
184
|
+
|
|
185
|
+
content = normalize_content(document)
|
|
186
|
+
return _build_request_entry(content, question, default_id=question.kind)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _serialize_batch_question(question: Question, *, default_id: str) -> Dict[str, Any]:
|
|
190
|
+
"""A batch question: the wire question with grounding *embedded*.
|
|
191
|
+
|
|
192
|
+
Unlike single ``/decide`` (where grounding is a top-level sibling), the
|
|
193
|
+
v0.5 batch API carries grounding inside each question object.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
wire = _serialize_question(question, default_id=default_id)
|
|
197
|
+
grounding = getattr(question, "grounding", None)
|
|
198
|
+
if grounding is not None:
|
|
199
|
+
wire["grounding"] = _serialize_grounding(grounding)
|
|
200
|
+
return wire
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _build_group_entry(
|
|
204
|
+
document: Content, questions: Sequence[Question]
|
|
205
|
+
) -> Dict[str, Any]:
|
|
206
|
+
"""One request group: ``{content, questions}`` (ids default ``q0, q1, ...``)."""
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
"content": normalize_content(document),
|
|
210
|
+
"questions": [
|
|
211
|
+
_serialize_batch_question(q, default_id=f"q{i}")
|
|
212
|
+
for i, q in enumerate(questions)
|
|
213
|
+
],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def build_batch_body(
|
|
218
|
+
document: Content, questions: Sequence[Question]
|
|
219
|
+
) -> Dict[str, Any]:
|
|
220
|
+
"""Body for ``POST /decide/batch``: one document + N questions.
|
|
221
|
+
|
|
222
|
+
Under the v0.5 batch API this is a single group, so the shared document is
|
|
223
|
+
sent once (not repeated per question). Ids default to ``q0, q1, ...``.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
return {"requests": [_build_group_entry(document, list(questions))]}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_groups_body(
|
|
230
|
+
groups: Sequence[Tuple[Content, Sequence[Question]]]
|
|
231
|
+
) -> Dict[str, Any]:
|
|
232
|
+
"""Body for ``POST /decide/batch`` with several ``(document, questions)`` groups."""
|
|
233
|
+
|
|
234
|
+
return {"requests": [_build_group_entry(doc, list(qs)) for doc, qs in groups]}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --------------------------------------------------------------------------- #
|
|
238
|
+
# Response parsing
|
|
239
|
+
# --------------------------------------------------------------------------- #
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def parse_group_answers(
|
|
243
|
+
questions: Sequence[Question], group: Dict[str, Any]
|
|
244
|
+
) -> List[BatchItem]:
|
|
245
|
+
"""Flatten one group's ``answers`` into ``BatchItem``s, aligned to ``questions``.
|
|
246
|
+
|
|
247
|
+
``id``/``kind`` come from each request's question (with the same ``q{i}``
|
|
248
|
+
id defaulting used when building the request), so answers map back cleanly.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
answers = (group or {}).get("answers", [])
|
|
252
|
+
items: List[BatchItem] = []
|
|
253
|
+
for i, question in enumerate(questions):
|
|
254
|
+
raw = answers[i] if i < len(answers) else {}
|
|
255
|
+
item: BatchItem = {
|
|
256
|
+
"id": question.id if question.id is not None else f"q{i}",
|
|
257
|
+
"kind": question.kind,
|
|
258
|
+
"ok": bool(raw.get("ok", False)),
|
|
259
|
+
}
|
|
260
|
+
# On success the server nests a full single-decide envelope under
|
|
261
|
+
# "result" ({id, kind, result, meta, grounding_meta?}); flatten it so a
|
|
262
|
+
# batch item reads like a single decide (item["result"] is the bare
|
|
263
|
+
# payload, with meta/grounding_meta lifted alongside it).
|
|
264
|
+
envelope = raw.get("result")
|
|
265
|
+
if isinstance(envelope, dict):
|
|
266
|
+
if "result" in envelope:
|
|
267
|
+
item["result"] = envelope["result"]
|
|
268
|
+
if "meta" in envelope:
|
|
269
|
+
item["meta"] = envelope["meta"]
|
|
270
|
+
if envelope.get("grounding_meta") is not None:
|
|
271
|
+
item["grounding_meta"] = envelope["grounding_meta"]
|
|
272
|
+
if raw.get("error") is not None:
|
|
273
|
+
item["error"] = raw["error"]
|
|
274
|
+
items.append(item)
|
|
275
|
+
return items
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def parse_batch(
|
|
279
|
+
questions: Sequence[Question], data: Dict[str, Any]
|
|
280
|
+
) -> List[BatchItem]:
|
|
281
|
+
"""Single-group batch response: flatten ``results[0].answers``."""
|
|
282
|
+
|
|
283
|
+
results = data.get("results", [])
|
|
284
|
+
first = results[0] if results else {}
|
|
285
|
+
return parse_group_answers(questions, first)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --------------------------------------------------------------------------- #
|
|
289
|
+
# Error mapping
|
|
290
|
+
# --------------------------------------------------------------------------- #
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _extract_detail(response: httpx.Response) -> Optional[str]:
|
|
294
|
+
try:
|
|
295
|
+
data = response.json()
|
|
296
|
+
except Exception:
|
|
297
|
+
text = response.text
|
|
298
|
+
return text or None
|
|
299
|
+
if isinstance(data, dict):
|
|
300
|
+
detail = data.get("detail")
|
|
301
|
+
if isinstance(detail, str):
|
|
302
|
+
return detail
|
|
303
|
+
if detail is not None:
|
|
304
|
+
return str(detail)
|
|
305
|
+
for key in ("message", "error"):
|
|
306
|
+
value = data.get(key)
|
|
307
|
+
if isinstance(value, str):
|
|
308
|
+
return value
|
|
309
|
+
return None
|
|
310
|
+
if isinstance(data, str):
|
|
311
|
+
return data
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def map_error(response: httpx.Response) -> LevantoError:
|
|
316
|
+
"""Map a non-2xx response to the appropriate exception instance."""
|
|
317
|
+
|
|
318
|
+
status = response.status_code
|
|
319
|
+
detail = _extract_detail(response)
|
|
320
|
+
message = f"HTTP {status}" + (f": {detail}" if detail else "")
|
|
321
|
+
|
|
322
|
+
if status in (401, 402):
|
|
323
|
+
return AuthError(message, status=status, detail=detail)
|
|
324
|
+
if status in (400, 422):
|
|
325
|
+
return ValidationError(message, status=status, detail=detail)
|
|
326
|
+
if status == 503:
|
|
327
|
+
return ServiceUnavailableError(message, status=status, detail=detail)
|
|
328
|
+
return LevantoAPIError(message, status=status, detail=detail)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def handle_response(response: httpx.Response) -> Any:
|
|
332
|
+
"""Return the parsed JSON body for a 2xx response, else raise."""
|
|
333
|
+
|
|
334
|
+
if response.is_success:
|
|
335
|
+
return response.json()
|
|
336
|
+
raise map_error(response)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def parse_single(data: Dict[str, Any]) -> Envelope:
|
|
340
|
+
"""The single-decision envelope is returned as-is (already the right shape)."""
|
|
341
|
+
|
|
342
|
+
return data # type: ignore[return-value]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --------------------------------------------------------------------------- #
|
|
346
|
+
# Retry policy (shared by both transports)
|
|
347
|
+
# --------------------------------------------------------------------------- #
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def should_retry(status: int, attempt: int, max_retries: int) -> bool:
|
|
351
|
+
"""Whether a response with ``status`` should be retried."""
|
|
352
|
+
|
|
353
|
+
return status in RETRYABLE_STATUSES and attempt < max_retries
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def backoff_delay(attempt: int, *, base: float = 0.5, cap: float = 8.0) -> float:
|
|
357
|
+
"""Exponential backoff with full jitter, in seconds.
|
|
358
|
+
|
|
359
|
+
``attempt`` is zero-based (0 for the delay before the first retry).
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
ceiling = min(cap, base * (2 ** attempt))
|
|
363
|
+
return random.uniform(0.0, ceiling)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def default_headers(api_key: str) -> Dict[str, str]:
|
|
367
|
+
return {
|
|
368
|
+
"Authorization": f"Bearer {api_key}",
|
|
369
|
+
"Content-Type": "application/json",
|
|
370
|
+
"User-Agent": USER_AGENT,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
__all__ = [
|
|
375
|
+
"normalize_content",
|
|
376
|
+
"build_single_body",
|
|
377
|
+
"build_batch_body",
|
|
378
|
+
"build_groups_body",
|
|
379
|
+
"parse_batch",
|
|
380
|
+
"parse_group_answers",
|
|
381
|
+
"parse_single",
|
|
382
|
+
"handle_response",
|
|
383
|
+
"map_error",
|
|
384
|
+
"should_retry",
|
|
385
|
+
"backoff_delay",
|
|
386
|
+
"default_headers",
|
|
387
|
+
"RETRYABLE_STATUSES",
|
|
388
|
+
"USER_AGENT",
|
|
389
|
+
"__version__",
|
|
390
|
+
]
|