clairic 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.
- clairic/__init__.py +100 -0
- clairic/_client.py +322 -0
- clairic/_compile.py +196 -0
- clairic/_version.py +1 -0
- clairic/answers.py +374 -0
- clairic/errors.py +99 -0
- clairic/py.typed +0 -0
- clairic/result.py +111 -0
- clairic/specs.py +346 -0
- clairic-0.1.0.dist-info/METADATA +209 -0
- clairic-0.1.0.dist-info/RECORD +14 -0
- clairic-0.1.0.dist-info/WHEEL +4 -0
- clairic-0.1.0.dist-info/licenses/LICENSE +21 -0
- clairic-0.1.0.dist-info/licenses/NOTICE +18 -0
clairic/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Unofficial typed Python SDK for Cloudflare Clairic.
|
|
2
|
+
|
|
3
|
+
Clairic is a Workers AI extraction model: it reads a text and returns typed values for named
|
|
4
|
+
fields, with confidence scores, probabilities of absence and percentile ranges. This package is
|
|
5
|
+
not affiliated with or endorsed by Cloudflare.
|
|
6
|
+
|
|
7
|
+
See https://developers.cloudflare.com/workers-ai/models/clairic/ for the model reference.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from clairic._client import AsyncClairic, Clairic
|
|
11
|
+
from clairic._version import __version__
|
|
12
|
+
from clairic.answers import (
|
|
13
|
+
Answer,
|
|
14
|
+
DateAnswer,
|
|
15
|
+
DateEstimateAnswer,
|
|
16
|
+
DatetimeAnswer,
|
|
17
|
+
DurationAnswer,
|
|
18
|
+
DurationUnit,
|
|
19
|
+
EstimateAnswer,
|
|
20
|
+
ForecastAnswer,
|
|
21
|
+
ForecastStep,
|
|
22
|
+
ListAnswer,
|
|
23
|
+
NumberAnswer,
|
|
24
|
+
Span,
|
|
25
|
+
TextAnswer,
|
|
26
|
+
TextListAnswer,
|
|
27
|
+
TimeAnswer,
|
|
28
|
+
)
|
|
29
|
+
from clairic.errors import (
|
|
30
|
+
APIConnectionError,
|
|
31
|
+
APIError,
|
|
32
|
+
APITimeoutError,
|
|
33
|
+
AuthenticationError,
|
|
34
|
+
ClairicConfigurationError,
|
|
35
|
+
ClairicError,
|
|
36
|
+
ClairicTruncationWarning,
|
|
37
|
+
ContextWindowExceededError,
|
|
38
|
+
InvalidRequestError,
|
|
39
|
+
ModelDefinitionError,
|
|
40
|
+
)
|
|
41
|
+
from clairic.result import Result, Usage
|
|
42
|
+
from clairic.specs import (
|
|
43
|
+
Date,
|
|
44
|
+
Datetime,
|
|
45
|
+
Duration,
|
|
46
|
+
Estimate,
|
|
47
|
+
Forecast,
|
|
48
|
+
Interval,
|
|
49
|
+
List,
|
|
50
|
+
Number,
|
|
51
|
+
Scale,
|
|
52
|
+
Spec,
|
|
53
|
+
Text,
|
|
54
|
+
Time,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"APIConnectionError",
|
|
59
|
+
"APIError",
|
|
60
|
+
"APITimeoutError",
|
|
61
|
+
"Answer",
|
|
62
|
+
"AsyncClairic",
|
|
63
|
+
"AuthenticationError",
|
|
64
|
+
"Clairic",
|
|
65
|
+
"ClairicConfigurationError",
|
|
66
|
+
"ClairicError",
|
|
67
|
+
"ClairicTruncationWarning",
|
|
68
|
+
"ContextWindowExceededError",
|
|
69
|
+
"Date",
|
|
70
|
+
"DateAnswer",
|
|
71
|
+
"DateEstimateAnswer",
|
|
72
|
+
"Datetime",
|
|
73
|
+
"DatetimeAnswer",
|
|
74
|
+
"Duration",
|
|
75
|
+
"DurationAnswer",
|
|
76
|
+
"DurationUnit",
|
|
77
|
+
"Estimate",
|
|
78
|
+
"EstimateAnswer",
|
|
79
|
+
"Forecast",
|
|
80
|
+
"ForecastAnswer",
|
|
81
|
+
"ForecastStep",
|
|
82
|
+
"Interval",
|
|
83
|
+
"InvalidRequestError",
|
|
84
|
+
"List",
|
|
85
|
+
"ListAnswer",
|
|
86
|
+
"ModelDefinitionError",
|
|
87
|
+
"Number",
|
|
88
|
+
"NumberAnswer",
|
|
89
|
+
"Result",
|
|
90
|
+
"Scale",
|
|
91
|
+
"Span",
|
|
92
|
+
"Spec",
|
|
93
|
+
"Text",
|
|
94
|
+
"TextAnswer",
|
|
95
|
+
"TextListAnswer",
|
|
96
|
+
"Time",
|
|
97
|
+
"TimeAnswer",
|
|
98
|
+
"Usage",
|
|
99
|
+
"__version__",
|
|
100
|
+
]
|
clairic/_client.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Sync and async Clairic clients over the Workers AI REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import os
|
|
7
|
+
import warnings
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel, ValidationError
|
|
13
|
+
from typing_extensions import Self
|
|
14
|
+
|
|
15
|
+
from clairic._compile import build_data, fields_payload
|
|
16
|
+
from clairic._version import __version__
|
|
17
|
+
from clairic.errors import (
|
|
18
|
+
APIConnectionError,
|
|
19
|
+
APIError,
|
|
20
|
+
APITimeoutError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
ClairicConfigurationError,
|
|
23
|
+
ClairicTruncationWarning,
|
|
24
|
+
ContextWindowExceededError,
|
|
25
|
+
InvalidRequestError,
|
|
26
|
+
)
|
|
27
|
+
from clairic.result import Result, Usage
|
|
28
|
+
|
|
29
|
+
M = TypeVar("M", bound=BaseModel)
|
|
30
|
+
|
|
31
|
+
DEFAULT_BASE_URL = "https://api.cloudflare.com/client/v4"
|
|
32
|
+
DEFAULT_TIMEOUT = 30.0
|
|
33
|
+
ACCOUNT_ID_VARIABLE = "CLOUDFLARE_ACCOUNT_ID"
|
|
34
|
+
API_TOKEN_VARIABLE = "CLOUDFLARE_API_TOKEN"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _ClientBase:
|
|
38
|
+
"""Request building and response parsing shared by the sync and async clients."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, *, account_id: str | None, api_token: str | None, base_url: str) -> None:
|
|
41
|
+
account_id = account_id or os.environ.get(ACCOUNT_ID_VARIABLE)
|
|
42
|
+
api_token = api_token or os.environ.get(API_TOKEN_VARIABLE)
|
|
43
|
+
if not account_id:
|
|
44
|
+
raise ClairicConfigurationError(
|
|
45
|
+
f"Pass account_id or set the {ACCOUNT_ID_VARIABLE} environment variable"
|
|
46
|
+
)
|
|
47
|
+
if not api_token:
|
|
48
|
+
raise ClairicConfigurationError(
|
|
49
|
+
f"Pass api_token or set the {API_TOKEN_VARIABLE} environment variable"
|
|
50
|
+
)
|
|
51
|
+
self.account_id = account_id
|
|
52
|
+
self._api_token = api_token
|
|
53
|
+
self._url = f"{base_url.rstrip('/')}/accounts/{account_id}/ai/run/@cf/cloudflare/clairic"
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return f"{type(self).__name__}(account_id={self.account_id!r})"
|
|
57
|
+
|
|
58
|
+
def _headers(self) -> dict[str, str]:
|
|
59
|
+
return {
|
|
60
|
+
"Authorization": f"Bearer {self._api_token}",
|
|
61
|
+
"User-Agent": f"clairic-python/{__version__}",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _body(state: str, model: type[BaseModel], reference_date: dt.date | None) -> dict[str, Any]:
|
|
66
|
+
fields = fields_payload(model)
|
|
67
|
+
if not state:
|
|
68
|
+
raise ValueError("state must be a non-empty text")
|
|
69
|
+
body: dict[str, Any] = {"model": "clairic", "state": state, "fields": fields}
|
|
70
|
+
if reference_date is not None:
|
|
71
|
+
if isinstance(reference_date, dt.datetime):
|
|
72
|
+
reference_date = reference_date.date()
|
|
73
|
+
body["reference_date"] = reference_date.isoformat()
|
|
74
|
+
return body
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _parse(response: httpx.Response, model: type[M]) -> Result[M]:
|
|
78
|
+
status_code = response.status_code
|
|
79
|
+
request_id = response.headers.get("cf-ai-req-id")
|
|
80
|
+
try:
|
|
81
|
+
envelope = _Envelope.model_validate_json(response.content)
|
|
82
|
+
except ValidationError as error:
|
|
83
|
+
raise APIError(
|
|
84
|
+
f"Unexpected response from Cloudflare: {response.text[:500]!r}",
|
|
85
|
+
status_code=status_code,
|
|
86
|
+
request_id=request_id,
|
|
87
|
+
) from error
|
|
88
|
+
if response.is_error or not envelope.success:
|
|
89
|
+
raise _api_error(status_code, envelope, request_id)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
result = _ResultBody.model_validate(envelope.result)
|
|
93
|
+
data = build_data(model, result.answers)
|
|
94
|
+
except ValidationError as error:
|
|
95
|
+
raise APIError(
|
|
96
|
+
f"Clairic returned answers that do not fit {model.__name__}: {error}",
|
|
97
|
+
status_code=status_code,
|
|
98
|
+
request_id=request_id,
|
|
99
|
+
) from error
|
|
100
|
+
if result.truncated:
|
|
101
|
+
warnings.warn(
|
|
102
|
+
"Clairic truncated the input: the answers were computed on part of it",
|
|
103
|
+
ClairicTruncationWarning,
|
|
104
|
+
stacklevel=3,
|
|
105
|
+
)
|
|
106
|
+
return Result[model](
|
|
107
|
+
data=data,
|
|
108
|
+
usage=result.usage,
|
|
109
|
+
truncated=result.truncated,
|
|
110
|
+
model=result.model,
|
|
111
|
+
request_id=request_id,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _ErrorItem(BaseModel):
|
|
116
|
+
code: int | None = None
|
|
117
|
+
message: str = ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _Envelope(BaseModel):
|
|
121
|
+
"""Cloudflare API response envelope."""
|
|
122
|
+
|
|
123
|
+
success: bool = False
|
|
124
|
+
errors: list[_ErrorItem] = []
|
|
125
|
+
result: Any = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _ResultBody(BaseModel):
|
|
129
|
+
"""`result` of a successful Clairic response."""
|
|
130
|
+
|
|
131
|
+
answers: dict[str, Any]
|
|
132
|
+
truncated: bool
|
|
133
|
+
usage: Usage
|
|
134
|
+
model: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _api_error(status_code: int, envelope: _Envelope, request_id: str | None) -> APIError:
|
|
138
|
+
first = envelope.errors[0] if envelope.errors else _ErrorItem()
|
|
139
|
+
message = first.message or f"HTTP {status_code}"
|
|
140
|
+
if status_code == 401:
|
|
141
|
+
error_type: type[APIError] = AuthenticationError
|
|
142
|
+
elif status_code == 413:
|
|
143
|
+
error_type = ContextWindowExceededError
|
|
144
|
+
elif status_code in (400, 422):
|
|
145
|
+
error_type = InvalidRequestError
|
|
146
|
+
else:
|
|
147
|
+
error_type = APIError
|
|
148
|
+
return error_type(message, status_code=status_code, code=first.code, request_id=request_id)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Clairic(_ClientBase):
|
|
152
|
+
"""Client for Cloudflare Clairic.
|
|
153
|
+
|
|
154
|
+
Credentials default to the `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` environment
|
|
155
|
+
variables. Close the client, or use it as a context manager, to release its connections.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
with Clairic() as clairic:
|
|
159
|
+
res = clairic.extract(text, InvoiceExtraction)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
account_id: str | None
|
|
164
|
+
Cloudflare account id.
|
|
165
|
+
api_token: str | None
|
|
166
|
+
Cloudflare API token.
|
|
167
|
+
base_url: str
|
|
168
|
+
Cloudflare API root, to route requests through a proxy.
|
|
169
|
+
timeout: float | httpx.Timeout
|
|
170
|
+
Seconds to wait for a response. Ignored when `http_client` is given.
|
|
171
|
+
http_client: httpx.Client | None
|
|
172
|
+
HTTP client to send requests with, for custom transports or proxies. The caller
|
|
173
|
+
closes it.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ClairicConfigurationError:
|
|
177
|
+
When the account id or the token is neither passed nor set in the environment.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
account_id: str | None = None,
|
|
184
|
+
api_token: str | None = None,
|
|
185
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
186
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
187
|
+
http_client: httpx.Client | None = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
super().__init__(account_id=account_id, api_token=api_token, base_url=base_url)
|
|
190
|
+
self._owns_http_client = http_client is None
|
|
191
|
+
self._http_client = http_client or httpx.Client(timeout=timeout)
|
|
192
|
+
|
|
193
|
+
def extract(
|
|
194
|
+
self, state: str, model: type[M], *, reference_date: dt.date | None = None
|
|
195
|
+
) -> Result[M]:
|
|
196
|
+
"""Extract the fields of `model` from `state`.
|
|
197
|
+
|
|
198
|
+
Clairic answers every field in a single pass without generating text, usually in under
|
|
199
|
+
a second. Each field of `model` declares its Clairic type through its answer type, its
|
|
200
|
+
options through an `Annotated` spec, and its instructions through
|
|
201
|
+
`Field(description=...)`.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
state: str
|
|
205
|
+
Text to read, up to Clairic's 65,536-token context window.
|
|
206
|
+
model: type[M]
|
|
207
|
+
Extraction model whose fields are answer types.
|
|
208
|
+
reference_date: date | None
|
|
209
|
+
Date that anchors dates written without a year and relative computations, such
|
|
210
|
+
as "net 30". Set it whenever a field depends on today's date.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
The answers in an instance of `model`, with the token usage and the request id.
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
ModelDefinitionError:
|
|
217
|
+
When `model` cannot be sent to Clairic, before any request.
|
|
218
|
+
AuthenticationError:
|
|
219
|
+
When Cloudflare rejects the credentials.
|
|
220
|
+
InvalidRequestError:
|
|
221
|
+
When Cloudflare rejects the request content.
|
|
222
|
+
ContextWindowExceededError:
|
|
223
|
+
When `state` is too long.
|
|
224
|
+
APIError:
|
|
225
|
+
For any other error response, or answers that do not fit `model`.
|
|
226
|
+
APIConnectionError:
|
|
227
|
+
When Cloudflare cannot be reached; `APITimeoutError` on timeout.
|
|
228
|
+
"""
|
|
229
|
+
body = self._body(state, model, reference_date)
|
|
230
|
+
try:
|
|
231
|
+
response = self._http_client.post(self._url, json=body, headers=self._headers())
|
|
232
|
+
except httpx.TimeoutException as error:
|
|
233
|
+
raise APITimeoutError(f"Clairic did not answer in time: {error}") from error
|
|
234
|
+
except httpx.TransportError as error:
|
|
235
|
+
raise APIConnectionError(f"Could not reach Cloudflare: {error}") from error
|
|
236
|
+
return self._parse(response, model)
|
|
237
|
+
|
|
238
|
+
def close(self) -> None:
|
|
239
|
+
"""Close the HTTP connections, unless the HTTP client was passed in."""
|
|
240
|
+
if self._owns_http_client:
|
|
241
|
+
self._http_client.close()
|
|
242
|
+
|
|
243
|
+
def __enter__(self) -> Self:
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(
|
|
247
|
+
self,
|
|
248
|
+
exc_type: type[BaseException] | None,
|
|
249
|
+
exc: BaseException | None,
|
|
250
|
+
traceback: TracebackType | None,
|
|
251
|
+
) -> None:
|
|
252
|
+
self.close()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class AsyncClairic(_ClientBase):
|
|
256
|
+
"""Async client for Cloudflare Clairic.
|
|
257
|
+
|
|
258
|
+
Same options and behavior as `Clairic`, with `extract` as a coroutine.
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
async with AsyncClairic() as clairic:
|
|
262
|
+
res = await clairic.extract(text, InvoiceExtraction)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
account_id: str | None
|
|
267
|
+
Cloudflare account id.
|
|
268
|
+
api_token: str | None
|
|
269
|
+
Cloudflare API token.
|
|
270
|
+
base_url: str
|
|
271
|
+
Cloudflare API root, to route requests through a proxy.
|
|
272
|
+
timeout: float | httpx.Timeout
|
|
273
|
+
Seconds to wait for a response. Ignored when `http_client` is given.
|
|
274
|
+
http_client: httpx.AsyncClient | None
|
|
275
|
+
HTTP client to send requests with. The caller closes it.
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
ClairicConfigurationError:
|
|
279
|
+
When the account id or the token is neither passed nor set in the environment.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def __init__(
|
|
283
|
+
self,
|
|
284
|
+
*,
|
|
285
|
+
account_id: str | None = None,
|
|
286
|
+
api_token: str | None = None,
|
|
287
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
288
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
289
|
+
http_client: httpx.AsyncClient | None = None,
|
|
290
|
+
) -> None:
|
|
291
|
+
super().__init__(account_id=account_id, api_token=api_token, base_url=base_url)
|
|
292
|
+
self._owns_http_client = http_client is None
|
|
293
|
+
self._http_client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
294
|
+
|
|
295
|
+
async def extract(
|
|
296
|
+
self, state: str, model: type[M], *, reference_date: dt.date | None = None
|
|
297
|
+
) -> Result[M]:
|
|
298
|
+
"""Extract the fields of `model` from `state`. See `Clairic.extract`."""
|
|
299
|
+
body = self._body(state, model, reference_date)
|
|
300
|
+
try:
|
|
301
|
+
response = await self._http_client.post(self._url, json=body, headers=self._headers())
|
|
302
|
+
except httpx.TimeoutException as error:
|
|
303
|
+
raise APITimeoutError(f"Clairic did not answer in time: {error}") from error
|
|
304
|
+
except httpx.TransportError as error:
|
|
305
|
+
raise APIConnectionError(f"Could not reach Cloudflare: {error}") from error
|
|
306
|
+
return self._parse(response, model)
|
|
307
|
+
|
|
308
|
+
async def aclose(self) -> None:
|
|
309
|
+
"""Close the HTTP connections, unless the HTTP client was passed in."""
|
|
310
|
+
if self._owns_http_client:
|
|
311
|
+
await self._http_client.aclose()
|
|
312
|
+
|
|
313
|
+
async def __aenter__(self) -> Self:
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
async def __aexit__(
|
|
317
|
+
self,
|
|
318
|
+
exc_type: type[BaseException] | None,
|
|
319
|
+
exc: BaseException | None,
|
|
320
|
+
traceback: TracebackType | None,
|
|
321
|
+
) -> None:
|
|
322
|
+
await self.aclose()
|
clairic/_compile.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Translation between extraction models and the Clairic request and response."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import functools
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from pydantic.fields import FieldInfo
|
|
13
|
+
|
|
14
|
+
from clairic.answers import (
|
|
15
|
+
DateAnswer,
|
|
16
|
+
DateEstimateAnswer,
|
|
17
|
+
DatetimeAnswer,
|
|
18
|
+
DurationAnswer,
|
|
19
|
+
EstimateAnswer,
|
|
20
|
+
ForecastAnswer,
|
|
21
|
+
ListAnswer,
|
|
22
|
+
NumberAnswer,
|
|
23
|
+
TextAnswer,
|
|
24
|
+
TextListAnswer,
|
|
25
|
+
TimeAnswer,
|
|
26
|
+
)
|
|
27
|
+
from clairic.errors import ModelDefinitionError
|
|
28
|
+
from clairic.specs import (
|
|
29
|
+
Date,
|
|
30
|
+
Datetime,
|
|
31
|
+
Duration,
|
|
32
|
+
Estimate,
|
|
33
|
+
Forecast,
|
|
34
|
+
List,
|
|
35
|
+
Number,
|
|
36
|
+
Spec,
|
|
37
|
+
Text,
|
|
38
|
+
Time,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
M = TypeVar("M", bound=BaseModel)
|
|
42
|
+
|
|
43
|
+
FIELD_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]{0,63}$")
|
|
44
|
+
"""Field names the Clairic API accepts."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class _AnswerKind:
|
|
49
|
+
"""How an answer class maps to a Clairic field."""
|
|
50
|
+
|
|
51
|
+
spec_type: type[Spec]
|
|
52
|
+
spec_required: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_KINDS: dict[type[BaseModel], _AnswerKind] = {
|
|
56
|
+
TextAnswer: _AnswerKind(Text),
|
|
57
|
+
NumberAnswer: _AnswerKind(Number),
|
|
58
|
+
DateAnswer: _AnswerKind(Date),
|
|
59
|
+
TimeAnswer: _AnswerKind(Time),
|
|
60
|
+
DatetimeAnswer: _AnswerKind(Datetime),
|
|
61
|
+
DurationAnswer: _AnswerKind(Duration),
|
|
62
|
+
TextListAnswer: _AnswerKind(List),
|
|
63
|
+
ListAnswer: _AnswerKind(List),
|
|
64
|
+
EstimateAnswer: _AnswerKind(Estimate, spec_required=True),
|
|
65
|
+
DateEstimateAnswer: _AnswerKind(Estimate, spec_required=True),
|
|
66
|
+
ForecastAnswer: _AnswerKind(Forecast, spec_required=True),
|
|
67
|
+
}
|
|
68
|
+
_GENERIC = (NumberAnswer, DurationAnswer, ListAnswer, EstimateAnswer)
|
|
69
|
+
_SUPPORTED = ", ".join(
|
|
70
|
+
f"{answer.__name__}[...]" if answer in _GENERIC else answer.__name__ for answer in _KINDS
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fields_payload(model: type[BaseModel]) -> dict[str, dict[str, Any]]:
|
|
75
|
+
"""Return the Clairic `fields` object for an extraction model.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ModelDefinitionError:
|
|
79
|
+
When a field of `model` cannot be sent to Clairic. The message names the field.
|
|
80
|
+
"""
|
|
81
|
+
return {name: dict(spec) for name, spec in _compile(model)}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_data(model: type[M], answers: dict[str, Any]) -> M:
|
|
85
|
+
"""Return an instance of `model` holding the answers Clairic returned, keyed by field name."""
|
|
86
|
+
return model.model_validate(answers)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@functools.cache
|
|
90
|
+
def _compile(model: type[BaseModel]) -> tuple[tuple[str, dict[str, Any]], ...]:
|
|
91
|
+
if not model.model_fields:
|
|
92
|
+
raise ModelDefinitionError(f"{model.__name__} has no fields; Clairic needs at least one")
|
|
93
|
+
return tuple(
|
|
94
|
+
_compile_field(model, attribute, info) for attribute, info in model.model_fields.items()
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _compile_field(
|
|
99
|
+
model: type[BaseModel], attribute: str, info: FieldInfo
|
|
100
|
+
) -> tuple[str, dict[str, Any]]:
|
|
101
|
+
where = f"{model.__name__}.{attribute}"
|
|
102
|
+
name = _clairic_name(where, attribute, info)
|
|
103
|
+
origin, argument = _answer_class(where, info.annotation)
|
|
104
|
+
kind = _KINDS[origin]
|
|
105
|
+
spec = _spec(where, info, kind)
|
|
106
|
+
|
|
107
|
+
payload: dict[str, Any] = {"type": spec.field_type}
|
|
108
|
+
if info.description:
|
|
109
|
+
payload["instructions"] = info.description
|
|
110
|
+
payload.update(spec.to_payload())
|
|
111
|
+
payload.update(_type_options(where, origin, argument, spec))
|
|
112
|
+
return name, payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _clairic_name(where: str, attribute: str, info: FieldInfo) -> str:
|
|
116
|
+
"""Return the field name sent to Clairic: the alias when set, else the attribute name."""
|
|
117
|
+
if info.validation_alias is not None and info.validation_alias != info.alias:
|
|
118
|
+
raise ModelDefinitionError(
|
|
119
|
+
f"{where}: validation_alias is not supported; use alias to rename the field"
|
|
120
|
+
)
|
|
121
|
+
name = info.alias or attribute
|
|
122
|
+
if not FIELD_NAME.match(name):
|
|
123
|
+
raise ModelDefinitionError(
|
|
124
|
+
f"{where}: Clairic field names must match {FIELD_NAME.pattern}, got {name!r}"
|
|
125
|
+
)
|
|
126
|
+
return name
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _answer_class(where: str, annotation: Any) -> tuple[type[BaseModel], Any]:
|
|
130
|
+
"""Return the unparametrized answer class of an annotation and its type argument."""
|
|
131
|
+
metadata = getattr(annotation, "__pydantic_generic_metadata__", None)
|
|
132
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel) and metadata:
|
|
133
|
+
origin = metadata["origin"] or annotation
|
|
134
|
+
if origin in _KINDS:
|
|
135
|
+
arguments = metadata["args"]
|
|
136
|
+
if origin in _GENERIC and not arguments:
|
|
137
|
+
raise ModelDefinitionError(
|
|
138
|
+
f"{where}: parametrize {origin.__name__} with its value type, "
|
|
139
|
+
f"for example {origin.__name__}[float]"
|
|
140
|
+
)
|
|
141
|
+
return origin, arguments[0] if arguments else None
|
|
142
|
+
raise ModelDefinitionError(
|
|
143
|
+
f"{where}: annotate the field with a Clairic answer type ({_SUPPORTED}), "
|
|
144
|
+
f"got {annotation!r}. Answers already allow a None value; do not wrap them in Optional"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _spec(where: str, info: FieldInfo, kind: _AnswerKind) -> Spec:
|
|
149
|
+
specs = [item for item in info.metadata if isinstance(item, Spec)]
|
|
150
|
+
if len(specs) > 1:
|
|
151
|
+
raise ModelDefinitionError(f"{where}: declare at most one spec, got {len(specs)}")
|
|
152
|
+
if not specs:
|
|
153
|
+
if kind.spec_required:
|
|
154
|
+
raise ModelDefinitionError(
|
|
155
|
+
f"{where}: add an {kind.spec_type.__name__}(...) spec with Annotated; "
|
|
156
|
+
"its bounds are required"
|
|
157
|
+
)
|
|
158
|
+
return kind.spec_type()
|
|
159
|
+
spec = specs[0]
|
|
160
|
+
if not isinstance(spec, kind.spec_type):
|
|
161
|
+
raise ModelDefinitionError(
|
|
162
|
+
f"{where}: {type(spec).__name__} does not apply to this answer type; "
|
|
163
|
+
f"use {kind.spec_type.__name__}"
|
|
164
|
+
)
|
|
165
|
+
return spec
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _type_options(where: str, origin: type[BaseModel], argument: Any, spec: Spec) -> dict[str, Any]:
|
|
169
|
+
"""Return the options implied by the answer type: `integer` and list `items`."""
|
|
170
|
+
if origin in (NumberAnswer, DurationAnswer):
|
|
171
|
+
return {"integer": True} if argument is int else {}
|
|
172
|
+
if origin is TextListAnswer:
|
|
173
|
+
return {"items": "text"}
|
|
174
|
+
if origin is ListAnswer:
|
|
175
|
+
if argument is dt.date:
|
|
176
|
+
return {"items": "date"}
|
|
177
|
+
return {"items": "number", "integer": True} if argument is int else {"items": "number"}
|
|
178
|
+
if isinstance(spec, Estimate):
|
|
179
|
+
_check_estimate_bounds(where, origin, argument, spec)
|
|
180
|
+
return {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _check_estimate_bounds(
|
|
184
|
+
where: str, origin: type[BaseModel], argument: Any, spec: Estimate
|
|
185
|
+
) -> None:
|
|
186
|
+
date_bounds = isinstance(spec.min, dt.date)
|
|
187
|
+
if origin is DateEstimateAnswer and not date_bounds:
|
|
188
|
+
raise ModelDefinitionError(f"{where}: DateEstimateAnswer needs date bounds")
|
|
189
|
+
if origin is EstimateAnswer and date_bounds:
|
|
190
|
+
raise ModelDefinitionError(f"{where}: use DateEstimateAnswer for date bounds")
|
|
191
|
+
if argument is int and not (
|
|
192
|
+
isinstance(spec.step, (int, float)) and float(spec.step).is_integer()
|
|
193
|
+
):
|
|
194
|
+
raise ModelDefinitionError(
|
|
195
|
+
f"{where}: EstimateAnswer[int] needs a whole-number step, got {spec.step!r}"
|
|
196
|
+
)
|
clairic/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|