basal-client 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.
- basal_client-0.1.0/LICENSE +202 -0
- basal_client-0.1.0/NOTICE +39 -0
- basal_client-0.1.0/PKG-INFO +133 -0
- basal_client-0.1.0/README.md +115 -0
- basal_client-0.1.0/pyproject.toml +22 -0
- basal_client-0.1.0/src/basal_client/__init__.py +30 -0
- basal_client-0.1.0/src/basal_client/client.py +245 -0
- basal_client-0.1.0/src/basal_client/py.typed +0 -0
- basal_client-0.1.0/src/basal_client/questions.py +348 -0
- basal_client-0.1.0/src/basal_client/types.py +84 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
basal-rs
|
|
2
|
+
Copyright 2026 basal-rs contributors
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
5
|
+
|
|
6
|
+
This project reimplements in Rust the inference protocol of basal
|
|
7
|
+
(https://github.com/rkinas/basal, Copyright 2026 Remigiusz Kinas, Apache License 2.0) and ports parts of it:
|
|
8
|
+
the prompt format and letter readout (basal/prompt.py), the System One request handling and answer semantics
|
|
9
|
+
including multi and act (basal/server.py), facts injection (basal/facts.py -> crates/basal-core/src/facts.rs),
|
|
10
|
+
evidence spans (basal/evidence.py -> crates/basal-core/src/evidence.rs) and shared-prefix packing (basal/engine.py).
|
|
11
|
+
Reference requests in tools/reference include examples from basal/examples.
|
|
12
|
+
|
|
13
|
+
The basal model weights (Remek/basal-1.0-*, Remek/basal-1.5-*) are not part of this repository; they are released by
|
|
14
|
+
their author under the Apache License 2.0.
|
|
15
|
+
|
|
16
|
+
The NOTICE file of basal (v1.5.0) follows.
|
|
17
|
+
|
|
18
|
+
--------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
basal
|
|
21
|
+
Copyright 2026 Remigiusz Kinas
|
|
22
|
+
|
|
23
|
+
This product includes models fine-tuned from the following works, licensed under the Apache License, Version 2.0:
|
|
24
|
+
|
|
25
|
+
basal-1.5-4.5B <- speakleash/Bielik-4.5B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-4.5B-v3.0-Instruct)
|
|
26
|
+
basal-1.5-max <- speakleash/Bielik-PL-11B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-PL-11B-v3.0-Instruct)
|
|
27
|
+
basal-1.5-mini <- speakleash/Bielik-1.5B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-1.5B-v3.0-Instruct)
|
|
28
|
+
basal-1.0-4.5B <- speakleash/Bielik-4.5B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-4.5B-v3.0-Instruct)
|
|
29
|
+
basal-1.0-1.5B <- speakleash/Bielik-1.5B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-1.5B-v3.0-Instruct)
|
|
30
|
+
|
|
31
|
+
The inference engine in this repository is original work licensed under the Apache License, Version 2.0.
|
|
32
|
+
|
|
33
|
+
Training data attribution (basal-1.0 models):
|
|
34
|
+
English decision items in the training data were converted from the public corpus
|
|
35
|
+
avbiswas/bev-decision-150K (https://huggingface.co/datasets/avbiswas/bev-decision-150K), which aggregates questions
|
|
36
|
+
derived from many upstream sources. Its maintainers ask users to check and attribute those upstream sources; row-level
|
|
37
|
+
source identifiers are not available, so the upstream sources of these items are not documented individually.
|
|
38
|
+
|
|
39
|
+
basal-1.5 models: released under the Apache License, Version 2.0.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: basal-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the native Basal HTTP API
|
|
5
|
+
Author: IT SOL
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: NOTICE
|
|
13
|
+
Requires-Dist: httpx>=0.28,<1
|
|
14
|
+
Project-URL: Documentation, https://github.com/itsoltech/basal-rs/blob/main/docs/SDK.md
|
|
15
|
+
Project-URL: Source, https://github.com/itsoltech/basal-rs/tree/main/sdk/python
|
|
16
|
+
Import-Name: basal_client
|
|
17
|
+
|
|
18
|
+
# basal-client (Python)
|
|
19
|
+
|
|
20
|
+
HTTP SDK for the native Basal API (`POST /v1/basal`). Python 3.11+; no GPU required on the client.
|
|
21
|
+
|
|
22
|
+
This package is versioned independently. Release tag: `sdk-python-vX.Y.Z`.
|
|
23
|
+
[Changelog](https://github.com/itsoltech/basal-rs/blob/main/sdk/python/CHANGELOG.md).
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install basal-client
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Choose either a normal dictionary or a declarative class. Both use the same factories.
|
|
30
|
+
A class gives each response field its exact inferred answer type:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from basal_client import BasalClient, Questions, choice, noul
|
|
34
|
+
|
|
35
|
+
routing = choice("Do którego działu skierować zgłoszenie?", {"returns": "Zwroty", "support": "Wsparcie techniczne"})
|
|
36
|
+
|
|
37
|
+
class TicketQuestions(Questions):
|
|
38
|
+
department = routing
|
|
39
|
+
second_department = routing
|
|
40
|
+
urgent = noul("Czy sprawa jest pilna?")
|
|
41
|
+
|
|
42
|
+
with BasalClient("http://127.0.0.1:8000") as client:
|
|
43
|
+
response = client.decide("Klient prosi o zwrot pieniędzy za uszkodzony telefon.", TicketQuestions)
|
|
44
|
+
print(response.answers.department.choice) # str, ChoiceAnswer
|
|
45
|
+
print(response.answers.second_department.choice) # independent answer
|
|
46
|
+
print(response.answers.urgent.noul) # float, NoulAnswer
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Pass the class itself, without `()`. No response class, field annotations, casts, type-checker
|
|
50
|
+
plugin or code generator is needed. The same definition may appear under several names.
|
|
51
|
+
|
|
52
|
+
For dynamic question sets, use a dictionary and read answers by their request keys:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from basal_client import BasalClient, choice
|
|
56
|
+
|
|
57
|
+
routing = choice("Which department?", {"returns": "Returns", "support": "Support"})
|
|
58
|
+
with BasalClient() as client:
|
|
59
|
+
response = client.decide("The phone is broken.", {
|
|
60
|
+
"department": routing,
|
|
61
|
+
"second_department": routing,
|
|
62
|
+
})
|
|
63
|
+
print(response.answers["department"])
|
|
64
|
+
print(response.answer("second_department"))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Dictionary values are checked answer dataclasses, with the static union `AnswerResult`.
|
|
68
|
+
Use `isinstance(answer, ChoiceAnswer)` to narrow that union when needed. Python cannot infer
|
|
69
|
+
an individual answer type from an arbitrary string key; the class form supplies that information.
|
|
70
|
+
Choice/label keys remain `str` in Python, rather than inferred literal unions.
|
|
71
|
+
`answer()` now takes a request key, not a question object (a change before the initial release).
|
|
72
|
+
|
|
73
|
+
Native asynchronous HTTP with connection pooling supports both forms:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from basal_client import AsyncBasalClient, Questions, noul
|
|
77
|
+
|
|
78
|
+
class Availability(Questions):
|
|
79
|
+
urgent = noul("Is this urgent?")
|
|
80
|
+
|
|
81
|
+
async def decide():
|
|
82
|
+
async with AsyncBasalClient(timeout=120) as client:
|
|
83
|
+
response = await client.decide("The service is unavailable.", Availability)
|
|
84
|
+
return response.answers.urgent.noul
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`models()` reads `/v1/models`. An optional `api_key` adds a bearer token
|
|
88
|
+
for an authenticating reverse proxy. The server root URL may include a proxy prefix; do not append `/v1`.
|
|
89
|
+
HTTPS is required except for loopback HTTP, suitable for local servers and SSH tunnels.
|
|
90
|
+
URL credentials/query/fragment are rejected. `trust_env=False` is the default: environment proxies and
|
|
91
|
+
certificate overrides are ignored. Set `trust_env=True` only when you intend to trust that configuration.
|
|
92
|
+
`max_response_bytes` defaults to 8 MiB and limits the actual decoded stream, including HTTP errors;
|
|
93
|
+
oversized responses raise `BasalResponseTooLargeError`. TLS verification remains enabled.
|
|
94
|
+
The client requests `Accept-Encoding: identity` and rejects compressed responses before decoding,
|
|
95
|
+
including when a proxy ignores that header. Configure proxies to return uncompressed JSON for this client.
|
|
96
|
+
Malformed or excessively nested JSON raises `BasalProtocolError`.
|
|
97
|
+
`timeout` is in seconds and applies to connect/read/write/pool phases, not a total wall-clock deadline.
|
|
98
|
+
|
|
99
|
+
`BasalHTTPError` provides `status_code`, `body` and the raw `retry_after` header. Transport failures raise
|
|
100
|
+
`BasalTransportError`; malformed successful JSON raises `BasalProtocolError`. Original exceptions are
|
|
101
|
+
available through `__cause__`. There are no automatic retries or redirects. Type hints describe the native
|
|
102
|
+
contract; successful responses are checked to be JSON objects, without full schema validation. Unknown
|
|
103
|
+
JSON fields are preserved. Server validation errors (422) and overload (529) remain distinct.
|
|
104
|
+
|
|
105
|
+
Supports `choice`, `noul`, `score`, `multi`, `act`, `facts` and `evidence`. Criterion order matters. Native
|
|
106
|
+
choice/score accepts 2–10 options; multi has independent probabilities and `set_confidence`. Evidence
|
|
107
|
+
offsets count Unicode characters. The SDK does not evaluate model decisions or perform selected actions.
|
|
108
|
+
|
|
109
|
+
Factories `choice`, `noul`, `score`, `multi`, `act` return concrete generic question classes.
|
|
110
|
+
Constructors `ChoiceQuestion(...)` etc. have the same signatures. Required response fields are
|
|
111
|
+
validated before returning; evidence defaults to an empty tuple. `response.raw` retains native
|
|
112
|
+
JSON and extensions. The one-argument `decide(request)` remains available for full native
|
|
113
|
+
JSON dictionaries. `model=` and `facts=` work with either two-argument form.
|
|
114
|
+
|
|
115
|
+
Schema fields may be inherited and overridden with the same question kind for compatibility
|
|
116
|
+
with both mypy and Pyright. Changing a field's kind or removing it with `None` is not supported
|
|
117
|
+
by mypy. Field order follows reversed Python MRO, like dataclasses: for `class D(M1, M2)`,
|
|
118
|
+
fields from `M2` precede those from `M1`; overriding a field retains its original position.
|
|
119
|
+
Names starting with `_` are reserved. Schema instances are populated by the SDK without
|
|
120
|
+
calling user constructors, and their fields cannot be reassigned. Define schemas declaratively;
|
|
121
|
+
do not mutate class definitions while requests are running. Dictionary question maps are also
|
|
122
|
+
snapshotted before I/O. Response dictionary keys are read-only; nested native data is not deeply frozen.
|
|
123
|
+
|
|
124
|
+
Typed response/answer containers do not support the copy/pickle protocols. For caching or
|
|
125
|
+
passing a result between processes, serialize `response.raw` as JSON. Use `copy.deepcopy(response.raw)`
|
|
126
|
+
when a detached native result is needed. Question definitions may also be stored in ordinary
|
|
127
|
+
classes, `ClassVar` attributes or dataclasses; outside `Questions` they remain question definitions.
|
|
128
|
+
|
|
129
|
+
[Full documentation and publication setup](https://github.com/itsoltech/basal-rs/blob/main/docs/SDK.md).
|
|
130
|
+
|
|
131
|
+
Runtime uses HTTPX with seven dependencies in the audited Python 3.11 graph. Building requires only the
|
|
132
|
+
hash-locked Flit Core backend; consumers of the wheel do not install the build backend.
|
|
133
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# basal-client (Python)
|
|
2
|
+
|
|
3
|
+
HTTP SDK for the native Basal API (`POST /v1/basal`). Python 3.11+; no GPU required on the client.
|
|
4
|
+
|
|
5
|
+
This package is versioned independently. Release tag: `sdk-python-vX.Y.Z`.
|
|
6
|
+
[Changelog](https://github.com/itsoltech/basal-rs/blob/main/sdk/python/CHANGELOG.md).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pip install basal-client
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Choose either a normal dictionary or a declarative class. Both use the same factories.
|
|
13
|
+
A class gives each response field its exact inferred answer type:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from basal_client import BasalClient, Questions, choice, noul
|
|
17
|
+
|
|
18
|
+
routing = choice("Do którego działu skierować zgłoszenie?", {"returns": "Zwroty", "support": "Wsparcie techniczne"})
|
|
19
|
+
|
|
20
|
+
class TicketQuestions(Questions):
|
|
21
|
+
department = routing
|
|
22
|
+
second_department = routing
|
|
23
|
+
urgent = noul("Czy sprawa jest pilna?")
|
|
24
|
+
|
|
25
|
+
with BasalClient("http://127.0.0.1:8000") as client:
|
|
26
|
+
response = client.decide("Klient prosi o zwrot pieniędzy za uszkodzony telefon.", TicketQuestions)
|
|
27
|
+
print(response.answers.department.choice) # str, ChoiceAnswer
|
|
28
|
+
print(response.answers.second_department.choice) # independent answer
|
|
29
|
+
print(response.answers.urgent.noul) # float, NoulAnswer
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Pass the class itself, without `()`. No response class, field annotations, casts, type-checker
|
|
33
|
+
plugin or code generator is needed. The same definition may appear under several names.
|
|
34
|
+
|
|
35
|
+
For dynamic question sets, use a dictionary and read answers by their request keys:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from basal_client import BasalClient, choice
|
|
39
|
+
|
|
40
|
+
routing = choice("Which department?", {"returns": "Returns", "support": "Support"})
|
|
41
|
+
with BasalClient() as client:
|
|
42
|
+
response = client.decide("The phone is broken.", {
|
|
43
|
+
"department": routing,
|
|
44
|
+
"second_department": routing,
|
|
45
|
+
})
|
|
46
|
+
print(response.answers["department"])
|
|
47
|
+
print(response.answer("second_department"))
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Dictionary values are checked answer dataclasses, with the static union `AnswerResult`.
|
|
51
|
+
Use `isinstance(answer, ChoiceAnswer)` to narrow that union when needed. Python cannot infer
|
|
52
|
+
an individual answer type from an arbitrary string key; the class form supplies that information.
|
|
53
|
+
Choice/label keys remain `str` in Python, rather than inferred literal unions.
|
|
54
|
+
`answer()` now takes a request key, not a question object (a change before the initial release).
|
|
55
|
+
|
|
56
|
+
Native asynchronous HTTP with connection pooling supports both forms:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from basal_client import AsyncBasalClient, Questions, noul
|
|
60
|
+
|
|
61
|
+
class Availability(Questions):
|
|
62
|
+
urgent = noul("Is this urgent?")
|
|
63
|
+
|
|
64
|
+
async def decide():
|
|
65
|
+
async with AsyncBasalClient(timeout=120) as client:
|
|
66
|
+
response = await client.decide("The service is unavailable.", Availability)
|
|
67
|
+
return response.answers.urgent.noul
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`models()` reads `/v1/models`. An optional `api_key` adds a bearer token
|
|
71
|
+
for an authenticating reverse proxy. The server root URL may include a proxy prefix; do not append `/v1`.
|
|
72
|
+
HTTPS is required except for loopback HTTP, suitable for local servers and SSH tunnels.
|
|
73
|
+
URL credentials/query/fragment are rejected. `trust_env=False` is the default: environment proxies and
|
|
74
|
+
certificate overrides are ignored. Set `trust_env=True` only when you intend to trust that configuration.
|
|
75
|
+
`max_response_bytes` defaults to 8 MiB and limits the actual decoded stream, including HTTP errors;
|
|
76
|
+
oversized responses raise `BasalResponseTooLargeError`. TLS verification remains enabled.
|
|
77
|
+
The client requests `Accept-Encoding: identity` and rejects compressed responses before decoding,
|
|
78
|
+
including when a proxy ignores that header. Configure proxies to return uncompressed JSON for this client.
|
|
79
|
+
Malformed or excessively nested JSON raises `BasalProtocolError`.
|
|
80
|
+
`timeout` is in seconds and applies to connect/read/write/pool phases, not a total wall-clock deadline.
|
|
81
|
+
|
|
82
|
+
`BasalHTTPError` provides `status_code`, `body` and the raw `retry_after` header. Transport failures raise
|
|
83
|
+
`BasalTransportError`; malformed successful JSON raises `BasalProtocolError`. Original exceptions are
|
|
84
|
+
available through `__cause__`. There are no automatic retries or redirects. Type hints describe the native
|
|
85
|
+
contract; successful responses are checked to be JSON objects, without full schema validation. Unknown
|
|
86
|
+
JSON fields are preserved. Server validation errors (422) and overload (529) remain distinct.
|
|
87
|
+
|
|
88
|
+
Supports `choice`, `noul`, `score`, `multi`, `act`, `facts` and `evidence`. Criterion order matters. Native
|
|
89
|
+
choice/score accepts 2–10 options; multi has independent probabilities and `set_confidence`. Evidence
|
|
90
|
+
offsets count Unicode characters. The SDK does not evaluate model decisions or perform selected actions.
|
|
91
|
+
|
|
92
|
+
Factories `choice`, `noul`, `score`, `multi`, `act` return concrete generic question classes.
|
|
93
|
+
Constructors `ChoiceQuestion(...)` etc. have the same signatures. Required response fields are
|
|
94
|
+
validated before returning; evidence defaults to an empty tuple. `response.raw` retains native
|
|
95
|
+
JSON and extensions. The one-argument `decide(request)` remains available for full native
|
|
96
|
+
JSON dictionaries. `model=` and `facts=` work with either two-argument form.
|
|
97
|
+
|
|
98
|
+
Schema fields may be inherited and overridden with the same question kind for compatibility
|
|
99
|
+
with both mypy and Pyright. Changing a field's kind or removing it with `None` is not supported
|
|
100
|
+
by mypy. Field order follows reversed Python MRO, like dataclasses: for `class D(M1, M2)`,
|
|
101
|
+
fields from `M2` precede those from `M1`; overriding a field retains its original position.
|
|
102
|
+
Names starting with `_` are reserved. Schema instances are populated by the SDK without
|
|
103
|
+
calling user constructors, and their fields cannot be reassigned. Define schemas declaratively;
|
|
104
|
+
do not mutate class definitions while requests are running. Dictionary question maps are also
|
|
105
|
+
snapshotted before I/O. Response dictionary keys are read-only; nested native data is not deeply frozen.
|
|
106
|
+
|
|
107
|
+
Typed response/answer containers do not support the copy/pickle protocols. For caching or
|
|
108
|
+
passing a result between processes, serialize `response.raw` as JSON. Use `copy.deepcopy(response.raw)`
|
|
109
|
+
when a detached native result is needed. Question definitions may also be stored in ordinary
|
|
110
|
+
classes, `ClassVar` attributes or dataclasses; outside `Questions` they remain question definitions.
|
|
111
|
+
|
|
112
|
+
[Full documentation and publication setup](https://github.com/itsoltech/basal-rs/blob/main/docs/SDK.md).
|
|
113
|
+
|
|
114
|
+
Runtime uses HTTPX with seven dependencies in the audited Python 3.11 graph. Building requires only the
|
|
115
|
+
hash-locked Flit Core backend; consumers of the wheel do not install the build backend.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core==4.1.0"]
|
|
3
|
+
build-backend = "flit_core.buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "basal-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the native Basal HTTP API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
license-files = ["LICENSE", "NOTICE"]
|
|
13
|
+
authors = [{name = "IT SOL"}]
|
|
14
|
+
dependencies = ["httpx>=0.28,<1"]
|
|
15
|
+
classifiers = ["Programming Language :: Python :: 3", "Typing :: Typed"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Source = "https://github.com/itsoltech/basal-rs/tree/main/sdk/python"
|
|
19
|
+
Documentation = "https://github.com/itsoltech/basal-rs/blob/main/docs/SDK.md"
|
|
20
|
+
|
|
21
|
+
[tool.flit.module]
|
|
22
|
+
name = "basal_client"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Client for the native Basal API (/v1/basal)."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
AsyncBasalClient, BasalClient, BasalError, BasalHTTPError,
|
|
5
|
+
BasalProtocolError, BasalTransportError, BasalResponseTooLargeError,
|
|
6
|
+
)
|
|
7
|
+
from .types import (
|
|
8
|
+
Answer, DecisionRequest, DecisionResponse, EvidenceSpan,
|
|
9
|
+
JsonValue, ModelMetadata, ModelsResponse, Question, Usage,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AsyncBasalClient", "BasalClient", "BasalError", "BasalHTTPError",
|
|
14
|
+
"BasalProtocolError", "BasalTransportError", "BasalResponseTooLargeError", "Answer", "DecisionRequest",
|
|
15
|
+
"DecisionResponse", "EvidenceSpan", "JsonValue",
|
|
16
|
+
"ModelMetadata", "ModelsResponse", "Question", "Usage",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
from .questions import (
|
|
20
|
+
ChoiceAnswer, NoulAnswer, ScoreAnswer, MultiAnswer, ActAnswer,
|
|
21
|
+
ChoiceQuestion, NoulQuestion, ScoreQuestion, MultiQuestion, ActQuestion, QuestionSpec, QuestionSet, TypedDecisionResponse,
|
|
22
|
+
Questions, SchemaDecisionResponse, AnswerResult,
|
|
23
|
+
choice, noul, score, multi, act,
|
|
24
|
+
)
|
|
25
|
+
__all__ += [
|
|
26
|
+
"ChoiceAnswer", "NoulAnswer", "ScoreAnswer", "MultiAnswer", "ActAnswer",
|
|
27
|
+
"ChoiceQuestion", "NoulQuestion", "ScoreQuestion", "MultiQuestion", "ActQuestion", "QuestionSpec", "QuestionSet", "TypedDecisionResponse",
|
|
28
|
+
"Questions", "SchemaDecisionResponse", "AnswerResult",
|
|
29
|
+
"choice", "noul", "score", "multi", "act",
|
|
30
|
+
]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Connection-pooled synchronous and asynchronous clients for the Basal API."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Literal, Self, cast, overload
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .types import DecisionRequest, DecisionResponse, ModelsResponse, JsonValue
|
|
13
|
+
from .questions import (
|
|
14
|
+
Questions, QuestionSet, TSchema, TypedDecisionResponse, SchemaDecisionResponse,
|
|
15
|
+
build_questions, schema_questions,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BasalError(Exception):
|
|
20
|
+
"""Base SDK error. Transport errors retain their original exception as cause."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BasalHTTPError(BasalError):
|
|
24
|
+
"""Non-2xx response, retaining the body and unmodified Retry-After header."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, status_code: int, body: Any, retry_after: str | None):
|
|
27
|
+
# Keep server bodies accessible explicitly; do not include state or secrets in logs.
|
|
28
|
+
super().__init__(f"Basal HTTP {status_code}")
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.body = body
|
|
31
|
+
self.retry_after = retry_after
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BasalTransportError(BasalError):
|
|
35
|
+
"""Connection, timeout or response-body transport failure."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BasalResponseTooLargeError(BasalError):
|
|
39
|
+
"""Actual streamed response exceeds the configured byte limit."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BasalProtocolError(BasalError):
|
|
43
|
+
"""Invalid JSON envelope or unsupported response encoding."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _options(base_url: str, api_key: str | None, timeout: float) -> dict[str, Any]:
|
|
47
|
+
url = urlsplit(base_url)
|
|
48
|
+
if (
|
|
49
|
+
url.scheme not in ("http", "https")
|
|
50
|
+
or not url.hostname
|
|
51
|
+
or url.username is not None
|
|
52
|
+
or url.password is not None
|
|
53
|
+
or url.query
|
|
54
|
+
or url.fragment
|
|
55
|
+
or "?" in base_url
|
|
56
|
+
or "#" in base_url
|
|
57
|
+
):
|
|
58
|
+
raise ValueError("base_url must be an HTTP(S) server URL without credentials, query or fragment")
|
|
59
|
+
try:
|
|
60
|
+
loopback = ipaddress.ip_address(url.hostname).is_loopback
|
|
61
|
+
except ValueError:
|
|
62
|
+
loopback = url.hostname.lower() == "localhost"
|
|
63
|
+
if url.scheme != "https" and not loopback:
|
|
64
|
+
raise ValueError("base_url requires HTTPS or loopback HTTP (e.g. an SSH tunnel)")
|
|
65
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
66
|
+
raise ValueError("timeout must be finite and positive (seconds)")
|
|
67
|
+
headers = {"Accept": "application/json", "Accept-Encoding": "identity", "User-Agent": "basal-client-python"}
|
|
68
|
+
if api_key is not None:
|
|
69
|
+
if not api_key or "\r" in api_key or "\n" in api_key:
|
|
70
|
+
raise ValueError("api_key must be a nonempty, single-line token")
|
|
71
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
72
|
+
return dict(base_url=base_url.rstrip("/") + "/", headers=headers, timeout=timeout,
|
|
73
|
+
follow_redirects=False)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _decode(response: httpx.Response, raw: bytes) -> dict[str, Any]:
|
|
77
|
+
try:
|
|
78
|
+
body = json.loads(raw)
|
|
79
|
+
except (ValueError, UnicodeError, RecursionError) as error:
|
|
80
|
+
if response.is_success:
|
|
81
|
+
raise BasalProtocolError("Basal returned invalid JSON") from error
|
|
82
|
+
body = raw.decode("utf-8", errors="replace")
|
|
83
|
+
if not response.is_success:
|
|
84
|
+
raise BasalHTTPError(response.status_code, body, response.headers.get("Retry-After"))
|
|
85
|
+
if not isinstance(body, dict):
|
|
86
|
+
raise BasalProtocolError("Basal returned a JSON value other than an object")
|
|
87
|
+
return body
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_limit(limit: int) -> int:
|
|
91
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
|
|
92
|
+
raise ValueError("max_response_bytes must be a positive integer")
|
|
93
|
+
return limit
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check_encoding(response: httpx.Response) -> None:
|
|
97
|
+
# Reject before HTTPX invokes a decompressor, including when a proxy ignores Accept-Encoding.
|
|
98
|
+
if response.headers.get("Content-Encoding", "").strip().lower() not in ("", "identity"):
|
|
99
|
+
raise BasalProtocolError("Basal requires an uncompressed response")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _decision_request(state: DecisionRequest | JsonValue, questions: QuestionSet | None,
|
|
103
|
+
model: str | None, facts: Literal["off", "auto"] | None) -> DecisionRequest:
|
|
104
|
+
if questions is None:
|
|
105
|
+
if model is not None or facts is not None:
|
|
106
|
+
raise ValueError("pass model/facts in a raw request, or supply typed questions")
|
|
107
|
+
if not isinstance(state, dict):
|
|
108
|
+
raise TypeError("a raw request must be a dictionary")
|
|
109
|
+
return cast(DecisionRequest, state)
|
|
110
|
+
if state is None:
|
|
111
|
+
raise ValueError("state must not be null")
|
|
112
|
+
request: DecisionRequest = {"state": cast(JsonValue, state), "questions": build_questions(questions)}
|
|
113
|
+
if model is not None:
|
|
114
|
+
if not isinstance(model, str) or not model.strip():
|
|
115
|
+
raise ValueError("model must be a nonempty string")
|
|
116
|
+
request["model"] = model
|
|
117
|
+
if facts is not None:
|
|
118
|
+
if facts not in ("off", "auto"):
|
|
119
|
+
raise ValueError("facts must be off or auto")
|
|
120
|
+
request["facts"] = facts
|
|
121
|
+
return request
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _prepare_questions(questions: QuestionSet | type[Questions] | None) -> tuple[type[Questions] | None, QuestionSet | None]:
|
|
125
|
+
if isinstance(questions, type):
|
|
126
|
+
return questions, schema_questions(questions)
|
|
127
|
+
return None, None if questions is None else dict(questions)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class BasalClient:
|
|
131
|
+
"""Sync client. Reuse one instance; close it or use a with block.
|
|
132
|
+
|
|
133
|
+
base_url is the server root (optionally a proxy prefix), without /v1.
|
|
134
|
+
timeout applies to each HTTPX network phase, not the whole request.
|
|
135
|
+
No automatic retries: the caller decides whether to repeat inference.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, base_url: str = "http://127.0.0.1:8000", *,
|
|
139
|
+
api_key: str | None = None, timeout: float = 120.0,
|
|
140
|
+
max_response_bytes: int = 8 * 1024 * 1024, trust_env: bool = False):
|
|
141
|
+
self._max_response_bytes = _validate_limit(max_response_bytes)
|
|
142
|
+
self._http = httpx.Client(**_options(base_url, api_key, timeout), trust_env=trust_env)
|
|
143
|
+
|
|
144
|
+
def _request(self, method: str, path: str, body: DecisionRequest | None = None) -> dict[str, Any]:
|
|
145
|
+
try:
|
|
146
|
+
with self._http.stream(method, path, json=body) as response:
|
|
147
|
+
_check_encoding(response)
|
|
148
|
+
raw = bytearray()
|
|
149
|
+
for chunk in response.iter_bytes():
|
|
150
|
+
if len(chunk) > self._max_response_bytes - len(raw):
|
|
151
|
+
raise BasalResponseTooLargeError("Basal response exceeds the configured byte limit")
|
|
152
|
+
raw.extend(chunk)
|
|
153
|
+
return _decode(response, bytes(raw))
|
|
154
|
+
except httpx.RequestError as error:
|
|
155
|
+
raise BasalTransportError("Basal HTTP transport failed") from error
|
|
156
|
+
|
|
157
|
+
@overload
|
|
158
|
+
def decide(self, state: DecisionRequest) -> DecisionResponse: ...
|
|
159
|
+
|
|
160
|
+
@overload
|
|
161
|
+
def decide(self, state: JsonValue, questions: QuestionSet, *, model: str | None = None,
|
|
162
|
+
facts: Literal["off", "auto"] | None = None) -> TypedDecisionResponse: ...
|
|
163
|
+
|
|
164
|
+
@overload
|
|
165
|
+
def decide(self, state: JsonValue, questions: type[TSchema], *, model: str | None = None,
|
|
166
|
+
facts: Literal["off", "auto"] | None = None) -> SchemaDecisionResponse[TSchema]: ...
|
|
167
|
+
|
|
168
|
+
def decide(self, state: DecisionRequest | JsonValue, questions: QuestionSet | type[Questions] | None = None, *,
|
|
169
|
+
model: str | None = None, facts: Literal["off", "auto"] | None = None) -> DecisionResponse | TypedDecisionResponse | SchemaDecisionResponse[Questions]:
|
|
170
|
+
"""POST a raw request, or decide(state, a question dictionary or Questions class)."""
|
|
171
|
+
schema, fields = _prepare_questions(questions)
|
|
172
|
+
response = cast(DecisionResponse, self._request("POST", "v1/basal", _decision_request(state, fields, model, facts)))
|
|
173
|
+
if fields is None:
|
|
174
|
+
return response
|
|
175
|
+
return SchemaDecisionResponse(response, schema, fields) if schema is not None else TypedDecisionResponse(response, fields)
|
|
176
|
+
|
|
177
|
+
def models(self) -> ModelsResponse:
|
|
178
|
+
"""GET /v1/models."""
|
|
179
|
+
return cast(ModelsResponse, self._request("GET", "v1/models"))
|
|
180
|
+
|
|
181
|
+
def close(self) -> None:
|
|
182
|
+
self._http.close()
|
|
183
|
+
|
|
184
|
+
def __enter__(self) -> Self:
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None,
|
|
188
|
+
traceback: TracebackType | None) -> None:
|
|
189
|
+
self.close()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class AsyncBasalClient:
|
|
193
|
+
"""Native async HTTPX client; use async with or await aclose()."""
|
|
194
|
+
|
|
195
|
+
def __init__(self, base_url: str = "http://127.0.0.1:8000", *,
|
|
196
|
+
api_key: str | None = None, timeout: float = 120.0,
|
|
197
|
+
max_response_bytes: int = 8 * 1024 * 1024, trust_env: bool = False):
|
|
198
|
+
self._max_response_bytes = _validate_limit(max_response_bytes)
|
|
199
|
+
self._http = httpx.AsyncClient(**_options(base_url, api_key, timeout), trust_env=trust_env)
|
|
200
|
+
|
|
201
|
+
async def _request(self, method: str, path: str, body: DecisionRequest | None = None) -> dict[str, Any]:
|
|
202
|
+
try:
|
|
203
|
+
async with self._http.stream(method, path, json=body) as response:
|
|
204
|
+
_check_encoding(response)
|
|
205
|
+
raw = bytearray()
|
|
206
|
+
async for chunk in response.aiter_bytes():
|
|
207
|
+
if len(chunk) > self._max_response_bytes - len(raw):
|
|
208
|
+
raise BasalResponseTooLargeError("Basal response exceeds the configured byte limit")
|
|
209
|
+
raw.extend(chunk)
|
|
210
|
+
return _decode(response, bytes(raw))
|
|
211
|
+
except httpx.RequestError as error:
|
|
212
|
+
raise BasalTransportError("Basal HTTP transport failed") from error
|
|
213
|
+
|
|
214
|
+
@overload
|
|
215
|
+
async def decide(self, state: DecisionRequest) -> DecisionResponse: ...
|
|
216
|
+
|
|
217
|
+
@overload
|
|
218
|
+
async def decide(self, state: JsonValue, questions: QuestionSet, *, model: str | None = None,
|
|
219
|
+
facts: Literal["off", "auto"] | None = None) -> TypedDecisionResponse: ...
|
|
220
|
+
|
|
221
|
+
@overload
|
|
222
|
+
async def decide(self, state: JsonValue, questions: type[TSchema], *, model: str | None = None,
|
|
223
|
+
facts: Literal["off", "auto"] | None = None) -> SchemaDecisionResponse[TSchema]: ...
|
|
224
|
+
|
|
225
|
+
async def decide(self, state: DecisionRequest | JsonValue, questions: QuestionSet | type[Questions] | None = None, *,
|
|
226
|
+
model: str | None = None, facts: Literal["off", "auto"] | None = None) -> DecisionResponse | TypedDecisionResponse | SchemaDecisionResponse[Questions]:
|
|
227
|
+
"""POST a raw request, a question dictionary, or a declarative Questions class."""
|
|
228
|
+
schema, fields = _prepare_questions(questions)
|
|
229
|
+
response = cast(DecisionResponse, await self._request("POST", "v1/basal", _decision_request(state, fields, model, facts)))
|
|
230
|
+
if fields is None:
|
|
231
|
+
return response
|
|
232
|
+
return SchemaDecisionResponse(response, schema, fields) if schema is not None else TypedDecisionResponse(response, fields)
|
|
233
|
+
|
|
234
|
+
async def models(self) -> ModelsResponse:
|
|
235
|
+
return cast(ModelsResponse, await self._request("GET", "v1/models"))
|
|
236
|
+
|
|
237
|
+
async def aclose(self) -> None:
|
|
238
|
+
await self._http.aclose()
|
|
239
|
+
|
|
240
|
+
async def __aenter__(self) -> Self:
|
|
241
|
+
return self
|
|
242
|
+
|
|
243
|
+
async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None,
|
|
244
|
+
traceback: TracebackType | None) -> None:
|
|
245
|
+
await self.aclose()
|
|
File without changes
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Typed, validated question factories. Wire dictionaries remain available separately."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import math
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Any, Generic, Literal, Self, TypeVar, cast, overload
|
|
11
|
+
|
|
12
|
+
from .types import DecisionResponse, EvidenceSpan, JsonValue, Question as WireQuestion, Usage
|
|
13
|
+
|
|
14
|
+
OptionKeys = Literal["show", "hide"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ChoiceAnswer:
|
|
19
|
+
choice: str
|
|
20
|
+
confidence: float
|
|
21
|
+
probabilities: dict[str, float]
|
|
22
|
+
evidence: tuple[EvidenceSpan, ...] = ()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class NoulAnswer:
|
|
27
|
+
noul: float
|
|
28
|
+
confidence: float
|
|
29
|
+
probabilities: dict[str, float]
|
|
30
|
+
evidence: tuple[EvidenceSpan, ...] = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ScoreAnswer:
|
|
35
|
+
score: float
|
|
36
|
+
confidence: float
|
|
37
|
+
legend: dict[str, str]
|
|
38
|
+
probabilities: dict[str, float]
|
|
39
|
+
evidence: tuple[EvidenceSpan, ...] = ()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class MultiAnswer:
|
|
44
|
+
selected: tuple[str, ...]
|
|
45
|
+
threshold: float
|
|
46
|
+
set_confidence: float
|
|
47
|
+
probabilities: dict[str, float]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class ActAnswer:
|
|
52
|
+
action: str
|
|
53
|
+
answer: str
|
|
54
|
+
expected_costs: dict[str, float]
|
|
55
|
+
confidence: float
|
|
56
|
+
calibration: Literal["validated", "unvalidated"]
|
|
57
|
+
probabilities: dict[str, float]
|
|
58
|
+
evidence: tuple[EvidenceSpan, ...] = ()
|
|
59
|
+
refused: str | None = None
|
|
60
|
+
max_error: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
TAnswer = TypeVar("TAnswer")
|
|
64
|
+
AnswerResult = ChoiceAnswer | NoulAnswer | ScoreAnswer | MultiAnswer | ActAnswer
|
|
65
|
+
TSchema = TypeVar("TSchema", bound="Questions")
|
|
66
|
+
TSchema_co = TypeVar("TSchema_co", bound="Questions", covariant=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _text(value: str, name: str) -> str:
|
|
70
|
+
if not isinstance(value, str) or not value.strip():
|
|
71
|
+
raise ValueError(f"{name} must be a nonempty string")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _criteria(values: Mapping[str, str], minimum: int, maximum: int | None) -> dict[str, JsonValue]:
|
|
76
|
+
if not isinstance(values, Mapping) or len(values) < minimum or (maximum is not None and len(values) > maximum):
|
|
77
|
+
raise ValueError("invalid number of criteria")
|
|
78
|
+
return {_text(key, "criterion key"): _text(value, "criterion description") for key, value in values.items()}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _number(value: float, name: str, probability: bool = False) -> float:
|
|
82
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
83
|
+
raise ValueError(f"{name} must be finite")
|
|
84
|
+
if probability and not 0 <= value <= 1:
|
|
85
|
+
raise ValueError(f"{name} must be between 0 and 1")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class QuestionSpec(Generic[TAnswer]):
|
|
90
|
+
"""Typed question. to_dict() returns a copy; read() checks its answer's required fields."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, kind: str, instructions: str, fields: dict[str, JsonValue]):
|
|
93
|
+
self._payload: dict[str, JsonValue] = {"type": kind, "instructions": _text(instructions, "instructions"), **fields}
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> WireQuestion:
|
|
96
|
+
return cast(WireQuestion, deepcopy(self._payload))
|
|
97
|
+
|
|
98
|
+
@overload
|
|
99
|
+
def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ...
|
|
100
|
+
|
|
101
|
+
@overload
|
|
102
|
+
def __get__(self, instance: Questions, owner: type[Any] | None = None) -> TAnswer: ...
|
|
103
|
+
|
|
104
|
+
@overload
|
|
105
|
+
def __get__(self, instance: object, owner: type[Any] | None = None) -> Self: ...
|
|
106
|
+
|
|
107
|
+
def __get__(self, instance: object, owner: type[Any] | None = None) -> Self | TAnswer:
|
|
108
|
+
# Non-data descriptor: validated answers stored under individual instance
|
|
109
|
+
# keys shadow the definition. Reusing one definition never merges keys.
|
|
110
|
+
# Outside a Questions schema, definitions remain ordinary values, also
|
|
111
|
+
# when stored in namespace classes, instance fields or dataclasses.
|
|
112
|
+
if not isinstance(instance, Questions):
|
|
113
|
+
return self
|
|
114
|
+
raise AttributeError("Question answers are populated by client.decide")
|
|
115
|
+
|
|
116
|
+
def read(self, response: DecisionResponse, question_id: str) -> TAnswer:
|
|
117
|
+
from .client import BasalProtocolError
|
|
118
|
+
answer = response.get("answers", {}).get(question_id)
|
|
119
|
+
if not isinstance(answer, dict) or answer.get("type") != self._payload["type"]:
|
|
120
|
+
raise BasalProtocolError("Basal returned a missing or mismatched answer")
|
|
121
|
+
data = cast(dict[str, Any], answer)
|
|
122
|
+
try:
|
|
123
|
+
probabilities = data["probabilities"]
|
|
124
|
+
if not isinstance(probabilities, dict) or any(not isinstance(k, str) for k in probabilities):
|
|
125
|
+
raise ValueError("invalid probabilities")
|
|
126
|
+
for value in probabilities.values():
|
|
127
|
+
_number(value, "probability", True)
|
|
128
|
+
spans = data.get("evidence", [])
|
|
129
|
+
if not isinstance(spans, list):
|
|
130
|
+
raise ValueError("invalid evidence")
|
|
131
|
+
for span in spans:
|
|
132
|
+
if not isinstance(span, dict) or not isinstance(span.get("text"), str):
|
|
133
|
+
raise ValueError("invalid span")
|
|
134
|
+
for field in ("start", "end"):
|
|
135
|
+
if isinstance(span.get(field), bool) or not isinstance(span.get(field), int) or span[field] < 0:
|
|
136
|
+
raise ValueError("invalid offset")
|
|
137
|
+
_number(span["probability"], "span probability", True)
|
|
138
|
+
evidence = tuple(cast(list[EvidenceSpan], spans))
|
|
139
|
+
kind = data["type"]
|
|
140
|
+
result: ChoiceAnswer | NoulAnswer | ScoreAnswer | MultiAnswer | ActAnswer
|
|
141
|
+
if kind == "choice":
|
|
142
|
+
selected = _text(data["choice"], "choice")
|
|
143
|
+
if selected not in cast(dict[str, JsonValue], self._payload["criteria"]):
|
|
144
|
+
raise ValueError("unknown choice")
|
|
145
|
+
result = ChoiceAnswer(selected, _number(data["confidence"], "confidence", True), probabilities, evidence)
|
|
146
|
+
elif kind == "noul":
|
|
147
|
+
result = NoulAnswer(_number(data["noul"], "noul", True), _number(data["confidence"], "confidence", True), probabilities, evidence)
|
|
148
|
+
elif kind == "score":
|
|
149
|
+
legend = data["legend"]
|
|
150
|
+
if not isinstance(legend, dict) or any(not isinstance(k, str) or not isinstance(v, str) for k, v in legend.items()):
|
|
151
|
+
raise ValueError("invalid legend")
|
|
152
|
+
result = ScoreAnswer(_number(data["score"], "score"), _number(data["confidence"], "confidence", True), legend, probabilities, evidence)
|
|
153
|
+
elif kind == "multi":
|
|
154
|
+
labels = data["selected"]
|
|
155
|
+
if not isinstance(labels, list) or any(not isinstance(k, str) or k not in cast(dict[str, JsonValue], self._payload["criteria"]) for k in labels):
|
|
156
|
+
raise ValueError("invalid selected labels")
|
|
157
|
+
result = MultiAnswer(tuple(labels), _number(data["threshold"], "threshold", True), _number(data["set_confidence"], "set_confidence", True), probabilities)
|
|
158
|
+
else:
|
|
159
|
+
action = _text(data["action"], "action")
|
|
160
|
+
if action not in cast(dict[str, JsonValue], self._payload["costs"]) or data["calibration"] not in ("validated", "unvalidated"):
|
|
161
|
+
raise ValueError("invalid action or calibration")
|
|
162
|
+
costs = data["expected_costs"]
|
|
163
|
+
if not isinstance(costs, dict) or any(not isinstance(k, str) for k in costs):
|
|
164
|
+
raise ValueError("invalid expected costs")
|
|
165
|
+
for value in costs.values():
|
|
166
|
+
_number(value, "expected cost")
|
|
167
|
+
refused, max_error = data.get("refused"), data.get("max_error")
|
|
168
|
+
if any(value is not None and not isinstance(value, str) for value in (refused, max_error)):
|
|
169
|
+
raise ValueError("invalid act metadata")
|
|
170
|
+
result = ActAnswer(action, _text(data["answer"], "answer"), costs, _number(data["confidence"], "confidence", True), data["calibration"], probabilities, evidence, refused, max_error)
|
|
171
|
+
return cast(TAnswer, result)
|
|
172
|
+
except (KeyError, TypeError, ValueError, OverflowError) as error:
|
|
173
|
+
raise BasalProtocolError("Basal returned an invalid typed answer") from error
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _display(evidence: bool, option_keys: OptionKeys) -> dict[str, JsonValue]:
|
|
177
|
+
if not isinstance(evidence, bool) or option_keys not in ("show", "hide"):
|
|
178
|
+
raise ValueError("invalid evidence or option_keys")
|
|
179
|
+
fields: dict[str, JsonValue] = {}
|
|
180
|
+
if evidence:
|
|
181
|
+
fields["evidence"] = True
|
|
182
|
+
if option_keys != "show":
|
|
183
|
+
fields["option_keys"] = option_keys
|
|
184
|
+
return fields
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ChoiceQuestion(QuestionSpec[ChoiceAnswer]):
|
|
188
|
+
def __init__(self, instructions: str, criteria: Mapping[str, str], *, evidence: bool = False,
|
|
189
|
+
option_keys: OptionKeys = "show"):
|
|
190
|
+
super().__init__("choice", instructions, {"criteria": _criteria(criteria, 2, 10), **_display(evidence, option_keys)})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class NoulQuestion(QuestionSpec[NoulAnswer]):
|
|
194
|
+
def __init__(self, instructions: str, *, evidence: bool = False):
|
|
195
|
+
super().__init__("noul", instructions, _display(evidence, "show"))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ScoreQuestion(QuestionSpec[ScoreAnswer]):
|
|
199
|
+
def __init__(self, instructions: str, levels: Sequence[str], *, evidence: bool = False):
|
|
200
|
+
if isinstance(levels, str) or not 2 <= len(levels) <= 10:
|
|
201
|
+
raise ValueError("score requires 2–10 ordered levels")
|
|
202
|
+
super().__init__("score", instructions, {"criteria": [_text(value, "level") for value in levels], **_display(evidence, "show")})
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class MultiQuestion(QuestionSpec[MultiAnswer]):
|
|
206
|
+
def __init__(self, instructions: str, criteria: Mapping[str, str], *, threshold: float = 0.5,
|
|
207
|
+
min: int | None = None, max: int | None = None):
|
|
208
|
+
fields: dict[str, JsonValue] = {"criteria": _criteria(criteria, 1, None)}
|
|
209
|
+
_number(threshold, "threshold", True)
|
|
210
|
+
if threshold != 0.5:
|
|
211
|
+
fields["threshold"] = threshold
|
|
212
|
+
for name, value in (("min", min), ("max", max)):
|
|
213
|
+
if value is not None:
|
|
214
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= len(criteria):
|
|
215
|
+
raise ValueError(f"{name} must be an integer within the number of labels")
|
|
216
|
+
fields[name] = value
|
|
217
|
+
if min is not None and max is not None and min > max:
|
|
218
|
+
raise ValueError("min must not exceed max")
|
|
219
|
+
super().__init__("multi", instructions, fields)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class ActQuestion(QuestionSpec[ActAnswer]):
|
|
223
|
+
def __init__(self, instructions: str, costs: Mapping[str, Mapping[str, float]], *,
|
|
224
|
+
criteria: Mapping[str, str] | None = None, evidence: bool = False,
|
|
225
|
+
defer_action: str | None = None, max_error: float | None = None):
|
|
226
|
+
outcomes = set(criteria) if criteria is not None else {"true", "false"}
|
|
227
|
+
fields: dict[str, JsonValue] = _display(evidence, "show")
|
|
228
|
+
if criteria is not None:
|
|
229
|
+
fields["criteria"] = _criteria(criteria, 2, 10)
|
|
230
|
+
if not costs:
|
|
231
|
+
raise ValueError("act requires a nonempty cost matrix")
|
|
232
|
+
matrix: dict[str, JsonValue] = {}
|
|
233
|
+
for action, row in costs.items():
|
|
234
|
+
if not isinstance(row, Mapping) or set(row) != outcomes:
|
|
235
|
+
raise ValueError("each cost row must cover every outcome exactly")
|
|
236
|
+
matrix[_text(action, "action")] = {key: _number(value, "cost") for key, value in row.items()}
|
|
237
|
+
fields["costs"] = matrix
|
|
238
|
+
if defer_action is not None:
|
|
239
|
+
if defer_action not in costs:
|
|
240
|
+
raise ValueError("defer_action must identify a cost row")
|
|
241
|
+
fields["defer_action"] = defer_action
|
|
242
|
+
if max_error is not None:
|
|
243
|
+
fields["max_error"] = _number(max_error, "max_error", True)
|
|
244
|
+
super().__init__("act", instructions, fields)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Factories and constructors share exactly the same typed signatures.
|
|
248
|
+
choice = ChoiceQuestion
|
|
249
|
+
noul = NoulQuestion
|
|
250
|
+
score = ScoreQuestion
|
|
251
|
+
multi = MultiQuestion
|
|
252
|
+
act = ActQuestion
|
|
253
|
+
QuestionSet = Mapping[str, QuestionSpec[Any]]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class Questions:
|
|
257
|
+
"""Declare questions as class fields; pass the class itself to client.decide.
|
|
258
|
+
|
|
259
|
+
The returned response.answers is an instance with inferred answer fields.
|
|
260
|
+
Definitions may be reused or inherited. Constructors are not called when
|
|
261
|
+
populating responses; use this class as a declarative schema.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
def __new__(cls) -> Self:
|
|
265
|
+
raise TypeError("Pass the Questions class to client.decide instead of constructing it")
|
|
266
|
+
|
|
267
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
268
|
+
raise AttributeError("Question answers are read-only")
|
|
269
|
+
|
|
270
|
+
def __delattr__(self, name: str) -> None:
|
|
271
|
+
raise AttributeError("Question answers are read-only")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def schema_questions(schema: type[Questions]) -> dict[str, QuestionSpec[Any]]:
|
|
275
|
+
"""Snapshot effective definitions in declaration order, respecting Python MRO."""
|
|
276
|
+
if not issubclass(schema, Questions):
|
|
277
|
+
raise TypeError("expected a Questions subclass")
|
|
278
|
+
members: dict[str, Any] = {}
|
|
279
|
+
for base in reversed(schema.__mro__):
|
|
280
|
+
members.update(vars(base))
|
|
281
|
+
fields: dict[str, QuestionSpec[Any]] = {}
|
|
282
|
+
for name, value in members.items():
|
|
283
|
+
if isinstance(value, QuestionSpec):
|
|
284
|
+
if name.startswith("_"):
|
|
285
|
+
raise ValueError("Schema question names must not start with an underscore")
|
|
286
|
+
fields[name] = value
|
|
287
|
+
return fields
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_questions(questions: QuestionSet) -> dict[str, WireQuestion]:
|
|
291
|
+
if not questions:
|
|
292
|
+
raise ValueError("questions must not be empty")
|
|
293
|
+
result: dict[str, WireQuestion] = {}
|
|
294
|
+
for key, question in questions.items():
|
|
295
|
+
_text(key, "question ID")
|
|
296
|
+
if not isinstance(question, (ChoiceQuestion, NoulQuestion, ScoreQuestion, MultiQuestion, ActQuestion)):
|
|
297
|
+
raise TypeError("questions must contain typed question specs")
|
|
298
|
+
result[key] = question.to_dict()
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class _DecisionResponseBase:
|
|
303
|
+
def __init__(self, raw: DecisionResponse):
|
|
304
|
+
from .client import BasalProtocolError
|
|
305
|
+
if not isinstance(raw.get("model"), str) or not raw.get("model") or not isinstance(raw.get("usage"), dict) or not isinstance(raw.get("answers"), dict):
|
|
306
|
+
raise BasalProtocolError("Invalid decision envelope")
|
|
307
|
+
for name in ("input_tokens", "output_tokens"):
|
|
308
|
+
value = raw["usage"].get(name)
|
|
309
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
310
|
+
raise BasalProtocolError("Invalid usage field")
|
|
311
|
+
self.model: str = raw["model"]
|
|
312
|
+
self.usage: Usage = raw["usage"]
|
|
313
|
+
self.raw = raw
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class TypedDecisionResponse(_DecisionResponseBase):
|
|
317
|
+
"""Checked answers from a dictionary request, indexed by their request keys.
|
|
318
|
+
|
|
319
|
+
Values have a union of answer types. Use a Questions class when each named
|
|
320
|
+
field should have an exact inferred type, or isinstance to narrow this union.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
def __init__(self, raw: DecisionResponse, questions: QuestionSet):
|
|
324
|
+
super().__init__(raw)
|
|
325
|
+
self.answers: Mapping[str, AnswerResult] = MappingProxyType({
|
|
326
|
+
name: question.read(raw, name) for name, question in questions.items()
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
def answer(self, key: str) -> AnswerResult:
|
|
330
|
+
"""Retrieve by request key; an unknown key raises KeyError."""
|
|
331
|
+
return self.answers[key]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class SchemaDecisionResponse(_DecisionResponseBase, Generic[TSchema_co]):
|
|
335
|
+
"""Checked response with the named answer fields inferred from its schema."""
|
|
336
|
+
|
|
337
|
+
def __init__(self, raw: DecisionResponse, schema: type[TSchema_co], questions: QuestionSet):
|
|
338
|
+
super().__init__(raw)
|
|
339
|
+
parsed = {name: question.read(raw, name) for name, question in questions.items()}
|
|
340
|
+
# Populate only after all answers pass validation, bypassing user
|
|
341
|
+
# constructors. Instance attributes shadow non-data question descriptors.
|
|
342
|
+
self._answers = object.__new__(schema)
|
|
343
|
+
vars(self._answers).update(parsed)
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def answers(self) -> TSchema_co:
|
|
347
|
+
"""Read-only schema instance with each field's inferred answer type."""
|
|
348
|
+
return self._answers
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Wire types for POST /v1/basal. Question and criteria order is significant."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal, NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
# Python 3.11 syntax keeps these aliases usable on the minimum supported version.
|
|
6
|
+
JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Question(TypedDict, total=False):
|
|
10
|
+
"""Native question fields; validation and calibration belong to the server."""
|
|
11
|
+
|
|
12
|
+
type: Literal["choice", "noul", "score", "multi", "act"]
|
|
13
|
+
instructions: JsonValue
|
|
14
|
+
criteria: dict[str, JsonValue] | list[JsonValue]
|
|
15
|
+
levels: dict[str, JsonValue] | list[JsonValue]
|
|
16
|
+
option_keys: Literal["show", "hide"]
|
|
17
|
+
evidence: bool
|
|
18
|
+
threshold: float
|
|
19
|
+
min: int
|
|
20
|
+
max: int
|
|
21
|
+
base: Literal["noul", "choice", "score"]
|
|
22
|
+
costs: dict[str, float] | dict[str, dict[str, float]]
|
|
23
|
+
defer_action: str
|
|
24
|
+
max_error: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DecisionRequest(TypedDict):
|
|
28
|
+
state: JsonValue
|
|
29
|
+
questions: dict[str, Question]
|
|
30
|
+
model: NotRequired[str]
|
|
31
|
+
facts: NotRequired[Literal["off", "auto"]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EvidenceSpan(TypedDict):
|
|
35
|
+
text: str
|
|
36
|
+
start: int
|
|
37
|
+
end: int
|
|
38
|
+
probability: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Answer(TypedDict):
|
|
42
|
+
type: Literal["choice", "noul", "score", "multi", "act"]
|
|
43
|
+
probabilities: dict[str, float]
|
|
44
|
+
confidence: NotRequired[float]
|
|
45
|
+
choice: NotRequired[str]
|
|
46
|
+
noul: NotRequired[float]
|
|
47
|
+
score: NotRequired[float]
|
|
48
|
+
legend: NotRequired[dict[str, str]]
|
|
49
|
+
selected: NotRequired[list[str]]
|
|
50
|
+
threshold: NotRequired[float]
|
|
51
|
+
set_confidence: NotRequired[float]
|
|
52
|
+
action: NotRequired[str]
|
|
53
|
+
answer: NotRequired[str]
|
|
54
|
+
expected_costs: NotRequired[dict[str, float]]
|
|
55
|
+
calibration: NotRequired[Literal["validated", "unvalidated"]]
|
|
56
|
+
refused: NotRequired[str]
|
|
57
|
+
max_error: NotRequired[str]
|
|
58
|
+
evidence: NotRequired[list[EvidenceSpan]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Usage(TypedDict):
|
|
62
|
+
input_tokens: int
|
|
63
|
+
output_tokens: int
|
|
64
|
+
questions: NotRequired[int]
|
|
65
|
+
branches: NotRequired[int]
|
|
66
|
+
latency_ms: NotRequired[float]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DecisionResponse(TypedDict):
|
|
70
|
+
model: str
|
|
71
|
+
answers: dict[str, Answer]
|
|
72
|
+
usage: Usage
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ModelMetadata(TypedDict):
|
|
76
|
+
name: str
|
|
77
|
+
description: NotRequired[str]
|
|
78
|
+
release_date: NotRequired[str]
|
|
79
|
+
mode: NotRequired[str]
|
|
80
|
+
early_exit: NotRequired[list[JsonValue]]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ModelsResponse(TypedDict):
|
|
84
|
+
models: list[ModelMetadata]
|