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.
@@ -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