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,3 @@
1
+ """Processor based on the Jev System One API"""
2
+
3
+ __version__ = "1.6.1"
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [pyprocessors.plugins]
2
+ jev = pyprocessors_jev.jev:JevProcessor
3
+ openjev = pyprocessors_jev.jev:OpenJevProcessor