pyprocessors-jev 1.6.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,68 @@
|
|
|
1
|
+
"""Minimal HTTP client for the Jev `System One` API.
|
|
2
|
+
|
|
3
|
+
The wire protocol is the same for the hosted TypeSafe service and for a
|
|
4
|
+
self-hosted Open-Jev server: `POST {base_url}/v1/systemone` with
|
|
5
|
+
`{"model", "state", "questions"}`, answering `{"model", "answers", "usage"}`.
|
|
6
|
+
Only the base url, the model route and the presence of a bearer token differ,
|
|
7
|
+
so one client serves both and the two processors only carry their defaults.
|
|
8
|
+
|
|
9
|
+
The official `typesafe_sdk` is deliberately not used: it cannot talk to an
|
|
10
|
+
Open-Jev server, which would mean two code paths for one protocol.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from log_with_context import Logger
|
|
18
|
+
|
|
19
|
+
logger = Logger("pymultirole")
|
|
20
|
+
|
|
21
|
+
# 429 and 529 are the documented back-pressure codes of the hosted API; the 5xx
|
|
22
|
+
# family covers a self-hosted server restarting behind a proxy.
|
|
23
|
+
RETRY_STATUS = frozenset({429, 500, 502, 503, 504, 529})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JevError(RuntimeError):
|
|
27
|
+
"""A Jev call that no retry can fix (bad key, invalid questions, ...)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JevClient:
|
|
31
|
+
def __init__(self, base_url: str, api_key: str | None = None, timeout: float = 60.0, max_retries: int = 3):
|
|
32
|
+
self.url = base_url.rstrip("/") + "/v1/systemone"
|
|
33
|
+
self.api_key = api_key
|
|
34
|
+
self.timeout = timeout
|
|
35
|
+
self.max_retries = max_retries
|
|
36
|
+
self.session = requests.Session()
|
|
37
|
+
|
|
38
|
+
def system_one(self, model: str, state, questions: dict) -> dict:
|
|
39
|
+
"""Ask every question in ONE call: the API answers them independently and
|
|
40
|
+
the server reuses the prefix computed from `state`, so splitting the
|
|
41
|
+
questions into several calls only multiplies latency and input tokens."""
|
|
42
|
+
headers = {"Content-Type": "application/json"}
|
|
43
|
+
if self.api_key:
|
|
44
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
45
|
+
payload = {"model": model, "state": state, "questions": questions}
|
|
46
|
+
|
|
47
|
+
for attempt in range(self.max_retries + 1):
|
|
48
|
+
response = self.session.post(self.url, json=payload, headers=headers, timeout=self.timeout)
|
|
49
|
+
if response.status_code in RETRY_STATUS and attempt < self.max_retries:
|
|
50
|
+
time.sleep(backoff_delay(attempt, response.headers.get("Retry-After")))
|
|
51
|
+
continue
|
|
52
|
+
if response.status_code >= 400:
|
|
53
|
+
# 401 (key), 422 (questions) and anything left after the retries: the
|
|
54
|
+
# body carries the only actionable detail, so it goes in the message.
|
|
55
|
+
raise JevError(f"Jev returned {response.status_code} for {self.url}: {response.text[:512]}")
|
|
56
|
+
return response.json()
|
|
57
|
+
raise JevError(f"Jev still unavailable after {self.max_retries} retries: {self.url}") # pragma: no cover
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def backoff_delay(attempt: int, retry_after: str | None = None) -> float:
|
|
61
|
+
"""Exponential backoff, jittered, but obeying `Retry-After` when the server
|
|
62
|
+
sent one — a server that names its own delay knows better than we do."""
|
|
63
|
+
if retry_after:
|
|
64
|
+
try:
|
|
65
|
+
return min(float(retry_after), 60.0)
|
|
66
|
+
except ValueError:
|
|
67
|
+
logger.warning(f"Ignoring unparseable Retry-After {retry_after!r}")
|
|
68
|
+
return min(2.0**attempt, 30.0) * (0.5 + random.random() / 2) # noqa: S311
|
pyprocessors_jev/jev.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Processor calling the Jev `System One` API.
|
|
2
|
+
|
|
3
|
+
Jev answers typed questions with calibrated probabilities instead of text, so
|
|
4
|
+
this processor never parses a completion: each question declares where its
|
|
5
|
+
answer goes (`categories`, `metadata`, `annotations`, `altTexts`) and the
|
|
6
|
+
answer is written there. Since the API answers many questions in one call,
|
|
7
|
+
classifying and enriching the metadata of a document costs one HTTP request —
|
|
8
|
+
there is no need for the `--- METADATA ---` add-on section that the text
|
|
9
|
+
completion processor has to split off.
|
|
10
|
+
|
|
11
|
+
What Jev cannot do bounds what this processor offers: it *chooses* and *rates*,
|
|
12
|
+
it does not write. There is no free-form extraction (use the completion
|
|
13
|
+
processor for that), no new annotation span (no offsets come back), and no
|
|
14
|
+
generated altText — only a selection among texts already on the document.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from functools import cache
|
|
21
|
+
from typing import Any, cast
|
|
22
|
+
|
|
23
|
+
from log_with_context import Logger, add_logging_context
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
from pymultirole_plugins.v1.processor import ProcessorBase, ProcessorParameters
|
|
26
|
+
from pymultirole_plugins.v1.schema import AltText, Category, Document
|
|
27
|
+
|
|
28
|
+
from .client import JevClient
|
|
29
|
+
|
|
30
|
+
logger = Logger("pymultirole")
|
|
31
|
+
|
|
32
|
+
# Stands for the project label set inside a `criteria` of the `questions` map, so
|
|
33
|
+
# that the labels injected by Sherpa do not have to be copied by hand.
|
|
34
|
+
LABELS_PLACEHOLDER = "$labels"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class JevTarget(StrEnum):
|
|
38
|
+
categories = "categories"
|
|
39
|
+
metadata = "metadata"
|
|
40
|
+
annotations = "annotations"
|
|
41
|
+
altTexts = "altTexts"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class JevFunction(StrEnum):
|
|
45
|
+
add_categories = "add_categories"
|
|
46
|
+
add_multilabel_categories = "add_multilabel_categories"
|
|
47
|
+
filter_annotations = "filter_annotations"
|
|
48
|
+
select_altText = "select_altText"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class JevParametersBase(ProcessorParameters):
|
|
52
|
+
base_url: str = Field(None, description="""Jev endpoint base url""", json_schema_extra={"extra": "advanced"})
|
|
53
|
+
model: str = Field(None, description="""Which Jev model route to use""", json_schema_extra={"extra": "advanced"})
|
|
54
|
+
function: JevFunction = Field(
|
|
55
|
+
JevFunction.add_categories,
|
|
56
|
+
description="""The question to ask, built from `labels`. Ignored as soon as `questions` is
|
|
57
|
+
defined. Options currently available:</br>
|
|
58
|
+
<li>`add_categories` - one `choice` question over `labels`: every label
|
|
59
|
+
scoring above `threshold` becomes a document category, carrying its
|
|
60
|
+
probability as score.
|
|
61
|
+
<li>`add_multilabel_categories` - one `noul` (yes/no) question per label,
|
|
62
|
+
all in the same call: each label above `threshold` becomes a category. Use
|
|
63
|
+
this rather than `add_categories` when several labels can hold at once, as
|
|
64
|
+
the probabilities of a `choice` are exclusive and sum to 1.
|
|
65
|
+
<li>`filter_annotations` - one `noul` question per existing annotation,
|
|
66
|
+
keeping those judged correct. The offsets are those of the candidates, so
|
|
67
|
+
nothing has to be re-aligned on the text.
|
|
68
|
+
<li>`select_altText` - one `choice` question over the alternative texts of
|
|
69
|
+
the document (a rerank / judge), the winner being copied to the altText
|
|
70
|
+
named by `selected_altText`.""",
|
|
71
|
+
)
|
|
72
|
+
instructions: str = Field(
|
|
73
|
+
"Choose the best category for this text.",
|
|
74
|
+
description="""Instructions of the question built from `function`""",
|
|
75
|
+
json_schema_extra={"extra": "multiline"},
|
|
76
|
+
)
|
|
77
|
+
labels: dict[str, str] = Field(
|
|
78
|
+
None,
|
|
79
|
+
description="""The list of possible labels, as a label name to description mapping. The
|
|
80
|
+
description is what Jev reads, the label name is what comes back.""",
|
|
81
|
+
json_schema_extra={"extra": "advanced,key:label,inject"},
|
|
82
|
+
)
|
|
83
|
+
questions: dict[str, str] = Field(
|
|
84
|
+
None,
|
|
85
|
+
description="""Questions to ask, by key, in JSON — the general case, which takes precedence
|
|
86
|
+
over `function`. Each question carries its Jev `type` (`choice`, `noul` or
|
|
87
|
+
`score`), its `instructions`, its `criteria` and the `target` its answer is
|
|
88
|
+
written to (`categories`, `metadata` or `altTexts`; `annotations` is only
|
|
89
|
+
reachable through the `filter_annotations` function, which needs one
|
|
90
|
+
question per span). A `criteria` of `$labels` is replaced by the project
|
|
91
|
+
label set. For example:<br/>
|
|
92
|
+
```
|
|
93
|
+
{
|
|
94
|
+
"topic": {"target": "categories", "type": "choice", "criteria": "$labels",
|
|
95
|
+
"instructions": "What is this document about?"},
|
|
96
|
+
"is_opinion": {"target": "metadata", "type": "noul",
|
|
97
|
+
"instructions": "Is this an editorial?"},
|
|
98
|
+
"urgency": {"target": "metadata", "type": "score",
|
|
99
|
+
"criteria": ["low", "medium", "high"],
|
|
100
|
+
"instructions": "How urgent is it?"}
|
|
101
|
+
}
|
|
102
|
+
```""",
|
|
103
|
+
json_schema_extra={"extra": "advanced,key:label,val:json"},
|
|
104
|
+
)
|
|
105
|
+
state_altText: str = Field(
|
|
106
|
+
None,
|
|
107
|
+
description="""<li>If defined: send the alternative text of that name as the state,
|
|
108
|
+
<li>if not: send the text of the document.""",
|
|
109
|
+
json_schema_extra={"extra": "advanced"},
|
|
110
|
+
)
|
|
111
|
+
decision_altText: str = Field(
|
|
112
|
+
None,
|
|
113
|
+
description="""<li>If defined: keep the questions, the answers and the token usage as an
|
|
114
|
+
alternative text of that name — the audit trail of the decision.""",
|
|
115
|
+
json_schema_extra={"extra": "advanced"},
|
|
116
|
+
)
|
|
117
|
+
selected_altText: str = Field(
|
|
118
|
+
"selected",
|
|
119
|
+
description="""Name of the alternative text receiving the winner of `select_altText`""",
|
|
120
|
+
json_schema_extra={"extra": "advanced"},
|
|
121
|
+
)
|
|
122
|
+
threshold: float = Field(
|
|
123
|
+
0.5,
|
|
124
|
+
description="""Only answers with a probability greater than threshold are kept.
|
|
125
|
+
Lowering it below the winner-takes-all point turns a `choice` into a
|
|
126
|
+
multilabel decision: every label above the bar becomes a category,
|
|
127
|
+
sorted by decreasing probability.""",
|
|
128
|
+
json_schema_extra={"extra": "advanced"},
|
|
129
|
+
)
|
|
130
|
+
keep_best: bool = Field(
|
|
131
|
+
False,
|
|
132
|
+
description="""<li>If true: when no answer of a `choice` question reaches `threshold`,
|
|
133
|
+
keep the most probable one anyway, so the document never comes back
|
|
134
|
+
without a category. <li>Harmless with few labels — with 3 of them the
|
|
135
|
+
winner is mechanically above 1/3 — but a large label set can leave every
|
|
136
|
+
probability under the bar.""",
|
|
137
|
+
json_schema_extra={"extra": "advanced"},
|
|
138
|
+
)
|
|
139
|
+
max_chars: int = Field(
|
|
140
|
+
0,
|
|
141
|
+
description="""Truncate the state to that many characters, 0 to send it whole""",
|
|
142
|
+
json_schema_extra={"extra": "advanced"},
|
|
143
|
+
)
|
|
144
|
+
timeout: float = Field(60.0, description="""Timeout in seconds""", json_schema_extra={"extra": "advanced"})
|
|
145
|
+
max_retries: int = Field(
|
|
146
|
+
3,
|
|
147
|
+
description="""How many times a throttled (429) or overloaded (529) call is retried,
|
|
148
|
+
with an exponential backoff""",
|
|
149
|
+
json_schema_extra={"extra": "advanced"},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class JevParameters(JevParametersBase):
|
|
154
|
+
base_url: str | None = Field(
|
|
155
|
+
os.getenv("JEV_API_BASE", os.getenv("TYPESAFE_BASE_URL", "https://api.typesafe.ai")),
|
|
156
|
+
description="""Jev endpoint base url""",
|
|
157
|
+
json_schema_extra={"extra": "advanced"},
|
|
158
|
+
)
|
|
159
|
+
model: str | None = Field(
|
|
160
|
+
os.getenv("JEV_MODEL", os.getenv("TYPESAFE_DEFAULT_MODEL", "jev-latest")),
|
|
161
|
+
description="""Which Jev model route to use""",
|
|
162
|
+
json_schema_extra={"extra": "advanced"},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class OpenJevParameters(JevParametersBase):
|
|
167
|
+
base_url: str | None = Field(
|
|
168
|
+
os.getenv("OPENJEV_API_BASE", "http://127.0.0.1:8791"),
|
|
169
|
+
description="""Open-Jev endpoint base url""",
|
|
170
|
+
json_schema_extra={"extra": "advanced"},
|
|
171
|
+
)
|
|
172
|
+
model: str | None = Field(
|
|
173
|
+
os.getenv("OPENJEV_MODEL", "open-jev"),
|
|
174
|
+
description="""Which Open-Jev model route to use""",
|
|
175
|
+
json_schema_extra={"extra": "advanced"},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class JevProcessorBase(ProcessorBase):
|
|
180
|
+
__doc__ = """Ask [Jev](https://typesafe.ai) typed questions about a document and write the
|
|
181
|
+
answers as categories, metadata, annotations or alternative texts."""
|
|
182
|
+
|
|
183
|
+
# Where the api key is read. Empty for the hosted service (`JEV_API_KEY`), `OPEN`
|
|
184
|
+
# for a self-hosted server (`OPENJEV_API_KEY`), which usually has none.
|
|
185
|
+
PREFIX: str = ""
|
|
186
|
+
API_KEY_ENVS: tuple[str, ...] = ()
|
|
187
|
+
|
|
188
|
+
def process(self, documents: list[Document], parameters: ProcessorParameters) -> list[Document]:
|
|
189
|
+
params: JevParametersBase = cast(JevParametersBase, parameters)
|
|
190
|
+
client = get_client(params.base_url, self.api_key(), params.timeout, params.max_retries)
|
|
191
|
+
|
|
192
|
+
for document in documents:
|
|
193
|
+
with add_logging_context(docid=document.identifier):
|
|
194
|
+
plan = build_plan(document, params)
|
|
195
|
+
if not plan:
|
|
196
|
+
logger.warning("No question to ask, document left untouched")
|
|
197
|
+
continue
|
|
198
|
+
payload = {qid: item["question"] for qid, item in plan.items()}
|
|
199
|
+
result = client.system_one(params.model, get_state(document, params), payload)
|
|
200
|
+
answers = result.get("answers") or {}
|
|
201
|
+
apply_answers(document, params, plan, answers)
|
|
202
|
+
if params.decision_altText:
|
|
203
|
+
add_decision_altText(document, params, payload, result)
|
|
204
|
+
return documents
|
|
205
|
+
|
|
206
|
+
def api_key(self) -> str | None:
|
|
207
|
+
for name in (self.PREFIX + "JEV_API_KEY", *self.API_KEY_ENVS):
|
|
208
|
+
key = os.getenv(name)
|
|
209
|
+
if key:
|
|
210
|
+
return key
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def get_model(cls) -> type[BaseModel]:
|
|
215
|
+
return JevParametersBase
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class JevProcessor(JevProcessorBase):
|
|
219
|
+
__doc__ = """Ask [TypeSafe Jev](https://typesafe.ai) typed questions about a document and write
|
|
220
|
+
the answers as categories, metadata, annotations or alternative texts."""
|
|
221
|
+
|
|
222
|
+
PREFIX = ""
|
|
223
|
+
API_KEY_ENVS = ("TYPESAFE_API_KEY",)
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def get_model(cls) -> type[BaseModel]:
|
|
227
|
+
return JevParameters
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class OpenJevProcessor(JevProcessorBase):
|
|
231
|
+
__doc__ = """Ask a self-hosted [Open-Jev](https://github.com/Zefan-Cai/Open-Jev) server typed
|
|
232
|
+
questions about a document and write the answers as categories, metadata, annotations or
|
|
233
|
+
alternative texts."""
|
|
234
|
+
|
|
235
|
+
PREFIX = "OPEN"
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def get_model(cls) -> type[BaseModel]:
|
|
239
|
+
return OpenJevParameters
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@cache
|
|
243
|
+
def get_client(base_url: str, api_key: str | None, timeout: float, max_retries: int) -> JevClient:
|
|
244
|
+
return JevClient(base_url, api_key=api_key, timeout=timeout, max_retries=max_retries)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_state(document: Document, params: JevParametersBase) -> str:
|
|
248
|
+
state = document.text
|
|
249
|
+
if params.state_altText:
|
|
250
|
+
texts = [alt.text for alt in (document.altTexts or []) if alt.name == params.state_altText]
|
|
251
|
+
if texts:
|
|
252
|
+
state = texts[0]
|
|
253
|
+
else:
|
|
254
|
+
logger.warning(f"No altText {params.state_altText!r}, falling back on the document text")
|
|
255
|
+
state = state or ""
|
|
256
|
+
return state[: params.max_chars] if params.max_chars > 0 else state
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def build_plan(document: Document, params: JevParametersBase) -> dict[str, dict[str, Any]]:
|
|
260
|
+
"""Compute, per question id, the question to send, the target its answer goes
|
|
261
|
+
to and whatever context writing that answer needs. Our own keys never reach
|
|
262
|
+
the API: it validates the question schema and answers 422 on an unknown field."""
|
|
263
|
+
if params.questions:
|
|
264
|
+
return build_declared_plan(params)
|
|
265
|
+
labels = params.labels or {}
|
|
266
|
+
|
|
267
|
+
if params.function == JevFunction.add_categories:
|
|
268
|
+
if not labels:
|
|
269
|
+
return {}
|
|
270
|
+
return {
|
|
271
|
+
"category": {
|
|
272
|
+
"target": JevTarget.categories,
|
|
273
|
+
"question": {"type": "choice", "instructions": params.instructions, "criteria": dict(labels)},
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if params.function == JevFunction.add_multilabel_categories:
|
|
278
|
+
return {
|
|
279
|
+
name: {
|
|
280
|
+
"target": JevTarget.categories,
|
|
281
|
+
"question": {
|
|
282
|
+
"type": "noul",
|
|
283
|
+
"instructions": f"{params.instructions}\n{label}",
|
|
284
|
+
"criteria": {"true": label, "false": f"not: {label}"},
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
for name, label in labels.items()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if params.function == JevFunction.filter_annotations:
|
|
291
|
+
plan = {}
|
|
292
|
+
for i, a in enumerate(document.annotations or []):
|
|
293
|
+
label = labels.get(a.labelName) or a.label or a.labelName
|
|
294
|
+
plan[f"annotation_{i}"] = {
|
|
295
|
+
"target": JevTarget.annotations,
|
|
296
|
+
"index": i,
|
|
297
|
+
"question": {
|
|
298
|
+
"type": "noul",
|
|
299
|
+
"instructions": f"{params.instructions}\nIs {a.text!r} really a {label}?",
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
return plan
|
|
303
|
+
|
|
304
|
+
candidates = {alt.name: alt.text for alt in (document.altTexts or []) if alt.name not in (params.selected_altText, params.decision_altText)}
|
|
305
|
+
if len(candidates) < 2: # a choice needs at least two candidates to mean anything
|
|
306
|
+
return {}
|
|
307
|
+
return {
|
|
308
|
+
params.selected_altText: {
|
|
309
|
+
"target": JevTarget.altTexts,
|
|
310
|
+
"candidates": candidates,
|
|
311
|
+
"question": {"type": "choice", "instructions": params.instructions, "criteria": candidates},
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def build_declared_plan(params: JevParametersBase) -> dict[str, dict[str, Any]]:
|
|
317
|
+
plan: dict[str, dict[str, Any]] = {}
|
|
318
|
+
for qid, raw in params.questions.items():
|
|
319
|
+
try:
|
|
320
|
+
question = json.loads(raw) if isinstance(raw, str) else dict(raw)
|
|
321
|
+
except ValueError as err:
|
|
322
|
+
logger.warning(f"Ignoring question {qid!r}, not valid JSON: {err}")
|
|
323
|
+
continue
|
|
324
|
+
target = question.pop("target", JevTarget.metadata)
|
|
325
|
+
if target == JevTarget.annotations:
|
|
326
|
+
logger.warning(f"Ignoring question {qid!r}: the annotations target needs the filter_annotations function")
|
|
327
|
+
continue
|
|
328
|
+
if question.get("criteria") == LABELS_PLACEHOLDER:
|
|
329
|
+
question["criteria"] = dict(params.labels or {})
|
|
330
|
+
plan[qid] = {"target": target, "question": question}
|
|
331
|
+
return plan
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def apply_answers(
|
|
335
|
+
document: Document,
|
|
336
|
+
params: JevParametersBase,
|
|
337
|
+
plan: dict[str, dict[str, Any]],
|
|
338
|
+
answers: dict[str, Any],
|
|
339
|
+
) -> Document:
|
|
340
|
+
categories: list[Category] = []
|
|
341
|
+
metadata = dict(document.metadata or {})
|
|
342
|
+
rejected: set[int] = set()
|
|
343
|
+
|
|
344
|
+
for qid, item in plan.items():
|
|
345
|
+
answer = answers.get(qid)
|
|
346
|
+
if answer is None:
|
|
347
|
+
logger.warning(f"No answer for question {qid!r}")
|
|
348
|
+
continue
|
|
349
|
+
target = item["target"]
|
|
350
|
+
if target == JevTarget.categories:
|
|
351
|
+
categories.extend(categories_of(qid, answer, params))
|
|
352
|
+
elif target == JevTarget.metadata:
|
|
353
|
+
metadata.update(metadata_of(qid, answer, params))
|
|
354
|
+
elif target == JevTarget.annotations:
|
|
355
|
+
if answer.get("noul", 0.0) < params.threshold:
|
|
356
|
+
rejected.add(item["index"])
|
|
357
|
+
else:
|
|
358
|
+
select_altText(document, qid, item, answer)
|
|
359
|
+
|
|
360
|
+
if categories:
|
|
361
|
+
document.categories = categories
|
|
362
|
+
if metadata: # never turn an absent metadata into an empty dict
|
|
363
|
+
document.metadata = metadata
|
|
364
|
+
if rejected:
|
|
365
|
+
document.annotations = [a for i, a in enumerate(document.annotations or []) if i not in rejected]
|
|
366
|
+
return document
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def categories_of(qid: str, answer: dict[str, Any], params: JevParametersBase) -> list[Category]:
|
|
370
|
+
"""Only `labelName` is written, never `label`: what `labels` holds is the description
|
|
371
|
+
Jev reads to decide, often a full sentence, and it has no place in the document — the
|
|
372
|
+
project label set already carries the display name. It also means an array or string
|
|
373
|
+
`criteria`, which the API accepts, no longer has to be looked up by choice name."""
|
|
374
|
+
if answer.get("type") == "noul":
|
|
375
|
+
# One question per label: the question id IS the label name.
|
|
376
|
+
probability = answer.get("noul", 0.0)
|
|
377
|
+
if probability < params.threshold:
|
|
378
|
+
return []
|
|
379
|
+
return [Category(labelName=qid, score=probability)]
|
|
380
|
+
|
|
381
|
+
confidence = answer.get("confidence")
|
|
382
|
+
probabilities = answer.get("probabilities") or {}
|
|
383
|
+
if not probabilities and answer.get("choice"): # a server may answer the winner only
|
|
384
|
+
probabilities = {answer["choice"]: 1.0}
|
|
385
|
+
|
|
386
|
+
classees = sorted(probabilities.items(), key=lambda kv: kv[1], reverse=True)
|
|
387
|
+
retenues = [(name, p) for name, p in classees if p >= params.threshold]
|
|
388
|
+
if not retenues and params.keep_best and classees:
|
|
389
|
+
# Nothing cleared the bar: with `keep_best` the document still gets its best
|
|
390
|
+
# answer rather than no category at all. Only meaningful for a `choice`, whose
|
|
391
|
+
# probabilities compete; a `noul` answers one label on its own above.
|
|
392
|
+
retenues = classees[:1]
|
|
393
|
+
return [
|
|
394
|
+
Category(
|
|
395
|
+
labelName=name,
|
|
396
|
+
score=probability,
|
|
397
|
+
properties={"confidence": confidence} if confidence is not None else None,
|
|
398
|
+
)
|
|
399
|
+
for name, probability in retenues
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def metadata_of(qid: str, answer: dict[str, Any], params: JevParametersBase) -> dict[str, Any]:
|
|
404
|
+
"""A `noul` gives a boolean (and keeps its probability aside), a `choice` the
|
|
405
|
+
chosen key, a `score` its probability-weighted value — never the level label,
|
|
406
|
+
which `legend` gives back if it is ever needed."""
|
|
407
|
+
kind = answer.get("type")
|
|
408
|
+
if kind == "noul":
|
|
409
|
+
probability = answer.get("noul", 0.0)
|
|
410
|
+
return {qid: probability >= params.threshold, f"{qid}_probability": probability}
|
|
411
|
+
if kind == "choice":
|
|
412
|
+
return {qid: answer.get("choice"), f"{qid}_confidence": answer.get("confidence")}
|
|
413
|
+
if kind == "score":
|
|
414
|
+
return {qid: answer.get("score"), f"{qid}_confidence": answer.get("confidence")}
|
|
415
|
+
logger.warning(f"Unknown answer type {kind!r} for question {qid!r}")
|
|
416
|
+
return {}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def select_altText(document: Document, qid: str, item: dict[str, Any], answer: dict[str, Any]) -> None:
|
|
420
|
+
chosen = answer.get("choice")
|
|
421
|
+
candidates = item.get("candidates") or {}
|
|
422
|
+
if chosen not in candidates:
|
|
423
|
+
logger.warning(f"Ignoring unknown altText {chosen!r} chosen for question {qid!r}")
|
|
424
|
+
return
|
|
425
|
+
altTexts = [alt for alt in (document.altTexts or []) if alt.name != qid]
|
|
426
|
+
altTexts.append(
|
|
427
|
+
AltText(
|
|
428
|
+
name=qid,
|
|
429
|
+
text=candidates[chosen],
|
|
430
|
+
properties={
|
|
431
|
+
"selected": chosen,
|
|
432
|
+
"confidence": answer.get("confidence"),
|
|
433
|
+
"probabilities": answer.get("probabilities"),
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
document.altTexts = altTexts
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def add_decision_altText(
|
|
441
|
+
document: Document,
|
|
442
|
+
params: JevParametersBase,
|
|
443
|
+
payload: dict[str, Any],
|
|
444
|
+
result: dict[str, Any],
|
|
445
|
+
) -> None:
|
|
446
|
+
trace = {
|
|
447
|
+
"model": result.get("model"),
|
|
448
|
+
"questions": payload,
|
|
449
|
+
"answers": result.get("answers"),
|
|
450
|
+
"usage": result.get("usage"),
|
|
451
|
+
}
|
|
452
|
+
altTexts = [alt for alt in (document.altTexts or []) if alt.name != params.decision_altText]
|
|
453
|
+
altTexts.append(AltText(name=params.decision_altText, text=json.dumps(trace, indent=2, ensure_ascii=False)))
|
|
454
|
+
document.altTexts = altTexts
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyprocessors-jev
|
|
3
|
+
Version: 1.6.1
|
|
4
|
+
Summary: Processor based on the Jev System One API
|
|
5
|
+
Project-URL: Homepage, https://bitbucket.org/kairntech/pyprocessors_jev
|
|
6
|
+
Author-email: Olivier Terrier <olivier.terrier@kairntech.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Information Technology
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: log-with-context
|
|
21
|
+
Requires-Dist: pydantic<3.0,>=2.0
|
|
22
|
+
Requires-Dist: pymultirole-plugins<1.7.0,>=1.6.0
|
|
23
|
+
Requires-Dist: python-singleton-metaclasses
|
|
24
|
+
Requires-Dist: requests
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: bump2version; extra == 'dev'
|
|
27
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
28
|
+
Provides-Extra: docs
|
|
29
|
+
Requires-Dist: lxml-html-clean; extra == 'docs'
|
|
30
|
+
Requires-Dist: myst-parser; extra == 'docs'
|
|
31
|
+
Requires-Dist: sphinx; extra == 'docs'
|
|
32
|
+
Requires-Dist: sphinx-rtd-theme; extra == 'docs'
|
|
33
|
+
Requires-Dist: sphinxcontrib-apidoc; extra == 'docs'
|
|
34
|
+
Provides-Extra: sbom
|
|
35
|
+
Requires-Dist: cyclonedx-bom; extra == 'sbom'
|
|
36
|
+
Requires-Dist: pip-audit; extra == 'sbom'
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: dirty-equals; extra == 'test'
|
|
39
|
+
Requires-Dist: pytest; extra == 'test'
|
|
40
|
+
Requires-Dist: pytest-cov; extra == 'test'
|
|
41
|
+
Requires-Dist: pytest-dotenv; extra == 'test'
|
|
42
|
+
Requires-Dist: ruff; extra == 'test'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# pyprocessors-jev
|
|
46
|
+
|
|
47
|
+
Processor based on the [Jev](https://typesafe.ai) *System One* API: it asks typed questions about a
|
|
48
|
+
document and gets back **calibrated probabilities** instead of text, so nothing has to be parsed out
|
|
49
|
+
of a completion.
|
|
50
|
+
|
|
51
|
+
Two providers, one protocol (`POST {base_url}/v1/systemone`):
|
|
52
|
+
|
|
53
|
+
| entry point | provider | base url | model | api key |
|
|
54
|
+
|-------------|----------|----------|-------|---------|
|
|
55
|
+
| `jev` | hosted TypeSafe | `JEV_API_BASE`, else `TYPESAFE_BASE_URL`, else `https://api.typesafe.ai` | `JEV_MODEL`, else `jev-latest` | `JEV_API_KEY`, else `TYPESAFE_API_KEY` |
|
|
56
|
+
| `openjev` | self-hosted [Open-Jev](https://github.com/Zefan-Cai/Open-Jev) | `OPENJEV_API_BASE`, else `http://127.0.0.1:8791` | `OPENJEV_MODEL`, else `open-jev` | `OPENJEV_API_KEY` (usually none) |
|
|
57
|
+
|
|
58
|
+
## What it can and cannot do
|
|
59
|
+
|
|
60
|
+
Jev *chooses* and *rates*; it does not write. That bounds the four outputs:
|
|
61
|
+
|
|
62
|
+
| output | how | not possible |
|
|
63
|
+
|--------|-----|--------------|
|
|
64
|
+
| `categories` | `choice` over the project labels, or one `noul` per label | — |
|
|
65
|
+
| `metadata` | typed values only: `noul` → boolean, `choice` → key of a closed set, `score` → number | free-form extraction (dates, amounts, names) — use `pyprocessors_openai_completion` |
|
|
66
|
+
| `annotations` | filter existing candidate spans | create spans: no offsets come back |
|
|
67
|
+
| `altTexts` | select among the texts already on the document (rerank / judge) | generate a summary or a translation |
|
|
68
|
+
|
|
69
|
+
Because the API answers **many questions in one call**, classifying a document and filling its
|
|
70
|
+
metadata costs one HTTP request — there is no `--- METADATA ---` section to split off.
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from pymultirole_plugins.v1.schema import Document
|
|
76
|
+
from pyprocessors_jev.jev import JevProcessor, JevParameters
|
|
77
|
+
|
|
78
|
+
processor = JevProcessor()
|
|
79
|
+
parameters = JevParameters(
|
|
80
|
+
labels={"billing": "Payments, invoicing, refunds", "technical": "Bugs, outages, integrations"},
|
|
81
|
+
instructions="Which team should handle this?",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
docs = processor.process([Document(text="Help! My payouts have been failing for 3 days.")], parameters)
|
|
85
|
+
for cat in docs[0].categories:
|
|
86
|
+
print(cat.labelName, cat.score, cat.properties)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Options
|
|
90
|
+
|
|
91
|
+
| Option | Default | Description |
|
|
92
|
+
|--------|---------|-------------|
|
|
93
|
+
| `base_url` | provider default (see above) | Jev endpoint base url |
|
|
94
|
+
| `model` | provider default (see above) | model route |
|
|
95
|
+
| `function` | `add_categories` | question built from `labels`: `add_categories` (one `choice`), `add_multilabel_categories` (one `noul` per label, same call), `filter_annotations` (one `noul` per annotation, offsets preserved), `select_altText` (`choice` over the alternative texts). **Ignored as soon as `questions` is defined.** |
|
|
96
|
+
| `instructions` | `Choose the best category for this text.` | instructions of the question built from `function` |
|
|
97
|
+
| `labels` | – | label name → description mapping, injected from the project label set. The description is what Jev reads to decide; the categories written on the document carry **`labelName` only**, never `label` — a description has no place on the document, and the project label set already holds the display name. |
|
|
98
|
+
| `questions` | – | the general case: one JSON question per key, each declaring `type`, `instructions`, `criteria` and `target` (`categories`, `metadata`, `altTexts`). A `criteria` of `$labels` is replaced by `labels`. Takes precedence over `function`. |
|
|
99
|
+
| `state_altText` | – | send that alternative text as the state instead of the document text; falls back on the text with a warning when it is missing |
|
|
100
|
+
| `decision_altText` | – | keep questions, answers and token usage in that alternative text — the audit trail |
|
|
101
|
+
| `selected_altText` | `selected` | alternative text receiving the winner of `select_altText` |
|
|
102
|
+
| `threshold` | `0.5` | probability below which an answer is dropped (a category, or an annotation kept by `filter_annotations`). Lower it below the winner-takes-all point and a `choice` becomes a multilabel decision: every label above the bar becomes a category, sorted by decreasing probability |
|
|
103
|
+
| `keep_best` | `false` | when no answer of a `choice` reaches `threshold`, keep the most probable one anyway, so the document never comes back without a category. A no-op with 3 labels (the winner is mechanically above 1/3), a safety net with a large label set |
|
|
104
|
+
| `max_chars` | `0` | truncate the state, `0` sends it whole |
|
|
105
|
+
| `timeout` | `60.0` | HTTP timeout, in seconds |
|
|
106
|
+
| `max_retries` | `3` | retries of a throttled (429) or overloaded (529) call, exponential backoff, obeying `Retry-After` |
|
|
107
|
+
|
|
108
|
+
Answers land in a predictable shape: a category carries the **probability** as its `score` and the
|
|
109
|
+
question `confidence` in its `properties`; a `noul` in metadata writes a boolean under the question
|
|
110
|
+
id plus its probability under `<id>_probability`; a `choice` and a `score` write their value plus
|
|
111
|
+
`<id>_confidence`. A question whose criteria are unknown labels, or an answer naming a label outside
|
|
112
|
+
`labels`, is dropped with a warning rather than invented.
|
|
113
|
+
|
|
114
|
+
The official `typesafe_sdk` is deliberately not a dependency: it cannot talk to an Open-Jev server,
|
|
115
|
+
which would mean two code paths for one protocol.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
The build is driven by [Task](https://taskfile.dev) and [uv](https://docs.astral.sh/uv/),
|
|
120
|
+
with the shared stages coming from the `python-archetype` submodule.
|
|
121
|
+
|
|
122
|
+
### Getting started
|
|
123
|
+
|
|
124
|
+
The stages live in a Git submodule, so **clone with `--recurse-submodules`**:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git clone --recurse-submodules git@bitbucket.org:kairntech/pyprocessors_jev.git
|
|
128
|
+
cd pyprocessors_jev
|
|
129
|
+
sh -c "$(curl -sSL https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
|
|
130
|
+
task
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Already cloned without it? The submodule directory is empty, and `task` fails on:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
task: No Taskfile found at ".../submodules/python-archetype/resources/Taskfile.yml"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
which means exactly that, and nothing worse:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
git submodule update --init
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Task is the only manual prerequisite.** An archetype cannot bootstrap itself: uv and the
|
|
146
|
+
Python interpreter install themselves on demand (every task that runs uv depends on an
|
|
147
|
+
internal `install-python` task), but the thing that runs them does not. Make sure
|
|
148
|
+
`~/.local/bin` is on your `PATH` — that is where `task` and `uv` both land.
|
|
149
|
+
|
|
150
|
+
### Running the pipeline
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
task stages # print the pipeline stages, in order
|
|
154
|
+
task # run the pipeline up to (but excluding) py:publish
|
|
155
|
+
task -- --skip-tests # same, without the test stage
|
|
156
|
+
task up-to -- py:lint # run the pipeline up to and including one stage
|
|
157
|
+
task jenkins # run every stage, exactly what Jenkins runs
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`task` with no argument is safe by construction: it runs every stage but the last, and that
|
|
161
|
+
bound is computed from the `STAGES` list rather than written down. The last stage is the
|
|
162
|
+
only one with an effect outside your machine.
|
|
163
|
+
|
|
164
|
+
`STAGES`, declared once in `Taskfile.yml`, is the single definition of the pipeline order —
|
|
165
|
+
so what you run locally is what Jenkins runs.
|
|
166
|
+
|
|
167
|
+
### Individual stages
|
|
168
|
+
|
|
169
|
+
| Task | Description |
|
|
170
|
+
|---------------------------------|-------------------------------------------------------|
|
|
171
|
+
| `task py:sync` | Install the project and its dependencies (uv sync) |
|
|
172
|
+
| `task py:lint` | `ruff check` and `ruff format --check` |
|
|
173
|
+
| `task py:format` | Reformat the code with ruff |
|
|
174
|
+
| `task py:test` | Run the test suite |
|
|
175
|
+
| `task py:test-marker -- <m>` | Run the tests carrying one pytest marker |
|
|
176
|
+
| `task py:sbom` | Generate a CycloneDX SBOM of the resolved environment |
|
|
177
|
+
| `task py:check-vulnerabilities` | Check for known CVEs |
|
|
178
|
+
| `task py:check-updates` | Check for dependency updates |
|
|
179
|
+
| `task py:build` | Build the wheel and sdist (uv build) |
|
|
180
|
+
| `task py:publish` | Publish the distributions (uv publish) |
|
|
181
|
+
| `task py:version-file` | Print the path of the file carrying `__version__` |
|
|
182
|
+
| `task py:set-version VERSION=x` | Write that version into it |
|
|
183
|
+
|
|
184
|
+
`uv.lock` is not versioned here, so `py:sync` always resolves from scratch (`--upgrade`):
|
|
185
|
+
a stale lock lying around on a machine would otherwise make you test and audit versions the
|
|
186
|
+
CI never sees.
|
|
187
|
+
|
|
188
|
+
### Tests, and where the api key goes
|
|
189
|
+
|
|
190
|
+
The unit tests never open a socket: the `recorder` fixture replaces `JevClient.system_one`,
|
|
191
|
+
so the whole suite runs without a key and without a server. They check what the processor
|
|
192
|
+
*sends* (the questions built, `$labels` substituted, one call for every label) and how it
|
|
193
|
+
*reads back* the typed answers — not that Jev answers well. That last part is the job of the
|
|
194
|
+
single `integration` test.
|
|
195
|
+
|
|
196
|
+
Keys for that one live in `tests/.env`, which `.gitignore` keeps out of git
|
|
197
|
+
(`pytest-dotenv` loads it, same convention as `pyprocessors_openai_completion`):
|
|
198
|
+
|
|
199
|
+
```dotenv
|
|
200
|
+
# tests/.env
|
|
201
|
+
JEV_API_KEY=sk-...
|
|
202
|
+
# or, for a self-hosted server:
|
|
203
|
+
OPENJEV_API_BASE=http://127.0.0.1:8791
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
A key sitting there does **not** make `task py:test` hit the network: `addopts` carries
|
|
207
|
+
`-m 'not integration'`, so the default run stays hermetic and the live test is asked for
|
|
208
|
+
explicitly (the `-m` of the command line wins over `addopts`):
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
task py:test-marker -- integration
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Without a key and without `OPENJEV_API_BASE`, that command skips instead of failing.
|
|
215
|
+
|
|
216
|
+
### Measuring a real label set
|
|
217
|
+
|
|
218
|
+
`tests/eval/` holds an evaluation harness for the Cairn question classifier: a frozen
|
|
219
|
+
dev/holdout split over 574 manually labelled questions, five label-description variants
|
|
220
|
+
with what each one scored, paired McNemar comparison, calibration and coverage curves, and
|
|
221
|
+
the saved model outputs so the numbers can be rechecked without spending tokens. It is not
|
|
222
|
+
part of the test suite — no file there is named `test_*`, so `task py:test` ignores it.
|
|
223
|
+
See `tests/eval/README.md`.
|
|
224
|
+
|
|
225
|
+
`tests/test_cairn_routing.py` pins the delivery configuration of that project — twelve real
|
|
226
|
+
questions of the corpus, the probabilities the API actually answered for them, and what the
|
|
227
|
+
processor must make of them at `threshold=0.25`. It runs offline, like the rest of the suite.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pyprocessors_jev/__init__.py,sha256=xCCK_V2JWJNUN8b7pnXLB-h2UqKPtI53n39HM0nPWjU,71
|
|
2
|
+
pyprocessors_jev/client.py,sha256=2KAjmROCmBBWj84rIkJovtlFvtaUL97kRrrVZ6lJ_dw,3188
|
|
3
|
+
pyprocessors_jev/jev.py,sha256=YvM4o9fmOgTtLQvo-Cxc0fLLtcnSGk4yWGyw0mTy_5Q,19769
|
|
4
|
+
pyprocessors_jev-1.6.1.dist-info/METADATA,sha256=lm4UMAcMfYpKZ6qEhJuk87ij2lqF6aqcn9wccx9w-vM,12174
|
|
5
|
+
pyprocessors_jev-1.6.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
pyprocessors_jev-1.6.1.dist-info/entry_points.txt,sha256=2b5tQgFU7-m1qlB-Z5qGGtOoh9rbkrk5y0KILWWqK10,111
|
|
7
|
+
pyprocessors_jev-1.6.1.dist-info/RECORD,,
|