bynn-decisionnode 0.3.1__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.
- bynn_decisionnode-0.3.1.dist-info/METADATA +939 -0
- bynn_decisionnode-0.3.1.dist-info/RECORD +19 -0
- bynn_decisionnode-0.3.1.dist-info/WHEEL +4 -0
- bynn_decisionnode-0.3.1.dist-info/licenses/LICENSE +21 -0
- decisionnode/__init__.py +195 -0
- decisionnode/_base.py +548 -0
- decisionnode/_batches.py +333 -0
- decisionnode/_boosters.py +553 -0
- decisionnode/_client.py +2269 -0
- decisionnode/_errors.py +218 -0
- decisionnode/_images.py +283 -0
- decisionnode/_questions.py +262 -0
- decisionnode/_sessions.py +711 -0
- decisionnode/_types.py +764 -0
- decisionnode/_uploads.py +265 -0
- decisionnode/_validate.py +934 -0
- decisionnode/_version.py +5 -0
- decisionnode/_videos.py +197 -0
- decisionnode/py.typed +0 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Boosters: specialist models that read a request's images before the decision.
|
|
2
|
+
|
|
3
|
+
A request names them in ``boosters``; their readings come back in ``Decision.evidence``
|
|
4
|
+
and the booster calls billed in ``usage.boosters``. The client checks nothing about
|
|
5
|
+
boosters itself: which names exist and which ones a workspace may use is the server's
|
|
6
|
+
to know, so a booster added on the server works with this version of the client.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Union
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Booster",
|
|
16
|
+
"BoosterInput",
|
|
17
|
+
"Evidence",
|
|
18
|
+
"Reading",
|
|
19
|
+
"AgeReading",
|
|
20
|
+
"AiGeneratedReading",
|
|
21
|
+
"OcrReading",
|
|
22
|
+
"FaceBox",
|
|
23
|
+
"FaceDetection",
|
|
24
|
+
"FaceDetectionReading",
|
|
25
|
+
"FaceMatch",
|
|
26
|
+
"FaceRecognitionReading",
|
|
27
|
+
"TopGenerator",
|
|
28
|
+
"UnknownReading",
|
|
29
|
+
"AnyReading",
|
|
30
|
+
"BoosterStatus",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Booster:
|
|
36
|
+
"""One booster of a request, with its settings.
|
|
37
|
+
|
|
38
|
+
A booster's name alone (``"age"``) is enough when it should read every image of the
|
|
39
|
+
request and the decision may run without its reading. Each argument left out is left
|
|
40
|
+
out of the request, so the server's default applies.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
name: The booster's name, such as ``age`` or ``ai_generated``.
|
|
44
|
+
images: The ids of the request's images the booster reads. Left out: every image.
|
|
45
|
+
require: ``True``: a booster that fails or does not answer in time fails the
|
|
46
|
+
request (``OverloadedError`` with ``error_type`` ``booster_unavailable``,
|
|
47
|
+
retried like every 529). Left out or ``False``: the decision runs without its
|
|
48
|
+
reading, and ``evidence`` says why.
|
|
49
|
+
options: Settings of the booster, as the booster documents them.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
images: Optional[Sequence[str]] = None
|
|
54
|
+
require: Optional[bool] = None
|
|
55
|
+
options: Optional[Mapping[str, Any]] = None
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
58
|
+
"""The booster in the API's object form, each setting only when given."""
|
|
59
|
+
out: Dict[str, Any] = {"name": self.name}
|
|
60
|
+
if self.images is not None:
|
|
61
|
+
out["images"] = list(self.images) if isinstance(self.images, (list, tuple)) else self.images
|
|
62
|
+
if self.require is not None:
|
|
63
|
+
out["require"] = self.require
|
|
64
|
+
if self.options is not None:
|
|
65
|
+
out["options"] = dict(self.options) if isinstance(self.options, Mapping) else self.options
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
def wire(self) -> Union[str, Dict[str, Any]]:
|
|
69
|
+
"""The booster as it is sent: its name alone when no setting is given, so a list
|
|
70
|
+
of such boosters is the plain list of names; else the object form."""
|
|
71
|
+
if self.images is None and self.require is None and self.options is None:
|
|
72
|
+
return self.name
|
|
73
|
+
return self.to_dict()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
BoosterInput = Union[str, Booster, Mapping[str, Any]]
|
|
77
|
+
"""A booster of a request: its name, a :class:`Booster`, or the wire form as a mapping."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def booster_wire(boosters: Any) -> Any:
|
|
81
|
+
"""The ``boosters`` field as it is sent, or ``None`` when the request names none.
|
|
82
|
+
|
|
83
|
+
``None`` and an empty list send no ``boosters`` key, so such a request is the
|
|
84
|
+
request of a client without boosters. A :class:`Booster` is written in wire form;
|
|
85
|
+
everything else goes as given, for the server to judge.
|
|
86
|
+
"""
|
|
87
|
+
if boosters is None:
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(boosters, Booster):
|
|
90
|
+
return boosters.wire()
|
|
91
|
+
if isinstance(boosters, (str, bytes, Mapping)) or not isinstance(boosters, Iterable):
|
|
92
|
+
return boosters
|
|
93
|
+
items = [b.wire() if isinstance(b, Booster) else b for b in boosters]
|
|
94
|
+
return items or None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def names_ocr(boosters: Any) -> bool:
|
|
98
|
+
"""Whether a request's ``boosters`` field, as sent, names ``ocr``: by its bare name or
|
|
99
|
+
in the object form ``{"name": "ocr", ...}``."""
|
|
100
|
+
if not isinstance(boosters, list):
|
|
101
|
+
return False
|
|
102
|
+
return any(b == "ocr" or (isinstance(b, Mapping) and b.get("name") == "ocr") for b in boosters)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------- readings
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class TopGenerator:
|
|
110
|
+
"""The image generator an ``ai_generated`` reading finds likeliest.
|
|
111
|
+
|
|
112
|
+
Attributes:
|
|
113
|
+
name: The generator's id.
|
|
114
|
+
probability: How likely that generator made the picture, from 0 to 1.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
name: str
|
|
118
|
+
probability: Union[int, float]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Reading:
|
|
123
|
+
"""Base class of a booster's reading of one image.
|
|
124
|
+
|
|
125
|
+
Attributes:
|
|
126
|
+
image: The id of the image read, or ``None`` when the reading names none.
|
|
127
|
+
raw: The reading exactly as received.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
image: Optional[str]
|
|
131
|
+
raw: Dict[str, Any] = field(repr=False)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class AgeReading(Reading):
|
|
136
|
+
"""A reading of the ``age`` booster: how old the person in the picture is.
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
faces: How many faces were found; the largest is read.
|
|
140
|
+
age_years: The age in years.
|
|
141
|
+
low: The low end of the range.
|
|
142
|
+
high: The high end of the range.
|
|
143
|
+
confidence: From 0 to 1.
|
|
144
|
+
|
|
145
|
+
With ``faces`` 0 the picture shows no face, and the four age fields are ``None``.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
faces: int
|
|
149
|
+
age_years: Optional[int] = None
|
|
150
|
+
low: Optional[int] = None
|
|
151
|
+
high: Optional[int] = None
|
|
152
|
+
confidence: Optional[Union[int, float]] = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class AiGeneratedReading(Reading):
|
|
157
|
+
"""A reading of the ``ai_generated`` booster: whether the picture was made by an image generator.
|
|
158
|
+
|
|
159
|
+
Attributes:
|
|
160
|
+
generated: The probability that a generator made the picture, from 0 to 1.
|
|
161
|
+
confidence: From 0 to 1.
|
|
162
|
+
method: ``inference`` (read from the pixels) or ``c2pa`` (the file's Content
|
|
163
|
+
Credentials say so); any other value as received.
|
|
164
|
+
top_generator: The likeliest generator, when the reading says the picture is
|
|
165
|
+
likely generated; ``None`` otherwise, and always on a ``c2pa`` reading.
|
|
166
|
+
c2pa_signer: Who signed the file's Content Credentials when they are trusted;
|
|
167
|
+
``None`` otherwise.
|
|
168
|
+
c2pa: ``untrusted`` when the file carries Content Credentials that validate but
|
|
169
|
+
are not trusted (they name no signer and change nothing in the reading); any
|
|
170
|
+
other value as received; ``None`` when the reading has none.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
generated: Union[int, float]
|
|
174
|
+
confidence: Union[int, float]
|
|
175
|
+
method: str
|
|
176
|
+
top_generator: Optional[TopGenerator] = None
|
|
177
|
+
c2pa_signer: Optional[str] = None
|
|
178
|
+
c2pa: Optional[str] = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class OcrReading(Reading):
|
|
183
|
+
"""A reading of the ``ocr`` booster: the text the picture shows, in reading order, or
|
|
184
|
+
the answer to the booster's ``prompt`` option.
|
|
185
|
+
|
|
186
|
+
Attributes:
|
|
187
|
+
text: The text read; empty when the picture shows none, which is a reading, not
|
|
188
|
+
a failure.
|
|
189
|
+
truncated: ``True`` when the reading was cut at the model's context limit.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
text: str
|
|
193
|
+
truncated: bool
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class UnknownReading(Reading):
|
|
198
|
+
"""A reading of a booster this version of the client does not know, or a known
|
|
199
|
+
reading whose fields are missing or of the wrong type. Read its fields from ``raw``."""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class FaceBox:
|
|
204
|
+
"""A face's box, by its corners: pixels of the image as sent, or fractions with four
|
|
205
|
+
decimals when every place question of the request asks for normalized units. The
|
|
206
|
+
numbers are as received.
|
|
207
|
+
|
|
208
|
+
Attributes:
|
|
209
|
+
x1: The left edge.
|
|
210
|
+
y1: The top edge.
|
|
211
|
+
x2: The right edge.
|
|
212
|
+
y2: The bottom edge.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
x1: Union[int, float]
|
|
216
|
+
y1: Union[int, float]
|
|
217
|
+
x2: Union[int, float]
|
|
218
|
+
y2: Union[int, float]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True)
|
|
222
|
+
class FaceDetection:
|
|
223
|
+
"""One face a ``face_detection`` reading found.
|
|
224
|
+
|
|
225
|
+
Attributes:
|
|
226
|
+
box: Where the face is.
|
|
227
|
+
confidence: From 0 to 1.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
box: FaceBox
|
|
231
|
+
confidence: Union[int, float]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class FaceDetectionReading(Reading):
|
|
236
|
+
"""A reading of the ``face_detection`` booster: the faces in the picture.
|
|
237
|
+
|
|
238
|
+
Attributes:
|
|
239
|
+
faces: How many faces were found. It can be larger than ``detections``, which
|
|
240
|
+
lists at most 64.
|
|
241
|
+
detections: Each face's box and confidence, in the order received.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
faces: int
|
|
245
|
+
detections: List[FaceDetection]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class FaceMatch:
|
|
250
|
+
"""A face that a ``face_recognition`` reading matched to an item of a collection.
|
|
251
|
+
|
|
252
|
+
Attributes:
|
|
253
|
+
box: Where the face is.
|
|
254
|
+
score: How well it matches, from 0 to 1.
|
|
255
|
+
collection: The collection searched, as the request named it.
|
|
256
|
+
reference: The matched item's reference.
|
|
257
|
+
name: The name of a public figure, for a match in a public collection that has
|
|
258
|
+
one; ``None`` otherwise.
|
|
259
|
+
label: The item's label, for an item of your own collection that has one;
|
|
260
|
+
``None`` otherwise.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
box: FaceBox
|
|
264
|
+
score: Union[int, float]
|
|
265
|
+
collection: str
|
|
266
|
+
reference: str
|
|
267
|
+
name: Optional[str] = None
|
|
268
|
+
label: Optional[str] = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True)
|
|
272
|
+
class FaceRecognitionReading(Reading):
|
|
273
|
+
"""A reading of the ``face_recognition`` booster: the faces searched in a collection.
|
|
274
|
+
|
|
275
|
+
Attributes:
|
|
276
|
+
faces: How many faces were searched.
|
|
277
|
+
matches: The faces that match an item, by score; empty when none does.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
faces: int
|
|
281
|
+
matches: List[FaceMatch]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
AnyReading = Union[
|
|
285
|
+
AgeReading, AiGeneratedReading, OcrReading, FaceDetectionReading, FaceRecognitionReading, UnknownReading
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(frozen=True)
|
|
290
|
+
class Evidence:
|
|
291
|
+
"""What one booster of the request did.
|
|
292
|
+
|
|
293
|
+
Attributes:
|
|
294
|
+
version: The version of the booster that read, such as ``age-v3``.
|
|
295
|
+
status: ``ok``, ``timeout`` (no answer in time), ``error``, or ``skipped`` (none
|
|
296
|
+
of the request's images is one it reads); any other value as received.
|
|
297
|
+
latency_ms: How long the booster took, in milliseconds.
|
|
298
|
+
results: One reading per image the booster was to read. A picture it did not read
|
|
299
|
+
(it timed out or failed) or could not decode (``readable`` false) is an
|
|
300
|
+
:class:`UnknownReading` holding those fields; ``status`` says why.
|
|
301
|
+
raw: The entry exactly as received.
|
|
302
|
+
billed_calls: The calls of this booster billed on this request; ``None`` when the
|
|
303
|
+
server did not send it.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
version: Optional[str]
|
|
307
|
+
status: Optional[str]
|
|
308
|
+
latency_ms: Optional[Union[int, float]]
|
|
309
|
+
results: List[AnyReading]
|
|
310
|
+
raw: Dict[str, Any] = field(repr=False)
|
|
311
|
+
billed_calls: Optional[int] = None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class _Malformed(Exception):
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _is_number(value: Any) -> bool:
|
|
319
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _number(raw: Mapping[str, Any], key: str) -> Union[int, float]:
|
|
323
|
+
value = raw.get(key)
|
|
324
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
325
|
+
raise _Malformed(key)
|
|
326
|
+
return value
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _string(raw: Mapping[str, Any], key: str) -> str:
|
|
330
|
+
value = raw.get(key)
|
|
331
|
+
if not isinstance(value, str):
|
|
332
|
+
raise _Malformed(key)
|
|
333
|
+
return value
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _integer(raw: Mapping[str, Any], key: str) -> int:
|
|
337
|
+
value = raw.get(key)
|
|
338
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
339
|
+
raise _Malformed(key)
|
|
340
|
+
return value
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _optional(read: Callable[[Mapping[str, Any], str], Any], raw: Mapping[str, Any], key: str, required: bool) -> Any:
|
|
344
|
+
if raw.get(key) is None:
|
|
345
|
+
if required:
|
|
346
|
+
raise _Malformed(key)
|
|
347
|
+
return None
|
|
348
|
+
return read(raw, key)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _age(raw: Dict[str, Any]) -> AgeReading:
|
|
352
|
+
faces = _integer(raw, "faces")
|
|
353
|
+
if faces < 0:
|
|
354
|
+
raise _Malformed("faces")
|
|
355
|
+
# With no face the age fields are left out; with one, each is required.
|
|
356
|
+
face = faces > 0
|
|
357
|
+
return AgeReading(
|
|
358
|
+
image=_string(raw, "image"),
|
|
359
|
+
raw=raw,
|
|
360
|
+
faces=faces,
|
|
361
|
+
age_years=_optional(_integer, raw, "age_years", face),
|
|
362
|
+
low=_optional(_integer, raw, "low", face),
|
|
363
|
+
high=_optional(_integer, raw, "high", face),
|
|
364
|
+
confidence=_optional(_number, raw, "confidence", face),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _ai_generated(raw: Dict[str, Any]) -> AiGeneratedReading:
|
|
369
|
+
# top_generator and c2pa_signer are always sent, null when unset; c2pa only when the
|
|
370
|
+
# file's Content Credentials are not trusted.
|
|
371
|
+
if "top_generator" not in raw or "c2pa_signer" not in raw:
|
|
372
|
+
raise _Malformed("top_generator" if "top_generator" not in raw else "c2pa_signer")
|
|
373
|
+
top = raw["top_generator"]
|
|
374
|
+
if top is not None and not isinstance(top, Mapping):
|
|
375
|
+
raise _Malformed("top_generator")
|
|
376
|
+
signer, c2pa = raw["c2pa_signer"], raw.get("c2pa")
|
|
377
|
+
if signer is not None and not isinstance(signer, str):
|
|
378
|
+
raise _Malformed("c2pa_signer")
|
|
379
|
+
if c2pa is not None and not isinstance(c2pa, str):
|
|
380
|
+
raise _Malformed("c2pa")
|
|
381
|
+
return AiGeneratedReading(
|
|
382
|
+
image=_string(raw, "image"),
|
|
383
|
+
raw=raw,
|
|
384
|
+
generated=_number(raw, "generated"),
|
|
385
|
+
confidence=_number(raw, "confidence"),
|
|
386
|
+
method=_string(raw, "method"),
|
|
387
|
+
top_generator=(
|
|
388
|
+
None if top is None else TopGenerator(name=_string(top, "name"), probability=_number(top, "probability"))
|
|
389
|
+
),
|
|
390
|
+
c2pa_signer=signer,
|
|
391
|
+
c2pa=c2pa,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _ocr(raw: Dict[str, Any]) -> OcrReading:
|
|
396
|
+
truncated = raw.get("truncated")
|
|
397
|
+
if not isinstance(truncated, bool):
|
|
398
|
+
raise _Malformed("truncated")
|
|
399
|
+
return OcrReading(image=_string(raw, "image"), raw=raw, text=_string(raw, "text"), truncated=truncated)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _box(entry: Mapping[str, Any]) -> FaceBox:
|
|
403
|
+
box = entry.get("box")
|
|
404
|
+
if not isinstance(box, Mapping):
|
|
405
|
+
raise _Malformed("box")
|
|
406
|
+
return FaceBox(x1=_number(box, "x1"), y1=_number(box, "y1"), x2=_number(box, "x2"), y2=_number(box, "y2"))
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _faces(raw: Mapping[str, Any]) -> int:
|
|
410
|
+
faces = _integer(raw, "faces")
|
|
411
|
+
if faces < 0:
|
|
412
|
+
raise _Malformed("faces")
|
|
413
|
+
return faces
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _entries(raw: Mapping[str, Any], key: str) -> List[Mapping[str, Any]]:
|
|
417
|
+
found = raw.get(key)
|
|
418
|
+
if not isinstance(found, list) or not all(isinstance(e, Mapping) for e in found):
|
|
419
|
+
raise _Malformed(key)
|
|
420
|
+
return found
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _face_detection(raw: Dict[str, Any]) -> FaceDetectionReading:
|
|
424
|
+
return FaceDetectionReading(
|
|
425
|
+
image=_string(raw, "image"),
|
|
426
|
+
raw=raw,
|
|
427
|
+
faces=_faces(raw),
|
|
428
|
+
detections=[
|
|
429
|
+
FaceDetection(box=_box(e), confidence=_number(e, "confidence")) for e in _entries(raw, "detections")
|
|
430
|
+
],
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _optional_string(raw: Mapping[str, Any], key: str) -> Optional[str]:
|
|
435
|
+
value = raw.get(key)
|
|
436
|
+
if value is not None and not isinstance(value, str):
|
|
437
|
+
raise _Malformed(key)
|
|
438
|
+
return value
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _face_match(e: Mapping[str, Any]) -> FaceMatch:
|
|
442
|
+
return FaceMatch(
|
|
443
|
+
box=_box(e),
|
|
444
|
+
score=_number(e, "score"),
|
|
445
|
+
collection=_string(e, "collection"),
|
|
446
|
+
reference=_string(e, "reference"),
|
|
447
|
+
name=_optional_string(e, "name"),
|
|
448
|
+
label=_optional_string(e, "label"),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _face_recognition(raw: Dict[str, Any]) -> FaceRecognitionReading:
|
|
453
|
+
return FaceRecognitionReading(
|
|
454
|
+
image=_string(raw, "image"),
|
|
455
|
+
raw=raw,
|
|
456
|
+
faces=_faces(raw),
|
|
457
|
+
matches=[_face_match(e) for e in _entries(raw, "matches")],
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
_READERS: Dict[str, Callable[[Dict[str, Any]], AnyReading]] = {
|
|
462
|
+
"age": _age,
|
|
463
|
+
"ai_generated": _ai_generated,
|
|
464
|
+
"ocr": _ocr,
|
|
465
|
+
"face_detection": _face_detection,
|
|
466
|
+
"face_recognition": _face_recognition,
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def parse_reading(booster: str, raw: Any) -> AnyReading:
|
|
471
|
+
"""One reading of the booster ``booster``. A reading of a booster this client does not
|
|
472
|
+
know, or one with a field missing or of the wrong type, is an :class:`UnknownReading`."""
|
|
473
|
+
if not isinstance(raw, Mapping):
|
|
474
|
+
return UnknownReading(image=None, raw={"value": raw})
|
|
475
|
+
raw = dict(raw)
|
|
476
|
+
reader = _READERS.get(booster)
|
|
477
|
+
if reader is not None:
|
|
478
|
+
try:
|
|
479
|
+
return reader(raw)
|
|
480
|
+
except _Malformed:
|
|
481
|
+
pass
|
|
482
|
+
image = raw.get("image")
|
|
483
|
+
return UnknownReading(image=image if isinstance(image, str) else None, raw=raw)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def parse_evidence(value: Any) -> Optional[Dict[str, Evidence]]:
|
|
487
|
+
"""The body's ``evidence``: booster name to :class:`Evidence`, in the body's order;
|
|
488
|
+
``None`` when the body has none."""
|
|
489
|
+
if not isinstance(value, Mapping):
|
|
490
|
+
return None
|
|
491
|
+
out: Dict[str, Evidence] = {}
|
|
492
|
+
for name, entry in value.items():
|
|
493
|
+
name = str(name)
|
|
494
|
+
if not isinstance(entry, Mapping):
|
|
495
|
+
out[name] = Evidence(version=None, status=None, latency_ms=None, results=[], raw={"value": entry})
|
|
496
|
+
continue
|
|
497
|
+
raw = dict(entry)
|
|
498
|
+
version, status, latency = raw.get("version"), raw.get("status"), raw.get("latency_ms")
|
|
499
|
+
results, billed = raw.get("results"), raw.get("billed_calls")
|
|
500
|
+
out[name] = Evidence(
|
|
501
|
+
version=version if isinstance(version, str) else None,
|
|
502
|
+
status=status if isinstance(status, str) else None,
|
|
503
|
+
latency_ms=latency if _is_number(latency) else None,
|
|
504
|
+
results=[parse_reading(name, r) for r in results] if isinstance(results, list) else [],
|
|
505
|
+
raw=raw,
|
|
506
|
+
billed_calls=billed if isinstance(billed, int) and not isinstance(billed, bool) else None,
|
|
507
|
+
)
|
|
508
|
+
return out
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# --------------------------------------------------------------------------- the header
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@dataclass(frozen=True)
|
|
515
|
+
class BoosterStatus:
|
|
516
|
+
"""One booster's entry of the ``x-decisionnode-boosters`` header.
|
|
517
|
+
|
|
518
|
+
Attributes:
|
|
519
|
+
status: ``ok``, ``timeout``, ``error`` or ``skipped``; any other value as received.
|
|
520
|
+
latency_ms: How long the booster took, in milliseconds, or ``None`` when not sent.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
status: str
|
|
524
|
+
latency_ms: Optional[int] = None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def parse_boosters(value: Optional[str]) -> Dict[str, BoosterStatus]:
|
|
528
|
+
"""Parse ``x-decisionnode-boosters`` (``age=ok:212,ai_generated=timeout:800``) into
|
|
529
|
+
booster name to :class:`BoosterStatus`, in the header's order. Empty when the header
|
|
530
|
+
is absent; an entry that cannot be read is left out."""
|
|
531
|
+
out: Dict[str, BoosterStatus] = {}
|
|
532
|
+
if not value:
|
|
533
|
+
return out
|
|
534
|
+
for item in value.split(","):
|
|
535
|
+
name, sep, rest = item.strip().partition("=")
|
|
536
|
+
name = name.strip()
|
|
537
|
+
if not sep or not name:
|
|
538
|
+
continue
|
|
539
|
+
status, _, ms = rest.partition(":")
|
|
540
|
+
ms = ms.strip()
|
|
541
|
+
latency = int(ms) if ms.isascii() and ms.isdigit() else None
|
|
542
|
+
out[name] = BoosterStatus(status=status.strip(), latency_ms=latency)
|
|
543
|
+
return out
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def header(headers: Optional[Mapping[str, str]]) -> Optional[str]:
|
|
547
|
+
"""The ``x-decisionnode-boosters`` header of a response's headers, by any case."""
|
|
548
|
+
if not headers:
|
|
549
|
+
return None
|
|
550
|
+
value = headers.get("x-decisionnode-boosters")
|
|
551
|
+
if value is None:
|
|
552
|
+
value = next((v for k, v in headers.items() if k.lower() == "x-decisionnode-boosters"), None)
|
|
553
|
+
return value
|