logit-classifier 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.
- logit_classifier/__init__.py +90 -0
- logit_classifier/__main__.py +10 -0
- logit_classifier/backends/__init__.py +5 -0
- logit_classifier/backends/base.py +125 -0
- logit_classifier/backends/hf.py +412 -0
- logit_classifier/calibrate.py +141 -0
- logit_classifier/classifier.py +329 -0
- logit_classifier/cli.py +51 -0
- logit_classifier/config.py +187 -0
- logit_classifier/deps.py +30 -0
- logit_classifier/errors.py +16 -0
- logit_classifier/labels.py +91 -0
- logit_classifier/prompt.py +154 -0
- logit_classifier/py.typed +0 -0
- logit_classifier/schema.py +285 -0
- logit_classifier/scoring.py +102 -0
- logit_classifier/service.py +174 -0
- logit_classifier/vision.py +95 -0
- logit_classifier/web/index.html +411 -0
- logit_classifier-0.1.0.dist-info/METADATA +354 -0
- logit_classifier-0.1.0.dist-info/RECORD +24 -0
- logit_classifier-0.1.0.dist-info/WHEEL +4 -0
- logit_classifier-0.1.0.dist-info/entry_points.txt +2 -0
- logit_classifier-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Request and response models mirroring the TypeSafe System One wire format.
|
|
2
|
+
|
|
3
|
+
Field names, nesting and answer shapes follow the published Jev contract so a
|
|
4
|
+
Jev request body is accepted unchanged. Deviations are noted where they occur.
|
|
5
|
+
|
|
6
|
+
Plain dataclasses rather than pydantic, so a ComfyUI node pack importing this
|
|
7
|
+
package never pulls a pydantic version into ComfyUI's own venv.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from .config import JEV_MAX_OPTIONS, JEV_MAX_QUESTIONS, JEV_MAX_SCORE_LEVELS
|
|
17
|
+
from .errors import LogitClassifierError
|
|
18
|
+
|
|
19
|
+
# Jev accepts a string, object or array anywhere prose is expected.
|
|
20
|
+
JSONContent = str | dict[str, Any] | list[Any]
|
|
21
|
+
|
|
22
|
+
DEFAULT_MODEL = "logit-latest"
|
|
23
|
+
|
|
24
|
+
_REQUEST_FIELDS = frozenset({"state", "model", "questions"})
|
|
25
|
+
_QUESTION_FIELDS = frozenset({"type", "instructions", "criteria"})
|
|
26
|
+
_NOUL_CRITERIA_FIELDS = frozenset({"true", "false"})
|
|
27
|
+
_QUESTION_TYPES = ("choice", "score", "noul")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SchemaError(LogitClassifierError, ValueError):
|
|
31
|
+
"""A body does not match the System One contract.
|
|
32
|
+
|
|
33
|
+
`field` carries the dotted path to the offending value, which is what the
|
|
34
|
+
service reports back so a caller can find it without guessing.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, field: str | None = None) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.message = message
|
|
40
|
+
self.field = field or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_content(value: JSONContent | None) -> str:
|
|
44
|
+
"""Flatten a JSONContent value into the text the model sees."""
|
|
45
|
+
if value is None:
|
|
46
|
+
return ""
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
return value
|
|
49
|
+
return json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, kw_only=True)
|
|
53
|
+
class NoulCriteria:
|
|
54
|
+
"""Wording for the two sides of a noul. `true` and `false` are reserved words."""
|
|
55
|
+
|
|
56
|
+
true_: JSONContent | None = None
|
|
57
|
+
false_: JSONContent | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, kw_only=True)
|
|
61
|
+
class ChoiceQuestion:
|
|
62
|
+
"""Pick one of the named options."""
|
|
63
|
+
|
|
64
|
+
type: Literal["choice"] = "choice"
|
|
65
|
+
instructions: JSONContent | None = None
|
|
66
|
+
criteria: dict[str, JSONContent | None]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, kw_only=True)
|
|
70
|
+
class ScoreQuestion:
|
|
71
|
+
"""Place the state on an ordered scale, lowest level first."""
|
|
72
|
+
|
|
73
|
+
type: Literal["score"] = "score"
|
|
74
|
+
instructions: JSONContent | None = None
|
|
75
|
+
criteria: list[JSONContent]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, kw_only=True)
|
|
79
|
+
class NoulQuestion:
|
|
80
|
+
"""How true one statement is of the state."""
|
|
81
|
+
|
|
82
|
+
type: Literal["noul"] = "noul"
|
|
83
|
+
instructions: JSONContent | None = None
|
|
84
|
+
criteria: NoulCriteria | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Question = ChoiceQuestion | ScoreQuestion | NoulQuestion
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, kw_only=True)
|
|
91
|
+
class SystemOneRequest:
|
|
92
|
+
"""One state, and every question asked about it."""
|
|
93
|
+
|
|
94
|
+
state: JSONContent
|
|
95
|
+
model: str = DEFAULT_MODEL
|
|
96
|
+
questions: dict[str, Question]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Answer field order matches the published response examples, and asdict follows
|
|
100
|
+
# the order the fields are declared in.
|
|
101
|
+
@dataclass(frozen=True, kw_only=True)
|
|
102
|
+
class ChoiceAnswer:
|
|
103
|
+
type: Literal["choice"] = "choice"
|
|
104
|
+
choice: str
|
|
105
|
+
confidence: float
|
|
106
|
+
probabilities: dict[str, float]
|
|
107
|
+
# How strongly the model wants none of the offered options. It sits outside
|
|
108
|
+
# probabilities, which still covers the caller's own options and sums to 1.
|
|
109
|
+
abstain: float | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, kw_only=True)
|
|
113
|
+
class ScoreAnswer:
|
|
114
|
+
type: Literal["score"] = "score"
|
|
115
|
+
score: float
|
|
116
|
+
confidence: float
|
|
117
|
+
legend: dict[str, JSONContent]
|
|
118
|
+
probabilities: dict[str, float]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, kw_only=True)
|
|
122
|
+
class NoulAnswer:
|
|
123
|
+
type: Literal["noul"] = "noul"
|
|
124
|
+
noul: float
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
Answer = ChoiceAnswer | ScoreAnswer | NoulAnswer
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, kw_only=True)
|
|
131
|
+
class Usage:
|
|
132
|
+
input_tokens: int | None = None
|
|
133
|
+
output_tokens: int | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, kw_only=True)
|
|
137
|
+
class SystemOneResponse:
|
|
138
|
+
model: str
|
|
139
|
+
answers: dict[str, Answer]
|
|
140
|
+
usage: Usage
|
|
141
|
+
|
|
142
|
+
def to_dict(self) -> dict[str, Any]:
|
|
143
|
+
return asdict(self)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, kw_only=True)
|
|
147
|
+
class ErrorBody:
|
|
148
|
+
"""The error shape this service returns.
|
|
149
|
+
|
|
150
|
+
Jev documents its status codes but publishes no error body, so this shape is ours.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
error: str
|
|
154
|
+
message: str
|
|
155
|
+
field: str | None = None
|
|
156
|
+
|
|
157
|
+
def to_dict(self) -> dict[str, Any]:
|
|
158
|
+
return asdict(self)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _path(*parts: str) -> str:
|
|
162
|
+
return ".".join(part for part in parts if part)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _require_mapping(value: Any, where: str) -> dict[str, Any]:
|
|
166
|
+
if not isinstance(value, dict):
|
|
167
|
+
raise SchemaError(f"expected an object, got {type(value).__name__}", where)
|
|
168
|
+
for key in value:
|
|
169
|
+
if not isinstance(key, str):
|
|
170
|
+
raise SchemaError(f"object keys must be strings, got {type(key).__name__}", where)
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _reject_unknown(mapping: dict[str, Any], allowed: frozenset[str], where: str) -> None:
|
|
175
|
+
unknown = sorted(set(mapping) - allowed)
|
|
176
|
+
if unknown:
|
|
177
|
+
raise SchemaError(f"unexpected field {unknown[0]!r}", _path(where, unknown[0]))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _content(value: Any, where: str) -> JSONContent:
|
|
181
|
+
if isinstance(value, str | dict | list):
|
|
182
|
+
return value
|
|
183
|
+
raise SchemaError(f"expected a string, object or array, got {type(value).__name__}", where)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _optional_content(value: Any, where: str) -> JSONContent | None:
|
|
187
|
+
return None if value is None else _content(value, where)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _choice_criteria(value: Any, where: str) -> dict[str, JSONContent | None]:
|
|
191
|
+
mapping = _require_mapping(value, where)
|
|
192
|
+
|
|
193
|
+
if not 2 <= len(mapping) <= JEV_MAX_OPTIONS:
|
|
194
|
+
raise SchemaError(
|
|
195
|
+
f"a choice needs 2 to {JEV_MAX_OPTIONS} options, got {len(mapping)}", where
|
|
196
|
+
)
|
|
197
|
+
return {name: _optional_content(body, _path(where, name)) for name, body in mapping.items()}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _score_criteria(value: Any, where: str) -> list[JSONContent]:
|
|
201
|
+
if not isinstance(value, list):
|
|
202
|
+
raise SchemaError(f"expected an array, got {type(value).__name__}", where)
|
|
203
|
+
if not 2 <= len(value) <= JEV_MAX_SCORE_LEVELS:
|
|
204
|
+
raise SchemaError(
|
|
205
|
+
f"a score needs 2 to {JEV_MAX_SCORE_LEVELS} levels, got {len(value)}", where
|
|
206
|
+
)
|
|
207
|
+
return [_content(level, _path(where, str(index))) for index, level in enumerate(value)]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _noul_criteria(value: Any, where: str) -> NoulCriteria:
|
|
211
|
+
mapping = _require_mapping(value, where)
|
|
212
|
+
|
|
213
|
+
_reject_unknown(mapping, _NOUL_CRITERIA_FIELDS, where)
|
|
214
|
+
return NoulCriteria(
|
|
215
|
+
true_=_optional_content(mapping.get("true"), _path(where, "true")),
|
|
216
|
+
false_=_optional_content(mapping.get("false"), _path(where, "false")),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _parse_question(value: Any, where: str) -> Question:
|
|
221
|
+
mapping = _require_mapping(value, where)
|
|
222
|
+
kind = mapping.get("type")
|
|
223
|
+
instructions = _optional_content(mapping.get("instructions"), _path(where, "instructions"))
|
|
224
|
+
criteria = mapping.get("criteria")
|
|
225
|
+
criteria_at = _path(where, "criteria")
|
|
226
|
+
|
|
227
|
+
if kind not in _QUESTION_TYPES:
|
|
228
|
+
raise SchemaError(
|
|
229
|
+
f"type must be one of {', '.join(_QUESTION_TYPES)}, got {kind!r}",
|
|
230
|
+
_path(where, "type"),
|
|
231
|
+
)
|
|
232
|
+
_reject_unknown(mapping, _QUESTION_FIELDS, where)
|
|
233
|
+
|
|
234
|
+
if kind == "choice":
|
|
235
|
+
if criteria is None:
|
|
236
|
+
raise SchemaError("a choice needs criteria", criteria_at)
|
|
237
|
+
return ChoiceQuestion(
|
|
238
|
+
instructions=instructions, criteria=_choice_criteria(criteria, criteria_at)
|
|
239
|
+
)
|
|
240
|
+
if kind == "score":
|
|
241
|
+
if criteria is None:
|
|
242
|
+
raise SchemaError("a score needs criteria", criteria_at)
|
|
243
|
+
return ScoreQuestion(
|
|
244
|
+
instructions=instructions, criteria=_score_criteria(criteria, criteria_at)
|
|
245
|
+
)
|
|
246
|
+
return NoulQuestion(
|
|
247
|
+
instructions=instructions,
|
|
248
|
+
criteria=None if criteria is None else _noul_criteria(criteria, criteria_at),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def parse_questions(value: Any) -> dict[str, Question]:
|
|
253
|
+
"""Validate a question map on its own, for a caller holding no full request."""
|
|
254
|
+
mapping = _require_mapping(value, "questions")
|
|
255
|
+
|
|
256
|
+
if not 1 <= len(mapping) <= JEV_MAX_QUESTIONS:
|
|
257
|
+
raise SchemaError(
|
|
258
|
+
f"1 to {JEV_MAX_QUESTIONS} questions are allowed, got {len(mapping)}", "questions"
|
|
259
|
+
)
|
|
260
|
+
return {qid: _parse_question(body, _path("questions", qid)) for qid, body in mapping.items()}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def parse_request(body: Any) -> SystemOneRequest:
|
|
264
|
+
"""Validate a decoded JSON body and return the request it describes.
|
|
265
|
+
|
|
266
|
+
Every failure raises SchemaError carrying the dotted path to the value that
|
|
267
|
+
failed, so the first rejection names its own field rather than the body.
|
|
268
|
+
"""
|
|
269
|
+
mapping = _require_mapping(body, "")
|
|
270
|
+
|
|
271
|
+
_reject_unknown(mapping, _REQUEST_FIELDS, "")
|
|
272
|
+
if "state" not in mapping:
|
|
273
|
+
raise SchemaError("state is required", "state")
|
|
274
|
+
if "questions" not in mapping:
|
|
275
|
+
raise SchemaError("questions is required", "questions")
|
|
276
|
+
|
|
277
|
+
model = mapping.get("model", DEFAULT_MODEL)
|
|
278
|
+
if not isinstance(model, str):
|
|
279
|
+
raise SchemaError(f"expected a string, got {type(model).__name__}", "model")
|
|
280
|
+
|
|
281
|
+
return SystemOneRequest(
|
|
282
|
+
state=_content(mapping["state"], "state"),
|
|
283
|
+
model=model,
|
|
284
|
+
questions=parse_questions(mapping["questions"]),
|
|
285
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Turning label logits into calibrated probabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def restricted_softmax(
|
|
9
|
+
z: np.ndarray, log_prior: np.ndarray | None = None, temperature: float = 1.0
|
|
10
|
+
) -> np.ndarray:
|
|
11
|
+
"""Normalise over the option labels only, in float64.
|
|
12
|
+
|
|
13
|
+
Subtracting a log-prior is the shared operation behind batch calibration,
|
|
14
|
+
contextual calibration and PriDe. Temperature then widens the distribution,
|
|
15
|
+
which a restricted softmax over an aligned model is otherwise too sharp for.
|
|
16
|
+
"""
|
|
17
|
+
adjusted = z.astype(np.float64)
|
|
18
|
+
|
|
19
|
+
if log_prior is not None:
|
|
20
|
+
adjusted = adjusted - log_prior
|
|
21
|
+
if temperature and temperature != 1.0:
|
|
22
|
+
adjusted = adjusted / temperature
|
|
23
|
+
adjusted = adjusted - adjusted.max()
|
|
24
|
+
weights: np.ndarray = np.exp(adjusted)
|
|
25
|
+
normalised: np.ndarray = weights / weights.sum()
|
|
26
|
+
return normalised
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def choice_confidence(probabilities: np.ndarray) -> float:
|
|
30
|
+
"""Peak probability rescaled so uniform maps to 0 and a point mass to 1.
|
|
31
|
+
|
|
32
|
+
Ported with score_confidence from TypeSafe's own
|
|
33
|
+
system_one_adapter._utils.confidence_metrics, so a System One client reading
|
|
34
|
+
our answers computes the same number it would from theirs.
|
|
35
|
+
"""
|
|
36
|
+
count = len(probabilities)
|
|
37
|
+
|
|
38
|
+
if count < 2:
|
|
39
|
+
return 1.0
|
|
40
|
+
peak = float(probabilities.max())
|
|
41
|
+
return float(np.clip((count * peak - 1.0) / (count - 1.0), 0.0, 1.0))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def score_confidence(probabilities: np.ndarray) -> float:
|
|
45
|
+
"""Concentration of an ordered distribution around its modal level.
|
|
46
|
+
|
|
47
|
+
Levels are ordinal, so mass one level from the mode counts less than mass
|
|
48
|
+
four levels away. The choice formula cannot express that, and it reports a
|
|
49
|
+
tight two level split as low confidence.
|
|
50
|
+
"""
|
|
51
|
+
count = len(probabilities)
|
|
52
|
+
|
|
53
|
+
if count < 2:
|
|
54
|
+
return 1.0
|
|
55
|
+
indices = np.arange(count, dtype=np.float64)
|
|
56
|
+
spread = float(probabilities @ np.abs(indices - int(np.argmax(probabilities))))
|
|
57
|
+
uniform_spread = float(np.abs(indices - (count - 1) / 2.0).mean())
|
|
58
|
+
return float(max(0.0, 1.0 - spread / uniform_spread))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalise_levels(yes_probabilities: np.ndarray) -> np.ndarray:
|
|
62
|
+
"""Turn independent per-level yes probabilities into one distribution."""
|
|
63
|
+
total = float(yes_probabilities.sum())
|
|
64
|
+
|
|
65
|
+
if total <= 0.0:
|
|
66
|
+
return np.full(len(yes_probabilities), 1.0 / len(yes_probabilities))
|
|
67
|
+
return yes_probabilities / total
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def expected_score(level_probabilities: np.ndarray) -> float:
|
|
71
|
+
"""Probability-weighted mean of the zero-based level indices."""
|
|
72
|
+
return float(level_probabilities @ np.arange(len(level_probabilities), dtype=np.float64))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def combine_escape(group_probabilities: list[np.ndarray]) -> tuple[np.ndarray, float]:
|
|
76
|
+
"""Merge groups whose last label is "none of these" into one distribution.
|
|
77
|
+
|
|
78
|
+
A group's weight is the mass it did not put on its escape label, so the
|
|
79
|
+
groups compete without ever being summarised for the model. That removes the
|
|
80
|
+
separate group branch, and with it the label bias that branch carried.
|
|
81
|
+
|
|
82
|
+
The second return is the abstain score, which is how hard the least declining
|
|
83
|
+
group declined. Every group has to decline for the question to be unanswerable,
|
|
84
|
+
so the weakest decline is the binding one. It is reported beside the distribution
|
|
85
|
+
rather than inside it.
|
|
86
|
+
"""
|
|
87
|
+
weights = np.array([1.0 - float(p[-1]) for p in group_probabilities], dtype=np.float64)
|
|
88
|
+
parts: list[np.ndarray] = []
|
|
89
|
+
|
|
90
|
+
if weights.sum() <= 0:
|
|
91
|
+
weights = np.ones(len(group_probabilities), dtype=np.float64)
|
|
92
|
+
weights = weights / weights.sum()
|
|
93
|
+
|
|
94
|
+
for weight, probabilities in zip(weights, group_probabilities, strict=True):
|
|
95
|
+
members = probabilities[:-1]
|
|
96
|
+
total = members.sum()
|
|
97
|
+
share = members / total if total > 0 else np.full(len(members), 1.0 / len(members))
|
|
98
|
+
parts.append(weight * share)
|
|
99
|
+
combined = np.concatenate(parts)
|
|
100
|
+
total = combined.sum()
|
|
101
|
+
declined = float(min(float(p[-1]) for p in group_probabilities))
|
|
102
|
+
return (combined / total if total > 0 else combined), declined
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""HTTP service exposing the Jev-shaped System One endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Any
|
|
13
|
+
|
|
14
|
+
from fastapi import Body, FastAPI, Query, Request
|
|
15
|
+
from fastapi.exceptions import RequestValidationError
|
|
16
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
17
|
+
from fastapi.staticfiles import StaticFiles
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .backends.base import VisionUnsupportedError
|
|
21
|
+
from .classifier import Classifier
|
|
22
|
+
from .config import Config
|
|
23
|
+
from .errors import LogitClassifierError
|
|
24
|
+
from .schema import ErrorBody, SchemaError, parse_request
|
|
25
|
+
from .vision import ImageError
|
|
26
|
+
|
|
27
|
+
WEB_DIR = Path(__file__).parent / "web"
|
|
28
|
+
|
|
29
|
+
_log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
EXAMPLE_REQUEST = {
|
|
32
|
+
"state": "The integration keeps failing and I am losing sales.",
|
|
33
|
+
"questions": {
|
|
34
|
+
"department": {
|
|
35
|
+
"type": "choice",
|
|
36
|
+
"instructions": "Which team should handle this",
|
|
37
|
+
"criteria": {"billing": "Payment issues", "technical": "Integration problems"},
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Loaded:
|
|
45
|
+
"""What the lifespan built, present only once the model is resident."""
|
|
46
|
+
|
|
47
|
+
config: Config
|
|
48
|
+
classifier: Classifier
|
|
49
|
+
load_seconds: float
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_loaded: Loaded | None = None
|
|
53
|
+
# One GPU, and batch composition must stay a pure function of the request, so
|
|
54
|
+
# requests are served one at a time.
|
|
55
|
+
_gpu_lock = threading.Lock()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@asynccontextmanager
|
|
59
|
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
60
|
+
global _loaded
|
|
61
|
+
config = Config.from_env()
|
|
62
|
+
started = time.time()
|
|
63
|
+
classifier = Classifier(config)
|
|
64
|
+
_loaded = Loaded(config=config, classifier=classifier,
|
|
65
|
+
load_seconds=round(time.time() - started, 2))
|
|
66
|
+
yield
|
|
67
|
+
classifier.persist_prior()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
app = FastAPI(title="Logit Classifier", version=__version__, lifespan=lifespan)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _rejected(message: str, field: str | None = None) -> JSONResponse:
|
|
74
|
+
body = ErrorBody(error="unprocessable_entity", message=message, field=field)
|
|
75
|
+
return JSONResponse(status_code=422, content=body.to_dict())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _failed(message: str) -> JSONResponse:
|
|
79
|
+
body = ErrorBody(error="internal_error", message=message)
|
|
80
|
+
return JSONResponse(status_code=500, content=body.to_dict())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unavailable() -> JSONResponse:
|
|
84
|
+
body = ErrorBody(error="service_unavailable", message="model is still loading")
|
|
85
|
+
return JSONResponse(status_code=503, content=body.to_dict())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.exception_handler(RequestValidationError)
|
|
89
|
+
async def on_validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
90
|
+
first = exc.errors()[0] if exc.errors() else {}
|
|
91
|
+
location = ".".join(str(p) for p in first.get("loc", []) if p != "body")
|
|
92
|
+
return _rejected(first.get("msg", "request failed validation"), location or None)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.post("/v1/systemone")
|
|
96
|
+
def system_one(
|
|
97
|
+
body: Annotated[dict[str, Any], Body(openapi_examples={"jev": {"value": EXAMPLE_REQUEST}})],
|
|
98
|
+
diagnostics: Annotated[bool, Query()] = False,
|
|
99
|
+
) -> Any:
|
|
100
|
+
loaded = _loaded
|
|
101
|
+
|
|
102
|
+
if loaded is None:
|
|
103
|
+
return _unavailable()
|
|
104
|
+
try:
|
|
105
|
+
request = parse_request(body)
|
|
106
|
+
except SchemaError as error:
|
|
107
|
+
return _rejected(error.message, error.field)
|
|
108
|
+
|
|
109
|
+
started = time.time()
|
|
110
|
+
try:
|
|
111
|
+
with _gpu_lock:
|
|
112
|
+
response, diag = loaded.classifier.classify(request, allow_image_paths=False)
|
|
113
|
+
except (ImageError, VisionUnsupportedError) as error:
|
|
114
|
+
return _rejected(str(error), "state")
|
|
115
|
+
except LogitClassifierError as error:
|
|
116
|
+
# A backend contract or label failure is ours, not the caller's, so it must
|
|
117
|
+
# not come back as a rejected request they could fix. The traceback is the
|
|
118
|
+
# operator's only diagnostic, and returning a body swallows it.
|
|
119
|
+
_log.exception("classify failed")
|
|
120
|
+
return _failed(str(error))
|
|
121
|
+
except Exception as error:
|
|
122
|
+
# A CUDA out of memory or a kernel error must still reach a Jev caller as an
|
|
123
|
+
# ErrorBody, never as a plain-text 500.
|
|
124
|
+
_log.exception("classify failed")
|
|
125
|
+
return _failed(f"{type(error).__name__}: {error}")
|
|
126
|
+
payload = response.to_dict()
|
|
127
|
+
|
|
128
|
+
if diagnostics:
|
|
129
|
+
payload["diagnostics"] = {
|
|
130
|
+
"candidate_mass": diag.candidate_mass,
|
|
131
|
+
"prior_applied": diag.prior_applied,
|
|
132
|
+
"branch_counts": diag.branch_counts,
|
|
133
|
+
"latency_ms": round((time.time() - started) * 1000, 1),
|
|
134
|
+
"temperature": loaded.classifier.temperature,
|
|
135
|
+
"score_method": loaded.config.score_method,
|
|
136
|
+
"prior_observations": loaded.classifier.priors.stats(),
|
|
137
|
+
}
|
|
138
|
+
return payload
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.get("/v1/models")
|
|
142
|
+
def models() -> Any:
|
|
143
|
+
loaded = _loaded
|
|
144
|
+
|
|
145
|
+
if loaded is None:
|
|
146
|
+
return _unavailable()
|
|
147
|
+
return {
|
|
148
|
+
"models": [
|
|
149
|
+
{
|
|
150
|
+
"name": loaded.config.served_model_id,
|
|
151
|
+
"description": f"Restricted-label scorer over {loaded.config.model_id}",
|
|
152
|
+
"aliases": list(loaded.config.model_aliases),
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.get("/healthz")
|
|
159
|
+
def healthz() -> dict[str, Any]:
|
|
160
|
+
loaded = _loaded
|
|
161
|
+
return {
|
|
162
|
+
"ready": loaded is not None,
|
|
163
|
+
"model_id": loaded.config.model_id if loaded else None,
|
|
164
|
+
"sees_images": loaded.classifier.backend.sees_images if loaded else None,
|
|
165
|
+
"load_seconds": loaded.load_seconds if loaded else None,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.get("/")
|
|
170
|
+
def index() -> FileResponse:
|
|
171
|
+
return FileResponse(WEB_DIR / "index.html")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Pulling an image out of the request state.
|
|
2
|
+
|
|
3
|
+
The state stays JSON, so an image arrives as a data URL, a bare base64 string or a
|
|
4
|
+
path. It is lifted out before the state is rendered as text, because base64 in the
|
|
5
|
+
prompt would cost thousands of tokens and say nothing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import io
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
15
|
+
|
|
16
|
+
from .deps import require
|
|
17
|
+
from .errors import LogitClassifierError
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from PIL.Image import Image
|
|
21
|
+
|
|
22
|
+
IMAGE_KEYS = ("image", "screenshot")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ImageError(LogitClassifierError, ValueError):
|
|
26
|
+
"""The state named an image that could not be read."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _from_base64(value: str) -> Image:
|
|
30
|
+
pil_image = require("PIL.Image", "service")
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
raw = base64.b64decode(value, validate=True)
|
|
34
|
+
except (binascii.Error, ValueError) as error:
|
|
35
|
+
raise ImageError(f"image is neither a readable path nor valid base64: {error}") from error
|
|
36
|
+
try:
|
|
37
|
+
return cast("Image", pil_image.open(io.BytesIO(raw)).convert("RGB"))
|
|
38
|
+
except (OSError, ValueError, pil_image.DecompressionBombError) as error:
|
|
39
|
+
raise ImageError(f"decoded bytes are not a readable image: {error}") from error
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _decode(value: str, key: str, *, allow_paths: bool) -> Image:
|
|
43
|
+
"""Decode a data URL, a path to a file, or bare base64, by trying rather than guessing.
|
|
44
|
+
|
|
45
|
+
Length cannot tell a path from base64, since a small PNG encodes shorter than many
|
|
46
|
+
paths. Whether the file exists can.
|
|
47
|
+
"""
|
|
48
|
+
path: Path | None = None
|
|
49
|
+
pil_image = require("PIL.Image", "service")
|
|
50
|
+
|
|
51
|
+
if value.startswith("data:"):
|
|
52
|
+
_, _, payload = value.partition(",")
|
|
53
|
+
if not payload:
|
|
54
|
+
raise ImageError("data URL carried no payload")
|
|
55
|
+
return _from_base64(payload)
|
|
56
|
+
# A path probe over HTTP would let a client read server files, and a UNC path would
|
|
57
|
+
# send the service account's credentials to a host the client names.
|
|
58
|
+
if not allow_paths:
|
|
59
|
+
try:
|
|
60
|
+
return _from_base64(value)
|
|
61
|
+
except ImageError as error:
|
|
62
|
+
raise ImageError(
|
|
63
|
+
f"state.{key} is not a data URL or base64 image. The HTTP service accepts "
|
|
64
|
+
f"only those two forms: {error}"
|
|
65
|
+
) from error
|
|
66
|
+
try:
|
|
67
|
+
candidate = Path(value)
|
|
68
|
+
path = candidate if candidate.is_file() else None
|
|
69
|
+
except (OSError, ValueError):
|
|
70
|
+
path = None # too long or illegal as a path, so it is not one
|
|
71
|
+
if path is None:
|
|
72
|
+
return _from_base64(value)
|
|
73
|
+
# UnidentifiedImageError subclasses OSError, so reading the file stays outside
|
|
74
|
+
# the probe above, which would report a real file as bad base64.
|
|
75
|
+
try:
|
|
76
|
+
opened = pil_image.open(path).convert("RGB")
|
|
77
|
+
except (OSError, ValueError, pil_image.DecompressionBombError) as error:
|
|
78
|
+
raise ImageError(f"{path} is not a readable image: {error}") from error
|
|
79
|
+
return cast("Image", opened)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def extract_image(state: Any, *, allow_paths: bool = True) -> tuple[Any, Image | None]:
|
|
83
|
+
"""Return the state with any image removed, and the image itself.
|
|
84
|
+
|
|
85
|
+
A state that is not a mapping carries no image, so it comes back untouched.
|
|
86
|
+
"""
|
|
87
|
+
if not isinstance(state, dict):
|
|
88
|
+
return state, None
|
|
89
|
+
|
|
90
|
+
for key in IMAGE_KEYS:
|
|
91
|
+
value = state.get(key)
|
|
92
|
+
if isinstance(value, str) and value:
|
|
93
|
+
remainder = {k: v for k, v in state.items() if k != key}
|
|
94
|
+
return (remainder or "(see image)"), _decode(value, key, allow_paths=allow_paths)
|
|
95
|
+
return state, None
|